create-eziwiki 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +46 -0
  2. package/bin/create-eziwiki.mjs +83 -0
  3. package/lib/scaffold.mjs +230 -0
  4. package/lib/scaffold.test.mjs +270 -0
  5. package/package.json +38 -0
  6. package/template/README.md +39 -0
  7. package/template/app/[...slug]/page.tsx +162 -0
  8. package/template/app/error.tsx +77 -0
  9. package/template/app/global-error.tsx +76 -0
  10. package/template/app/globals.css +44 -0
  11. package/template/app/graph/page.tsx +62 -0
  12. package/template/app/layout.tsx +164 -0
  13. package/template/app/not-found.tsx +48 -0
  14. package/template/app/page.tsx +34 -0
  15. package/template/app/robots.ts +20 -0
  16. package/template/app/sitemap.ts +48 -0
  17. package/template/components/ThemeToggle.tsx +77 -0
  18. package/template/components/graph/GraphView.tsx +156 -0
  19. package/template/components/layout/Backlinks.tsx +42 -0
  20. package/template/components/layout/Breadcrumb.tsx +88 -0
  21. package/template/components/layout/MobileMenu.tsx +299 -0
  22. package/template/components/layout/NavigationButtons.tsx +87 -0
  23. package/template/components/layout/PageLayout.tsx +89 -0
  24. package/template/components/layout/Sidebar.tsx +376 -0
  25. package/template/components/layout/TabBar.tsx +312 -0
  26. package/template/components/layout/TabBarSkeleton.tsx +12 -0
  27. package/template/components/layout/TabInitializer.tsx +99 -0
  28. package/template/components/layout/TableOfContents.tsx +138 -0
  29. package/template/components/markdown/CodeCopy.tsx +65 -0
  30. package/template/components/markdown/MarkdownContent.tsx +38 -0
  31. package/template/components/markdown/PageTransition.tsx +56 -0
  32. package/template/components/providers/UrlMapProvider.tsx +68 -0
  33. package/template/components/search/SearchDialog.tsx +286 -0
  34. package/template/components/search/SearchTrigger.tsx +41 -0
  35. package/template/content/guides/_meta.json +4 -0
  36. package/template/content/guides/writing.md +82 -0
  37. package/template/content/intro.md +29 -0
  38. package/template/eslintignore +7 -0
  39. package/template/eslintrc.js +40 -0
  40. package/template/gitignore +40 -0
  41. package/template/lib/basePath.test.ts +120 -0
  42. package/template/lib/basePath.ts +108 -0
  43. package/template/lib/cache.ts +36 -0
  44. package/template/lib/content/registry.ts +311 -0
  45. package/template/lib/content/resolver.ts +109 -0
  46. package/template/lib/graph/build.ts +214 -0
  47. package/template/lib/graph/layout.test.ts +189 -0
  48. package/template/lib/graph/layout.ts +247 -0
  49. package/template/lib/markdown/languages.test.ts +85 -0
  50. package/template/lib/markdown/languages.ts +103 -0
  51. package/template/lib/markdown/rehype-plugins.ts +240 -0
  52. package/template/lib/markdown/remark-wikilink.ts +141 -0
  53. package/template/lib/markdown/render.ts +175 -0
  54. package/template/lib/markdown/wikilink.test.ts +91 -0
  55. package/template/lib/markdown/wikilink.ts +85 -0
  56. package/template/lib/navigation/auto.ts +227 -0
  57. package/template/lib/navigation/builder.test.ts +129 -0
  58. package/template/lib/navigation/builder.ts +122 -0
  59. package/template/lib/navigation/hash.ts +32 -0
  60. package/template/lib/navigation/url.test.ts +88 -0
  61. package/template/lib/navigation/url.ts +108 -0
  62. package/template/lib/navigation/urlMap.ts +81 -0
  63. package/template/lib/payload/schema.ts +81 -0
  64. package/template/lib/payload/types.ts +105 -0
  65. package/template/lib/payload/validator.ts +56 -0
  66. package/template/lib/search/build.ts +204 -0
  67. package/template/lib/search/client.ts +190 -0
  68. package/template/lib/search/tokenizer.test.ts +60 -0
  69. package/template/lib/search/tokenizer.ts +83 -0
  70. package/template/lib/search/types.ts +40 -0
  71. package/template/lib/site.ts +86 -0
  72. package/template/lib/store/searchStore.ts +26 -0
  73. package/template/lib/store/tabStore.ts +313 -0
  74. package/template/next-env.d.ts +5 -0
  75. package/template/next.config.js +43 -0
  76. package/template/package-lock.json +9933 -0
  77. package/template/package.json +69 -0
  78. package/template/payload/config.ts +34 -0
  79. package/template/postcss.config.js +6 -0
  80. package/template/prettierignore +7 -0
  81. package/template/prettierrc +8 -0
  82. package/template/public/favicon.svg +10 -0
  83. package/template/public/fonts/Pretandard/Pretendard-Bold.woff2 +0 -0
  84. package/template/public/fonts/Pretandard/Pretendard-Regular.woff2 +0 -0
  85. package/template/public/fonts/Pretandard/Pretendard-SemiBold.woff2 +0 -0
  86. package/template/public/fonts/SUITE/SUITE-Bold.woff2 +0 -0
  87. package/template/public/fonts/SUITE/SUITE-Regular.woff2 +0 -0
  88. package/template/public/fonts/SUITE/SUITE-SemiBold.woff2 +0 -0
  89. package/template/public/images/.gitkeep +0 -0
  90. package/template/scripts/build-search-index.ts +36 -0
  91. package/template/scripts/check-links.ts +40 -0
  92. package/template/scripts/show-urls.ts +48 -0
  93. package/template/scripts/validate-payload.ts +30 -0
  94. package/template/styles/markdown.css +167 -0
  95. package/template/styles/theme.css +65 -0
  96. package/template/tailwind.config.ts +156 -0
  97. package/template/tsconfig.json +32 -0
  98. package/template/vitest.config.ts +30 -0
