@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.
@@ -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.5.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",