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 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.ts b/source/commands/generate/transformer.ts index d3036c1..22d528c 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,76 @@ export class NavigationTransformer { sectionName ); + // 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; + let categorySlug = slug; + let hasCover = false; + + 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(0, 1); + } + } else if (hasSubcats) { + // 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, slugs: [...markedSlugs] } + ); + } else if (markedSlugs.size === 1) { + const coverSlugEN = [...markedSlugs][0]; + const coverNodeIdx = documentNodes.findIndex( + n => n.type === 'markdown' && (n.slug as any)?.en === coverSlugEN + ); + 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) 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 }); @@ -627,8 +681,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)) { @@ -664,7 +721,7 @@ export class NavigationTransformer { } } - return Array.from(bySlug.values()); + return [...Array.from(bySlug.values()), ...markdownNodes]; } private generateLocalizedCategorySlugs( 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..5610937 100644 --- a/source/schemas/navigation.schema.json +++ b/source/schemas/navigation.schema.json @@ -100,8 +100,7 @@ "if": { "properties": { "type": { "const": "markdown" } } }, "then": { "properties": { - "slug": { "$ref": "#/definitions/localizedString" }, - "children": { "type": "array", "maxItems": 0 } + "slug": { "$ref": "#/definitions/localizedString" } } } }