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.
@@ -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![a diagram of the pipeline](/x.png)\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 { Root, Text, PhrasingContent, Parent } from 'mdast';
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, resolve: WikiLinkResolver): PhrasingContent {
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: { className: ['ezw-wikilink'] },
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 resolve - Target resolver
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, resolve: WikiLinkResolver): PhrasingContent[] | null {
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[1], match[0]);
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, resolve));
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 resolve - Resolves a target to its destination
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, (target) =>
123
- * target === 'intro' ? { url: '/intro', title: 'Introduction' } : null,
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(resolve: WikiLinkResolver) {
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, resolve);
319
+ const replacement = splitText(node, resolvers);
133
320
  if (!replacement) return;
134
321
 
135
322
  (parent as Parent).children.splice(index, 1, ...replacement);