@liminis/editor 0.4.1 → 0.6.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 +66 -1
- package/dist/app/editor/LinkClickPlugin.js +5 -1
- package/dist/app/editor/WikiLinkExistencePlugin.d.ts +13 -3
- package/dist/app/editor/WikiLinkExistencePlugin.js +91 -32
- package/dist/app/editor/editorNodes.js +3 -1
- package/dist/app/editor/nodes/BlockAnchorComponent.d.ts +5 -0
- package/dist/app/editor/nodes/BlockAnchorComponent.js +37 -0
- package/dist/app/editor/nodes/BlockAnchorNode.d.ts +42 -0
- package/dist/app/editor/nodes/BlockAnchorNode.js +151 -0
- package/dist/app/editor/nodes/CustomLinkNode.d.ts +14 -0
- package/dist/app/editor/nodes/CustomLinkNode.js +37 -0
- package/dist/app/editor/nodes/TransclusionComponent.d.ts +8 -0
- package/dist/app/editor/nodes/TransclusionComponent.js +65 -0
- package/dist/app/editor/nodes/TransclusionNode.d.ts +48 -0
- package/dist/app/editor/nodes/TransclusionNode.js +141 -0
- package/dist/app/editor/nodes/index.d.ts +4 -0
- package/dist/app/editor/nodes/index.js +2 -0
- package/dist/app/editor/nodes/transclusion-loading.d.ts +27 -0
- package/dist/app/editor/nodes/transclusion-loading.js +29 -0
- package/dist/app/editor/nodes/transclusion-render.d.ts +49 -0
- package/dist/app/editor/nodes/transclusion-render.js +186 -0
- package/dist/app/mapper/lexicalToMdast.js +116 -22
- package/dist/app/mapper/mdastToLexical.js +76 -4
- package/dist/host/defaults.js +1 -0
- package/dist/host/messages.d.ts +7 -1
- package/dist/host/messages.js +3 -3
- package/dist/host/types.d.ts +14 -1
- package/dist/markdown/parse.js +535 -1
- package/dist/markdown/stringify.js +35 -10
- package/dist/markdown/vendor/mdast-util-wiki-link/README.md +17 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.d.ts +8 -1
- package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.js +26 -1
- package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.d.ts +13 -7
- package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.js +3 -1
- package/dist/styles.css +48 -0
- package/dist/types.d.ts +1 -0
- package/docs/decisions/adr-119-block-transclusion.md +247 -0
- package/docs/decisions/adr-122-block-anchor-badge.md +462 -0
- package/docs/editor-api.md +1 -0
- package/docs/markdown-pipeline.md +323 -6
- package/package.json +5 -3
package/dist/markdown/parse.js
CHANGED
|
@@ -14,6 +14,29 @@ import * as wikiLinkMdast from './vendor/mdast-util-wiki-link/index.js';
|
|
|
14
14
|
// Wiki-link options: use | as the alias divider (Obsidian/Foam style)
|
|
15
15
|
const wikiLinkOptions = { aliasDivider: '|' };
|
|
16
16
|
const EMPTY_ALIAS_SENTINEL = '__EMPTY_ALIAS__';
|
|
17
|
+
// Private-Use-Area sentinel marking a `!` that immediately precedes `[[`
|
|
18
|
+
// (candidate transclusion/embed marker, #119). The next free codepoint after
|
|
19
|
+
// `annotate-sentinels.ts`'s E000-E003 range and `stringify.ts`'s E004.
|
|
20
|
+
//
|
|
21
|
+
// Why substitute at all, rather than just checking "does the wikiLink node
|
|
22
|
+
// have a `!` text sibling" after parsing: the micromark wiki-link tokenizer
|
|
23
|
+
// is not vendored (see the vendor README) and only hooks the `[` character.
|
|
24
|
+
// A literal `!` immediately before `[[` is claimed *first* by the default
|
|
25
|
+
// image-label-start construct (hooked on `!`), and when that construct fails
|
|
26
|
+
// to find a following `(url)`/`[ref]` — which it always will here, since
|
|
27
|
+
// `[[target]]` is not image syntax — CommonMark's own bracket-resolution
|
|
28
|
+
// falls the *entire* `![[target]]` span back to one literal text node,
|
|
29
|
+
// without ever giving the wiki-link tokenizer a chance to fire on the inner
|
|
30
|
+
// `[[`. A backslash-escaped `\!` sidesteps the image construct entirely (it
|
|
31
|
+
// is consumed as a plain escaped character, not a construct trigger) and
|
|
32
|
+
// `[[target]]` parses normally — confirmed empirically against
|
|
33
|
+
// `micromark-extension-wiki-link@0.0.4`'s tokenizer. Swapping the raw `!`
|
|
34
|
+
// for this sentinel *before* parsing reproduces that same escape-shaped
|
|
35
|
+
// bypass without a real backslash reaching the output, so `[[target]]` parses
|
|
36
|
+
// as a normal wikiLink node with the sentinel left on the preceding text
|
|
37
|
+
// node — which `resolveWikiEmbeds` (below) then reads to decide embed vs.
|
|
38
|
+
// plain link, and always strips before the tree leaves `parseMarkdown`.
|
|
39
|
+
const EMBED_MARKER_SENTINEL = '\u{E005}';
|
|
17
40
|
/**
|
|
18
41
|
* Escape pipes inside wiki-links: [[target|alias]] → [[target\|alias]]
|
|
19
42
|
* This prevents GFM table parsing from splitting wiki-links at the pipe.
|
|
@@ -57,6 +80,200 @@ function escapeWikiLinkPipes(text) {
|
|
|
57
80
|
result += text.slice(cursor);
|
|
58
81
|
return { text: result, replacements };
|
|
59
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* Swap a `!` for {@link EMBED_MARKER_SENTINEL}, but only when it is
|
|
85
|
+
* immediately followed by a *complete, single-line* `[[...]]` span with no
|
|
86
|
+
* internal `]` — i.e. exactly the shape the wiki-link tokenizer's own
|
|
87
|
+
* `consumeTarget`/`consumeAlias` states require to succeed (a bare `]` not
|
|
88
|
+
* immediately followed by a second `]` aborts the whole construct; so does a
|
|
89
|
+
* line ending). One codepoint swapped for one codepoint, so — unlike
|
|
90
|
+
* `escapeWikiLinkPipes`/`normalizeWikiLinks` above — this never shifts any
|
|
91
|
+
* subsequent offset and needs no `Replacement` tracking of its own.
|
|
92
|
+
*
|
|
93
|
+
* The "complete span" requirement is load-bearing, not a nicety: an `!`
|
|
94
|
+
* immediately before `[` is *also* how a real image's alt text starting with
|
|
95
|
+
* a literal `[` looks at the character level (`![[leading] bracket](x.png)`
|
|
96
|
+
* is `![` + alt text `[leading] bracket` + `](x.png)`, i.e. contains the raw
|
|
97
|
+
* substring `![[`). Matching on `!(?=\[\[)` alone — with no lookahead past
|
|
98
|
+
* the second `[` — can't tell that case apart from a genuine embed candidate
|
|
99
|
+
* and would substitute inside it, preventing the image construct (which
|
|
100
|
+
* needs the literal `!`) from ever being tried and corrupting the image
|
|
101
|
+
* (caught by the `903-image-alt-leading-bracket` regression fixture).
|
|
102
|
+
* Requiring the run between `[[` and `]]` to contain no internal `]` rules
|
|
103
|
+
* that case out: `[leading] bracket](x.png)` hits an un-doubled `]` right
|
|
104
|
+
* after `leading`, so the pattern below never matches there, `!` survives
|
|
105
|
+
* untouched, and the image construct parses exactly as before this feature
|
|
106
|
+
* existed (FR-014).
|
|
107
|
+
*
|
|
108
|
+
* A `!` that is already backslash-escaped (`\![[...]]`) is left alone: that
|
|
109
|
+
* spelling already means "literal `!`, then a normal wiki-link" with no
|
|
110
|
+
* substitution needed (CommonMark consumes the escape before the image
|
|
111
|
+
* construct ever sees the `!`) — see FR-013's requirement that an author can
|
|
112
|
+
* explicitly opt out of transclusion for a `#^id`-bearing target.
|
|
113
|
+
*
|
|
114
|
+
* "Escaped" here means CommonMark backslash-*parity*, not merely "a `\`
|
|
115
|
+
* immediately precedes the `!`": an even run of backslashes (`\\!`, `\\\\!`,
|
|
116
|
+
* ...) pairs off into literal backslashes and does not escape the `!`, while
|
|
117
|
+
* an odd run (`\!`, `\\\!`, ...) does. A naive one-character lookbehind gets
|
|
118
|
+
* every even run ≥ 2 wrong (treats the `!` as escaped when it isn't), so the
|
|
119
|
+
* preceding backslash run is captured and its length checked explicitly.
|
|
120
|
+
*
|
|
121
|
+
* A match inside a fenced code block or inline code span must be skipped
|
|
122
|
+
* entirely, not just left un-embedded: inside those constructs the wiki-link
|
|
123
|
+
* tokenizer never runs at all (code content is verbatim), so a substituted
|
|
124
|
+
* sentinel would never get a `wikiLink` node to attach to and restore from —
|
|
125
|
+
* `resolveWikiEmbeds`'s leftover-sentinel sweep only walks `text` nodes, not
|
|
126
|
+
* a `code`/`inlineCode` node's `value`, so the sentinel would otherwise leak
|
|
127
|
+
* through as a literal, invisible Private-Use-Area character in the final
|
|
128
|
+
* output instead of being restored to `!`. {@link findProtectedRanges}
|
|
129
|
+
* identifies those spans so the substitution can leave them untouched.
|
|
130
|
+
*/
|
|
131
|
+
function substituteEmbedMarker(text) {
|
|
132
|
+
const protectedRanges = findProtectedRanges(text);
|
|
133
|
+
return text.replace(/(\\*)!(\[\[[^\]\n]*\]\])/g, (match, backslashes, bracketed, offset) => {
|
|
134
|
+
if (protectedRanges.some((r) => offset >= r.start && offset < r.end)) {
|
|
135
|
+
return match;
|
|
136
|
+
}
|
|
137
|
+
return backslashes.length % 2 === 1
|
|
138
|
+
? `${backslashes}!${bracketed}`
|
|
139
|
+
: `${backslashes}${EMBED_MARKER_SENTINEL}${bracketed}`;
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Finds fenced code blocks and inline code spans in raw markdown text, so
|
|
144
|
+
* {@link substituteEmbedMarker} can avoid mutating their (verbatim) content.
|
|
145
|
+
* Deliberately approximate rather than a full CommonMark tokenizer — good
|
|
146
|
+
* enough to protect the common cases (``` fences, `inline` spans) without
|
|
147
|
+
* duplicating the real tokenizer this file elsewhere avoids vendoring.
|
|
148
|
+
*/
|
|
149
|
+
function findProtectedRanges(text) {
|
|
150
|
+
const ranges = [];
|
|
151
|
+
// Fenced code blocks: a line of (up to 3 leading spaces then) 3+ backticks
|
|
152
|
+
// or tildes opens one; it's closed by a later line of at least as many of
|
|
153
|
+
// the same character (optionally indented, nothing else on the line).
|
|
154
|
+
const fenceOpenRe = /^ {0,3}(`{3,}|~{3,})/;
|
|
155
|
+
let fenceChar = null;
|
|
156
|
+
let fenceLen = 0;
|
|
157
|
+
let fenceStart = -1;
|
|
158
|
+
let cursor = 0;
|
|
159
|
+
for (const line of text.split('\n')) {
|
|
160
|
+
const lineEnd = cursor + line.length;
|
|
161
|
+
if (fenceChar === null) {
|
|
162
|
+
const m = fenceOpenRe.exec(line);
|
|
163
|
+
if (m) {
|
|
164
|
+
fenceChar = m[1][0];
|
|
165
|
+
fenceLen = m[1].length;
|
|
166
|
+
fenceStart = cursor;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
const closeRe = new RegExp(`^ {0,3}\\${fenceChar}{${fenceLen},}\\s*$`);
|
|
171
|
+
if (closeRe.test(line)) {
|
|
172
|
+
ranges.push({ start: fenceStart, end: lineEnd });
|
|
173
|
+
fenceChar = null;
|
|
174
|
+
fenceLen = 0;
|
|
175
|
+
fenceStart = -1;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
cursor = lineEnd + 1; // +1 for the '\n' consumed by split
|
|
179
|
+
}
|
|
180
|
+
if (fenceChar !== null) {
|
|
181
|
+
ranges.push({ start: fenceStart, end: text.length });
|
|
182
|
+
}
|
|
183
|
+
// Inline code spans, outside any fenced block already found: a backtick
|
|
184
|
+
// run opens a span, closed by the next run of the *same* length (a run of
|
|
185
|
+
// a different length is span content, not a delimiter) — CommonMark's own
|
|
186
|
+
// code-span rule. An opening run with no matching close is not a code
|
|
187
|
+
// span at all (its backticks are literal), so it protects nothing.
|
|
188
|
+
const isInFence = (pos) => ranges.some((r) => pos >= r.start && pos < r.end);
|
|
189
|
+
const backtickRun = /`+/g;
|
|
190
|
+
let pendingOpen = null;
|
|
191
|
+
let match;
|
|
192
|
+
while ((match = backtickRun.exec(text)) !== null) {
|
|
193
|
+
if (isInFence(match.index))
|
|
194
|
+
continue;
|
|
195
|
+
const len = match[0].length;
|
|
196
|
+
if (pendingOpen === null) {
|
|
197
|
+
pendingOpen = { start: match.index, len };
|
|
198
|
+
}
|
|
199
|
+
else if (len === pendingOpen.len) {
|
|
200
|
+
ranges.push({ start: pendingOpen.start, end: match.index + len });
|
|
201
|
+
pendingOpen = null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return ranges;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Retype a `wikiLink` node to `wikiEmbed` when it was immediately preceded by
|
|
208
|
+
* an {@link EMBED_MARKER_SENTINEL} *and* carries a `data.blockId` (#119).
|
|
209
|
+
*
|
|
210
|
+
* Two outcomes when a sentinel-terminated text node precedes a `wikiLink`:
|
|
211
|
+
* - **Has `blockId`**: this is a genuine transclusion. Strip the sentinel
|
|
212
|
+
* off the preceding text (dropping the text node entirely if it was the
|
|
213
|
+
* sentinel alone) and retype the node to `wikiEmbed`, carrying the same
|
|
214
|
+
* `value`/`data`.
|
|
215
|
+
* - **No `blockId`**: FR-013 — `![[file]]` with no anchor is not a supported
|
|
216
|
+
* construct in v1. Restore the literal `!` in the preceding text and leave
|
|
217
|
+
* the node as an ordinary `wikiLink`, so it degrades to ordinary
|
|
218
|
+
* file-only link treatment (no embedding), matching what plain
|
|
219
|
+
* `\![[file]]` already does without any sentinel involved.
|
|
220
|
+
*
|
|
221
|
+
* A final sweep restores any sentinel left over from a `!` that didn't end up
|
|
222
|
+
* immediately before a completed `wikiLink` (e.g. the target never closed) —
|
|
223
|
+
* this sentinel must never leak into rendered/round-tripped content.
|
|
224
|
+
*/
|
|
225
|
+
function resolveWikiEmbeds(root) {
|
|
226
|
+
function walkChildren(children) {
|
|
227
|
+
const result = [];
|
|
228
|
+
for (const rawChild of children) {
|
|
229
|
+
const child = walk(rawChild);
|
|
230
|
+
if (child?.type === 'wikiLink' && result.length > 0) {
|
|
231
|
+
const prevIndex = result.length - 1;
|
|
232
|
+
const prev = result[prevIndex];
|
|
233
|
+
if (prev.type === 'text' && typeof prev.value === 'string' && prev.value.endsWith(EMBED_MARKER_SENTINEL)) {
|
|
234
|
+
const blockId = child.data?.blockId;
|
|
235
|
+
const hasBlockId = typeof blockId === 'string' && blockId.length > 0;
|
|
236
|
+
const strippedValue = prev.value.slice(0, -1);
|
|
237
|
+
if (hasBlockId) {
|
|
238
|
+
if (strippedValue.length === 0) {
|
|
239
|
+
result.pop();
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
result[prevIndex] = { ...prev, value: strippedValue };
|
|
243
|
+
}
|
|
244
|
+
result.push({ ...child, type: 'wikiEmbed' });
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
result[prevIndex] = { ...prev, value: strippedValue + '!' };
|
|
248
|
+
result.push(child);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
result.push(child);
|
|
253
|
+
}
|
|
254
|
+
return result;
|
|
255
|
+
}
|
|
256
|
+
function walk(node) {
|
|
257
|
+
if (!node || typeof node !== 'object')
|
|
258
|
+
return node;
|
|
259
|
+
if (node.children && Array.isArray(node.children)) {
|
|
260
|
+
return { ...node, children: walkChildren(node.children) };
|
|
261
|
+
}
|
|
262
|
+
return node;
|
|
263
|
+
}
|
|
264
|
+
function restoreLeftoverSentinels(node) {
|
|
265
|
+
if (!node || typeof node !== 'object')
|
|
266
|
+
return node;
|
|
267
|
+
if (node.type === 'text' && typeof node.value === 'string' && node.value.includes(EMBED_MARKER_SENTINEL)) {
|
|
268
|
+
return { ...node, value: node.value.split(EMBED_MARKER_SENTINEL).join('!') };
|
|
269
|
+
}
|
|
270
|
+
if (node.children && Array.isArray(node.children)) {
|
|
271
|
+
return { ...node, children: node.children.map(restoreLeftoverSentinels) };
|
|
272
|
+
}
|
|
273
|
+
return node;
|
|
274
|
+
}
|
|
275
|
+
return restoreLeftoverSentinels(walk(root));
|
|
276
|
+
}
|
|
60
277
|
/**
|
|
61
278
|
* Combine two sets of replacements from sequential preprocessing steps.
|
|
62
279
|
* The second set of replacements refers to positions in text AFTER the first set was applied.
|
|
@@ -349,6 +566,313 @@ function splitTextNodeEscapes(node, normalizedText) {
|
|
|
349
566
|
flushRun(runStart, parts.length);
|
|
350
567
|
return result;
|
|
351
568
|
}
|
|
569
|
+
// Any id shape the resolver and the reference-side wiki-link parser already
|
|
570
|
+
// accept — ULID included — gated by the resolver's own position rule: the
|
|
571
|
+
// caret must start a token (line-start or preceded by whitespace), and the
|
|
572
|
+
// captured id must run to end of line (trailing spaces/tabs allowed). This is
|
|
573
|
+
// what makes badge and resolver agree by construction for every id form with
|
|
574
|
+
// no carve-out (#124/FR-002, #126/FR-001) — ULID no longer gets a permissive,
|
|
575
|
+
// position-free rule of its own; Crockford Base32 (ULID's charset) is already
|
|
576
|
+
// a strict subset of this charset, so every ULID that satisfies this position
|
|
577
|
+
// rule keeps badging unchanged (see ADR-122's amendments).
|
|
578
|
+
//
|
|
579
|
+
// The charset excludes `*` (in addition to `#`/`]`/whitespace) to match
|
|
580
|
+
// `liminis-app/src/main/fs.ts`'s `ANCHOR_LINE_PATTERN`
|
|
581
|
+
// (`[^\s\]#*]`) as actually implemented for `liminis#1114` — that resolver
|
|
582
|
+
// pattern narrowed *both* its wrapped and unwrapped branches to exclude `*`,
|
|
583
|
+
// unlike the stale, pre-implementation regex the #127 issue body quoted.
|
|
584
|
+
// `_` stays allowed: excluding it would break the NanoID-with-underscore
|
|
585
|
+
// case (#124/FR-004), and the resolver's own pattern allows it too.
|
|
586
|
+
const WIDE_ID_CHAR = /[^\s\]#*]/;
|
|
587
|
+
// A symmetric emphasis wrapper (`**`, `__`, `*`, `_`) around `^<id>` at line
|
|
588
|
+
// end badges too (#127), so this rule agrees with the widened resolver
|
|
589
|
+
// (`liminis#1114`) for wrapped ids the same way it already agrees for plain
|
|
590
|
+
// ones. The wrapped id charset matches the resolver's actual wrapped-branch
|
|
591
|
+
// charset (`[^\s\]#*]+?` in
|
|
592
|
+
// `ANCHOR_LINE_PATTERN`) — same as `WIDE_ID_CHAR` above; kept as a distinct,
|
|
593
|
+
// separately-named constant since the wrapped and unwrapped charsets are
|
|
594
|
+
// independent knobs in the resolver's pattern and could diverge again.
|
|
595
|
+
// Longest markers first so a `**`/`__` candidate is tried before its `*`/`_`
|
|
596
|
+
// prefix.
|
|
597
|
+
const WRAPPED_ID_CHAR = /[^\s\]#*]/;
|
|
598
|
+
const WRAPPER_MARKERS = ['**', '__', '*', '_'];
|
|
599
|
+
const isSpaceOrTab = (ch) => ch === ' ' || ch === '\t';
|
|
600
|
+
/**
|
|
601
|
+
* Look for one of `WRAPPER_MARKERS` immediately before `offset` in the raw,
|
|
602
|
+
* pre-parse `normalizedText`, itself preceded by whitespace or document
|
|
603
|
+
* start — the same left-boundary rule already applied to a bare caret, just
|
|
604
|
+
* one token further out. Returns the matched marker string, or
|
|
605
|
+
* `null` if none of them fit.
|
|
606
|
+
*
|
|
607
|
+
* This only needs to look *outside* the current text node (at `offset`, the
|
|
608
|
+
* node's own start) because a caret can only be adjacent to a *structural*
|
|
609
|
+
* wrapper marker when it is the first character of its own text node: if a
|
|
610
|
+
* literal `**`/`_` sat before the caret inside the same text node, that run
|
|
611
|
+
* of emphasis markers never found a matching closer and CommonMark left it
|
|
612
|
+
* as ordinary text, which the plain (non-wrapped) left-boundary check
|
|
613
|
+
* already handles correctly with no wrapper logic involved.
|
|
614
|
+
*/
|
|
615
|
+
function matchWrapperMarkerBefore(normalizedText, offset) {
|
|
616
|
+
for (const marker of WRAPPER_MARKERS) {
|
|
617
|
+
const markerStart = offset - marker.length;
|
|
618
|
+
if (markerStart < 0)
|
|
619
|
+
continue;
|
|
620
|
+
if (normalizedText.slice(markerStart, offset) !== marker)
|
|
621
|
+
continue;
|
|
622
|
+
if (markerStart === 0 || /\s/.test(normalizedText[markerStart - 1]))
|
|
623
|
+
return marker;
|
|
624
|
+
}
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Find every block-anchor match in `decoded` — any id shape, position-gated
|
|
629
|
+
* by the resolver's own rule, with no separate ULID carve-out (#126) — at
|
|
630
|
+
* each unescaped `^`, left to right.
|
|
631
|
+
*
|
|
632
|
+
* The boundary checks peek one character outside this text node's own
|
|
633
|
+
* `decoded`/`source` span — into `normalizedText` at `start - 1` (left) or
|
|
634
|
+
* from `end` onward (right) — rather than inspecting sibling AST nodes: the
|
|
635
|
+
* raw source character is equivalent and needs no tree traversal (see Plan
|
|
636
|
+
* stage's "Key Decisions"). This is what lets the rule correctly refuse a
|
|
637
|
+
* match immediately followed by more prose on the same line, even when that
|
|
638
|
+
* prose lives in a following sibling node (e.g. a wiki-link right after the
|
|
639
|
+
* id), and correctly accept one immediately after a preceding sibling ends
|
|
640
|
+
* with whitespace.
|
|
641
|
+
*
|
|
642
|
+
* A symmetric emphasis wrapper (`**`, `__`, `*`, `_`) around the id at line
|
|
643
|
+
* end also badges (#127), using the same outside-the-node peek:
|
|
644
|
+
* when the caret is the first character of this text node (`i === 0`) and
|
|
645
|
+
* the plain whitespace/start rule doesn't hold, it peeks backward for a
|
|
646
|
+
* wrapper marker; when one is found, the id must then run to this text
|
|
647
|
+
* node's own end (`idEnd === decoded.length`) and be followed immediately
|
|
648
|
+
* by the *same* marker string before the usual trailing-whitespace/EOL
|
|
649
|
+
* check. Both `i === 0` and `idEnd === decoded.length` are load-bearing
|
|
650
|
+
* invariants, not incidental: they are exactly the positions at which a
|
|
651
|
+
* *structural* wrapper marker (one CommonMark parsed as real emphasis,
|
|
652
|
+
* rather than literal text left over from an unmatched delimiter run) can
|
|
653
|
+
* be adjacent to the caret/id at all — see `matchWrapperMarkerBefore`.
|
|
654
|
+
*/
|
|
655
|
+
function findBlockAnchorMatches(decoded, parts, normalizedText, start, end) {
|
|
656
|
+
const matches = [];
|
|
657
|
+
let cursor = 0;
|
|
658
|
+
for (let i = 0; i < decoded.length; i++) {
|
|
659
|
+
if (i < cursor)
|
|
660
|
+
continue;
|
|
661
|
+
if (decoded[i] !== '^' || parts[i].escaped)
|
|
662
|
+
continue;
|
|
663
|
+
// Left boundary: preceded by whitespace, or true document start — or,
|
|
664
|
+
// when the caret is the first character of this text node, a symmetric
|
|
665
|
+
// emphasis wrapper immediately before it (#127). That `i === 0` gate is
|
|
666
|
+
// load-bearing: it's the only position at which a *structural* wrapper
|
|
667
|
+
// marker (one CommonMark actually parsed as emphasis, not literal text)
|
|
668
|
+
// can sit immediately before the caret — see `matchWrapperMarkerBefore`.
|
|
669
|
+
const leftOk = i > 0 ? /\s/.test(decoded[i - 1]) : start === 0 || /\s/.test(normalizedText[start - 1]);
|
|
670
|
+
let wrapMarker = null;
|
|
671
|
+
if (!leftOk) {
|
|
672
|
+
if (i === 0)
|
|
673
|
+
wrapMarker = matchWrapperMarkerBefore(normalizedText, start);
|
|
674
|
+
if (!wrapMarker)
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
// Id capture: greedy run of non-whitespace, non-`]`, non-`#`, non-`*`,
|
|
678
|
+
// tested against `decoded` rather than raw source. A backslash-escaped `#` or
|
|
679
|
+
// `]` inside an id (`^abc\#def`) therefore truncates the capture one
|
|
680
|
+
// character earlier than a regex run over raw, undecoded file text
|
|
681
|
+
// would (the resolver's own matching model) — but this never produces a
|
|
682
|
+
// badge/resolver disagreement: whatever follows the truncation point is
|
|
683
|
+
// identical, non-whitespace content in both the decoded and raw views
|
|
684
|
+
// (backslash-escaping only ever turns `\X` into `X`, never anything
|
|
685
|
+
// into whitespace), so the right-boundary check below rejects the match
|
|
686
|
+
// in both models alike whenever this truncation is reachable. See the
|
|
687
|
+
// "does not badge an id containing a backslash-escaped delimiter"
|
|
688
|
+
// regression test.
|
|
689
|
+
//
|
|
690
|
+
// When wrapped, `WRAPPED_ID_CHAR` applies instead of `WIDE_ID_CHAR` (see
|
|
691
|
+
// their definitions) — currently identical charsets, kept as separate
|
|
692
|
+
// named constants since the resolver's wrapped/unwrapped branches are
|
|
693
|
+
// independent knobs that could diverge again.
|
|
694
|
+
const idCharTest = wrapMarker ? WRAPPED_ID_CHAR : WIDE_ID_CHAR;
|
|
695
|
+
let idEnd = i + 1;
|
|
696
|
+
while (idEnd < decoded.length && idCharTest.test(decoded[idEnd]))
|
|
697
|
+
idEnd++;
|
|
698
|
+
// Empty capture: the character right after `^` is already whitespace
|
|
699
|
+
// (or end of text), e.g. `a ^ b`, `a ^`, `x ^\t`. `a ^ b` would also be
|
|
700
|
+
// rejected by the right-boundary check below regardless (the trailing
|
|
701
|
+
// `b` isn't end-of-line), but a bare trailing caret like `a ^` or
|
|
702
|
+
// `x ^\t` reaches true end-of-line/end-of-document and would otherwise
|
|
703
|
+
// pass that check with an empty id — this guard is what actually stops
|
|
704
|
+
// that case. See "does not badge a bare trailing caret" regression test.
|
|
705
|
+
if (idEnd === i + 1)
|
|
706
|
+
continue;
|
|
707
|
+
// Right boundary: only trailing spaces/tabs before end of line or end of
|
|
708
|
+
// document — peeking past this node's own end into `normalizedText` if
|
|
709
|
+
// the capture runs all the way to it.
|
|
710
|
+
//
|
|
711
|
+
// When wrapped, the closer is required instead: the id capture must run
|
|
712
|
+
// all the way to this text node's own end (the mirror image of the
|
|
713
|
+
// `i === 0` left-boundary gate — a structural closer can only be
|
|
714
|
+
// adjacent to the id there), the exact same marker string that opened
|
|
715
|
+
// it must appear immediately after, and only trailing spaces/tabs and
|
|
716
|
+
// end-of-line/end-of-document may follow that. No fallback to the
|
|
717
|
+
// unwrapped rule on mismatch — an asymmetric wrapper (e.g. `**^id_`)
|
|
718
|
+
// must never badge with a corrupted id (#127/SC-003).
|
|
719
|
+
let rightOk;
|
|
720
|
+
if (wrapMarker) {
|
|
721
|
+
if (idEnd !== decoded.length)
|
|
722
|
+
continue;
|
|
723
|
+
const closerEnd = end + wrapMarker.length;
|
|
724
|
+
if (normalizedText.slice(end, closerEnd) !== wrapMarker)
|
|
725
|
+
continue;
|
|
726
|
+
let pos = closerEnd;
|
|
727
|
+
while (pos < normalizedText.length && isSpaceOrTab(normalizedText[pos]))
|
|
728
|
+
pos++;
|
|
729
|
+
rightOk = pos === normalizedText.length || normalizedText[pos] === '\n';
|
|
730
|
+
}
|
|
731
|
+
else {
|
|
732
|
+
let j = idEnd;
|
|
733
|
+
while (j < decoded.length && isSpaceOrTab(decoded[j]))
|
|
734
|
+
j++;
|
|
735
|
+
if (j < decoded.length) {
|
|
736
|
+
rightOk = decoded[j] === '\n';
|
|
737
|
+
}
|
|
738
|
+
else {
|
|
739
|
+
let pos = end;
|
|
740
|
+
while (pos < normalizedText.length && isSpaceOrTab(normalizedText[pos]))
|
|
741
|
+
pos++;
|
|
742
|
+
rightOk = pos === normalizedText.length || normalizedText[pos] === '\n';
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
if (!rightOk)
|
|
746
|
+
continue;
|
|
747
|
+
matches.push({ index: i, length: idEnd - i });
|
|
748
|
+
cursor = idEnd;
|
|
749
|
+
}
|
|
750
|
+
return matches;
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Split a single `text` node into `[before, blockAnchor, after, ...]`
|
|
754
|
+
* siblings wherever it contains a {@link findBlockAnchorMatches} match,
|
|
755
|
+
* mirroring `splitTextNodeEscapes`'s decode-replay + position-mapping
|
|
756
|
+
* machinery exactly (including its conservative bail-out when replayed
|
|
757
|
+
* decoding doesn't exactly reproduce `node.value`, e.g. a character
|
|
758
|
+
* reference in the span) rather than duplicating it (#122).
|
|
759
|
+
*
|
|
760
|
+
* Because this only ever inspects a `text` node's own `value` (plus, for
|
|
761
|
+
* the boundary checks, one character immediately outside it), it can
|
|
762
|
+
* never see into `inlineCode`, `code`, `inlineMath`, `wikiLink` or
|
|
763
|
+
* `wikiEmbed` node content — none of those are `text` nodes once mdast has
|
|
764
|
+
* typed them — which satisfies the code-span/fenced-code/math edge case and
|
|
765
|
+
* FR-006 by construction, with no "protected ranges" pre-parse machinery
|
|
766
|
+
* needed.
|
|
767
|
+
*/
|
|
768
|
+
function splitTextNodeBlockAnchors(node, normalizedText) {
|
|
769
|
+
const start = node.position?.start?.offset;
|
|
770
|
+
const end = node.position?.end?.offset;
|
|
771
|
+
if (start == null || end == null) {
|
|
772
|
+
return [node];
|
|
773
|
+
}
|
|
774
|
+
const source = normalizedText.slice(start, end);
|
|
775
|
+
const { decoded, parts } = replayDecodeEscapes(source);
|
|
776
|
+
if (decoded !== node.value) {
|
|
777
|
+
return [node];
|
|
778
|
+
}
|
|
779
|
+
// A caret whose leading `^` came from a backslash escape (`\^`) in the
|
|
780
|
+
// source is never a match candidate — `findBlockAnchorMatches` checks
|
|
781
|
+
// `parts[i].escaped` itself before trying either branch. `decoded` has
|
|
782
|
+
// already resolved `\^` to a plain `^`, so nothing downstream can tell the
|
|
783
|
+
// two apart on its own; an author who deliberately escaped a caret meant
|
|
784
|
+
// literal text, not an anchor, and `stringify.ts`'s `blockAnchor` handler
|
|
785
|
+
// always emits a bare `^id` with no escaping, so badging it would silently
|
|
786
|
+
// drop the escape on the next save.
|
|
787
|
+
const matches = findBlockAnchorMatches(decoded, parts, normalizedText, start, end);
|
|
788
|
+
if (matches.length === 0) {
|
|
789
|
+
return [node];
|
|
790
|
+
}
|
|
791
|
+
const startPos = node.position.start;
|
|
792
|
+
// Per-offset line/column within `source`, mirroring splitTextNodeEscapes.
|
|
793
|
+
const positionAt = new Array(source.length + 1);
|
|
794
|
+
{
|
|
795
|
+
let line = startPos.line;
|
|
796
|
+
let column = startPos.column;
|
|
797
|
+
positionAt[0] = { line, column };
|
|
798
|
+
for (let i = 0; i < source.length; i++) {
|
|
799
|
+
if (source[i] === '\n') {
|
|
800
|
+
line += 1;
|
|
801
|
+
column = 1;
|
|
802
|
+
}
|
|
803
|
+
else {
|
|
804
|
+
column += 1;
|
|
805
|
+
}
|
|
806
|
+
positionAt[i + 1] = { line, column };
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
const makePosition = (srcStart, srcEnd) => ({
|
|
810
|
+
start: { ...positionAt[srcStart - start], offset: srcStart },
|
|
811
|
+
end: { ...positionAt[srcEnd - start], offset: srcEnd },
|
|
812
|
+
});
|
|
813
|
+
const makeTextNode = (value, srcStart, srcEnd) => ({
|
|
814
|
+
type: 'text',
|
|
815
|
+
value,
|
|
816
|
+
position: makePosition(srcStart, srcEnd),
|
|
817
|
+
});
|
|
818
|
+
const result = [];
|
|
819
|
+
let cursor = 0; // index into `parts`/`decoded`
|
|
820
|
+
for (const match of matches) {
|
|
821
|
+
const matchStart = match.index;
|
|
822
|
+
const matchEnd = matchStart + match.length;
|
|
823
|
+
if (matchStart > cursor) {
|
|
824
|
+
const value = parts.slice(cursor, matchStart).map((p) => p.char).join('');
|
|
825
|
+
result.push(makeTextNode(value, start + parts[cursor].srcStart, start + parts[matchStart - 1].srcEnd));
|
|
826
|
+
}
|
|
827
|
+
// Captured from raw `source`, not `decoded`: stringify.ts's `blockAnchor`
|
|
828
|
+
// handler re-emits `^${node.id}` verbatim with no escaping, so the id
|
|
829
|
+
// must already carry any backslash the author wrote (e.g. `^ab\_cd`) or
|
|
830
|
+
// that escape is silently dropped on the next save — a round-trip
|
|
831
|
+
// corruption distinct from, and not covered by, the decoded-vs-raw
|
|
832
|
+
// truncation reasoning above (that reasoning only shows the *match
|
|
833
|
+
// boundary* never diverges; it says nothing about what ends up inside
|
|
834
|
+
// an id that does match).
|
|
835
|
+
result.push({
|
|
836
|
+
type: 'blockAnchor',
|
|
837
|
+
id: source.slice(parts[matchStart + 1].srcStart, parts[matchEnd - 1].srcEnd),
|
|
838
|
+
position: makePosition(start + parts[matchStart].srcStart, start + parts[matchEnd - 1].srcEnd),
|
|
839
|
+
});
|
|
840
|
+
cursor = matchEnd;
|
|
841
|
+
}
|
|
842
|
+
if (cursor < parts.length) {
|
|
843
|
+
const value = parts.slice(cursor, parts.length).map((p) => p.char).join('');
|
|
844
|
+
result.push(makeTextNode(value, start + parts[cursor].srcStart, start + parts[parts.length - 1].srcEnd));
|
|
845
|
+
}
|
|
846
|
+
return result;
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Walk the tree and split every `text` node containing a block anchor (see
|
|
850
|
+
* `splitTextNodeBlockAnchors`) into siblings. Run after `resolveWikiEmbeds`/
|
|
851
|
+
* `annotateEmphasisMarkers` (so this never sees wiki-link/embed target text —
|
|
852
|
+
* FR-006) and before `splitEscapedPunctuation` (so that pass still sees, and
|
|
853
|
+
* can process, any escaped punctuation left in this split's "before"/"after"
|
|
854
|
+
* text siblings) (#122).
|
|
855
|
+
*/
|
|
856
|
+
function splitBlockAnchors(root, normalizedText) {
|
|
857
|
+
function walk(node) {
|
|
858
|
+
if (!node || typeof node !== 'object')
|
|
859
|
+
return node;
|
|
860
|
+
if (node.children && Array.isArray(node.children)) {
|
|
861
|
+
const children = [];
|
|
862
|
+
for (const child of node.children) {
|
|
863
|
+
if (child?.type === 'text') {
|
|
864
|
+
children.push(...splitTextNodeBlockAnchors(child, normalizedText));
|
|
865
|
+
}
|
|
866
|
+
else {
|
|
867
|
+
children.push(walk(child));
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
return { ...node, children };
|
|
871
|
+
}
|
|
872
|
+
return node;
|
|
873
|
+
}
|
|
874
|
+
return walk(root);
|
|
875
|
+
}
|
|
352
876
|
/**
|
|
353
877
|
* Walk the tree and split every `text` node containing a force-escaped
|
|
354
878
|
* character (see `splitTextNodeEscapes`) into siblings. Run last, after all
|
|
@@ -434,8 +958,12 @@ function addCheckboxesToOrderedLists(root) {
|
|
|
434
958
|
}
|
|
435
959
|
export function parseMarkdown(text, _options = {}) {
|
|
436
960
|
// Pre-process to handle edge cases
|
|
961
|
+
// Step 0: Swap a `!` immediately before `[[` for a sentinel so the
|
|
962
|
+
// wiki-link tokenizer gets a chance to fire (#119; see EMBED_MARKER_SENTINEL).
|
|
963
|
+
// Same-length, so it needs no offset-replacement tracking of its own.
|
|
964
|
+
const embedMarked = substituteEmbedMarker(text);
|
|
437
965
|
// Step 1: Escape pipes inside wiki-links to protect from GFM table parsing
|
|
438
|
-
const { text: pipesEscaped, replacements: pipeReplacements } = escapeWikiLinkPipes(
|
|
966
|
+
const { text: pipesEscaped, replacements: pipeReplacements } = escapeWikiLinkPipes(embedMarked);
|
|
439
967
|
// Step 2: Normalize empty aliases (existing logic)
|
|
440
968
|
const { text: normalizedText, replacements: aliasReplacements } = normalizeWikiLinks(pipesEscaped);
|
|
441
969
|
// Combine replacements for offset mapping (pipeReplacements first, then adjust aliasReplacements)
|
|
@@ -458,8 +986,14 @@ export function parseMarkdown(text, _options = {}) {
|
|
|
458
986
|
root = stripEscapedPipeFromWikiLinks(root);
|
|
459
987
|
// Post-process: mark wiki-links that had empty aliases in the source
|
|
460
988
|
root = markEmptyAliasWikiLinks(root);
|
|
989
|
+
// Post-process: retype a sentinel-preceded wiki-link with a blockId to a
|
|
990
|
+
// transclusion/embed node, and restore the literal `!` everywhere else (#119)
|
|
991
|
+
root = resolveWikiEmbeds(root);
|
|
461
992
|
// Post-process: annotate emphasis/strong marker characters from original source
|
|
462
993
|
root = annotateEmphasisMarkers(root, text, replacements);
|
|
994
|
+
// Post-process: split a bare `^ULID` block anchor out of its surrounding
|
|
995
|
+
// text so it can render as a badge instead of raw text (#122)
|
|
996
|
+
root = splitBlockAnchors(root, normalizedText);
|
|
463
997
|
// Post-process: split out backslash-escaped punctuation so its escape can
|
|
464
998
|
// be carried through Lexical and restored at stringify time (#17)
|
|
465
999
|
root = splitEscapedPunctuation(root, normalizedText);
|
|
@@ -214,7 +214,7 @@ function normalizeWikiLinkNodes(node) {
|
|
|
214
214
|
if (!node || typeof node !== 'object') {
|
|
215
215
|
return node;
|
|
216
216
|
}
|
|
217
|
-
if (node.type === 'wikiLink') {
|
|
217
|
+
if (node.type === 'wikiLink' || node.type === 'wikiEmbed') {
|
|
218
218
|
const data = node.data && typeof node.data === 'object' ? { ...node.data } : {};
|
|
219
219
|
return {
|
|
220
220
|
...node,
|
|
@@ -289,6 +289,27 @@ function widenTableDelimiterDashes(markdown) {
|
|
|
289
289
|
}
|
|
290
290
|
return lines.join('\n');
|
|
291
291
|
}
|
|
292
|
+
/**
|
|
293
|
+
* Format the `[[target...]]` (or `[[target...|alias]]`) body shared by the
|
|
294
|
+
* `wikiLink` and `wikiEmbed` handlers below — everything between the double
|
|
295
|
+
* brackets, minus the brackets themselves and (for `wikiEmbed`) the leading
|
|
296
|
+
* `!`. `data.blockId`, when present, is re-appended as `#^blockId` (#119),
|
|
297
|
+
* mirroring the vendored `mdast-util-wiki-link/to-markdown.ts` — duplicated
|
|
298
|
+
* rather than shared because this handler otherwise diverges from the
|
|
299
|
+
* vendored one already (see the module comment above `stringifyMarkdown`).
|
|
300
|
+
*/
|
|
301
|
+
function formatWikiLinkBody(node) {
|
|
302
|
+
const value = node.value ?? '';
|
|
303
|
+
const data = node.data && typeof node.data === 'object' ? node.data : {};
|
|
304
|
+
const blockId = typeof data.blockId === 'string' && data.blockId.length > 0 ? data.blockId : null;
|
|
305
|
+
const target = blockId ? `${value}#^${blockId}` : value;
|
|
306
|
+
const alias = typeof data.alias === 'string' ? data.alias : '';
|
|
307
|
+
const hasAlias = alias.length > 0 && alias !== value;
|
|
308
|
+
const emptyAlias = data._emptyAlias === true;
|
|
309
|
+
const divider = wikiLinkOptions.aliasDivider;
|
|
310
|
+
const aliasPart = hasAlias ? `${divider}${alias}` : emptyAlias ? divider : '';
|
|
311
|
+
return `${target}${aliasPart}`;
|
|
312
|
+
}
|
|
292
313
|
export function stringifyMarkdown(root, options = {}) {
|
|
293
314
|
// Pre-process: add checkbox text to ordered list items (GFM only outputs for unordered)
|
|
294
315
|
let processedRoot = addCheckboxTextToOrderedLists(root);
|
|
@@ -327,15 +348,19 @@ export function stringifyMarkdown(root, options = {}) {
|
|
|
327
348
|
return marker + content + marker;
|
|
328
349
|
},
|
|
329
350
|
escapedChar: (node) => `${FORCE_ESCAPE_PLACEHOLDER}${node.value}${FORCE_ESCAPE_PLACEHOLDER}`,
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
351
|
+
// Block anchor badge (#122): a `blockAnchor` node produced by
|
|
352
|
+
// `splitBlockAnchors` in parse.ts always carries exactly the id
|
|
353
|
+
// matched from the source, so re-emitting `^id` is lossless by
|
|
354
|
+
// construction — no escaping needed, mirroring `wikiLink`/`wikiEmbed`.
|
|
355
|
+
blockAnchor: (node) => `^${node.id}`,
|
|
356
|
+
wikiLink: (node) => `[[${formatWikiLinkBody(node)}]]`,
|
|
357
|
+
// Transclusion/embed (#119): the `!`-prefixed form of a block-scoped
|
|
358
|
+
// wiki-link. `formatWikiLinkBody` requires `data.blockId` be present
|
|
359
|
+
// to have been produced by `resolveWikiEmbeds` in parse.ts in the
|
|
360
|
+
// first place (FR-013 never lets a blockId-less node reach this
|
|
361
|
+
// type), but nothing here depends on that — this handler just emits
|
|
362
|
+
// whatever the node carries, `!` and all.
|
|
363
|
+
wikiEmbed: (node) => `![[${formatWikiLinkBody(node)}]]`,
|
|
339
364
|
// Override mdast-util-definition-list's default (`:` + 3 spaces, i.e. a
|
|
340
365
|
// 4-char marker matching a 4-space continuation indent) with the
|
|
341
366
|
// single-space `: ` marker convention used throughout PHP Markdown
|
|
@@ -37,5 +37,22 @@ upstream pulled in.
|
|
|
37
37
|
|
|
38
38
|
3. **Types.** The `any`-typed option and node shapes are given real types.
|
|
39
39
|
|
|
40
|
+
4. **`#^blockId` fragment splitting (#119).** `from-markdown.ts` splits a
|
|
41
|
+
trailing `#^blockId` fragment off the target into `data.blockId` before
|
|
42
|
+
`pageResolver` runs, so `data.permalink`/`data.exists` are computed from
|
|
43
|
+
the file target alone; `to-markdown.ts` re-appends it on the way back out.
|
|
44
|
+
An ordinary heading anchor (`[[file#heading]]`, no caret) is untouched —
|
|
45
|
+
only the caret-prefixed Obsidian block-reference form matches. This is
|
|
46
|
+
deliberately scoped to the `wikiLink` node shape only: the `!`-prefixed
|
|
47
|
+
transclusion/embed form (`![[file#^id]]`) is *not* handled here — that
|
|
48
|
+
detection lives in `src/markdown/parse.ts`/`stringify.ts` (a same-length
|
|
49
|
+
sentinel substitution around the unvendored micromark tokenizer, since a
|
|
50
|
+
leading `!` before `[[` today makes the *whole* `![[...]]` span fall back
|
|
51
|
+
to inert text — see the tokenizer's `text: {33: ...}` image-label
|
|
52
|
+
precedence). A raw `./markdown`-subpath consumer therefore gets
|
|
53
|
+
`data.blockId` for free but not embed/transclusion detection; this
|
|
54
|
+
asymmetry is deliberate (documented in `docs/markdown-pipeline.md`) rather
|
|
55
|
+
than duplicating the sentinel machinery into a second vendored package.
|
|
56
|
+
|
|
40
57
|
Parse and serialize behaviour is otherwise unchanged. Parity is covered by
|
|
41
58
|
`src/markdown/__tests__/vendor-wiki-link.test.ts`.
|