@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
@@ -0,0 +1,462 @@
1
+ # ADR-122: Block Anchors Render as a Badge via a New `blockAnchor` mdast Node, Detected by a Strict-ULID Post-Parse Text Split
2
+
3
+ **Date:** 2026-09-09
4
+ **Status:** Accepted
5
+ **Supersedes:** none
6
+ **Amends:** none
7
+ **Issue:** #122 (verveguy/liminis-editor)
8
+
9
+ ## Context
10
+
11
+ Block anchors (`^ULID`) are already in real notes, most heavily trailing
12
+ action-item checkboxes (`- [ ] ... ^01M00VDX0S4JHMDNA7F776Y8R8`). Now that
13
+ 0.5.0 (#119, ADR-119) makes `[[file#^id]]`/`![[file#^id]]` meaningful
14
+ reference targets, showing the raw 26-character id inline is both visual
15
+ noise and a missed affordance — the id is now worth copying, not just
16
+ reading past.
17
+
18
+ Research surfaced a real tension between two requirements the issue states
19
+ side by side. FR-005 requires detection to "match the anchor convention as
20
+ actually written in real notes," verified against the shipped
21
+ `mdast-util-wiki-link` parser rather than derived from the issue's own
22
+ example. That parser's reference-side pattern
23
+ (`BLOCK_ID_PATTERN = /#\^([^\s\]#]+)$/`, inside `[[file#^id]]`) accepts *any*
24
+ non-whitespace token as a block id — ULIDs, snowflake-style numeric ids,
25
+ short ids, slugs — safely, because the surrounding `[[...]]` brackets bound
26
+ the match. SC-004 simultaneously requires that a non-anchor caret in
27
+ ordinary prose (`x^2`, `2^10`, `a ^ b`) never gets badged. A badge sits at
28
+ the anchor's bare *definition* site, with no bracket delimiter to bound it,
29
+ so the reference side's permissiveness cannot transfer directly: a charset
30
+ wide enough to cover every id form the reference side accepts is also wide
31
+ enough to match an exponent.
32
+
33
+ ## Decision
34
+
35
+ **Detect only the 26-character uppercase Crockford Base32 ULID shape**
36
+ (`/\^[0-9A-HJKMNP-TV-Z]{26}(?![0-9A-HJKMNP-TV-Z])/`), and represent a match
37
+ as a new mdast node type (`blockAnchor`) with a matching Lexical
38
+ `DecoratorNode` (`BlockAnchorNode`) — the same architectural shape this
39
+ codebase already uses for every other round-trip-sensitive inline construct
40
+ (`footnoteReference`/`FootnoteNode`, `wikiEmbed`/`TransclusionNode`).
41
+
42
+ ### 1. Charset alone cannot satisfy both FR-005 and SC-004 — length is the disambiguating signal, not a whitespace or position rule
43
+
44
+ A narrow, ULID-shaped charset satisfies SC-004 by construction: `x^2` and
45
+ `2^10` are far too short to match a 26-character run, with no separate
46
+ word-boundary or preceding-whitespace rule needed. The trade against FR-005
47
+ is explicit and accepted, not silent: snowflake-style and short-id anchors
48
+ — forms the reference side already accepts — continue rendering as raw text
49
+ after this issue ships. This worktree has first-party evidence only for the
50
+ ULID shape (the issue's own real checkbox example, confirmed against the
51
+ shipped parser); widening to cover forms with no such evidence here would
52
+ trade a known, bounded gap for an unverified regex. Extending detection to
53
+ those forms is a deliberately deferred, isolated follow-up once that
54
+ convention has first-party evidence, not a design flaw in this decision.
55
+
56
+ A required-whitespace-before-`^` rule (one of the options Research raised)
57
+ was rejected: the issue's own edge cases require an anchor immediately after
58
+ other inline syntax with no preceding space (right after a wiki-link, at the
59
+ end of an emphasis run), which a boundary rule would exclude structurally.
60
+ A position rule ("anchors sit at the end of a block") was also rejected as
61
+ unnecessary once length alone resolves the conflict, and adding it would
62
+ only add complexity without changing which real documents get badged
63
+ correctly.
64
+
65
+ ### 2. Detection is a post-parse text split, not a pre-parse sentinel substitution
66
+
67
+ `#119`'s embed marker (`![[...]]`) needs a pre-parse sentinel substitution
68
+ because it has to influence *tokenization itself* — letting the wiki-link
69
+ tokenizer see a `[[` it would otherwise miss. A bare `^ULID` unlocks no
70
+ syntax; nothing needs to change about how the surrounding text tokenizes.
71
+ Detection is therefore a **post-parse** walk over already-typed mdast `text`
72
+ nodes (`splitTextNodeBlockAnchors`/`splitBlockAnchors` in `parse.ts`),
73
+ splitting a matching run into a `blockAnchor` node — structurally the same
74
+ shape as `#17`'s `splitTextNodeEscapes`/`splitEscapedPunctuation`, whose
75
+ decode-replay and per-offset position-mapping machinery this pass reuses
76
+ directly rather than re-implementing, including its conservative bail-out:
77
+ if a text node's replayed decoding does not exactly reproduce `node.value`
78
+ (e.g. a character reference is present), the whole run is left unsplit.
79
+ This codebase's established risk tolerance for this problem shape is that a
80
+ missed badge is an acceptable cost; a corrupted split is not.
81
+
82
+ Being a post-parse pass over already-typed nodes, not a raw-string regex
83
+ over source text, the matcher structurally cannot see into `inlineCode`,
84
+ `code`, `inlineMath`, `wikiLink` or `wikiEmbed` node content — none of those
85
+ are `text` nodes once mdast has typed them. This satisfies the code-span/
86
+ fenced-code/math edge case and FR-006 (never weaken existing wiki-link/embed
87
+ detection) by construction, with no "protected ranges" pre-parse machinery
88
+ needed at all — the class of machinery `substituteEmbedMarker` requires only
89
+ because it has to influence tokenization.
90
+
91
+ The pass runs after `resolveWikiEmbeds`/`annotateEmphasisMarkers` (so it
92
+ never sees wiki-link or embed target text) and immediately before
93
+ `splitEscapedPunctuation`, which stays last so it can still process any
94
+ escaped punctuation left in this pass's "before"/"after" text siblings.
95
+
96
+ ### 3. A new mdast node type + `DecoratorNode`, not Lexical-only text-entity matching
97
+
98
+ Research raised a Lexical-only alternative — `registerLexicalTextEntity`,
99
+ matching live `TextNode` content inside the mounted `<Editor>` with no
100
+ `parseMarkdown`/`stringifyMarkdown` change at all. Rejected: it would make a
101
+ headless `parseMarkdown`-only consumer (no editor mounted) see plain text
102
+ where a mounted editor sees a badge, violating this issue's own edge case
103
+ ("Anchors in a headless/`parseMarkdown`-only context ... the pipeline must
104
+ behave identically") and diverging from the mdast-node precedent every
105
+ other round-trip-sensitive inline construct in this codebase already
106
+ follows (`footnoteReference`/`FootnoteNode`, `wikiEmbed`/`TransclusionNode`).
107
+ The mdast-node approach keeps `parseMarkdown`'s output identical with or
108
+ without an editor mounted, at the cost of touching more files (a new node
109
+ type end-to-end) than the Lexical-only alternative would have.
110
+
111
+ ### 4. Format bitmask and strong/emphasis marker carry-through, mirroring `FootnoteNode` exactly
112
+
113
+ `BlockAnchorNode` mirrors `TextNode`'s format bitmask and `FootnoteNode`'s
114
+ `--md-strong-marker`/`--md-emphasis-marker` style-hook pattern, so an anchor
115
+ sitting inside `**bold**`/`_italic_` still round-trips its original
116
+ underscore-vs-asterisk marker. Real anchors are almost always plain
117
+ end-of-line text today, so this could have been deferred, but this codebase
118
+ has already shipped the "decorator silently drops formatting/marker inside
119
+ bold/italic" defect class twice (`#898`, `#908`) — the marginal cost of
120
+ including the mirroring now is small next to rediscovering that bug class
121
+ in a follow-up issue.
122
+
123
+ ### 5. `convertListItemNode` keeps a block anchor inline — a deliberate, narrow exception to an existing gap
124
+
125
+ `lexicalToMdast.ts`'s `convertListItemNode` routes only text runs, line
126
+ breaks and links through its inline phrasing path; every other child type
127
+ (image, equation, footnote, inline HTML) falls to the generic block
128
+ dispatcher and does not survive a round trip inline — a documented,
129
+ pre-existing gap (see `hoistedTokenReachesOutput`'s docstring), accepted
130
+ because no document exercised it. This issue's primary real-world shape is
131
+ exactly `- [ ] ... ^ULID` — a block anchor as the last content of a
132
+ checkbox list item — so silently inheriting that gap for `BlockAnchorNode`
133
+ would break the pattern the spec calls out explicitly as the reported
134
+ problem. `convertListItemNode` therefore gets a narrow, additional
135
+ `$isBlockAnchorNode` branch keeping the anchor inline, and
136
+ `hoistedTokenReachesOutput` is updated to match (a hoisted annotation
137
+ boundary onto a block anchor inside a list item now genuinely reaches the
138
+ output). The gap for image/equation/footnote/HTML is deliberately left as
139
+ is — repairing it is unrelated to this issue and out of scope.
140
+
141
+ ### 6. Affordance: native `title` tooltip + click-to-copy, no new interaction pattern
142
+
143
+ User Story 2 (the id must remain readable and copyable) is satisfied by
144
+ exposing the full id via the native `title` attribute on hover, and copying
145
+ it via `navigator.clipboard.writeText` on click with brief "Copied"
146
+ feedback — mirroring `CodeBlockPlugin.tsx`'s existing copy-button pattern
147
+ rather than introducing a new popover/menu affordance.
148
+
149
+ ## Consequences
150
+
151
+ **Good:**
152
+
153
+ - A block anchor at any of the shapes this issue's edge cases name (bare
154
+ end-of-line, immediately after a wiki-link, inside emphasis/strong, inside
155
+ a checkbox action item) renders as a badge and round-trips
156
+ byte-identically through `parseMarkdown -> stringifyMarkdown` (FR-002/
157
+ SC-002), verified by the `122-block-anchor/` fixture corpus.
158
+ - SC-004's three false-positive cases (`x^2`, `2^10`, `a ^ b`) are excluded
159
+ structurally, by length alone — no separate boundary-detection logic to
160
+ maintain or get wrong.
161
+ - A caret inside inline code, a fenced code block, or inline math is never
162
+ touched, by construction of the post-parse `text`-node-only pass — no
163
+ "protected ranges" machinery needed, unlike the embed marker.
164
+ - Existing wiki-link and transclusion fixtures pass unmodified (SC-005): the
165
+ new matcher runs after those constructs are already typed and cannot see
166
+ into their node content.
167
+
168
+ **Bad / accepted:**
169
+
170
+ - **Non-ULID id forms — snowflake-style numeric ids, short ids, slugs —
171
+ continue rendering as raw text.** These are accepted today by the
172
+ wiki-link reference side inside `[[file#^id]]`, but this worktree has no
173
+ first-party evidence of them being used as bare definition-site anchors,
174
+ so widening detection to cover them is deliberately deferred rather than
175
+ guessed at. A future issue extending this regex has this ADR's reasoning
176
+ to build on rather than needing to re-derive the FR-005/SC-004 tension
177
+ from scratch.
178
+ - **The pre-existing image/equation/footnote/inline-HTML-inside-a-list-item
179
+ gap in `convertListItemNode` is unchanged** for every type except block
180
+ anchors. Repairing it more generally is unrelated to this issue and left
181
+ for whenever a real document actually needs one of those constructs
182
+ inline in a list item.
183
+ - **A text node containing a character reference (e.g. `&amp;`) alongside a
184
+ block anchor is left unsplit** (the same conservative bail-out `#17`
185
+ already accepts for escaped punctuation) — a missed badge in that narrow
186
+ combination, not a corrupted one.
187
+ - **A backslash-escaped caret (`\^`) immediately followed by a ULID-shaped
188
+ run is never badged**, even though `decoded` (the value the matcher runs
189
+ against) has already resolved `\^` to a plain `^` by the time the regex
190
+ sees it. The matcher consults `replayDecodeEscapes`'s per-offset `escaped`
191
+ flag and rejects any match whose `^` came from an escape, on the same
192
+ reasoning as the character-reference bail-out above: badging it would
193
+ defeat an author's deliberate escape and, since `stringify.ts`'s
194
+ `blockAnchor` handler always emits a bare `^id`, silently drop the
195
+ backslash on the next save. This only prevents the *new* harm (wrongly
196
+ badging escaped-looking text); it does not fix the pre-existing, unrelated
197
+ gap that `^` is outside `FORCE_ESCAPE_CHARS`, so a bare `\^` — anchor-
198
+ shaped or not — already does not round-trip its backslash today. Repairing
199
+ that is out of scope for this issue.
200
+
201
+ > **Amended 2026-09-09 (#124) — "length is the disambiguating signal" no
202
+ > longer holds; the resolver's position rule is.** #122 shipped detecting
203
+ > only the 26-character ULID shape, arguing (§1 above) that length alone
204
+ > resolved the FR-005/SC-004 tension, and that a wider charset would
205
+ > reintroduce false positives like `x^2`/`2^10`. That argument turned out to
206
+ > rest on a premise this ADR didn't state explicitly: math and code are
207
+ > excluded from detection *structurally*, by mdast node type (§2), not by
208
+ > charset — the text-splitter pass never reaches inside `inlineCode`, `code`
209
+ > or `inlineMath`. So the only false-positive risk length was actually
210
+ > protecting against was a bare caret in ordinary prose, outside any math or
211
+ > code construct — and that risk is a *position* problem (where the caret
212
+ > sits on the line), not a charset-width problem.
213
+ >
214
+ > #124 (Widen block-anchor badge detection beyond strict ULID) adopts the
215
+ > resolver's own position rule verbatim as a second detection branch:
216
+ > `/(?:^|\s)\^([^\s\]#]+)\s*$/` — the caret must start a token (line-start or
217
+ > preceded by whitespace) and the captured id must run to end of line. This
218
+ > branch now badges every id form the resolver and the wiki-link reference
219
+ > side already accept (ULID, raw-decimal snowflake, NanoID with `_`/`-`,
220
+ > mixed-case base62, UUID-shaped hyphenated ids, short alphanumeric ids),
221
+ > closing the gap §168's "Bad / accepted" section flagged as deliberately
222
+ > deferred.
223
+ >
224
+ > **The original ULID branch (§"Decision" above) is kept exactly as shipped,
225
+ > unconstrained by position, as a first branch tried before the new one.**
226
+ > Applying the position rule to ULID too was considered and rejected: #122's
227
+ > own fixtures and unit tests require a ULID to badge immediately after a
228
+ > wiki-link or emphasis run with no preceding space (§1's "required-
229
+ > whitespace-before-`^`... rejected" reasoning, still valid), and require a
230
+ > ULID followed by further prose on the same line to badge
231
+ > (`multiple-anchors.md`). A single, universally-applied position rule breaks
232
+ > both. Keeping ULID's original permissive rule frozen, and adding position-
233
+ > gating only for every other id shape, satisfies both requirements at once
234
+ > without reopening #122's already-settled charset reasoning for the case it
235
+ > was actually designed for.
236
+ >
237
+ > **A minimum-length threshold (e.g. ~8 characters) was considered and
238
+ > rejected**, as an alternative to the position rule, to exclude the one
239
+ > residual false positive the position rule alone doesn't resolve: a bare
240
+ > short number at line end preceded by whitespace, such as `^100`. Rejected
241
+ > because #124's own requirements include badging short alphanumeric ids
242
+ > (`^a1b2c3`, 6 characters) — any length floor high enough to exclude `^100`
243
+ > also excludes ids of that length, reintroducing the exact false-negative
244
+ > #124 exists to fix. The residual `^100`-at-line-end false positive is
245
+ > therefore accepted, not filtered: it requires an author to end a line on a
246
+ > lone number with a caret in front of it and nothing after, which is not how
247
+ > exponents are normally written (`2^10` continues the sentence; real math is
248
+ > wrapped in MathJax `$...$`, itself excluded structurally per §2).
249
+ >
250
+ > Math/code exclusion (§2) is unaffected by this amendment — both detection
251
+ > branches remain a post-parse pass over already-typed `text` nodes, so the
252
+ > structural exclusion holds for every id shape, not just ULID, with no new
253
+ > "protected ranges" machinery. See `specs/124-widen-block-anchor-badge/spec.md`
254
+ > for the full analysis, including the rejected `^=`-sigil alternative.
255
+
256
+ > **Amended 2026-09-10 (#127) — Branch B now accepts a symmetric emphasis
257
+ > wrapper at line end, for resolver parity.** `verveguy/liminis#1114` widens
258
+ > the resolver's `ANCHOR_LINE_PATTERN` to accept `**^id**`/`__^id__`/
259
+ > `*^id*`/`_^id_` at line end, matching an id it previously rejected. Left
260
+ > unmirrored, this would reopen #124's defect in the opposite direction:
261
+ > `**^<ULID>**` already badges today, but only via Branch A (unconstrained
262
+ > by position, §"Decision" above and the first amendment's "kept exactly as
263
+ > shipped"); Branch B still demanded whitespace immediately before the
264
+ > caret, so a wrapped *non*-ULID id (e.g. `**^a1b2c3**`) would resolve under
265
+ > the widened resolver without ever badging — a new badge/resolver
266
+ > disagreement, in the same failure mode #124 fixed the first time.
267
+ >
268
+ > Branch B's left/right boundary checks were extended, not replaced: when
269
+ > the plain whitespace/start rule fails but the caret is the first character
270
+ > of its own text node, a backward peek into the surrounding raw text looks
271
+ > for one of `**`/`__`/`*`/`_` immediately before the node, itself preceded
272
+ > by whitespace or document start; when found, the id must then run to that
273
+ > same text node's own end and be followed immediately by the *exact same*
274
+ > marker string, before the usual trailing-whitespace/end-of-line check.
275
+ > Both position invariants (caret at the node's start, id at the node's end)
276
+ > are load-bearing: they are exactly the positions at which a *structural*
277
+ > wrapper marker — one CommonMark actually parsed as emphasis, as opposed to
278
+ > literal text left over from an unmatched delimiter run — can be adjacent
279
+ > to the caret/id at all. This is what lets the extension stay a same-style
280
+ > raw-text peek, the technique §2's post-parse pass and the first
281
+ > amendment's position rule both already use, rather than requiring parent-
282
+ > node type/marker/position to be threaded down through the tree walk.
283
+ >
284
+ > Three consequences of this widening are accepted deliberately, not left as
285
+ > undocumented surprises:
286
+ >
287
+ > 1. **`squared *^2*` becomes a badged-and-resolved false anchor.** This is
288
+ > the same class of residual the first amendment already accepted for
289
+ > `^100` at line end, for the same reason: any length floor that would
290
+ > exclude `^2` also excludes legitimate short ids like `^a1b2c3`, which
291
+ > #124 exists to badge. Wrapping the id in emphasis does not change that
292
+ > tension, so the same residual is accepted here rather than re-litigated.
293
+ > 2. **An asymmetric wrapper is rejected, not partially matched.**
294
+ > `item **^<ULID>_` (opening `**`, closing `_`) does not badge via Branch
295
+ > B's wrapper rule with a corrupted id such as `<ULID>_` — the exact-
296
+ > string closer check fails closed on any mismatch, including uneven
297
+ > delimiter-run lengths (`**^id*`). (That specific ULID example still
298
+ > badges — the clean, uncorrupted id — but via Branch A, which has always
299
+ > ignored wrapper symmetry entirely and is untouched by this amendment.)
300
+ > 3. **Wrapper forms other than `**`/`__`/`*`/`_` remain unhandled, by
301
+ > design.** Triple emphasis (`***…***`), strikethrough (`~~…~~`),
302
+ > backtick-wrapped (`` `^id` ``), and paren-wrapped (`(^id)`) forms stay
303
+ > unresolved and unbadged — not a general "any wrapper" rule, because
304
+ > `liminis#1114`'s resolver pattern only accepts these four marker forms;
305
+ > matching a wrapper the resolver doesn't would reopen the same
306
+ > disagreement this amendment exists to close, just in the other
307
+ > direction.
308
+ >
309
+ > The wrapped path's id charset (`[^\s\]#*_]`) additionally excludes `*` and
310
+ > `_`, matching the resolver's own corrected wrapped-branch charset. The
311
+ > *unwrapped* path's charset (§"#124 amendment" above) is deliberately left
312
+ > untouched — narrowing it to match would regress #124's own NanoID-with-
313
+ > underscore regression test (`^V1StGXR8_Z5jdHi6B-myT`, FR-004), which
314
+ > `liminis#1114`'s corrected pattern would otherwise also exclude. This is a
315
+ > known, narrow point of divergence from resolver charset parity, confined
316
+ > to ids that legitimately contain `*`/`_` *and* are wrapped in emphasis —
317
+ > flagged for whoever coordinates `liminis#1114`'s own Implement stage, not
318
+ > something this issue's code works around.
319
+ >
320
+ > Branch A and its try-first ordering are untouched by this amendment; the
321
+ > existing `checkbox-anchor-formatted.md` fixture (a bold-wrapped and an
322
+ > italic-wrapped ULID, each at end of line) already badged both anchors
323
+ > before this change, via Branch A, and continues to do so unchanged. See
324
+ > `specs/127-widen-branch-b-to/spec.md` for the full analysis, including why
325
+ > the regex FR-2 originally quoted from the issue body was itself found to
326
+ > have a corrupted-id bug and was not ported literally.
327
+
328
+ > **Amended 2026-09-10 (#127, correction) — `WRAPPED_ID_CHAR` wrongly
329
+ > excluded `_`; `WIDE_ID_CHAR` now matches the resolver's actual narrowed
330
+ > charset too.** The amendment above, written before `liminis#1114`'s
331
+ > Implement stage landed, assumed its corrected wrapped-branch charset
332
+ > excluded both `*` and `_`. Checked against `liminis-app/src/main/fs.ts` as
333
+ > actually implemented on `verveguy/liminis`'s `fabrik/issue-1114` branch,
334
+ > `ANCHOR_LINE_PATTERN` is:
335
+ > ```
336
+ > /(?:^|\s)(?:(\*\*|__|\*|_)\^(?<wrappedId>[^\s\]#*]+?)\1|\^(?<unwrappedId>[^\s\]#*]+))\s*$/
337
+ > ```
338
+ > Both the wrapped and unwrapped id groups are `[^\s\]#*]` — excluding `*`
339
+ > only. Two things followed from the earlier, incorrect assumption:
340
+ >
341
+ > 1. `WRAPPED_ID_CHAR` (`[^\s\]#*_]`) wrongly excluded `_` as well, so a
342
+ > wrapped id containing an underscore — a bold-wrapped NanoID
343
+ > (`**^V1StGXR8_Z5jdHi6B-myT**`) or an ordinary `snake_case` id — resolved
344
+ > under the widened resolver but never badged: the exact disagreement this
345
+ > issue exists to close, reintroduced in the wrapped case specifically.
346
+ > 2. `WIDE_ID_CHAR` (`[^\s\]#]`, the *unwrapped* path, inherited from #124)
347
+ > still admitted `*`, while the resolver's implemented pattern narrowed
348
+ > its unwrapped branch too — so an unwrapped id containing `*` (e.g.
349
+ > `^ab*cd`) badged but never resolved. No known id format (ULID, NanoID,
350
+ > base62, snowflake, UUID) contains `*`, so this was lower practical risk
351
+ > than [1], but the same drift in the other direction.
352
+ >
353
+ > Fixed by narrowing `WIDE_ID_CHAR` to `[^\s\]#*]` (dropping `*`, keeping
354
+ > `_`) and correcting `WRAPPED_ID_CHAR` to the same `[^\s\]#*]` (dropping the
355
+ > wrongful `_` exclusion, keeping the `*` exclusion). The two constants are
356
+ > now identical in value; they stay separate, named constants because the
357
+ > resolver's wrapped and unwrapped charsets are independent knobs in
358
+ > `ANCHOR_LINE_PATTERN` that happen to currently agree, not because they are
359
+ > structurally required to.
360
+ >
361
+ > This is the second time these two independently-maintained rules have
362
+ > drifted while both issues were still open — the first was #124's original
363
+ > defect (the reason this ADR exists), the second is this correction. A
364
+ > shared, cross-repo fixture/case-list — noted as a candidate follow-up in
365
+ > `specs/127-widen-branch-b-to/spec.md`'s Out of Scope section and not
366
+ > pursued there — would turn the next such drift into a test failure in both
367
+ > repositories rather than something caught by manual cross-checking.
368
+
369
+ > **Amended 2026-09-10 (#126) — Branch A is deleted; the position rule now
370
+ > applies to every id form, ULID included, with no carve-out.** The first
371
+ > amendment above kept ULID's original, position-free rule "exactly as
372
+ > shipped, unconstrained by position, as a first branch tried before the new
373
+ > one," on the grounds that #122's own fixtures required a ULID to badge
374
+ > mid-line — immediately after a wiki-link or emphasis run with no preceding
375
+ > space, and followed by further prose on the same line. That inverted the
376
+ > defect #124 closes: a ULID badged in a position the resolver
377
+ > (`verveguy/liminis`'s `fs.ts`, `/(?:^|\s)\^([^\s\]#]+)\s*$/`) can never
378
+ > address, since the resolver only ever matches an anchor definition running
379
+ > to end of line. The badge was claiming resolvability the system could not
380
+ > deliver — the same "UI says X" / "system does Y" disagreement #124 fixes,
381
+ > just in the opposite direction.
382
+ >
383
+ > **Mid-line ULID *definitions* were never a supported shape.** Checking
384
+ > every ULID-bearing fixture that existed in `122-block-anchor/` against the
385
+ > resolver rule: of five, exactly one (`checkbox-anchor.md`, a checkbox item
386
+ > ending in `^<ULID>`) encoded an anchor the resolver could actually address
387
+ > — and it is the shape `liminis-framework`'s actions tooling emits. The
388
+ > other four asserted mid-line placement, immediately-adjacent-to-a-sibling
389
+ > placement, or both. Production's only mid-line ULID occurrences are
390
+ > wiki-link *references* (`[[file#^id]]` / `![[file#^id]]`), which are
391
+ > parsed position-independently by the existing wiki-link machinery and are
392
+ > untouched by this decision — nothing about referencing an anchor from
393
+ > mid-sentence changes. What #122's mid-line fixtures actually exercised was
394
+ > parser robustness (sibling-boundary text-node splitting, multiple anchors
395
+ > per paragraph), not a product requirement that anchor *definitions* be
396
+ > badge-able mid-line.
397
+ >
398
+ > **This supersedes both the original ADR's "length is the disambiguating
399
+ > signal" rationale (§"Decision" above) and the first amendment's carve-out
400
+ > that kept ULID's rule position-free.** `ULID_AT_CARET` and the try-A-then-B
401
+ > ordering in `findBlockAnchorMatches` are deleted outright — not
402
+ > reparameterized — leaving the position-gated rule (originally introduced
403
+ > for every *other* id form by the first amendment, and extended to accept a
404
+ > symmetric emphasis wrapper by the second) as the only path. This is safe
405
+ > as a pure deletion, not a rewrite: Crockford Base32 (ULID's charset) is
406
+ > already a strict subset of that rule's charset (`WIDE_ID_CHAR`/
407
+ > `WRAPPED_ID_CHAR`, `[^\s\]#*]`), so every ULID that already satisfied the
408
+ > position rule — plain or emphasis-wrapped, at line end — keeps badging
409
+ > unchanged. Only mid-line ULIDs, and ULIDs immediately adjacent to a
410
+ > preceding sibling with zero intervening whitespace, stop badging. That is
411
+ > the intended outcome, not a regression: those shapes could never be
412
+ > resolved, and the UI claiming otherwise was the defect.
413
+ >
414
+ > **Result: badge and resolver now agree for every id form, with no
415
+ > carve-out** — the outcome the first amendment (#124) set out to achieve
416
+ > and reached for every shape except ULID. `checkbox-anchor.md` (ULID at
417
+ > line end) and `checkbox-anchor-formatted.md` (an emphasis-wrapped ULID,
418
+ > #127's territory) are both unaffected and continue to badge unchanged.
419
+ >
420
+ > **A new residual, not previously named**: a caret with zero preceding
421
+ > whitespace, immediately following a wiki-link or emphasis/strong sibling
422
+ > with no intervening space, can never badge under the universal rule — at
423
+ > *any* position on the line, not just mid-line. `anchor-after-wikilink.md`
424
+ > and `anchor-in-emphasis-strong.md` originally asserted this exact
425
+ > zero-space shape; rewritten with one space inserted before the caret (see
426
+ > `fixtures/roundtrip/README.md`'s `122-block-anchor/` section), which keeps
427
+ > the sibling-boundary-splitting scenario under test while satisfying the
428
+ > position rule. The true zero-space shape is preserved as an explicit
429
+ > "does not badge" unit test in `parse.test.ts` rather than silently dropped,
430
+ > so this residual is recorded rather than rediscovered later.
431
+ >
432
+ > **The shared cross-repo case table** the previous amendment flagged as an
433
+ > unpursued candidate follow-up now exists:
434
+ > `src/markdown/__tests__/blockAnchorCases.ts` exports
435
+ > `BLOCK_ANCHOR_POSITION_CASES`, an id/position table asserted in full by a
436
+ > single `it.each` in `parse.test.ts`, pinned against the resolver's actual,
437
+ > merged `ANCHOR_LINE_PATTERN` (`liminis-app/src/main/fs.ts`, main @
438
+ > `19330368`). Scope is id/position combinations only; the wrapper/charset
439
+ > cases #127 added stay in their own `it.each` blocks. A future divergence
440
+ > between this repository's position rule and the resolver's now fails a
441
+ > test in this table rather than shipping as a live defect, the way both
442
+ > #124's and this issue's own gaps originally did.
443
+
444
+ ## References
445
+
446
+ - Issue #122 (this decision)
447
+ - Issue #124 (2026-09-09 amendment above — widened detection beyond ULID)
448
+ - Issue #127 (2026-09-10 amendment above — widened Branch B to badge
449
+ emphasis-wrapped ids, for parity with `verveguy/liminis#1114`)
450
+ - `docs/markdown-pipeline.md` ("Block anchor badges (#122, widened by #124
451
+ and #127)" section — the detection regex, the wrapper extension, the
452
+ post-parse text-split technique, and the round-trip contract)
453
+ - `docs/decisions/adr-119-block-transclusion.md` (the `#^blockId` reference-
454
+ side pattern this issue's regex is deliberately narrower than, and the
455
+ embed-marker sentinel technique this issue's post-parse approach is
456
+ contrasted against)
457
+ - `src/markdown/vendor/mdast-util-wiki-link/from-markdown.ts` (`BLOCK_ID_PATTERN`,
458
+ the reference-side pattern cited above)
459
+ - `src/app/mapper/__tests__/fixtures/roundtrip/122-block-anchor/` (the
460
+ round-trip fixture corpus backing FR-002/SC-002/SC-004/SC-005, including
461
+ `checkbox-anchor-formatted.md`'s wrapped-ULID case FR-3/SC-002 (#127)
462
+ keeps passing)
@@ -234,6 +234,7 @@ import { EditorHostProvider, Editor } from '@liminis/editor'
234
234
  | `bridge` | `EditorHostBridge` | The channel to your environment. Two methods only: `postMessage(message)` and `addMessageHandler(handler)` returning an unsubscribe. Every higher-level helper (`requestInit`, `applyTextEdits`, `writeAsset`, `openLink`) is built on `postMessage` inside the package, so every host adapter emits byte-identical payloads. Defaults to a no-op bridge. |
235
235
  | `logger` | `(namespace: string) => EditorLogger` | Namespaced logger factory. `EditorLogger` is `{ debug, info, warn, error }`, structurally compatible with most app loggers. |
236
236
  | `resolveWikiLinks` | `(targets: string[]) => Promise<Record<string, string \| null>>` | Map wiki-link targets to existing paths; `null` for unresolved. Drives the "this page does not exist yet" styling. |
237
+ | `resolveTransclusion` | `(file: string, blockId: string) => Promise<string \| null>` | Resolve a workspace-global block reference (`file#^blockId`, #119) to that block's current markdown content. Backs both live transclusion (`![[file#^id]]`) and existence styling for block-scoped links (`[[file#^id]]`). `null` for unresolved (unknown file or unknown block id — the parser does not distinguish the two; a resolver that cares to can). Absent, a transclusion renders a clearly marked "unresolved" placeholder rather than throwing. |
237
238
  | `onScrollToAnchor` | `(cb: (anchor: string) => void) => () => void` | Subscribe to host-driven scroll requests. Returns an unsubscribe. |
238
239
  | `notifyError` | `(message: string, description?: string) => void` | Surface a user-visible error. Defaults to a console warning. |
239
240
  | `corrections` | `CorrectionHostServices` | Persistence and knowledge-graph services backing the correction feature: `readCorrections`, `writeCorrections`, `suggestEntities`, `suggestPassages`, `applyCorrections`. The in-editor correction UI is package-side; the file and the graph are yours. |