create-eziwiki 0.1.1 → 0.2.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 +3 -1
- 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/PageLayout.tsx +11 -3
- 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/assets.ts +158 -0
- package/template/lib/content/excerpt.test.ts +68 -0
- package/template/lib/content/excerpt.ts +142 -0
- package/template/lib/graph/build.ts +55 -0
- package/template/lib/markdown/rehype-plugins.ts +5 -0
- package/template/lib/markdown/remark-wikilink.ts +201 -14
- package/template/lib/markdown/render.ts +110 -8
- package/template/lib/markdown/wikilink.test.ts +30 -0
- package/template/lib/markdown/wikilink.ts +12 -4
- package/template/lib/payload/schema.ts +1 -0
- package/template/lib/payload/types.ts +7 -0
- package/template/package-lock.json +3 -83
- package/template/package.json +2 -0
- package/template/styles/markdown.css +74 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildExcerpt, getExcerpt } from './excerpt';
|
|
3
|
+
|
|
4
|
+
describe('buildExcerpt', () => {
|
|
5
|
+
it('prefers the description an author wrote', () => {
|
|
6
|
+
expect(buildExcerpt('# Title\n\nThe body.', 'A deliberate summary')).toBe(
|
|
7
|
+
'A deliberate summary',
|
|
8
|
+
);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('falls back to the opening prose', () => {
|
|
12
|
+
expect(buildExcerpt('# Title\n\nThe first sentence.')).toBe('The first sentence.');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// The card shows the title on its own line, so repeating it as the summary
|
|
16
|
+
// would waste the space there is.
|
|
17
|
+
it('skips the title heading', () => {
|
|
18
|
+
expect(buildExcerpt('# Page Title\n\nBody text.')).not.toContain('Page Title');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('skips code, rules and tables to reach the prose', () => {
|
|
22
|
+
const markdown = '# T\n\n```bash\nnpm install\n```\n\n---\n\nThe actual sentence.';
|
|
23
|
+
|
|
24
|
+
expect(buildExcerpt(markdown)).toBe('The actual sentence.');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// A page opening with a figure should preview as the sentence under it, not
|
|
28
|
+
// as the image's alt text.
|
|
29
|
+
it('leaves image alt text out', () => {
|
|
30
|
+
expect(buildExcerpt('# T\n\n\n\nReal prose.')).toBe(
|
|
31
|
+
'Real prose.',
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('flattens inline markup and whitespace', () => {
|
|
36
|
+
expect(buildExcerpt('# T\n\nSome **bold**\nand `code` here.')).toBe('Some bold and code here.');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('cuts at a word boundary rather than mid-word', () => {
|
|
40
|
+
const long = `# T\n\n${'alpha '.repeat(80)}`;
|
|
41
|
+
const excerpt = buildExcerpt(long);
|
|
42
|
+
|
|
43
|
+
expect(excerpt.endsWith('…')).toBe(true);
|
|
44
|
+
expect(excerpt).not.toMatch(/alph…$/);
|
|
45
|
+
expect(excerpt.length).toBeLessThanOrEqual(181);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('returns nothing for a document with no prose', () => {
|
|
49
|
+
expect(buildExcerpt('# Only a title\n')).toBe('');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('getExcerpt', () => {
|
|
54
|
+
// `intro` is the one document both this repository and a freshly scaffolded
|
|
55
|
+
// project have, so this test travels with the engine rather than having to be
|
|
56
|
+
// held back from it.
|
|
57
|
+
it('summarises a document from the registry', () => {
|
|
58
|
+
expect(getExcerpt('intro').length).toBeGreaterThan(0);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('returns the same summary on a repeat call', () => {
|
|
62
|
+
expect(getExcerpt('intro')).toBe(getExcerpt('intro'));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('returns an empty summary for an unknown path', () => {
|
|
66
|
+
expect(getExcerpt('no/such/document')).toBe('');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -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
|
*
|
|
@@ -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
|
|
|
@@ -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);
|