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,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
|
+
}
|