Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,14 +65,48 @@ 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

- 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
Expand Down
226 changes: 226 additions & 0 deletions run-cover-tests.mjs
Original file line number Diff line number Diff line change
@@ -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);
79 changes: 68 additions & 11 deletions source/commands/generate/transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -664,7 +721,7 @@ export class NavigationTransformer {
}
}

return Array.from(bySlug.values());
return [...Array.from(bySlug.values()), ...markdownNodes];
}

private generateLocalizedCategorySlugs(
Expand Down
Loading