@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
package/dist/markdown/parse.js
CHANGED
|
@@ -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
|