@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
|
@@ -314,6 +314,219 @@ is called at each level. Removing either turns an authoring mistake into an
|
|
|
314
314
|
infinite loop or unbounded recursion instead of a contained "circular
|
|
315
315
|
transclusion"/"nested too deeply" indicator.
|
|
316
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
|
+
|
|
317
530
|
## Wiki-link promotion on export
|
|
318
531
|
|
|
319
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",
|