create-eziwiki 0.1.1 → 0.3.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 (39) hide show
  1. package/README.md +3 -2
  2. package/package.json +1 -1
  3. package/template/app/[...slug]/page.tsx +59 -3
  4. package/template/app/layout.tsx +3 -1
  5. package/template/components/graph/GraphView.tsx +20 -7
  6. package/template/components/layout/LocalGraph.tsx +52 -0
  7. package/template/components/layout/MobileMenu.tsx +16 -1
  8. package/template/components/layout/MovedPage.tsx +45 -0
  9. package/template/components/layout/PageLayout.tsx +11 -3
  10. package/template/components/layout/PageNavigation.tsx +73 -0
  11. package/template/components/layout/Sidebar.tsx +30 -2
  12. package/template/components/markdown/LinkPreview.tsx +164 -0
  13. package/template/components/markdown/MarkdownContent.tsx +3 -1
  14. package/template/lib/content/aliases.test.ts +76 -0
  15. package/template/lib/content/aliases.ts +114 -0
  16. package/template/lib/content/assets.ts +158 -0
  17. package/template/lib/content/excerpt.test.ts +68 -0
  18. package/template/lib/content/excerpt.ts +142 -0
  19. package/template/lib/content/registry.ts +31 -0
  20. package/template/lib/graph/build.ts +55 -0
  21. package/template/lib/graph/health.test.ts +60 -0
  22. package/template/lib/graph/health.ts +58 -0
  23. package/template/lib/markdown/callout.test.ts +87 -0
  24. package/template/lib/markdown/mermaid.test.ts +72 -0
  25. package/template/lib/markdown/rehype-mermaid.ts +133 -0
  26. package/template/lib/markdown/rehype-plugins.ts +50 -0
  27. package/template/lib/markdown/remark-callout.ts +173 -0
  28. package/template/lib/markdown/remark-wikilink.ts +201 -14
  29. package/template/lib/markdown/render.ts +125 -9
  30. package/template/lib/markdown/wikilink.test.ts +30 -0
  31. package/template/lib/markdown/wikilink.ts +12 -4
  32. package/template/lib/navigation/sequence.test.ts +73 -0
  33. package/template/lib/navigation/sequence.ts +100 -0
  34. package/template/lib/payload/schema.ts +1 -0
  35. package/template/lib/payload/types.ts +7 -0
  36. package/template/package-lock.json +32 -83
  37. package/template/package.json +3 -0
  38. package/template/scripts/check-links.ts +60 -18
  39. package/template/styles/markdown.css +231 -0
