@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.
@@ -566,6 +566,313 @@ function splitTextNodeEscapes(node, normalizedText) {
566
566
  flushRun(runStart, parts.length);
567
567
  return result;
568
568
  }
569
+ // Any id shape the resolver and the reference-side wiki-link parser already
570
+ // accept — ULID included — gated by the resolver's own position rule: the
571
+ // caret must start a token (line-start or preceded by whitespace), and the
572
+ // captured id must run to end of line (trailing spaces/tabs allowed). This is
573
+ // what makes badge and resolver agree by construction for every id form with
574
+ // no carve-out (#124/FR-002, #126/FR-001) — ULID no longer gets a permissive,
575
+ // position-free rule of its own; Crockford Base32 (ULID's charset) is already
576
+ // a strict subset of this charset, so every ULID that satisfies this position
577
+ // rule keeps badging unchanged (see ADR-122's amendments).
578
+ //
579
+ // The charset excludes `*` (in addition to `#`/`]`/whitespace) to match
580
+ // `liminis-app/src/main/fs.ts`'s `ANCHOR_LINE_PATTERN`
581
+ // (`[^\s\]#*]`) as actually implemented for `liminis#1114` — that resolver
582
+ // pattern narrowed *both* its wrapped and unwrapped branches to exclude `*`,
583
+ // unlike the stale, pre-implementation regex the #127 issue body quoted.
584
+ // `_` stays allowed: excluding it would break the NanoID-with-underscore
585
+ // case (#124/FR-004), and the resolver's own pattern allows it too.
586
+ const WIDE_ID_CHAR = /[^\s\]#*]/;
587
+ // A symmetric emphasis wrapper (`**`, `__`, `*`, `_`) around `^<id>` at line
588
+ // end badges too (#127), so this rule agrees with the widened resolver
589
+ // (`liminis#1114`) for wrapped ids the same way it already agrees for plain
590
+ // ones. The wrapped id charset matches the resolver's actual wrapped-branch
591
+ // charset (`[^\s\]#*]+?` in
592
+ // `ANCHOR_LINE_PATTERN`) — same as `WIDE_ID_CHAR` above; kept as a distinct,
593
+ // separately-named constant since the wrapped and unwrapped charsets are
594
+ // independent knobs in the resolver's pattern and could diverge again.
595
+ // Longest markers first so a `**`/`__` candidate is tried before its `*`/`_`
596
+ // prefix.
597
+ const WRAPPED_ID_CHAR = /[^\s\]#*]/;
598
+ const WRAPPER_MARKERS = ['**', '__', '*', '_'];
599
+ const isSpaceOrTab = (ch) => ch === ' ' || ch === '\t';
600
+ /**
601
+ * Look for one of `WRAPPER_MARKERS` immediately before `offset` in the raw,
602
+ * pre-parse `normalizedText`, itself preceded by whitespace or document
603
+ * start — the same left-boundary rule already applied to a bare caret, just
604
+ * one token further out. Returns the matched marker string, or
605
+ * `null` if none of them fit.
606
+ *
607
+ * This only needs to look *outside* the current text node (at `offset`, the
608
+ * node's own start) because a caret can only be adjacent to a *structural*
609
+ * wrapper marker when it is the first character of its own text node: if a
610
+ * literal `**`/`_` sat before the caret inside the same text node, that run
611
+ * of emphasis markers never found a matching closer and CommonMark left it
612
+ * as ordinary text, which the plain (non-wrapped) left-boundary check
613
+ * already handles correctly with no wrapper logic involved.
614
+ */
615
+ function matchWrapperMarkerBefore(normalizedText, offset) {
616
+ for (const marker of WRAPPER_MARKERS) {
617
+ const markerStart = offset - marker.length;
618
+ if (markerStart < 0)
619
+ continue;
620
+ if (normalizedText.slice(markerStart, offset) !== marker)
621
+ continue;
622
+ if (markerStart === 0 || /\s/.test(normalizedText[markerStart - 1]))
623
+ return marker;
624
+ }
625
+ return null;
626
+ }
627
+ /**
628
+ * Find every block-anchor match in `decoded` — any id shape, position-gated
629
+ * by the resolver's own rule, with no separate ULID carve-out (#126) — at
630
+ * each unescaped `^`, left to right.
631
+ *
632
+ * The boundary checks peek one character outside this text node's own
633
+ * `decoded`/`source` span — into `normalizedText` at `start - 1` (left) or
634
+ * from `end` onward (right) — rather than inspecting sibling AST nodes: the
635
+ * raw source character is equivalent and needs no tree traversal (see Plan
636
+ * stage's "Key Decisions"). This is what lets the rule correctly refuse a
637
+ * match immediately followed by more prose on the same line, even when that
638
+ * prose lives in a following sibling node (e.g. a wiki-link right after the
639
+ * id), and correctly accept one immediately after a preceding sibling ends
640
+ * with whitespace.
641
+ *
642
+ * A symmetric emphasis wrapper (`**`, `__`, `*`, `_`) around the id at line
643
+ * end also badges (#127), using the same outside-the-node peek:
644
+ * when the caret is the first character of this text node (`i === 0`) and
645
+ * the plain whitespace/start rule doesn't hold, it peeks backward for a
646
+ * wrapper marker; when one is found, the id must then run to this text
647
+ * node's own end (`idEnd === decoded.length`) and be followed immediately
648
+ * by the *same* marker string before the usual trailing-whitespace/EOL
649
+ * check. Both `i === 0` and `idEnd === decoded.length` are load-bearing
650
+ * invariants, not incidental: they are exactly the positions at which a
651
+ * *structural* wrapper marker (one CommonMark parsed as real emphasis,
652
+ * rather than literal text left over from an unmatched delimiter run) can
653
+ * be adjacent to the caret/id at all — see `matchWrapperMarkerBefore`.
654
+ */
655
+ function findBlockAnchorMatches(decoded, parts, normalizedText, start, end) {
656
+ const matches = [];
657
+ let cursor = 0;
658
+ for (let i = 0; i < decoded.length; i++) {
659
+ if (i < cursor)
660
+ continue;
661
+ if (decoded[i] !== '^' || parts[i].escaped)
662
+ continue;
663
+ // Left boundary: preceded by whitespace, or true document start — or,
664
+ // when the caret is the first character of this text node, a symmetric
665
+ // emphasis wrapper immediately before it (#127). That `i === 0` gate is
666
+ // load-bearing: it's the only position at which a *structural* wrapper
667
+ // marker (one CommonMark actually parsed as emphasis, not literal text)
668
+ // can sit immediately before the caret — see `matchWrapperMarkerBefore`.
669
+ const leftOk = i > 0 ? /\s/.test(decoded[i - 1]) : start === 0 || /\s/.test(normalizedText[start - 1]);
670
+ let wrapMarker = null;
671
+ if (!leftOk) {
672
+ if (i === 0)
673
+ wrapMarker = matchWrapperMarkerBefore(normalizedText, start);
674
+ if (!wrapMarker)
675
+ continue;
676
+ }
677
+ // Id capture: greedy run of non-whitespace, non-`]`, non-`#`, non-`*`,
678
+ // tested against `decoded` rather than raw source. A backslash-escaped `#` or
679
+ // `]` inside an id (`^abc\#def`) therefore truncates the capture one
680
+ // character earlier than a regex run over raw, undecoded file text
681
+ // would (the resolver's own matching model) — but this never produces a
682
+ // badge/resolver disagreement: whatever follows the truncation point is
683
+ // identical, non-whitespace content in both the decoded and raw views
684
+ // (backslash-escaping only ever turns `\X` into `X`, never anything
685
+ // into whitespace), so the right-boundary check below rejects the match
686
+ // in both models alike whenever this truncation is reachable. See the
687
+ // "does not badge an id containing a backslash-escaped delimiter"
688
+ // regression test.
689
+ //
690
+ // When wrapped, `WRAPPED_ID_CHAR` applies instead of `WIDE_ID_CHAR` (see
691
+ // their definitions) — currently identical charsets, kept as separate
692
+ // named constants since the resolver's wrapped/unwrapped branches are
693
+ // independent knobs that could diverge again.
694
+ const idCharTest = wrapMarker ? WRAPPED_ID_CHAR : WIDE_ID_CHAR;
695
+ let idEnd = i + 1;
696
+ while (idEnd < decoded.length && idCharTest.test(decoded[idEnd]))
697
+ idEnd++;
698
+ // Empty capture: the character right after `^` is already whitespace
699
+ // (or end of text), e.g. `a ^ b`, `a ^`, `x ^\t`. `a ^ b` would also be
700
+ // rejected by the right-boundary check below regardless (the trailing
701
+ // `b` isn't end-of-line), but a bare trailing caret like `a ^` or
702
+ // `x ^\t` reaches true end-of-line/end-of-document and would otherwise
703
+ // pass that check with an empty id — this guard is what actually stops
704
+ // that case. See "does not badge a bare trailing caret" regression test.
705
+ if (idEnd === i + 1)
706
+ continue;
707
+ // Right boundary: only trailing spaces/tabs before end of line or end of
708
+ // document — peeking past this node's own end into `normalizedText` if
709
+ // the capture runs all the way to it.
710
+ //
711
+ // When wrapped, the closer is required instead: the id capture must run
712
+ // all the way to this text node's own end (the mirror image of the
713
+ // `i === 0` left-boundary gate — a structural closer can only be
714
+ // adjacent to the id there), the exact same marker string that opened
715
+ // it must appear immediately after, and only trailing spaces/tabs and
716
+ // end-of-line/end-of-document may follow that. No fallback to the
717
+ // unwrapped rule on mismatch — an asymmetric wrapper (e.g. `**^id_`)
718
+ // must never badge with a corrupted id (#127/SC-003).
719
+ let rightOk;
720
+ if (wrapMarker) {
721
+ if (idEnd !== decoded.length)
722
+ continue;
723
+ const closerEnd = end + wrapMarker.length;
724
+ if (normalizedText.slice(end, closerEnd) !== wrapMarker)
725
+ continue;
726
+ let pos = closerEnd;
727
+ while (pos < normalizedText.length && isSpaceOrTab(normalizedText[pos]))
728
+ pos++;
729
+ rightOk = pos === normalizedText.length || normalizedText[pos] === '\n';
730
+ }
731
+ else {
732
+ let j = idEnd;
733
+ while (j < decoded.length && isSpaceOrTab(decoded[j]))
734
+ j++;
735
+ if (j < decoded.length) {
736
+ rightOk = decoded[j] === '\n';
737
+ }
738
+ else {
739
+ let pos = end;
740
+ while (pos < normalizedText.length && isSpaceOrTab(normalizedText[pos]))
741
+ pos++;
742
+ rightOk = pos === normalizedText.length || normalizedText[pos] === '\n';
743
+ }
744
+ }
745
+ if (!rightOk)
746
+ continue;
747
+ matches.push({ index: i, length: idEnd - i });
748
+ cursor = idEnd;
749
+ }
750
+ return matches;
751
+ }
752
+ /**
753
+ * Split a single `text` node into `[before, blockAnchor, after, ...]`
754
+ * siblings wherever it contains a {@link findBlockAnchorMatches} match,
755
+ * mirroring `splitTextNodeEscapes`'s decode-replay + position-mapping
756
+ * machinery exactly (including its conservative bail-out when replayed
757
+ * decoding doesn't exactly reproduce `node.value`, e.g. a character
758
+ * reference in the span) rather than duplicating it (#122).
759
+ *
760
+ * Because this only ever inspects a `text` node's own `value` (plus, for
761
+ * the boundary checks, one character immediately outside it), it can
762
+ * never see into `inlineCode`, `code`, `inlineMath`, `wikiLink` or
763
+ * `wikiEmbed` node content — none of those are `text` nodes once mdast has
764
+ * typed them — which satisfies the code-span/fenced-code/math edge case and
765
+ * FR-006 by construction, with no "protected ranges" pre-parse machinery
766
+ * needed.
767
+ */
768
+ function splitTextNodeBlockAnchors(node, normalizedText) {
769
+ const start = node.position?.start?.offset;
770
+ const end = node.position?.end?.offset;
771
+ if (start == null || end == null) {
772
+ return [node];
773
+ }
774
+ const source = normalizedText.slice(start, end);
775
+ const { decoded, parts } = replayDecodeEscapes(source);
776
+ if (decoded !== node.value) {
777
+ return [node];
778
+ }
779
+ // A caret whose leading `^` came from a backslash escape (`\^`) in the
780
+ // source is never a match candidate — `findBlockAnchorMatches` checks
781
+ // `parts[i].escaped` itself before trying either branch. `decoded` has
782
+ // already resolved `\^` to a plain `^`, so nothing downstream can tell the
783
+ // two apart on its own; an author who deliberately escaped a caret meant
784
+ // literal text, not an anchor, and `stringify.ts`'s `blockAnchor` handler
785
+ // always emits a bare `^id` with no escaping, so badging it would silently
786
+ // drop the escape on the next save.
787
+ const matches = findBlockAnchorMatches(decoded, parts, normalizedText, start, end);
788
+ if (matches.length === 0) {
789
+ return [node];
790
+ }
791
+ const startPos = node.position.start;
792
+ // Per-offset line/column within `source`, mirroring splitTextNodeEscapes.
793
+ const positionAt = new Array(source.length + 1);
794
+ {
795
+ let line = startPos.line;
796
+ let column = startPos.column;
797
+ positionAt[0] = { line, column };
798
+ for (let i = 0; i < source.length; i++) {
799
+ if (source[i] === '\n') {
800
+ line += 1;
801
+ column = 1;
802
+ }
803
+ else {
804
+ column += 1;
805
+ }
806
+ positionAt[i + 1] = { line, column };
807
+ }
808
+ }
809
+ const makePosition = (srcStart, srcEnd) => ({
810
+ start: { ...positionAt[srcStart - start], offset: srcStart },
811
+ end: { ...positionAt[srcEnd - start], offset: srcEnd },
812
+ });
813
+ const makeTextNode = (value, srcStart, srcEnd) => ({
814
+ type: 'text',
815
+ value,
816
+ position: makePosition(srcStart, srcEnd),
817
+ });
818
+ const result = [];
819
+ let cursor = 0; // index into `parts`/`decoded`
820
+ for (const match of matches) {
821
+ const matchStart = match.index;
822
+ const matchEnd = matchStart + match.length;
823
+ if (matchStart > cursor) {
824
+ const value = parts.slice(cursor, matchStart).map((p) => p.char).join('');
825
+ result.push(makeTextNode(value, start + parts[cursor].srcStart, start + parts[matchStart - 1].srcEnd));
826
+ }
827
+ // Captured from raw `source`, not `decoded`: stringify.ts's `blockAnchor`
828
+ // handler re-emits `^${node.id}` verbatim with no escaping, so the id
829
+ // must already carry any backslash the author wrote (e.g. `^ab\_cd`) or
830
+ // that escape is silently dropped on the next save — a round-trip
831
+ // corruption distinct from, and not covered by, the decoded-vs-raw
832
+ // truncation reasoning above (that reasoning only shows the *match
833
+ // boundary* never diverges; it says nothing about what ends up inside
834
+ // an id that does match).
835
+ result.push({
836
+ type: 'blockAnchor',
837
+ id: source.slice(parts[matchStart + 1].srcStart, parts[matchEnd - 1].srcEnd),
838
+ position: makePosition(start + parts[matchStart].srcStart, start + parts[matchEnd - 1].srcEnd),
839
+ });
840
+ cursor = matchEnd;
841
+ }
842
+ if (cursor < parts.length) {
843
+ const value = parts.slice(cursor, parts.length).map((p) => p.char).join('');
844
+ result.push(makeTextNode(value, start + parts[cursor].srcStart, start + parts[parts.length - 1].srcEnd));
845
+ }
846
+ return result;
847
+ }
848
+ /**
849
+ * Walk the tree and split every `text` node containing a block anchor (see
850
+ * `splitTextNodeBlockAnchors`) into siblings. Run after `resolveWikiEmbeds`/
851
+ * `annotateEmphasisMarkers` (so this never sees wiki-link/embed target text —
852
+ * FR-006) and before `splitEscapedPunctuation` (so that pass still sees, and
853
+ * can process, any escaped punctuation left in this split's "before"/"after"
854
+ * text siblings) (#122).
855
+ */
856
+ function splitBlockAnchors(root, normalizedText) {
857
+ function walk(node) {
858
+ if (!node || typeof node !== 'object')
859
+ return node;
860
+ if (node.children && Array.isArray(node.children)) {
861
+ const children = [];
862
+ for (const child of node.children) {
863
+ if (child?.type === 'text') {
864
+ children.push(...splitTextNodeBlockAnchors(child, normalizedText));
865
+ }
866
+ else {
867
+ children.push(walk(child));
868
+ }
869
+ }
870
+ return { ...node, children };
871
+ }
872
+ return node;
873
+ }
874
+ return walk(root);
875
+ }
569
876
  /**
570
877
  * Walk the tree and split every `text` node containing a force-escaped
571
878
  * character (see `splitTextNodeEscapes`) into siblings. Run last, after all
@@ -684,6 +991,9 @@ export function parseMarkdown(text, _options = {}) {
684
991
  root = resolveWikiEmbeds(root);
685
992
  // Post-process: annotate emphasis/strong marker characters from original source
686
993
  root = annotateEmphasisMarkers(root, text, replacements);
994
+ // Post-process: split a bare `^ULID` block anchor out of its surrounding
995
+ // text so it can render as a badge instead of raw text (#122)
996
+ root = splitBlockAnchors(root, normalizedText);
687
997
  // Post-process: split out backslash-escaped punctuation so its escape can
688
998
  // be carried through Lexical and restored at stringify time (#17)
689
999
  root = splitEscapedPunctuation(root, normalizedText);
@@ -348,6 +348,11 @@ export function stringifyMarkdown(root, options = {}) {
348
348
  return marker + content + marker;
349
349
  },
350
350
  escapedChar: (node) => `${FORCE_ESCAPE_PLACEHOLDER}${node.value}${FORCE_ESCAPE_PLACEHOLDER}`,
351
+ // Block anchor badge (#122): a `blockAnchor` node produced by
352
+ // `splitBlockAnchors` in parse.ts always carries exactly the id
353
+ // matched from the source, so re-emitting `^id` is lossless by
354
+ // construction — no escaping needed, mirroring `wikiLink`/`wikiEmbed`.
355
+ blockAnchor: (node) => `^${node.id}`,
351
356
  wikiLink: (node) => `[[${formatWikiLinkBody(node)}]]`,
352
357
  // Transclusion/embed (#119): the `!`-prefixed form of a block-scoped
353
358
  // wiki-link. `formatWikiLinkBody` requires `data.blockId` be present