@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/package.json +17 -17
- package/src/anchor.test.ts +268 -40
- package/src/anchor.ts +355 -43
- package/src/diff.test.ts +42 -25
- package/src/diff.ts +112 -77
- package/src/feedback.test.ts +313 -36
- package/src/feedback.ts +219 -50
- package/src/fuzzy.test.ts +110 -0
- package/src/fuzzy.ts +115 -0
- package/src/herdr.test.ts +50 -0
- package/src/herdr.ts +54 -0
- package/src/history.test.ts +395 -0
- package/src/history.ts +419 -0
- package/src/index.ts +6 -0
- package/src/inline.test.ts +195 -0
- package/src/inline.ts +240 -0
- package/src/markdown.test.ts +76 -21
- package/src/markdown.ts +123 -72
- package/src/participants.test.ts +58 -0
- package/src/participants.ts +34 -0
- package/src/path-view.test.ts +201 -0
- package/src/path-view.ts +85 -0
- package/src/types.test.ts +45 -1
- package/src/types.ts +198 -12
- package/src/working-copy.test.ts +70 -23
- package/src/working-copy.ts +24 -5
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
|
+
}
|
package/src/markdown.test.ts
CHANGED
|
@@ -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
|
-
|
|
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((
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
expect(lines[
|
|
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
|
|
84
|
+
let orderedItemCount = 0;
|
|
74
85
|
const rebuilt = blocks
|
|
75
|
-
.map((
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
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
|
|
3
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
36
|
-
const lines =
|
|
40
|
+
export function parseBlocks(markdown: string): Block[] {
|
|
41
|
+
const lines = markdown.split("\n");
|
|
37
42
|
const blocks: Block[] = [];
|
|
38
|
-
let
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 (
|
|
46
|
-
const start =
|
|
47
|
-
const lang =
|
|
48
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
58
|
+
|
|
59
|
+
while (lineIndex < lines.length && !lines[lineIndex]!.startsWith("```")) {
|
|
60
|
+
body.push(lines[lineIndex]!);
|
|
61
|
+
lineIndex++;
|
|
53
62
|
}
|
|
54
|
-
const end = Math.min(
|
|
55
|
-
|
|
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 (
|
|
58
|
-
blocks.push({
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
97
|
+
|
|
98
|
+
while (lineIndex < lines.length && lines[lineIndex]!.startsWith("> ")) {
|
|
99
|
+
body.push(stripLeadingBlockMarker(lines[lineIndex]!));
|
|
100
|
+
lineIndex++;
|
|
75
101
|
}
|
|
76
|
-
blocks.push({
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 =
|
|
125
|
+
const start = lineIndex;
|
|
85
126
|
const body: string[] = [];
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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:
|
|
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(
|
|
98
|
-
switch (
|
|
144
|
+
export function blockToMd(block: Block, ordinal = 1): string {
|
|
145
|
+
switch (block.kind) {
|
|
99
146
|
case "h1":
|
|
100
|
-
return "# " +
|
|
147
|
+
return "# " + block.text;
|
|
101
148
|
case "h2":
|
|
102
|
-
return "## " +
|
|
149
|
+
return "## " + block.text;
|
|
103
150
|
case "h3":
|
|
104
|
-
return "### " +
|
|
151
|
+
return "### " + block.text;
|
|
105
152
|
case "li":
|
|
106
|
-
return "- " +
|
|
153
|
+
return "- " + block.text;
|
|
107
154
|
case "oli":
|
|
108
|
-
return `${ordinal}. ` +
|
|
155
|
+
return `${ordinal}. ` + block.text;
|
|
109
156
|
case "quote":
|
|
110
|
-
return
|
|
157
|
+
return block.text
|
|
111
158
|
.split("\n")
|
|
112
|
-
.map((
|
|
159
|
+
.map((line) => "> " + line)
|
|
113
160
|
.join("\n");
|
|
114
161
|
case "code":
|
|
115
|
-
return "```" + (
|
|
162
|
+
return "```" + (block.lang ?? "") + "\n" + block.text + "\n```";
|
|
116
163
|
case "hr":
|
|
117
164
|
return "---";
|
|
118
165
|
default:
|
|
119
|
-
return
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
180
|
+
|
|
181
|
+
return sectionTitle;
|
|
131
182
|
}
|