@liminis/editor 0.1.0-rc.1
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/LICENSE +34 -0
- package/README.md +376 -0
- package/dist/annotations/anchor-align.d.ts +50 -0
- package/dist/annotations/anchor-align.js +74 -0
- package/dist/annotations/anchor-model.d.ts +81 -0
- package/dist/annotations/anchor-model.js +80 -0
- package/dist/annotations/anchor-resolver.d.ts +92 -0
- package/dist/annotations/anchor-resolver.js +231 -0
- package/dist/annotations/block-structure.d.ts +65 -0
- package/dist/annotations/block-structure.js +113 -0
- package/dist/annotations/types.d.ts +159 -0
- package/dist/annotations/types.js +50 -0
- package/dist/annotations.d.ts +25 -0
- package/dist/annotations.js +27 -0
- package/dist/app/App.d.ts +64 -0
- package/dist/app/App.js +180 -0
- package/dist/app/editor/AmbientCorrectionPlugin.d.ts +18 -0
- package/dist/app/editor/AmbientCorrectionPlugin.js +185 -0
- package/dist/app/editor/AnchorScrollPlugin.d.ts +16 -0
- package/dist/app/editor/AnchorScrollPlugin.js +134 -0
- package/dist/app/editor/AnnotationMarkerPlugin.d.ts +24 -0
- package/dist/app/editor/AnnotationMarkerPlugin.js +224 -0
- package/dist/app/editor/AnnotationPlugin.d.ts +42 -0
- package/dist/app/editor/AnnotationPlugin.js +135 -0
- package/dist/app/editor/AnnotationSurface.d.ts +34 -0
- package/dist/app/editor/AnnotationSurface.js +115 -0
- package/dist/app/editor/AssetContext.d.ts +20 -0
- package/dist/app/editor/AssetContext.js +81 -0
- package/dist/app/editor/BlockClickPlugin.d.ts +8 -0
- package/dist/app/editor/BlockClickPlugin.js +126 -0
- package/dist/app/editor/CodeBlockPlugin.d.ts +1 -0
- package/dist/app/editor/CodeBlockPlugin.js +219 -0
- package/dist/app/editor/CorrectionPanelPlugin.d.ts +14 -0
- package/dist/app/editor/CorrectionPanelPlugin.js +333 -0
- package/dist/app/editor/DragHandlePlugin.d.ts +1 -0
- package/dist/app/editor/DragHandlePlugin.js +300 -0
- package/dist/app/editor/Editor.d.ts +84 -0
- package/dist/app/editor/Editor.js +539 -0
- package/dist/app/editor/FrontmatterPlugin.d.ts +19 -0
- package/dist/app/editor/FrontmatterPlugin.js +301 -0
- package/dist/app/editor/ImageModal.d.ts +13 -0
- package/dist/app/editor/ImageModal.js +267 -0
- package/dist/app/editor/ImagePlugin.d.ts +3 -0
- package/dist/app/editor/ImagePlugin.js +187 -0
- package/dist/app/editor/LinkClickPlugin.d.ts +23 -0
- package/dist/app/editor/LinkClickPlugin.js +90 -0
- package/dist/app/editor/MarkdownShortcutsPlugin.d.ts +49 -0
- package/dist/app/editor/MarkdownShortcutsPlugin.js +373 -0
- package/dist/app/editor/OrderedTaskListPlugin.d.ts +21 -0
- package/dist/app/editor/OrderedTaskListPlugin.js +78 -0
- package/dist/app/editor/SearchPlugin.d.ts +1 -0
- package/dist/app/editor/SearchPlugin.js +379 -0
- package/dist/app/editor/SelectionContextMenuPlugin.d.ts +29 -0
- package/dist/app/editor/SelectionContextMenuPlugin.js +160 -0
- package/dist/app/editor/SlashMenu.d.ts +11 -0
- package/dist/app/editor/SlashMenu.js +494 -0
- package/dist/app/editor/SlashMenuPlugin.d.ts +1 -0
- package/dist/app/editor/SlashMenuPlugin.js +168 -0
- package/dist/app/editor/TableActionsPlugin.d.ts +1 -0
- package/dist/app/editor/TableActionsPlugin.js +398 -0
- package/dist/app/editor/TogglePlugin.d.ts +1 -0
- package/dist/app/editor/TogglePlugin.js +159 -0
- package/dist/app/editor/Toolbar.d.ts +10 -0
- package/dist/app/editor/Toolbar.js +263 -0
- package/dist/app/editor/WikiLinkExistencePlugin.d.ts +17 -0
- package/dist/app/editor/WikiLinkExistencePlugin.js +104 -0
- package/dist/app/editor/WikiLinkFormatPlugin.d.ts +17 -0
- package/dist/app/editor/WikiLinkFormatPlugin.js +203 -0
- package/dist/app/editor/annotation-marks.d.ts +177 -0
- package/dist/app/editor/annotation-marks.js +750 -0
- package/dist/app/editor/annotationCommands.d.ts +13 -0
- package/dist/app/editor/annotationCommands.js +11 -0
- package/dist/app/editor/c4/C4InteractiveRenderer.d.ts +35 -0
- package/dist/app/editor/c4/C4InteractiveRenderer.js +299 -0
- package/dist/app/editor/c4/edge-clipping.d.ts +24 -0
- package/dist/app/editor/c4/edge-clipping.js +139 -0
- package/dist/app/editor/c4/hooks/useC4DiagramDrag.d.ts +38 -0
- package/dist/app/editor/c4/hooks/useC4DiagramDrag.js +112 -0
- package/dist/app/editor/c4/layout.d.ts +25 -0
- package/dist/app/editor/c4/layout.js +839 -0
- package/dist/app/editor/c4/parser.d.ts +19 -0
- package/dist/app/editor/c4/parser.js +410 -0
- package/dist/app/editor/c4/render-to-string.d.ts +24 -0
- package/dist/app/editor/c4/render-to-string.js +34 -0
- package/dist/app/editor/c4/renderer.d.ts +64 -0
- package/dist/app/editor/c4/renderer.js +569 -0
- package/dist/app/editor/c4/types.d.ts +203 -0
- package/dist/app/editor/c4/types.js +43 -0
- package/dist/app/editor/correction-yaml.d.ts +10 -0
- package/dist/app/editor/correction-yaml.js +40 -0
- package/dist/app/editor/editorNodes.d.ts +9 -0
- package/dist/app/editor/editorNodes.js +36 -0
- package/dist/app/editor/index.d.ts +1 -0
- package/dist/app/editor/index.js +1 -0
- package/dist/app/editor/mark-overlap-resolver.d.ts +6 -0
- package/dist/app/editor/mark-overlap-resolver.js +45 -0
- package/dist/app/editor/nodes/C4Component.d.ts +14 -0
- package/dist/app/editor/nodes/C4Component.js +284 -0
- package/dist/app/editor/nodes/C4Node.d.ts +32 -0
- package/dist/app/editor/nodes/C4Node.js +112 -0
- package/dist/app/editor/nodes/CalloutNode.d.ts +23 -0
- package/dist/app/editor/nodes/CalloutNode.js +94 -0
- package/dist/app/editor/nodes/CustomLinkNode.d.ts +57 -0
- package/dist/app/editor/nodes/CustomLinkNode.js +209 -0
- package/dist/app/editor/nodes/CustomListItemNode.d.ts +46 -0
- package/dist/app/editor/nodes/CustomListItemNode.js +120 -0
- package/dist/app/editor/nodes/CustomListNode.d.ts +27 -0
- package/dist/app/editor/nodes/CustomListNode.js +54 -0
- package/dist/app/editor/nodes/DefinitionListNode.d.ts +44 -0
- package/dist/app/editor/nodes/DefinitionListNode.js +151 -0
- package/dist/app/editor/nodes/DiagramContextMenu.d.ts +45 -0
- package/dist/app/editor/nodes/DiagramContextMenu.js +116 -0
- package/dist/app/editor/nodes/EquationComponent.d.ts +17 -0
- package/dist/app/editor/nodes/EquationComponent.js +130 -0
- package/dist/app/editor/nodes/EquationNode.d.ts +43 -0
- package/dist/app/editor/nodes/EquationNode.js +179 -0
- package/dist/app/editor/nodes/FootnoteNode.d.ts +40 -0
- package/dist/app/editor/nodes/FootnoteNode.js +151 -0
- package/dist/app/editor/nodes/FrontmatterNode.d.ts +24 -0
- package/dist/app/editor/nodes/FrontmatterNode.js +88 -0
- package/dist/app/editor/nodes/HorizontalRuleNode.d.ts +17 -0
- package/dist/app/editor/nodes/HorizontalRuleNode.js +57 -0
- package/dist/app/editor/nodes/HtmlNode.d.ts +32 -0
- package/dist/app/editor/nodes/HtmlNode.js +121 -0
- package/dist/app/editor/nodes/ImageComponent.d.ts +11 -0
- package/dist/app/editor/nodes/ImageComponent.js +228 -0
- package/dist/app/editor/nodes/ImageNode.d.ts +53 -0
- package/dist/app/editor/nodes/ImageNode.js +183 -0
- package/dist/app/editor/nodes/ListItemParagraphBreakNode.d.ts +42 -0
- package/dist/app/editor/nodes/ListItemParagraphBreakNode.js +95 -0
- package/dist/app/editor/nodes/MermaidComponent.d.ts +11 -0
- package/dist/app/editor/nodes/MermaidComponent.js +216 -0
- package/dist/app/editor/nodes/MermaidNode.d.ts +27 -0
- package/dist/app/editor/nodes/MermaidNode.js +96 -0
- package/dist/app/editor/nodes/ToggleNode.d.ts +56 -0
- package/dist/app/editor/nodes/ToggleNode.js +237 -0
- package/dist/app/editor/nodes/diagram-context-menu.d.ts +11 -0
- package/dist/app/editor/nodes/diagram-context-menu.js +70 -0
- package/dist/app/editor/nodes/diagram-utils.d.ts +13 -0
- package/dist/app/editor/nodes/diagram-utils.js +29 -0
- package/dist/app/editor/nodes/index.d.ts +30 -0
- package/dist/app/editor/nodes/index.js +17 -0
- package/dist/app/editor/utils.d.ts +2 -0
- package/dist/app/editor/utils.js +17 -0
- package/dist/app/mapper/index.d.ts +3 -0
- package/dist/app/mapper/index.js +2 -0
- package/dist/app/mapper/lexicalToMdast.d.ts +62 -0
- package/dist/app/mapper/lexicalToMdast.js +1937 -0
- package/dist/app/mapper/mdastToLexical.d.ts +31 -0
- package/dist/app/mapper/mdastToLexical.js +1075 -0
- package/dist/contract.d.ts +8 -0
- package/dist/contract.js +8 -0
- package/dist/headless.d.ts +40 -0
- package/dist/headless.js +45 -0
- package/dist/host/context.d.ts +20 -0
- package/dist/host/context.js +24 -0
- package/dist/host/defaults.d.ts +21 -0
- package/dist/host/defaults.js +47 -0
- package/dist/host/messages.d.ts +26 -0
- package/dist/host/messages.js +47 -0
- package/dist/host/types.d.ts +75 -0
- package/dist/host/types.js +13 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +51 -0
- package/dist/markdown/annotate-sentinels.d.ts +42 -0
- package/dist/markdown/annotate-sentinels.js +86 -0
- package/dist/markdown/parse.d.ts +56 -0
- package/dist/markdown/parse.js +516 -0
- package/dist/markdown/stringify.d.ts +7 -0
- package/dist/markdown/stringify.js +439 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/LICENSE +21 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/README.md +41 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.d.ts +72 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/from-markdown.js +127 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/index.d.ts +8 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/index.js +6 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.d.ts +17 -0
- package/dist/markdown/vendor/mdast-util-wiki-link/to-markdown.js +33 -0
- package/dist/markdown.d.ts +28 -0
- package/dist/markdown.js +44 -0
- package/dist/mathjax-config.d.ts +89 -0
- package/dist/mathjax-config.js +133 -0
- package/dist/nodes.d.ts +34 -0
- package/dist/nodes.js +34 -0
- package/dist/stores/correctionStore.d.ts +15 -0
- package/dist/stores/correctionStore.js +12 -0
- package/dist/stores/editorStore.d.ts +16 -0
- package/dist/stores/editorStore.js +27 -0
- package/dist/styles.css +2477 -0
- package/dist/types.d.ts +296 -0
- package/dist/types.js +91 -0
- package/dist/utils/file-types.d.ts +10 -0
- package/dist/utils/file-types.js +46 -0
- package/docs/annotations.md +134 -0
- package/docs/decisions/README.md +72 -0
- package/docs/decisions/adr-002.md +27 -0
- package/docs/decisions/adr-007.md +27 -0
- package/docs/decisions/adr-010.md +26 -0
- package/docs/decisions/adr-024.md +124 -0
- package/docs/decisions/adr-024b.md +0 -0
- package/docs/decisions/adr-025.md +103 -0
- package/docs/decisions/adr-027.md +66 -0
- package/docs/decisions/adr-057.md +67 -0
- package/docs/decisions/adr-070.md +131 -0
- package/docs/decisions/adr-075.md +410 -0
- package/docs/decisions/adr-076.md +183 -0
- package/docs/decisions/adr-077.md +410 -0
- package/docs/decisions/adr-078.md +243 -0
- package/docs/decisions/adr-079.md +167 -0
- package/docs/decisions/adr-080.md +325 -0
- package/docs/decisions/adr-081.md +108 -0
- package/docs/decisions/adr-082.md +123 -0
- package/docs/decisions/adr-083.md +204 -0
- package/docs/decisions/adr-084.md +179 -0
- package/docs/editor-api.md +106 -0
- package/docs/markdown-pipeline.md +282 -0
- package/docs/provenance.md +106 -0
- package/docs/zusammen-editor-capability-map.md +232 -0
- package/package.json +175 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PROVENANCE — ported from Zusammen (`verveguy/zusammen`) for Liminis #939.
|
|
3
|
+
*
|
|
4
|
+
* The doc comments below are the original author's and are kept verbatim so
|
|
5
|
+
* this module stays diffable against its source. Their `FR-NNN`/`SC-NNN`
|
|
6
|
+
* identifiers, `#NN` issue references and `adrs/` paths therefore name
|
|
7
|
+
* **Zusammen's** spec, issues and ADRs — not this repository's, where the same
|
|
8
|
+
* identifiers mean something else entirely. For the Liminis-side design see
|
|
9
|
+
* `docs/decisions/adr-077.md` and
|
|
10
|
+
* `docs/zusammen-editor-capability-map.md`.
|
|
11
|
+
*
|
|
12
|
+
* "Comment"/"thread" in these comments should be read as "annotation": this
|
|
13
|
+
* module now serves both annotation kinds, not comments alone.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Comment Anchor Model
|
|
17
|
+
*
|
|
18
|
+
* Defines the content-relative anchor payload persisted through the
|
|
19
|
+
* comment-event-store's (issue #13) `re-anchor`/`anchor-recorded` events, and
|
|
20
|
+
* the resolution-outcome payload persisted through `anchor-resolved` events.
|
|
21
|
+
* Both were left opaque (`unknown`) by that store on purpose — this module is
|
|
22
|
+
* what gives them shape (FR-001, FR-002).
|
|
23
|
+
*
|
|
24
|
+
* Pure, git-free — no fs, no git, safe to unit test with plain strings.
|
|
25
|
+
*/
|
|
26
|
+
import { z } from 'zod';
|
|
27
|
+
import { findEnclosingBlock, parseBlocks } from './block-structure.js';
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Anchor
|
|
30
|
+
// ============================================================================
|
|
31
|
+
/** How many characters of surrounding text are captured on each side of the target, for duplicate disambiguation (FR-006). */
|
|
32
|
+
export const CONTEXT_WINDOW_CHARS = 40;
|
|
33
|
+
export const ANCHOR_SCHEMA = z.object({
|
|
34
|
+
targetText: z.string(),
|
|
35
|
+
prefixContext: z.string(),
|
|
36
|
+
suffixContext: z.string(),
|
|
37
|
+
blockType: z.enum(['paragraph', 'heading', 'code', 'tableCell', 'thematicBreak']).nullable(),
|
|
38
|
+
occurrenceIndex: z.number().int().min(0),
|
|
39
|
+
docVersion: z.string().min(1),
|
|
40
|
+
});
|
|
41
|
+
/** Count occurrences of `targetText` in `text` that start strictly before `beforeStart`. */
|
|
42
|
+
function occurrenceIndexOf(text, targetText, beforeStart) {
|
|
43
|
+
if (!targetText)
|
|
44
|
+
return 0;
|
|
45
|
+
let count = 0;
|
|
46
|
+
let idx = text.indexOf(targetText);
|
|
47
|
+
while (idx !== -1 && idx < beforeStart) {
|
|
48
|
+
count++;
|
|
49
|
+
idx = text.indexOf(targetText, idx + 1);
|
|
50
|
+
}
|
|
51
|
+
return count;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Capture a durable anchor for the text at `range` within `text`, as of
|
|
55
|
+
* `docVersion`. The target's enclosing block type is null when the range
|
|
56
|
+
* spans more than one leaf block (e.g. a comment deliberately covering two
|
|
57
|
+
* paragraphs) — the resolver treats that the same as any other
|
|
58
|
+
* structural-boundary case.
|
|
59
|
+
*/
|
|
60
|
+
export function captureAnchor(text, range, docVersion) {
|
|
61
|
+
const targetText = text.slice(range.start, range.end);
|
|
62
|
+
const prefixContext = text.slice(Math.max(0, range.start - CONTEXT_WINDOW_CHARS), range.start);
|
|
63
|
+
const suffixContext = text.slice(range.end, Math.min(text.length, range.end + CONTEXT_WINDOW_CHARS));
|
|
64
|
+
const blocks = parseBlocks(text);
|
|
65
|
+
const enclosing = findEnclosingBlock(blocks, range.start, range.end);
|
|
66
|
+
return {
|
|
67
|
+
targetText,
|
|
68
|
+
prefixContext,
|
|
69
|
+
suffixContext,
|
|
70
|
+
blockType: enclosing?.type ?? null,
|
|
71
|
+
occurrenceIndex: occurrenceIndexOf(text, targetText, range.start),
|
|
72
|
+
docVersion,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export const ANCHOR_RESOLUTION_SCHEMA = z.object({
|
|
76
|
+
outcome: z.enum(['unchanged', 're-attached', 'flagged', 'orphaned']),
|
|
77
|
+
docVersion: z.string().min(1),
|
|
78
|
+
reason: z.string(),
|
|
79
|
+
anchor: ANCHOR_SCHEMA.optional(),
|
|
80
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PROVENANCE — ported from Zusammen (`verveguy/zusammen`) for Liminis #939.
|
|
3
|
+
*
|
|
4
|
+
* The doc comments below are the original author's and are kept verbatim so
|
|
5
|
+
* this module stays diffable against its source. Their `FR-NNN`/`SC-NNN`
|
|
6
|
+
* identifiers, `#NN` issue references and `adrs/` paths therefore name
|
|
7
|
+
* **Zusammen's** spec, issues and ADRs — not this repository's, where the same
|
|
8
|
+
* identifiers mean something else entirely. For the Liminis-side design see
|
|
9
|
+
* `docs/decisions/adr-077.md` and
|
|
10
|
+
* `docs/zusammen-editor-capability-map.md`.
|
|
11
|
+
*
|
|
12
|
+
* "Comment"/"thread" in these comments should be read as "annotation": this
|
|
13
|
+
* module now serves both annotation kinds, not comments alone.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Comment Anchor Resolver
|
|
17
|
+
*
|
|
18
|
+
* The deterministic classification algorithm at the heart of durable comment
|
|
19
|
+
* anchoring (issue #25): given an anchor captured against some prior document
|
|
20
|
+
* version and the text of a new version, decide whether the comment's target
|
|
21
|
+
* is unchanged, should be re-attached, must be flagged, or is orphaned
|
|
22
|
+
* (FR-002). Needs only the anchor plus the new text — never the old text —
|
|
23
|
+
* which is what makes resolution provenance-agnostic (FR-010): a direct edit
|
|
24
|
+
* and a merge that produce the same new text resolve identically.
|
|
25
|
+
*
|
|
26
|
+
* Pure, git-free — no fs, no git, safe to unit test with plain strings.
|
|
27
|
+
*/
|
|
28
|
+
import type { Anchor, AnchorResolution } from './anchor-model.js';
|
|
29
|
+
/**
|
|
30
|
+
* Minimum normalized similarity for a fuzzy match to re-attach automatically.
|
|
31
|
+
* Chosen so a "single-digit percentage of characters changed" reword (SC-002)
|
|
32
|
+
* clears this floor comfortably (e.g. changing 2 words out of 10 in a short
|
|
33
|
+
* sentence typically scores well above 0.6), while a heavily rewritten or
|
|
34
|
+
* unrelated target does not. Not corpus-calibrated — a reasoned default,
|
|
35
|
+
* revisable via this one constant.
|
|
36
|
+
*/
|
|
37
|
+
export declare const REATTACH_THRESHOLD = 0.6;
|
|
38
|
+
/**
|
|
39
|
+
* Minimum normalized similarity for a fuzzy match to flag rather than orphan
|
|
40
|
+
* outright. Below this floor there is essentially no lexical overlap left
|
|
41
|
+
* (SC-004's deleted-target case, and US2 acceptance scenario 3's heavy
|
|
42
|
+
* rewrite), so the target is treated as gone rather than merely uncertain.
|
|
43
|
+
*/
|
|
44
|
+
export declare const FLAG_THRESHOLD = 0.35;
|
|
45
|
+
/**
|
|
46
|
+
* Optional extension point consulted only for an otherwise-orphaned comment
|
|
47
|
+
* (FR-008). Returns a freshly captured anchor if a relocation is proposed, or
|
|
48
|
+
* null to fall through to orphaned. This issue ships only the no-op default —
|
|
49
|
+
* a model-backed implementation is a separate follow-on (see adrs/ for the
|
|
50
|
+
* anchor-resolution ADR and the design context on this issue).
|
|
51
|
+
*/
|
|
52
|
+
export type ProposeSemanticRelocation = (params: {
|
|
53
|
+
anchor: Anchor;
|
|
54
|
+
newText: string;
|
|
55
|
+
}) => Promise<Anchor | null> | Anchor | null;
|
|
56
|
+
export declare const noopProposeSemanticRelocation: ProposeSemanticRelocation;
|
|
57
|
+
/** Normalized similarity in [0, 1] — 1 means identical, 0 means completely dissimilar. */
|
|
58
|
+
export declare function similarity(a: string, b: string): number;
|
|
59
|
+
export interface ResolveAnchorOptions {
|
|
60
|
+
proposeSemanticRelocation?: ProposeSemanticRelocation;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Resolve a comment's anchor against a new document version (FR-002). Never
|
|
64
|
+
* needs the old document text — only the anchor and `newText` — so a direct
|
|
65
|
+
* edit and a merge that land on the same new text resolve identically
|
|
66
|
+
* (FR-010).
|
|
67
|
+
*/
|
|
68
|
+
export declare function resolveAnchor(anchor: Anchor, newText: string, newDocVersion: string, options?: ResolveAnchorOptions): Promise<AnchorResolution>;
|
|
69
|
+
/** An anchor paired with the caller's opaque identifier for it. */
|
|
70
|
+
export interface IdentifiedAnchor {
|
|
71
|
+
id: string;
|
|
72
|
+
anchor: Anchor;
|
|
73
|
+
}
|
|
74
|
+
/** One batch entry's outcome, carrying back the caller's `id` unchanged. */
|
|
75
|
+
export interface AnchorResolutionResult {
|
|
76
|
+
id: string;
|
|
77
|
+
outcome: AnchorResolution['outcome'];
|
|
78
|
+
reason: string;
|
|
79
|
+
/** Present only when the outcome is `re-attached`. */
|
|
80
|
+
resolvedAnchor?: Anchor;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Resolve many anchors against one document version — the headless entry point
|
|
84
|
+
* hosts call outside a rendered editor (FR-002). Each anchor resolves through
|
|
85
|
+
* the same {@link resolveAnchor} path, so classification is identical to the
|
|
86
|
+
* single-anchor case; this wrapper only handles fan-out and re-attaches the
|
|
87
|
+
* caller's opaque `id` to each outcome.
|
|
88
|
+
*
|
|
89
|
+
* With no `proposeSemanticRelocation` supplied, an unmatchable anchor yields
|
|
90
|
+
* `orphaned` rather than throwing (US3 acceptance scenario 3).
|
|
91
|
+
*/
|
|
92
|
+
export declare function resolveAnchors(anchors: readonly IdentifiedAnchor[], documentText: string, documentVersion: string, options?: ResolveAnchorOptions): Promise<AnchorResolutionResult[]>;
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PROVENANCE — ported from Zusammen (`verveguy/zusammen`) for Liminis #939.
|
|
3
|
+
*
|
|
4
|
+
* The doc comments below are the original author's and are kept verbatim so
|
|
5
|
+
* this module stays diffable against its source. Their `FR-NNN`/`SC-NNN`
|
|
6
|
+
* identifiers, `#NN` issue references and `adrs/` paths therefore name
|
|
7
|
+
* **Zusammen's** spec, issues and ADRs — not this repository's, where the same
|
|
8
|
+
* identifiers mean something else entirely. For the Liminis-side design see
|
|
9
|
+
* `docs/decisions/adr-077.md` and
|
|
10
|
+
* `docs/zusammen-editor-capability-map.md`.
|
|
11
|
+
*
|
|
12
|
+
* "Comment"/"thread" in these comments should be read as "annotation": this
|
|
13
|
+
* module now serves both annotation kinds, not comments alone.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Comment Anchor Resolver
|
|
17
|
+
*
|
|
18
|
+
* The deterministic classification algorithm at the heart of durable comment
|
|
19
|
+
* anchoring (issue #25): given an anchor captured against some prior document
|
|
20
|
+
* version and the text of a new version, decide whether the comment's target
|
|
21
|
+
* is unchanged, should be re-attached, must be flagged, or is orphaned
|
|
22
|
+
* (FR-002). Needs only the anchor plus the new text — never the old text —
|
|
23
|
+
* which is what makes resolution provenance-agnostic (FR-010): a direct edit
|
|
24
|
+
* and a merge that produce the same new text resolve identically.
|
|
25
|
+
*
|
|
26
|
+
* Pure, git-free — no fs, no git, safe to unit test with plain strings.
|
|
27
|
+
*/
|
|
28
|
+
import { captureAnchor, CONTEXT_WINDOW_CHARS } from './anchor-model.js';
|
|
29
|
+
import { findEnclosingBlock, parseBlocks, sentenceChunks } from './block-structure.js';
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// Thresholds
|
|
32
|
+
// ============================================================================
|
|
33
|
+
/**
|
|
34
|
+
* Minimum normalized similarity for a fuzzy match to re-attach automatically.
|
|
35
|
+
* Chosen so a "single-digit percentage of characters changed" reword (SC-002)
|
|
36
|
+
* clears this floor comfortably (e.g. changing 2 words out of 10 in a short
|
|
37
|
+
* sentence typically scores well above 0.6), while a heavily rewritten or
|
|
38
|
+
* unrelated target does not. Not corpus-calibrated — a reasoned default,
|
|
39
|
+
* revisable via this one constant.
|
|
40
|
+
*/
|
|
41
|
+
export const REATTACH_THRESHOLD = 0.6;
|
|
42
|
+
/**
|
|
43
|
+
* Minimum normalized similarity for a fuzzy match to flag rather than orphan
|
|
44
|
+
* outright. Below this floor there is essentially no lexical overlap left
|
|
45
|
+
* (SC-004's deleted-target case, and US2 acceptance scenario 3's heavy
|
|
46
|
+
* rewrite), so the target is treated as gone rather than merely uncertain.
|
|
47
|
+
*/
|
|
48
|
+
export const FLAG_THRESHOLD = 0.35;
|
|
49
|
+
/**
|
|
50
|
+
* Minimum score gap between the best and second-best candidate occurrence of
|
|
51
|
+
* duplicate target text before the resolver will pick one automatically
|
|
52
|
+
* (FR-006). Below this margin, picking either risks silently relocating onto
|
|
53
|
+
* the wrong occurrence (SC-003) — flagged instead.
|
|
54
|
+
*/
|
|
55
|
+
const AMBIGUITY_MARGIN = 0.15;
|
|
56
|
+
export const noopProposeSemanticRelocation = () => null;
|
|
57
|
+
// ============================================================================
|
|
58
|
+
// Similarity
|
|
59
|
+
// ============================================================================
|
|
60
|
+
/** Levenshtein edit distance, O(n*m) time and O(min(n,m)) space. */
|
|
61
|
+
function levenshteinDistance(a, b) {
|
|
62
|
+
if (a === b)
|
|
63
|
+
return 0;
|
|
64
|
+
if (a.length === 0)
|
|
65
|
+
return b.length;
|
|
66
|
+
if (b.length === 0)
|
|
67
|
+
return a.length;
|
|
68
|
+
// Iterate over the shorter string as columns to bound space by min(n, m).
|
|
69
|
+
const [shorter, longer] = a.length <= b.length ? [a, b] : [b, a];
|
|
70
|
+
let prevRow = Array.from({ length: shorter.length + 1 }, (_, i) => i);
|
|
71
|
+
for (let i = 1; i <= longer.length; i++) {
|
|
72
|
+
const currRow = [i];
|
|
73
|
+
for (let j = 1; j <= shorter.length; j++) {
|
|
74
|
+
const cost = longer[i - 1] === shorter[j - 1] ? 0 : 1;
|
|
75
|
+
currRow[j] = Math.min(prevRow[j] + 1, // deletion
|
|
76
|
+
currRow[j - 1] + 1, // insertion
|
|
77
|
+
prevRow[j - 1] + cost);
|
|
78
|
+
}
|
|
79
|
+
prevRow = currRow;
|
|
80
|
+
}
|
|
81
|
+
return prevRow[shorter.length];
|
|
82
|
+
}
|
|
83
|
+
/** Normalized similarity in [0, 1] — 1 means identical, 0 means completely dissimilar. */
|
|
84
|
+
export function similarity(a, b) {
|
|
85
|
+
const maxLen = Math.max(a.length, b.length, 1);
|
|
86
|
+
return 1 - levenshteinDistance(a, b) / maxLen;
|
|
87
|
+
}
|
|
88
|
+
function findExactOccurrences(text, targetText) {
|
|
89
|
+
if (!targetText)
|
|
90
|
+
return [];
|
|
91
|
+
const matches = [];
|
|
92
|
+
let idx = text.indexOf(targetText);
|
|
93
|
+
while (idx !== -1) {
|
|
94
|
+
matches.push({ start: idx, end: idx + targetText.length });
|
|
95
|
+
idx = text.indexOf(targetText, idx + 1);
|
|
96
|
+
}
|
|
97
|
+
return matches;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Rank exact occurrences of the target text by how well their surrounding
|
|
101
|
+
* context matches the anchor's recorded context, plus proximity to the
|
|
102
|
+
* anchor's recorded occurrence index (FR-006). Returns the best candidate and
|
|
103
|
+
* whether it's confident enough to pick automatically.
|
|
104
|
+
*/
|
|
105
|
+
function rankDuplicateOccurrences(newText, anchor, matches) {
|
|
106
|
+
const scored = matches.map((match, index) => {
|
|
107
|
+
const prefix = newText.slice(Math.max(0, match.start - CONTEXT_WINDOW_CHARS), match.start);
|
|
108
|
+
const suffix = newText.slice(match.end, Math.min(newText.length, match.end + CONTEXT_WINDOW_CHARS));
|
|
109
|
+
const contextScore = (similarity(prefix, anchor.prefixContext) + similarity(suffix, anchor.suffixContext)) / 2;
|
|
110
|
+
const proximityScore = 1 - Math.abs(index - anchor.occurrenceIndex) / matches.length;
|
|
111
|
+
return { match, score: contextScore * 0.8 + proximityScore * 0.2 };
|
|
112
|
+
});
|
|
113
|
+
scored.sort((a, b) => b.score - a.score);
|
|
114
|
+
const [best, second] = scored;
|
|
115
|
+
const confident = !second || best.score - second.score > AMBIGUITY_MARGIN;
|
|
116
|
+
return { best: best.match, confident };
|
|
117
|
+
}
|
|
118
|
+
function scoreCandidate(candidateText, range, targetText) {
|
|
119
|
+
return { ...range, text: candidateText, score: similarity(candidateText, targetText) };
|
|
120
|
+
}
|
|
121
|
+
function bestOf(candidates) {
|
|
122
|
+
return candidates.reduce((best, candidate) => (!best || candidate.score > best.score ? candidate : best), undefined);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Search for the best fuzzy match to the anchor's target text, considering
|
|
126
|
+
* each block's full text and each sentence-chunk within it as a single-block
|
|
127
|
+
* candidate, plus each pair of adjacent blocks' concatenated text as a
|
|
128
|
+
* multi-block candidate (used only to detect a range split — FR-007, US2
|
|
129
|
+
* acceptance scenario 4). If the best-scoring candidate overall only clears
|
|
130
|
+
* the re-attach floor by spanning two blocks, that's reported so the caller
|
|
131
|
+
* flags instead of silently re-attaching to a partial range.
|
|
132
|
+
*/
|
|
133
|
+
function findBestFuzzyMatch(newText, anchor, blocks) {
|
|
134
|
+
const singleBlockCandidates = [];
|
|
135
|
+
for (const block of blocks) {
|
|
136
|
+
const blockText = newText.slice(block.start, block.end);
|
|
137
|
+
singleBlockCandidates.push(scoreCandidate(blockText, block, anchor.targetText));
|
|
138
|
+
for (const chunk of sentenceChunks(blockText)) {
|
|
139
|
+
const range = { start: block.start + chunk.start, end: block.start + chunk.end };
|
|
140
|
+
singleBlockCandidates.push(scoreCandidate(chunk.text, range, anchor.targetText));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Concatenate each nearby pair of blocks' *own* text — skipping up to one
|
|
144
|
+
// block in between (e.g. a newly inserted heading) — so a target that split
|
|
145
|
+
// across a new structural boundary still reconstructs at high similarity.
|
|
146
|
+
// Without this, whichever half happens to score highest alone could clear
|
|
147
|
+
// REATTACH_THRESHOLD on its own and silently re-attach to a partial range.
|
|
148
|
+
const multiBlockCandidates = [];
|
|
149
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
150
|
+
for (const offset of [1, 2]) {
|
|
151
|
+
const j = i + offset;
|
|
152
|
+
if (j >= blocks.length)
|
|
153
|
+
continue;
|
|
154
|
+
const a = blocks[i];
|
|
155
|
+
const b = blocks[j];
|
|
156
|
+
const combinedText = newText.slice(a.start, a.end) + newText.slice(b.start, b.end);
|
|
157
|
+
const range = { start: a.start, end: b.end };
|
|
158
|
+
multiBlockCandidates.push(scoreCandidate(combinedText, range, anchor.targetText));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const bestSingle = bestOf(singleBlockCandidates);
|
|
162
|
+
const bestMulti = bestOf(multiBlockCandidates);
|
|
163
|
+
if (bestMulti && bestMulti.score >= REATTACH_THRESHOLD && (!bestSingle || bestMulti.score > bestSingle.score)) {
|
|
164
|
+
return { best: bestMulti, spansMultipleBlocks: true };
|
|
165
|
+
}
|
|
166
|
+
return { best: bestSingle, spansMultipleBlocks: false };
|
|
167
|
+
}
|
|
168
|
+
function sameBlockType(block, anchorBlockType) {
|
|
169
|
+
return (block?.type ?? null) === anchorBlockType;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Resolve a comment's anchor against a new document version (FR-002). Never
|
|
173
|
+
* needs the old document text — only the anchor and `newText` — so a direct
|
|
174
|
+
* edit and a merge that land on the same new text resolve identically
|
|
175
|
+
* (FR-010).
|
|
176
|
+
*/
|
|
177
|
+
export async function resolveAnchor(anchor, newText, newDocVersion, options = {}) {
|
|
178
|
+
const blocks = parseBlocks(newText);
|
|
179
|
+
const exactMatches = findExactOccurrences(newText, anchor.targetText);
|
|
180
|
+
if (exactMatches.length > 0) {
|
|
181
|
+
const { best, confident } = exactMatches.length === 1
|
|
182
|
+
? { best: exactMatches[0], confident: true }
|
|
183
|
+
: rankDuplicateOccurrences(newText, anchor, exactMatches);
|
|
184
|
+
if (!confident) {
|
|
185
|
+
return { outcome: 'flagged', docVersion: newDocVersion, reason: 'ambiguous duplicate occurrence' };
|
|
186
|
+
}
|
|
187
|
+
const enclosing = findEnclosingBlock(blocks, best.start, best.end);
|
|
188
|
+
if (sameBlockType(enclosing, anchor.blockType)) {
|
|
189
|
+
return { outcome: 'unchanged', docVersion: newDocVersion, reason: 'exact match, same block type' };
|
|
190
|
+
}
|
|
191
|
+
return { outcome: 'flagged', docVersion: newDocVersion, reason: 'exact match crossed a structural boundary' };
|
|
192
|
+
}
|
|
193
|
+
const { best, spansMultipleBlocks } = findBestFuzzyMatch(newText, anchor, blocks);
|
|
194
|
+
if (best && spansMultipleBlocks) {
|
|
195
|
+
return { outcome: 'flagged', docVersion: newDocVersion, reason: 'best match only clears the bar by spanning a structural boundary (range split)' };
|
|
196
|
+
}
|
|
197
|
+
if (best && best.score >= REATTACH_THRESHOLD) {
|
|
198
|
+
const newAnchor = captureAnchor(newText, { start: best.start, end: best.end }, newDocVersion);
|
|
199
|
+
return { outcome: 're-attached', docVersion: newDocVersion, reason: `fuzzy match (similarity ${best.score.toFixed(2)})`, anchor: newAnchor };
|
|
200
|
+
}
|
|
201
|
+
if (best && best.score >= FLAG_THRESHOLD) {
|
|
202
|
+
return { outcome: 'flagged', docVersion: newDocVersion, reason: `low-confidence match (similarity ${best.score.toFixed(2)})` };
|
|
203
|
+
}
|
|
204
|
+
const propose = options.proposeSemanticRelocation ?? noopProposeSemanticRelocation;
|
|
205
|
+
const proposed = await propose({ anchor, newText });
|
|
206
|
+
if (proposed) {
|
|
207
|
+
return { outcome: 're-attached', docVersion: newDocVersion, reason: 'semantic relocation proposed', anchor: proposed };
|
|
208
|
+
}
|
|
209
|
+
return { outcome: 'orphaned', docVersion: newDocVersion, reason: 'no recognizable match; relocation seam declined' };
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Resolve many anchors against one document version — the headless entry point
|
|
213
|
+
* hosts call outside a rendered editor (FR-002). Each anchor resolves through
|
|
214
|
+
* the same {@link resolveAnchor} path, so classification is identical to the
|
|
215
|
+
* single-anchor case; this wrapper only handles fan-out and re-attaches the
|
|
216
|
+
* caller's opaque `id` to each outcome.
|
|
217
|
+
*
|
|
218
|
+
* With no `proposeSemanticRelocation` supplied, an unmatchable anchor yields
|
|
219
|
+
* `orphaned` rather than throwing (US3 acceptance scenario 3).
|
|
220
|
+
*/
|
|
221
|
+
export async function resolveAnchors(anchors, documentText, documentVersion, options = {}) {
|
|
222
|
+
return Promise.all(anchors.map(async ({ id, anchor }) => {
|
|
223
|
+
const resolution = await resolveAnchor(anchor, documentText, documentVersion, options);
|
|
224
|
+
return {
|
|
225
|
+
id,
|
|
226
|
+
outcome: resolution.outcome,
|
|
227
|
+
reason: resolution.reason,
|
|
228
|
+
...(resolution.anchor ? { resolvedAnchor: resolution.anchor } : {}),
|
|
229
|
+
};
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PROVENANCE — ported from Zusammen (`verveguy/zusammen`) for Liminis #939.
|
|
3
|
+
*
|
|
4
|
+
* The doc comments below are the original author's and are kept verbatim so
|
|
5
|
+
* this module stays diffable against its source. Their `FR-NNN`/`SC-NNN`
|
|
6
|
+
* identifiers, `#NN` issue references and `adrs/` paths therefore name
|
|
7
|
+
* **Zusammen's** spec, issues and ADRs — not this repository's, where the same
|
|
8
|
+
* identifiers mean something else entirely. For the Liminis-side design see
|
|
9
|
+
* `docs/decisions/adr-077.md` and
|
|
10
|
+
* `docs/zusammen-editor-capability-map.md`.
|
|
11
|
+
*
|
|
12
|
+
* "Comment"/"thread" in these comments should be read as "annotation": this
|
|
13
|
+
* module now serves both annotation kinds, not comments alone.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Minimal Markdown Block Structure
|
|
17
|
+
*
|
|
18
|
+
* A lightweight, dependency-free-beyond-mdast reading of a markdown document's
|
|
19
|
+
* block-level structure — used by the comment-anchoring resolver (issue #25) to
|
|
20
|
+
* detect when a comment's target has crossed a structural boundary (FR-007) and
|
|
21
|
+
* to scope fuzzy-matching search to sentence-sized units within a block instead
|
|
22
|
+
* of comparing against an entire, possibly large, block of text.
|
|
23
|
+
*
|
|
24
|
+
* Deliberately separate from `editor/markdown/parse.ts`: that module carries
|
|
25
|
+
* wiki-link/emphasis/frontmatter concerns for the live editor and lives under
|
|
26
|
+
* the web-only tsconfig project, while this one runs in `src/main`/`src/shared`
|
|
27
|
+
* against arbitrary git blob text with no DOM dependency.
|
|
28
|
+
*/
|
|
29
|
+
export type BlockType = 'paragraph' | 'heading' | 'code' | 'tableCell' | 'thematicBreak';
|
|
30
|
+
/** A leaf block's character-offset span within the document text it was parsed from. */
|
|
31
|
+
export interface Block {
|
|
32
|
+
type: BlockType;
|
|
33
|
+
start: number;
|
|
34
|
+
end: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Parse a markdown document into its leaf structural blocks — paragraphs,
|
|
38
|
+
* headings, code blocks, table cells, and thematic breaks — each carrying the
|
|
39
|
+
* character-offset span it occupies in `text`. Container nodes (list,
|
|
40
|
+
* listItem, blockquote, table, tableRow) are transparent: their block-level
|
|
41
|
+
* content surfaces as the leaf blocks found inside them.
|
|
42
|
+
*/
|
|
43
|
+
export declare function parseBlocks(text: string): Block[];
|
|
44
|
+
/**
|
|
45
|
+
* Find the single block that fully contains `[start, end)`. Returns undefined
|
|
46
|
+
* if no leaf block covers the whole range — either the range sits in
|
|
47
|
+
* unstructured space (rare) or it straddles two blocks, which callers should
|
|
48
|
+
* treat as a structural-boundary crossing (FR-007).
|
|
49
|
+
*/
|
|
50
|
+
export declare function findEnclosingBlock(blocks: readonly Block[], start: number, end?: number): Block | undefined;
|
|
51
|
+
export declare function blockPlainText(block: Block, text: string): string;
|
|
52
|
+
export interface SentenceChunk {
|
|
53
|
+
text: string;
|
|
54
|
+
/** Offset relative to the start of the text passed to sentenceChunks. */
|
|
55
|
+
start: number;
|
|
56
|
+
end: number;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Split a block's text into sentence-sized chunks, so a light reword of one
|
|
60
|
+
* sentence in a multi-sentence block is compared against just that sentence
|
|
61
|
+
* rather than the whole block. Uses the split regex's own match offsets
|
|
62
|
+
* (rather than re-searching for each piece via `indexOf`) so a repeated
|
|
63
|
+
* sentence can never be mis-offset onto a different occurrence.
|
|
64
|
+
*/
|
|
65
|
+
export declare function sentenceChunks(text: string): SentenceChunk[];
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PROVENANCE — ported from Zusammen (`verveguy/zusammen`) for Liminis #939.
|
|
3
|
+
*
|
|
4
|
+
* The doc comments below are the original author's and are kept verbatim so
|
|
5
|
+
* this module stays diffable against its source. Their `FR-NNN`/`SC-NNN`
|
|
6
|
+
* identifiers, `#NN` issue references and `adrs/` paths therefore name
|
|
7
|
+
* **Zusammen's** spec, issues and ADRs — not this repository's, where the same
|
|
8
|
+
* identifiers mean something else entirely. For the Liminis-side design see
|
|
9
|
+
* `docs/decisions/adr-077.md` and
|
|
10
|
+
* `docs/zusammen-editor-capability-map.md`.
|
|
11
|
+
*
|
|
12
|
+
* "Comment"/"thread" in these comments should be read as "annotation": this
|
|
13
|
+
* module now serves both annotation kinds, not comments alone.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Minimal Markdown Block Structure
|
|
17
|
+
*
|
|
18
|
+
* A lightweight, dependency-free-beyond-mdast reading of a markdown document's
|
|
19
|
+
* block-level structure — used by the comment-anchoring resolver (issue #25) to
|
|
20
|
+
* detect when a comment's target has crossed a structural boundary (FR-007) and
|
|
21
|
+
* to scope fuzzy-matching search to sentence-sized units within a block instead
|
|
22
|
+
* of comparing against an entire, possibly large, block of text.
|
|
23
|
+
*
|
|
24
|
+
* Deliberately separate from `editor/markdown/parse.ts`: that module carries
|
|
25
|
+
* wiki-link/emphasis/frontmatter concerns for the live editor and lives under
|
|
26
|
+
* the web-only tsconfig project, while this one runs in `src/main`/`src/shared`
|
|
27
|
+
* against arbitrary git blob text with no DOM dependency.
|
|
28
|
+
*/
|
|
29
|
+
import { fromMarkdown } from 'mdast-util-from-markdown';
|
|
30
|
+
import { gfm } from 'micromark-extension-gfm';
|
|
31
|
+
import { gfmFromMarkdown } from 'mdast-util-gfm';
|
|
32
|
+
// ============================================================================
|
|
33
|
+
// Blocks
|
|
34
|
+
// ============================================================================
|
|
35
|
+
/** mdast node types treated as leaf structural blocks — never recursed into further. */
|
|
36
|
+
const BLOCK_NODE_TYPES = new Set(['paragraph', 'heading', 'code', 'tableCell', 'thematicBreak']);
|
|
37
|
+
/**
|
|
38
|
+
* Parse a markdown document into its leaf structural blocks — paragraphs,
|
|
39
|
+
* headings, code blocks, table cells, and thematic breaks — each carrying the
|
|
40
|
+
* character-offset span it occupies in `text`. Container nodes (list,
|
|
41
|
+
* listItem, blockquote, table, tableRow) are transparent: their block-level
|
|
42
|
+
* content surfaces as the leaf blocks found inside them.
|
|
43
|
+
*/
|
|
44
|
+
export function parseBlocks(text) {
|
|
45
|
+
const root = fromMarkdown(text, {
|
|
46
|
+
extensions: [gfm()],
|
|
47
|
+
mdastExtensions: [gfmFromMarkdown()],
|
|
48
|
+
});
|
|
49
|
+
const blocks = [];
|
|
50
|
+
function walk(node) {
|
|
51
|
+
if (!node)
|
|
52
|
+
return;
|
|
53
|
+
if (BLOCK_NODE_TYPES.has(node.type)) {
|
|
54
|
+
const start = node.position?.start?.offset;
|
|
55
|
+
const end = node.position?.end?.offset;
|
|
56
|
+
if (start != null && end != null) {
|
|
57
|
+
blocks.push({ type: node.type, start, end });
|
|
58
|
+
}
|
|
59
|
+
return; // leaf blocks have no nested blocks worth surfacing separately
|
|
60
|
+
}
|
|
61
|
+
if (node.children) {
|
|
62
|
+
for (const child of node.children)
|
|
63
|
+
walk(child);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
walk(root);
|
|
67
|
+
blocks.sort((a, b) => a.start - b.start);
|
|
68
|
+
return blocks;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Find the single block that fully contains `[start, end)`. Returns undefined
|
|
72
|
+
* if no leaf block covers the whole range — either the range sits in
|
|
73
|
+
* unstructured space (rare) or it straddles two blocks, which callers should
|
|
74
|
+
* treat as a structural-boundary crossing (FR-007).
|
|
75
|
+
*/
|
|
76
|
+
export function findEnclosingBlock(blocks, start, end = start) {
|
|
77
|
+
return blocks.find((block) => block.start <= start && end <= block.end);
|
|
78
|
+
}
|
|
79
|
+
export function blockPlainText(block, text) {
|
|
80
|
+
return text.slice(block.start, block.end);
|
|
81
|
+
}
|
|
82
|
+
// Splits after sentence-ending punctuation followed by whitespace, or on any
|
|
83
|
+
// run of newlines. Naive by design (no NLP sentence-splitter dependency
|
|
84
|
+
// exists in this codebase) — can mis-split on abbreviations/decimals, which
|
|
85
|
+
// is acceptable for v1 fuzzy-matching scope.
|
|
86
|
+
// The `\r\n|\r|\n` alternation rather than a bare `\n+`: with the latter, a
|
|
87
|
+
// CRLF document leaves the `\r` on the end of the preceding chunk, and a fuzzy
|
|
88
|
+
// re-attach onto that chunk would capture the carriage return into the durable
|
|
89
|
+
// target text (review finding, CodeRabbit).
|
|
90
|
+
const SENTENCE_SPLIT = /(?<=[.!?])\s+|(?:\r\n|\r|\n)+/;
|
|
91
|
+
/**
|
|
92
|
+
* Split a block's text into sentence-sized chunks, so a light reword of one
|
|
93
|
+
* sentence in a multi-sentence block is compared against just that sentence
|
|
94
|
+
* rather than the whole block. Uses the split regex's own match offsets
|
|
95
|
+
* (rather than re-searching for each piece via `indexOf`) so a repeated
|
|
96
|
+
* sentence can never be mis-offset onto a different occurrence.
|
|
97
|
+
*/
|
|
98
|
+
export function sentenceChunks(text) {
|
|
99
|
+
const chunks = [];
|
|
100
|
+
const delimiter = new RegExp(SENTENCE_SPLIT.source, 'g');
|
|
101
|
+
let cursor = 0;
|
|
102
|
+
let match;
|
|
103
|
+
while ((match = delimiter.exec(text))) {
|
|
104
|
+
const piece = text.slice(cursor, match.index);
|
|
105
|
+
if (piece.length > 0)
|
|
106
|
+
chunks.push({ text: piece, start: cursor, end: match.index });
|
|
107
|
+
cursor = delimiter.lastIndex;
|
|
108
|
+
}
|
|
109
|
+
const tail = text.slice(cursor);
|
|
110
|
+
if (tail.length > 0)
|
|
111
|
+
chunks.push({ text: tail, start: cursor, end: text.length });
|
|
112
|
+
return chunks;
|
|
113
|
+
}
|