@cueloop/schema 0.1.0-alpha.7 → 0.1.0-alpha.71

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/src/inline.ts ADDED
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Inline markdown tokenizer: turns one block's text into styled runs while
3
+ * preserving exact source offsets, so the review surface can conceal the markup
4
+ * markers yet keep quote anchors char-precise.
5
+ *
6
+ * Each run is either VISIBLE text - carrying `start`, its offset in the input -
7
+ * or a CONCEALED marker (`**`, `` ` ``, `[`, `](url)`, ...) with `start: null`.
8
+ * A null run occupies no source offset, exactly like a word-diff removal, so the
9
+ * renderer's offset-mapping (overlayMarks / renderedOffsetFor / workRangeForRendered)
10
+ * paints highlights across the visible letters and skips the markers unchanged.
11
+ *
12
+ * Pure and dependency-free: the parse tree never escapes this module - the only
13
+ * output is the flat run list. Covers the plan subset: **strong**, *em*, `code`,
14
+ * ~~strike~~, [label](url), and backslash escapes. Unbalanced markup falls back
15
+ * to literal text.
16
+ */
17
+
18
+ /** What a visible run represents; a marker run is concealed markup, no emphasis. */
19
+ export type InlineRole = "text" | "strong" | "em" | "code" | "strike" | "link" | "marker";
20
+
21
+ export interface InlineRun {
22
+ text: string;
23
+ role: InlineRole;
24
+ /** Offset of this run in the input text; null for a concealed marker. */
25
+ start: number | null;
26
+ /** Link target, present only on `link` runs. */
27
+ href?: string;
28
+ }
29
+
30
+ /** An emphasis span opened by a delimiter at some index, once its close is found. */
31
+ interface Span {
32
+ /** Length of the opening marker (e.g. 2 for `**`). */
33
+ openLength: number;
34
+ /** Length of the closing marker. */
35
+ closeLength: number;
36
+ /** Index in `text` where the closing marker starts. */
37
+ closeIndex: number;
38
+ /** Emphasis role applied to the span's (recursively parsed) content. */
39
+ role: "strong" | "em" | "strike";
40
+ }
41
+
42
+ const PUNCTUATION = new Set(["*", "_", "`", "~", "[", "]", "(", ")", "\\"]);
43
+
44
+ /** Tokenize one block's text into visible + concealed-marker runs, in order. */
45
+ export function inlineRuns(text: string): InlineRun[] {
46
+ const runs: InlineRun[] = [];
47
+
48
+ tokenize(text, 0, "text", runs);
49
+
50
+ return runs;
51
+ }
52
+
53
+ /**
54
+ * Walk `source` (whose first character sits at absolute offset `base`), emitting
55
+ * plain text as `role` runs and recursing into emphasis spans. Links and code
56
+ * spans are handled inline because their content is not further emphasized the
57
+ * same way.
58
+ */
59
+ function tokenize(source: string, base: number, role: InlineRole, out: InlineRun[]): void {
60
+ let plainStart = 0;
61
+ let cursor = 0;
62
+ const flushPlain = (end: number): void => {
63
+ if (end > plainStart) {
64
+ out.push({ text: source.slice(plainStart, end), role, start: base + plainStart });
65
+ }
66
+ };
67
+
68
+ while (cursor < source.length) {
69
+ const char = source[cursor]!;
70
+
71
+ if (char === "\\" && cursor + 1 < source.length && PUNCTUATION.has(source[cursor + 1]!)) {
72
+ flushPlain(cursor);
73
+ out.push({ text: "\\", role: "marker", start: null });
74
+ out.push({ text: source[cursor + 1]!, role, start: base + cursor + 1 });
75
+ cursor += 2;
76
+ plainStart = cursor;
77
+ continue;
78
+ }
79
+
80
+ const link = matchLink(source, cursor);
81
+
82
+ if (link) {
83
+ flushPlain(cursor);
84
+ out.push({ text: source.slice(cursor, link.labelStart), role: "marker", start: null });
85
+ out.push({
86
+ text: source.slice(link.labelStart, link.labelEnd),
87
+ role: "link",
88
+ start: base + link.labelStart,
89
+ href: source.slice(link.hrefStart, link.hrefEnd),
90
+ });
91
+ out.push({ text: source.slice(link.labelEnd, link.end), role: "marker", start: null });
92
+ cursor = link.end;
93
+ plainStart = cursor;
94
+ continue;
95
+ }
96
+
97
+ const code = matchCode(source, cursor);
98
+
99
+ if (code) {
100
+ flushPlain(cursor);
101
+ out.push({ text: source.slice(cursor, code.contentStart), role: "marker", start: null });
102
+ out.push({
103
+ text: source.slice(code.contentStart, code.contentEnd),
104
+ role: "code",
105
+ start: base + code.contentStart,
106
+ });
107
+ out.push({ text: source.slice(code.contentEnd, code.end), role: "marker", start: null });
108
+ cursor = code.end;
109
+ plainStart = cursor;
110
+ continue;
111
+ }
112
+
113
+ const span = matchEmphasis(source, cursor);
114
+
115
+ if (span) {
116
+ flushPlain(cursor);
117
+ out.push({
118
+ text: source.slice(cursor, cursor + span.openLength),
119
+ role: "marker",
120
+ start: null,
121
+ });
122
+ const contentStart = cursor + span.openLength;
123
+
124
+ tokenize(source.slice(contentStart, span.closeIndex), base + contentStart, span.role, out);
125
+ out.push({
126
+ text: source.slice(span.closeIndex, span.closeIndex + span.closeLength),
127
+ role: "marker",
128
+ start: null,
129
+ });
130
+ cursor = span.closeIndex + span.closeLength;
131
+ plainStart = cursor;
132
+ continue;
133
+ }
134
+
135
+ cursor++;
136
+ }
137
+ flushPlain(source.length);
138
+ }
139
+
140
+ /** A `[label](url)` link at `index`, or null. Empty label or url does not match. */
141
+ function matchLink(
142
+ text: string,
143
+ index: number,
144
+ ): {
145
+ labelStart: number;
146
+ labelEnd: number;
147
+ hrefStart: number;
148
+ hrefEnd: number;
149
+ end: number;
150
+ } | null {
151
+ if (text[index] !== "[") return null;
152
+ const labelStart = index + 1;
153
+ const labelEnd = text.indexOf("]", labelStart);
154
+
155
+ if (labelEnd < 0 || labelEnd === labelStart) return null;
156
+ if (text[labelEnd + 1] !== "(") return null;
157
+ const hrefStart = labelEnd + 2;
158
+ // the destination may carry balanced parentheses (wiki URLs); the span ends
159
+ // at the first unmatched closer
160
+ let depth = 0;
161
+
162
+ for (let scan = hrefStart; scan < text.length; scan++) {
163
+ const character = text[scan];
164
+
165
+ if (character === "(") depth++;
166
+ else if (character === ")") {
167
+ if (depth > 0) {
168
+ depth--;
169
+ continue;
170
+ }
171
+ if (scan === hrefStart) return null;
172
+
173
+ return { labelStart, labelEnd, hrefStart, hrefEnd: scan, end: scan + 1 };
174
+ }
175
+ }
176
+
177
+ return null;
178
+ }
179
+
180
+ /** A `` `code` `` span at `index` (n backticks .. n backticks), or null. */
181
+ function matchCode(
182
+ text: string,
183
+ index: number,
184
+ ): { contentStart: number; contentEnd: number; end: number } | null {
185
+ if (text[index] !== "`") return null;
186
+ let fence = 0;
187
+
188
+ while (text[index + fence] === "`") fence++;
189
+ const contentStart = index + fence;
190
+ const closer = "`".repeat(fence);
191
+ const contentEnd = text.indexOf(closer, contentStart);
192
+
193
+ if (contentEnd < 0 || contentEnd === contentStart) return null;
194
+
195
+ return { contentStart, contentEnd, end: contentEnd + fence };
196
+ }
197
+
198
+ /** A `**strong**`, `*em*`, or `~~strike~~` span at `index`, or null. */
199
+ function matchEmphasis(text: string, index: number): Span | null {
200
+ const two = text.slice(index, index + 2);
201
+
202
+ if (two === "**") return closingSpan(text, index, 2, "strong");
203
+ if (two === "~~") return closingSpan(text, index, 2, "strike");
204
+ if (text[index] === "*") return closingSpan(text, index, 1, "em");
205
+
206
+ return null;
207
+ }
208
+
209
+ /** Find the matching close for a marker of `length` at `index`; null if unbalanced. */
210
+ function closingSpan(
211
+ text: string,
212
+ index: number,
213
+ length: number,
214
+ role: "strong" | "em" | "strike",
215
+ ): Span | null {
216
+ const marker = text.slice(index, index + length);
217
+ const contentStart = index + length;
218
+ const closeIndex =
219
+ length === 1 ? findLoneStar(text, contentStart) : text.indexOf(marker, contentStart);
220
+
221
+ if (closeIndex < 0 || closeIndex === contentStart) return null; // unbalanced or empty
222
+
223
+ return { openLength: length, closeLength: length, closeIndex, role };
224
+ }
225
+
226
+ /** Index of the next standalone `*` (not part of a `**` run), or -1. */
227
+ function findLoneStar(text: string, from: number): number {
228
+ let index = from;
229
+
230
+ while (index < text.length) {
231
+ if (text[index] === "*") {
232
+ if (text[index + 1] !== "*") return index;
233
+ while (text[index] === "*") index++; // skip the whole `**` run
234
+ continue;
235
+ }
236
+ index++;
237
+ }
238
+
239
+ return -1;
240
+ }
@@ -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
 
@@ -29,8 +29,11 @@ const y = 2;
29
29
 
30
30
  describe("parseBlocks", () => {
31
31
  test("parses every block kind with source line ranges", () => {
32
+ // Act
32
33
  const blocks = parseBlocks(SAMPLE);
33
- expect(blocks.map((b) => b.kind)).toEqual([
34
+
35
+ // Assert
36
+ expect(blocks.map((block) => block.kind)).toEqual([
34
37
  "h1",
35
38
  "h2",
36
39
  "p",
@@ -43,7 +46,8 @@ describe("parseBlocks", () => {
43
46
  "quote",
44
47
  "hr",
45
48
  ]);
46
- const code = blocks.find((b) => b.kind === "code")!;
49
+ const code = blocks.find((block) => block.kind === "code")!;
50
+
47
51
  expect(code.lang).toBe("ts");
48
52
  expect(code.text).toBe("const x = 1;\nconst y = 2;");
49
53
  // fence lines included in the source range
@@ -51,50 +55,101 @@ describe("parseBlocks", () => {
51
55
  });
52
56
 
53
57
  test("multi-line paragraph keeps its text and range", () => {
58
+ // Arrange
54
59
  const blocks = parseBlocks(SAMPLE);
55
- const p = blocks.find((b) => b.kind === "p")!;
56
- expect(p.text).toBe("A paragraph that\nspans two lines.");
57
- expect(p.lineEnd - p.lineStart).toBe(1);
60
+ const paragraph = blocks.find((block) => block.kind === "p")!;
61
+
62
+ // Assert
63
+ expect(paragraph.text).toBe("A paragraph that\nspans two lines.");
64
+ expect(paragraph.lineEnd - paragraph.lineStart).toBe(1);
58
65
  });
59
66
 
60
67
  test("line ranges index into the source", () => {
68
+ // Arrange
61
69
  const lines = SAMPLE.split("\n");
62
- for (const b of parseBlocks(SAMPLE)) {
63
- if (b.kind === "code") {
64
- expect(lines[b.lineStart]!.startsWith("```")).toBe(true);
65
- } else if (b.kind === "h2") {
66
- expect(lines[b.lineStart]!.startsWith("## ")).toBe(true);
70
+
71
+ // Assert
72
+ for (const block of parseBlocks(SAMPLE)) {
73
+ if (block.kind === "code") {
74
+ expect(lines[block.lineStart]!.startsWith("```")).toBe(true);
75
+ } else if (block.kind === "h2") {
76
+ expect(lines[block.lineStart]!.startsWith("## ")).toBe(true);
67
77
  }
68
78
  }
69
79
  });
70
80
 
71
81
  test("round-trips through blockToMd", () => {
82
+ // Arrange
72
83
  const blocks = parseBlocks(SAMPLE);
73
- let oli = 0;
84
+ let orderedItemCount = 0;
74
85
  const rebuilt = blocks
75
- .map((b) => {
76
- oli = b.kind === "oli" ? oli + 1 : 0;
77
- return blockToMd(b, oli || 1);
86
+ .map((block) => {
87
+ orderedItemCount = block.kind === "oli" ? orderedItemCount + 1 : 0;
88
+
89
+ return blockToMd(block, orderedItemCount || 1);
78
90
  })
79
91
  .join("\n\n");
92
+
93
+ // Act
80
94
  // re-parsing the rebuild yields the same kinds and texts
81
95
  const again = parseBlocks(rebuilt);
82
- expect(again.map((b) => [b.kind, b.text])).toEqual(blocks.map((b) => [b.kind, b.text]));
96
+
97
+ // Assert
98
+ expect(again.map((block) => [block.kind, block.text])).toEqual(
99
+ blocks.map((block) => [block.kind, block.text]),
100
+ );
83
101
  });
84
102
 
85
103
  test("unknown constructs degrade to paragraphs, no content lost", () => {
104
+ // Arrange
86
105
  const md = "| a | b |\n|---|---|\n| 1 | 2 |";
106
+
107
+ // Act
87
108
  const blocks = parseBlocks(md);
88
- expect(blocks.map((b) => b.text).join("\n")).toContain("| a | b |");
109
+
110
+ // Assert
111
+ expect(blocks.map((block) => block.text).join("\n")).toContain("| a | b |");
89
112
  });
90
113
  });
91
114
 
92
115
  describe("sectionOf", () => {
93
116
  test("returns the nearest preceding heading", () => {
117
+ // Arrange
94
118
  const blocks = parseBlocks(SAMPLE);
95
- const oliIdx = blocks.findIndex((b) => b.kind === "oli");
96
- expect(sectionOf(blocks, oliIdx)).toBe("Steps");
97
- const pIdx = blocks.findIndex((b) => b.kind === "p");
98
- expect(sectionOf(blocks, pIdx)).toBe("Context");
119
+
120
+ // Assert
121
+ const orderedItemIndex = blocks.findIndex((block) => block.kind === "oli");
122
+
123
+ expect(sectionOf(blocks, orderedItemIndex)).toBe("Steps");
124
+ const paragraphIndex = blocks.findIndex((block) => block.kind === "p");
125
+
126
+ expect(sectionOf(blocks, paragraphIndex)).toBe("Context");
127
+ });
128
+ });
129
+
130
+ describe("stripLeadingBlockMarker", () => {
131
+ test("strips the marker the parser strips for every marked block kind", () => {
132
+ // Assert
133
+ expect(stripLeadingBlockMarker("# Heading")).toBe("Heading");
134
+ expect(stripLeadingBlockMarker("### Heading")).toBe("Heading");
135
+ expect(stripLeadingBlockMarker("- bullet item")).toBe("bullet item");
136
+ expect(stripLeadingBlockMarker("3. ordered item")).toBe("ordered item");
137
+ expect(stripLeadingBlockMarker("> quoted line")).toBe("quoted line");
138
+ });
139
+
140
+ test("agrees with the text the parser produces", () => {
141
+ // Arrange
142
+ const source = "- This is a dummy plan - confirm the item";
143
+
144
+ // Act
145
+ const [listBlock] = parseBlocks(source);
146
+
147
+ // Assert
148
+ expect(stripLeadingBlockMarker(source)).toBe(listBlock!.text);
149
+ });
150
+
151
+ test("leaves an unmarked line untouched", () => {
152
+ // Assert
153
+ expect(stripLeadingBlockMarker("a plain paragraph")).toBe("a plain paragraph");
99
154
  });
100
155
  });
package/src/markdown.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Markdown block model (map #22): parse source into addressable blocks that
3
- * record their source line ranges. Rendered→source mapping is block+line
2
+ * Markdown block model: parse source into addressable blocks that record
3
+ * their source line ranges. Rendered-to-source mapping is block+line
4
4
  * granular; the quote carries sub-block precision. Round-trip safe for the
5
5
  * subset cueloop plans use; unknown constructs fall back to paragraph blocks
6
6
  * so no content is ever lost.
@@ -19,113 +19,164 @@ export interface Block {
19
19
  lineEnd: number;
20
20
  }
21
21
 
22
- function isMarkerLine(l: string): boolean {
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
+
34
+ function isMarkerLine(line: string): boolean {
23
35
  return (
24
- l.startsWith("```") ||
25
- l.startsWith("# ") ||
26
- l.startsWith("## ") ||
27
- l.startsWith("### ") ||
28
- l.startsWith("> ") ||
29
- /^- /.test(l) ||
30
- /^\d+\. /.test(l) ||
31
- /^(---|\*\*\*|___)\s*$/.test(l)
36
+ line.startsWith("```") || LEADING_BLOCK_MARKER.test(line) || /^(---|\*\*\*|___)\s*$/.test(line)
32
37
  );
33
38
  }
34
39
 
35
- export function parseBlocks(md: string): Block[] {
36
- const lines = md.split("\n");
40
+ export function parseBlocks(markdown: string): Block[] {
41
+ const lines = markdown.split("\n");
37
42
  const blocks: Block[] = [];
38
- let i = 0;
39
- while (i < lines.length) {
40
- const l = lines[i]!;
41
- if (l.trim() === "") {
42
- i++;
43
+ let lineIndex = 0;
44
+
45
+ while (lineIndex < lines.length) {
46
+ const line = lines[lineIndex]!;
47
+
48
+ if (line.trim() === "") {
49
+ lineIndex++;
43
50
  continue;
44
51
  }
45
- if (l.startsWith("```")) {
46
- const start = i;
47
- const lang = l.slice(3).trim() || undefined;
48
- i++;
52
+ if (line.startsWith("```")) {
53
+ const start = lineIndex;
54
+ const lang = line.slice(3).trim() || undefined;
55
+
56
+ lineIndex++;
49
57
  const body: string[] = [];
50
- while (i < lines.length && !lines[i]!.startsWith("```")) {
51
- body.push(lines[i]!);
52
- i++;
58
+
59
+ while (lineIndex < lines.length && !lines[lineIndex]!.startsWith("```")) {
60
+ body.push(lines[lineIndex]!);
61
+ lineIndex++;
53
62
  }
54
- const end = Math.min(i, lines.length - 1);
55
- i++;
63
+ const end = Math.min(lineIndex, lines.length - 1);
64
+
65
+ lineIndex++;
56
66
  blocks.push({ kind: "code", text: body.join("\n"), lang, lineStart: start, lineEnd: end });
57
- } else if (l.startsWith("### ")) {
58
- blocks.push({ kind: "h3", text: l.slice(4), lineStart: i, lineEnd: i });
59
- i++;
60
- } else if (l.startsWith("## ")) {
61
- blocks.push({ kind: "h2", text: l.slice(3), lineStart: i, lineEnd: i });
62
- i++;
63
- } else if (l.startsWith("# ")) {
64
- blocks.push({ kind: "h1", text: l.slice(2), lineStart: i, lineEnd: i });
65
- i++;
66
- } else if (/^(---|\*\*\*|___)\s*$/.test(l)) {
67
- blocks.push({ kind: "hr", text: "", lineStart: i, lineEnd: i });
68
- i++;
69
- } else if (l.startsWith("> ")) {
70
- const start = i;
67
+ } else if (line.startsWith("### ")) {
68
+ blocks.push({
69
+ kind: "h3",
70
+ text: stripLeadingBlockMarker(line),
71
+ lineStart: lineIndex,
72
+ lineEnd: lineIndex,
73
+ });
74
+ lineIndex++;
75
+ } else if (line.startsWith("## ")) {
76
+ blocks.push({
77
+ kind: "h2",
78
+ text: stripLeadingBlockMarker(line),
79
+ lineStart: lineIndex,
80
+ lineEnd: lineIndex,
81
+ });
82
+ lineIndex++;
83
+ } else if (line.startsWith("# ")) {
84
+ blocks.push({
85
+ kind: "h1",
86
+ text: stripLeadingBlockMarker(line),
87
+ lineStart: lineIndex,
88
+ lineEnd: lineIndex,
89
+ });
90
+ lineIndex++;
91
+ } else if (/^(---|\*\*\*|___)\s*$/.test(line)) {
92
+ blocks.push({ kind: "hr", text: "", lineStart: lineIndex, lineEnd: lineIndex });
93
+ lineIndex++;
94
+ } else if (line.startsWith("> ")) {
95
+ const start = lineIndex;
71
96
  const body: string[] = [];
72
- while (i < lines.length && lines[i]!.startsWith("> ")) {
73
- body.push(lines[i]!.slice(2));
74
- i++;
97
+
98
+ while (lineIndex < lines.length && lines[lineIndex]!.startsWith("> ")) {
99
+ body.push(stripLeadingBlockMarker(lines[lineIndex]!));
100
+ lineIndex++;
75
101
  }
76
- blocks.push({ kind: "quote", text: body.join("\n"), lineStart: start, lineEnd: i - 1 });
77
- } else if (/^- /.test(l)) {
78
- blocks.push({ kind: "li", text: l.slice(2), lineStart: i, lineEnd: i });
79
- i++;
80
- } else if (/^\d+\. /.test(l)) {
81
- blocks.push({ kind: "oli", text: l.replace(/^\d+\. /, ""), lineStart: i, lineEnd: i });
82
- i++;
102
+ blocks.push({
103
+ kind: "quote",
104
+ text: body.join("\n"),
105
+ lineStart: start,
106
+ lineEnd: lineIndex - 1,
107
+ });
108
+ } else if (line.startsWith("- ")) {
109
+ blocks.push({
110
+ kind: "li",
111
+ text: stripLeadingBlockMarker(line),
112
+ lineStart: lineIndex,
113
+ lineEnd: lineIndex,
114
+ });
115
+ lineIndex++;
116
+ } else if (/^\d+\. /.test(line)) {
117
+ blocks.push({
118
+ kind: "oli",
119
+ text: stripLeadingBlockMarker(line),
120
+ lineStart: lineIndex,
121
+ lineEnd: lineIndex,
122
+ });
123
+ lineIndex++;
83
124
  } else {
84
- const start = i;
125
+ const start = lineIndex;
85
126
  const body: string[] = [];
86
- while (i < lines.length && lines[i]!.trim() !== "" && !isMarkerLine(lines[i]!)) {
87
- body.push(lines[i]!);
88
- i++;
127
+
128
+ while (
129
+ lineIndex < lines.length &&
130
+ lines[lineIndex]!.trim() !== "" &&
131
+ !isMarkerLine(lines[lineIndex]!)
132
+ ) {
133
+ body.push(lines[lineIndex]!);
134
+ lineIndex++;
89
135
  }
90
- blocks.push({ kind: "p", text: body.join("\n"), lineStart: start, lineEnd: i - 1 });
136
+ blocks.push({ kind: "p", text: body.join("\n"), lineStart: start, lineEnd: lineIndex - 1 });
91
137
  }
92
138
  }
139
+
93
140
  return blocks;
94
141
  }
95
142
 
96
143
  /** Serialize one block back to its markdown chunk. */
97
- export function blockToMd(b: Block, ordinal = 1): string {
98
- switch (b.kind) {
144
+ export function blockToMd(block: Block, ordinal = 1): string {
145
+ switch (block.kind) {
99
146
  case "h1":
100
- return "# " + b.text;
147
+ return "# " + block.text;
101
148
  case "h2":
102
- return "## " + b.text;
149
+ return "## " + block.text;
103
150
  case "h3":
104
- return "### " + b.text;
151
+ return "### " + block.text;
105
152
  case "li":
106
- return "- " + b.text;
153
+ return "- " + block.text;
107
154
  case "oli":
108
- return `${ordinal}. ` + b.text;
155
+ return `${ordinal}. ` + block.text;
109
156
  case "quote":
110
- return b.text
157
+ return block.text
111
158
  .split("\n")
112
- .map((l) => "> " + l)
159
+ .map((line) => "> " + line)
113
160
  .join("\n");
114
161
  case "code":
115
- return "```" + (b.lang ?? "") + "\n" + b.text + "\n```";
162
+ return "```" + (block.lang ?? "") + "\n" + block.text + "\n```";
116
163
  case "hr":
117
164
  return "---";
118
165
  default:
119
- return b.text;
166
+ return block.text;
120
167
  }
121
168
  }
122
169
 
123
170
  /** The section (nearest preceding heading) a block belongs to. */
124
171
  export function sectionOf(blocks: Block[], index: number): string {
125
- let sec = "";
126
- for (let i = 0; i <= index && i < blocks.length; i++) {
127
- const b = blocks[i]!;
128
- if (b.kind === "h1" || b.kind === "h2" || b.kind === "h3") sec = b.text;
172
+ let sectionTitle = "";
173
+
174
+ for (let blockIndex = 0; blockIndex <= index && blockIndex < blocks.length; blockIndex++) {
175
+ const block = blocks[blockIndex]!;
176
+
177
+ if (block.kind === "h1" || block.kind === "h2" || block.kind === "h3")
178
+ sectionTitle = block.text;
129
179
  }
130
- return sec;
180
+
181
+ return sectionTitle;
131
182
  }