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.
- package/README.md +46 -0
- package/bin/create-eziwiki.mjs +83 -0
- package/lib/scaffold.mjs +230 -0
- package/lib/scaffold.test.mjs +270 -0
- package/package.json +38 -0
- package/template/README.md +39 -0
- package/template/app/[...slug]/page.tsx +162 -0
- package/template/app/error.tsx +77 -0
- package/template/app/global-error.tsx +76 -0
- package/template/app/globals.css +44 -0
- package/template/app/graph/page.tsx +62 -0
- package/template/app/layout.tsx +164 -0
- package/template/app/not-found.tsx +48 -0
- package/template/app/page.tsx +34 -0
- package/template/app/robots.ts +20 -0
- package/template/app/sitemap.ts +48 -0
- package/template/components/ThemeToggle.tsx +77 -0
- package/template/components/graph/GraphView.tsx +156 -0
- package/template/components/layout/Backlinks.tsx +42 -0
- package/template/components/layout/Breadcrumb.tsx +88 -0
- package/template/components/layout/MobileMenu.tsx +299 -0
- package/template/components/layout/NavigationButtons.tsx +87 -0
- package/template/components/layout/PageLayout.tsx +89 -0
- package/template/components/layout/Sidebar.tsx +376 -0
- package/template/components/layout/TabBar.tsx +312 -0
- package/template/components/layout/TabBarSkeleton.tsx +12 -0
- package/template/components/layout/TabInitializer.tsx +99 -0
- package/template/components/layout/TableOfContents.tsx +138 -0
- package/template/components/markdown/CodeCopy.tsx +65 -0
- package/template/components/markdown/MarkdownContent.tsx +38 -0
- package/template/components/markdown/PageTransition.tsx +56 -0
- package/template/components/providers/UrlMapProvider.tsx +68 -0
- package/template/components/search/SearchDialog.tsx +286 -0
- package/template/components/search/SearchTrigger.tsx +41 -0
- package/template/content/guides/_meta.json +4 -0
- package/template/content/guides/writing.md +82 -0
- package/template/content/intro.md +29 -0
- package/template/eslintignore +7 -0
- package/template/eslintrc.js +40 -0
- package/template/gitignore +40 -0
- package/template/lib/basePath.test.ts +120 -0
- package/template/lib/basePath.ts +108 -0
- package/template/lib/cache.ts +36 -0
- package/template/lib/content/registry.ts +311 -0
- package/template/lib/content/resolver.ts +109 -0
- package/template/lib/graph/build.ts +214 -0
- package/template/lib/graph/layout.test.ts +189 -0
- package/template/lib/graph/layout.ts +247 -0
- package/template/lib/markdown/languages.test.ts +85 -0
- package/template/lib/markdown/languages.ts +103 -0
- package/template/lib/markdown/rehype-plugins.ts +240 -0
- package/template/lib/markdown/remark-wikilink.ts +141 -0
- package/template/lib/markdown/render.ts +175 -0
- package/template/lib/markdown/wikilink.test.ts +91 -0
- package/template/lib/markdown/wikilink.ts +85 -0
- package/template/lib/navigation/auto.ts +227 -0
- package/template/lib/navigation/builder.test.ts +129 -0
- package/template/lib/navigation/builder.ts +122 -0
- package/template/lib/navigation/hash.ts +32 -0
- package/template/lib/navigation/url.test.ts +88 -0
- package/template/lib/navigation/url.ts +108 -0
- package/template/lib/navigation/urlMap.ts +81 -0
- package/template/lib/payload/schema.ts +81 -0
- package/template/lib/payload/types.ts +105 -0
- package/template/lib/payload/validator.ts +56 -0
- package/template/lib/search/build.ts +204 -0
- package/template/lib/search/client.ts +190 -0
- package/template/lib/search/tokenizer.test.ts +60 -0
- package/template/lib/search/tokenizer.ts +83 -0
- package/template/lib/search/types.ts +40 -0
- package/template/lib/site.ts +86 -0
- package/template/lib/store/searchStore.ts +26 -0
- package/template/lib/store/tabStore.ts +313 -0
- package/template/next-env.d.ts +5 -0
- package/template/next.config.js +43 -0
- package/template/package-lock.json +9933 -0
- package/template/package.json +69 -0
- package/template/payload/config.ts +34 -0
- package/template/postcss.config.js +6 -0
- package/template/prettierignore +7 -0
- package/template/prettierrc +8 -0
- package/template/public/favicon.svg +10 -0
- package/template/public/fonts/Pretandard/Pretendard-Bold.woff2 +0 -0
- package/template/public/fonts/Pretandard/Pretendard-Regular.woff2 +0 -0
- package/template/public/fonts/Pretandard/Pretendard-SemiBold.woff2 +0 -0
- package/template/public/fonts/SUITE/SUITE-Bold.woff2 +0 -0
- package/template/public/fonts/SUITE/SUITE-Regular.woff2 +0 -0
- package/template/public/fonts/SUITE/SUITE-SemiBold.woff2 +0 -0
- package/template/public/images/.gitkeep +0 -0
- package/template/scripts/build-search-index.ts +36 -0
- package/template/scripts/check-links.ts +40 -0
- package/template/scripts/show-urls.ts +48 -0
- package/template/scripts/validate-payload.ts +30 -0
- package/template/styles/markdown.css +167 -0
- package/template/styles/theme.css +65 -0
- package/template/tailwind.config.ts +156 -0
- package/template/tsconfig.json +32 -0
- 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
|
+
}
|