From b30ae3696a5ed841792dd25dedf2314f929b239b Mon Sep 17 00:00:00 2001 From: julia-rabello <77292838+julia-rabello@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:05:23 -0300 Subject: [PATCH 1/7] feat: use markdown file as category cover in navigation - Add categoryCover frontmatter field to FrontMatter interface - When a folder has a single .md with categoryCover: true and subcategories, promote that file's name/slug to the category node and set type to markdown - Flatten degenerate categories (single .md, no subfolders) to plain markdown nodes - Relax schema maxItems constraint on markdown node children to allow cover-backed nodes --- source/commands/generate/transformer.ts | 51 ++++++++++++++++++++----- source/commands/generate/types.ts | 1 + source/schemas/navigation.schema.json | 5 +-- 3 files changed, 45 insertions(+), 12 deletions(-) diff --git a/source/commands/generate/transformer.ts b/source/commands/generate/transformer.ts index d3036c1..a7b99da 100644 --- a/source/commands/generate/transformer.ts +++ b/source/commands/generate/transformer.ts @@ -212,8 +212,12 @@ export class NavigationTransformer { } } - // Drop categories that ended up empty after pruning - const nonEmpty = nodes.filter(n => Array.isArray((n as any).children) && (n as any).children.length > 0); + // Drop categories that ended up empty after pruning. + // Markdown nodes are kept as-is: they were promoted from degenerate single-file categories. + const nonEmpty = nodes.filter(n => { + if ((n as any).type === 'markdown') return true; + return Array.isArray((n as any).children) && (n as any).children.length > 0; + }); // Merge categories by the English slug so categories are localized entities const merged = this.mergeCategoryNodeLists(nonEmpty); @@ -275,26 +279,55 @@ export class NavigationTransformer { sectionName ); + // --- Degenerate category: single markdown child, no subcategories --- + // A folder with only one .md file and no subfolders is flattened to a + // plain markdown node so it doesn't produce an unnecessary category wrapper. + const coverFile = directFiles.find(f => f.metadata.categoryCover === true); + if (!coverFile && subcategoryNodes.length === 0 && documentNodes.length === 1) { + return documentNodes[0] ?? null; + } + + // --- Category cover --- + // When a file has categoryCover: true AND the category has subcategories, + // that file's title/slug are promoted to the category level and the file + // is removed from the children list (clicking the category opens the cover). + let categoryName = name; + let categorySlug = slug; + let hasCover = false; + + if (coverFile && subcategoryNodes.length > 0) { + const coverNodeIdx = documentNodes.findIndex( + n => n.type === 'markdown' && (n.slug as any)?.en === coverFile.metadata.slugEN + ); + if (coverNodeIdx !== -1) { + const coverNode = documentNodes[coverNodeIdx]!; + categoryName = coverNode.name as LocalizedString; + categorySlug = coverNode.slug as LocalizedString; + hasCover = true; + documentNodes.splice(coverNodeIdx, 1); + } + } + // Subcategories first, then direct markdown (same ordering as mergeCategoryNodeLists) const combinedChildren = [...subcategoryNodes, ...documentNodes]; - if (combinedChildren.length === 0) { + if (combinedChildren.length === 0 && !hasCover) { return null; } - const node = { - name, - slug: slug, + const node: any = { + name: categoryName, + slug: categorySlug, origin: '', - type: 'category', + type: hasCover ? 'markdown' : 'category', children: combinedChildren, - } as NavigationNode; + }; if (typeof categoryInfo.order === 'number') { node.order = categoryInfo.order; } - return node; + return node as NavigationNode; } catch (error) { this.logger.error('Failed to build navigation node', { error, categoryInfo }); diff --git a/source/commands/generate/types.ts b/source/commands/generate/types.ts index d18148f..adf7290 100644 --- a/source/commands/generate/types.ts +++ b/source/commands/generate/types.ts @@ -11,6 +11,7 @@ export interface FrontMatter { updatedAt?: string; publishedAt?: string; order?: number; // Order for track articles + categoryCover?: boolean; // If true, this file is used as the cover page of its parent category [key: string]: any; // Allow additional fields } diff --git a/source/schemas/navigation.schema.json b/source/schemas/navigation.schema.json index 77aec06..d8063a2 100644 --- a/source/schemas/navigation.schema.json +++ b/source/schemas/navigation.schema.json @@ -84,7 +84,7 @@ "items": { "$ref": "#/definitions/navigationNode" } - } + }, }, "allOf": [ { @@ -100,8 +100,7 @@ "if": { "properties": { "type": { "const": "markdown" } } }, "then": { "properties": { - "slug": { "$ref": "#/definitions/localizedString" }, - "children": { "type": "array", "maxItems": 0 } + "slug": { "$ref": "#/definitions/localizedString" } } } } From f6902660b0cbf198fd995d3721966c8ed4175885 Mon Sep 17 00:00:00 2001 From: julia-rabello <77292838+julia-rabello@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:19:42 -0300 Subject: [PATCH 2/7] fix: pass markdown nodes through mergeCategoryNodeLists and fix schema trailing comma --- source/commands/generate/transformer.ts | 7 +++++-- source/schemas/navigation.schema.json | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/source/commands/generate/transformer.ts b/source/commands/generate/transformer.ts index a7b99da..b914d40 100644 --- a/source/commands/generate/transformer.ts +++ b/source/commands/generate/transformer.ts @@ -660,8 +660,11 @@ export class NavigationTransformer { return Array.from(docMap.values()); }; + // Collect markdown nodes (cover-backed categories) separately — they don't need merging + const markdownNodes = nodes.filter(n => (n as any).type === 'markdown'); + for (const node of nodes) { - if ((node as any).type !== 'category') continue; // should not happen here + if ((node as any).type !== 'category') continue; const slugVal = (node as any).slug as any; const key = typeof slugVal === 'string' ? slugVal : (slugVal?.en || JSON.stringify(slugVal)); if (!bySlug.has(key)) { @@ -697,7 +700,7 @@ export class NavigationTransformer { } } - return Array.from(bySlug.values()); + return [...Array.from(bySlug.values()), ...markdownNodes]; } private generateLocalizedCategorySlugs( diff --git a/source/schemas/navigation.schema.json b/source/schemas/navigation.schema.json index d8063a2..5610937 100644 --- a/source/schemas/navigation.schema.json +++ b/source/schemas/navigation.schema.json @@ -84,7 +84,7 @@ "items": { "$ref": "#/definitions/navigationNode" } - }, + } }, "allOf": [ { From aa63ca876f7107ba099a9e41b3709f62c4b52c23 Mon Sep 17 00:00:00 2001 From: julia-rabello <77292838+julia-rabello@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:31:41 -0300 Subject: [PATCH 3/7] refactor: align cover logic with spec - structure-driven auto cover, categoryCover as tiebreaker --- source/commands/generate/transformer.ts | 49 ++++++++++++++++--------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/source/commands/generate/transformer.ts b/source/commands/generate/transformer.ts index b914d40..006fa2e 100644 --- a/source/commands/generate/transformer.ts +++ b/source/commands/generate/transformer.ts @@ -279,33 +279,46 @@ export class NavigationTransformer { sectionName ); - // --- Degenerate category: single markdown child, no subcategories --- - // A folder with only one .md file and no subfolders is flattened to a - // plain markdown node so it doesn't produce an unnecessary category wrapper. - const coverFile = directFiles.find(f => f.metadata.categoryCover === true); - if (!coverFile && subcategoryNodes.length === 0 && documentNodes.length === 1) { - return documentNodes[0] ?? null; - } + const isSingleFile = directFiles.length === 1; + const hasSubcats = subcategoryNodes.length > 0; - // --- Category cover --- - // When a file has categoryCover: true AND the category has subcategories, - // that file's title/slug are promoted to the category level and the file - // is removed from the children list (clicking the category opens the cover). let categoryName = name; let categorySlug = slug; let hasCover = false; - if (coverFile && subcategoryNodes.length > 0) { - const coverNodeIdx = documentNodes.findIndex( - n => n.type === 'markdown' && (n.slug as any)?.en === coverFile.metadata.slugEN - ); - if (coverNodeIdx !== -1) { - const coverNode = documentNodes[coverNodeIdx]!; + if (isSingleFile && !hasSubcats) { + // Single .md, no subfolders → degenerate, flatten to plain markdown + return documentNodes[0] ?? null; + } else if (isSingleFile && hasSubcats) { + // Single .md + subfolders → auto cover, no frontmatter field needed + const coverNode = documentNodes[0]; + if (coverNode) { categoryName = coverNode.name as LocalizedString; categorySlug = coverNode.slug as LocalizedString; hasCover = true; - documentNodes.splice(coverNodeIdx, 1); + documentNodes.splice(0, 1); + } + } else if (hasSubcats) { + // Multiple .md files + subfolders → categoryCover: true designates the cover + const markedFiles = directFiles.filter(f => f.metadata.categoryCover === true); + if (markedFiles.length > 1) { + this.logger.warn( + `Multiple files with categoryCover: true in the same folder — cover logic skipped, falling back to regular category.`, + { category: categoryInfo.path, files: markedFiles.map(f => f.fileName) } + ); + } else if (markedFiles.length === 1) { + const coverNodeIdx = documentNodes.findIndex( + n => n.type === 'markdown' && (n.slug as any)?.en === markedFiles[0]!.metadata.slugEN + ); + if (coverNodeIdx !== -1) { + const coverNode = documentNodes[coverNodeIdx]!; + categoryName = coverNode.name as LocalizedString; + categorySlug = coverNode.slug as LocalizedString; + hasCover = true; + documentNodes.splice(coverNodeIdx, 1); + } } + // else: no categoryCover marked → regular category, no changes } // Subcategories first, then direct markdown (same ordering as mergeCategoryNodeLists) From 76cc6dea524d9968d47738e27e6491f896b6aa43 Mon Sep 17 00:00:00 2001 From: julia-rabello <77292838+julia-rabello@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:44:46 -0300 Subject: [PATCH 4/7] fix: use documentNodes.length and slugEN dedup to handle multilingual file counts correctly --- source/commands/generate/transformer.ts | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/source/commands/generate/transformer.ts b/source/commands/generate/transformer.ts index 006fa2e..22d528c 100644 --- a/source/commands/generate/transformer.ts +++ b/source/commands/generate/transformer.ts @@ -279,7 +279,9 @@ export class NavigationTransformer { sectionName ); - const isSingleFile = directFiles.length === 1; + // Use documentNodes.length (deduplicated by slugEN across languages) rather than + // directFiles.length, which would count EN + PT + ES versions as separate files. + const isSingleFile = documentNodes.length === 1; const hasSubcats = subcategoryNodes.length > 0; let categoryName = name; @@ -299,16 +301,22 @@ export class NavigationTransformer { documentNodes.splice(0, 1); } } else if (hasSubcats) { - // Multiple .md files + subfolders → categoryCover: true designates the cover - const markedFiles = directFiles.filter(f => f.metadata.categoryCover === true); - if (markedFiles.length > 1) { + // Multiple .md files + subfolders → categoryCover: true designates the cover. + // Deduplicate by slugEN so EN/PT/ES versions of the same file count as one. + const markedSlugs = new Set( + directFiles + .filter(f => f.metadata.categoryCover === true) + .map(f => f.metadata.slugEN) + ); + if (markedSlugs.size > 1) { this.logger.warn( `Multiple files with categoryCover: true in the same folder — cover logic skipped, falling back to regular category.`, - { category: categoryInfo.path, files: markedFiles.map(f => f.fileName) } + { category: categoryInfo.path, slugs: [...markedSlugs] } ); - } else if (markedFiles.length === 1) { + } else if (markedSlugs.size === 1) { + const coverSlugEN = [...markedSlugs][0]; const coverNodeIdx = documentNodes.findIndex( - n => n.type === 'markdown' && (n.slug as any)?.en === markedFiles[0]!.metadata.slugEN + n => n.type === 'markdown' && (n.slug as any)?.en === coverSlugEN ); if (coverNodeIdx !== -1) { const coverNode = documentNodes[coverNodeIdx]!; From 498eef6656c3f46a802dcf476807007faae94510 Mon Sep 17 00:00:00 2001 From: julia-rabello <77292838+julia-rabello@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:45:29 -0300 Subject: [PATCH 5/7] docs: document category cover page behavior and categoryCover frontmatter field --- readme.md | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/readme.md b/readme.md index a3afec4..f8f5e7b 100644 --- a/readme.md +++ b/readme.md @@ -65,6 +65,40 @@ Generation pipeline: - Categories across languages are merged by their English slug so they are treated as a single localized entity - Document slugs are derived from filenames; for Announcements we only read the leading date to sort but do not alter names +### Category cover pages + +A folder can designate one of its markdown files as the category's landing page (cover). When a cover is set, the category node gets `type: "markdown"` and its `name`/`slug` are taken from the cover file rather than the folder name. The subcategories remain in `children`. + +The cover is determined by folder structure and, when needed, by a frontmatter field: + +| Folder contents | `categoryCover` field | Result | +|---|---|---| +| Single `.md` + subfolders | any | Cover applied automatically | +| Single `.md`, no subfolders | any | Flattened to a plain markdown node (no category wrapper) | +| Multiple `.md` + subfolders | none | Regular `type: "category"` | +| Multiple `.md` + subfolders | `true` on one file | That file becomes the cover | +| Multiple `.md` + subfolders | `true` on more than one file | Warning logged, falls back to regular category | + +To explicitly mark a file as cover when there are sibling markdown files, add to its frontmatter: + +```yaml +categoryCover: true +``` + +Example output for a cover-backed category: + +```json +{ + "type": "markdown", + "name": { "en": "Account settings - Overview", "es": "...", "pt": "..." }, + "slug": { "en": "account-settings-overview", "es": "...", "pt": "..." }, + "origin": "", + "children": [ + { "type": "category", "name": { ... }, "slug": { ... }, "children": [...] } + ] +} +``` + ### Validation See also: Troubleshooting guide at docs/troubleshooting.md @@ -72,7 +106,7 @@ See also: Troubleshooting guide at docs/troubleshooting.md - JSON Schema (Draft‑07) at `source/schemas/navigation.schema.json` - name and slug are LocalizedString objects with en, es, pt keys - Categories: type=category, children >= 1 - - Documents: type=markdown, children must be empty + - Documents: type=markdown, children can be empty or non-empty (when acting as a category cover) - Additional properties are disallowed - Custom checks - Sibling categories under the same parent must have unique English slug From d04ccde1a310e83c50b51b5d584b4d2a3028231c Mon Sep 17 00:00:00 2001 From: julia-rabello <77292838+julia-rabello@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:07:47 -0300 Subject: [PATCH 6/7] test: add category cover scenario tests Seven cases covering all cover logic paths: auto cover, degenerate flatten, no cover, explicit cover, conflict warning, multilingual dedup, and pruning survival. Runs via plain node against dist/. --- run-cover-tests.mjs | 226 +++++++++++++++ .../generate/transformer.cover.test.ts | 268 ++++++++++++++++++ 2 files changed, 494 insertions(+) create mode 100644 run-cover-tests.mjs create mode 100644 source/commands/generate/transformer.cover.test.ts diff --git a/run-cover-tests.mjs b/run-cover-tests.mjs new file mode 100644 index 0000000..9916091 --- /dev/null +++ b/run-cover-tests.mjs @@ -0,0 +1,226 @@ +/** + * Standalone test runner for category cover scenarios. + * Uses dist/ (compiled JS) to avoid ts-node/esm issues on Node 24. + */ + +import { NavigationTransformer } from './dist/commands/generate/transformer.js'; + +let passed = 0; +let failed = 0; + +function assert(condition, message) { + if (!condition) throw new Error(`Assertion failed: ${message}`); +} + +async function test(name, fn) { + try { + await fn(); + console.log(` ✅ ${name}`); + passed++; + } catch (err) { + console.log(` ❌ ${name}`); + console.log(` ${err.message}`); + failed++; + } +} + +function makeLogger(warnings = []) { + return { + info: () => {}, + debug: () => {}, + error: () => {}, + warn: (msg) => { warnings.push(msg); }, + startPhase: () => {}, + completePhase: () => {}, + log: () => {}, + setStatsUpdateCallback: () => {}, + setLogUpdateCallback: () => {}, + updateStats: () => {}, + setCurrentFile: () => {}, + incrementProcessed: () => {}, + updateLanguageStats: () => {}, + updateSectionStats: () => {}, + getStats: () => ({}), + getLogs: () => [], + close: async () => {}, + }; +} + +const defaultOptions = { + contentDir: '', + output: '', + validate: false, + report: false, + fix: false, + languages: ['en', 'pt', 'es'], + sections: [], + verbose: false, + interactive: false, +}; + +function makeFile(slugEN, { categoryCover, locale = 'en' } = {}) { + return { + path: `/content/${locale}/${slugEN}.md`, + relativePath: `${locale}/${slugEN}.md`, + language: locale, + section: 'docs', + category: 'test-category', + originalCategory: 'test-category', + fileName: `${slugEN}.md`, + content: '', + metadata: { + title: slugEN.replace(/-/g, ' '), + id: slugEN, + status: 'published', + slugEN, + locale, + ...(categoryCover !== undefined ? { categoryCover } : {}), + }, + }; +} + +function makeHierarchy() { + return { + sections: {}, + crossLanguageMap: {}, + stats: { totalCategories: 0, totalDocuments: 0, languageCoverage: {} }, + }; +} + +function makeCategoryInfo(path, overrides = {}) { + return { + name: { en: 'Test Category', pt: 'Categoria de Teste', es: 'Categoría de Prueba' }, + path, + level: 1, + children: { files: [], subcategories: {} }, + ...overrides, + }; +} + +// A subcategory with one file so it survives empty-category pruning +function makeSubcat(parentPath) { + const subPath = `${parentPath}/payments`; + return { + [subPath]: { + name: { en: 'Payments', pt: 'Pagamentos', es: 'Pagos' }, + path: subPath, + level: 2, + children: { files: [makeFile('card-payments')], subcategories: {} }, + }, + }; +} + +async function buildNode(transformer, categoryInfo, hierarchy, directFiles, subcategories) { + return transformer.buildNavigationNode( + { ...categoryInfo, children: { files: directFiles, subcategories } }, + hierarchy, + new Set(), + new Map(), + [], + 'docs', + ); +} + +// ───────────────────────────────────────────────────────────────────────────── + +console.log('\nCategory cover scenarios\n'); + +await test('Case 1 — single .md + subfolders → auto cover (type: markdown, slug from cover file)', async () => { + const transformer = new NavigationTransformer(makeLogger(), defaultOptions); + const node = await buildNode( + transformer, + makeCategoryInfo('test-category'), + makeHierarchy(), + [makeFile('overview')], + makeSubcat('test-category'), + ); + assert(node?.type === 'markdown', `expected type markdown, got ${node?.type}`); + assert(node?.slug?.en === 'overview', `expected slug.en=overview, got ${node?.slug?.en}`); + assert(node?.children?.length > 0, 'expected non-empty children'); +}); + +await test('Case 2 — single .md, no subfolders → flattened to plain markdown (no category wrapper)', async () => { + const transformer = new NavigationTransformer(makeLogger(), defaultOptions); + const node = await buildNode( + transformer, + makeCategoryInfo('test-category'), + makeHierarchy(), + [makeFile('release-notes-2024')], + {}, + ); + assert(node?.type === 'markdown', `expected type markdown, got ${node?.type}`); + assert(node?.slug?.en === 'release-notes-2024', `expected slug.en=release-notes-2024, got ${node?.slug?.en}`); + assert(!node?.children?.length, 'expected no children on plain markdown node'); +}); + +await test('Case 3 — multiple .md + subfolders, no categoryCover → regular category', async () => { + const transformer = new NavigationTransformer(makeLogger(), defaultOptions); + const node = await buildNode( + transformer, + makeCategoryInfo('test-category'), + makeHierarchy(), + [makeFile('overview'), makeFile('getting-started')], + makeSubcat('test-category'), + ); + assert(node?.type === 'category', `expected type category, got ${node?.type}`); +}); + +await test('Case 4 — multiple .md + subfolders, one categoryCover: true → that file becomes cover', async () => { + const transformer = new NavigationTransformer(makeLogger(), defaultOptions); + const node = await buildNode( + transformer, + makeCategoryInfo('test-category'), + makeHierarchy(), + [makeFile('overview', { categoryCover: true }), makeFile('getting-started')], + makeSubcat('test-category'), + ); + assert(node?.type === 'markdown', `expected type markdown, got ${node?.type}`); + assert(node?.slug?.en === 'overview', `expected slug.en=overview, got ${node?.slug?.en}`); + const childSlugs = node?.children?.map(c => c?.slug?.en ?? c?.slug); + assert(childSlugs?.some(s => s === 'getting-started'), `expected getting-started in children, got ${JSON.stringify(childSlugs)}`); +}); + +await test('Case 5 — multiple categoryCover: true → warning logged, falls back to regular category', async () => { + const warnings = []; + const transformer = new NavigationTransformer(makeLogger(warnings), defaultOptions); + const node = await buildNode( + transformer, + makeCategoryInfo('test-category'), + makeHierarchy(), + [makeFile('overview', { categoryCover: true }), makeFile('getting-started', { categoryCover: true })], + makeSubcat('test-category'), + ); + assert(node?.type === 'category', `expected type category, got ${node?.type}`); + assert(warnings.some(w => w.includes('Multiple files with categoryCover: true')), `expected warning, got: ${warnings}`); +}); + +await test('Case 6 — EN/PT/ES variants of same slugEN count as one file (auto cover still fires)', async () => { + const transformer = new NavigationTransformer(makeLogger(), defaultOptions); + const node = await buildNode( + transformer, + makeCategoryInfo('test-category'), + makeHierarchy(), + [makeFile('overview', { locale: 'en' }), makeFile('overview', { locale: 'pt' }), makeFile('overview', { locale: 'es' })], + makeSubcat('test-category'), + ); + assert(node?.type === 'markdown', `expected type markdown (auto cover), got ${node?.type}`); +}); + +await test('Case 7 — cover-backed markdown node with children is not dropped by pruning', async () => { + const transformer = new NavigationTransformer(makeLogger(), defaultOptions); + const node = await buildNode( + transformer, + makeCategoryInfo('test-category'), + makeHierarchy(), + [makeFile('overview')], + makeSubcat('test-category'), + ); + assert(node != null, 'node should not be null'); + assert(node?.type === 'markdown', `expected type markdown, got ${node?.type}`); + assert(Array.isArray(node?.children), 'children should be an array'); +}); + +// ───────────────────────────────────────────────────────────────────────────── + +console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`); +if (failed > 0) process.exit(1); diff --git a/source/commands/generate/transformer.cover.test.ts b/source/commands/generate/transformer.cover.test.ts new file mode 100644 index 0000000..c4bde30 --- /dev/null +++ b/source/commands/generate/transformer.cover.test.ts @@ -0,0 +1,268 @@ +import test from 'ava'; +import { NavigationTransformer } from './transformer.js'; +import type { CategoryHierarchy, ContentFile, GenerationOptions } from './types.js'; + +// Minimal DualLogger stub +function makeLogger(warnings: string[] = []) { + return { + info: () => {}, + debug: () => {}, + error: () => {}, + warn: (_msg: string, _ctx?: any) => { warnings.push(_msg); }, + startPhase: () => {}, + completePhase: () => {}, + log: () => {}, + setStatsUpdateCallback: () => {}, + setLogUpdateCallback: () => {}, + updateStats: () => {}, + setCurrentFile: () => {}, + incrementProcessed: () => {}, + updateLanguageStats: () => {}, + updateSectionStats: () => {}, + getStats: () => ({}), + getLogs: () => [], + close: async () => {}, + } as any; +} + +const defaultOptions: GenerationOptions = { + contentDir: '', + output: '', + validate: false, + report: false, + fix: false, + languages: ['en', 'pt', 'es'], + sections: [], + verbose: false, + interactive: false, +}; + +function makeFile(slugEN: string, opts: { categoryCover?: boolean; locale?: string } = {}): ContentFile { + const locale = opts.locale ?? 'en'; + return { + path: `/content/${locale}/${slugEN}.md`, + relativePath: `${locale}/${slugEN}.md`, + language: locale as any, + section: 'docs', + category: 'test-category', + originalCategory: 'test-category', + fileName: `${slugEN}.md`, + content: '', + metadata: { + title: slugEN.replace(/-/g, ' '), + id: slugEN, + status: 'published', + slugEN, + locale, + ...(opts.categoryCover !== undefined ? { categoryCover: opts.categoryCover } : {}), + }, + }; +} + +function makeHierarchy(): CategoryHierarchy { + return { + sections: {}, + crossLanguageMap: {}, + stats: { totalCategories: 0, totalDocuments: 0, languageCoverage: {} }, + }; +} + +function makeCategoryInfo(path: string, overrides: Record = {}) { + return { + name: { en: 'Test Category', pt: 'Categoria de Teste', es: 'Categoría de Prueba' }, + path, + level: 1, + children: { files: [], subcategories: {} }, + ...overrides, + }; +} + +// Helper: call the private buildNavigationNode +async function buildNode( + transformer: NavigationTransformer, + categoryInfo: any, + hierarchy: CategoryHierarchy, + directFiles: ContentFile[], + subcategories: Record, +) { + return (transformer as any).buildNavigationNode( + { ...categoryInfo, children: { files: directFiles, subcategories } }, + hierarchy, + new Set(), + new Map(), + [], + 'docs', + ); +} + +// ─── Case 1: single .md + subfolders → auto cover ──────────────────────────── + +test('single .md + subfolders → type becomes markdown, name/slug from cover file', async t => { + const logger = makeLogger(); + const transformer = new NavigationTransformer(logger, defaultOptions); + const hierarchy = makeHierarchy(); + + const coverFile = makeFile('overview'); + const subcatInfo = makeCategoryInfo('test-category/payments'); + + const node = await buildNode( + transformer, + makeCategoryInfo('test-category'), + hierarchy, + [coverFile], + { 'test-category/payments': subcatInfo }, + ); + + t.is(node?.type, 'markdown'); + t.is((node?.slug as any)?.en, 'overview'); + t.truthy(node?.children?.length > 0); +}); + +// ─── Case 2: single .md, no subfolders → flatten to plain markdown ──────────── + +test('single .md, no subfolders → flattened to plain markdown node with no category wrapper', async t => { + const logger = makeLogger(); + const transformer = new NavigationTransformer(logger, defaultOptions); + const hierarchy = makeHierarchy(); + + const file = makeFile('release-notes-2024'); + + const node = await buildNode( + transformer, + makeCategoryInfo('test-category'), + hierarchy, + [file], + {}, + ); + + t.is(node?.type, 'markdown'); + t.is((node?.slug as any)?.en, 'release-notes-2024'); + // Flattened node has no children array (it's the raw document node) + t.falsy(node?.children?.length); +}); + +// ─── Case 3: multiple .md + subfolders, no categoryCover → regular category ── + +test('multiple .md + subfolders, no categoryCover → type stays category', async t => { + const logger = makeLogger(); + const transformer = new NavigationTransformer(logger, defaultOptions); + const hierarchy = makeHierarchy(); + + const files = [makeFile('overview'), makeFile('getting-started')]; + const subcatInfo = makeCategoryInfo('test-category/payments'); + + const node = await buildNode( + transformer, + makeCategoryInfo('test-category'), + hierarchy, + files, + { 'test-category/payments': subcatInfo }, + ); + + t.is(node?.type, 'category'); +}); + +// ─── Case 4: multiple .md + subfolders, one categoryCover: true → that file is cover + +test('multiple .md + subfolders, one categoryCover: true → that file becomes cover', async t => { + const logger = makeLogger(); + const transformer = new NavigationTransformer(logger, defaultOptions); + const hierarchy = makeHierarchy(); + + const files = [ + makeFile('overview', { categoryCover: true }), + makeFile('getting-started'), + ]; + const subcatInfo = makeCategoryInfo('test-category/payments'); + + const node = await buildNode( + transformer, + makeCategoryInfo('test-category'), + hierarchy, + files, + { 'test-category/payments': subcatInfo }, + ); + + t.is(node?.type, 'markdown'); + t.is((node?.slug as any)?.en, 'overview'); + // getting-started should still be in children + const childSlugs = node?.children?.map((c: any) => c?.slug?.en ?? c?.slug); + t.true(childSlugs?.some((s: string) => s === 'getting-started')); +}); + +// ─── Case 5: multiple categoryCover: true → warning, fallback to category ──── + +test('multiple categoryCover: true → warning logged, falls back to regular category', async t => { + const warnings: string[] = []; + const logger = makeLogger(warnings); + const transformer = new NavigationTransformer(logger, defaultOptions); + const hierarchy = makeHierarchy(); + + const files = [ + makeFile('overview', { categoryCover: true }), + makeFile('getting-started', { categoryCover: true }), + ]; + const subcatInfo = makeCategoryInfo('test-category/payments'); + + const node = await buildNode( + transformer, + makeCategoryInfo('test-category'), + hierarchy, + files, + { 'test-category/payments': subcatInfo }, + ); + + t.is(node?.type, 'category'); + t.true(warnings.some(w => w.includes('Multiple files with categoryCover: true'))); +}); + +// ─── Case 6: multilingual files, same slugEN → counted as one ──────────────── + +test('EN/PT/ES variants of same slugEN count as one file, not three', async t => { + const logger = makeLogger(); + const transformer = new NavigationTransformer(logger, defaultOptions); + const hierarchy = makeHierarchy(); + + // Three language versions, all with same slugEN + const files = [ + makeFile('overview', { locale: 'en' }), + makeFile('overview', { locale: 'pt' }), + makeFile('overview', { locale: 'es' }), + ]; + const subcatInfo = makeCategoryInfo('test-category/payments'); + + const node = await buildNode( + transformer, + makeCategoryInfo('test-category'), + hierarchy, + files, + { 'test-category/payments': subcatInfo }, + ); + + // Still treated as single file → auto cover + t.is(node?.type, 'markdown'); +}); + +// ─── Case 7: cover node survives mergeCategoryNodeLists and pruning ─────────── + +test('cover-backed markdown node with children is not dropped by empty-children pruning', async t => { + const logger = makeLogger(); + const transformer = new NavigationTransformer(logger, defaultOptions); + const hierarchy = makeHierarchy(); + + const coverFile = makeFile('overview'); + const subcatInfo = makeCategoryInfo('test-category/payments'); + + const node = await buildNode( + transformer, + makeCategoryInfo('test-category'), + hierarchy, + [coverFile], + { 'test-category/payments': subcatInfo }, + ); + + // Node must survive: it has type markdown + non-empty children + t.truthy(node); + t.is(node?.type, 'markdown'); + t.true(Array.isArray(node?.children)); +}); From adef14a9e41e5744197b03cf144d01ac86e56914 Mon Sep 17 00:00:00 2001 From: julia-rabello <77292838+julia-rabello@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:10:03 -0300 Subject: [PATCH 7/7] =?UTF-8?q?remove=20ava=20test=20file=20=E2=80=94=20no?= =?UTF-8?q?t=20relevant=20for=20this=20PR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../generate/transformer.cover.test.ts | 268 ------------------ 1 file changed, 268 deletions(-) delete mode 100644 source/commands/generate/transformer.cover.test.ts diff --git a/source/commands/generate/transformer.cover.test.ts b/source/commands/generate/transformer.cover.test.ts deleted file mode 100644 index c4bde30..0000000 --- a/source/commands/generate/transformer.cover.test.ts +++ /dev/null @@ -1,268 +0,0 @@ -import test from 'ava'; -import { NavigationTransformer } from './transformer.js'; -import type { CategoryHierarchy, ContentFile, GenerationOptions } from './types.js'; - -// Minimal DualLogger stub -function makeLogger(warnings: string[] = []) { - return { - info: () => {}, - debug: () => {}, - error: () => {}, - warn: (_msg: string, _ctx?: any) => { warnings.push(_msg); }, - startPhase: () => {}, - completePhase: () => {}, - log: () => {}, - setStatsUpdateCallback: () => {}, - setLogUpdateCallback: () => {}, - updateStats: () => {}, - setCurrentFile: () => {}, - incrementProcessed: () => {}, - updateLanguageStats: () => {}, - updateSectionStats: () => {}, - getStats: () => ({}), - getLogs: () => [], - close: async () => {}, - } as any; -} - -const defaultOptions: GenerationOptions = { - contentDir: '', - output: '', - validate: false, - report: false, - fix: false, - languages: ['en', 'pt', 'es'], - sections: [], - verbose: false, - interactive: false, -}; - -function makeFile(slugEN: string, opts: { categoryCover?: boolean; locale?: string } = {}): ContentFile { - const locale = opts.locale ?? 'en'; - return { - path: `/content/${locale}/${slugEN}.md`, - relativePath: `${locale}/${slugEN}.md`, - language: locale as any, - section: 'docs', - category: 'test-category', - originalCategory: 'test-category', - fileName: `${slugEN}.md`, - content: '', - metadata: { - title: slugEN.replace(/-/g, ' '), - id: slugEN, - status: 'published', - slugEN, - locale, - ...(opts.categoryCover !== undefined ? { categoryCover: opts.categoryCover } : {}), - }, - }; -} - -function makeHierarchy(): CategoryHierarchy { - return { - sections: {}, - crossLanguageMap: {}, - stats: { totalCategories: 0, totalDocuments: 0, languageCoverage: {} }, - }; -} - -function makeCategoryInfo(path: string, overrides: Record = {}) { - return { - name: { en: 'Test Category', pt: 'Categoria de Teste', es: 'Categoría de Prueba' }, - path, - level: 1, - children: { files: [], subcategories: {} }, - ...overrides, - }; -} - -// Helper: call the private buildNavigationNode -async function buildNode( - transformer: NavigationTransformer, - categoryInfo: any, - hierarchy: CategoryHierarchy, - directFiles: ContentFile[], - subcategories: Record, -) { - return (transformer as any).buildNavigationNode( - { ...categoryInfo, children: { files: directFiles, subcategories } }, - hierarchy, - new Set(), - new Map(), - [], - 'docs', - ); -} - -// ─── Case 1: single .md + subfolders → auto cover ──────────────────────────── - -test('single .md + subfolders → type becomes markdown, name/slug from cover file', async t => { - const logger = makeLogger(); - const transformer = new NavigationTransformer(logger, defaultOptions); - const hierarchy = makeHierarchy(); - - const coverFile = makeFile('overview'); - const subcatInfo = makeCategoryInfo('test-category/payments'); - - const node = await buildNode( - transformer, - makeCategoryInfo('test-category'), - hierarchy, - [coverFile], - { 'test-category/payments': subcatInfo }, - ); - - t.is(node?.type, 'markdown'); - t.is((node?.slug as any)?.en, 'overview'); - t.truthy(node?.children?.length > 0); -}); - -// ─── Case 2: single .md, no subfolders → flatten to plain markdown ──────────── - -test('single .md, no subfolders → flattened to plain markdown node with no category wrapper', async t => { - const logger = makeLogger(); - const transformer = new NavigationTransformer(logger, defaultOptions); - const hierarchy = makeHierarchy(); - - const file = makeFile('release-notes-2024'); - - const node = await buildNode( - transformer, - makeCategoryInfo('test-category'), - hierarchy, - [file], - {}, - ); - - t.is(node?.type, 'markdown'); - t.is((node?.slug as any)?.en, 'release-notes-2024'); - // Flattened node has no children array (it's the raw document node) - t.falsy(node?.children?.length); -}); - -// ─── Case 3: multiple .md + subfolders, no categoryCover → regular category ── - -test('multiple .md + subfolders, no categoryCover → type stays category', async t => { - const logger = makeLogger(); - const transformer = new NavigationTransformer(logger, defaultOptions); - const hierarchy = makeHierarchy(); - - const files = [makeFile('overview'), makeFile('getting-started')]; - const subcatInfo = makeCategoryInfo('test-category/payments'); - - const node = await buildNode( - transformer, - makeCategoryInfo('test-category'), - hierarchy, - files, - { 'test-category/payments': subcatInfo }, - ); - - t.is(node?.type, 'category'); -}); - -// ─── Case 4: multiple .md + subfolders, one categoryCover: true → that file is cover - -test('multiple .md + subfolders, one categoryCover: true → that file becomes cover', async t => { - const logger = makeLogger(); - const transformer = new NavigationTransformer(logger, defaultOptions); - const hierarchy = makeHierarchy(); - - const files = [ - makeFile('overview', { categoryCover: true }), - makeFile('getting-started'), - ]; - const subcatInfo = makeCategoryInfo('test-category/payments'); - - const node = await buildNode( - transformer, - makeCategoryInfo('test-category'), - hierarchy, - files, - { 'test-category/payments': subcatInfo }, - ); - - t.is(node?.type, 'markdown'); - t.is((node?.slug as any)?.en, 'overview'); - // getting-started should still be in children - const childSlugs = node?.children?.map((c: any) => c?.slug?.en ?? c?.slug); - t.true(childSlugs?.some((s: string) => s === 'getting-started')); -}); - -// ─── Case 5: multiple categoryCover: true → warning, fallback to category ──── - -test('multiple categoryCover: true → warning logged, falls back to regular category', async t => { - const warnings: string[] = []; - const logger = makeLogger(warnings); - const transformer = new NavigationTransformer(logger, defaultOptions); - const hierarchy = makeHierarchy(); - - const files = [ - makeFile('overview', { categoryCover: true }), - makeFile('getting-started', { categoryCover: true }), - ]; - const subcatInfo = makeCategoryInfo('test-category/payments'); - - const node = await buildNode( - transformer, - makeCategoryInfo('test-category'), - hierarchy, - files, - { 'test-category/payments': subcatInfo }, - ); - - t.is(node?.type, 'category'); - t.true(warnings.some(w => w.includes('Multiple files with categoryCover: true'))); -}); - -// ─── Case 6: multilingual files, same slugEN → counted as one ──────────────── - -test('EN/PT/ES variants of same slugEN count as one file, not three', async t => { - const logger = makeLogger(); - const transformer = new NavigationTransformer(logger, defaultOptions); - const hierarchy = makeHierarchy(); - - // Three language versions, all with same slugEN - const files = [ - makeFile('overview', { locale: 'en' }), - makeFile('overview', { locale: 'pt' }), - makeFile('overview', { locale: 'es' }), - ]; - const subcatInfo = makeCategoryInfo('test-category/payments'); - - const node = await buildNode( - transformer, - makeCategoryInfo('test-category'), - hierarchy, - files, - { 'test-category/payments': subcatInfo }, - ); - - // Still treated as single file → auto cover - t.is(node?.type, 'markdown'); -}); - -// ─── Case 7: cover node survives mergeCategoryNodeLists and pruning ─────────── - -test('cover-backed markdown node with children is not dropped by empty-children pruning', async t => { - const logger = makeLogger(); - const transformer = new NavigationTransformer(logger, defaultOptions); - const hierarchy = makeHierarchy(); - - const coverFile = makeFile('overview'); - const subcatInfo = makeCategoryInfo('test-category/payments'); - - const node = await buildNode( - transformer, - makeCategoryInfo('test-category'), - hierarchy, - [coverFile], - { 'test-category/payments': subcatInfo }, - ); - - // Node must survive: it has type markdown + non-empty children - t.truthy(node); - t.is(node?.type, 'markdown'); - t.true(Array.isArray(node?.children)); -});