@@ -0,0 +1,142 @@
1
+ import { unified } from 'unified';
2
+ import remarkParse from 'remark-parse';
3
+ import remarkGfm from 'remark-gfm';
4
+ import { visit } from 'unist-util-visit';
5
+ import type { Root, RootContent } from 'mdast';
6
+ import { toString as mdastToString } from 'mdast-util-to-string';
7
+ import { getDoc } from './registry';
8
+ import { CACHE_DERIVED_CONTENT } from '../cache';
9
+
10
+ /**
11
+ * Short plain-text summaries of documents.
12
+ *
13
+ * Used for the card that appears when a reader hovers a wiki link. Deriving it
14
+ * from Markdown rather than from the rendered HTML keeps markup, syntax
15
+ * highlighting, and the `ezw-` wrappers out of what is meant to be one or two
16
+ * readable sentences.
17
+ *
18
+ * Server-only.
19
+ */
20
+
21
+ /** How much of a document the card shows before trailing off. */
22
+ const MAX_LENGTH = 180;
23
+
24
+ /** Parser used only to reach the text; no rendering plugins. */
25
+ const parser = unified().use(remarkParse).use(remarkGfm);
26
+
27
+ /**
28
+ * Node types that carry no prose worth previewing.
29
+ *
30
+ * A page opening with a diagram or a command should preview as the sentence
31
+ * underneath it, not as an empty string or a line of shell.
32
+ */
33
+ const SKIPPED = new Set(['code', 'thematicBreak', 'image', 'html', 'table', 'math']);
34
+
35
+ /**
36
+ * Whether a node is the document's own title heading.
37
+ *
38
+ * The card shows the title separately, so repeating it as the first line of the
39
+ * body wastes the little space there is.
40
+ */
41
+ function isTitleHeading(node: RootContent): boolean {
42
+ return node.type === 'heading' && node.depth === 1;
43
+ }
44
+
45
+ /**
46
+ * Collapses a node's text into a single line.
47
+ */
48
+ function flatten(node: RootContent): string {
49
+ // An image alt is not prose; strip it so a paragraph that is only a figure
50
+ // does not preview as its alt text.
51
+ const clone: Root = { type: 'root', children: [structuredClone(node)] };
52
+ visit(clone, 'image', (_image, index, parent) => {
53
+ if (parent && index !== undefined) parent.children.splice(index, 1);
54
+ return index;
55
+ });
56
+
57
+ return mdastToString(clone).replace(/\s+/g, ' ').trim();
58
+ }
59
+
60
+ /**
61
+ * Builds a one-or-two sentence summary of a Markdown document.
62
+ *
63
+ * Prefers the frontmatter description, which an author wrote deliberately.
64
+ * Otherwise takes prose from the top of the document, skipping the title and
65
+ * anything that is not text, and stops at a word boundary so the card never
66
+ * cuts mid-word.
67
+ *
68
+ * @param markdown - Document body, with frontmatter already stripped
69
+ * @param description - `description` from the frontmatter, when present
70
+ * @returns The summary, or an empty string when the document has no prose
71
+ *
72
+ * @example
73
+ * ```typescript
74
+ * buildExcerpt('# Title\n\nThe first sentence.'); // 'The first sentence.'
75
+ * buildExcerpt('# Title\n\nIgnored.', 'From the frontmatter'); // 'From the frontmatter'
76
+ * ```
77
+ */
78
+ export function buildExcerpt(markdown: string, description?: string): string {
79
+ if (description?.trim()) return truncate(description.trim());
80
+
81
+ const tree = parser.parse(markdown) as Root;
82
+ const parts: string[] = [];
83
+
84
+ for (const node of tree.children) {
85
+ if (isTitleHeading(node) || SKIPPED.has(node.type)) continue;
86
+
87
+ const text = flatten(node);
88
+ if (!text) continue;
89
+
90
+ parts.push(text);
91
+ if (parts.join(' ').length >= MAX_LENGTH) break;
92
+ }
93
+
94
+ return truncate(parts.join(' '));
95
+ }
96
+
97
+ const cache = new Map<string, string>();
98
+
99
+ /**
100
+ * Returns a document's summary, memoised by path.
101
+ *
102
+ * A popular page is linked from dozens of others, and each of those links asks
103
+ * for the same summary while its page is rendered. Parsing the target once per
104
+ * build rather than once per link keeps that from showing up in build time.
105
+ *
106
+ * @param docPath - Content-relative path without extension
107
+ * @returns The summary, or an empty string when there is no such document
108
+ *
109
+ * @example
110
+ * ```typescript
111
+ * getExcerpt('getting-started/quick-start'); // 'Get a wiki running in…'
112
+ * ```
113
+ */
114
+ export function getExcerpt(docPath: string): string {
115
+ // Checked through the map rather than `cached()`, because an empty summary is
116
+ // a legitimate result and would otherwise be indistinguishable from a miss.
117
+ if (CACHE_DERIVED_CONTENT) {
118
+ const hit = cache.get(docPath);
119
+ if (hit !== undefined) return hit;
120
+ }
121
+
122
+ const doc = getDoc(docPath);
123
+ const excerpt = doc ? buildExcerpt(doc.content, doc.description) : '';
124
+
125
+ cache.set(docPath, excerpt);
126
+ return excerpt;
127
+ }
128
+
129
+ /**
130
+ * Shortens text to the preview length, breaking at a space.
131
+ *
132
+ * @param text - Text to shorten
133
+ * @returns The text, with an ellipsis when it was cut
134
+ */
135
+ function truncate(text: string): string {
136
+ if (text.length <= MAX_LENGTH) return text;
137
+
138
+ const cut = text.slice(0, MAX_LENGTH);
139
+ const lastSpace = cut.lastIndexOf(' ');
140
+
141
+ return `${(lastSpace > MAX_LENGTH / 2 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
142
+ }
@@ -25,6 +25,13 @@ export interface ContentDoc {
25
25
  order: number;
26
26
  /** Excluded from navigation, but still reachable by direct URL */
27
27
  hidden: boolean;
28
+ /**
29
+ * Paths this document used to live at.
30
+ *
31
+ * A wiki moves pages; without these, every published link to the old
32
+ * location breaks silently the moment a file is renamed.
33
+ */
34
+ aliases: string[];
28
35
  /** Full parsed frontmatter, for consumers that need custom fields */
29
36
  frontmatter: Record<string, unknown>;
30
37
  /** Markdown body with frontmatter stripped */
@@ -107,6 +114,29 @@ function readBoolean(value: unknown): boolean {
107
114
  return false;
108
115
  }
109
116
 
117
+ /**
118
+ * Reads the `aliases` frontmatter into a list of content paths.
119
+ *
120
+ * Accepts a single string or a list, since an author moving one page writes
121
+ * one path and should not have to remember which form is required. Leading and
122
+ * trailing slashes and a `.md` suffix are tolerated: the value looks like a
123
+ * path, and being strict about its punctuation would only produce silent
124
+ * misses.
125
+ *
126
+ * @param value - The raw frontmatter value
127
+ * @returns Normalised content paths, without duplicates
128
+ */
129
+ function readAliases(value: unknown): string[] {
130
+ const raw = typeof value === 'string' ? [value] : Array.isArray(value) ? value : [];
131
+
132
+ const paths = raw
133
+ .filter((entry): entry is string => typeof entry === 'string')
134
+ .map((entry) => entry.trim().replace(/^\/+/, '').replace(/\.md$/i, '').replace(/\/+$/, ''))
135
+ .filter(Boolean);
136
+
137
+ return [...new Set(paths)];
138
+ }
139
+
110
140
  /**
111
141
  * Reads the optional `_meta.json` for a content subdirectory.
112
142
  *
@@ -198,6 +228,7 @@ function readDoc(filePath: string): ContentDoc | null {
198
228
  description: typeof frontmatter.description === 'string' ? frontmatter.description : undefined,
199
229
  order: readOrder(frontmatter.order),
200
230
  hidden: readBoolean(frontmatter.hidden) || frontmatter.nav === false,
231
+ aliases: readAliases(frontmatter.aliases),
201
232
  frontmatter,
202
233
  content,
203
234
  filePath,
@@ -6,6 +6,7 @@ import type { Root, Link, Text } from 'mdast';
6
6
  import { getContentRegistry, type ContentDoc } from '../content/registry';
7
7
  import { resolveTarget } from '../content/resolver';
8
8
  import { findWikiLinks } from '../markdown/wikilink';
9
+ import { resolveAsset } from '../content/assets';
9
10
  import { getSite } from '../site';
10
11
  import { cached } from '../cache';
11
12
 
@@ -117,6 +118,12 @@ function scanDoc(doc: ContentDoc): { targets: Set<string>; broken: BrokenLink[]
117
118
  // An anchor-only link stays within the page and is not an edge.
118
119
  if (!link.target) continue;
119
120
 
121
+ // `![[diagram.png]]` embeds a file. It is neither an edge between pages
122
+ // nor a broken reference, so it leaves the graph here. An embed that
123
+ // names no such file falls through, matching the renderer, which treats
124
+ // it as a link to a document.
125
+ if (link.embed && resolveAsset(link.target)) continue;
126
+
120
127
  const resolution = resolveTarget(link.target);
121
128
 
122
129
  if (resolution.doc) {
@@ -197,6 +204,54 @@ export function getLinkGraph(): LinkGraph {
197
204
  return memo;
198
205
  }
199
206
 
207
+ /** A page and everything one link away from it. */
208
+ export interface LocalGraph {
209
+ /** The page itself, plus its immediate neighbours */
210
+ nodes: GraphNode[];
211
+ /** Links among those nodes, including ones not touching the page */
212
+ edges: GraphEdge[];
213
+ }
214
+
215
+ /**
216
+ * Returns the neighbourhood around a page.
217
+ *
218
+ * The whole-site graph answers "how is this wiki shaped"; past a few dozen
219
+ * pages it stops answering "what is near this one", which is the question a
220
+ * reader has while reading. This is that view: the page, everything it links
221
+ * to, everything linking to it, and the links among them — the last so the
222
+ * neighbours read as a cluster rather than a fan of unconnected dots.
223
+ *
224
+ * Direction is deliberately not distinguished. A reader looking for related
225
+ * pages cares that two are connected, not which one did the linking; the
226
+ * backlinks list already says that for the pages that point here.
227
+ *
228
+ * @param path - Content path of the page at the centre
229
+ * @returns The neighbourhood, or empty when the page has no links either way
230
+ *
231
+ * @example
232
+ * ```typescript
233
+ * const { nodes, edges } = getLocalGraph('features/wiki-links');
234
+ * nodes.length; // the page plus its neighbours
235
+ * ```
236
+ */
237
+ export function getLocalGraph(path: string): LocalGraph {
238
+ const graph = getLinkGraph();
239
+
240
+ const neighbours = new Set<string>([
241
+ ...(graph.outbound.get(path) ?? []),
242
+ ...(graph.backlinks.get(path) ?? []),
243
+ ]);
244
+
245
+ if (neighbours.size === 0) return { nodes: [], edges: [] };
246
+
247
+ const included = new Set<string>([path, ...neighbours]);
248
+
249
+ return {
250
+ nodes: graph.nodes.filter((node) => included.has(node.path)),
251
+ edges: graph.edges.filter((edge) => included.has(edge.from) && included.has(edge.to)),
252
+ };
253
+ }
254
+
200
255
  /**
201
256
  * Returns the documents that link to a given page.
202
257
  *
@@ -0,0 +1,60 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { getWikiHealth } from './health';
3
+ import { getLinkGraph } from './build';
4
+ import { getReadingOrder } from '../navigation/sequence';
5
+
6
+ describe('getWikiHealth', () => {
7
+ it('finds pages nothing links to', () => {
8
+ const graph = getLinkGraph();
9
+
10
+ for (const page of getWikiHealth().orphans) {
11
+ expect(graph.backlinks.get(page.path) ?? []).toHaveLength(0);
12
+ }
13
+ });
14
+
15
+ it('finds pages with no links out', () => {
16
+ const graph = getLinkGraph();
17
+
18
+ for (const page of getWikiHealth().deadEnds) {
19
+ expect(graph.outbound.get(page.path) ?? []).toHaveLength(0);
20
+ }
21
+ });
22
+
23
+ // Where a reader starts needs nothing pointing at it. Reporting it on every
24
+ // build would teach everyone to ignore the report.
25
+ it('never calls the entry page an orphan', () => {
26
+ const [entry] = getReadingOrder();
27
+
28
+ expect(getWikiHealth().orphans.map((page) => page.path)).not.toContain(entry);
29
+ });
30
+
31
+ it('reports only pages that are in the graph', () => {
32
+ const paths = new Set(getLinkGraph().nodes.map((node) => node.path));
33
+ const { orphans, deadEnds } = getWikiHealth();
34
+
35
+ for (const page of [...orphans, ...deadEnds]) {
36
+ expect(paths.has(page.path)).toBe(true);
37
+ }
38
+ });
39
+
40
+ // Hidden pages are absent from the graph entirely, so an unlisted page is
41
+ // not reported as disconnected — it is unlisted on purpose.
42
+ it('says nothing about hidden pages', () => {
43
+ const { orphans, deadEnds } = getWikiHealth();
44
+ const reported = [...orphans, ...deadEnds].map((page) => page.path);
45
+ const visible = new Set(getLinkGraph().nodes.map((node) => node.path));
46
+
47
+ for (const path of reported) {
48
+ expect(visible.has(path)).toBe(true);
49
+ }
50
+ });
51
+
52
+ it('gives every reported page a title and a link', () => {
53
+ const { orphans, deadEnds } = getWikiHealth();
54
+
55
+ for (const page of [...orphans, ...deadEnds]) {
56
+ expect(page.title).toBeTruthy();
57
+ expect(page.url).toBeTruthy();
58
+ }
59
+ });
60
+ });
@@ -0,0 +1,58 @@
1
+ import { getLinkGraph, type GraphNode } from './build';
2
+ import { getReadingOrder } from '../navigation/sequence';
3
+ import { cached } from '../cache';
4
+
5
+ /**
6
+ * What the link graph says about the state of the wiki.
7
+ *
8
+ * A broken link is an error and already reported. These are not errors — a
9
+ * wiki can be perfectly correct and still have them — but they are the shapes
10
+ * a collection of documents falls into when it stops being a wiki: pages
11
+ * nothing leads to, and pages nothing leads on from. Neither is visible from
12
+ * inside a single document, and neither shows up in a link check, which only
13
+ * asks whether the links that exist resolve.
14
+ *
15
+ * Server-only.
16
+ */
17
+
18
+ /** Pages the graph flags as worth a second look. */
19
+ export interface WikiHealth {
20
+ /** Pages nothing links to, so a reader can only arrive through the sidebar */
21
+ orphans: GraphNode[];
22
+ /** Pages with no links out, where a reader arrives and has nowhere to go */
23
+ deadEnds: GraphNode[];
24
+ }
25
+
26
+ let memo: WikiHealth | null = null;
27
+
28
+ /**
29
+ * Finds pages that are disconnected from the rest of the wiki.
30
+ *
31
+ * The first page in reading order is never an orphan. It is where a reader
32
+ * starts, so nothing needs to point at it, and reporting it every build would
33
+ * teach everyone to ignore the report.
34
+ *
35
+ * @returns The pages worth looking at
36
+ *
37
+ * @example
38
+ * ```typescript
39
+ * const { orphans, deadEnds } = getWikiHealth();
40
+ * orphans.map((page) => page.path); // ['examples/api-docs', …]
41
+ * ```
42
+ */
43
+ export function getWikiHealth(): WikiHealth {
44
+ const hit = cached(memo);
45
+ if (hit) return hit;
46
+
47
+ const graph = getLinkGraph();
48
+ const [entry] = getReadingOrder();
49
+
50
+ const orphans = graph.nodes.filter(
51
+ (node) => node.path !== entry && (graph.backlinks.get(node.path) ?? []).length === 0,
52
+ );
53
+
54
+ const deadEnds = graph.nodes.filter((node) => (graph.outbound.get(node.path) ?? []).length === 0);
55
+
56
+ memo = { orphans, deadEnds };
57
+ return memo;
58
+ }
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { renderMarkdown } from './render';
3
+
4
+ /** Renders a callout and reports what it became. */
5
+ async function render(markdown: string) {
6
+ const { html } = await renderMarkdown(`${markdown}\n`);
7
+
8
+ return {
9
+ html,
10
+ kind: (html.match(/ezw-callout--(\w+)/) ?? [])[1] ?? null,
11
+ tag: (html.match(/<(details|div)[^>]*ezw-callout/) ?? [])[1] ?? null,
12
+ title: (html.match(/ezw-callout__title">([^<]*)/) ?? [])[1] ?? null,
13
+ };
14
+ }
15
+
16
+ describe('callouts', () => {
17
+ it('turns a marked blockquote into a callout', async () => {
18
+ const { kind, tag, title, html } = await render('> [!NOTE]\n> Useful information.');
19
+
20
+ expect(kind).toBe('note');
21
+ expect(tag).toBe('div');
22
+ expect(title).toBe('Note');
23
+ expect(html).toContain('Useful information.');
24
+ });
25
+
26
+ it('uses a title given on the marker line', async () => {
27
+ expect((await render('> [!WARNING] Mind the gap\n> Careful.')).title).toBe('Mind the gap');
28
+ });
29
+
30
+ it('recognises the kinds case-insensitively', async () => {
31
+ expect((await render('> [!note]\n> x')).kind).toBe('note');
32
+ expect((await render('> [!NoTe]\n> x')).kind).toBe('note');
33
+ });
34
+
35
+ // Obsidian defines more kinds than GitHub and vaults use them, so they map
36
+ // onto the nearest one instead of losing their formatting.
37
+ it('maps the extra Obsidian kinds onto the nearest one', async () => {
38
+ expect((await render('> [!danger]\n> x')).kind).toBe('caution');
39
+ expect((await render('> [!success]\n> x')).kind).toBe('tip');
40
+ expect((await render('> [!question]\n> x')).kind).toBe('important');
41
+ });
42
+
43
+ it('leaves an unknown kind as an ordinary quote', async () => {
44
+ const { kind, html } = await render('> [!nonsense]\n> x');
45
+
46
+ expect(kind).toBeNull();
47
+ expect(html).toContain('<blockquote>');
48
+ });
49
+
50
+ it('leaves a plain quote alone', async () => {
51
+ const { kind, html } = await render('> Just a quote.');
52
+
53
+ expect(kind).toBeNull();
54
+ expect(html).toContain('<blockquote>');
55
+ });
56
+
57
+ // `<details>` opens and closes without script, so a disclosure keeps working
58
+ // with JavaScript disabled.
59
+ it('folds with a trailing - or +', async () => {
60
+ const closed = await render('> [!TIP]- Optional\n> Hidden.');
61
+ const open = await render('> [!TIP]+ Shown\n> Visible.');
62
+
63
+ expect(closed.tag).toBe('details');
64
+ expect(closed.html).not.toMatch(/<details[^>]*\sopen/);
65
+ expect(open.tag).toBe('details');
66
+ expect(open.html).toMatch(/<details[^>]*\sopen/);
67
+ });
68
+
69
+ // The body passes through the rest of the pipeline, so nothing inside a
70
+ // callout behaves differently from the same text outside one.
71
+ it('renders links, wiki links and code inside the body', async () => {
72
+ // `intro` is the one page both this repository and a scaffolded project
73
+ // have, so the test travels with the engine.
74
+ const wiki = await render('> [!NOTE]\n> See [[intro]].');
75
+ const code = await render('> [!TIP]\n> Run `npm i`.');
76
+
77
+ expect(wiki.html).toContain('ezw-wikilink');
78
+ expect(code.html).toContain('<code');
79
+ });
80
+
81
+ it('keeps a multi-line body together', async () => {
82
+ const { html } = await render('> [!NOTE]\n> First line.\n> Second line.');
83
+
84
+ expect(html).toContain('First line.');
85
+ expect(html).toContain('Second line.');
86
+ });
87
+ });
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { renderMarkdown } from './render';
3
+
4
+ /** Wraps a diagram in a fence tagged `mermaid`. */
5
+ function fence(body: string): string {
6
+ return ['```mermaid', body, '```', ''].join('\n');
7
+ }
8
+
9
+ describe('mermaid diagrams', () => {
10
+ it('draws a fence as SVG during the build', async () => {
11
+ const { html } = await renderMarkdown(fence('flowchart TD\n A[One] --> B[Two]'));
12
+
13
+ expect(html).toContain('class="ezw-mermaid"');
14
+ expect(html).toContain('<svg');
15
+ expect(html).toContain('One');
16
+ });
17
+
18
+ // The site self-hosts and subsets its fonts; the renderer inlines a Google
19
+ // Fonts import that would undo that on every page carrying a diagram.
20
+ it('carries no third-party request', async () => {
21
+ const { html } = await renderMarkdown(fence('flowchart TD\n A --> B'));
22
+
23
+ expect(html).not.toContain('fonts.googleapis.com');
24
+ expect(html).not.toContain('@import');
25
+ });
26
+
27
+ // Fixed inline, the colours would survive a switch to dark; left to CSS, the
28
+ // diagram follows the theme like everything else.
29
+ it('leaves its colours to the stylesheet', async () => {
30
+ const { html } = await renderMarkdown(fence('flowchart TD\n A --> B'));
31
+
32
+ expect(html).toMatch(/var\(--(bg|fg)\)/);
33
+ expect(html).not.toMatch(/<svg[^>]*style="[^"]*--bg/);
34
+ });
35
+
36
+ // Nothing is drawn in the browser, so a crawler and a reader without
37
+ // JavaScript see the same diagram everyone else does.
38
+ it('needs no script to appear', async () => {
39
+ const { html } = await renderMarkdown(fence('flowchart TD\n A --> B'));
40
+
41
+ expect(html).not.toContain('<script');
42
+ });
43
+
44
+ it('draws the kinds a wiki actually uses', async () => {
45
+ const kinds = [
46
+ 'sequenceDiagram\n A->>B: hello',
47
+ 'stateDiagram-v2\n [*] --> Draft',
48
+ 'classDiagram\n class Page',
49
+ 'erDiagram\n PAGE ||--o{ LINK : has',
50
+ ];
51
+
52
+ for (const source of kinds) {
53
+ expect((await renderMarkdown(fence(source))).html).toContain('ezw-mermaid');
54
+ }
55
+ });
56
+
57
+ // A kind the renderer cannot draw leaves the reader exactly what they had
58
+ // before diagrams existed, rather than stopping the build.
59
+ it('falls back to a code block on an unsupported diagram', async () => {
60
+ const { html } = await renderMarkdown(fence('pie title X\n "a" : 1'));
61
+
62
+ expect(html).not.toContain('ezw-mermaid');
63
+ expect(html).toContain('data-language="mermaid"');
64
+ });
65
+
66
+ it('leaves ordinary code fences alone', async () => {
67
+ const { html } = await renderMarkdown('```js\nconst a = 1;\n```\n');
68
+
69
+ expect(html).toContain('ezw-code');
70
+ expect(html).not.toContain('ezw-mermaid');
71
+ });
72
+ });
@@ -0,0 +1,133 @@
1
+ import { visit } from 'unist-util-visit';
2
+ import { toString } from 'hast-util-to-string';
3
+ import type { Element, Root } from 'hast';
4
+
5
+ /**
6
+ * Renders ```mermaid fences to SVG during the build.
7
+ *
8
+ * The usual way to put a diagram on a page is to ship Mermaid to the browser
9
+ * and let it draw one after load. That would be the largest thing this site
10
+ * downloads by a wide margin, it would move the page as the diagram appeared,
11
+ * and a crawler or a reader without JavaScript would see nothing. The diagram
12
+ * is fixed at build time, so it is drawn once, here, and arrives as markup.
13
+ *
14
+ * A fence that cannot be drawn is left alone, and goes on to be highlighted as
15
+ * an ordinary code block. Failing that way costs a reader nothing they had
16
+ * before, while stopping the build over one diagram would.
17
+ */
18
+
19
+ /** Class Markdown gives a fence tagged `mermaid`. */
20
+ const FENCE_CLASS = 'language-mermaid';
21
+
22
+ /**
23
+ * The renderer, loaded on first use.
24
+ *
25
+ * `beautiful-mermaid` ships ESM only, with no CommonJS entry, so a static
26
+ * import fails in the build scripts that reach this module through `tsx`.
27
+ * Importing it dynamically works from either, and the promise is kept so the
28
+ * cost is paid once rather than per document.
29
+ */
30
+ let rendererPromise: Promise<(text: string) => string> | null = null;
31
+
32
+ /**
33
+ * Returns the SVG renderer.
34
+ */
35
+ function getRenderer(): Promise<(text: string) => string> {
36
+ rendererPromise ??= import('beautiful-mermaid').then((module) => module.renderMermaidSVG);
37
+ return rendererPromise;
38
+ }
39
+
40
+ /**
41
+ * Pulls the web-font import out of the rendered SVG.
42
+ *
43
+ * The renderer inlines an `@import` for Google Fonts, which would be a
44
+ * third-party request on every page carrying a diagram — from a site that
45
+ * self-hosts and subsets its fonts precisely so there are none. The diagram
46
+ * inherits the page's font once the rule is gone.
47
+ */
48
+ function stripFontImport(svg: string): string {
49
+ return svg.replace(/@import\s+url\([^)]*\);?/g, '');
50
+ }
51
+
52
+ /**
53
+ * Removes the colours the renderer fixes on the root element.
54
+ *
55
+ * It writes `--bg` and `--fg` into an inline `style`, where a stylesheet cannot
56
+ * reach them, which would leave every diagram in its light-theme colours after
57
+ * a reader switched to dark. Dropping them lets the site's own CSS supply the
58
+ * variables the SVG already refers to — the same arrangement the syntax
59
+ * highlighter uses.
60
+ */
61
+ function stripInlineColours(svg: string): string {
62
+ return svg.replace(/(<svg[^>]*?)\sstyle="[^"]*"/, '$1');
63
+ }
64
+
65
+ /**
66
+ * Reads the fence's language from its class list.
67
+ */
68
+ function isMermaidFence(node: Element): boolean {
69
+ const className = node.properties?.className;
70
+ const classes = Array.isArray(className) ? className.map(String) : [];
71
+
72
+ return classes.includes(FENCE_CLASS);
73
+ }
74
+
75
+ /**
76
+ * Rehype plugin factory.
77
+ *
78
+ * Must run before the code-block chrome and the highlighter, so that a fence it
79
+ * claims never becomes a code block, and one it declines still does.
80
+ *
81
+ * @example
82
+ * ```typescript
83
+ * unified().use(rehypeMermaid).use(rehypeCodeShell).use(rehypeShiki, options);
84
+ * ```
85
+ */
86
+ export function rehypeMermaid() {
87
+ return async (tree: Root) => {
88
+ // Collected first, then rendered: `visit` is synchronous, and the renderer
89
+ // has to be awaited before any of it can be drawn.
90
+ const fences: Array<{ parent: Root | Element; index: number; source: string }> = [];
91
+
92
+ visit(tree, 'element', (node: Element, index, parent) => {
93
+ if (node.tagName !== 'pre' || !parent || index === undefined) return;
94
+
95
+ const code = node.children.find(
96
+ (child): child is Element => child.type === 'element' && child.tagName === 'code',
97
+ );
98
+
99
+ if (!code || !isMermaidFence(code)) return;
100
+
101
+ fences.push({ parent: parent as Root | Element, index, source: toString(code) });
102
+ });
103
+
104
+ if (fences.length === 0) return;
105
+
106
+ const render = await getRenderer();
107
+
108
+ for (const fence of fences) {
109
+ let svg: string;
110
+
111
+ try {
112
+ svg = render(fence.source);
113
+ } catch {
114
+ // Diagram kinds the renderer does not know, and syntax it cannot read,
115
+ // both land here. The fence stays as it was written and goes on to be
116
+ // highlighted as an ordinary code block.
117
+ continue;
118
+ }
119
+
120
+ fence.parent.children[fence.index] = {
121
+ type: 'element',
122
+ tagName: 'figure',
123
+ properties: { className: ['ezw-mermaid'] },
124
+ children: [
125
+ // Serialised verbatim: `rehype-stringify` is configured to pass raw
126
+ // through, and parsing the SVG back into a tree only to print it
127
+ // again would buy nothing.
128
+ { type: 'raw', value: stripInlineColours(stripFontImport(svg)) } as never,
129
+ ],
130
+ };
131
+ }
132
+ };
133
+ }