create-eziwiki 0.2.0 → 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/package.json +1 -1
- package/template/app/[...slug]/page.tsx +56 -2
- package/template/components/layout/MovedPage.tsx +45 -0
- package/template/components/layout/PageNavigation.tsx +73 -0
- package/template/lib/content/aliases.test.ts +76 -0
- package/template/lib/content/aliases.ts +114 -0
- package/template/lib/content/registry.ts +31 -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 +45 -0
- package/template/lib/markdown/remark-callout.ts +173 -0
- package/template/lib/markdown/render.ts +15 -1
- package/template/lib/navigation/sequence.test.ts +73 -0
- package/template/lib/navigation/sequence.ts +100 -0
- package/template/package-lock.json +29 -0
- package/template/package.json +1 -0
- package/template/scripts/check-links.ts +60 -18
- package/template/styles/markdown.css +157 -0
|
@@ -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
|
+
}
|
|
@@ -68,6 +68,51 @@ export function rehypeCollectHeadings() {
|
|
|
68
68
|
};
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/** Heading levels that get a link to themselves. */
|
|
72
|
+
const ANCHORED_LEVELS = new Set(['h2', 'h3', 'h4', 'h5', 'h6']);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Appends a link to each heading pointing at itself.
|
|
76
|
+
*
|
|
77
|
+
* A reader who wants to send someone to one section of a long page otherwise
|
|
78
|
+
* has to read the id out of the address bar, or link the whole page and say
|
|
79
|
+
* "scroll down". Every documentation site solves this the same way, and the
|
|
80
|
+
* anchor is a real link — focusable, copyable, and working without script.
|
|
81
|
+
*
|
|
82
|
+
* `h1` is skipped: it names the page, which the page URL already addresses.
|
|
83
|
+
* Transcluded headings are skipped too, since their `id` belongs to the
|
|
84
|
+
* document they came from and linking here would send a reader to a copy.
|
|
85
|
+
*/
|
|
86
|
+
export function rehypeHeadingAnchors() {
|
|
87
|
+
return (tree: Root) => {
|
|
88
|
+
visit(tree, 'element', (node: Element) => {
|
|
89
|
+
if (!ANCHORED_LEVELS.has(node.tagName)) return;
|
|
90
|
+
if (node.properties?.dataTranscluded) return;
|
|
91
|
+
|
|
92
|
+
const id = node.properties?.id;
|
|
93
|
+
if (typeof id !== 'string' || !id) return;
|
|
94
|
+
|
|
95
|
+
node.properties.className = [
|
|
96
|
+
...(Array.isArray(node.properties.className) ? node.properties.className.map(String) : []),
|
|
97
|
+
'ezw-heading',
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
node.children.push({
|
|
101
|
+
type: 'element',
|
|
102
|
+
tagName: 'a',
|
|
103
|
+
properties: {
|
|
104
|
+
href: `#${id}`,
|
|
105
|
+
className: ['ezw-heading__anchor'],
|
|
106
|
+
// The heading's own text already names the destination; without this
|
|
107
|
+
// a screen reader hears every heading followed by a stray "#".
|
|
108
|
+
'aria-label': `Link to this section: ${toString(node)}`,
|
|
109
|
+
},
|
|
110
|
+
children: [{ type: 'text', value: '#' }],
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
71
116
|
/**
|
|
72
117
|
* Determines whether an href points outside the site.
|
|
73
118
|
*/
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { visit } from 'unist-util-visit';
|
|
2
|
+
import type { Root, Blockquote, PhrasingContent, BlockContent } from 'mdast';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Turns `> [!NOTE]` blockquotes into callouts.
|
|
6
|
+
*
|
|
7
|
+
* The syntax is GitHub's and Obsidian's alike, which is the reason for
|
|
8
|
+
* choosing it: a document written for either renders here, and one written
|
|
9
|
+
* here still reads as an ordinary blockquote anywhere that does not know the
|
|
10
|
+
* convention. Nothing is invented.
|
|
11
|
+
*
|
|
12
|
+
* Runs on the Markdown AST so the body passes through the rest of the pipeline
|
|
13
|
+
* unchanged — links, code and wiki links inside a callout behave exactly as
|
|
14
|
+
* they do outside one.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Marker opening a callout: the kind, an optional fold hint, and an optional
|
|
19
|
+
* title on the same line.
|
|
20
|
+
*/
|
|
21
|
+
const MARKER = /^\[!([A-Za-z]+)\]([-+])?\s*(.*)$/;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Callout kinds, and the ones that are the same thing under another name.
|
|
25
|
+
*
|
|
26
|
+
* GitHub defines five; Obsidian defines more and its vaults use them, so the
|
|
27
|
+
* extra names map onto the nearest kind rather than falling back to a plain
|
|
28
|
+
* quote. A vault should not lose its formatting on the way in.
|
|
29
|
+
*/
|
|
30
|
+
const KINDS: Record<string, string> = {
|
|
31
|
+
note: 'note',
|
|
32
|
+
info: 'note',
|
|
33
|
+
abstract: 'note',
|
|
34
|
+
summary: 'note',
|
|
35
|
+
tip: 'tip',
|
|
36
|
+
hint: 'tip',
|
|
37
|
+
success: 'tip',
|
|
38
|
+
check: 'tip',
|
|
39
|
+
done: 'tip',
|
|
40
|
+
important: 'important',
|
|
41
|
+
example: 'important',
|
|
42
|
+
question: 'important',
|
|
43
|
+
help: 'important',
|
|
44
|
+
faq: 'important',
|
|
45
|
+
warning: 'warning',
|
|
46
|
+
attention: 'warning',
|
|
47
|
+
todo: 'warning',
|
|
48
|
+
caution: 'caution',
|
|
49
|
+
danger: 'caution',
|
|
50
|
+
error: 'caution',
|
|
51
|
+
failure: 'caution',
|
|
52
|
+
fail: 'caution',
|
|
53
|
+
bug: 'caution',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** What a callout marker said. */
|
|
57
|
+
interface Marker {
|
|
58
|
+
/** Normalised kind, one of the values in {@link KINDS} */
|
|
59
|
+
kind: string;
|
|
60
|
+
/** Heading text, defaulting to the kind when the author gave none */
|
|
61
|
+
title: PhrasingContent[];
|
|
62
|
+
/** Whether the body folds away, and whether it starts open */
|
|
63
|
+
fold: 'none' | 'open' | 'closed';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Reads the marker from the first line of a blockquote.
|
|
68
|
+
*
|
|
69
|
+
* @param node - The blockquote to inspect
|
|
70
|
+
* @returns The marker, or null when this is an ordinary quote
|
|
71
|
+
*/
|
|
72
|
+
function readMarker(node: Blockquote): Marker | null {
|
|
73
|
+
const [first] = node.children;
|
|
74
|
+
if (!first || first.type !== 'paragraph') return null;
|
|
75
|
+
|
|
76
|
+
const [lead] = first.children;
|
|
77
|
+
if (!lead || lead.type !== 'text') return null;
|
|
78
|
+
|
|
79
|
+
// Only the first line carries the marker; the rest of the paragraph is body.
|
|
80
|
+
const newline = lead.value.indexOf('\n');
|
|
81
|
+
const head = newline === -1 ? lead.value : lead.value.slice(0, newline);
|
|
82
|
+
|
|
83
|
+
const match = MARKER.exec(head.trim());
|
|
84
|
+
if (!match) return null;
|
|
85
|
+
|
|
86
|
+
const kind = KINDS[match[1].toLowerCase()];
|
|
87
|
+
if (!kind) return null;
|
|
88
|
+
|
|
89
|
+
const rest = newline === -1 ? '' : lead.value.slice(newline + 1);
|
|
90
|
+
const heading = match[3].trim();
|
|
91
|
+
|
|
92
|
+
// Everything after the marker line stays in the body, including any inline
|
|
93
|
+
// nodes that followed the opening text.
|
|
94
|
+
const body: PhrasingContent[] = [
|
|
95
|
+
...(rest ? [{ type: 'text' as const, value: rest }] : []),
|
|
96
|
+
...first.children.slice(1),
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
node.children = [
|
|
100
|
+
...(body.length ? [{ type: 'paragraph' as const, children: body }] : []),
|
|
101
|
+
...node.children.slice(1),
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
kind,
|
|
106
|
+
title: [{ type: 'text', value: heading || titleFor(kind) }],
|
|
107
|
+
fold: match[2] === '-' ? 'closed' : match[2] === '+' ? 'open' : 'none',
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Default heading for a kind, when the author supplied none. */
|
|
112
|
+
function titleFor(kind: string): string {
|
|
113
|
+
return kind.charAt(0).toUpperCase() + kind.slice(1);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Builds the callout node.
|
|
118
|
+
*
|
|
119
|
+
* A foldable callout becomes `<details>`, which opens and closes without any
|
|
120
|
+
* script — the browser already knows how to do this, and a disclosure that
|
|
121
|
+
* depends on JavaScript is one that fails with it disabled.
|
|
122
|
+
*/
|
|
123
|
+
function toCallout(node: Blockquote, marker: Marker): Blockquote {
|
|
124
|
+
const foldable = marker.fold !== 'none';
|
|
125
|
+
|
|
126
|
+
const heading: BlockContent = {
|
|
127
|
+
type: 'paragraph',
|
|
128
|
+
data: {
|
|
129
|
+
hName: foldable ? 'summary' : 'p',
|
|
130
|
+
hProperties: { className: ['ezw-callout__title'] },
|
|
131
|
+
},
|
|
132
|
+
children: marker.title,
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
type: 'blockquote',
|
|
137
|
+
data: {
|
|
138
|
+
hName: foldable ? 'details' : 'div',
|
|
139
|
+
hProperties: {
|
|
140
|
+
className: ['ezw-callout', `ezw-callout--${marker.kind}`],
|
|
141
|
+
...(marker.fold === 'open' ? { open: true } : {}),
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
children: [heading, ...node.children],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Remark plugin factory.
|
|
150
|
+
*
|
|
151
|
+
* @example
|
|
152
|
+
* ```typescript
|
|
153
|
+
* unified().use(remarkParse).use(remarkCallouts);
|
|
154
|
+
* // > [!WARNING] Mind the gap
|
|
155
|
+
* // > Body text.
|
|
156
|
+
* ```
|
|
157
|
+
*/
|
|
158
|
+
export function remarkCallouts() {
|
|
159
|
+
return (tree: Root) => {
|
|
160
|
+
visit(tree, 'blockquote', (node: Blockquote, index, parent) => {
|
|
161
|
+
if (!parent || index === undefined) return;
|
|
162
|
+
|
|
163
|
+
const marker = readMarker(node);
|
|
164
|
+
if (!marker) return;
|
|
165
|
+
|
|
166
|
+
parent.children[index] = toCallout(node, marker);
|
|
167
|
+
|
|
168
|
+
// Skip the node just written: its children have already been read, and
|
|
169
|
+
// revisiting would look at a blockquote that is now a callout.
|
|
170
|
+
return index + 1;
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -14,11 +14,14 @@ import {
|
|
|
14
14
|
rehypeBasePath,
|
|
15
15
|
rehypeCodeShell,
|
|
16
16
|
rehypeCollectHeadings,
|
|
17
|
+
rehypeHeadingAnchors,
|
|
17
18
|
rehypeImages,
|
|
18
19
|
rehypeInternalLinks,
|
|
19
20
|
type Heading,
|
|
20
21
|
} from './rehype-plugins';
|
|
21
22
|
import { remarkWikiLinks, type WikiLinkTarget, type TranscludeTarget } from './remark-wikilink';
|
|
23
|
+
import { remarkCallouts } from './remark-callout';
|
|
24
|
+
import { rehypeMermaid } from './rehype-mermaid';
|
|
22
25
|
import { getUsedLanguages } from './languages';
|
|
23
26
|
import { cached } from '../cache';
|
|
24
27
|
import { BASE_PATH } from '../basePath';
|
|
@@ -171,11 +174,19 @@ function resolveWikiTransclusion(target: string, anchor?: string): TranscludeTar
|
|
|
171
174
|
* Builds the shared unified processor.
|
|
172
175
|
*
|
|
173
176
|
* Plugin order is load-bearing:
|
|
177
|
+
* - `remarkCallouts` must run before the wiki-link pass, so that a link written
|
|
178
|
+
* inside a callout is resolved like any other rather than being left in the
|
|
179
|
+
* text the marker was read from.
|
|
174
180
|
* - `remarkWikiLinks` must run while the tree is still Markdown, so the links
|
|
175
181
|
* it produces are processed like any other link downstream.
|
|
176
182
|
* - `rehype-raw` must follow `remark-rehype` with `allowDangerousHtml`, so that
|
|
177
183
|
* inline HTML in Markdown is parsed rather than escaped.
|
|
178
|
-
* - `rehype-slug` must precede heading collection
|
|
184
|
+
* - `rehype-slug` must precede heading collection and the heading anchors,
|
|
185
|
+
* both of which read the ids it adds.
|
|
186
|
+
* - `rehypeHeadingAnchors` must follow heading collection, or the anchor's own
|
|
187
|
+
* text would be gathered into the contents rail.
|
|
188
|
+
* - `rehypeMermaid` must precede both, so a diagram fence never becomes a code
|
|
189
|
+
* block, and one it cannot draw still does.
|
|
179
190
|
* - `rehypeCodeShell` must precede the highlighter, since it reads the
|
|
180
191
|
* `language-*` class that highlighting replaces.
|
|
181
192
|
* - `rehypeBasePath` runs last among the link plugins, so it prefixes the
|
|
@@ -186,6 +197,7 @@ function createProcessor(): Processor {
|
|
|
186
197
|
.use(remarkParse)
|
|
187
198
|
.use(remarkGfm)
|
|
188
199
|
.use(remarkMath)
|
|
200
|
+
.use(remarkCallouts)
|
|
189
201
|
.use(remarkWikiLinks, {
|
|
190
202
|
link: resolveWikiLink,
|
|
191
203
|
embed: resolveWikiEmbed,
|
|
@@ -195,9 +207,11 @@ function createProcessor(): Processor {
|
|
|
195
207
|
.use(rehypeRaw)
|
|
196
208
|
.use(rehypeSlug)
|
|
197
209
|
.use(rehypeCollectHeadings)
|
|
210
|
+
.use(rehypeHeadingAnchors)
|
|
198
211
|
.use(rehypeKatex)
|
|
199
212
|
.use(rehypeInternalLinks, getUrlMap())
|
|
200
213
|
.use(rehypeImages)
|
|
214
|
+
.use(rehypeMermaid)
|
|
201
215
|
.use(rehypeCodeShell)
|
|
202
216
|
.use(rehypeShiki, {
|
|
203
217
|
themes: SHIKI_THEMES,
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { getAdjacentPages } from './sequence';
|
|
3
|
+
import { getSite } from '../site';
|
|
4
|
+
import { extractAllPaths, filterHiddenItems } from './builder';
|
|
5
|
+
|
|
6
|
+
/** The reading order, as the sidebar presents it. */
|
|
7
|
+
function order(): string[] {
|
|
8
|
+
return extractAllPaths(filterHiddenItems(getSite().navigation));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('getAdjacentPages', () => {
|
|
12
|
+
// Written against however many pages there are: a scaffolded project starts
|
|
13
|
+
// with two, and indexing past them would fail there rather than here.
|
|
14
|
+
it('follows the order the sidebar shows', () => {
|
|
15
|
+
const sequence = order();
|
|
16
|
+
|
|
17
|
+
for (let i = 0; i < sequence.length; i++) {
|
|
18
|
+
const { previous, next } = getAdjacentPages(sequence[i]);
|
|
19
|
+
|
|
20
|
+
if (i > 0) expect(previous?.url).toContain(sequence[i - 1]);
|
|
21
|
+
if (i < sequence.length - 1) expect(next?.url).toContain(sequence[i + 1]);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('gives the first page no predecessor', () => {
|
|
26
|
+
expect(getAdjacentPages(order()[0]).previous).toBeNull();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('gives the last page no successor', () => {
|
|
30
|
+
const sequence = order();
|
|
31
|
+
|
|
32
|
+
expect(getAdjacentPages(sequence[sequence.length - 1]).next).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('titles each neighbour', () => {
|
|
36
|
+
const sequence = order();
|
|
37
|
+
// Skipped rather than failed on a one-page wiki, which has no neighbours
|
|
38
|
+
// to title.
|
|
39
|
+
if (sequence.length < 2) return;
|
|
40
|
+
|
|
41
|
+
const { next } = getAdjacentPages(sequence[0]);
|
|
42
|
+
|
|
43
|
+
expect(next?.title).toBeTruthy();
|
|
44
|
+
expect(next?.title).not.toBe(next?.url);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Stepping through a guide should not land on a page deliberately kept out
|
|
48
|
+
// of the sidebar.
|
|
49
|
+
it('leaves hidden pages out of the sequence', () => {
|
|
50
|
+
const { hiddenPaths } = getSite();
|
|
51
|
+
const sequence = order();
|
|
52
|
+
|
|
53
|
+
for (const hidden of hiddenPaths) {
|
|
54
|
+
expect(sequence).not.toContain(hidden);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('offers nothing for a page outside the sequence', () => {
|
|
59
|
+
expect(getAdjacentPages('no/such/page')).toEqual({ previous: null, next: null });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Every URL it produces is the trailing-slash form the export serves, or the
|
|
63
|
+
// links here would redirect like the ones fixed elsewhere.
|
|
64
|
+
it('emits URLs in the form the export serves', () => {
|
|
65
|
+
for (const path of order()) {
|
|
66
|
+
const { previous, next } = getAdjacentPages(path);
|
|
67
|
+
|
|
68
|
+
for (const page of [previous, next]) {
|
|
69
|
+
if (page) expect(page.url).toMatch(/^\/.*\/$/);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { getSite } from '../site';
|
|
2
|
+
import { extractAllPaths, filterHiddenItems } from './builder';
|
|
3
|
+
import { docPathToUrl } from './url';
|
|
4
|
+
import { getDoc } from '../content/registry';
|
|
5
|
+
import { cached } from '../cache';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The order pages are meant to be read in.
|
|
9
|
+
*
|
|
10
|
+
* The sidebar already answers this: sections come from the content tree, order
|
|
11
|
+
* from `_meta.json` and frontmatter, and reading it top to bottom is how a
|
|
12
|
+
* reader works through a guide. Flattening that same tree gives the sequence,
|
|
13
|
+
* so the two can never disagree — a separate ordering would be one more thing
|
|
14
|
+
* to keep in step.
|
|
15
|
+
*
|
|
16
|
+
* Server-only.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** A page next to another in reading order. */
|
|
20
|
+
export interface AdjacentPage {
|
|
21
|
+
/** Display title */
|
|
22
|
+
title: string;
|
|
23
|
+
/** Href, in the site's configured URL form */
|
|
24
|
+
url: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What comes before and after a page. */
|
|
28
|
+
export interface Adjacent {
|
|
29
|
+
previous: AdjacentPage | null;
|
|
30
|
+
next: AdjacentPage | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let memo: string[] | null = null;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Every page in reading order, hidden ones excluded.
|
|
37
|
+
*
|
|
38
|
+
* Hidden pages are reachable by direct link but are not part of the sequence:
|
|
39
|
+
* stepping through a guide should not land on something deliberately unlisted.
|
|
40
|
+
*
|
|
41
|
+
* @returns Content paths, in the order the sidebar shows them
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```typescript
|
|
45
|
+
* getReadingOrder()[0]; // the page a reader starts at
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function getReadingOrder(): string[] {
|
|
49
|
+
const hit = cached(memo);
|
|
50
|
+
if (hit) return hit;
|
|
51
|
+
|
|
52
|
+
const { navigation } = getSite();
|
|
53
|
+
|
|
54
|
+
// Sections that only group pages carry no path of their own and drop out
|
|
55
|
+
// here, leaving just the readable pages.
|
|
56
|
+
memo = extractAllPaths(filterHiddenItems(navigation));
|
|
57
|
+
return memo;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Finds the pages either side of one in reading order.
|
|
62
|
+
*
|
|
63
|
+
* @param path - Content path of the current page
|
|
64
|
+
* @returns The neighbours, each null at the ends of the sequence
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```typescript
|
|
68
|
+
* const { previous, next } = getAdjacentPages('getting-started/installation');
|
|
69
|
+
* next?.title; // 'Your First Wiki'
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
export function getAdjacentPages(path: string): Adjacent {
|
|
73
|
+
const sequence = getReadingOrder();
|
|
74
|
+
const index = sequence.indexOf(path);
|
|
75
|
+
|
|
76
|
+
// A hidden page, or one reached by a URL that navigation does not cover, has
|
|
77
|
+
// no place in the sequence and so no neighbours to offer.
|
|
78
|
+
if (index === -1) return { previous: null, next: null };
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
previous: toAdjacent(sequence[index - 1]),
|
|
82
|
+
next: toAdjacent(sequence[index + 1]),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolves a content path to the title and href a link needs.
|
|
88
|
+
*
|
|
89
|
+
* @param path - Content path, or undefined at either end of the sequence
|
|
90
|
+
* @returns The page, or null when there is none
|
|
91
|
+
*/
|
|
92
|
+
function toAdjacent(path: string | undefined): AdjacentPage | null {
|
|
93
|
+
if (!path) return null;
|
|
94
|
+
|
|
95
|
+
const { urlMap } = getSite();
|
|
96
|
+
const url = docPathToUrl(urlMap, path);
|
|
97
|
+
if (!url) return null;
|
|
98
|
+
|
|
99
|
+
return { title: getDoc(path)?.title ?? path, url: `/${url}/` };
|
|
100
|
+
}
|