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.
- package/README.md +3 -2
- package/package.json +1 -1
- package/template/app/[...slug]/page.tsx +59 -3
- package/template/app/layout.tsx +3 -1
- package/template/components/graph/GraphView.tsx +20 -7
- package/template/components/layout/LocalGraph.tsx +52 -0
- package/template/components/layout/MobileMenu.tsx +16 -1
- package/template/components/layout/MovedPage.tsx +45 -0
- package/template/components/layout/PageLayout.tsx +11 -3
- package/template/components/layout/PageNavigation.tsx +73 -0
- package/template/components/layout/Sidebar.tsx +30 -2
- package/template/components/markdown/LinkPreview.tsx +164 -0
- package/template/components/markdown/MarkdownContent.tsx +3 -1
- package/template/lib/content/aliases.test.ts +76 -0
- package/template/lib/content/aliases.ts +114 -0
- package/template/lib/content/assets.ts +158 -0
- package/template/lib/content/excerpt.test.ts +68 -0
- package/template/lib/content/excerpt.ts +142 -0
- package/template/lib/content/registry.ts +31 -0
- package/template/lib/graph/build.ts +55 -0
- package/template/lib/graph/health.test.ts +60 -0
- package/template/lib/graph/health.ts +58 -0
- package/template/lib/markdown/callout.test.ts +87 -0
- package/template/lib/markdown/mermaid.test.ts +72 -0
- package/template/lib/markdown/rehype-mermaid.ts +133 -0
- package/template/lib/markdown/rehype-plugins.ts +50 -0
- package/template/lib/markdown/remark-callout.ts +173 -0
- package/template/lib/markdown/remark-wikilink.ts +201 -14
- package/template/lib/markdown/render.ts +125 -9
- package/template/lib/markdown/wikilink.test.ts +30 -0
- package/template/lib/markdown/wikilink.ts +12 -4
- package/template/lib/navigation/sequence.test.ts +73 -0
- package/template/lib/navigation/sequence.ts +100 -0
- package/template/lib/payload/schema.ts +1 -0
- package/template/lib/payload/types.ts +7 -0
- package/template/package-lock.json +32 -83
- package/template/package.json +3 -0
- package/template/scripts/check-links.ts +60 -18
- 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
|
+
}
|