@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.
Files changed (41) hide show
  1. package/README.md +66 -1
  2. package/dist/app/editor/LinkClickPlugin.js +5 -1
  3. package/dist/app/editor/WikiLinkExistencePlugin.d.ts +13 -3
  4. package/dist/app/editor/WikiLinkExistencePlugin.js +91 -32
  5. package/dist/app/editor/editorNodes.js +3 -1
  6. package/dist/app/editor/nodes/BlockAnchorComponent.d.ts +5 -0
  7. package/dist/app/editor/nodes/BlockAnchorComponent.js +37 -0
  8. package/dist/app/editor/nodes/BlockAnchorNode.d.ts +42 -0
  9. package/dist/app/editor/nodes/BlockAnchorNode.js +151 -0
  10. package/dist/app/editor/nodes/CustomLinkNode.d.ts +14 -0
  11. package/dist/app/editor/nodes/CustomLinkNode.js +37 -0
  12. package/dist/app/editor/nodes/TransclusionComponent.d.ts +8 -0
  13. package/dist/app/editor/nodes/TransclusionComponent.js +65 -0
  14. package/dist/app/editor/nodes/TransclusionNode.d.ts +48 -0
  15. package/dist/app/editor/nodes/TransclusionNode.js +141 -0
  16. package/dist/app/editor/nodes/index.d.ts +4 -0
  17. package/dist/app/editor/nodes/index.js +2 -0
  18. package/dist/app/editor/nodes/transclusion-loading.d.ts +27 -0
  19. package/dist/app/editor/nodes/transclusion-loading.js +29 -0
  20. package/dist/app/editor/nodes/transclusion-render.d.ts +49 -0
  21. package/dist/app/editor/nodes/transclusion-render.js +186 -0
  22. package/dist/app/mapper/lexicalToMdast.js +116 -22
  23. package/dist/app/mapper/mdastToLexical.js +76 -4
  24. package/dist/host/defaults.js +1 -0
  25. package/dist/host/messages.d.ts +7 -1
  26. package/dist/host/messages.js +3 -3
  27. package/dist/host/types.d.ts +14 -1
  28. package/dist/markdown/parse.js +535 -1
  29. package/dist/markdown/stringify.js +35 -10
  30. package/dist/markdown/vendor/mdast-util-wiki-link/README.md +17 -0
  31. package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.d.ts +8 -1
  32. package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.js +26 -1
  33. package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.d.ts +13 -7
  34. package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.js +3 -1
  35. package/dist/styles.css +48 -0
  36. package/dist/types.d.ts +1 -0
  37. package/docs/decisions/adr-119-block-transclusion.md +247 -0
  38. package/docs/decisions/adr-122-block-anchor-badge.md +462 -0
  39. package/docs/editor-api.md +1 -0
  40. package/docs/markdown-pipeline.md +323 -6
  41. package/package.json +5 -3
@@ -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(text);
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
- wikiLink: (node) => {
331
- const value = node.value ?? '';
332
- const data = node.data && typeof node.data === 'object' ? node.data : {};
333
- const alias = typeof data.alias === 'string' ? data.alias : '';
334
- const hasAlias = alias.length > 0 && alias !== value;
335
- const emptyAlias = data._emptyAlias === true;
336
- const aliasPart = hasAlias ? `${wikiLinkOptions.aliasDivider}${alias}` : emptyAlias ? `${wikiLinkOptions.aliasDivider}` : '';
337
- return `[[${value}${aliasPart}]]`;
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`.