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
|
@@ -49,6 +49,11 @@ export function rehypeCollectHeadings() {
|
|
|
49
49
|
visit(tree, 'element', (node: Element) => {
|
|
50
50
|
if (!TOC_LEVELS.has(node.tagName)) return;
|
|
51
51
|
|
|
52
|
+
// A transcluded heading belongs to the document it came from. Listing it
|
|
53
|
+
// would offer a reader sections that are not this page's own, and two
|
|
54
|
+
// entries with the same name whenever a page includes part of another.
|
|
55
|
+
if (node.properties?.dataTranscluded) return;
|
|
56
|
+
|
|
52
57
|
const id = node.properties?.id;
|
|
53
58
|
if (typeof id !== 'string' || !id) return;
|
|
54
59
|
|
|
@@ -63,6 +68,51 @@ export function rehypeCollectHeadings() {
|
|
|
63
68
|
};
|
|
64
69
|
}
|
|
65
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
|
+
|
|
66
116
|
/**
|
|
67
117
|
* Determines whether an href points outside the site.
|
|
68
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
|
+
}
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { visit } from 'unist-util-visit';
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
Root,
|
|
4
|
+
Text,
|
|
5
|
+
PhrasingContent,
|
|
6
|
+
Parent,
|
|
7
|
+
Paragraph,
|
|
8
|
+
RootContent,
|
|
9
|
+
BlockContent,
|
|
10
|
+
} from 'mdast';
|
|
3
11
|
import { WIKILINK_PATTERN, parseWikiLink, type WikiLink } from './wikilink';
|
|
4
12
|
|
|
5
13
|
/**
|
|
@@ -18,6 +26,8 @@ export interface WikiLinkTarget {
|
|
|
18
26
|
url: string;
|
|
19
27
|
/** Default display text when the author gave no label */
|
|
20
28
|
title: string;
|
|
29
|
+
/** One-line summary, carried on the link so a hover card needs no request */
|
|
30
|
+
excerpt?: string;
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
/**
|
|
@@ -25,6 +35,48 @@ export interface WikiLinkTarget {
|
|
|
25
35
|
*/
|
|
26
36
|
export type WikiLinkResolver = (target: string) => WikiLinkTarget | null;
|
|
27
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Resolves an embed target to a static file, or null when there is none.
|
|
40
|
+
*/
|
|
41
|
+
export type EmbedResolver = (target: string) => { url: string } | null;
|
|
42
|
+
|
|
43
|
+
/** A document whose content is to be shown inside another. */
|
|
44
|
+
export interface TranscludeTarget {
|
|
45
|
+
/** Content path, used to detect a page including itself */
|
|
46
|
+
path: string;
|
|
47
|
+
/** Href of the source document */
|
|
48
|
+
url: string;
|
|
49
|
+
/** Title of the source document */
|
|
50
|
+
title: string;
|
|
51
|
+
/** The Markdown nodes to splice in */
|
|
52
|
+
nodes: RootContent[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolves an embed target to a document's content, or null when the target is
|
|
57
|
+
* not a document or the named section does not exist.
|
|
58
|
+
*/
|
|
59
|
+
export type TranscludeResolver = (target: string, anchor?: string) => TranscludeTarget | null;
|
|
60
|
+
|
|
61
|
+
/** How a wiki link should be turned into a node. */
|
|
62
|
+
export interface WikiLinkResolvers {
|
|
63
|
+
/** Resolves a document target */
|
|
64
|
+
link: WikiLinkResolver;
|
|
65
|
+
/** Resolves an embeddable file; absent when embeds are not supported */
|
|
66
|
+
embed?: EmbedResolver;
|
|
67
|
+
/** Resolves a document to include inline; absent disables transclusion */
|
|
68
|
+
transclude?: TranscludeResolver;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* How deeply a transclusion may nest.
|
|
73
|
+
*
|
|
74
|
+
* A page including a page that includes a page is already hard to read; past
|
|
75
|
+
* that it is more likely a mistake than an intent, and each level multiplies
|
|
76
|
+
* the work the build does.
|
|
77
|
+
*/
|
|
78
|
+
const MAX_TRANSCLUSION_DEPTH = 3;
|
|
79
|
+
|
|
28
80
|
/**
|
|
29
81
|
* Builds the replacement node for one wiki link.
|
|
30
82
|
*
|
|
@@ -32,7 +84,24 @@ export type WikiLinkResolver = (target: string) => WikiLinkTarget | null;
|
|
|
32
84
|
* that goes nowhere is worse than visibly broken text, because it looks
|
|
33
85
|
* clickable and silently is not.
|
|
34
86
|
*/
|
|
35
|
-
function toNode(link: WikiLink,
|
|
87
|
+
function toNode(link: WikiLink, resolvers: WikiLinkResolvers): PhrasingContent {
|
|
88
|
+
const resolve = resolvers.link;
|
|
89
|
+
|
|
90
|
+
// `![[file.png]]` shows the file rather than linking to it. Only static
|
|
91
|
+
// assets are embedded for now; `![[some-note]]` falls through to a link, so
|
|
92
|
+
// an author who writes it gets a working reference instead of nothing.
|
|
93
|
+
if (link.embed && resolvers.embed) {
|
|
94
|
+
const asset = resolvers.embed(link.target);
|
|
95
|
+
|
|
96
|
+
if (asset) {
|
|
97
|
+
return {
|
|
98
|
+
type: 'image',
|
|
99
|
+
url: asset.url,
|
|
100
|
+
alt: link.label ?? link.target,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
36
105
|
// An anchor-only link points within the current page, so there is nothing to
|
|
37
106
|
// resolve.
|
|
38
107
|
if (!link.target && link.anchor) {
|
|
@@ -65,7 +134,14 @@ function toNode(link: WikiLink, resolve: WikiLinkResolver): PhrasingContent {
|
|
|
65
134
|
type: 'link',
|
|
66
135
|
url: link.anchor ? `${resolved.url}#${link.anchor}` : resolved.url,
|
|
67
136
|
data: {
|
|
68
|
-
hProperties: {
|
|
137
|
+
hProperties: {
|
|
138
|
+
className: ['ezw-wikilink'],
|
|
139
|
+
// The card's contents travel with the link rather than being fetched.
|
|
140
|
+
// The build already knows them, and a reader who hovers should not wait
|
|
141
|
+
// on a request to find out where a link goes.
|
|
142
|
+
'data-preview-title': resolved.title,
|
|
143
|
+
...(resolved.excerpt ? { 'data-preview': resolved.excerpt } : {}),
|
|
144
|
+
},
|
|
69
145
|
},
|
|
70
146
|
children: [{ type: 'text', value: link.label ?? resolved.title }],
|
|
71
147
|
};
|
|
@@ -75,10 +151,10 @@ function toNode(link: WikiLink, resolve: WikiLinkResolver): PhrasingContent {
|
|
|
75
151
|
* Splits a text node into text and link nodes.
|
|
76
152
|
*
|
|
77
153
|
* @param node - The text node to split
|
|
78
|
-
* @param
|
|
154
|
+
* @param resolvers - Target resolvers for links and embeds
|
|
79
155
|
* @returns Replacement nodes, or null when the text contains no wiki links
|
|
80
156
|
*/
|
|
81
|
-
function splitText(node: Text,
|
|
157
|
+
function splitText(node: Text, resolvers: WikiLinkResolvers): PhrasingContent[] | null {
|
|
82
158
|
const { value } = node;
|
|
83
159
|
if (!value.includes('[[')) return null;
|
|
84
160
|
|
|
@@ -89,7 +165,7 @@ function splitText(node: Text, resolve: WikiLinkResolver): PhrasingContent[] | n
|
|
|
89
165
|
// matchAll on a global pattern is safe here because the regex literal is
|
|
90
166
|
// re-evaluated per call; lastIndex never leaks between documents.
|
|
91
167
|
for (const match of value.matchAll(WIKILINK_PATTERN)) {
|
|
92
|
-
const parsed = parseWikiLink(match[
|
|
168
|
+
const parsed = parseWikiLink(match[2], match[0], match[1] === '!');
|
|
93
169
|
if (!parsed) continue;
|
|
94
170
|
|
|
95
171
|
const start = match.index ?? 0;
|
|
@@ -98,7 +174,7 @@ function splitText(node: Text, resolve: WikiLinkResolver): PhrasingContent[] | n
|
|
|
98
174
|
replacement.push({ type: 'text', value: value.slice(cursor, start) });
|
|
99
175
|
}
|
|
100
176
|
|
|
101
|
-
replacement.push(toNode(parsed,
|
|
177
|
+
replacement.push(toNode(parsed, resolvers));
|
|
102
178
|
cursor = start + match[0].length;
|
|
103
179
|
matched = true;
|
|
104
180
|
}
|
|
@@ -112,24 +188,135 @@ function splitText(node: Text, resolve: WikiLinkResolver): PhrasingContent[] | n
|
|
|
112
188
|
return replacement;
|
|
113
189
|
}
|
|
114
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Reads a paragraph that consists of nothing but one embed.
|
|
193
|
+
*
|
|
194
|
+
* Transclusion replaces a paragraph with whole blocks — headings, lists, code —
|
|
195
|
+
* which cannot sit inside one. So it applies only when the embed is alone in
|
|
196
|
+
* its paragraph, which is also how an author writes it. An embed with prose
|
|
197
|
+
* beside it stays inline and becomes a link.
|
|
198
|
+
*
|
|
199
|
+
* @param node - Paragraph to inspect
|
|
200
|
+
* @returns The embed, or null when the paragraph holds anything else
|
|
201
|
+
*/
|
|
202
|
+
function soleEmbed(node: Paragraph): WikiLink | null {
|
|
203
|
+
if (node.children.length !== 1) return null;
|
|
204
|
+
|
|
205
|
+
const [child] = node.children;
|
|
206
|
+
if (child.type !== 'text') return null;
|
|
207
|
+
|
|
208
|
+
const text = child.value.trim();
|
|
209
|
+
const match = /^(!?)\[\[([^\]\n]+)\]\]$/.exec(text);
|
|
210
|
+
if (!match || match[1] !== '!') return null;
|
|
211
|
+
|
|
212
|
+
return parseWikiLink(match[2], match[0], true);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Wraps included content so a reader can see where it came from.
|
|
217
|
+
*
|
|
218
|
+
* `blockquote` carries the block children — it is a known type that accepts
|
|
219
|
+
* them — while `hName` renders it as a plain division.
|
|
220
|
+
*/
|
|
221
|
+
function wrapTranscluded(target: TranscludeTarget, nodes: RootContent[]): RootContent {
|
|
222
|
+
return {
|
|
223
|
+
type: 'blockquote',
|
|
224
|
+
data: {
|
|
225
|
+
hName: 'div',
|
|
226
|
+
hProperties: { className: ['ezw-transclusion'] },
|
|
227
|
+
},
|
|
228
|
+
children: [
|
|
229
|
+
...(nodes as BlockContent[]),
|
|
230
|
+
{
|
|
231
|
+
type: 'paragraph',
|
|
232
|
+
data: { hProperties: { className: ['ezw-transclusion__source'] } },
|
|
233
|
+
children: [
|
|
234
|
+
{
|
|
235
|
+
type: 'link',
|
|
236
|
+
url: target.url,
|
|
237
|
+
children: [{ type: 'text', value: target.title }],
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Marks headings that arrived by transclusion.
|
|
247
|
+
*
|
|
248
|
+
* The table of contents describes the page a reader is on. Headings pulled in
|
|
249
|
+
* from elsewhere would list sections that belong to another document, so they
|
|
250
|
+
* are flagged here and skipped when the contents are collected.
|
|
251
|
+
*/
|
|
252
|
+
function markTranscludedHeadings(tree: Root): void {
|
|
253
|
+
visit(tree, 'heading', (heading) => {
|
|
254
|
+
heading.data ??= {};
|
|
255
|
+
const properties = (heading.data.hProperties ??= {}) as Record<string, unknown>;
|
|
256
|
+
properties['data-transcluded'] = 'true';
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Replaces sole-embed paragraphs with the content they name.
|
|
262
|
+
*
|
|
263
|
+
* @param tree - Tree to transform in place
|
|
264
|
+
* @param resolvers - Target resolvers
|
|
265
|
+
* @param stack - Paths already being included, innermost last
|
|
266
|
+
*/
|
|
267
|
+
function transclude(tree: Root, resolvers: WikiLinkResolvers, stack: string[]): void {
|
|
268
|
+
const resolve = resolvers.transclude;
|
|
269
|
+
if (!resolve) return;
|
|
270
|
+
|
|
271
|
+
visit(tree, 'paragraph', (node: Paragraph, index, parent) => {
|
|
272
|
+
if (!parent || index === undefined) return;
|
|
273
|
+
|
|
274
|
+
const embed = soleEmbed(node);
|
|
275
|
+
if (!embed || !embed.target) return;
|
|
276
|
+
|
|
277
|
+
const target = resolve(embed.target, embed.anchor);
|
|
278
|
+
if (!target) return;
|
|
279
|
+
|
|
280
|
+
// A page including itself, directly or through a chain, would never finish.
|
|
281
|
+
// Leaving the link is the honest outcome: the reference is real, it just
|
|
282
|
+
// cannot be shown here.
|
|
283
|
+
if (stack.includes(target.path) || stack.length >= MAX_TRANSCLUSION_DEPTH) return;
|
|
284
|
+
|
|
285
|
+
const inner: Root = { type: 'root', children: target.nodes };
|
|
286
|
+
transclude(inner, resolvers, [...stack, target.path]);
|
|
287
|
+
markTranscludedHeadings(inner);
|
|
288
|
+
|
|
289
|
+
parent.children.splice(index, 1, wrapTranscluded(target, inner.children));
|
|
290
|
+
|
|
291
|
+
return index + 1;
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
115
295
|
/**
|
|
116
296
|
* Remark plugin factory.
|
|
117
297
|
*
|
|
118
|
-
* @param
|
|
298
|
+
* @param resolvers - Resolves a target to a document, and optionally to a file
|
|
119
299
|
*
|
|
120
300
|
* @example
|
|
121
301
|
* ```typescript
|
|
122
|
-
* unified().use(remarkParse).use(remarkWikiLinks,
|
|
123
|
-
*
|
|
124
|
-
*
|
|
302
|
+
* unified().use(remarkParse).use(remarkWikiLinks, {
|
|
303
|
+
* link: (target) =>
|
|
304
|
+
* target === 'intro' ? { url: '/intro/', title: 'Introduction' } : null,
|
|
305
|
+
* embed: (target) => (target === 'logo.svg' ? { url: '/images/logo.svg' } : null),
|
|
306
|
+
* });
|
|
125
307
|
* ```
|
|
126
308
|
*/
|
|
127
|
-
export function remarkWikiLinks(
|
|
128
|
-
return (tree: Root) => {
|
|
309
|
+
export function remarkWikiLinks(resolvers: WikiLinkResolvers) {
|
|
310
|
+
return (tree: Root, file: { data: Record<string, unknown> }) => {
|
|
311
|
+
// Transclusion first: it works on whole paragraphs, and the inline pass
|
|
312
|
+
// below would otherwise have already turned the embed into a link.
|
|
313
|
+
const docPath = file?.data?.docPath;
|
|
314
|
+
transclude(tree, resolvers, typeof docPath === 'string' ? [docPath] : []);
|
|
315
|
+
|
|
129
316
|
visit(tree, 'text', (node: Text, index, parent) => {
|
|
130
317
|
if (!parent || index === undefined) return;
|
|
131
318
|
|
|
132
|
-
const replacement = splitText(node,
|
|
319
|
+
const replacement = splitText(node, resolvers);
|
|
133
320
|
if (!replacement) return;
|
|
134
321
|
|
|
135
322
|
(parent as Parent).children.splice(index, 1, ...replacement);
|