@profullstack/readm3 0.2.0 → 0.3.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.
package/dist/markdown.js CHANGED
@@ -1,29 +1,38 @@
1
1
  /**
2
- * A small markdown renderer that produces styled lines instead of a DOM.
2
+ * Markdown to styled lines, instead of to a DOM.
3
+ *
4
+ * Parsing is marked's — CommonMark, GFM, and the extensions in `flavors.ts`
5
+ * for what GitHub and Reddit added on top. What lives here is the half marked
6
+ * has no opinion about: turning a token tree into fixed-width lines of spans
7
+ * that a terminal can paint.
3
8
  *
4
9
  * Spans carry a semantic `role` rather than a color; the viewer maps roles onto
5
10
  * the active HQTUI theme. That split keeps this file pure and testable without
6
11
  * a terminal, and lets a theme change re-color a document without re-parsing it.
7
12
  */
8
13
  import { stringWidth } from "@profullstack/hqtui";
14
+ import { DEFAULT_FLAVOR, lex, lexInline, } from "./flavors.js";
9
15
  const BULLETS = ["•", "◦", "▪", "·"];
10
- const HR = /^ {0,3}(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/;
11
- const HEADING = /^ {0,3}(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$/;
12
- const FENCE = /^ {0,3}(```+|~~~+)[ \t]*([\w+#.-]*)/;
13
- const QUOTE = /^ {0,3}>[ \t]?(.*)$/;
14
- const LIST = /^([ \t]*)([-*+]|\d{1,9}[.)])([ \t]+)(.*)$/;
15
- const TASK = /^\[([ xX])\][ \t]+/;
16
- const SETEXT_H1 = /^ {0,3}=+[ \t]*$/;
17
- const SETEXT_H2 = /^ {0,3}-+[ \t]*$/;
18
- const TABLE_ROW = /^[ \t]*\|/;
19
- const TABLE_RULE = /^[ \t]*\|?[ \t]*:?-+:?[ \t]*(\|[ \t]*:?-+:?[ \t]*)*\|?[ \t]*$/;
20
- const INDENTED_CODE = /^(?: {4}|\t)(.*)$/;
16
+ /**
17
+ * A hard break inside a paragraph, carried as a sentinel because wrapping
18
+ * works on one logical line at a time.
19
+ */
20
+ const BREAK = "\u0000";
21
+ const ALERTS = {
22
+ note: { role: "alert-note", label: "Note", glyph: "ℹ" },
23
+ tip: { role: "alert-tip", label: "Tip", glyph: "✓" },
24
+ important: { role: "alert-important", label: "Important", glyph: "◆" },
25
+ warning: { role: "alert-warning", label: "Warning", glyph: "▲" },
26
+ caution: { role: "alert-caution", label: "Caution", glyph: "✕" },
27
+ };
28
+ const ALERT_HEAD = /^\[!(note|tip|important|warning|caution)\][ \t]*(?:\n|$)/i;
21
29
  function sameStyle(a, b) {
22
30
  return (a.role === b.role &&
23
31
  !a.bold === !b.bold &&
24
32
  !a.italic === !b.italic &&
25
33
  !a.underline === !b.underline &&
26
- !a.dim === !b.dim);
34
+ !a.dim === !b.dim &&
35
+ !a.strike === !b.strike);
27
36
  }
28
37
  /**
29
38
  * Glue neighbouring spans that share a style back together.
@@ -51,62 +60,7 @@ export function plain(line) {
51
60
  export function toText(lines) {
52
61
  return lines.map(plain);
53
62
  }
54
- // --------------------------------------------------------------- inline
55
- function matchLink(src) {
56
- if (src[0] !== "[")
57
- return null;
58
- let depth = 0;
59
- let i = 0;
60
- for (; i < src.length; i++) {
61
- const c = src[i];
62
- if (c === "\\") {
63
- i++;
64
- continue;
65
- }
66
- if (c === "[")
67
- depth++;
68
- else if (c === "]") {
69
- depth--;
70
- if (depth === 0)
71
- break;
72
- }
73
- }
74
- if (depth !== 0 || i >= src.length)
75
- return null;
76
- const text = src.slice(1, i);
77
- const rest = src.slice(i + 1);
78
- const inline = /^\([ \t]*(<[^>]*>|[^()\s]*(?:\([^()]*\)[^()\s]*)*)(?:[ \t]+["'(][^\n]*?["')])?[ \t]*\)/.exec(rest);
79
- if (inline) {
80
- const href = (inline[1] ?? "").replace(/^<|>$/g, "");
81
- return { text, href, length: i + 1 + inline[0].length };
82
- }
83
- const ref = /^\[([^\]]*)\]/.exec(rest);
84
- if (ref)
85
- return { text, href: ref[1] ?? "", length: i + 1 + ref[0].length };
86
- return null;
87
- }
88
- /**
89
- * Emphasis closers must not sit against a space, and `_` must not fire inside a
90
- * word — otherwise every snake_case identifier in a README turns italic.
91
- */
92
- function findClose(src, from, marker) {
93
- let j = from;
94
- while (j < src.length) {
95
- const k = src.indexOf(marker, j);
96
- if (k < 0)
97
- return -1;
98
- if (k === from || /\s/.test(src[k - 1] ?? "")) {
99
- j = k + marker.length;
100
- continue;
101
- }
102
- if (marker[0] === "_" && /[\w]/.test(src[k + marker.length] ?? "")) {
103
- j = k + marker.length;
104
- continue;
105
- }
106
- return k;
107
- }
108
- return -1;
109
- }
63
+ /* ------------------------------------------------------------------ html --- */
110
64
  /**
111
65
  * Fold the HTML that real READMEs are full of back into markdown.
112
66
  *
@@ -135,93 +89,154 @@ function stripTags(src) {
135
89
  .replace(/&#3[49];/g, "'")
136
90
  .replace(/&amp;/g, "&");
137
91
  }
138
- export function parseInline(src, base = {}) {
139
- return parseSpans(normalizeHtml(src), base);
92
+ /* ---------------------------------------------------------------- inline --- */
93
+ const SUPERSCRIPTS = {
94
+ "0": "⁰", "1": "¹", "2": "²", "3": "³", "4": "⁴",
95
+ "5": "⁵", "6": "⁶", "7": "⁷", "8": "⁸", "9": "⁹",
96
+ "+": "⁺", "-": "⁻", "=": "⁼", "(": "⁽", ")": "⁾",
97
+ a: "ᵃ", b: "ᵇ", c: "ᶜ", d: "ᵈ", e: "ᵉ", f: "ᶠ",
98
+ g: "ᵍ", h: "ʰ", i: "ⁱ", j: "ʲ", k: "ᵏ", l: "ˡ",
99
+ m: "ᵐ", n: "ⁿ", o: "ᵒ", p: "ᵖ", r: "ʳ", s: "ˢ",
100
+ t: "ᵗ", u: "ᵘ", v: "ᵛ", w: "ʷ", x: "ˣ", y: "ʸ",
101
+ z: "ᶻ",
102
+ };
103
+ /**
104
+ * Raise text into real superscript glyphs when every character has one.
105
+ *
106
+ * Unicode has no superscript `q`, and only some capitals, so a partial mapping
107
+ * would read as a ransom note. Anything that does not map keeps its caret.
108
+ */
109
+ function raise(text) {
110
+ let out = "";
111
+ for (const ch of text) {
112
+ const up = SUPERSCRIPTS[ch.toLowerCase()];
113
+ if (up === undefined)
114
+ return null;
115
+ out += up;
116
+ }
117
+ return out;
140
118
  }
141
- function parseSpans(src, base = {}) {
119
+ function inlineSpans(tokens, base, ctx) {
120
+ // A block whose inline run holds raw HTML is re-lexed with the HTML folded
121
+ // into markdown. Doing it here rather than over the whole source means fenced
122
+ // and indented code are never touched: they are not inline runs.
123
+ if (tokens.some((t) => t.type === "html")) {
124
+ const raw = tokens.map((t) => t.raw).join("");
125
+ const cleaned = normalizeHtml(raw);
126
+ if (cleaned !== raw)
127
+ tokens = lexInline(cleaned, ctx.flavor);
128
+ }
142
129
  const spans = [];
143
- let buf = "";
144
- const flush = () => {
145
- if (buf) {
146
- spans.push({ ...base, text: buf });
147
- buf = "";
148
- }
149
- };
150
- let i = 0;
151
- while (i < src.length) {
152
- const c = src[i];
153
- if (c === "\\" && /[\\`*_{}[\]()#+\-.!~>|]/.test(src[i + 1] ?? "")) {
154
- buf += src[i + 1];
155
- i += 2;
156
- continue;
157
- }
158
- if (c === "`") {
159
- const m = /^(`+)([\s\S]*?)\1(?!`)/.exec(src.slice(i));
160
- if (m?.[2] !== undefined) {
161
- flush();
162
- const inner = m[2].trim() === "" ? m[2] : m[2].replace(/^ (.*) $/, "$1");
163
- spans.push({ ...base, text: inner, role: "code" });
164
- i += m[0].length;
165
- continue;
130
+ for (const token of tokens) {
131
+ switch (token.type) {
132
+ case "text":
133
+ case "escape": {
134
+ const t = token;
135
+ // A `text` token carries its own inline children when it came out of a
136
+ // list item; anything else is a leaf.
137
+ if (t.tokens && t.tokens.length > 0)
138
+ spans.push(...inlineSpans(t.tokens, base, ctx));
139
+ else
140
+ spans.push({ ...base, text: decode(t.text) });
141
+ break;
166
142
  }
167
- }
168
- if (c === "!" && src[i + 1] === "[") {
169
- const m = matchLink(src.slice(i + 1));
170
- if (m) {
171
- flush();
172
- spans.push({ ...base, text: `🖼 ${m.text || m.href}`, role: "meta" });
173
- i += 1 + m.length;
174
- continue;
143
+ case "strong":
144
+ spans.push(...inlineSpans(token.tokens, { ...base, bold: true }, ctx));
145
+ break;
146
+ case "em":
147
+ spans.push(...inlineSpans(token.tokens, { ...base, italic: true }, ctx));
148
+ break;
149
+ case "del":
150
+ spans.push(...inlineSpans(token.tokens, { ...base, dim: true, strike: true }, ctx));
151
+ break;
152
+ case "codespan":
153
+ spans.push({ ...base, text: decode(token.text), role: "code" });
154
+ break;
155
+ case "br":
156
+ spans.push({ text: BREAK });
157
+ break;
158
+ case "link": {
159
+ const link = token;
160
+ // A bare URL marked autolinked reads as a URL, not as link text.
161
+ const role = link.text === link.href ? "url" : "link";
162
+ spans.push(...inlineSpans(link.tokens, { ...base, role, underline: true }, ctx));
163
+ break;
175
164
  }
176
- }
177
- if (c === "[") {
178
- const m = matchLink(src.slice(i));
179
- if (m) {
180
- flush();
181
- spans.push(...parseSpans(m.text, { ...base, role: "link", underline: true }));
182
- i += m.length;
183
- continue;
165
+ case "image": {
166
+ const img = token;
167
+ spans.push({ ...base, text: `\u{1f5bc} ${img.text || img.href}`, role: "meta" });
168
+ break;
184
169
  }
185
- }
186
- if (c === "<") {
187
- const m = /^<((?:https?|mailto):[^>\s]+)>/.exec(src.slice(i));
188
- if (m?.[1]) {
189
- flush();
190
- spans.push({ ...base, text: m[1], role: "url", underline: true });
191
- i += m[0].length;
192
- continue;
170
+ case "html":
171
+ // Survives only when normalizing changed nothing, i.e. a bare tag.
172
+ break;
173
+ case "emoji":
174
+ spans.push({ ...base, text: token.text });
175
+ break;
176
+ case "spoiler": {
177
+ const inner = token;
178
+ if (ctx.spoilers) {
179
+ spans.push(...inlineSpans(inner.tokens, { ...base, role: "spoiler" }, ctx));
180
+ }
181
+ else {
182
+ // Blocked out rather than colored out, so it stays hidden with
183
+ // --color never and in a pipe.
184
+ spans.push({ ...base, text: "█".repeat(Math.max(1, stringWidth(inner.text))), role: "spoiler" });
185
+ }
186
+ break;
193
187
  }
194
- }
195
- if (c === "~" && src.startsWith("~~", i)) {
196
- const close = src.indexOf("~~", i + 2);
197
- if (close > i + 2) {
198
- flush();
199
- spans.push(...parseSpans(src.slice(i + 2, close), { ...base, dim: true }));
200
- i = close + 2;
201
- continue;
188
+ case "superscript": {
189
+ const sup = token;
190
+ const raised = raise(sup.text);
191
+ if (raised)
192
+ spans.push({ ...base, text: raised, role: "sup" });
193
+ else
194
+ spans.push({ ...base, text: `^${sup.text}`, role: "sup", dim: true });
195
+ break;
202
196
  }
203
- }
204
- if ((c === "*" || c === "_") && !(c === "_" && /[\w]/.test(src[i - 1] ?? ""))) {
205
- const strong = src.startsWith(c + c, i);
206
- const marker = strong ? c + c : c;
207
- if (!/\s/.test(src[i + marker.length] ?? " ")) {
208
- const close = findClose(src, i + marker.length, marker);
209
- if (close > 0) {
210
- flush();
211
- const inner = src.slice(i + marker.length, close);
212
- spans.push(...parseSpans(inner, strong ? { ...base, bold: true } : { ...base, italic: true }));
213
- i = close + marker.length;
214
- continue;
215
- }
197
+ case "redditLink": {
198
+ const rl = token;
199
+ spans.push({ ...base, text: `${rl.kind}/${rl.name}`, role: "link", underline: true });
200
+ break;
201
+ }
202
+ case "footnoteRef": {
203
+ const ref = token;
204
+ spans.push({ ...base, text: `[${numberFor(ref.label, ctx)}]`, role: "footnote" });
205
+ break;
206
+ }
207
+ default: {
208
+ const any = token;
209
+ if (any.text)
210
+ spans.push({ ...base, text: decode(any.text) });
211
+ break;
216
212
  }
217
213
  }
218
- buf += c;
219
- i++;
220
214
  }
221
- flush();
222
215
  return spans.length > 0 ? spans : [{ ...base, text: "" }];
223
216
  }
224
- // ---------------------------------------------------------------- wrap
217
+ /** marked escapes for HTML output; a terminal wants the characters back. */
218
+ function decode(text) {
219
+ return text
220
+ .replace(/&lt;/g, "<")
221
+ .replace(/&gt;/g, ">")
222
+ .replace(/&quot;/g, '"')
223
+ .replace(/&#39;/g, "'")
224
+ .replace(/&amp;/g, "&");
225
+ }
226
+ function numberFor(label, ctx) {
227
+ const seen = ctx.numbers.get(label);
228
+ if (seen !== undefined)
229
+ return seen;
230
+ const next = ctx.numbers.size + 1;
231
+ ctx.numbers.set(label, next);
232
+ return next;
233
+ }
234
+ /** Inline markdown to spans. Exported because the site and tests both want it. */
235
+ export function parseInline(src, base = {}, options = {}) {
236
+ const ctx = context(options);
237
+ return mergeSpans(inlineSpans(lexInline(normalizeHtml(src), ctx.flavor), base, ctx));
238
+ }
239
+ /* ------------------------------------------------------------------ wrap --- */
225
240
  /** Greedy word wrap that carries each word's styling with it. */
226
241
  export function wrapSpans(spans, width, first = 0, hanging = first) {
227
242
  const max = Math.max(1, width);
@@ -283,30 +298,50 @@ export function wrapSpans(spans, width, first = 0, hanging = first) {
283
298
  flush();
284
299
  return lines;
285
300
  }
286
- // --------------------------------------------------------------- blocks
287
- function rule(width, char, style) {
288
- return { spans: [{ ...style, text: char.repeat(Math.max(1, width)) }] };
289
- }
290
- function splitRow(row) {
291
- const trimmed = row.trim().replace(/^\|/, "").replace(/\|$/, "");
292
- const cells = [];
293
- let buf = "";
294
- for (let i = 0; i < trimmed.length; i++) {
295
- const c = trimmed[i];
296
- if (c === "\\" && trimmed[i + 1] === "|") {
297
- buf += "|";
298
- i++;
299
- continue;
300
- }
301
- if (c === "|") {
302
- cells.push(buf.trim());
303
- buf = "";
301
+ /** Wrap a run that may hold hard breaks, which start a line without a blank. */
302
+ function wrapBlock(spans, width, first = 0, hanging = first) {
303
+ if (!spans.some((s) => s.text.includes(BREAK)))
304
+ return wrapSpans(spans, width, first, hanging);
305
+ const segments = [[]];
306
+ for (const span of spans) {
307
+ if (!span.text.includes(BREAK)) {
308
+ segments[segments.length - 1].push(span);
304
309
  continue;
305
310
  }
306
- buf += c;
311
+ span.text.split(BREAK).forEach((part, index) => {
312
+ if (index > 0)
313
+ segments.push([]);
314
+ if (part)
315
+ segments[segments.length - 1].push({ ...span, text: part });
316
+ });
307
317
  }
308
- cells.push(buf.trim());
309
- return cells;
318
+ const out = [];
319
+ segments.forEach((segment, index) => {
320
+ out.push(...wrapSpans(segment, width, index === 0 ? first : hanging, hanging));
321
+ });
322
+ return out;
323
+ }
324
+ /* ---------------------------------------------------------------- blocks --- */
325
+ function rule(width, char, style) {
326
+ return { spans: [{ ...style, text: char.repeat(Math.max(1, width)) }] };
327
+ }
328
+ function blankLine() {
329
+ return { spans: [{ text: "" }] };
330
+ }
331
+ /** Drop the blank lines a nested render leaves at either end. */
332
+ function trim(lines) {
333
+ let start = 0;
334
+ let end = lines.length;
335
+ while (start < end && plain(lines[start]).trim() === "")
336
+ start++;
337
+ while (end > start && plain(lines[end - 1]).trim() === "")
338
+ end--;
339
+ return lines.slice(start, end);
340
+ }
341
+ function prefixed(lines, first, rest) {
342
+ return lines.map((line, index) => ({
343
+ spans: [...(index === 0 ? first : rest).map((s) => ({ ...s })), ...line.spans],
344
+ }));
310
345
  }
311
346
  function pad(spans, target, align) {
312
347
  const used = spans.reduce((n, s) => n + stringWidth(s.text), 0);
@@ -321,10 +356,14 @@ function pad(spans, target, align) {
321
356
  }
322
357
  return [...spans, { text: " ".repeat(gap) }];
323
358
  }
324
- function renderTable(rows, aligns, width) {
359
+ function renderTable(token, width, ctx) {
360
+ const header = token.header.map((cell) => inlineSpans(cell.tokens, { role: "th", bold: true }, ctx));
361
+ const body = token.rows.map((row) => row.map((cell) => inlineSpans(cell.tokens, {}, ctx)));
362
+ const rows = [header, ...body];
325
363
  const columns = Math.max(...rows.map((r) => r.length));
326
- const cells = rows.map((row) => Array.from({ length: columns }, (_, i) => parseInline(row[i] ?? "")));
327
- const widths = Array.from({ length: columns }, (_, i) => Math.max(1, ...cells.map((row) => (row[i] ?? []).reduce((n, s) => n + stringWidth(s.text), 0))));
364
+ const aligns = token.align.map((a) => a ?? "left");
365
+ const cells = rows.map((row) => Array.from({ length: columns }, (_, i) => row[i] ?? [{ text: "" }]));
366
+ const widths = Array.from({ length: columns }, (_, i) => Math.max(1, ...cells.map((row) => row[i].reduce((n, s) => n + stringWidth(s.text), 0))));
328
367
  // Shrink the widest columns first until the table fits the pane.
329
368
  const gap = 2;
330
369
  let total = widths.reduce((a, b) => a + b, 0) + gap * (columns - 1);
@@ -336,9 +375,9 @@ function renderTable(rows, aligns, width) {
336
375
  total--;
337
376
  }
338
377
  const out = [];
339
- rows.forEach((_, r) => {
378
+ cells.forEach((row, r) => {
340
379
  const spans = [];
341
- cells[r].forEach((cell, c) => {
380
+ row.forEach((cell, c) => {
342
381
  if (c > 0)
343
382
  spans.push({ text: " ".repeat(gap) });
344
383
  const w = widths[c];
@@ -348,10 +387,9 @@ function renderTable(rows, aligns, width) {
348
387
  if (used >= w)
349
388
  break;
350
389
  const room = w - used;
351
- const text = stringWidth(span.text) > room ? span.text.slice(0, Math.max(0, room - 1)) + "…" : span.text;
352
- clipped.push(r === 0 ? { ...span, role: span.role ?? "th", bold: true } : span);
390
+ const text = stringWidth(span.text) > room ? `${span.text.slice(0, Math.max(0, room - 1))}…` : span.text;
391
+ clipped.push({ ...span, text });
353
392
  used += stringWidth(text);
354
- clipped[clipped.length - 1] = { ...clipped[clipped.length - 1], text };
355
393
  }
356
394
  spans.push(...pad(clipped, w, aligns[c] ?? "left"));
357
395
  });
@@ -368,230 +406,237 @@ function renderTable(rows, aligns, width) {
368
406
  });
369
407
  return out;
370
408
  }
409
+ function renderCode(text, lang, width, framed = true) {
410
+ const out = [];
411
+ const head = lang ? `┌─ ${lang} ` : "┌─ ";
412
+ if (framed)
413
+ out.push({
414
+ spans: [
415
+ { text: "┌─ ", role: "gutter" },
416
+ ...(lang ? [{ text: `${lang} `, role: "lang" }] : []),
417
+ { text: "─".repeat(Math.max(0, width - stringWidth(head))), role: "gutter" },
418
+ ],
419
+ });
420
+ for (const raw of text.replace(/\n$/, "").split("\n")) {
421
+ const line = raw.replace(/\t/g, " ");
422
+ out.push({
423
+ spans: [
424
+ { text: "│ ", role: "gutter" },
425
+ { text: stringWidth(line) > width - 2 ? `${line.slice(0, width - 3)}…` : line, role: "fence" },
426
+ ],
427
+ });
428
+ }
429
+ if (framed)
430
+ out.push({ spans: [{ text: `└${"─".repeat(Math.max(0, width - 1))}`, role: "gutter" }] });
431
+ return out;
432
+ }
433
+ function renderQuote(token, width, ctx) {
434
+ const inner = Math.max(4, width - 2);
435
+ const alert = ALERT_HEAD.exec(token.text);
436
+ if (alert) {
437
+ const kind = ALERTS[alert[1].toLowerCase()];
438
+ const body = token.text.slice(alert[0].length);
439
+ const lines = trim(renderTokens(lex(body, ctx.flavor), inner, ctx, 0));
440
+ const head = {
441
+ spans: [{ text: `${kind.glyph} ${kind.label}`, role: kind.role, bold: true }],
442
+ };
443
+ return prefixed([head, ...lines], [{ text: "▌ ", role: kind.role }], [
444
+ { text: "▌ ", role: kind.role },
445
+ ]);
446
+ }
447
+ const lines = trim(renderTokens(token.tokens, inner, ctx, 0));
448
+ return prefixed(lines.map((line) => ({ spans: line.spans.map((s) => ({ dim: true, ...s })) })), [{ text: "▌ ", role: "quote" }], [{ text: "▌ ", role: "quote" }]);
449
+ }
450
+ function renderList(token, width, ctx, depth) {
451
+ const out = [];
452
+ const start = typeof token.start === "number" && Number.isFinite(token.start) ? token.start : 1;
453
+ token.items.forEach((item, index) => {
454
+ const glyph = item.task
455
+ ? item.checked
456
+ ? "☑"
457
+ : "☐"
458
+ : token.ordered
459
+ ? `${start + index}.`
460
+ : BULLETS[depth % BULLETS.length];
461
+ const lead = stringWidth(glyph) + 1;
462
+ const inner = Math.max(8, width - lead);
463
+ // The checkbox is a marker, not content: it is already in the glyph.
464
+ const children = item.tokens.filter((t) => t.type !== "checkbox");
465
+ const style = item.task && item.checked ? { dim: true } : {};
466
+ const lines = trim(renderTokens(children, inner, ctx, depth + 1, style, !token.loose));
467
+ out.push(...prefixed(lines.length > 0 ? lines : [blankLine()], [{ text: `${glyph} `, role: "bullet", bold: token.ordered }], [{ text: " ".repeat(lead) }]));
468
+ if (token.loose && index < token.items.length - 1)
469
+ out.push(blankLine());
470
+ });
471
+ return out;
472
+ }
473
+ function renderHeading(token, width, ctx) {
474
+ const role = token.depth === 1 ? "h1" : token.depth === 2 ? "h2" : "h3";
475
+ const prefix = token.depth >= 3 ? [{ text: "▸ ", role, dim: true }] : [];
476
+ const spans = [...prefix, ...inlineSpans(token.tokens, { role, bold: true }, ctx)];
477
+ const out = wrapBlock(spans, width);
478
+ const text = out.map(plain).join(" ");
479
+ if (token.depth === 1)
480
+ out.push(rule(width, "━", { role, dim: true }));
481
+ else if (token.depth === 2)
482
+ out.push(rule(Math.min(width, stringWidth(text) + 2), "─", { role, dim: true }));
483
+ return out;
484
+ }
371
485
  /**
372
- * Render markdown to styled lines already wrapped to `width`.
486
+ * A run of block tokens to lines, with one blank line between blocks.
373
487
  *
374
- * `width` is the pane's interior, so a resize re-renders rather than reflows —
375
- * cheap, and it keeps tables and code gutters honest.
488
+ * `depth` only selects the bullet glyph; indentation comes from the caller
489
+ * prefixing what it gets back, so a nested list, a code block and a table
490
+ * inside a list item all line up the same way.
376
491
  */
377
- export function renderMarkdown(source, width) {
378
- const w = Math.max(20, Math.floor(width));
379
- const src = source.replace(/\r\n?/g, "\n").replace(/<!--[\s\S]*?-->/g, "");
380
- const raw = src.split("\n");
492
+ function renderTokens(tokens, width, ctx, depth, base = {}, tight = false) {
381
493
  const out = [];
382
- const blank = () => {
383
- if (out.length > 0 && plain(out[out.length - 1]).trim() !== "")
384
- out.push({ spans: [{ text: "" }] });
385
- };
386
- let i = 0;
387
- // YAML front matter, shown rather than hidden: it is usually the metadata a
388
- // reader opened the file for.
389
- if (raw[0] === "---") {
390
- const end = raw.findIndex((l, n) => n > 0 && (l === "---" || l === "..."));
391
- if (end > 0) {
392
- for (const line of raw.slice(1, end)) {
393
- out.push({ spans: [{ text: "▏ ", role: "gutter" }, { text: line, role: "meta", dim: true }] });
394
- }
395
- out.push({ spans: [{ text: "" }] });
396
- i = end + 1;
397
- }
398
- }
399
- let paragraph = [];
400
- const flushParagraph = () => {
401
- if (paragraph.length === 0)
494
+ const w = Math.max(4, width);
495
+ const push = (lines) => {
496
+ if (lines.length === 0)
402
497
  return;
403
- const text = paragraph.join(" ").trim();
404
- paragraph = [];
405
- if (!text)
406
- return;
407
- const spans = parseInline(text);
408
- // A paragraph that was only an HTML wrapper leaves nothing to show.
409
- if (spans.every((span) => span.text.trim() === ""))
410
- return;
411
- out.push(...wrapSpans(spans, w));
498
+ if (out.length > 0 && !tight)
499
+ out.push(blankLine());
500
+ out.push(...lines);
412
501
  };
413
- const isBlockStart = (line) => line.trim() === "" ||
414
- HR.test(line) ||
415
- HEADING.test(line) ||
416
- FENCE.test(line) ||
417
- QUOTE.test(line) ||
418
- LIST.test(line) ||
419
- TABLE_ROW.test(line);
420
- for (; i < raw.length; i++) {
421
- const line = raw[i];
422
- if (line.trim() === "") {
423
- flushParagraph();
424
- blank();
425
- continue;
426
- }
427
- // Setext headings close the paragraph they underline.
428
- if (paragraph.length > 0 && (SETEXT_H1.test(line) || SETEXT_H2.test(line))) {
429
- const text = paragraph.join(" ").trim();
430
- paragraph = [];
431
- const h1 = SETEXT_H1.test(line);
432
- blank();
433
- out.push(...wrapSpans(parseInline(text, { role: h1 ? "h1" : "h2", bold: true }), w));
434
- out.push(rule(h1 ? w : Math.min(w, stringWidth(text)), h1 ? "━" : "─", { role: h1 ? "h1" : "h2", dim: true }));
435
- out.push({ spans: [{ text: "" }] });
436
- continue;
437
- }
438
- const fence = FENCE.exec(line);
439
- if (fence) {
440
- flushParagraph();
441
- const marker = fence[1][0];
442
- const lang = fence[2] ?? "";
443
- const body = [];
444
- i++;
445
- for (; i < raw.length; i++) {
446
- const l = raw[i];
447
- if (new RegExp(`^ {0,3}${marker === "`" ? "```" : "~~~"}+[ \t]*$`).test(l))
502
+ for (const token of tokens) {
503
+ switch (token.type) {
504
+ case "space":
505
+ case "def":
506
+ case "footnoteDef":
507
+ break;
508
+ case "heading":
509
+ push(renderHeading(token, w, ctx));
510
+ break;
511
+ case "paragraph": {
512
+ const spans = inlineSpans(token.tokens, base, ctx);
513
+ if (spans.every((s) => s.text.trim() === ""))
448
514
  break;
449
- body.push(l);
450
- }
451
- blank();
452
- const head = lang ? `┌─ ${lang} ` : "┌─ ";
453
- out.push({
454
- spans: [
455
- { text: "┌─ ", role: "gutter" },
456
- ...(lang ? [{ text: `${lang} `, role: "lang" }] : []),
457
- { text: "─".repeat(Math.max(0, w - stringWidth(head))), role: "gutter" },
458
- ],
459
- });
460
- for (const l of body) {
461
- const text = l.replace(/\t/g, " ");
462
- out.push({
463
- spans: [
464
- { text: "│ ", role: "gutter" },
465
- { text: stringWidth(text) > w - 2 ? text.slice(0, w - 3) + "…" : text, role: "fence" },
466
- ],
467
- });
515
+ push(wrapBlock(spans, w));
516
+ break;
468
517
  }
469
- out.push({ spans: [{ text: "└" + "─".repeat(Math.max(0, w - 1)), role: "gutter" }] });
470
- out.push({ spans: [{ text: "" }] });
471
- continue;
472
- }
473
- const heading = HEADING.exec(line);
474
- if (heading) {
475
- flushParagraph();
476
- const level = heading[1].length;
477
- const text = heading[2];
478
- const role = level === 1 ? "h1" : level === 2 ? "h2" : "h3";
479
- blank();
480
- const prefix = level >= 3 ? [{ text: "▸ ", role, dim: true }] : [];
481
- out.push(...wrapSpans([...prefix, ...parseInline(text, { role, bold: true })], w));
482
- if (level === 1)
483
- out.push(rule(w, "━", { role, dim: true }));
484
- else if (level === 2)
485
- out.push(rule(Math.min(w, stringWidth(text) + 2), "─", { role, dim: true }));
486
- out.push({ spans: [{ text: "" }] });
487
- continue;
488
- }
489
- if (HR.test(line)) {
490
- flushParagraph();
491
- blank();
492
- out.push(rule(w, "─", { role: "rule" }));
493
- out.push({ spans: [{ text: "" }] });
494
- continue;
495
- }
496
- if (QUOTE.test(line)) {
497
- flushParagraph();
498
- const body = [];
499
- for (; i < raw.length; i++) {
500
- const m = QUOTE.exec(raw[i]);
501
- if (m)
502
- body.push(m[1] ?? "");
503
- else if (raw[i].trim() !== "" && body.length > 0)
504
- body.push(raw[i]);
505
- else
518
+ case "text": {
519
+ const t = token;
520
+ const spans = t.tokens ? inlineSpans(t.tokens, base, ctx) : [{ ...base, text: decode(t.text) }];
521
+ if (spans.every((s) => s.text.trim() === ""))
506
522
  break;
523
+ push(wrapBlock(spans, w));
524
+ break;
507
525
  }
508
- i--;
509
- blank();
510
- for (const inner of renderMarkdown(body.join("\n"), w - 2)) {
511
- out.push({ spans: [{ text: "▌ ", role: "quote" }, ...inner.spans.map((s) => ({ dim: true, ...s }))] });
526
+ case "code": {
527
+ const code = token;
528
+ // Indented code has no info string and no fence to echo, so it keeps a
529
+ // bare gutter; only a real fence is framed.
530
+ push(renderCode(code.text, code.lang ?? "", w, code.codeBlockStyle !== "indented"));
531
+ break;
512
532
  }
513
- out.push({ spans: [{ text: "" }] });
514
- continue;
515
- }
516
- const list = LIST.exec(line);
517
- if (list) {
518
- flushParagraph();
519
- const lead = list[1].replace(/\t/g, " ");
520
- const depth = Math.min(3, Math.floor(lead.length / 2));
521
- const marker = list[2];
522
- let rest = list[4];
523
- // Lazy continuation: fold following plain lines into this item.
524
- const parts = [rest];
525
- while (i + 1 < raw.length && !isBlockStart(raw[i + 1])) {
526
- parts.push(raw[i + 1].trim());
527
- i++;
533
+ case "blockquote":
534
+ push(renderQuote(token, w, ctx));
535
+ break;
536
+ case "list":
537
+ push(renderList(token, w, ctx, depth));
538
+ break;
539
+ case "table":
540
+ push(renderTable(token, w, ctx));
541
+ break;
542
+ case "hr":
543
+ push([rule(w, "─", { role: "rule" })]);
544
+ break;
545
+ case "html": {
546
+ // A block of raw HTML: fold it to markdown and render what is left.
547
+ const cleaned = normalizeHtml(token.raw).trim();
548
+ if (!cleaned)
549
+ break;
550
+ push(trim(renderTokens(lex(cleaned, ctx.flavor), w, ctx, depth, base)));
551
+ break;
528
552
  }
529
- rest = parts.join(" ");
530
- const task = TASK.exec(rest);
531
- let checked = false;
532
- if (task) {
533
- checked = task[1].toLowerCase() === "x";
534
- rest = rest.slice(task[0].length);
553
+ default: {
554
+ const any = token;
555
+ if (any.text?.trim())
556
+ push(wrapBlock([{ ...base, text: decode(any.text) }], w));
557
+ break;
535
558
  }
536
- const ordered = /\d/.test(marker);
537
- const glyph = task ? (checked ? "☑" : "☐") : ordered ? marker : BULLETS[depth];
538
- const indent = depth * 2;
539
- const bullet = { text: `${glyph} `, role: "bullet", bold: ordered };
540
- const body = parseInline(rest, checked ? { dim: true } : {});
541
- const lines = wrapSpans([bullet, ...body], w, indent, indent + stringWidth(glyph) + 1);
542
- out.push(...lines);
543
- continue;
544
559
  }
545
- if (TABLE_ROW.test(line) && TABLE_RULE.test(raw[i + 1] ?? "")) {
546
- flushParagraph();
547
- const header = splitRow(line);
548
- const aligns = splitRow(raw[i + 1]).map((cell) => {
549
- const left = cell.startsWith(":");
550
- const right = cell.endsWith(":");
551
- return left && right ? "center" : right ? "right" : "left";
552
- });
553
- const rows = [header];
554
- i += 2;
555
- for (; i < raw.length && TABLE_ROW.test(raw[i]); i++)
556
- rows.push(splitRow(raw[i]));
557
- i--;
558
- blank();
559
- out.push(...renderTable(rows, aligns, w));
560
- out.push({ spans: [{ text: "" }] });
561
- continue;
560
+ }
561
+ return out;
562
+ }
563
+ /** The numbered notes, rendered under a rule at the foot of the document. */
564
+ function renderFootnotes(width, ctx) {
565
+ if (ctx.defs.size === 0)
566
+ return [];
567
+ // Referenced notes first, in the order they were cited; then any that were
568
+ // defined and never used, which is worth seeing rather than dropping.
569
+ const referenced = [...ctx.numbers.entries()]
570
+ .sort((a, b) => a[1] - b[1])
571
+ .filter(([label]) => ctx.defs.has(label));
572
+ const orphans = [...ctx.defs.keys()].filter((label) => !ctx.numbers.has(label));
573
+ const entries = [
574
+ ...referenced.map(([label, n]) => [label, `${n}.`]),
575
+ ...orphans.map((label) => [label, "•"]),
576
+ ];
577
+ if (entries.length === 0)
578
+ return [];
579
+ const out = [blankLine(), rule(width, "─", { role: "rule" }), blankLine()];
580
+ entries.forEach(([label, marker], index) => {
581
+ if (index > 0)
582
+ out.push(blankLine());
583
+ const lead = stringWidth(marker) + 1;
584
+ const lines = trim(renderTokens(ctx.defs.get(label), Math.max(8, width - lead), ctx, 0));
585
+ out.push(...prefixed(lines.length > 0 ? lines : [blankLine()], [{ text: `${marker} `, role: "footnote" }], [
586
+ { text: " ".repeat(lead) },
587
+ ]));
588
+ });
589
+ return out;
590
+ }
591
+ function collectDefs(tokens, ctx) {
592
+ for (const token of tokens) {
593
+ if (token.type === "footnoteDef") {
594
+ const def = token;
595
+ if (!ctx.defs.has(def.label))
596
+ ctx.defs.set(def.label, def.tokens);
562
597
  }
563
- // Indented code, but only where a fresh block can start.
564
- const indented = INDENTED_CODE.exec(line);
565
- if (indented && paragraph.length === 0) {
566
- const body = [];
567
- for (; i < raw.length; i++) {
568
- const l = raw[i];
569
- const m = INDENTED_CODE.exec(l);
570
- if (m)
571
- body.push(m[1]);
572
- else if (l.trim() === "")
573
- body.push("");
574
- else
575
- break;
576
- }
577
- i--;
578
- while (body.length > 0 && body[body.length - 1].trim() === "")
579
- body.pop();
580
- blank();
581
- for (const l of body) {
582
- out.push({
583
- spans: [
584
- { text: "│ ", role: "gutter" },
585
- { text: stringWidth(l) > w - 2 ? l.slice(0, w - 3) + "…" : l, role: "fence" },
586
- ],
587
- });
598
+ }
599
+ }
600
+ function context(options) {
601
+ return {
602
+ flavor: options.flavor ?? DEFAULT_FLAVOR,
603
+ spoilers: options.spoilers ?? false,
604
+ numbers: new Map(),
605
+ defs: new Map(),
606
+ };
607
+ }
608
+ /**
609
+ * Render markdown to styled lines already wrapped to `width`.
610
+ *
611
+ * `width` is the pane's interior, so a resize re-renders rather than reflows —
612
+ * cheap, and it keeps tables and code gutters honest.
613
+ */
614
+ export function renderMarkdown(source, width, options = {}) {
615
+ const w = Math.max(20, Math.floor(width));
616
+ const ctx = context(options);
617
+ const src = source.replace(/\r\n?/g, "\n").replace(/<!--[\s\S]*?-->/g, "");
618
+ const out = [];
619
+ let body = src;
620
+ // YAML front matter, shown rather than hidden: it is usually the metadata a
621
+ // reader opened the file for.
622
+ const lines = src.split("\n");
623
+ if (lines[0] === "---") {
624
+ const end = lines.findIndex((l, n) => n > 0 && (l === "---" || l === "..."));
625
+ if (end > 0) {
626
+ for (const line of lines.slice(1, end)) {
627
+ out.push({ spans: [{ text: "▏ ", role: "gutter" }, { text: line, role: "meta", dim: true }] });
588
628
  }
589
- out.push({ spans: [{ text: "" }] });
590
- continue;
629
+ out.push(blankLine());
630
+ body = lines.slice(end + 1).join("\n");
591
631
  }
592
- paragraph.push(line.trim());
593
632
  }
594
- flushParagraph();
633
+ const tokens = lex(body, ctx.flavor);
634
+ collectDefs(tokens, ctx);
635
+ const rendered = renderTokens(tokens, w, ctx, 0);
636
+ if (out.length > 0 && rendered.length > 0)
637
+ out.push(blankLine());
638
+ out.push(...rendered);
639
+ out.push(...renderFootnotes(w, ctx));
595
640
  while (out.length > 0 && plain(out[out.length - 1]).trim() === "")
596
641
  out.pop();
597
642
  return out.map((line) => ({ spans: mergeSpans(line.spans) }));