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,85 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { bundledLanguages } from 'shiki';
3
+ import { clearLanguageCache, findFenceLanguages, getUsedLanguages } from './languages';
4
+
5
+ describe('findFenceLanguages', () => {
6
+ it('finds the language on a fence', () => {
7
+ expect(findFenceLanguages('```typescript\nconst x = 1;\n```')).toEqual(['typescript']);
8
+ });
9
+
10
+ it('lower-cases identifiers', () => {
11
+ expect(findFenceLanguages('```TypeScript\nx\n```')).toEqual(['typescript']);
12
+ });
13
+
14
+ it('deduplicates', () => {
15
+ expect(findFenceLanguages('```js\na\n```\n\n```js\nb\n```')).toEqual(['js']);
16
+ });
17
+
18
+ it('finds several languages', () => {
19
+ expect(findFenceLanguages('```ts\na\n```\n\n```python\nb\n```').sort()).toEqual([
20
+ 'python',
21
+ 'ts',
22
+ ]);
23
+ });
24
+
25
+ it('handles tilde fences', () => {
26
+ expect(findFenceLanguages('~~~rust\nx\n~~~')).toEqual(['rust']);
27
+ });
28
+
29
+ it('ignores fences with no language', () => {
30
+ expect(findFenceLanguages('```\nplain\n```')).toEqual([]);
31
+ });
32
+
33
+ it('accepts identifiers with punctuation', () => {
34
+ expect(findFenceLanguages('```c++\nx\n```')).toEqual(['c++']);
35
+ expect(findFenceLanguages('```objective-c\nx\n```')).toEqual(['objective-c']);
36
+ });
37
+
38
+ it('returns nothing for text with no fences', () => {
39
+ expect(findFenceLanguages('just prose')).toEqual([]);
40
+ });
41
+ });
42
+
43
+ describe('getUsedLanguages', () => {
44
+ it('returns only grammars Shiki actually bundles', () => {
45
+ const known = new Set(Object.keys(bundledLanguages));
46
+
47
+ for (const language of getUsedLanguages()) {
48
+ expect(known.has(language), language).toBe(true);
49
+ }
50
+ });
51
+
52
+ it('loads far fewer than the full bundle', () => {
53
+ // The whole point is to avoid loading every grammar; if this ever
54
+ // approaches the bundle size, first-render cost has regressed badly.
55
+ expect(getUsedLanguages().length).toBeLessThan(Object.keys(bundledLanguages).length / 4);
56
+ });
57
+
58
+ it('always includes the base languages', () => {
59
+ const languages = getUsedLanguages();
60
+
61
+ for (const base of ['typescript', 'bash', 'json', 'markdown']) {
62
+ expect(languages, base).toContain(base);
63
+ }
64
+ });
65
+
66
+ it('includes languages the content uses', () => {
67
+ // content/features/syntax-highlighting.md demonstrates several languages.
68
+ expect(getUsedLanguages().length).toBeGreaterThan(10);
69
+ });
70
+
71
+ it('is sorted and free of duplicates', () => {
72
+ const languages = getUsedLanguages();
73
+
74
+ expect(languages).toEqual([...languages].sort());
75
+ expect(new Set(languages).size).toBe(languages.length);
76
+ });
77
+
78
+ it('memoises until cleared', () => {
79
+ const first = getUsedLanguages();
80
+ expect(getUsedLanguages()).toBe(first);
81
+
82
+ clearLanguageCache();
83
+ expect(getUsedLanguages()).not.toBe(first);
84
+ });
85
+ });
@@ -0,0 +1,103 @@
1
+ import { bundledLanguages, type BuiltinLanguage } from 'shiki';
2
+ import { getContentRegistry } from '../content/registry';
3
+ import { cached } from '../cache';
4
+
5
+ /**
6
+ * Works out which syntax-highlighting grammars the site actually needs.
7
+ *
8
+ * Shiki loads every bundled grammar by default — around two hundred of them —
9
+ * which dominates the cost of the first render and, therefore, of the whole
10
+ * build. Almost none of them are ever used: a documentation site typically
11
+ * touches a handful of languages. Scanning the content for the fences that
12
+ * exist and loading only those cuts initialisation from tens of seconds to
13
+ * well under one.
14
+ *
15
+ * Server-only.
16
+ */
17
+
18
+ /**
19
+ * Languages always loaded, whether or not the content currently uses them.
20
+ *
21
+ * These cover the fences most likely to be added next, so a new code block in
22
+ * a common language does not silently render unhighlighted until someone
23
+ * notices.
24
+ */
25
+ const BASE_LANGUAGES = [
26
+ 'bash',
27
+ 'css',
28
+ 'diff',
29
+ 'html',
30
+ 'javascript',
31
+ 'json',
32
+ 'jsx',
33
+ 'markdown',
34
+ 'python',
35
+ 'shell',
36
+ 'sql',
37
+ 'tsx',
38
+ 'typescript',
39
+ 'yaml',
40
+ ] as const;
41
+
42
+ /** Matches the language written on an opening code fence. */
43
+ const FENCE_PATTERN = /^ {0,3}(?:`{3,}|~{3,})[ \t]*([A-Za-z0-9_+#-]+)/gm;
44
+
45
+ /**
46
+ * Extracts the language identifiers used by fenced code blocks in a string.
47
+ *
48
+ * @param markdown - Markdown source
49
+ * @returns Lower-cased identifiers, deduplicated
50
+ *
51
+ * @example
52
+ * ```typescript
53
+ * findFenceLanguages('```ts\nx\n```\n```python\ny\n```');
54
+ * // ['ts', 'python']
55
+ * ```
56
+ */
57
+ export function findFenceLanguages(markdown: string): string[] {
58
+ const found = new Set<string>();
59
+
60
+ for (const match of markdown.matchAll(FENCE_PATTERN)) {
61
+ found.add(match[1].toLowerCase());
62
+ }
63
+
64
+ return [...found];
65
+ }
66
+
67
+ let memo: BuiltinLanguage[] | null = null;
68
+
69
+ /**
70
+ * Returns the grammars to load for this site.
71
+ *
72
+ * Identifiers that Shiki does not recognise are dropped rather than passed
73
+ * through: an unknown name makes the highlighter throw at construction, which
74
+ * would turn one typo in one fence into a failed build. Unrecognised fences
75
+ * fall back to plain text at render time instead.
76
+ *
77
+ * @returns Bundled language names, sorted
78
+ */
79
+ export function getUsedLanguages(): BuiltinLanguage[] {
80
+ const hit = cached(memo);
81
+ if (hit) return hit;
82
+
83
+ const known = new Set(Object.keys(bundledLanguages));
84
+ const wanted = new Set<string>(BASE_LANGUAGES);
85
+
86
+ for (const doc of getContentRegistry().docs) {
87
+ for (const language of findFenceLanguages(doc.content)) {
88
+ wanted.add(language);
89
+ }
90
+ }
91
+
92
+ // The filter is what makes the assertion sound: only names present in the
93
+ // bundle survive, and those are exactly the BuiltinLanguage values.
94
+ memo = [...wanted].filter((language) => known.has(language)).sort() as BuiltinLanguage[];
95
+ return memo;
96
+ }
97
+
98
+ /**
99
+ * Discards the memoised language list. Intended for tests.
100
+ */
101
+ export function clearLanguageCache(): void {
102
+ memo = null;
103
+ }
@@ -0,0 +1,240 @@
1
+ import { visit } from 'unist-util-visit';
2
+ import { toString } from 'hast-util-to-string';
3
+ import type { Element, Root } from 'hast';
4
+ import { docPathToUrl, type UrlMap } from '../navigation/url';
5
+
6
+ /**
7
+ * Custom rehype plugins used by the build-time Markdown pipeline.
8
+ *
9
+ * These run against the HTML AST, after Markdown has been converted but before
10
+ * it is serialised, which is the only place where heading anchors, resolved
11
+ * links, and code-block chrome can all be produced without shipping a Markdown
12
+ * parser to the browser.
13
+ */
14
+
15
+ /** A heading extracted from a document, used to render the table of contents. */
16
+ export interface Heading {
17
+ /** Anchor id, matching the `id` attribute rehype-slug assigned */
18
+ id: string;
19
+ /** Rendered text of the heading */
20
+ text: string;
21
+ /** Heading level, 1 for h1 through 6 for h6 */
22
+ depth: number;
23
+ }
24
+
25
+ /** Heading levels collected for the table of contents. */
26
+ const TOC_LEVELS = new Set(['h2', 'h3', 'h4']);
27
+
28
+ /**
29
+ * Collects document headings onto `file.data.headings`.
30
+ *
31
+ * Results are attached to the virtual file rather than to a closure so that a
32
+ * single compiled processor can be reused across every document in the build —
33
+ * instantiating the syntax highlighter per page is the dominant cost otherwise.
34
+ *
35
+ * Must run after `rehype-slug`, which assigns the `id` attributes the table of
36
+ * contents links to. Headings without an id are skipped rather than given a
37
+ * generated one, since an anchor absent from the document is a dead link.
38
+ *
39
+ * @example
40
+ * ```typescript
41
+ * const file = await unified().use(rehypeSlug).use(rehypeCollectHeadings).process(md);
42
+ * const headings = file.data.headings as Heading[];
43
+ * ```
44
+ */
45
+ export function rehypeCollectHeadings() {
46
+ return (tree: Root, file: { data: Record<string, unknown> }) => {
47
+ const collected: Heading[] = [];
48
+
49
+ visit(tree, 'element', (node: Element) => {
50
+ if (!TOC_LEVELS.has(node.tagName)) return;
51
+
52
+ const id = node.properties?.id;
53
+ if (typeof id !== 'string' || !id) return;
54
+
55
+ collected.push({
56
+ id,
57
+ text: toString(node),
58
+ depth: Number(node.tagName.slice(1)),
59
+ });
60
+ });
61
+
62
+ file.data.headings = collected;
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Determines whether an href points outside the site.
68
+ */
69
+ function isExternal(href: string): boolean {
70
+ return /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//');
71
+ }
72
+
73
+ /**
74
+ * Rewrites internal Markdown links to the site's configured URL form, and
75
+ * hardens external links.
76
+ *
77
+ * Authors write links the way the content tree looks — `[Setup](guides/setup)`
78
+ * — and this resolves them through the URL map, so the same Markdown works
79
+ * under either URL strategy. Anchors, external URLs, and links to unknown
80
+ * documents are left untouched.
81
+ *
82
+ * @param urlMap - Precomputed mapping from content paths to URL segments
83
+ */
84
+ export function rehypeInternalLinks(urlMap: UrlMap) {
85
+ return (tree: Root) => {
86
+ visit(tree, 'element', (node: Element) => {
87
+ if (node.tagName !== 'a') return;
88
+
89
+ const href = node.properties?.href;
90
+ if (typeof href !== 'string' || !href) return;
91
+
92
+ if (isExternal(href)) {
93
+ node.properties.target = '_blank';
94
+ // `rel` is a space-separated list property in hast, so it is modelled
95
+ // as an array rather than a single string.
96
+ node.properties.rel = ['noopener', 'noreferrer'];
97
+ return;
98
+ }
99
+
100
+ // In-page anchors already point at ids produced by rehype-slug.
101
+ if (href.startsWith('#')) return;
102
+
103
+ // Split off any anchor or query so the document part can be resolved.
104
+ const match = /^([^#?]*)(.*)$/.exec(href);
105
+ if (!match) return;
106
+
107
+ const [, rawPath, suffix] = match;
108
+ const docPath = rawPath.replace(/^\/+/, '').replace(/\.md$/, '').replace(/\/+$/, '');
109
+ if (!docPath) return;
110
+
111
+ const url = docPathToUrl(urlMap, docPath);
112
+ if (url) {
113
+ // Trailing slash to match `trailingSlash` in the Next config: that is
114
+ // the form every page is exported under. Hosts that redirect the
115
+ // slashless form cost a round trip per link; hosts that do not — a
116
+ // plain object store, say — answer it with a 404.
117
+ node.properties.href = `/${url}/${suffix}`;
118
+ }
119
+ });
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Wraps fenced code blocks in a container with a language label and copy button.
125
+ *
126
+ * Runs *before* the syntax highlighter so that the language recorded in the
127
+ * Markdown fence is still available; the highlighter then rewrites the inner
128
+ * `<pre>` in place. The copy button carries no inline handler — a single
129
+ * delegated listener on the client picks it up — which keeps the entire
130
+ * highlighting stack out of the browser bundle.
131
+ */
132
+ export function rehypeCodeShell() {
133
+ return (tree: Root) => {
134
+ visit(tree, 'element', (node: Element, index, parent) => {
135
+ if (node.tagName !== 'pre' || !parent || index === undefined) return;
136
+
137
+ // Skip blocks that have already been wrapped.
138
+ if (
139
+ parent.type === 'element' &&
140
+ (parent as Element).properties?.['data-ezw-code'] !== undefined
141
+ ) {
142
+ return;
143
+ }
144
+
145
+ const code = node.children.find(
146
+ (child): child is Element => child.type === 'element' && child.tagName === 'code',
147
+ );
148
+
149
+ const className = code?.properties?.className;
150
+ const classes = Array.isArray(className) ? className.map(String) : [];
151
+ const languageClass = classes.find((cls) => cls.startsWith('language-'));
152
+ const language = languageClass ? languageClass.slice('language-'.length) : 'text';
153
+
154
+ const wrapper: Element = {
155
+ type: 'element',
156
+ tagName: 'div',
157
+ properties: {
158
+ className: ['ezw-code'],
159
+ 'data-ezw-code': '',
160
+ 'data-language': language,
161
+ },
162
+ children: [
163
+ {
164
+ type: 'element',
165
+ tagName: 'div',
166
+ properties: { className: ['ezw-code__bar'] },
167
+ children: [
168
+ {
169
+ type: 'element',
170
+ tagName: 'span',
171
+ properties: { className: ['ezw-code__lang'] },
172
+ children: [{ type: 'text', value: language }],
173
+ },
174
+ {
175
+ type: 'element',
176
+ tagName: 'button',
177
+ properties: {
178
+ type: 'button',
179
+ className: ['ezw-code__copy'],
180
+ 'data-ezw-copy': '',
181
+ 'aria-label': 'Copy code',
182
+ },
183
+ children: [{ type: 'text', value: 'Copy' }],
184
+ },
185
+ ],
186
+ },
187
+ node,
188
+ ],
189
+ };
190
+
191
+ parent.children[index] = wrapper;
192
+ });
193
+ };
194
+ }
195
+
196
+ /**
197
+ * Prefixes root-relative asset URLs with the deployment base path.
198
+ *
199
+ * Static exports served from a subdirectory — GitHub Pages project sites, for
200
+ * one — need `/images/x.png` rewritten to `/eziwiki/images/x.png`. Next rewrites
201
+ * these for JSX it controls, but not for HTML produced by this pipeline.
202
+ *
203
+ * @param basePath - Deployment base path, or '' when served from the root
204
+ */
205
+ export function rehypeBasePath(basePath: string) {
206
+ return (tree: Root) => {
207
+ if (!basePath) return;
208
+
209
+ visit(tree, 'element', (node: Element) => {
210
+ const attr = node.tagName === 'a' ? 'href' : node.tagName === 'img' ? 'src' : null;
211
+ if (!attr) return;
212
+
213
+ const value = node.properties?.[attr];
214
+ if (typeof value !== 'string') return;
215
+ if (!value.startsWith('/') || value.startsWith('//')) return;
216
+ if (value.startsWith(`${basePath}/`)) return;
217
+
218
+ node.properties[attr] = `${basePath}${value}`;
219
+ });
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Adds lazy loading and consistent styling hooks to content images.
225
+ */
226
+ export function rehypeImages() {
227
+ return (tree: Root) => {
228
+ visit(tree, 'element', (node: Element) => {
229
+ if (node.tagName !== 'img') return;
230
+
231
+ node.properties ??= {};
232
+ node.properties.loading ??= 'lazy';
233
+ node.properties.decoding ??= 'async';
234
+
235
+ const className = node.properties.className;
236
+ const classes = Array.isArray(className) ? className.map(String) : [];
237
+ node.properties.className = [...classes, 'ezw-img'];
238
+ });
239
+ };
240
+ }
@@ -0,0 +1,141 @@
1
+ import { visit } from 'unist-util-visit';
2
+ import type { Root, Text, PhrasingContent, Parent } from 'mdast';
3
+ import { WIKILINK_PATTERN, parseWikiLink, type WikiLink } from './wikilink';
4
+
5
+ /**
6
+ * Turns `[[wiki links]]` into ordinary Markdown links.
7
+ *
8
+ * Runs on the Markdown AST, before conversion to HTML, so the resulting links
9
+ * pass through the rest of the pipeline like any other. Only text nodes are
10
+ * visited, which means links written inside code spans and fenced blocks are
11
+ * left alone for free — documentation that explains the syntax has to be able
12
+ * to show it.
13
+ */
14
+
15
+ /** What a target resolved to. */
16
+ export interface WikiLinkTarget {
17
+ /** Root-relative href for the document */
18
+ url: string;
19
+ /** Default display text when the author gave no label */
20
+ title: string;
21
+ }
22
+
23
+ /**
24
+ * Resolves a wiki-link target to a destination, or null when there is none.
25
+ */
26
+ export type WikiLinkResolver = (target: string) => WikiLinkTarget | null;
27
+
28
+ /**
29
+ * Builds the replacement node for one wiki link.
30
+ *
31
+ * An unresolved link renders as marked-up text rather than an anchor: a link
32
+ * that goes nowhere is worse than visibly broken text, because it looks
33
+ * clickable and silently is not.
34
+ */
35
+ function toNode(link: WikiLink, resolve: WikiLinkResolver): PhrasingContent {
36
+ // An anchor-only link points within the current page, so there is nothing to
37
+ // resolve.
38
+ if (!link.target && link.anchor) {
39
+ return {
40
+ type: 'link',
41
+ url: `#${link.anchor}`,
42
+ children: [{ type: 'text', value: link.label ?? link.anchor }],
43
+ };
44
+ }
45
+
46
+ const resolved = resolve(link.target);
47
+
48
+ if (!resolved) {
49
+ return {
50
+ // `emphasis` is a carrier for the rendered span: it is a known phrasing
51
+ // type, so it degrades to <em> if the hName hint is ever ignored.
52
+ type: 'emphasis',
53
+ data: {
54
+ hName: 'span',
55
+ hProperties: {
56
+ className: ['ezw-broken-link'],
57
+ title: `Unresolved link: ${link.target}`,
58
+ },
59
+ },
60
+ children: [{ type: 'text', value: link.label ?? link.target }],
61
+ };
62
+ }
63
+
64
+ return {
65
+ type: 'link',
66
+ url: link.anchor ? `${resolved.url}#${link.anchor}` : resolved.url,
67
+ data: {
68
+ hProperties: { className: ['ezw-wikilink'] },
69
+ },
70
+ children: [{ type: 'text', value: link.label ?? resolved.title }],
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Splits a text node into text and link nodes.
76
+ *
77
+ * @param node - The text node to split
78
+ * @param resolve - Target resolver
79
+ * @returns Replacement nodes, or null when the text contains no wiki links
80
+ */
81
+ function splitText(node: Text, resolve: WikiLinkResolver): PhrasingContent[] | null {
82
+ const { value } = node;
83
+ if (!value.includes('[[')) return null;
84
+
85
+ const replacement: PhrasingContent[] = [];
86
+ let cursor = 0;
87
+ let matched = false;
88
+
89
+ // matchAll on a global pattern is safe here because the regex literal is
90
+ // re-evaluated per call; lastIndex never leaks between documents.
91
+ for (const match of value.matchAll(WIKILINK_PATTERN)) {
92
+ const parsed = parseWikiLink(match[1], match[0]);
93
+ if (!parsed) continue;
94
+
95
+ const start = match.index ?? 0;
96
+
97
+ if (start > cursor) {
98
+ replacement.push({ type: 'text', value: value.slice(cursor, start) });
99
+ }
100
+
101
+ replacement.push(toNode(parsed, resolve));
102
+ cursor = start + match[0].length;
103
+ matched = true;
104
+ }
105
+
106
+ if (!matched) return null;
107
+
108
+ if (cursor < value.length) {
109
+ replacement.push({ type: 'text', value: value.slice(cursor) });
110
+ }
111
+
112
+ return replacement;
113
+ }
114
+
115
+ /**
116
+ * Remark plugin factory.
117
+ *
118
+ * @param resolve - Resolves a target to its destination
119
+ *
120
+ * @example
121
+ * ```typescript
122
+ * unified().use(remarkParse).use(remarkWikiLinks, (target) =>
123
+ * target === 'intro' ? { url: '/intro', title: 'Introduction' } : null,
124
+ * );
125
+ * ```
126
+ */
127
+ export function remarkWikiLinks(resolve: WikiLinkResolver) {
128
+ return (tree: Root) => {
129
+ visit(tree, 'text', (node: Text, index, parent) => {
130
+ if (!parent || index === undefined) return;
131
+
132
+ const replacement = splitText(node, resolve);
133
+ if (!replacement) return;
134
+
135
+ (parent as Parent).children.splice(index, 1, ...replacement);
136
+
137
+ // Continue after the nodes just inserted, so their text is not rescanned.
138
+ return index + replacement.length;
139
+ });
140
+ };
141
+ }