@@ -0,0 +1,175 @@
1
+ import { unified, type Processor } from 'unified';
2
+ import remarkParse from 'remark-parse';
3
+ import remarkGfm from 'remark-gfm';
4
+ import remarkMath from 'remark-math';
5
+ import remarkRehype from 'remark-rehype';
6
+ import rehypeRaw from 'rehype-raw';
7
+ import rehypeSlug from 'rehype-slug';
8
+ import rehypeKatex from 'rehype-katex';
9
+ import rehypeShiki from '@shikijs/rehype';
10
+ import rehypeStringify from 'rehype-stringify';
11
+ import {
12
+ rehypeBasePath,
13
+ rehypeCodeShell,
14
+ rehypeCollectHeadings,
15
+ rehypeImages,
16
+ rehypeInternalLinks,
17
+ type Heading,
18
+ } from './rehype-plugins';
19
+ import { remarkWikiLinks, type WikiLinkTarget } from './remark-wikilink';
20
+ import { getUsedLanguages } from './languages';
21
+ import { cached } from '../cache';
22
+ import { BASE_PATH } from '../basePath';
23
+ import { getUrlMap } from '../navigation/urlMap';
24
+ import { getDoc } from '../content/registry';
25
+ import { resolveTarget } from '../content/resolver';
26
+ import { docPathToUrl } from '../navigation/url';
27
+
28
+ /**
29
+ * Build-time Markdown rendering.
30
+ *
31
+ * Markdown is compiled to HTML once, during the build, instead of being parsed
32
+ * in the browser on every page view. The browser receives finished markup, so
33
+ * neither the Markdown parser nor the syntax highlighter ships to the client.
34
+ *
35
+ * Server-only: this module reads the content registry and the URL map.
36
+ */
37
+
38
+ /** A rendered document: finished markup plus everything derived along the way. */
39
+ export interface RenderedMarkdown {
40
+ /** Serialised HTML, ready for `dangerouslySetInnerHTML` */
41
+ html: string;
42
+ /** Headings collected for the table of contents */
43
+ headings: Heading[];
44
+ }
45
+
46
+ /** Syntax highlighting themes, applied as CSS variables for light and dark. */
47
+ const SHIKI_THEMES = { light: 'github-light', dark: 'github-dark' } as const;
48
+
49
+ let processor: Processor | null = null;
50
+
51
+ /**
52
+ * Resolves a wiki-link target to a destination in this site.
53
+ *
54
+ * @param target - Raw target text from inside the brackets
55
+ * @returns The destination, or null when the target does not resolve
56
+ */
57
+ function resolveWikiLink(target: string): WikiLinkTarget | null {
58
+ const { doc } = resolveTarget(target);
59
+ if (!doc) return null;
60
+
61
+ const url = docPathToUrl(getUrlMap(), doc.path);
62
+ return url ? { url: `/${url}`, title: doc.title } : null;
63
+ }
64
+
65
+ /**
66
+ * Builds the shared unified processor.
67
+ *
68
+ * Plugin order is load-bearing:
69
+ * - `remarkWikiLinks` must run while the tree is still Markdown, so the links
70
+ * it produces are processed like any other link downstream.
71
+ * - `rehype-raw` must follow `remark-rehype` with `allowDangerousHtml`, so that
72
+ * inline HTML in Markdown is parsed rather than escaped.
73
+ * - `rehype-slug` must precede heading collection, which reads the ids it adds.
74
+ * - `rehypeCodeShell` must precede the highlighter, since it reads the
75
+ * `language-*` class that highlighting replaces.
76
+ * - `rehypeBasePath` runs last among the link plugins, so it prefixes the
77
+ * already-resolved internal hrefs rather than the authored ones.
78
+ */
79
+ function createProcessor(): Processor {
80
+ return unified()
81
+ .use(remarkParse)
82
+ .use(remarkGfm)
83
+ .use(remarkMath)
84
+ .use(remarkWikiLinks, resolveWikiLink)
85
+ .use(remarkRehype, { allowDangerousHtml: true })
86
+ .use(rehypeRaw)
87
+ .use(rehypeSlug)
88
+ .use(rehypeCollectHeadings)
89
+ .use(rehypeKatex)
90
+ .use(rehypeInternalLinks, getUrlMap())
91
+ .use(rehypeImages)
92
+ .use(rehypeCodeShell)
93
+ .use(rehypeShiki, {
94
+ themes: SHIKI_THEMES,
95
+ defaultColor: false,
96
+ cssVariablePrefix: '--shiki-',
97
+ fallbackLanguage: 'text',
98
+ // Without this Shiki loads every bundled grammar, which costs tens of
99
+ // seconds before the first page renders.
100
+ langs: getUsedLanguages(),
101
+ })
102
+ .use(rehypeBasePath, BASE_PATH)
103
+ .use(rehypeStringify, { allowDangerousHtml: true }) as unknown as Processor;
104
+ }
105
+
106
+ /** Language set the current processor was built with. */
107
+ let processorLangs = '';
108
+
109
+ /**
110
+ * Returns the shared processor, creating it on first use.
111
+ *
112
+ * Shiki loads its grammars and themes when the plugin is first applied; a
113
+ * per-page processor would repeat that work for every document in the site.
114
+ *
115
+ * The grammar list is fixed when the processor is constructed, so it is
116
+ * rebuilt if the set of languages the content uses changes — otherwise adding
117
+ * a code block in a new language during `next dev` would render unhighlighted
118
+ * until the server was restarted.
119
+ */
120
+ function getProcessor(): Processor {
121
+ const langs = getUsedLanguages().join(',');
122
+
123
+ if (!processor || langs !== processorLangs) {
124
+ processor = createProcessor();
125
+ processorLangs = langs;
126
+ }
127
+
128
+ return processor;
129
+ }
130
+
131
+ /**
132
+ * Compiles a Markdown string to HTML and extracts its headings.
133
+ *
134
+ * @param markdown - Markdown source, with frontmatter already stripped
135
+ * @returns The rendered HTML and the headings found in it
136
+ *
137
+ * @example
138
+ * ```typescript
139
+ * const { html, headings } = await renderMarkdown('## Setup\n\nRun `npm i`.');
140
+ * headings; // [{ id: 'setup', text: 'Setup', depth: 2 }]
141
+ * ```
142
+ */
143
+ export async function renderMarkdown(markdown: string): Promise<RenderedMarkdown> {
144
+ const file = await getProcessor().process(markdown);
145
+
146
+ return {
147
+ html: String(file),
148
+ headings: (file.data.headings as Heading[] | undefined) ?? [],
149
+ };
150
+ }
151
+
152
+ const cache = new Map<string, RenderedMarkdown>();
153
+
154
+ /**
155
+ * Renders a document from the content registry, memoised by path.
156
+ *
157
+ * A page's metadata, its body, and its table of contents are produced by
158
+ * separate calls in the Next.js render lifecycle; caching keeps a document from
159
+ * being compiled several times per build.
160
+ *
161
+ * @param docPath - Content-relative path without extension
162
+ * @returns The rendered document, or null if no such document exists
163
+ */
164
+ export async function renderDoc(docPath: string): Promise<RenderedMarkdown | null> {
165
+ const hit = cached(cache.get(docPath) ?? null);
166
+ if (hit) return hit;
167
+
168
+ const doc = getDoc(docPath);
169
+ if (!doc) return null;
170
+
171
+ const rendered = await renderMarkdown(doc.content);
172
+ cache.set(docPath, rendered);
173
+
174
+ return rendered;
175
+ }
@@ -0,0 +1,91 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { findWikiLinks, parseWikiLink } from './wikilink';
3
+
4
+ describe('parseWikiLink', () => {
5
+ const parse = (inner: string) => parseWikiLink(inner, `[[${inner}]]`);
6
+
7
+ it('parses a bare target', () => {
8
+ expect(parse('guides/setup')).toMatchObject({
9
+ target: 'guides/setup',
10
+ anchor: undefined,
11
+ label: undefined,
12
+ });
13
+ });
14
+
15
+ it('parses a label after a pipe', () => {
16
+ expect(parse('guides/setup|Setup Guide')).toMatchObject({
17
+ target: 'guides/setup',
18
+ label: 'Setup Guide',
19
+ });
20
+ });
21
+
22
+ it('parses an anchor', () => {
23
+ expect(parse('guides/setup#step-1')).toMatchObject({
24
+ target: 'guides/setup',
25
+ anchor: 'step-1',
26
+ });
27
+ });
28
+
29
+ it('parses an anchor and a label together', () => {
30
+ expect(parse('guides/setup#step-1|Step one')).toMatchObject({
31
+ target: 'guides/setup',
32
+ anchor: 'step-1',
33
+ label: 'Step one',
34
+ });
35
+ });
36
+
37
+ it('treats a hash in the label as part of the label', () => {
38
+ expect(parse('setup|Step #1')).toMatchObject({
39
+ target: 'setup',
40
+ anchor: undefined,
41
+ label: 'Step #1',
42
+ });
43
+ });
44
+
45
+ it('keeps a pipe inside the label', () => {
46
+ expect(parse('setup|a | b')).toMatchObject({ target: 'setup', label: 'a | b' });
47
+ });
48
+
49
+ it('trims surrounding whitespace', () => {
50
+ expect(parse(' setup | Label ')).toMatchObject({ target: 'setup', label: 'Label' });
51
+ });
52
+
53
+ it('allows an anchor-only link with no target', () => {
54
+ expect(parse('#section')).toMatchObject({ target: '', anchor: 'section' });
55
+ });
56
+
57
+ it('returns null when there is neither target nor anchor', () => {
58
+ expect(parse('')).toBeNull();
59
+ expect(parse(' ')).toBeNull();
60
+ expect(parse('|only a label')).toBeNull();
61
+ });
62
+
63
+ it('treats an empty label as absent', () => {
64
+ expect(parse('setup|')).toMatchObject({ target: 'setup', label: undefined });
65
+ });
66
+ });
67
+
68
+ describe('findWikiLinks', () => {
69
+ it('finds every link in a string', () => {
70
+ const links = findWikiLinks('See [[a]] and [[b|Bee]] today.');
71
+
72
+ expect(links.map((link) => link.target)).toEqual(['a', 'b']);
73
+ expect(links[1].label).toBe('Bee');
74
+ });
75
+
76
+ it('returns nothing when there are no links', () => {
77
+ expect(findWikiLinks('plain text')).toEqual([]);
78
+ });
79
+
80
+ it('does not match across a newline', () => {
81
+ expect(findWikiLinks('[[broken\nlink]]')).toEqual([]);
82
+ });
83
+
84
+ it('does not let an unterminated bracket swallow the rest', () => {
85
+ expect(findWikiLinks('[[unclosed and then some text')).toEqual([]);
86
+ });
87
+
88
+ it('captures the raw source of each match', () => {
89
+ expect(findWikiLinks('x [[a|B]] y')[0].raw).toBe('[[a|B]]');
90
+ });
91
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Parsing for `[[wiki link]]` syntax.
3
+ *
4
+ * Kept free of any filesystem or registry access so it can be unit-tested and
5
+ * reused by both the renderer and the link-graph scanner.
6
+ */
7
+
8
+ /**
9
+ * Matches a wiki link and captures its contents.
10
+ *
11
+ * Deliberately refuses `]` and newlines inside the brackets: an unterminated
12
+ * `[[` should stay literal text rather than swallowing the rest of a paragraph.
13
+ */
14
+ export const WIKILINK_PATTERN = /\[\[([^\]\n]+)\]\]/g;
15
+
16
+ /** The parts of a wiki link. */
17
+ export interface WikiLink {
18
+ /** Document being linked to, before the anchor */
19
+ target: string;
20
+ /** In-page anchor, without the leading '#' */
21
+ anchor?: string;
22
+ /** Display text, when the author supplied one after '|' */
23
+ label?: string;
24
+ /** The full matched source, e.g. '[[guide#setup|Setup]]' */
25
+ raw: string;
26
+ }
27
+
28
+ /**
29
+ * Parses the inside of a wiki link.
30
+ *
31
+ * Supported forms:
32
+ * - `[[target]]`
33
+ * - `[[target|label]]`
34
+ * - `[[target#anchor]]`
35
+ * - `[[target#anchor|label]]`
36
+ *
37
+ * The label is split off first, so a `|` inside it is preserved and a `#` in
38
+ * the label is not mistaken for an anchor.
39
+ *
40
+ * @param inner - Text between the brackets
41
+ * @param raw - The full matched source, stored on the result
42
+ * @returns The parsed link, or null when the target is empty
43
+ *
44
+ * @example
45
+ * ```typescript
46
+ * parseWikiLink('guides/setup#step-1|Step one', '[[guides/setup#step-1|Step one]]');
47
+ * // { target: 'guides/setup', anchor: 'step-1', label: 'Step one', raw: '...' }
48
+ * ```
49
+ */
50
+ export function parseWikiLink(inner: string, raw: string): WikiLink | null {
51
+ const pipe = inner.indexOf('|');
52
+ const label = pipe === -1 ? undefined : inner.slice(pipe + 1).trim();
53
+ const locator = (pipe === -1 ? inner : inner.slice(0, pipe)).trim();
54
+
55
+ const hash = locator.indexOf('#');
56
+ const target = (hash === -1 ? locator : locator.slice(0, hash)).trim();
57
+ const anchor = hash === -1 ? undefined : locator.slice(hash + 1).trim() || undefined;
58
+
59
+ // An anchor-only link such as [[#section]] refers within the current page.
60
+ if (!target && !anchor) return null;
61
+
62
+ return {
63
+ target,
64
+ anchor,
65
+ label: label || undefined,
66
+ raw,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Finds every wiki link in a string.
72
+ *
73
+ * @param text - Text to scan
74
+ * @returns Parsed links, in order of appearance
75
+ */
76
+ export function findWikiLinks(text: string): WikiLink[] {
77
+ const links: WikiLink[] = [];
78
+
79
+ for (const match of text.matchAll(WIKILINK_PATTERN)) {
80
+ const parsed = parseWikiLink(match[1], match[0]);
81
+ if (parsed) links.push(parsed);
82
+ }
83
+
84
+ return links;
85
+ }
@@ -0,0 +1,227 @@
1
+ import { NavigationItem } from '../payload/types';
2
+ import { getContentRegistry, titleize, type ContentDoc, type DirMeta } from '../content/registry';
3
+ import { extractAllPaths } from './builder';
4
+ import { cached } from '../cache';
5
+
6
+ /**
7
+ * Filesystem-derived navigation.
8
+ *
9
+ * The curated tree in `payload/config.ts` stays authoritative for naming and
10
+ * ordering, but it no longer has to be exhaustive: any document under
11
+ * `content/` that the curated tree does not mention is discovered here and
12
+ * appended to the section matching its directory. Adding a Markdown file is
13
+ * therefore enough to publish it.
14
+ *
15
+ * This module reads the content registry and must only run on the server.
16
+ */
17
+
18
+ /** Sort weight applied to directories without an explicit `order`. */
19
+ const DEFAULT_DIR_ORDER = Number.MAX_SAFE_INTEGER;
20
+
21
+ /**
22
+ * Returns the parent directory of a content directory path.
23
+ *
24
+ * @param dir - Directory path relative to `content/`
25
+ * @returns The parent directory, or '' for a top-level directory
26
+ */
27
+ function parentDir(dir: string): string {
28
+ const index = dir.lastIndexOf('/');
29
+ return index === -1 ? '' : dir.slice(0, index);
30
+ }
31
+
32
+ /**
33
+ * Deep-clones a navigation tree so merging never mutates the payload config.
34
+ *
35
+ * The payload is a module-level constant shared across every rendered page;
36
+ * appending discovered documents to it in place would compound the tree on
37
+ * each render during a dev session.
38
+ */
39
+ function cloneTree(items: NavigationItem[]): NavigationItem[] {
40
+ return items.map((item) => ({
41
+ ...item,
42
+ children: item.children ? cloneTree(item.children) : undefined,
43
+ }));
44
+ }
45
+
46
+ /**
47
+ * Maps content directories to the curated section that already represents them.
48
+ *
49
+ * A curated section does not declare which directory it covers, so ownership is
50
+ * inferred from its descendants: a section whose documents all live under
51
+ * `getting-started/` is taken to own that directory. Sections spanning several
52
+ * directories are left unmapped, since appending to them would be a guess.
53
+ *
54
+ * @param items - Curated navigation tree
55
+ * @returns Directory path to the owning navigation node
56
+ */
57
+ function indexSectionsByDir(items: NavigationItem[]): Map<string, NavigationItem> {
58
+ const sections = new Map<string, NavigationItem>();
59
+
60
+ function visit(node: NavigationItem): Set<string> {
61
+ const dirs = new Set<string>();
62
+
63
+ if (node.path) {
64
+ const index = node.path.lastIndexOf('/');
65
+ dirs.add(index === -1 ? '' : node.path.slice(0, index));
66
+ }
67
+
68
+ for (const child of node.children ?? []) {
69
+ for (const dir of visit(child)) dirs.add(dir);
70
+ }
71
+
72
+ // Only claim ownership when the section is unambiguous.
73
+ if (node.children && dirs.size === 1) {
74
+ const [dir] = Array.from(dirs);
75
+ if (dir && !sections.has(dir)) {
76
+ sections.set(dir, node);
77
+ }
78
+ }
79
+
80
+ return dirs;
81
+ }
82
+
83
+ for (const item of items) visit(item);
84
+ return sections;
85
+ }
86
+
87
+ /**
88
+ * The sort weight a document contributes to its position at the top level.
89
+ *
90
+ * For a document inside a folder this is the folder's `_meta.json` order, since
91
+ * the document's own order only ranks it among its siblings. For a root-level
92
+ * document there is no folder, so its own `order` serves — which is what lets a
93
+ * single sequence of numbers interleave root pages and sections, rather than
94
+ * root pages always landing after every folder.
95
+ */
96
+ function sectionOrder(doc: ContentDoc, dirOrder: (dir: string) => number): number {
97
+ return doc.dir === '' ? doc.order : dirOrder(doc.dir);
98
+ }
99
+
100
+ /**
101
+ * Orders discovered documents so that appended entries land predictably.
102
+ *
103
+ * Documents are grouped by the section they belong to and those sections
104
+ * ordered first, so that a section is created at the right position the moment
105
+ * its first document is appended. Within a section, documents follow their own
106
+ * frontmatter order.
107
+ */
108
+ function compareOrphans(a: ContentDoc, b: ContentDoc, dirOrder: (dir: string) => number): number {
109
+ const aSection = sectionOrder(a, dirOrder);
110
+ const bSection = sectionOrder(b, dirOrder);
111
+ if (aSection !== bSection) return aSection - bSection;
112
+
113
+ if (a.dir !== b.dir) return a.dir.localeCompare(b.dir);
114
+ if (a.order !== b.order) return a.order - b.order;
115
+ return a.title.localeCompare(b.title);
116
+ }
117
+
118
+ /**
119
+ * Builds a navigation node for a discovered document.
120
+ */
121
+ function docToNavItem(doc: ContentDoc): NavigationItem {
122
+ const item: NavigationItem = {
123
+ name: doc.title,
124
+ path: doc.path,
125
+ };
126
+
127
+ if (typeof doc.frontmatter.icon === 'string') item.icon = doc.frontmatter.icon;
128
+ if (typeof doc.frontmatter.color === 'string') item.color = doc.frontmatter.color;
129
+
130
+ return item;
131
+ }
132
+
133
+ /**
134
+ * Builds a navigation section node for a content directory.
135
+ */
136
+ function dirToNavItem(dir: string, meta: DirMeta): NavigationItem {
137
+ const name = meta.name ?? titleize(dir.slice(dir.lastIndexOf('/') + 1));
138
+ const item: NavigationItem = { name, children: [] };
139
+
140
+ if (meta.icon) item.icon = meta.icon;
141
+ if (meta.color) item.color = meta.color;
142
+ if (meta.hidden) item.hidden = true;
143
+
144
+ return item;
145
+ }
146
+
147
+ /**
148
+ * Merges curated navigation with documents discovered under `content/`.
149
+ *
150
+ * Curated entries are preserved exactly as written. Every document not already
151
+ * referenced — and not marked `hidden` in its frontmatter — is appended to the
152
+ * section covering its directory, creating that section (and any missing
153
+ * ancestors) when necessary.
154
+ *
155
+ * @param curated - Navigation from the payload config; may be empty
156
+ * @returns The merged navigation tree
157
+ *
158
+ * @example
159
+ * ```typescript
160
+ * // content/guides/advanced.md exists but is absent from the payload
161
+ * const nav = mergeDiscoveredDocs(payload.navigation ?? []);
162
+ * // The 'Guides' section now includes an 'Advanced' entry.
163
+ * ```
164
+ */
165
+ export function mergeDiscoveredDocs(curated: NavigationItem[]): NavigationItem[] {
166
+ const { docs, dirMeta } = getContentRegistry();
167
+ const root = cloneTree(curated);
168
+
169
+ const referenced = new Set(extractAllPaths(root));
170
+ const orphans = docs.filter((doc) => !referenced.has(doc.path) && !doc.hidden);
171
+
172
+ if (orphans.length === 0) return root;
173
+
174
+ const sections = indexSectionsByDir(root);
175
+ const dirOrder = (dir: string) => dirMeta.get(dir)?.order ?? DEFAULT_DIR_ORDER;
176
+
177
+ /**
178
+ * Returns the children array that documents in `dir` should be appended to,
179
+ * creating the section chain if it does not exist yet.
180
+ */
181
+ function childrenFor(dir: string): NavigationItem[] {
182
+ if (!dir) return root;
183
+
184
+ const existing = sections.get(dir);
185
+ if (existing) {
186
+ existing.children ??= [];
187
+ return existing.children;
188
+ }
189
+
190
+ const node = dirToNavItem(dir, dirMeta.get(dir) ?? {});
191
+ childrenFor(parentDir(dir)).push(node);
192
+ sections.set(dir, node);
193
+
194
+ return node.children!;
195
+ }
196
+
197
+ for (const doc of [...orphans].sort((a, b) => compareOrphans(a, b, dirOrder))) {
198
+ childrenFor(doc.dir).push(docToNavItem(doc));
199
+ }
200
+
201
+ return root;
202
+ }
203
+
204
+ let memo: NavigationItem[] | null = null;
205
+
206
+ /**
207
+ * Returns the site navigation, memoised per process.
208
+ *
209
+ * When `autoNavigation` is disabled in the payload, the curated tree is
210
+ * returned untouched; otherwise discovered documents are merged in.
211
+ *
212
+ * @param curated - Navigation from the payload config
213
+ * @param autoNavigation - Whether to append discovered documents (default true)
214
+ * @returns The navigation tree to render
215
+ */
216
+ export function getNavigation(
217
+ curated: NavigationItem[] | undefined,
218
+ autoNavigation = true,
219
+ ): NavigationItem[] {
220
+ const hit = cached(memo);
221
+ if (hit) return hit;
222
+
223
+ const base = curated ?? [];
224
+ memo = autoNavigation ? mergeDiscoveredDocs(base) : cloneTree(base);
225
+
226
+ return memo;
227
+ }