@cueloop/schema 0.1.0-alpha.42 → 0.1.0-alpha.43

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/schema",
3
- "version": "0.1.0-alpha.42",
3
+ "version": "0.1.0-alpha.43",
4
4
  "description": "The ReviewSession primitive: types, markdown block model, quote-primary anchors, feedback serialization",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -103,6 +103,67 @@ describe("anchor cascade", () => {
103
103
  expect(resolved.start).toBe(5);
104
104
  });
105
105
 
106
+ test("binds a quote that still carries its leading list marker", () => {
107
+ // Arrange
108
+ // an agent copied the source line verbatim, including the "- " bullet, but
109
+ // the parsed list block strips the marker from its text
110
+ const blocks = blocksOf("## Notes\n\n- This is a dummy plan - confirm the item");
111
+ const anchor = { quote: "- This is a dummy plan", prefix: "", suffix: "" };
112
+
113
+ // Act
114
+ const resolved = resolveAnchor(anchor, blocks)!;
115
+
116
+ // Assert
117
+ expect(resolved).not.toBeNull();
118
+ expect(resolved.strategy).toBe("normalized");
119
+ expect(blocks[resolved.blockIndex]!.text.slice(resolved.start, resolved.end)).toBe(
120
+ "This is a dummy plan",
121
+ );
122
+ });
123
+
124
+ test("binds a heading quote that still carries its leading hashes", () => {
125
+ // Arrange
126
+ const blocks = blocksOf(DOC);
127
+ const anchor = { quote: "## Storage", prefix: "", suffix: "" };
128
+
129
+ // Act
130
+ const resolved = resolveAnchor(anchor, blocks)!;
131
+
132
+ // Assert
133
+ expect(resolved.strategy).toBe("normalized");
134
+ expect(blocks[resolved.blockIndex]!.text).toBe("Storage");
135
+ });
136
+
137
+ test("fuzzy tier binds a lightly edited quote", () => {
138
+ // Arrange
139
+ const blocks = blocksOf(DOC);
140
+ const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
141
+ // a typo the reviewer never saw: "sesssions"
142
+ const anchor = { quote: "Review sesssions currently live", prefix: "", suffix: "" };
143
+
144
+ // Act
145
+ const resolved = resolveAnchor(anchor, blocks)!;
146
+
147
+ // Assert
148
+ expect(resolved.strategy).toBe("fuzzy");
149
+ expect(resolved.blockIndex).toBe(contextBlockIndex);
150
+ expect(resolved.approximate).toBe(true);
151
+ });
152
+
153
+ test("exact matches report the exact strategy", () => {
154
+ // Arrange
155
+ const blocks = blocksOf(DOC);
156
+ const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("Review sessions"));
157
+ const anchor = makeAnchor(blocks, contextBlockIndex, 0, 15);
158
+
159
+ // Act
160
+ const resolved = resolveAnchor(anchor, blocks)!;
161
+
162
+ // Assert
163
+ expect(resolved.strategy).toBe("exact");
164
+ expect(resolved.approximate).toBe(false);
165
+ });
166
+
106
167
  test("trimmed-quote fallback marks the resolution approximate", () => {
107
168
  // Arrange
108
169
  const anchor = {
package/src/anchor.ts CHANGED
@@ -1,25 +1,38 @@
1
1
  /**
2
2
  * Anchor resolution: the quote-primary cascade. Anchors resolve against the
3
- * current block list:
4
- * 1. exact quote in a block (prefix/suffix selectors break ties,
5
- * position hint breaks remaining ties)
6
- * 2. trimmed quote
7
- * 3. orphan - keep the quote for display, drop the highlight.
8
- * Never silently bind to the wrong text.
3
+ * current block list, each tier tried only when the one above it misses:
4
+ * 1. exact - the quote appears verbatim in a block
5
+ * 2. trimmed - the quote appears once surrounding whitespace is dropped
6
+ * 3. normalized - the quote appears once a leading markdown block marker
7
+ * (bullet, heading, ordered, quote) is stripped, matching how
8
+ * the parser strips block text
9
+ * 4. fuzzy - the quote was lightly edited but is still recognizably there
10
+ * 5. orphan - none matched; keep the quote for display, drop the highlight
11
+ * Prefix/suffix selectors and the recorded position break ties within a tier.
12
+ * Never silently bind to the wrong text: fuzzy needs a high similarity floor.
9
13
  */
10
14
 
11
15
  import type { Anchor } from "./types";
12
- import type { Block } from "./markdown";
16
+ import { type Block, stripLeadingBlockMarker } from "./markdown";
17
+ import { fuzzyFindBestMatch } from "./fuzzy";
13
18
 
14
19
  /** Characters of surrounding text captured as prefix/suffix selectors. */
15
20
  const ANCHOR_CONTEXT_CHARS = 24;
16
21
 
22
+ /** Minimum similarity a fuzzy match needs before the resolver will trust it. */
23
+ const FUZZY_MINIMUM_SIMILARITY = 0.75;
24
+
25
+ /** How the quote bound to the block text, weakest binding named last. */
26
+ export type AnchorMatchStrategy = "exact" | "trimmed" | "normalized" | "fuzzy";
27
+
17
28
  export interface ResolvedAnchor {
18
29
  blockIndex: number;
19
30
  start: number;
20
31
  end: number;
21
- /** True when the quote had to be trimmed to bind. */
32
+ /** True whenever the binding was not an exact quote match. */
22
33
  approximate: boolean;
34
+ /** Which cascade tier bound the quote. */
35
+ strategy: AnchorMatchStrategy;
23
36
  }
24
37
 
25
38
  export function makeAnchor(
@@ -39,11 +52,33 @@ export function makeAnchor(
39
52
  };
40
53
  }
41
54
 
42
- interface Candidate extends ResolvedAnchor {
55
+ interface Candidate {
56
+ blockIndex: number;
57
+ start: number;
58
+ end: number;
43
59
  score: number;
44
60
  }
45
61
 
46
- function findCandidates(anchor: Anchor, blocks: Block[], quote: string): Candidate[] {
62
+ /** Score a candidate window by how well its context and position agree. */
63
+ function contextScore(
64
+ anchor: Anchor,
65
+ blockIndex: number,
66
+ block: Block,
67
+ start: number,
68
+ end: number,
69
+ ): number {
70
+ const prefix = block.text.slice(Math.max(0, start - ANCHOR_CONTEXT_CHARS), start);
71
+ const suffix = block.text.slice(end, end + ANCHOR_CONTEXT_CHARS);
72
+ let score = 0;
73
+ if (prefix === anchor.prefix) score += 2;
74
+ if (suffix === anchor.suffix) score += 2;
75
+ if (anchor.blockIndex === blockIndex) score += 1;
76
+ if (anchor.blockIndex === blockIndex && anchor.start === start) score += 1;
77
+ return score;
78
+ }
79
+
80
+ /** Every verbatim occurrence of `quote` across the blocks, scored by context. */
81
+ function findExactCandidates(anchor: Anchor, blocks: Block[], quote: string): Candidate[] {
47
82
  const candidates: Candidate[] = [];
48
83
  blocks.forEach((block, blockIndex) => {
49
84
  const text = block.text;
@@ -52,42 +87,88 @@ function findCandidates(anchor: Anchor, blocks: Block[], quote: string): Candida
52
87
  matchStart !== -1;
53
88
  matchStart = text.indexOf(quote, matchStart + 1)
54
89
  ) {
55
- const prefix = text.slice(Math.max(0, matchStart - ANCHOR_CONTEXT_CHARS), matchStart);
56
- const suffix = text.slice(
57
- matchStart + quote.length,
58
- matchStart + quote.length + ANCHOR_CONTEXT_CHARS,
59
- );
60
- let score = 0;
61
- if (prefix === anchor.prefix) score += 2;
62
- if (suffix === anchor.suffix) score += 2;
63
- if (anchor.blockIndex === blockIndex) score += 1;
64
- if (anchor.blockIndex === blockIndex && anchor.start === matchStart) score += 1;
90
+ const matchEnd = matchStart + quote.length;
65
91
  candidates.push({
66
92
  blockIndex,
67
93
  start: matchStart,
68
- end: matchStart + quote.length,
69
- approximate: false,
70
- score,
94
+ end: matchEnd,
95
+ score: contextScore(anchor, blockIndex, block, matchStart, matchEnd),
71
96
  });
72
97
  }
73
98
  });
74
99
  return candidates;
75
100
  }
76
101
 
102
+ /**
103
+ * How much a full context agreement (0-6) may nudge the fuzzy ranking. Kept far
104
+ * below any real similarity gap so text quality always leads and context only
105
+ * breaks near-ties - a stale position hint must never pull the anchor onto a
106
+ * meaningfully less-similar passage.
107
+ */
108
+ const FUZZY_CONTEXT_TIE_BREAK = 0.01;
109
+
110
+ /** The best fuzzy window per block, ranked by similarity with context as a tiebreak. */
111
+ function findFuzzyCandidates(
112
+ anchor: Anchor,
113
+ blocks: Block[],
114
+ needle: string,
115
+ minimumSimilarity: number,
116
+ ): Candidate[] {
117
+ const candidates: Candidate[] = [];
118
+ blocks.forEach((block, blockIndex) => {
119
+ const match = fuzzyFindBestMatch(needle, block.text, minimumSimilarity);
120
+ if (match === null) return;
121
+ const score =
122
+ match.similarity +
123
+ contextScore(anchor, blockIndex, block, match.start, match.end) * FUZZY_CONTEXT_TIE_BREAK;
124
+ candidates.push({ blockIndex, start: match.start, end: match.end, score });
125
+ });
126
+ return candidates;
127
+ }
128
+
129
+ /** Highest-scoring candidate, or null when there are none. */
130
+ function pickBest(candidates: Candidate[]): Candidate | null {
131
+ if (candidates.length === 0) return null;
132
+ candidates.sort((left, right) => right.score - left.score);
133
+ return candidates[0]!;
134
+ }
135
+
136
+ function resolved(candidate: Candidate, strategy: AnchorMatchStrategy): ResolvedAnchor {
137
+ return {
138
+ blockIndex: candidate.blockIndex,
139
+ start: candidate.start,
140
+ end: candidate.end,
141
+ approximate: strategy !== "exact",
142
+ strategy,
143
+ };
144
+ }
145
+
77
146
  /** Resolve an anchor against the current blocks, or null when orphaned. */
78
147
  export function resolveAnchor(anchor: Anchor, blocks: Block[]): ResolvedAnchor | null {
79
148
  if (anchor.quote === "") return null;
80
- let candidates = findCandidates(anchor, blocks, anchor.quote);
81
- let approximate = false;
82
- if (candidates.length === 0) {
83
- const trimmed = anchor.quote.trim();
84
- if (trimmed !== "" && trimmed !== anchor.quote) {
85
- candidates = findCandidates(anchor, blocks, trimmed);
86
- approximate = true;
87
- }
149
+
150
+ const exact = pickBest(findExactCandidates(anchor, blocks, anchor.quote));
151
+ if (exact) return resolved(exact, "exact");
152
+
153
+ const trimmedQuote = anchor.quote.trim();
154
+ if (trimmedQuote !== "" && trimmedQuote !== anchor.quote) {
155
+ const trimmed = pickBest(findExactCandidates(anchor, blocks, trimmedQuote));
156
+ if (trimmed) return resolved(trimmed, "trimmed");
88
157
  }
89
- if (candidates.length === 0) return null;
90
- candidates.sort((left, right) => right.score - left.score);
91
- const best = candidates[0]!;
92
- return { ...best, approximate };
158
+
159
+ const normalizedQuote = stripLeadingBlockMarker(anchor.quote).trim();
160
+ if (normalizedQuote !== "" && normalizedQuote !== trimmedQuote) {
161
+ const normalized = pickBest(findExactCandidates(anchor, blocks, normalizedQuote));
162
+ if (normalized) return resolved(normalized, "normalized");
163
+ }
164
+
165
+ const fuzzyNeedle = normalizedQuote !== "" ? normalizedQuote : trimmedQuote;
166
+ if (fuzzyNeedle !== "") {
167
+ const fuzzy = pickBest(
168
+ findFuzzyCandidates(anchor, blocks, fuzzyNeedle, FUZZY_MINIMUM_SIMILARITY),
169
+ );
170
+ if (fuzzy) return resolved(fuzzy, "fuzzy");
171
+ }
172
+
173
+ return null;
93
174
  }
@@ -0,0 +1,92 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { fuzzyFindBestMatch, levenshteinDistance, similarityRatio } from "./fuzzy";
3
+
4
+ describe("levenshteinDistance", () => {
5
+ test("identical strings cost nothing", () => {
6
+ // Arrange / Act / Assert
7
+ expect(levenshteinDistance("anchor", "anchor")).toBe(0);
8
+ });
9
+
10
+ test("counts a single substitution", () => {
11
+ // Arrange / Act / Assert
12
+ expect(levenshteinDistance("plan", "plane")).toBe(1);
13
+ });
14
+
15
+ test("empty against non-empty costs the whole length", () => {
16
+ // Arrange / Act / Assert
17
+ expect(levenshteinDistance("", "quote")).toBe(5);
18
+ });
19
+
20
+ test("is symmetric", () => {
21
+ // Arrange / Act / Assert
22
+ expect(levenshteinDistance("kitten", "sitting")).toBe(levenshteinDistance("sitting", "kitten"));
23
+ });
24
+ });
25
+
26
+ describe("similarityRatio", () => {
27
+ test("two empty strings are identical", () => {
28
+ // Arrange / Act / Assert
29
+ expect(similarityRatio("", "")).toBe(1);
30
+ });
31
+
32
+ test("identical strings score one", () => {
33
+ // Arrange / Act / Assert
34
+ expect(similarityRatio("review", "review")).toBe(1);
35
+ });
36
+
37
+ test("a one-character typo in a long string stays close to one", () => {
38
+ // Arrange
39
+ const original = "demonstration plan";
40
+
41
+ // Act
42
+ const ratio = similarityRatio(original, "demonstration plann");
43
+
44
+ // Assert
45
+ expect(ratio).toBeGreaterThan(0.9);
46
+ });
47
+
48
+ test("unrelated strings score low", () => {
49
+ // Arrange / Act / Assert
50
+ expect(similarityRatio("abcdef", "zyxwvu")).toBeLessThan(0.2);
51
+ });
52
+ });
53
+
54
+ describe("fuzzyFindBestMatch", () => {
55
+ test("finds a lightly edited window and reports its offsets", () => {
56
+ // Arrange
57
+ const haystack = "the quick brown fox jumps";
58
+
59
+ // Act
60
+ const match = fuzzyFindBestMatch("quikc brown", haystack, 0.75)!;
61
+
62
+ // Assert
63
+ expect(match).not.toBeNull();
64
+ expect(haystack.slice(match.start, match.end)).toBe("quick brown");
65
+ expect(match.similarity).toBeGreaterThan(0.75);
66
+ });
67
+
68
+ test("returns null when nothing clears the similarity floor", () => {
69
+ // Arrange / Act
70
+ const match = fuzzyFindBestMatch("completely unrelated", "short text", 0.75);
71
+
72
+ // Assert
73
+ expect(match).toBeNull();
74
+ });
75
+
76
+ test("an empty needle or haystack never matches", () => {
77
+ // Arrange / Act / Assert
78
+ expect(fuzzyFindBestMatch("", "text", 0.75)).toBeNull();
79
+ expect(fuzzyFindBestMatch("text", "", 0.75)).toBeNull();
80
+ });
81
+
82
+ test("ties keep the earliest window", () => {
83
+ // Arrange
84
+ const haystack = "word word";
85
+
86
+ // Act
87
+ const match = fuzzyFindBestMatch("word", haystack, 0.9)!;
88
+
89
+ // Assert
90
+ expect(match.start).toBe(0);
91
+ });
92
+ });
package/src/fuzzy.ts ADDED
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Approximate string matching for anchor re-binding. The anchor resolver falls
3
+ * back to these when exact and marker-normalized quote lookups both miss - the
4
+ * quoted text is still present but was lightly edited (a word changed, a typo
5
+ * fixed, punctuation adjusted). Everything here is pure and offset-exact so it
6
+ * can be tested in isolation from the resolver.
7
+ */
8
+
9
+ /** A window of the haystack that best matches the needle. */
10
+ export interface FuzzyMatch {
11
+ /** Inclusive start offset into the haystack. */
12
+ start: number;
13
+ /** Exclusive end offset into the haystack. */
14
+ end: number;
15
+ /** Similarity of the matched window to the needle, in [0, 1]. */
16
+ similarity: number;
17
+ }
18
+
19
+ /**
20
+ * Levenshtein edit distance: the fewest single-character insertions, deletions,
21
+ * or substitutions that turn `left` into `right`. Uses two rolling rows, so it
22
+ * costs O(left.length * right.length) time and O(right.length) space.
23
+ */
24
+ export function levenshteinDistance(left: string, right: string): number {
25
+ if (left === right) return 0;
26
+ if (left.length === 0) return right.length;
27
+ if (right.length === 0) return left.length;
28
+
29
+ let previousRow: number[] = Array.from({ length: right.length + 1 }, (_unused, column) => column);
30
+ let currentRow: number[] = Array.from({ length: right.length + 1 }, () => 0);
31
+
32
+ for (let leftIndex = 1; leftIndex <= left.length; leftIndex++) {
33
+ currentRow[0] = leftIndex;
34
+ for (let rightIndex = 1; rightIndex <= right.length; rightIndex++) {
35
+ const substitutionCost = left[leftIndex - 1] === right[rightIndex - 1] ? 0 : 1;
36
+ currentRow[rightIndex] = Math.min(
37
+ currentRow[rightIndex - 1]! + 1, // insertion
38
+ previousRow[rightIndex]! + 1, // deletion
39
+ previousRow[rightIndex - 1]! + substitutionCost, // substitution
40
+ );
41
+ }
42
+ const swap = previousRow;
43
+ previousRow = currentRow;
44
+ currentRow = swap;
45
+ }
46
+
47
+ return previousRow[right.length]!;
48
+ }
49
+
50
+ /**
51
+ * Similarity of two strings in [0, 1]: 1 is identical, 0 shares nothing. Derived
52
+ * from the edit distance normalized by the longer string. Two empty strings are
53
+ * defined as identical (1).
54
+ */
55
+ export function similarityRatio(left: string, right: string): number {
56
+ const longestLength = Math.max(left.length, right.length);
57
+ if (longestLength === 0) return 1;
58
+ return 1 - levenshteinDistance(left, right) / longestLength;
59
+ }
60
+
61
+ /**
62
+ * Find the haystack window most similar to the needle, or null when nothing
63
+ * clears `minimumSimilarity`. Windows range around the needle length so the
64
+ * match tolerates a few inserted or deleted characters, not only substitutions.
65
+ * Ties keep the earliest window. Offsets index the haystack directly.
66
+ */
67
+ export function fuzzyFindBestMatch(
68
+ needle: string,
69
+ haystack: string,
70
+ minimumSimilarity: number,
71
+ ): FuzzyMatch | null {
72
+ if (needle === "" || haystack === "") return null;
73
+
74
+ const lengthTolerance = Math.max(2, Math.round(needle.length * 0.25));
75
+ const minimumWindowLength = Math.max(1, needle.length - lengthTolerance);
76
+ const maximumWindowLength = needle.length + lengthTolerance;
77
+
78
+ let bestMatch: FuzzyMatch | null = null;
79
+ for (let start = 0; start < haystack.length; start++) {
80
+ for (
81
+ let windowLength = minimumWindowLength;
82
+ windowLength <= maximumWindowLength && start + windowLength <= haystack.length;
83
+ windowLength++
84
+ ) {
85
+ const window = haystack.slice(start, start + windowLength);
86
+ const similarity = similarityRatio(needle, window);
87
+ if (similarity < minimumSimilarity) continue;
88
+ if (bestMatch === null || similarity > bestMatch.similarity) {
89
+ bestMatch = { start, end: start + windowLength, similarity };
90
+ }
91
+ }
92
+ }
93
+ return bestMatch;
94
+ }
package/src/index.ts CHANGED
@@ -4,5 +4,6 @@ export * from "./markdown";
4
4
  export * from "./working-copy";
5
5
  export * from "./diff";
6
6
  export * from "./anchor";
7
+ export * from "./fuzzy";
7
8
  export * from "./feedback";
8
9
  export * from "./herdr";
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { parseBlocks, blockToMd, sectionOf } from "./markdown";
2
+ import { parseBlocks, blockToMd, sectionOf, stripLeadingBlockMarker } from "./markdown";
3
3
 
4
4
  const SAMPLE = `# Title
5
5
 
@@ -122,3 +122,30 @@ describe("sectionOf", () => {
122
122
  expect(sectionOf(blocks, paragraphIndex)).toBe("Context");
123
123
  });
124
124
  });
125
+
126
+ describe("stripLeadingBlockMarker", () => {
127
+ test("strips the marker the parser strips for every marked block kind", () => {
128
+ // Assert
129
+ expect(stripLeadingBlockMarker("# Heading")).toBe("Heading");
130
+ expect(stripLeadingBlockMarker("### Heading")).toBe("Heading");
131
+ expect(stripLeadingBlockMarker("- bullet item")).toBe("bullet item");
132
+ expect(stripLeadingBlockMarker("3. ordered item")).toBe("ordered item");
133
+ expect(stripLeadingBlockMarker("> quoted line")).toBe("quoted line");
134
+ });
135
+
136
+ test("agrees with the text the parser produces", () => {
137
+ // Arrange
138
+ const source = "- This is a dummy plan - confirm the item";
139
+
140
+ // Act
141
+ const [listBlock] = parseBlocks(source);
142
+
143
+ // Assert
144
+ expect(stripLeadingBlockMarker(source)).toBe(listBlock!.text);
145
+ });
146
+
147
+ test("leaves an unmarked line untouched", () => {
148
+ // Assert
149
+ expect(stripLeadingBlockMarker("a plain paragraph")).toBe("a plain paragraph");
150
+ });
151
+ });
package/src/markdown.ts CHANGED
@@ -19,16 +19,21 @@ export interface Block {
19
19
  lineEnd: number;
20
20
  }
21
21
 
22
+ /**
23
+ * The leading block markers the parser strips from a line: headings (h1-h3),
24
+ * bullet list, ordered list, and blockquote. Single source of truth so the
25
+ * anchor resolver strips exactly the same set and the two never drift.
26
+ */
27
+ export const LEADING_BLOCK_MARKER = /^(?:#{1,3} |- |\d+\. |> )/;
28
+
29
+ /** Remove one leading markdown block marker, or return the line unchanged. */
30
+ export function stripLeadingBlockMarker(line: string): string {
31
+ return line.replace(LEADING_BLOCK_MARKER, "");
32
+ }
33
+
22
34
  function isMarkerLine(line: string): boolean {
23
35
  return (
24
- line.startsWith("```") ||
25
- line.startsWith("# ") ||
26
- line.startsWith("## ") ||
27
- line.startsWith("### ") ||
28
- line.startsWith("> ") ||
29
- line.startsWith("- ") ||
30
- /^\d+\. /.test(line) ||
31
- /^(---|\*\*\*|___)\s*$/.test(line)
36
+ line.startsWith("```") || LEADING_BLOCK_MARKER.test(line) || /^(---|\*\*\*|___)\s*$/.test(line)
32
37
  );
33
38
  }
34
39
 
@@ -55,13 +60,28 @@ export function parseBlocks(markdown: string): Block[] {
55
60
  lineIndex++;
56
61
  blocks.push({ kind: "code", text: body.join("\n"), lang, lineStart: start, lineEnd: end });
57
62
  } else if (line.startsWith("### ")) {
58
- blocks.push({ kind: "h3", text: line.slice(4), lineStart: lineIndex, lineEnd: lineIndex });
63
+ blocks.push({
64
+ kind: "h3",
65
+ text: stripLeadingBlockMarker(line),
66
+ lineStart: lineIndex,
67
+ lineEnd: lineIndex,
68
+ });
59
69
  lineIndex++;
60
70
  } else if (line.startsWith("## ")) {
61
- blocks.push({ kind: "h2", text: line.slice(3), lineStart: lineIndex, lineEnd: lineIndex });
71
+ blocks.push({
72
+ kind: "h2",
73
+ text: stripLeadingBlockMarker(line),
74
+ lineStart: lineIndex,
75
+ lineEnd: lineIndex,
76
+ });
62
77
  lineIndex++;
63
78
  } else if (line.startsWith("# ")) {
64
- blocks.push({ kind: "h1", text: line.slice(2), lineStart: lineIndex, lineEnd: lineIndex });
79
+ blocks.push({
80
+ kind: "h1",
81
+ text: stripLeadingBlockMarker(line),
82
+ lineStart: lineIndex,
83
+ lineEnd: lineIndex,
84
+ });
65
85
  lineIndex++;
66
86
  } else if (/^(---|\*\*\*|___)\s*$/.test(line)) {
67
87
  blocks.push({ kind: "hr", text: "", lineStart: lineIndex, lineEnd: lineIndex });
@@ -70,7 +90,7 @@ export function parseBlocks(markdown: string): Block[] {
70
90
  const start = lineIndex;
71
91
  const body: string[] = [];
72
92
  while (lineIndex < lines.length && lines[lineIndex]!.startsWith("> ")) {
73
- body.push(lines[lineIndex]!.slice(2));
93
+ body.push(stripLeadingBlockMarker(lines[lineIndex]!));
74
94
  lineIndex++;
75
95
  }
76
96
  blocks.push({
@@ -80,12 +100,17 @@ export function parseBlocks(markdown: string): Block[] {
80
100
  lineEnd: lineIndex - 1,
81
101
  });
82
102
  } else if (line.startsWith("- ")) {
83
- blocks.push({ kind: "li", text: line.slice(2), lineStart: lineIndex, lineEnd: lineIndex });
103
+ blocks.push({
104
+ kind: "li",
105
+ text: stripLeadingBlockMarker(line),
106
+ lineStart: lineIndex,
107
+ lineEnd: lineIndex,
108
+ });
84
109
  lineIndex++;
85
110
  } else if (/^\d+\. /.test(line)) {
86
111
  blocks.push({
87
112
  kind: "oli",
88
- text: line.replace(/^\d+\. /, ""),
113
+ text: stripLeadingBlockMarker(line),
89
114
  lineStart: lineIndex,
90
115
  lineEnd: lineIndex,
91
116
  });