@liminis/editor 0.5.0 → 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 +22 -1
- package/dist/app/editor/editorNodes.js +2 -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/index.d.ts +2 -0
- package/dist/app/editor/nodes/index.js +1 -0
- package/dist/app/mapper/lexicalToMdast.js +62 -17
- package/dist/app/mapper/mdastToLexical.js +10 -4
- package/dist/markdown/parse.js +310 -0
- package/dist/markdown/stringify.js +5 -0
- package/docs/decisions/adr-122-block-anchor-badge.md +462 -0
- package/docs/markdown-pipeline.md +213 -0
- package/package.json +1 -1
|
@@ -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. `&`) 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)
|