paseo-beads 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,435 @@
1
+ /**
2
+ * A bounded, dependency-free Markdown subset parser.
3
+ *
4
+ * Beads issue bodies, acceptance criteria, and comments are authored as Markdown
5
+ * by humans and agents, so rendering them as one flat paragraph loses the
6
+ * structure that carries the meaning. The plugin has no runtime dependency and
7
+ * must never be the thing that hangs the app, so this parser:
8
+ *
9
+ * - accepts a fixed subset (ATX h1-h3, paragraphs, lists, task items,
10
+ * blockquotes, thematic rules, fenced code, inline code, bold, italic, links),
11
+ * - bounds input length, line count, block count, inline segments, and nesting,
12
+ * - never throws: anything unrecognised or unclosed degrades to readable text.
13
+ *
14
+ * It is pure so it can be unit tested in the Vitest node environment.
15
+ */
16
+
17
+ /** Input beyond this many characters is dropped; the document is marked truncated. */
18
+ export const MARKDOWN_MAX_CHARS = 12_000;
19
+ /** Lines beyond this count are dropped. */
20
+ export const MARKDOWN_MAX_LINES = 500;
21
+ /** Blocks beyond this count are dropped. */
22
+ export const MARKDOWN_MAX_BLOCKS = 160;
23
+ /** Inline segments per block; a hostile marker soup cannot exceed this. */
24
+ export const MARKDOWN_MAX_INLINE_SEGMENTS = 96;
25
+ /** Emphasis/link nesting depth before the rest is emitted as plain text. */
26
+ export const MARKDOWN_MAX_INLINE_DEPTH = 4;
27
+ /** List indentation levels that are visually distinguished. */
28
+ export const MARKDOWN_MAX_LIST_DEPTH = 3;
29
+ /** Lines kept inside one fenced code block. */
30
+ export const MARKDOWN_MAX_CODE_LINES = 80;
31
+ /** Characters kept for one run. A run may span the globally bounded input. */
32
+ export const MARKDOWN_MAX_SEGMENT_CHARS = MARKDOWN_MAX_CHARS;
33
+ /** Characters kept for a link target. */
34
+ export const MARKDOWN_MAX_HREF_CHARS = 300;
35
+
36
+ /**
37
+ * One styled run of inline text. Marks are flattened onto the run rather than
38
+ * nested, so the renderer is a single map over a flat list.
39
+ */
40
+ export interface InlineSegment {
41
+ readonly text: string;
42
+ readonly strong: boolean;
43
+ readonly emphasis: boolean;
44
+ readonly code: boolean;
45
+ /** Link target when this run came from `[label](target)`, else `null`. */
46
+ readonly href: string | null;
47
+ }
48
+
49
+ export type MarkdownBlock =
50
+ | { readonly kind: "heading"; readonly level: 1 | 2 | 3; readonly inline: readonly InlineSegment[] }
51
+ | { readonly kind: "paragraph"; readonly inline: readonly InlineSegment[] }
52
+ | {
53
+ readonly kind: "listItem";
54
+ readonly ordered: boolean;
55
+ /** 0-based, clamped to {@link MARKDOWN_MAX_LIST_DEPTH} - 1. */
56
+ readonly depth: number;
57
+ /** Rendered bullet or number label, e.g. `•` or `2.`. */
58
+ readonly marker: string;
59
+ /** `null` when the item is not a task item. */
60
+ readonly checked: boolean | null;
61
+ readonly inline: readonly InlineSegment[];
62
+ }
63
+ | { readonly kind: "quote"; readonly inline: readonly InlineSegment[] }
64
+ | { readonly kind: "rule" }
65
+ | { readonly kind: "code"; readonly language: string | null; readonly lines: readonly string[] };
66
+
67
+ export interface MarkdownDocument {
68
+ readonly blocks: readonly MarkdownBlock[];
69
+ /** True when input was cut by a character, line, or block bound. */
70
+ readonly truncated: boolean;
71
+ }
72
+
73
+ const EMPTY_DOCUMENT: MarkdownDocument = { blocks: [], truncated: false };
74
+
75
+ const HEADING = /^ {0,3}(#{1,6})[ \t]+(.*)$/;
76
+ const FENCE = /^ {0,3}(`{3,}|~{3,})[ \t]*(\S*)[ \t]*$/;
77
+ const RULE = /^ {0,3}([-*_])[ \t]*(?:\1[ \t]*){2,}$/;
78
+ const QUOTE = /^ {0,3}> ?(.*)$/;
79
+ const LIST_ITEM = /^( {0,16})([-*+]|\d{1,9}[.)])[ \t]+(.*)$/;
80
+ const TASK_MARK = /^\[([ xX])\][ \t]+(.*)$/;
81
+ const LINK = /^\[([^\][]{0,200})\]\(([^\s()]{0,300})\)/;
82
+ const TRAILING_HASHES = /[ \t]+#+[ \t]*$/;
83
+
84
+ /**
85
+ * Parses a Markdown subset. Never throws; unparseable input degrades to text.
86
+ */
87
+ export function parseMarkdown(input: string | null): MarkdownDocument {
88
+ if (input === null) return EMPTY_DOCUMENT;
89
+ const normalized = normalizeSource(input);
90
+ if (normalized.text.length === 0) return { blocks: [], truncated: normalized.truncated };
91
+
92
+ const rawLines = normalized.text.split("\n");
93
+ const lines = rawLines.slice(0, MARKDOWN_MAX_LINES);
94
+ let truncated = normalized.truncated || rawLines.length > lines.length;
95
+
96
+ const blocks: MarkdownBlock[] = [];
97
+ let paragraph: string[] = [];
98
+ let quote: string[] = [];
99
+
100
+ const flushParagraph = (): void => {
101
+ if (paragraph.length === 0) return;
102
+ const text = paragraph.join(" ");
103
+ paragraph = [];
104
+ push({ kind: "paragraph", inline: parseInlineText(text) });
105
+ };
106
+ const flushQuote = (): void => {
107
+ if (quote.length === 0) return;
108
+ const text = quote.join(" ");
109
+ quote = [];
110
+ push({ kind: "quote", inline: parseInlineText(text) });
111
+ };
112
+ const flush = (): void => {
113
+ flushParagraph();
114
+ flushQuote();
115
+ };
116
+ function push(block: MarkdownBlock): void {
117
+ if (blocks.length >= MARKDOWN_MAX_BLOCKS) {
118
+ truncated = true;
119
+ return;
120
+ }
121
+ blocks.push(block);
122
+ }
123
+
124
+ for (let index = 0; index < lines.length; index += 1) {
125
+ const line = lines[index] ?? "";
126
+
127
+ const fence = FENCE.exec(line);
128
+ if (fence !== null) {
129
+ flush();
130
+ const marker = fence[1] ?? "```";
131
+ const language = (fence[2] ?? "").length === 0 ? null : clampText(fence[2] ?? "", 40);
132
+ const code: string[] = [];
133
+ index += 1;
134
+ // An unclosed fence simply ends at the last line; the body is still shown.
135
+ for (; index < lines.length; index += 1) {
136
+ const codeLine = lines[index] ?? "";
137
+ if (isClosingFence(codeLine, marker)) break;
138
+ if (code.length >= MARKDOWN_MAX_CODE_LINES) {
139
+ truncated = true;
140
+ continue;
141
+ }
142
+ code.push(clampText(codeLine, MARKDOWN_MAX_SEGMENT_CHARS));
143
+ }
144
+ push({ kind: "code", language, lines: code });
145
+ continue;
146
+ }
147
+
148
+ if (line.trim().length === 0) {
149
+ flush();
150
+ continue;
151
+ }
152
+
153
+ if (RULE.test(line)) {
154
+ flush();
155
+ push({ kind: "rule" });
156
+ continue;
157
+ }
158
+
159
+ const heading = HEADING.exec(line);
160
+ if (heading !== null) {
161
+ flush();
162
+ const hashes = (heading[1] ?? "#").length;
163
+ const level = (hashes > 3 ? 3 : hashes) as 1 | 2 | 3;
164
+ const text = (heading[2] ?? "").replace(TRAILING_HASHES, "").trim();
165
+ push({ kind: "heading", level, inline: parseInlineText(text) });
166
+ continue;
167
+ }
168
+
169
+ const quoteLine = QUOTE.exec(line);
170
+ if (quoteLine !== null) {
171
+ flushParagraph();
172
+ quote.push((quoteLine[1] ?? "").trim());
173
+ continue;
174
+ }
175
+ flushQuote();
176
+
177
+ const item = LIST_ITEM.exec(line);
178
+ if (item !== null) {
179
+ flushParagraph();
180
+ const indent = (item[1] ?? "").length;
181
+ const bullet = item[2] ?? "-";
182
+ const ordered = /\d/.test(bullet);
183
+ const depth = Math.min(Math.floor(indent / 2), MARKDOWN_MAX_LIST_DEPTH - 1);
184
+ const task = TASK_MARK.exec(item[3] ?? "");
185
+ const checked = task === null ? null : (task[1] ?? " ").toLowerCase() === "x";
186
+ const content = task === null ? (item[3] ?? "") : (task[2] ?? "");
187
+ push({
188
+ kind: "listItem",
189
+ ordered,
190
+ depth,
191
+ marker: ordered ? bullet : bulletFor(depth),
192
+ checked,
193
+ inline: parseInlineText(content),
194
+ });
195
+ continue;
196
+ }
197
+
198
+ paragraph.push(line.trim());
199
+ }
200
+
201
+ flush();
202
+ return { blocks, truncated };
203
+ }
204
+
205
+ /** True when `text` contains anything the renderer would show as structure. */
206
+ export function hasMarkdownStructure(text: string): boolean {
207
+ return parseMarkdown(text).blocks.some((block) => block.kind !== "paragraph");
208
+ }
209
+
210
+ /** Plain-text projection of a parsed document, for accessibility labels. */
211
+ export function markdownToPlainText(parsed: MarkdownDocument, maxChars = 400): string {
212
+ const parts: string[] = [];
213
+ for (const block of parsed.blocks) {
214
+ if (block.kind === "rule") continue;
215
+ if (block.kind === "code") {
216
+ parts.push(block.lines.join(" "));
217
+ continue;
218
+ }
219
+ const prefix = block.kind === "listItem" ? `${block.marker} ` : "";
220
+ parts.push(prefix + block.inline.map((segment) => segment.text).join(""));
221
+ if (parts.join(" ").length > maxChars) break;
222
+ }
223
+ return clampText(parts.join(" ").replace(/\s+/g, " ").trim(), maxChars);
224
+ }
225
+
226
+ function isClosingFence(line: string, marker: string): boolean {
227
+ const trimmed = line.trim();
228
+ const char = marker[0] ?? "`";
229
+ if (trimmed.length < marker.length) return false;
230
+ for (const candidate of trimmed) {
231
+ if (candidate !== char) return false;
232
+ }
233
+ return true;
234
+ }
235
+
236
+ function bulletFor(depth: number): string {
237
+ if (depth <= 0) return "•";
238
+ if (depth === 1) return "◦";
239
+ return "▪";
240
+ }
241
+
242
+ interface NormalizedSource {
243
+ readonly text: string;
244
+ readonly truncated: boolean;
245
+ }
246
+
247
+ /**
248
+ * Collapses line endings, expands tabs, and strips control characters so a
249
+ * hostile payload cannot smuggle terminal escapes into a `Text` node.
250
+ */
251
+ function normalizeSource(input: string): NormalizedSource {
252
+ const bounded = input.length > MARKDOWN_MAX_CHARS ? input.slice(0, MARKDOWN_MAX_CHARS) : input;
253
+ const text = bounded
254
+ .replace(/\r\n?/g, "\n")
255
+ .replace(/\t/g, " ")
256
+ // eslint-disable-next-line no-control-regex -- deliberately stripping C0/C1 except newline.
257
+ .replace(/[\u0000-\u0009\u000B-\u001F\u007F-\u009F]/g, "");
258
+ return { text: text.trim(), truncated: bounded.length < input.length };
259
+ }
260
+
261
+ function clampText(value: string, maxChars: number): string {
262
+ return value.length > maxChars ? `${value.slice(0, maxChars)}…` : value;
263
+ }
264
+
265
+ interface Marks {
266
+ readonly strong: boolean;
267
+ readonly emphasis: boolean;
268
+ }
269
+
270
+ const PLAIN: Marks = { strong: false, emphasis: false };
271
+
272
+ /** Parses one logical line of inline Markdown into flat styled runs. */
273
+ export function parseInlineText(source: string): readonly InlineSegment[] {
274
+ const out: InlineSegment[] = [];
275
+ parseInline(source, PLAIN, null, 0, out);
276
+ return out;
277
+ }
278
+
279
+ function parseInline(
280
+ source: string,
281
+ marks: Marks,
282
+ href: string | null,
283
+ depth: number,
284
+ out: InlineSegment[],
285
+ ): void {
286
+ if (source.length === 0) return;
287
+ if (depth > MARKDOWN_MAX_INLINE_DEPTH) {
288
+ pushRun(out, source, marks, href, false);
289
+ return;
290
+ }
291
+
292
+ let cursor = 0;
293
+ let plainStart = 0;
294
+ const flushPlain = (end: number): void => {
295
+ if (end > plainStart) pushRun(out, source.slice(plainStart, end), marks, href, false);
296
+ };
297
+
298
+ while (cursor < source.length) {
299
+ if (out.length >= MARKDOWN_MAX_INLINE_SEGMENTS - 1) {
300
+ // Reserve the final segment for readable plain-text fallback. Nested or
301
+ // marker-heavy input may stop receiving rich styling, but its tail must
302
+ // not silently disappear.
303
+ pushRemainder(out, source.slice(plainStart));
304
+ return;
305
+ }
306
+ const char = source[cursor] ?? "";
307
+
308
+ if (char === "\\" && cursor + 1 < source.length) {
309
+ // Escaped punctuation is emitted literally without its backslash.
310
+ flushPlain(cursor);
311
+ pushRun(out, source[cursor + 1] ?? "", marks, href, false);
312
+ cursor += 2;
313
+ plainStart = cursor;
314
+ continue;
315
+ }
316
+
317
+ if (char === "`") {
318
+ const run = runLength(source, cursor, "`");
319
+ const closing = source.indexOf("`".repeat(run), cursor + run);
320
+ if (closing > cursor + run - 1) {
321
+ flushPlain(cursor);
322
+ pushRun(out, source.slice(cursor + run, closing).trim(), marks, href, true);
323
+ cursor = closing + run;
324
+ plainStart = cursor;
325
+ continue;
326
+ }
327
+ cursor += run;
328
+ continue;
329
+ }
330
+
331
+ if (char === "[" && href === null) {
332
+ const link = LINK.exec(source.slice(cursor));
333
+ if (link !== null) {
334
+ const target = clampText((link[2] ?? "").trim(), MARKDOWN_MAX_HREF_CHARS);
335
+ const label = link[1] ?? "";
336
+ flushPlain(cursor);
337
+ if (target.length === 0) {
338
+ pushRun(out, label, marks, href, false);
339
+ } else {
340
+ parseInline(label.length === 0 ? target : label, marks, target, depth + 1, out);
341
+ }
342
+ cursor += (link[0] ?? "").length;
343
+ plainStart = cursor;
344
+ continue;
345
+ }
346
+ cursor += 1;
347
+ continue;
348
+ }
349
+
350
+ if (char === "*" || char === "_") {
351
+ const run = Math.min(runLength(source, cursor, char), 2);
352
+ const marker = char.repeat(run);
353
+ const contentStart = cursor + run;
354
+ const closing = findEmphasisClose(source, contentStart, marker, char);
355
+ const intraword = char === "_" && isWordChar(source[cursor - 1]);
356
+ if (closing > contentStart && !intraword) {
357
+ flushPlain(cursor);
358
+ const next: Marks =
359
+ run === 2 ? { strong: true, emphasis: marks.emphasis } : { strong: marks.strong, emphasis: true };
360
+ parseInline(source.slice(contentStart, closing), next, href, depth + 1, out);
361
+ cursor = closing + run;
362
+ plainStart = cursor;
363
+ continue;
364
+ }
365
+ cursor += run;
366
+ continue;
367
+ }
368
+
369
+ cursor += 1;
370
+ }
371
+
372
+ flushPlain(source.length);
373
+ }
374
+
375
+ function findEmphasisClose(source: string, from: number, marker: string, char: string): number {
376
+ let search = from;
377
+ while (search < source.length) {
378
+ const found = source.indexOf(marker, search);
379
+ if (found < 0) return -1;
380
+ if (char === "_" && isWordChar(source[found + marker.length])) {
381
+ search = found + marker.length;
382
+ continue;
383
+ }
384
+ return found;
385
+ }
386
+ return -1;
387
+ }
388
+
389
+ function runLength(source: string, from: number, char: string): number {
390
+ let length = 0;
391
+ while (source[from + length] === char) length += 1;
392
+ return length;
393
+ }
394
+
395
+ function isWordChar(char: string | undefined): boolean {
396
+ return char !== undefined && /[A-Za-z0-9]/.test(char);
397
+ }
398
+
399
+ /** Keeps the unparsed tail readable when the inline-segment budget is exhausted. */
400
+ function pushRemainder(out: InlineSegment[], text: string): void {
401
+ if (text.length === 0) return;
402
+ const remainder = clampText(text, MARKDOWN_MAX_SEGMENT_CHARS);
403
+ if (out.length < MARKDOWN_MAX_INLINE_SEGMENTS) {
404
+ out.push({ text: remainder, strong: false, emphasis: false, code: false, href: null });
405
+ return;
406
+ }
407
+ const last = out[out.length - 1];
408
+ if (last === undefined) return;
409
+ out[out.length - 1] = {
410
+ text: clampText(last.text + remainder, MARKDOWN_MAX_SEGMENT_CHARS),
411
+ strong: false,
412
+ emphasis: false,
413
+ code: false,
414
+ href: null,
415
+ };
416
+ }
417
+
418
+
419
+ /** Appends a run, merging into the previous run when the styling is identical. */
420
+ function pushRun(out: InlineSegment[], text: string, marks: Marks, href: string | null, code: boolean): void {
421
+ if (text.length === 0) return;
422
+ if (out.length >= MARKDOWN_MAX_INLINE_SEGMENTS) return;
423
+ const last = out[out.length - 1];
424
+ if (
425
+ last !== undefined &&
426
+ last.code === code &&
427
+ last.strong === marks.strong &&
428
+ last.emphasis === marks.emphasis &&
429
+ last.href === href
430
+ ) {
431
+ out[out.length - 1] = { ...last, text: clampText(last.text + text, MARKDOWN_MAX_SEGMENT_CHARS) };
432
+ return;
433
+ }
434
+ out.push({ text, strong: marks.strong, emphasis: marks.emphasis, code, href });
435
+ }