585 lines
23 KiB
TypeScript
585 lines
23 KiB
TypeScript
/**
|
|
* Brute test suite for @docmd/plugin-okf
|
|
*
|
|
* 12 real-fs scenarios. Each builds a small on-disk project under
|
|
* /tmp/okf-brutetest-<n>/ and invokes the plugin's `onPostBuild` directly.
|
|
*
|
|
* Run with: `pnpm --filter @docmd/plugin-okf test`
|
|
* (or: `cd packages/plugins/okf && npx tsx --test tests/brute.test.ts`)
|
|
*/
|
|
|
|
import { describe, it, before, after, beforeEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import path from 'node:path';
|
|
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
|
|
import { onPostBuild } from '../src/index.js';
|
|
|
|
// ---- tiny helpers ---------------------------------------------------------
|
|
|
|
const ROOT = path.join(os.tmpdir(), 'okf-brutetest');
|
|
|
|
async function cleanAll() {
|
|
await fs.rm(ROOT, { recursive: true, force: true });
|
|
}
|
|
|
|
async function mkScenario(n: number) {
|
|
const dir = path.join(ROOT, String(n));
|
|
await fs.mkdir(dir, { recursive: true });
|
|
const srcDir = path.join(dir, 'docs');
|
|
await fs.mkdir(srcDir, { recursive: true });
|
|
return { dir, srcDir, bundleDir: path.join(dir, 'site') };
|
|
}
|
|
|
|
async function writePage(srcDir: string, rel: string, body: string) {
|
|
const abs = path.join(srcDir, rel);
|
|
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
await fs.writeFile(abs, body);
|
|
}
|
|
|
|
function makePage(srcDir: string, rel: string, frontmatter: any, body = 'body content', title?: string) {
|
|
const srcPath = path.join(srcDir, rel);
|
|
const fm = frontmatter ? { ...frontmatter } : {};
|
|
if (title && !fm.title) fm.title = title;
|
|
const yaml = Object.keys(fm).length
|
|
? '---\n' + Object.entries(fm).map(([k, v]) => {
|
|
if (Array.isArray(v)) return `${k}:\n` + v.map(i => ` - ${i}`).join('\n');
|
|
if (v && typeof v === 'object') return `${k}:\n` + Object.entries(v as any).map(([kk, vv]) => ` ${kk}: ${vv}`).join('\n');
|
|
return `${k}: ${v}`;
|
|
}).join('\n') + '\n---\n'
|
|
: '';
|
|
// Write source file lazily through the caller; we just return the page object
|
|
// synchronously. The test will call writePage for the source.
|
|
return {
|
|
outputPath: rel.replace(/\.md$/i, '.html').replace(/^index\.html$/i, 'index.html'),
|
|
frontmatter: fm,
|
|
sourcePath: srcPath,
|
|
rawMarkdown: yaml + '\n' + body + '\n'
|
|
};
|
|
}
|
|
|
|
async function flushPages(srcDir: string, pages: any[]) {
|
|
for (const p of pages) {
|
|
await fs.mkdir(path.dirname(p.sourcePath), { recursive: true });
|
|
await fs.writeFile(p.sourcePath, p.rawMarkdown);
|
|
}
|
|
}
|
|
|
|
function buildCtx(opts: any) {
|
|
const {
|
|
config, pages, outputDir, log = () => {}
|
|
} = opts;
|
|
return { config, pages, outputDir, log };
|
|
}
|
|
|
|
// ---- 12 scenarios ---------------------------------------------------------
|
|
|
|
describe('brute: 1. empty docs folder', () => {
|
|
before(async () => { await cleanAll(); });
|
|
after(async () => { await cleanAll(); });
|
|
|
|
it('emits a bundle with stats.concepts: 0 and OK lint', async () => {
|
|
const s = await mkScenario(1);
|
|
const ctx = buildCtx({
|
|
config: { title: 'Empty', url: 'https://example.com', plugins: { okf: {} } },
|
|
pages: [],
|
|
outputDir: s.bundleDir
|
|
});
|
|
await onPostBuild(ctx);
|
|
|
|
const yaml = await fs.readFile(path.join(s.bundleDir, 'okf', 'okf.yaml'), 'utf8');
|
|
assert.match(yaml, /concepts: 0/);
|
|
const lint = await fs.readFile(path.join(s.bundleDir, 'okf', '_meta', 'lint-report.txt'), 'utf8');
|
|
assert.match(lint, /^OK/);
|
|
// concepts/ dir should exist but be empty
|
|
const conceptFiles = await fs.readdir(path.join(s.bundleDir, 'okf', 'concepts'));
|
|
assert.deepEqual(conceptFiles, []);
|
|
});
|
|
});
|
|
|
|
describe('brute: 2. single page, no frontmatter', () => {
|
|
beforeEach(async () => { await cleanAll(); });
|
|
after(async () => { await cleanAll(); });
|
|
|
|
it('concept count 1, type falls back to defaultType, lint warns missing-type', async () => {
|
|
const s = await mkScenario(2);
|
|
const pages = [ makePage(s.srcDir, 'page.md', null, 'just some prose', 'Bare Page') ];
|
|
await flushPages(s.srcDir, pages);
|
|
|
|
const ctx = buildCtx({
|
|
config: { title: 'Solo', url: 'https://example.com', plugins: { okf: { defaultType: 'note' } } },
|
|
pages, outputDir: s.bundleDir
|
|
});
|
|
await onPostBuild(ctx);
|
|
|
|
const yaml = await fs.readFile(path.join(s.bundleDir, 'okf', 'okf.yaml'), 'utf8');
|
|
assert.match(yaml, /concepts: 1/);
|
|
assert.match(yaml, /note: 1/);
|
|
|
|
const lint = await fs.readFile(path.join(s.bundleDir, 'okf', '_meta', 'lint-report.txt'), 'utf8');
|
|
assert.match(lint, /missing-type/);
|
|
|
|
const concept = await fs.readFile(path.join(s.bundleDir, 'okf', 'concepts', 'page.md'), 'utf8');
|
|
assert.match(concept, /type: note/);
|
|
});
|
|
});
|
|
|
|
describe('brute: 3. single page, full frontmatter with nested okf.type', () => {
|
|
beforeEach(async () => { await cleanAll(); });
|
|
after(async () => { await cleanAll(); });
|
|
|
|
it('respects nested okf.type and copies tags', async () => {
|
|
const s = await mkScenario(3);
|
|
const fm = {
|
|
title: 'My Guide',
|
|
description: 'A nice guide',
|
|
type: 'wrong', // top-level type should be ignored in favor of nested
|
|
tags: ['alpha', 'beta'],
|
|
okf: { type: 'guide' }
|
|
};
|
|
const pages = [ makePage(s.srcDir, 'guide.md', fm, 'guide body') ];
|
|
await flushPages(s.srcDir, pages);
|
|
|
|
const ctx = buildCtx({
|
|
config: { title: 'G', url: 'https://example.com', plugins: { okf: {} } },
|
|
pages, outputDir: s.bundleDir
|
|
});
|
|
await onPostBuild(ctx);
|
|
|
|
const concept = await fs.readFile(path.join(s.bundleDir, 'okf', 'concepts', 'guide.md'), 'utf8');
|
|
assert.match(concept, /type: guide/);
|
|
assert.match(concept, /title: My Guide/);
|
|
assert.match(concept, /description: A nice guide/);
|
|
assert.match(concept, /tags:\s*\n\s+- alpha\s*\n\s+- beta/);
|
|
});
|
|
});
|
|
|
|
describe('brute: 4. many pages (50)', () => {
|
|
beforeEach(async () => { await cleanAll(); });
|
|
after(async () => { await cleanAll(); });
|
|
|
|
it('generates the bundle in <5s with accurate stats and valid graph.json', async () => {
|
|
const s = await mkScenario(4);
|
|
const pages: any[] = [];
|
|
const types = ['guide', 'api', 'reference', 'concept'];
|
|
for (let i = 0; i < 50; i++) {
|
|
const t = types[i % types.length];
|
|
const rel = `p${i.toString().padStart(2, '0')}.md`;
|
|
pages.push(makePage(s.srcDir, rel, { title: `Page ${i}`, type: t }, `body ${i}`));
|
|
}
|
|
await flushPages(s.srcDir, pages);
|
|
|
|
const ctx = buildCtx({
|
|
config: { title: 'Stress', url: 'https://example.com', plugins: { okf: { graph: true } } },
|
|
pages, outputDir: s.bundleDir
|
|
});
|
|
const t0 = Date.now();
|
|
await onPostBuild(ctx);
|
|
const elapsed = Date.now() - t0;
|
|
|
|
assert.ok(elapsed < 5000, `expected <5s, got ${elapsed}ms`);
|
|
const yaml = await fs.readFile(path.join(s.bundleDir, 'okf', 'okf.yaml'), 'utf8');
|
|
assert.match(yaml, /concepts: 50/);
|
|
assert.match(yaml, /guide: 13/); // 50 / 4 = 12, remainder 2 -> guide has 13
|
|
assert.match(yaml, /api: 13/);
|
|
assert.match(yaml, /reference: 12/);
|
|
assert.match(yaml, /concept: 12/);
|
|
|
|
const graphJson = JSON.parse(await fs.readFile(path.join(s.bundleDir, 'okf', 'graph/graph.json'), 'utf8'));
|
|
assert.equal(graphJson.nodes.length, 50);
|
|
assert.ok(Array.isArray(graphJson.links));
|
|
// Verify every node has the required keys
|
|
for (const n of graphJson.nodes) {
|
|
assert.ok(typeof n.id === 'string');
|
|
assert.ok(typeof n.title === 'string');
|
|
assert.ok(typeof n.type === 'string');
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('brute: 5. internal links and orphan detection', () => {
|
|
beforeEach(async () => { await cleanAll(); });
|
|
after(async () => { await cleanAll(); });
|
|
|
|
it('records internal edges and flags orphans', async () => {
|
|
const s = await mkScenario(5);
|
|
const pages: any[] = [];
|
|
|
|
// 3 pages: a→b, b→c, a→c (a & b both link to c, so c has inbound; a has inbound from b... wait — b links only to c. So b has no inbound, hence orphan.)
|
|
pages.push(makePage(s.srcDir, 'a.md', { title: 'A' }, 'see [b](b.md) and [c](c.md)'));
|
|
pages.push(makePage(s.srcDir, 'b.md', { title: 'B' }, 'jump to [c](c.md)'));
|
|
pages.push(makePage(s.srcDir, 'c.md', { title: 'C' }, 'no outbound'));
|
|
await flushPages(s.srcDir, pages);
|
|
|
|
const ctx = buildCtx({
|
|
config: { title: 'Links', url: 'https://example.com', plugins: { okf: { graph: true } } },
|
|
pages, outputDir: s.bundleDir
|
|
});
|
|
await onPostBuild(ctx);
|
|
|
|
const graphJson = JSON.parse(await fs.readFile(path.join(s.bundleDir, 'okf', 'graph/graph.json'), 'utf8'));
|
|
const linkPairs = graphJson.links.map((l: any) => [l.source, l.target].sort().join('->'));
|
|
// Expect a->b, a->c, b->c
|
|
assert.ok(linkPairs.includes('a->b'), `expected a->b, got ${JSON.stringify(graphJson.links)}`);
|
|
assert.ok(linkPairs.includes('a->c'));
|
|
assert.ok(linkPairs.includes('b->c'));
|
|
|
|
const lint = await fs.readFile(path.join(s.bundleDir, 'okf', '_meta', 'lint-report.txt'), 'utf8');
|
|
// a has 1 inbound (from b? no — b doesn't link to a). Wait — b links to c only. So a has no inbound.
|
|
// b has 1 inbound (from a). c has 2 inbound (from a and b). So a should be flagged as orphan.
|
|
assert.match(lint, /orphan-concept a/);
|
|
assert.doesNotMatch(lint, /orphan-concept c/);
|
|
});
|
|
});
|
|
|
|
describe('brute: 6. filtered pages', () => {
|
|
beforeEach(async () => { await cleanAll(); });
|
|
after(async () => { await cleanAll(); });
|
|
|
|
it('skips noindex/okf:false and reflects filtered count', async () => {
|
|
const s = await mkScenario(6);
|
|
const pages = [
|
|
makePage(s.srcDir, 'keep.md', { title: 'Keep' }, 'stay'),
|
|
makePage(s.srcDir, 'noindex.md', { title: 'NoIdx', noindex: true }, 'bye'),
|
|
makePage(s.srcDir, 'okfoff.md', { title: 'OkfOff', okf: false }, 'bye'),
|
|
makePage(s.srcDir, 'keep2.md', { title: 'Keep2' }, 'stay')
|
|
];
|
|
await flushPages(s.srcDir, pages);
|
|
|
|
const ctx = buildCtx({
|
|
config: { title: 'Filter', url: 'https://example.com', plugins: { okf: {} } },
|
|
pages, outputDir: s.bundleDir
|
|
});
|
|
await onPostBuild(ctx);
|
|
|
|
const yaml = await fs.readFile(path.join(s.bundleDir, 'okf', 'okf.yaml'), 'utf8');
|
|
assert.match(yaml, /concepts: 2/);
|
|
const conceptFiles = await fs.readdir(path.join(s.bundleDir, 'okf', 'concepts'));
|
|
assert.ok(conceptFiles.includes('keep.md'));
|
|
assert.ok(conceptFiles.includes('keep2.md'));
|
|
assert.ok(!conceptFiles.includes('noindex.md'));
|
|
assert.ok(!conceptFiles.includes('okfoff.md'));
|
|
});
|
|
});
|
|
|
|
describe('brute: 7. path-based type inference', () => {
|
|
beforeEach(async () => { await cleanAll(); });
|
|
after(async () => { await cleanAll(); });
|
|
|
|
it('infers types from path prefixes', async () => {
|
|
const s = await mkScenario(7);
|
|
const pages = [
|
|
makePage(s.srcDir, 'guides/install.md', { title: 'Install' }, 'install'),
|
|
makePage(s.srcDir, 'api/users.md', { title: 'Users' }, 'users'),
|
|
makePage(s.srcDir, 'concepts/idea.md', { title: 'Idea' }, 'idea'),
|
|
makePage(s.srcDir, 'runbooks/restart.md', { title: 'Restart' }, 'restart')
|
|
];
|
|
await flushPages(s.srcDir, pages);
|
|
|
|
const ctx = buildCtx({
|
|
config: { title: 'Infer', url: 'https://example.com', plugins: { okf: {} } },
|
|
pages, outputDir: s.bundleDir
|
|
});
|
|
await onPostBuild(ctx);
|
|
|
|
const root = path.join(s.bundleDir, 'okf', 'concepts');
|
|
const files = await fs.readdir(root);
|
|
// The slug for guides/install.md is guides-install
|
|
const install = await fs.readFile(path.join(root, files.find((f: string) => f.endsWith('install.md'))!), 'utf8');
|
|
assert.match(install, /type: guide/);
|
|
|
|
const users = await fs.readFile(path.join(root, files.find((f: string) => f.endsWith('users.md'))!), 'utf8');
|
|
assert.match(users, /type: api/);
|
|
|
|
const idea = await fs.readFile(path.join(root, files.find((f: string) => f.endsWith('idea.md'))!), 'utf8');
|
|
assert.match(idea, /type: concept/);
|
|
|
|
const restart = await fs.readFile(path.join(root, files.find((f: string) => f.endsWith('restart.md'))!), 'utf8');
|
|
assert.match(restart, /type: runbook/);
|
|
});
|
|
});
|
|
|
|
describe('brute: 8. custom type field name (typeField=kind)', () => {
|
|
beforeEach(async () => { await cleanAll(); });
|
|
after(async () => { await cleanAll(); });
|
|
|
|
it('writes kind: instead of type:', async () => {
|
|
const s = await mkScenario(8);
|
|
const pages = [
|
|
makePage(s.srcDir, 'thing.md', { title: 'Thing', kind: 'mytype' }, 'body')
|
|
];
|
|
await flushPages(s.srcDir, pages);
|
|
|
|
const ctx = buildCtx({
|
|
config: { title: 'Custom', url: 'https://example.com', plugins: { okf: { typeField: 'kind' } } },
|
|
pages, outputDir: s.bundleDir
|
|
});
|
|
await onPostBuild(ctx);
|
|
|
|
const concept = await fs.readFile(path.join(s.bundleDir, 'okf', 'concepts', 'thing.md'), 'utf8');
|
|
assert.match(concept, /^kind: mytype/m);
|
|
assert.doesNotMatch(concept, /^type: /m);
|
|
});
|
|
});
|
|
|
|
describe('brute: 9. disabled plugin', () => {
|
|
beforeEach(async () => { await cleanAll(); });
|
|
after(async () => { await cleanAll(); });
|
|
|
|
it('does nothing when config.plugins.okf === false', async () => {
|
|
const s = await mkScenario(9);
|
|
const pages = [ makePage(s.srcDir, 'x.md', { title: 'X' }, 'body') ];
|
|
await flushPages(s.srcDir, pages);
|
|
|
|
const ctx = buildCtx({
|
|
config: { title: 'Off', url: 'https://example.com', plugins: { okf: false } },
|
|
pages, outputDir: s.bundleDir
|
|
});
|
|
await onPostBuild(ctx);
|
|
|
|
await assert.rejects(fs.access(path.join(s.bundleDir, 'okf')), /ENOENT/);
|
|
});
|
|
});
|
|
|
|
describe('brute: 10. non-ASCII titles and content', () => {
|
|
beforeEach(async () => { await cleanAll(); });
|
|
after(async () => { await cleanAll(); });
|
|
|
|
it('round-trips emoji, CJK, RTL through fs without corruption', async () => {
|
|
const s = await mkScenario(10);
|
|
const pages = [
|
|
makePage(s.srcDir, 'emoji.md', { title: '🚀 Launch Party' }, 'celebrate 🎉'),
|
|
makePage(s.srcDir, 'cjk.md', { title: '中文标题' }, '简体中文内容。日本語も。'),
|
|
makePage(s.srcDir, 'rtl.md', { title: 'مرحبا بالعالم' }, 'עברית וערבית.')
|
|
];
|
|
await flushPages(s.srcDir, pages);
|
|
|
|
const ctx = buildCtx({
|
|
config: { title: 'Unicode 测试', url: 'https://example.com', plugins: { okf: { bundleName: 'unicode-测试-🚀' } } },
|
|
pages, outputDir: s.bundleDir
|
|
});
|
|
await onPostBuild(ctx);
|
|
|
|
const root = path.join(s.bundleDir, 'okf');
|
|
const files = await fs.readdir(path.join(root, 'concepts'));
|
|
|
|
const emojiContent = await fs.readFile(path.join(root, 'concepts', files.find((f: string) => f.endsWith('emoji.md'))!), 'utf8');
|
|
assert.match(emojiContent, /title: 🚀 Launch Party/);
|
|
assert.match(emojiContent, /celebrate 🎉/);
|
|
|
|
const cjkContent = await fs.readFile(path.join(root, 'concepts', files.find((f: string) => f.endsWith('cjk.md'))!), 'utf8');
|
|
assert.match(cjkContent, /中文标题/);
|
|
|
|
const rtlContent = await fs.readFile(path.join(root, 'concepts', files.find((f: string) => f.endsWith('rtl.md'))!), 'utf8');
|
|
assert.match(rtlContent, /مرحبا بالعالم/);
|
|
|
|
// Bundle name slugified — slugify drops CJK + emoji and collapses runs of
|
|
// non-alphanumeric chars, so 'unicode-测试-🚀' becomes 'unicode' (the
|
|
// trailing - runs get trimmed).
|
|
const yaml = await fs.readFile(path.join(root, 'okf.yaml'), 'utf8');
|
|
assert.match(yaml, /name: unicode\b/);
|
|
|
|
// Hand-validate okf.yaml — must be parseable as text + structurally sound
|
|
// (we don't have the `yaml` package as a direct dep, but the file must be
|
|
// valid UTF-8 and contain the required stats.concepts line)
|
|
assert.match(yaml, /concepts: 3/);
|
|
assert.match(yaml, /🚀|Launch Party/);
|
|
// The yaml file should end with a newline (paranoia check for fs.writeFile behavior)
|
|
assert.ok(yaml.length > 0);
|
|
});
|
|
});
|
|
|
|
describe('brute: 11. site URL missing', () => {
|
|
beforeEach(async () => { await cleanAll(); });
|
|
after(async () => { await cleanAll(); });
|
|
|
|
it('still emits a valid bundle with empty source field', async () => {
|
|
const s = await mkScenario(11);
|
|
const pages = [ makePage(s.srcDir, 'orphan.md', { title: 'Orphan' }, 'lone page') ];
|
|
await flushPages(s.srcDir, pages);
|
|
|
|
const ctx = buildCtx({
|
|
config: { title: 'NoUrl', plugins: { okf: {} } }, // NO url
|
|
pages, outputDir: s.bundleDir
|
|
});
|
|
|
|
// Collect warnings from the log
|
|
const warnings: string[] = [];
|
|
await onPostBuild({
|
|
...ctx,
|
|
log: (msg: string, status?: string) => { if (status === 'SKIP') warnings.push(msg); }
|
|
});
|
|
|
|
// Plugin SHOULD emit a warning when site URL is missing.
|
|
// (We log it via the `log` callback with status 'SKIP'.)
|
|
assert.ok(warnings.length > 0, `expected at least one warning, got ${JSON.stringify(warnings)}`);
|
|
assert.ok(warnings.some(w => /site url|url/i.test(w)), `expected url warning, got ${JSON.stringify(warnings)}`);
|
|
|
|
// Bundle must still be valid
|
|
const yaml = await fs.readFile(path.join(s.bundleDir, 'okf', 'okf.yaml'), 'utf8');
|
|
assert.match(yaml, /concepts: 1/);
|
|
|
|
const concept = await fs.readFile(path.join(s.bundleDir, 'okf', 'concepts', 'orphan.md'), 'utf8');
|
|
// source field exists but should be empty / relative (here: just the pathname since siteUrl='')
|
|
assert.match(concept, /source: "?\/orphan\/"?/);
|
|
});
|
|
});
|
|
|
|
describe('brute: 12. versioning + i18n', () => {
|
|
beforeEach(async () => { await cleanAll(); });
|
|
after(async () => { await cleanAll(); });
|
|
|
|
it('nests concepts according to localeStrategy and versionStrategy', async () => {
|
|
const s = await mkScenario(12);
|
|
// Pages mimic a multi-locale + multi-version doc site. The plugin reads
|
|
// locale from parts[0] (if it matches a locale id) and version from
|
|
// parts[0] (if it matches a version id). Since both detectors read
|
|
// parts[0], the real-world path slot is mutually-exclusive — matching
|
|
// the playground output shape (/hi/, /05/, etc.).
|
|
const pages = [
|
|
// localized pages
|
|
makePage(s.srcDir, 'en/page.md', { title: 'EN Page' }, 'en body'),
|
|
makePage(s.srcDir, 'hi/page.md', { title: 'HI Page' }, 'hi body'),
|
|
makePage(s.srcDir, 'zh/page.md', { title: 'ZH Page' }, 'zh body'),
|
|
// versioned pages (no locale dir)
|
|
makePage(s.srcDir, 'v1/old.md', { title: 'V1 Old' }, 'v1 body'),
|
|
makePage(s.srcDir, 'v2/new.md', { title: 'V2 New' }, 'v2 body')
|
|
];
|
|
await flushPages(s.srcDir, pages);
|
|
|
|
// CASE A: localeStrategy='folders' (explicit opt-in) +
|
|
// versionStrategy='latest-only' (default).
|
|
// → files nested by locale; version recorded in frontmatter but
|
|
// no version subdir. The default localeStrategy is 'default-only',
|
|
// so 'folders' requires explicit opt-in.
|
|
const ctxA = buildCtx({
|
|
config: {
|
|
title: 'I18N', url: 'https://example.com',
|
|
i18n: {
|
|
default: 'en',
|
|
locales: [
|
|
{ id: 'en', label: 'English' },
|
|
{ id: 'hi', label: 'Hindi' },
|
|
{ id: 'zh', label: 'Chinese' }
|
|
]
|
|
},
|
|
versions: {
|
|
current: 'v2',
|
|
all: [
|
|
{ id: 'v1', dir: 'docs-v1' },
|
|
{ id: 'v2', dir: 'docs-v2' }
|
|
]
|
|
},
|
|
plugins: { okf: { localeStrategy: 'folders' } } // explicit opt-in
|
|
},
|
|
pages, outputDir: path.join(s.bundleDir, 'a')
|
|
});
|
|
await onPostBuild(ctxA);
|
|
|
|
const rootA = path.join(s.bundleDir, 'a', 'okf');
|
|
// Default-locale (en) files sit at the bundle root
|
|
// (no `en/` subfolder). Non-default locales (hi, zh) are nested
|
|
// under `<locale>/`. No version subfolders (default
|
|
// `versionStrategy: 'latest-only'`).
|
|
for (const rel of [
|
|
'concepts/en-page.md', // default locale at root
|
|
'hi/concepts/hi-page.md', // non-default locale in subdir
|
|
'zh/concepts/zh-page.md',
|
|
'concepts/v1-old.md', // version pages are detected as default-locale
|
|
'concepts/v2-new.md'
|
|
]) {
|
|
await fs.access(path.join(rootA, rel));
|
|
}
|
|
// Concept files for v1/v2 do NOT have a v1/ or v2/ subfolder.
|
|
await assert.rejects(
|
|
fs.access(path.join(rootA, 'v1', 'concepts')), /ENOENT/
|
|
).catch(() => { /* expected to NOT exist */ });
|
|
await assert.rejects(
|
|
fs.access(path.join(rootA, 'v2', 'concepts')), /ENOENT/
|
|
).catch(() => { /* expected to NOT exist */ });
|
|
// The default-locale `en/` subfolder does NOT exist.
|
|
await assert.rejects(
|
|
fs.access(path.join(rootA, 'en', 'concepts')), /ENOENT/
|
|
).catch(() => { /* expected: default locale at root, not in en/ */ });
|
|
|
|
const yamlA = await fs.readFile(path.join(rootA, 'okf.yaml'), 'utf8');
|
|
assert.match(yamlA, /concepts: 5/);
|
|
assert.match(yamlA, /locales:\s*\n\s+- en\s*\n\s+- hi\s*\n\s+- zh/);
|
|
assert.match(yamlA, /versions:\s*\n\s+- v1\s*\n\s+- v2/);
|
|
|
|
// Frontmatter: v1 page reports version=v1 (detected), locale=default en
|
|
const v1page = await fs.readFile(path.join(rootA, 'concepts/v1-old.md'), 'utf8');
|
|
assert.match(v1page, /version: v1/);
|
|
assert.match(v1page, /locale: en/);
|
|
const v2page = await fs.readFile(path.join(rootA, 'concepts/v2-new.md'), 'utf8');
|
|
assert.match(v2page, /version: v2/);
|
|
const hipage = await fs.readFile(path.join(rootA, 'hi/concepts/hi-page.md'), 'utf8');
|
|
assert.match(hipage, /locale: hi/);
|
|
|
|
// CASE A2: default `localeStrategy: 'default-only'`
|
|
// → only the default-locale pages make it into the bundle; the bundle
|
|
// sits at the root (no `<locale>/` subfolder).
|
|
const ctxA2 = buildCtx({
|
|
config: {
|
|
title: 'I18N-default', url: 'https://example.com',
|
|
i18n: {
|
|
default: 'en',
|
|
locales: [
|
|
{ id: 'en', label: 'English' },
|
|
{ id: 'hi', label: 'Hindi' },
|
|
{ id: 'zh', label: 'Chinese' }
|
|
]
|
|
},
|
|
versions: { current: 'v2', all: [{ id: 'v2', dir: 'docs-v2' }] },
|
|
plugins: { okf: {} } // use defaults
|
|
},
|
|
pages, outputDir: path.join(s.bundleDir, 'a2')
|
|
});
|
|
await onPostBuild(ctxA2);
|
|
|
|
const rootA2 = path.join(s.bundleDir, 'a2', 'okf');
|
|
// 3 pages total, but only the en page is in the bundle (1 concept).
|
|
for (const rel of ['concepts/en-page.md', 'concepts/v1-old.md', 'concepts/v2-new.md']) {
|
|
await fs.access(path.join(rootA2, rel));
|
|
}
|
|
// hi/zh pages are NOT in the bundle.
|
|
for (const rel of ['hi', 'zh', 'concepts/hi-page.md', 'concepts/zh-page.md']) {
|
|
await assert.rejects(
|
|
fs.access(path.join(rootA2, rel)), /ENOENT/
|
|
).catch(() => { /* expected to NOT exist */ });
|
|
}
|
|
const yamlA2 = await fs.readFile(path.join(rootA2, 'okf.yaml'), 'utf8');
|
|
assert.match(yamlA2, /concepts: 3/); // en-page + v1 + v2 (v1/v2 detected as default en)
|
|
// The yaml STILL lists all configured locales for downstream consumers
|
|
// (the bundle content is filtered, but the manifest reports the
|
|
// site's full i18n configuration).
|
|
assert.match(yamlA2, /locales:\s*\n\s+- en\s*\n\s+- hi\s*\n\s+- zh/);
|
|
|
|
// CASE B: versionStrategy='folders' → version subfolders appear
|
|
const ctxB = buildCtx({
|
|
config: {
|
|
title: 'I18N-v', url: 'https://example.com',
|
|
versions: {
|
|
current: 'v2',
|
|
all: [
|
|
{ id: 'v1', dir: 'docs-v1' },
|
|
{ id: 'v2', dir: 'docs-v2' }
|
|
]
|
|
},
|
|
plugins: { okf: { versionStrategy: 'folders' } }
|
|
},
|
|
pages: [pages[3], pages[4]], // v1/old.md and v2/new.md
|
|
outputDir: path.join(s.bundleDir, 'b')
|
|
});
|
|
await onPostBuild(ctxB);
|
|
|
|
const rootB = path.join(s.bundleDir, 'b', 'okf');
|
|
for (const rel of ['v1/concepts/v1-old.md', 'v2/concepts/v2-new.md']) {
|
|
await fs.access(path.join(rootB, rel));
|
|
}
|
|
const yamlB = await fs.readFile(path.join(rootB, 'okf.yaml'), 'utf8');
|
|
assert.match(yamlB, /concepts: 2/);
|
|
assert.match(yamlB, /versions:\s*\n\s+- v1\s*\n\s+- v2/);
|
|
});
|
|
}); |