@liminis/editor 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -1
- package/dist/app/editor/LinkClickPlugin.js +5 -1
- package/dist/app/editor/WikiLinkExistencePlugin.d.ts +13 -3
- package/dist/app/editor/WikiLinkExistencePlugin.js +91 -32
- package/dist/app/editor/editorNodes.js +3 -1
- package/dist/app/editor/nodes/BlockAnchorComponent.d.ts +5 -0
- package/dist/app/editor/nodes/BlockAnchorComponent.js +37 -0
- package/dist/app/editor/nodes/BlockAnchorNode.d.ts +42 -0
- package/dist/app/editor/nodes/BlockAnchorNode.js +151 -0
- package/dist/app/editor/nodes/CustomLinkNode.d.ts +14 -0
- package/dist/app/editor/nodes/CustomLinkNode.js +37 -0
- package/dist/app/editor/nodes/TransclusionComponent.d.ts +8 -0
- package/dist/app/editor/nodes/TransclusionComponent.js +65 -0
- package/dist/app/editor/nodes/TransclusionNode.d.ts +48 -0
- package/dist/app/editor/nodes/TransclusionNode.js +141 -0
- package/dist/app/editor/nodes/index.d.ts +4 -0
- package/dist/app/editor/nodes/index.js +2 -0
- package/dist/app/editor/nodes/transclusion-loading.d.ts +27 -0
- package/dist/app/editor/nodes/transclusion-loading.js +29 -0
- package/dist/app/editor/nodes/transclusion-render.d.ts +49 -0
- package/dist/app/editor/nodes/transclusion-render.js +186 -0
- package/dist/app/mapper/lexicalToMdast.js +116 -22
- package/dist/app/mapper/mdastToLexical.js +76 -4
- package/dist/host/defaults.js +1 -0
- package/dist/host/messages.d.ts +7 -1
- package/dist/host/messages.js +3 -3
- package/dist/host/types.d.ts +14 -1
- package/dist/markdown/parse.js +535 -1
- package/dist/markdown/stringify.js +35 -10
- package/dist/markdown/vendor/mdast-util-wiki-link/README.md +17 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.d.ts +8 -1
- package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.js +26 -1
- package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.d.ts +13 -7
- package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.js +3 -1
- package/dist/styles.css +48 -0
- package/dist/types.d.ts +1 -0
- package/docs/decisions/adr-119-block-transclusion.md +247 -0
- package/docs/decisions/adr-122-block-anchor-badge.md +462 -0
- package/docs/editor-api.md +1 -0
- package/docs/markdown-pipeline.md +323 -6
- package/package.json +5 -3
|
@@ -199,17 +199,334 @@ inside the extension:
|
|
|
199
199
|
`[[target]]` only because `parseMarkdown` substitutes a sentinel before parsing
|
|
200
200
|
and sets `data._emptyAlias` after. The extensions alone produce no wiki-link
|
|
201
201
|
node at all for that input.
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
raw
|
|
202
|
+
- **Transclusion/embed detection (`![[target]]`, #119).** The `!`-prefixed form
|
|
203
|
+
is recognized only by `parseMarkdown`'s own embed-sentinel pre/post-processing
|
|
204
|
+
(see below) — the raw extensions have no concept of it at all. A `!` before a
|
|
205
|
+
raw-extension `[[...]]` is just ordinary preceding text; the extensions never
|
|
206
|
+
produce a `wikiEmbed` node. `data.blockId` on a plain `wikiLink` node *is*
|
|
207
|
+
available through the raw extensions (it's pure token-value splitting inside
|
|
208
|
+
the vendored `mdast-util-wiki-link`, not pipeline-level surgery) — only the
|
|
209
|
+
embed marker itself is main-pipeline-only.
|
|
210
|
+
|
|
211
|
+
So: **if your pipeline enables GFM tables, cares about `[[target|]]`, or needs
|
|
212
|
+
`![[target#^id]]` transclusion detection, call `parseMarkdown` rather than
|
|
213
|
+
assembling the extensions yourself.** Reach for the raw extensions only when
|
|
214
|
+
you control the input and none of those cases apply.
|
|
206
215
|
|
|
207
216
|
Note also that `<Editor>`'s own *serialization* does not go through
|
|
208
217
|
`wikiLinkToMarkdown`: `stringifyMarkdown` carries a hand-rolled wiki-link handler
|
|
209
|
-
that additionally understands `data._emptyAlias
|
|
210
|
-
faithful vendored upstream serializer
|
|
218
|
+
that additionally understands `data._emptyAlias` and `wikiEmbed` nodes.
|
|
219
|
+
`wikiLinkToMarkdown` is the faithful vendored upstream serializer (extended with
|
|
220
|
+
`data.blockId` re-appending, see below), not a byte-for-byte match for what the
|
|
211
221
|
editor emits.
|
|
212
222
|
|
|
223
|
+
## Block-scoped links and transclusion (#119)
|
|
224
|
+
|
|
225
|
+
`[[file#^id]]` is a block-scoped link — the same `[[...]]` construct as
|
|
226
|
+
above, extended with an optional `#^blockId` fragment (Obsidian's
|
|
227
|
+
block-reference convention: a caret immediately after the `#`). It parses to
|
|
228
|
+
the same `wikiLink` node shape, with `data.blockId` set:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
{ type: 'wikiLink', value: 'file', data: { alias, permalink, exists, blockId: 'id', /* … */ } }
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
An ordinary heading anchor (`[[file#heading]]`, no caret) is untouched —
|
|
235
|
+
`data.blockId` is only ever set for the caret-prefixed form, so this is
|
|
236
|
+
purely additive to the existing anchor-link behavior described elsewhere in
|
|
237
|
+
this document.
|
|
238
|
+
|
|
239
|
+
`![[file#^id]]` — the same target+id addressing, `!`-prefixed — is
|
|
240
|
+
**transclusion**: a live, resolver-driven rendering of that block's current
|
|
241
|
+
content in place of the reference, not a link. It parses to a distinct
|
|
242
|
+
`wikiEmbed` node, not a `wikiLink` with a flag:
|
|
243
|
+
|
|
244
|
+
```ts
|
|
245
|
+
{ type: 'wikiEmbed', value: 'file', data: { alias, blockId: 'id', /* … */ } }
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
`![[file]]` with no `#^id` fragment (whole-file transclusion) is not a
|
|
249
|
+
supported construct — the parser leaves it as an ordinary `[[file]]` link
|
|
250
|
+
(no embedding), never a `wikiEmbed`.
|
|
251
|
+
|
|
252
|
+
### Why `wikiEmbed` is a separate node type, not a field on `wikiLink`
|
|
253
|
+
|
|
254
|
+
Every existing `wikiLink` consumer — including this repository's own
|
|
255
|
+
mdast↔Lexical mappers — is untouched by this addition. `[[file#^id]]` (link)
|
|
256
|
+
and `![[file#^id]]` (embed) are trivially distinguishable by `node.type` for
|
|
257
|
+
any downstream consumer, rather than requiring a new-field check added to
|
|
258
|
+
code that predates this feature.
|
|
259
|
+
|
|
260
|
+
### How the embed marker is detected without a second vendored tokenizer
|
|
261
|
+
|
|
262
|
+
`micromark-extension-wiki-link` (unvendored, straight from npm) hooks only
|
|
263
|
+
the `[` character, with no `!`-prefix awareness. A literal `!` immediately
|
|
264
|
+
before `[[` is claimed *first* by the default CommonMark image-label-start
|
|
265
|
+
construct; when that construct fails to find a following `(url)`/`[ref]`
|
|
266
|
+
(which it always does for `[[target]]` — that isn't image syntax), bracket
|
|
267
|
+
resolution falls the *entire* `![[target]]` span back to one literal text
|
|
268
|
+
node, without the wiki-link tokenizer ever getting a chance to fire on the
|
|
269
|
+
inner `[[`.
|
|
270
|
+
|
|
271
|
+
Rather than vendoring a second tokenizer package to add `!`-prefix detection
|
|
272
|
+
(the LICENSE/provenance/parity-test burden this repository already paid once
|
|
273
|
+
for #347's `mdast-util-wiki-link` fix), `parseMarkdown` swaps a `!` for a
|
|
274
|
+
Private-Use-Area sentinel codepoint *before* parsing — but only when it is
|
|
275
|
+
immediately followed by a complete, single-line `[[...]]` span with no
|
|
276
|
+
internal `]`, exactly the grammar the tokenizer's own target/alias states
|
|
277
|
+
require to succeed. That condition is load-bearing, not incidental: a naive
|
|
278
|
+
"any `!` before `[[`" substitution collides with a real image whose alt text
|
|
279
|
+
starts with a literal bracket (`![[leading] bracket](img.png)` contains the
|
|
280
|
+
raw substring `![[`), and would prevent the image construct — which needs
|
|
281
|
+
the literal `!` — from ever being tried.
|
|
282
|
+
|
|
283
|
+
After parsing, a post-process retypes a sentinel-preceded `wikiLink` node to
|
|
284
|
+
`wikiEmbed` only when it carries a `blockId`; otherwise the literal `!` is
|
|
285
|
+
restored and the node stays an ordinary `wikiLink` (`![[file]]` with no id
|
|
286
|
+
degrades to a plain link, per the "not a supported construct" rule above).
|
|
287
|
+
The swap is one codepoint for one codepoint, so it needs no offset-remapping
|
|
288
|
+
the way the pipe-escaping/empty-alias-normalization pre-passes above do.
|
|
289
|
+
|
|
290
|
+
**If you are maintaining this package: do not remove or loosen the
|
|
291
|
+
"complete span, no internal `]`" condition on the embed-marker substitution
|
|
292
|
+
in `parse.ts`.** It looks like it could be simplified to a bare
|
|
293
|
+
`!(?=\[\[)` lookahead. Doing so silently corrupts any image whose alt text
|
|
294
|
+
starts with a bracketed span — caught before merge by the
|
|
295
|
+
`903-image-alt-leading-bracket` round-trip fixture, which is the regression
|
|
296
|
+
gate for this specific failure mode.
|
|
297
|
+
|
|
298
|
+
### The transclusion resolver
|
|
299
|
+
|
|
300
|
+
`![[file#^id]]` renders the resolved block's live content via an optional
|
|
301
|
+
host-injected `resolveTransclusion(file, blockId) => Promise<string | null>`
|
|
302
|
+
(see `docs/editor-api.md`), consumed by a lazily-loaded component — not at
|
|
303
|
+
mapper time, since resolution is async and `mdastToLexical`/`lexicalToMdast`
|
|
304
|
+
are synchronous. With no resolver injected, or one that returns `null`, the
|
|
305
|
+
transclusion renders a clearly marked "unresolved" placeholder rather than
|
|
306
|
+
throwing or rendering nothing.
|
|
307
|
+
|
|
308
|
+
**If you are maintaining this package: do not remove the cycle/depth guard**
|
|
309
|
+
in `src/app/editor/nodes/transclusion-render.tsx`. A transclusion cycle (A
|
|
310
|
+
embeds B, B embeds A — directly, or transitively through a longer chain) is
|
|
311
|
+
guarded by a per-branch visited-path of `file#^blockId` keys, and nested
|
|
312
|
+
transclusion is bounded to a depth of 8, both checked *before* the resolver
|
|
313
|
+
is called at each level. Removing either turns an authoring mistake into an
|
|
314
|
+
infinite loop or unbounded recursion instead of a contained "circular
|
|
315
|
+
transclusion"/"nested too deeply" indicator.
|
|
316
|
+
|
|
317
|
+
## Block anchor badges (#122, widened by #124 and #127)
|
|
318
|
+
|
|
319
|
+
A block anchor — a bare `^ULID` at its *definition* site, most commonly
|
|
320
|
+
trailing an action-item checkbox (`- [ ] ... ^01M00VDX0S4JHMDNA7F776Y8R8`) —
|
|
321
|
+
renders as a compact badge instead of the raw caret-plus-id text. This is
|
|
322
|
+
presentation only: the underlying markdown, and the id itself, never change.
|
|
323
|
+
It has no relationship to the `#^blockId` fragment inside `[[file#^id]]`
|
|
324
|
+
above other than sharing a source convention — this section is about the
|
|
325
|
+
anchor's own definition site, not a link's target.
|
|
326
|
+
|
|
327
|
+
### `blockAnchor` is a new mdast node type, detected by a post-parse text split
|
|
328
|
+
|
|
329
|
+
```ts
|
|
330
|
+
{ type: 'blockAnchor', id: '01M00VDX0S4JHMDNA7F776Y8R8' }
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Unlike the embed marker (`![[...]]`), a bare `^ULID` needs no tokenizer
|
|
334
|
+
unlock — there is no bracket syntax to let through. Detection is therefore a
|
|
335
|
+
**post-parse** pass (`splitTextNodeBlockAnchors`/`splitBlockAnchors` in
|
|
336
|
+
`parse.ts`) walking already-typed mdast `text` nodes and splitting a matching
|
|
337
|
+
run into its own `blockAnchor` node, mirroring `#17`'s
|
|
338
|
+
`splitTextNodeEscapes`/`splitEscapedPunctuation` (including its decode-replay
|
|
339
|
+
position-mapping machinery and the same conservative bail-out: if a text
|
|
340
|
+
node's replayed decoding doesn't exactly reproduce `node.value` — e.g. a
|
|
341
|
+
character reference is present — the whole run is left unsplit rather than
|
|
342
|
+
risk a wrong split).
|
|
343
|
+
|
|
344
|
+
Because this only ever inspects a `text` node's own `value`, it structurally
|
|
345
|
+
cannot see into `inlineCode`, `code`, `inlineMath`, `wikiLink` or `wikiEmbed`
|
|
346
|
+
node content — none of those are `text` nodes once mdast has typed them —
|
|
347
|
+
which satisfies the code-span/fenced-code/math edge case and FR-006 for free,
|
|
348
|
+
with no "protected ranges" pre-parse machinery required (unlike
|
|
349
|
+
`substituteEmbedMarker` above, which needs that machinery only because it has
|
|
350
|
+
to influence tokenization itself).
|
|
351
|
+
|
|
352
|
+
The matcher also rejects a match whose leading `^` came from a backslash
|
|
353
|
+
escape (`\^`) in the source, using the same `replayDecodeEscapes` output the
|
|
354
|
+
decode-replay machinery above already computes. Without this check, an
|
|
355
|
+
author who deliberately wrote `\^` before a ULID-shaped run to mean literal
|
|
356
|
+
text — not an anchor — would still get a badge, and since the `blockAnchor`
|
|
357
|
+
stringify handler always emits a bare, unescaped `^id`, saving would silently
|
|
358
|
+
drop their backslash. This does not repair the pre-existing, unrelated gap
|
|
359
|
+
that `^` sits outside `FORCE_ESCAPE_CHARS`: a bare `\^` with no adjacent
|
|
360
|
+
ULID-shaped run still loses its backslash on round-trip today, anchor or
|
|
361
|
+
not — see ADR-122's accepted limitations.
|
|
362
|
+
|
|
363
|
+
The pass runs in `parseMarkdown`'s post-process sequence after
|
|
364
|
+
`resolveWikiEmbeds`/`annotateEmphasisMarkers` — so it never sees wiki-link or
|
|
365
|
+
embed target text — and immediately before `splitEscapedPunctuation` (which
|
|
366
|
+
stays last), so that pass still sees, and can process, any escaped
|
|
367
|
+
punctuation left in the anchor split's "before"/"after" text siblings.
|
|
368
|
+
|
|
369
|
+
`stringify.ts`'s `blockAnchor` handler emits exactly `^` + the node's `id`,
|
|
370
|
+
with no escaping — lossless by construction, the same way `wikiLink`/
|
|
371
|
+
`wikiEmbed` are.
|
|
372
|
+
|
|
373
|
+
### The detection rule: one universal position rule, every id form alike (#124, #127, #126)
|
|
374
|
+
|
|
375
|
+
`findBlockAnchorMatches` in `parse.ts` applies a single rule at each
|
|
376
|
+
unescaped `^`, left to right: any id the resolver and the wiki-link
|
|
377
|
+
reference side already accept — ULID, raw-decimal snowflake ids, NanoID-style
|
|
378
|
+
ids with `_`/`-`, mixed-case base62, UUID-shaped hyphenated ids, short
|
|
379
|
+
alphanumeric ids — badges if and only if the caret starts a token (line-start
|
|
380
|
+
or preceded by whitespace) and the captured id (`WIDE_ID_CHAR`, the same
|
|
381
|
+
charset the wiki-link reference side and the resolver use) runs to end of
|
|
382
|
+
line. This is the resolver's own regex (`liminis-app/src/main/fs.ts`,
|
|
383
|
+
verveguy/liminis#1109) adopted verbatim, so badge and resolver agree by
|
|
384
|
+
construction rather than by coincidence, for every id form with no
|
|
385
|
+
carve-out. (At the time #124 shipped, `WIDE_ID_CHAR` was `[^\s\]#]+`; #127
|
|
386
|
+
later narrowed it to also exclude `*`, matching a further narrowing the
|
|
387
|
+
resolver itself picked up for `liminis#1114` — see below.)
|
|
388
|
+
|
|
389
|
+
**This was not always one rule.** #124 originally split detection into two
|
|
390
|
+
branches: ULID kept #122's original rule, unconstrained by position, while
|
|
391
|
+
every other id form was gated by the position rule above. That carve-out
|
|
392
|
+
existed because #122's own fixtures required a ULID to badge immediately
|
|
393
|
+
after a wiki-link/emphasis run with no preceding space, and required a ULID
|
|
394
|
+
followed by more prose on the same line to badge — shapes the resolver can
|
|
395
|
+
never address, since it only ever matches an anchor definition running to
|
|
396
|
+
end of line. #126 deleted the carve-out: those mid-line fixtures turned out
|
|
397
|
+
to encode parser-robustness tests, not a product requirement, because
|
|
398
|
+
production's actual mid-line ULID occurrences are wiki-link *references*
|
|
399
|
+
(`[[file#^id]]`), parsed position-independently and unaffected by this
|
|
400
|
+
change — not anchor *definitions*. A badged mid-line ULID was a promise
|
|
401
|
+
`[[file#^id]]` could never keep, the same "UI says X, system does Y"
|
|
402
|
+
disagreement #124 fixes, in the opposite direction. Deleting the carve-out
|
|
403
|
+
was a pure removal, not a rewrite: Crockford Base32 (ULID's charset) is
|
|
404
|
+
already a strict subset of the position rule's charset, so every ULID that
|
|
405
|
+
already satisfied the position rule — plain or emphasis-wrapped, at line
|
|
406
|
+
end — keeps badging unchanged; only mid-line ULIDs, and ULIDs immediately
|
|
407
|
+
adjacent to a preceding sibling with zero intervening whitespace, stopped
|
|
408
|
+
badging. See ADR-122's 2026-09-10 (#126) amendment for the full history,
|
|
409
|
+
including the rejected minimum-length threshold and the residual this left
|
|
410
|
+
(a caret with zero preceding whitespace, immediately after a wiki-link or
|
|
411
|
+
emphasis/strong sibling, can never badge at any line position — not just
|
|
412
|
+
mid-line).
|
|
413
|
+
|
|
414
|
+
Widening the charset without position-gating was, and remains, rejected:
|
|
415
|
+
`x^2`/`2^10`/`a ^ b` would badge as false positives, since nothing else
|
|
416
|
+
bounds an undelimited charset match the way `[[...]]` brackets bound the
|
|
417
|
+
wiki-link reference side's use of the same charset.
|
|
418
|
+
|
|
419
|
+
A shared, cross-repo case table
|
|
420
|
+
(`src/markdown/__tests__/blockAnchorCases.ts`, `BLOCK_ANCHOR_POSITION_CASES`)
|
|
421
|
+
pins this rule's id/position behavior against the resolver's actual, merged
|
|
422
|
+
`ANCHOR_LINE_PATTERN`, asserted in full by a single `it.each` in
|
|
423
|
+
`parse.test.ts` — so a future divergence between the two repositories' rules
|
|
424
|
+
fails a test instead of shipping as a live defect, the way both #124's and
|
|
425
|
+
#126's own gaps originally did.
|
|
426
|
+
|
|
427
|
+
The boundary checks may need to peek one character outside the current text
|
|
428
|
+
node's own source span — into the surrounding normalized document text, one
|
|
429
|
+
character before the node's start or from the node's end onward — since a
|
|
430
|
+
preceding/following sibling (e.g. a wiki-link) has no character of its own
|
|
431
|
+
for the matcher to inspect directly. This is equivalent to inspecting the
|
|
432
|
+
sibling node and needs no AST traversal to do it.
|
|
433
|
+
|
|
434
|
+
**A symmetric emphasis wrapper at line end (added by #127).**
|
|
435
|
+
`**^a1b2c3**`, `__^a1b2c3__`, `*^a1b2c3*` and `_^a1b2c3_` all badge
|
|
436
|
+
`a1b2c3` — matching `verveguy/liminis#1114`'s widened resolver, which
|
|
437
|
+
accepts the same wrapped shape.
|
|
438
|
+
|
|
439
|
+
When CommonMark parses `**^a1b2c3**` as real emphasis, the wrapper
|
|
440
|
+
characters are consumed into the *parent* `strong`/`emphasis` node's
|
|
441
|
+
position span — they never appear in the inner `text` node's own
|
|
442
|
+
`decoded`/`value`. That has a useful structural consequence: a caret can
|
|
443
|
+
only be adjacent to a *structural* wrapper marker when it is the first
|
|
444
|
+
character of its own text node (`i === 0`), and a closer only when the id
|
|
445
|
+
capture runs all the way to that same node's own end
|
|
446
|
+
(`idEnd === decoded.length`) — outside those two positions, whatever
|
|
447
|
+
wrapper-like characters are present are literal text left over from an
|
|
448
|
+
unmatched delimiter run, already correctly rejected by the plain rule with
|
|
449
|
+
no wrapper logic involved. Gating wrapper detection on both invariants
|
|
450
|
+
means the extension is just two more raw-character peeks into the
|
|
451
|
+
surrounding normalized text (`matchWrapperMarkerBefore` on the open side,
|
|
452
|
+
an exact-string check on the close side), the same style the plain
|
|
453
|
+
whitespace boundary above already uses — no need to pass the enclosing
|
|
454
|
+
`strong`/`emphasis` node's type, marker, or position down through the tree
|
|
455
|
+
walk.
|
|
456
|
+
|
|
457
|
+
The wrapped id charset (`WRAPPED_ID_CHAR`, `[^\s\]#*]`) matches the
|
|
458
|
+
resolver's actual wrapped-branch charset as implemented for `liminis#1114`
|
|
459
|
+
(`ANCHOR_LINE_PATTERN`'s `[^\s\]#*]+?` wrapped-id group) — it excludes `*`
|
|
460
|
+
but allows `_`, since `_` is a legitimate character in ids like
|
|
461
|
+
`V1StGXR8_Z5jdHi6B-myT` (NanoID) and `snake_case_id`. `WIDE_ID_CHAR` (the
|
|
462
|
+
unwrapped path) uses the same charset: the resolver's implemented pattern
|
|
463
|
+
narrowed *both* its wrapped and unwrapped branches to exclude `*`, not just
|
|
464
|
+
the wrapped one, so the two constants are currently identical — kept as
|
|
465
|
+
separate names since they're independent knobs in the resolver's pattern
|
|
466
|
+
that could diverge again. Neither excludes `_`: doing so would regress the
|
|
467
|
+
NanoID-with-underscore case (`^V1StGXR8_Z5jdHi6B-myT`, #124/FR-004), and the
|
|
468
|
+
resolver doesn't exclude it either.
|
|
469
|
+
|
|
470
|
+
The closer must be the *exact same marker string* that opened it — not an
|
|
471
|
+
independently optional match — so an asymmetric wrapper (`item **^<ULID>_`)
|
|
472
|
+
is rejected outright rather than captured with a corrupted id, ULID or not
|
|
473
|
+
(#126: there is no longer a position-free carve-out for it to fall back to).
|
|
474
|
+
Wrapper forms other than `**`/`__`/`*`/`_` — triple emphasis (`***…***`),
|
|
475
|
+
strikethrough (`~~…~~`), backtick-wrapped, and paren-wrapped — stay
|
|
476
|
+
unhandled by design: `WRAPPER_MARKERS` only lists the four forms
|
|
477
|
+
`liminis#1114`'s resolver pattern accepts. `squared *^2*` becomes a
|
|
478
|
+
badged-and-resolved false anchor as a result of this widening — accepted
|
|
479
|
+
deliberately, for the same reason a minimum-length floor was already
|
|
480
|
+
rejected for `^100` in #124 (excluding it would also exclude legitimate
|
|
481
|
+
short ids like `^a1b2c3`). See ADR-122's 2026-09-10 (#127) and 2026-09-10
|
|
482
|
+
(#126) amendments.
|
|
483
|
+
|
|
484
|
+
### The Lexical side: `BlockAnchorNode`, mirroring `FootnoteNode`
|
|
485
|
+
|
|
486
|
+
`BlockAnchorNode` (`src/app/editor/nodes/BlockAnchorNode.tsx`) is an inline
|
|
487
|
+
`DecoratorNode<JSX.Element>` carrying the id, a format bitmask, and
|
|
488
|
+
strong/emphasis marker fields — the same shape as `FootnoteNode`, so an
|
|
489
|
+
anchor sitting inside `**bold**`/`_italic_` still round-trips its original
|
|
490
|
+
marker style rather than silently dropping it (the defect class already
|
|
491
|
+
fixed once for other inline decorators, `#898`/`#908`). `BlockAnchorComponent`
|
|
492
|
+
renders the actual badge: the full id is exposed via the native `title`
|
|
493
|
+
tooltip on hover, and a click copies it via `navigator.clipboard.writeText`
|
|
494
|
+
with brief "Copied" feedback — mirroring `CodeBlockPlugin.tsx`'s existing
|
|
495
|
+
copy pattern, rather than a new interaction affordance (User Story 2/SC-003).
|
|
496
|
+
|
|
497
|
+
`convertInlineNode` (`mdastToLexical.ts`) maps a `blockAnchor` mdast node to
|
|
498
|
+
`$createBlockAnchorNode`; the reverse direction in `lexicalToMdast.ts` adds an
|
|
499
|
+
`$isBlockAnchorNode` branch at every site that already special-cases
|
|
500
|
+
`$isFootnoteNode` (`isHoistableConstruct`, `getMergeableFormat`,
|
|
501
|
+
`resolveMarkers`, and each content-conversion call site) so a `BlockAnchorNode`
|
|
502
|
+
participates in bold/italic wrapping, mark-boundary hoisting, and format
|
|
503
|
+
merging exactly like every other round-trip-sensitive inline construct.
|
|
504
|
+
|
|
505
|
+
**If you are maintaining this package: `convertListItemNode` keeps a
|
|
506
|
+
`BlockAnchorNode` inline, unlike an image/equation/footnote/inline-HTML
|
|
507
|
+
child of a list item** (a documented, pre-existing gap — see
|
|
508
|
+
`hoistedTokenReachesOutput`'s docstring in `lexicalToMdast.ts` — where those
|
|
509
|
+
constructs fall to the block dispatcher and do not survive a round trip
|
|
510
|
+
inline). `- [ ] ... ^ULID` is this feature's primary real-world shape, so
|
|
511
|
+
dropping a trailing anchor there would break the checkbox-action-item
|
|
512
|
+
pattern the spec calls out explicitly. Do not fold the `$isBlockAnchorNode`
|
|
513
|
+
branch back into the generic block-dispatch fallback.
|
|
514
|
+
|
|
515
|
+
### Round-trip contract
|
|
516
|
+
|
|
517
|
+
`parseMarkdown` → `stringifyMarkdown` reproduces a document containing block
|
|
518
|
+
anchors byte-identically (FR-002/SC-002), including inside a checkbox action
|
|
519
|
+
item, one space after a wiki-link or emphasis run, and inside bold/italic
|
|
520
|
+
text. A caret inside inline code, a fenced code block, or inline math is
|
|
521
|
+
never touched — it round-trips as plain literal text, since the post-parse
|
|
522
|
+
pass never sees inside those node types. A caret with *zero* preceding
|
|
523
|
+
whitespace immediately after a wiki-link or emphasis run also round-trips
|
|
524
|
+
byte-identically, but as plain text, not a `blockAnchor` node (#126: that
|
|
525
|
+
position can never satisfy the position rule, so it never badges — see the
|
|
526
|
+
detection-rule section above). See
|
|
527
|
+
`src/app/mapper/__tests__/fixtures/roundtrip/122-block-anchor/` for the
|
|
528
|
+
fixture corpus.
|
|
529
|
+
|
|
213
530
|
## Wiki-link promotion on export
|
|
214
531
|
|
|
215
532
|
Everything above is about *parsing* `[[target]]` syntax the author already wrote.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liminis/editor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"//publishing": "Publishing is deliberate, never incidental. `private: true` was this package's guard until verveguy/liminis-editor#39 took the publish decision; it is gone because that decision was taken, not because it was tidied away. The guard is now `prepublishOnly` -> scripts/guard-publish.mjs, which refuses unless LIMINIS_ALLOW_PUBLISH=1 is set explicitly. That variable is set at step scope in .github/workflows/publish.yml and nowhere else, so a release is the only path that publishes. Note that `npm publish --dry-run` does NOT report a private package as blocked (npm 10.8.2), which is why the guard is a script rather than a flag.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Lexical-based markdown WYSIWYG editor with mdast round-trip and a host-injection seam",
|
|
@@ -74,14 +74,15 @@
|
|
|
74
74
|
"test:watch": "vitest",
|
|
75
75
|
"test:coverage": "vitest run --coverage",
|
|
76
76
|
"verify:package": "node scripts/verify-package.mjs",
|
|
77
|
+
"check:drift": "node scripts/check-lockfile-drift.mjs",
|
|
77
78
|
"docs:theming": "node scripts/generate-theming-docs.mjs",
|
|
78
79
|
"docs:theming-baseline": "node scripts/update-theming-baseline.mjs",
|
|
79
80
|
"demo": "node scripts/run-demo.mjs",
|
|
80
81
|
"build:examples": "node scripts/build-examples.mjs",
|
|
81
82
|
"build:site": "node scripts/build-site.mjs"
|
|
82
83
|
},
|
|
83
|
-
"//peerDependencies": "Ranges are the compatibility contract, not the tested matrix. CI resolves one point version per range (currently react 19.2.x latest and lexical 0.49.x latest), so every caret range here has an untested lower half
|
|
84
|
-
"//lexicalPeerPolicy": "The twelve Lexical ranges are a single-caret bump per Lexical minor actually adopted and tested here, not a wide multi-version band. `react`'s untested lower half (above) is safe because a break there would be a bug in a stable major; Lexical is `0.x`, where every minor is permitted to break, so a wide band would claim compatibility with untested, potentially-breaking releases. Widening a range later is patch-safe; narrowing one is breaking
|
|
84
|
+
"//peerDependencies": "Ranges are the compatibility contract, not the tested matrix. CI resolves one point version per range (currently react 19.2.x latest and lexical 0.49.x latest), so every caret range here has an untested lower half — narrowing `react` to the devDependency's `^19.2.5` would not change that, it would only reject working consumers. The react floor is not a claim, it is enforced: `tests/package-manifest-contract.test.ts` (\"imports no React API newer than the declared 19.2.0 peer floor\") scans every source file for post-19.0 named imports — `useEffectEvent`, `Activity`, `cacheSignal`, `ViewTransition`, `captureOwnerStack` — and fails if one appears. Reaching for a newer API therefore breaks CI until you raise the floor here too, which is the pairing this note would otherwise only assert. Raise a floor when the code actually starts needing a later API, not to mirror whatever CI happened to install. This reasoning does not extend to the Lexical ranges below — see `//lexicalPeerPolicy`.",
|
|
85
|
+
"//lexicalPeerPolicy": "The twelve Lexical ranges are a single-caret bump per Lexical minor actually adopted and tested here, not a wide multi-version band. `react`'s untested lower half (above) is safe because a break there would be a bug in a stable major; Lexical is `0.x`, where every minor is permitted to break, so a wide band would claim compatibility with untested, potentially-breaking releases. Widening a range later is patch-safe; narrowing one is breaking — so this stays narrow by default and moves forward only when the code is actually run against the new minor. See docs/decisions/adr-92-lexical-peer-range-policy.md.",
|
|
85
86
|
"peerDependencies": {
|
|
86
87
|
"@lexical/code": "^0.49.0",
|
|
87
88
|
"@lexical/code-prism": "^0.49.0",
|
|
@@ -150,6 +151,7 @@
|
|
|
150
151
|
"lexical": "^0.49.0",
|
|
151
152
|
"react": "^19.2.5",
|
|
152
153
|
"react-dom": "^19.2.5",
|
|
154
|
+
"semver": "^7.8.5",
|
|
153
155
|
"tsc-alias": "^1.9.1",
|
|
154
156
|
"typescript": "^6.0.3",
|
|
155
157
|
"typescript-eslint": "^8.59.0",
|