@diegopetrucci/pi-annotate-last-message 0.1.8 → 0.1.10

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,458 @@
1
+ /* global globalThis */
2
+
3
+ /**
4
+ * TLH annotate-last-message: pure markdown line parser.
5
+ *
6
+ * Single source of truth for the parsing logic. ui.ts reads this file and
7
+ * inlines it as a dedicated script before app.js, so globalThis.__tlhMdRenderer
8
+ * is available when the app script runs. Tests load this file directly in a
9
+ * Node.js vm context (no DOM required).
10
+ */
11
+ (function (global) {
12
+ "use strict";
13
+
14
+ // -------------------------------------------------------------------------
15
+ // Fence-state machine
16
+ //
17
+ // Applied over the full ordered line list before per-line rendering begins.
18
+ // Each line is tagged as: 'plain' | 'fence-open' | 'fence-body' | 'fence-close'
19
+ //
20
+ // Fence-delimiter lines (the ``` or ~~~ lines) are tagged 'fence-open' or
21
+ // 'fence-close'; they are rendered visible but styled as block-edge markers
22
+ // (--mdCodeBlockBorder colour) rather than suppressed. Fence-body lines skip
23
+ // inline markdown processing and receive code-block styling instead.
24
+ //
25
+ // A fenced block that reaches end-of-input without a closing delimiter is
26
+ // left open; all remaining lines are tagged 'fence-body'.
27
+ // -------------------------------------------------------------------------
28
+
29
+ function isFenceDelimiter(text) {
30
+ // CommonMark §4.5: opening/closing fences allow at most 3 spaces of
31
+ // indentation. A line with 4+ leading spaces is an indented code block
32
+ // and must not be treated as a fence delimiter.
33
+ const m = /^ {0,3}(```+|~~~+)/.exec(text);
34
+ return m ? { char: m[1][0], len: m[1].length } : null;
35
+ }
36
+
37
+ /**
38
+ * applyFenceState(lineTexts) → Array<{ text: string, lineType: string }>
39
+ *
40
+ * One output entry per input entry; the count is always preserved.
41
+ */
42
+ function applyFenceState(lineTexts) {
43
+ const result = [];
44
+ let inFence = false;
45
+ let fenceChar = "";
46
+ let fenceLen = 0;
47
+
48
+ for (const text of lineTexts) {
49
+ const fence = isFenceDelimiter(text);
50
+
51
+ if (!inFence) {
52
+ if (fence) {
53
+ inFence = true;
54
+ fenceChar = fence.char;
55
+ fenceLen = fence.len;
56
+ result.push({ text, lineType: "fence-open" });
57
+ } else {
58
+ result.push({ text, lineType: "plain" });
59
+ }
60
+ } else {
61
+ // Close if same delimiter character, at least as many chars, and
62
+ // nothing but optional whitespace after the delimiter (CommonMark
63
+ // §4.5: a closing fence must not have trailing non-whitespace).
64
+ const afterDelim = fence ? text.replace(/^ {0,3}(?:```+|~~~+)/, "") : "";
65
+ if (
66
+ fence &&
67
+ fence.char === fenceChar &&
68
+ fence.len >= fenceLen &&
69
+ /^\s*$/.test(afterDelim)
70
+ ) {
71
+ inFence = false;
72
+ fenceChar = "";
73
+ fenceLen = 0;
74
+ result.push({ text, lineType: "fence-close" });
75
+ } else {
76
+ result.push({ text, lineType: "fence-body" });
77
+ }
78
+ }
79
+ }
80
+
81
+ return result;
82
+ }
83
+
84
+ // -------------------------------------------------------------------------
85
+ // Line classifier (called only on 'plain'-typed lines)
86
+ // -------------------------------------------------------------------------
87
+
88
+ /**
89
+ * classifyLine(text) → LineInfo
90
+ *
91
+ * Returned shapes:
92
+ * { type: 'blank' }
93
+ * { type: 'heading', level: 1-6, text: string }
94
+ * { type: 'hr' }
95
+ * { type: 'blockquote', text: string }
96
+ * { type: 'ul', indent: string, bullet: string, text: string }
97
+ * { type: 'ol', indent: string, bullet: string, text: string }
98
+ * { type: 'plain', text: string }
99
+ */
100
+ function classifyLine(text) {
101
+ if (text.trim().length === 0) {
102
+ return { type: "blank" };
103
+ }
104
+
105
+ const trimmed = text.trimStart();
106
+ const indentLen = text.length - trimmed.length;
107
+
108
+ // CommonMark block-indentation rule: constructs that HIDE or REPLACE
109
+ // source characters (headings hide '#', thematic breaks replace the
110
+ // whole line, blockquotes hide '>') must have at most 3 spaces of
111
+ // leading indentation. At 4+ spaces the line is indented code and
112
+ // must render literally. List markers are exempt: they preserve every
113
+ // source character and only restyle the bullet, so nested-list
114
+ // indentation stays lenient.
115
+ const blockSafe = indentLen <= 3;
116
+
117
+ // ATX heading: # … through ###### …
118
+ // The heading content may optionally end with a closing # sequence.
119
+ if (blockSafe) {
120
+ const headingMatch = /^(#{1,6})\s+(.+?)(?:\s+#+\s*)?$/.exec(trimmed);
121
+ if (headingMatch) {
122
+ return { type: "heading", level: headingMatch[1].length, text: headingMatch[2] };
123
+ }
124
+ }
125
+
126
+ // Thematic break: 3+ dashes, stars, or underscores with optional spaces.
127
+ if (blockSafe) {
128
+ if (/^(?:(?:-[ \t]*){3,}|(?:\*[ \t]*){3,}|(?:_[ \t]*){3,})$/.test(trimmed)) {
129
+ return { type: "hr" };
130
+ }
131
+ }
132
+
133
+ // Blockquote: > text
134
+ if (blockSafe) {
135
+ const bqMatch = /^>[ \t]?(.*)/.exec(trimmed);
136
+ if (bqMatch) {
137
+ return { type: "blockquote", text: bqMatch[1] };
138
+ }
139
+ }
140
+
141
+ // Unordered list item: optional indent + (- * +) + one or more spaces + text
142
+ const ulMatch = /^(\s*)([-*+])[ \t]+(.*)/.exec(text);
143
+ if (ulMatch) {
144
+ return { type: "ul", indent: ulMatch[1], bullet: ulMatch[2], text: ulMatch[3] };
145
+ }
146
+
147
+ // Ordered list item: optional indent + number. + one or more spaces + text
148
+ const olMatch = /^(\s*)(\d+\.)[ \t]+(.*)/.exec(text);
149
+ if (olMatch) {
150
+ return { type: "ol", indent: olMatch[1], bullet: olMatch[2], text: olMatch[3] };
151
+ }
152
+
153
+ return { type: "plain", text };
154
+ }
155
+
156
+ // -------------------------------------------------------------------------
157
+ // Inline tokenizer
158
+ // -------------------------------------------------------------------------
159
+
160
+ function isMarkdownSpecial(ch) {
161
+ return ch === "*" || ch === "_" || ch === "~" || ch === "`" || ch === "[" || ch === "\\";
162
+ }
163
+
164
+ /**
165
+ * Try to parse an inline code span starting at position i.
166
+ * Returns { codeText, end } on success, null on failure.
167
+ */
168
+ function tryParseCodeSpan(text, i) {
169
+ // Count opening backtick run.
170
+ let tickCount = 0;
171
+ while (i + tickCount < text.length && text[i + tickCount] === "`") tickCount++;
172
+ const contentStart = i + tickCount;
173
+
174
+ // Find a closing run of exactly the same length.
175
+ let j = contentStart;
176
+ while (j < text.length) {
177
+ if (text[j] !== "`") {
178
+ j++;
179
+ continue;
180
+ }
181
+ let runLen = 0;
182
+ while (j + runLen < text.length && text[j + runLen] === "`") runLen++;
183
+ if (runLen === tickCount) {
184
+ return { codeText: text.slice(contentStart, j), end: j + tickCount };
185
+ }
186
+ j += runLen;
187
+ }
188
+ return null;
189
+ }
190
+
191
+ /**
192
+ * Find the closing position of an emphasis/strikethrough marker.
193
+ * Skips over backslash escapes and inline code spans.
194
+ * For single-char markers (star or underscore) the closer must be an
195
+ * isolated run of exactly 1; for 2- or 3-char markers the run must be
196
+ * exactly that length. Returns -1 if not found.
197
+ *
198
+ * When checkIntraword is true (used for underscore-based delimiters),
199
+ * a candidate closing run that is flanked by alphanumerics on both sides
200
+ * is skipped — CommonMark's intraword-underscore rule.
201
+ */
202
+ function findEmphasisCloser(text, marker, from, checkIntraword = false) {
203
+ const mLen = marker.length;
204
+ const mChar = marker[0];
205
+ let j = from;
206
+
207
+ while (j < text.length) {
208
+ // Backslash escape: skip the next character.
209
+ if (text[j] === "\\" && j + 1 < text.length) {
210
+ j += 2;
211
+ continue;
212
+ }
213
+
214
+ // Inline code span: skip its entire content.
215
+ if (text[j] === "`") {
216
+ let tickCount = 0;
217
+ while (j + tickCount < text.length && text[j + tickCount] === "`") tickCount++;
218
+ const closeAt = text.indexOf("`".repeat(tickCount), j + tickCount);
219
+ j = closeAt === -1 ? text.length : closeAt + tickCount;
220
+ continue;
221
+ }
222
+
223
+ if (text[j] === mChar) {
224
+ // Measure the run length at this position.
225
+ let runLen = 0;
226
+ while (j + runLen < text.length && text[j + runLen] === mChar) runLen++;
227
+
228
+ if (runLen === mLen) {
229
+ if (checkIntraword) {
230
+ // Underscore-based closing delimiter flanked by alphanumerics
231
+ // on both sides is intraword and cannot close emphasis.
232
+ const charBefore = j > 0 ? text[j - 1] : "";
233
+ const charAfter = j + mLen < text.length ? text[j + mLen] : "";
234
+ if (/[a-zA-Z0-9]/.test(charBefore) && /[a-zA-Z0-9]/.test(charAfter)) {
235
+ j += runLen;
236
+ continue;
237
+ }
238
+ }
239
+ return j;
240
+ }
241
+ // Wrong run length — skip the whole run and keep scanning.
242
+ j += runLen;
243
+ continue;
244
+ }
245
+
246
+ j++;
247
+ }
248
+
249
+ return -1;
250
+ }
251
+
252
+ /**
253
+ * tokenizeLine(text) → Token[]
254
+ *
255
+ * Parses inline markdown and returns a token tree. Falls back to a single
256
+ * plain-text token if an error is thrown.
257
+ *
258
+ * Token shapes:
259
+ * { type: 'text', text: string }
260
+ * { type: 'code', text: string }
261
+ * { type: 'bold', children: Token[] }
262
+ * { type: 'italic', children: Token[] }
263
+ * { type: 'boldItalic', children: Token[] }
264
+ * { type: 'strikethrough', children: Token[] }
265
+ * { type: 'link', labelTokens: Token[], url: string }
266
+ */
267
+ function tokenizeLine(text) {
268
+ try {
269
+ return parseInlineSpans(text);
270
+ } catch {
271
+ return [{ type: "text", text }];
272
+ }
273
+ }
274
+
275
+ function parseInlineSpans(text) {
276
+ const tokens = [];
277
+ let i = 0;
278
+ let buf = "";
279
+ let bracketMatches = null;
280
+
281
+ // Match raw brackets once per parse call; suffix rescans make runs quadratic.
282
+ function getBracketMatches() {
283
+ if (bracketMatches !== null) return bracketMatches;
284
+
285
+ bracketMatches = new Map();
286
+ const stack = [];
287
+ for (let k = 0; k < text.length; k++) {
288
+ if (text[k] === "[") {
289
+ stack.push(k);
290
+ } else if (text[k] === "]" && stack.length > 0) {
291
+ bracketMatches.set(stack.pop(), k);
292
+ }
293
+ }
294
+ return bracketMatches;
295
+ }
296
+
297
+ function flush() {
298
+ if (buf.length > 0) {
299
+ tokens.push({ type: "text", text: buf });
300
+ buf = "";
301
+ }
302
+ }
303
+
304
+ while (i < text.length) {
305
+ const ch = text[i];
306
+
307
+ // Backslash escape: treat next markdown-special character as literal.
308
+ if (ch === "\\" && i + 1 < text.length && isMarkdownSpecial(text[i + 1])) {
309
+ buf += text[i + 1];
310
+ i += 2;
311
+ continue;
312
+ }
313
+
314
+ // Inline code span.
315
+ if (ch === "`") {
316
+ const result = tryParseCodeSpan(text, i);
317
+ if (result) {
318
+ flush();
319
+ tokens.push({ type: "code", text: result.codeText });
320
+ i = result.end;
321
+ continue;
322
+ }
323
+ buf += ch;
324
+ i++;
325
+ continue;
326
+ }
327
+
328
+ // Link: [label](url)
329
+ if (ch === "[") {
330
+ const closeLabel = getBracketMatches().get(i) ?? -1;
331
+ if (closeLabel !== -1 && text[closeLabel + 1] === "(") {
332
+ const urlStart = closeLabel + 2;
333
+ const closeUrl = text.indexOf(")", urlStart);
334
+ if (closeUrl !== -1) {
335
+ flush();
336
+ const labelText = text.slice(i + 1, closeLabel);
337
+ const url = text.slice(urlStart, closeUrl);
338
+ tokens.push({ type: "link", labelTokens: parseInlineSpans(labelText), url });
339
+ i = closeUrl + 1;
340
+ continue;
341
+ }
342
+ }
343
+ buf += ch;
344
+ i++;
345
+ continue;
346
+ }
347
+
348
+ // Bold+italic: ***text*** or ___text___
349
+ if (
350
+ (ch === "*" && text[i + 1] === "*" && text[i + 2] === "*") ||
351
+ (ch === "_" && text[i + 1] === "_" && text[i + 2] === "_")
352
+ ) {
353
+ // Underscore-based opening: skip if flanked by alphanumerics
354
+ // on both sides (CommonMark intraword-underscore rule).
355
+ if (ch === "_") {
356
+ const charBefore = i > 0 ? text[i - 1] : "";
357
+ const charAfter = i + 3 < text.length ? text[i + 3] : "";
358
+ if (/[a-zA-Z0-9]/.test(charBefore) && /[a-zA-Z0-9]/.test(charAfter)) {
359
+ buf += ch.repeat(3);
360
+ i += 3;
361
+ continue;
362
+ }
363
+ }
364
+ const marker = ch.repeat(3);
365
+ const closeIdx = findEmphasisCloser(text, marker, i + 3, ch === "_");
366
+ if (closeIdx !== -1) {
367
+ const inner = text.slice(i + 3, closeIdx);
368
+ if (inner.length > 0 && inner[0] !== " " && inner[inner.length - 1] !== " ") {
369
+ flush();
370
+ tokens.push({ type: "boldItalic", children: parseInlineSpans(inner) });
371
+ i = closeIdx + 3;
372
+ continue;
373
+ }
374
+ }
375
+ }
376
+
377
+ // Bold: **text** or __text__
378
+ if (
379
+ (ch === "*" && text[i + 1] === "*" && text[i + 2] !== "*") ||
380
+ (ch === "_" && text[i + 1] === "_" && text[i + 2] !== "_")
381
+ ) {
382
+ // Underscore-based opening: skip if flanked by alphanumerics
383
+ // on both sides (CommonMark intraword-underscore rule).
384
+ if (ch === "_") {
385
+ const charBefore = i > 0 ? text[i - 1] : "";
386
+ const charAfter = i + 2 < text.length ? text[i + 2] : "";
387
+ if (/[a-zA-Z0-9]/.test(charBefore) && /[a-zA-Z0-9]/.test(charAfter)) {
388
+ buf += ch.repeat(2);
389
+ i += 2;
390
+ continue;
391
+ }
392
+ }
393
+ const marker = ch.repeat(2);
394
+ const closeIdx = findEmphasisCloser(text, marker, i + 2, ch === "_");
395
+ if (closeIdx !== -1) {
396
+ const inner = text.slice(i + 2, closeIdx);
397
+ if (inner.length > 0 && inner[0] !== " " && inner[inner.length - 1] !== " ") {
398
+ flush();
399
+ tokens.push({ type: "bold", children: parseInlineSpans(inner) });
400
+ i = closeIdx + 2;
401
+ continue;
402
+ }
403
+ }
404
+ }
405
+
406
+ // Strikethrough: ~~text~~
407
+ if (ch === "~" && text[i + 1] === "~") {
408
+ const closeIdx = text.indexOf("~~", i + 2);
409
+ if (closeIdx !== -1) {
410
+ const inner = text.slice(i + 2, closeIdx);
411
+ if (inner.length > 0) {
412
+ flush();
413
+ tokens.push({ type: "strikethrough", children: parseInlineSpans(inner) });
414
+ i = closeIdx + 2;
415
+ continue;
416
+ }
417
+ }
418
+ }
419
+
420
+ // Italic: *text* or _text_ (single marker, not start of ** or ***)
421
+ if ((ch === "*" && text[i + 1] !== "*") || (ch === "_" && text[i + 1] !== "_")) {
422
+ // Underscore-based opening: skip if flanked by alphanumerics
423
+ // on both sides (CommonMark intraword-underscore rule).
424
+ if (ch === "_") {
425
+ const charBefore = i > 0 ? text[i - 1] : "";
426
+ const charAfter = i + 1 < text.length ? text[i + 1] : "";
427
+ if (/[a-zA-Z0-9]/.test(charBefore) && /[a-zA-Z0-9]/.test(charAfter)) {
428
+ buf += ch;
429
+ i += 1;
430
+ continue;
431
+ }
432
+ }
433
+ const closeIdx = findEmphasisCloser(text, ch, i + 1, ch === "_");
434
+ if (closeIdx !== -1) {
435
+ const inner = text.slice(i + 1, closeIdx);
436
+ if (inner.length > 0 && inner[0] !== " " && inner[inner.length - 1] !== " ") {
437
+ flush();
438
+ tokens.push({ type: "italic", children: parseInlineSpans(inner) });
439
+ i = closeIdx + 1;
440
+ continue;
441
+ }
442
+ }
443
+ }
444
+
445
+ buf += ch;
446
+ i++;
447
+ }
448
+
449
+ flush();
450
+ return tokens;
451
+ }
452
+
453
+ global.__tlhMdRenderer = {
454
+ applyFenceState,
455
+ classifyLine,
456
+ tokenizeLine,
457
+ };
458
+ })(globalThis);