@profullstack/readm3 0.1.1 → 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,23 +1,57 @@
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;
29
+ function sameStyle(a, b) {
30
+ return (a.role === b.role &&
31
+ !a.bold === !b.bold &&
32
+ !a.italic === !b.italic &&
33
+ !a.underline === !b.underline &&
34
+ !a.dim === !b.dim &&
35
+ !a.strike === !b.strike);
36
+ }
37
+ /**
38
+ * Glue neighbouring spans that share a style back together.
39
+ *
40
+ * Wrapping splits a line into one span per word, which no consumer wants: it is
41
+ * an escape sequence per word when printed, and a layout child per word on
42
+ * screen.
43
+ */
44
+ export function mergeSpans(spans) {
45
+ const out = [];
46
+ for (const span of spans) {
47
+ const last = out[out.length - 1];
48
+ if (last && sameStyle(last, span))
49
+ last.text += span.text;
50
+ else
51
+ out.push({ ...span });
52
+ }
53
+ return out;
54
+ }
21
55
  /** The visible text of a line, with all styling dropped. Handy in tests. */
22
56
  export function plain(line) {
23
57
  return line.spans.map((s) => s.text).join("");
@@ -26,62 +60,7 @@ export function plain(line) {
26
60
  export function toText(lines) {
27
61
  return lines.map(plain);
28
62
  }
29
- // --------------------------------------------------------------- inline
30
- function matchLink(src) {
31
- if (src[0] !== "[")
32
- return null;
33
- let depth = 0;
34
- let i = 0;
35
- for (; i < src.length; i++) {
36
- const c = src[i];
37
- if (c === "\\") {
38
- i++;
39
- continue;
40
- }
41
- if (c === "[")
42
- depth++;
43
- else if (c === "]") {
44
- depth--;
45
- if (depth === 0)
46
- break;
47
- }
48
- }
49
- if (depth !== 0 || i >= src.length)
50
- return null;
51
- const text = src.slice(1, i);
52
- const rest = src.slice(i + 1);
53
- const inline = /^\([ \t]*(<[^>]*>|[^()\s]*(?:\([^()]*\)[^()\s]*)*)(?:[ \t]+["'(][^\n]*?["')])?[ \t]*\)/.exec(rest);
54
- if (inline) {
55
- const href = (inline[1] ?? "").replace(/^<|>$/g, "");
56
- return { text, href, length: i + 1 + inline[0].length };
57
- }
58
- const ref = /^\[([^\]]*)\]/.exec(rest);
59
- if (ref)
60
- return { text, href: ref[1] ?? "", length: i + 1 + ref[0].length };
61
- return null;
62
- }
63
- /**
64
- * Emphasis closers must not sit against a space, and `_` must not fire inside a
65
- * word — otherwise every snake_case identifier in a README turns italic.
66
- */
67
- function findClose(src, from, marker) {
68
- let j = from;
69
- while (j < src.length) {
70
- const k = src.indexOf(marker, j);
71
- if (k < 0)
72
- return -1;
73
- if (k === from || /\s/.test(src[k - 1] ?? "")) {
74
- j = k + marker.length;
75
- continue;
76
- }
77
- if (marker[0] === "_" && /[\w]/.test(src[k + marker.length] ?? "")) {
78
- j = k + marker.length;
79
- continue;
80
- }
81
- return k;
82
- }
83
- return -1;
84
- }
63
+ /* ------------------------------------------------------------------ html --- */
85
64
  /**
86
65
  * Fold the HTML that real READMEs are full of back into markdown.
87
66
  *
@@ -110,93 +89,154 @@ function stripTags(src) {
110
89
  .replace(/&#3[49];/g, "'")
111
90
  .replace(/&amp;/g, "&");
112
91
  }
113
- export function parseInline(src, base = {}) {
114
- 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;
115
118
  }
116
- 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
+ }
117
129
  const spans = [];
118
- let buf = "";
119
- const flush = () => {
120
- if (buf) {
121
- spans.push({ ...base, text: buf });
122
- buf = "";
123
- }
124
- };
125
- let i = 0;
126
- while (i < src.length) {
127
- const c = src[i];
128
- if (c === "\\" && /[\\`*_{}[\]()#+\-.!~>|]/.test(src[i + 1] ?? "")) {
129
- buf += src[i + 1];
130
- i += 2;
131
- continue;
132
- }
133
- if (c === "`") {
134
- const m = /^(`+)([\s\S]*?)\1(?!`)/.exec(src.slice(i));
135
- if (m?.[2] !== undefined) {
136
- flush();
137
- const inner = m[2].trim() === "" ? m[2] : m[2].replace(/^ (.*) $/, "$1");
138
- spans.push({ ...base, text: inner, role: "code" });
139
- i += m[0].length;
140
- 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;
141
142
  }
142
- }
143
- if (c === "!" && src[i + 1] === "[") {
144
- const m = matchLink(src.slice(i + 1));
145
- if (m) {
146
- flush();
147
- spans.push({ ...base, text: `🖼 ${m.text || m.href}`, role: "meta" });
148
- i += 1 + m.length;
149
- 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;
150
164
  }
151
- }
152
- if (c === "[") {
153
- const m = matchLink(src.slice(i));
154
- if (m) {
155
- flush();
156
- spans.push(...parseSpans(m.text, { ...base, role: "link", underline: true }));
157
- i += m.length;
158
- continue;
165
+ case "image": {
166
+ const img = token;
167
+ spans.push({ ...base, text: `\u{1f5bc} ${img.text || img.href}`, role: "meta" });
168
+ break;
159
169
  }
160
- }
161
- if (c === "<") {
162
- const m = /^<((?:https?|mailto):[^>\s]+)>/.exec(src.slice(i));
163
- if (m?.[1]) {
164
- flush();
165
- spans.push({ ...base, text: m[1], role: "url", underline: true });
166
- i += m[0].length;
167
- 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;
168
187
  }
169
- }
170
- if (c === "~" && src.startsWith("~~", i)) {
171
- const close = src.indexOf("~~", i + 2);
172
- if (close > i + 2) {
173
- flush();
174
- spans.push(...parseSpans(src.slice(i + 2, close), { ...base, dim: true }));
175
- i = close + 2;
176
- 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;
177
196
  }
178
- }
179
- if ((c === "*" || c === "_") && !(c === "_" && /[\w]/.test(src[i - 1] ?? ""))) {
180
- const strong = src.startsWith(c + c, i);
181
- const marker = strong ? c + c : c;
182
- if (!/\s/.test(src[i + marker.length] ?? " ")) {
183
- const close = findClose(src, i + marker.length, marker);
184
- if (close > 0) {
185
- flush();
186
- const inner = src.slice(i + marker.length, close);
187
- spans.push(...parseSpans(inner, strong ? { ...base, bold: true } : { ...base, italic: true }));
188
- i = close + marker.length;
189
- continue;
190
- }
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;
191
212
  }
192
213
  }
193
- buf += c;
194
- i++;
195
214
  }
196
- flush();
197
215
  return spans.length > 0 ? spans : [{ ...base, text: "" }];
198
216
  }
199
- // ---------------------------------------------------------------- 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 --- */
200
240
  /** Greedy word wrap that carries each word's styling with it. */
201
241
  export function wrapSpans(spans, width, first = 0, hanging = first) {
202
242
  const max = Math.max(1, width);
@@ -211,6 +251,12 @@ export function wrapSpans(spans, width, first = 0, hanging = first) {
211
251
  filled = false;
212
252
  };
213
253
  const flush = () => {
254
+ // A wrapped line should not carry the space that ended it.
255
+ while (cur.length > 0 && cur[cur.length - 1].text.trim() === "")
256
+ cur.pop();
257
+ const last = cur[cur.length - 1];
258
+ if (last)
259
+ last.text = last.text.replace(/[ \t]+$/, "");
214
260
  lines.push({ spans: cur.length > 0 ? cur : [{ text: "" }] });
215
261
  indent = hanging;
216
262
  begin();
@@ -252,30 +298,50 @@ export function wrapSpans(spans, width, first = 0, hanging = first) {
252
298
  flush();
253
299
  return lines;
254
300
  }
255
- // --------------------------------------------------------------- blocks
256
- function rule(width, char, style) {
257
- return { spans: [{ ...style, text: char.repeat(Math.max(1, width)) }] };
258
- }
259
- function splitRow(row) {
260
- const trimmed = row.trim().replace(/^\|/, "").replace(/\|$/, "");
261
- const cells = [];
262
- let buf = "";
263
- for (let i = 0; i < trimmed.length; i++) {
264
- const c = trimmed[i];
265
- if (c === "\\" && trimmed[i + 1] === "|") {
266
- buf += "|";
267
- i++;
268
- continue;
269
- }
270
- if (c === "|") {
271
- cells.push(buf.trim());
272
- 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);
273
309
  continue;
274
310
  }
275
- 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
+ });
276
317
  }
277
- cells.push(buf.trim());
278
- 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
+ }));
279
345
  }
280
346
  function pad(spans, target, align) {
281
347
  const used = spans.reduce((n, s) => n + stringWidth(s.text), 0);
@@ -290,10 +356,14 @@ function pad(spans, target, align) {
290
356
  }
291
357
  return [...spans, { text: " ".repeat(gap) }];
292
358
  }
293
- 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];
294
363
  const columns = Math.max(...rows.map((r) => r.length));
295
- const cells = rows.map((row) => Array.from({ length: columns }, (_, i) => parseInline(row[i] ?? "")));
296
- 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))));
297
367
  // Shrink the widest columns first until the table fits the pane.
298
368
  const gap = 2;
299
369
  let total = widths.reduce((a, b) => a + b, 0) + gap * (columns - 1);
@@ -305,9 +375,9 @@ function renderTable(rows, aligns, width) {
305
375
  total--;
306
376
  }
307
377
  const out = [];
308
- rows.forEach((_, r) => {
378
+ cells.forEach((row, r) => {
309
379
  const spans = [];
310
- cells[r].forEach((cell, c) => {
380
+ row.forEach((cell, c) => {
311
381
  if (c > 0)
312
382
  spans.push({ text: " ".repeat(gap) });
313
383
  const w = widths[c];
@@ -317,10 +387,9 @@ function renderTable(rows, aligns, width) {
317
387
  if (used >= w)
318
388
  break;
319
389
  const room = w - used;
320
- const text = stringWidth(span.text) > room ? span.text.slice(0, Math.max(0, room - 1)) + "…" : span.text;
321
- 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 });
322
392
  used += stringWidth(text);
323
- clipped[clipped.length - 1] = { ...clipped[clipped.length - 1], text };
324
393
  }
325
394
  spans.push(...pad(clipped, w, aligns[c] ?? "left"));
326
395
  });
@@ -337,232 +406,239 @@ function renderTable(rows, aligns, width) {
337
406
  });
338
407
  return out;
339
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
+ }
340
485
  /**
341
- * Render markdown to styled lines already wrapped to `width`.
486
+ * A run of block tokens to lines, with one blank line between blocks.
342
487
  *
343
- * `width` is the pane's interior, so a resize re-renders rather than reflows —
344
- * 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.
345
491
  */
346
- export function renderMarkdown(source, width) {
347
- const w = Math.max(20, Math.floor(width));
348
- const src = source.replace(/\r\n?/g, "\n").replace(/<!--[\s\S]*?-->/g, "");
349
- const raw = src.split("\n");
492
+ function renderTokens(tokens, width, ctx, depth, base = {}, tight = false) {
350
493
  const out = [];
351
- const blank = () => {
352
- if (out.length > 0 && plain(out[out.length - 1]).trim() !== "")
353
- out.push({ spans: [{ text: "" }] });
354
- };
355
- let i = 0;
356
- // YAML front matter, shown rather than hidden: it is usually the metadata a
357
- // reader opened the file for.
358
- if (raw[0] === "---") {
359
- const end = raw.findIndex((l, n) => n > 0 && (l === "---" || l === "..."));
360
- if (end > 0) {
361
- for (const line of raw.slice(1, end)) {
362
- out.push({ spans: [{ text: "▏ ", role: "gutter" }, { text: line, role: "meta", dim: true }] });
363
- }
364
- out.push({ spans: [{ text: "" }] });
365
- i = end + 1;
366
- }
367
- }
368
- let paragraph = [];
369
- const flushParagraph = () => {
370
- if (paragraph.length === 0)
494
+ const w = Math.max(4, width);
495
+ const push = (lines) => {
496
+ if (lines.length === 0)
371
497
  return;
372
- const text = paragraph.join(" ").trim();
373
- paragraph = [];
374
- if (!text)
375
- return;
376
- const spans = parseInline(text);
377
- // A paragraph that was only an HTML wrapper leaves nothing to show.
378
- if (spans.every((span) => span.text.trim() === ""))
379
- return;
380
- out.push(...wrapSpans(spans, w));
498
+ if (out.length > 0 && !tight)
499
+ out.push(blankLine());
500
+ out.push(...lines);
381
501
  };
382
- const isBlockStart = (line) => line.trim() === "" ||
383
- HR.test(line) ||
384
- HEADING.test(line) ||
385
- FENCE.test(line) ||
386
- QUOTE.test(line) ||
387
- LIST.test(line) ||
388
- TABLE_ROW.test(line);
389
- for (; i < raw.length; i++) {
390
- const line = raw[i];
391
- if (line.trim() === "") {
392
- flushParagraph();
393
- blank();
394
- continue;
395
- }
396
- // Setext headings close the paragraph they underline.
397
- if (paragraph.length > 0 && (SETEXT_H1.test(line) || SETEXT_H2.test(line))) {
398
- const text = paragraph.join(" ").trim();
399
- paragraph = [];
400
- const h1 = SETEXT_H1.test(line);
401
- blank();
402
- out.push(...wrapSpans(parseInline(text, { role: h1 ? "h1" : "h2", bold: true }), w));
403
- out.push(rule(h1 ? w : Math.min(w, stringWidth(text)), h1 ? "━" : "─", { role: h1 ? "h1" : "h2", dim: true }));
404
- out.push({ spans: [{ text: "" }] });
405
- continue;
406
- }
407
- const fence = FENCE.exec(line);
408
- if (fence) {
409
- flushParagraph();
410
- const marker = fence[1][0];
411
- const lang = fence[2] ?? "";
412
- const body = [];
413
- i++;
414
- for (; i < raw.length; i++) {
415
- const l = raw[i];
416
- 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() === ""))
417
514
  break;
418
- body.push(l);
419
- }
420
- blank();
421
- const head = lang ? `┌─ ${lang} ` : "┌─ ";
422
- out.push({
423
- spans: [
424
- { text: "┌─ ", role: "gutter" },
425
- ...(lang ? [{ text: `${lang} `, role: "lang" }] : []),
426
- { text: "─".repeat(Math.max(0, w - stringWidth(head))), role: "gutter" },
427
- ],
428
- });
429
- for (const l of body) {
430
- const text = l.replace(/\t/g, " ");
431
- out.push({
432
- spans: [
433
- { text: "│ ", role: "gutter" },
434
- { text: stringWidth(text) > w - 2 ? text.slice(0, w - 3) + "…" : text, role: "fence" },
435
- ],
436
- });
515
+ push(wrapBlock(spans, w));
516
+ break;
437
517
  }
438
- out.push({ spans: [{ text: "└" + "─".repeat(Math.max(0, w - 1)), role: "gutter" }] });
439
- out.push({ spans: [{ text: "" }] });
440
- continue;
441
- }
442
- const heading = HEADING.exec(line);
443
- if (heading) {
444
- flushParagraph();
445
- const level = heading[1].length;
446
- const text = heading[2];
447
- const role = level === 1 ? "h1" : level === 2 ? "h2" : "h3";
448
- blank();
449
- const prefix = level >= 3 ? [{ text: "▸ ", role, dim: true }] : [];
450
- out.push(...wrapSpans([...prefix, ...parseInline(text, { role, bold: true })], w));
451
- if (level === 1)
452
- out.push(rule(w, "━", { role, dim: true }));
453
- else if (level === 2)
454
- out.push(rule(Math.min(w, stringWidth(text) + 2), "─", { role, dim: true }));
455
- out.push({ spans: [{ text: "" }] });
456
- continue;
457
- }
458
- if (HR.test(line)) {
459
- flushParagraph();
460
- blank();
461
- out.push(rule(w, "─", { role: "rule" }));
462
- out.push({ spans: [{ text: "" }] });
463
- continue;
464
- }
465
- if (QUOTE.test(line)) {
466
- flushParagraph();
467
- const body = [];
468
- for (; i < raw.length; i++) {
469
- const m = QUOTE.exec(raw[i]);
470
- if (m)
471
- body.push(m[1] ?? "");
472
- else if (raw[i].trim() !== "" && body.length > 0)
473
- body.push(raw[i]);
474
- 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() === ""))
475
522
  break;
523
+ push(wrapBlock(spans, w));
524
+ break;
476
525
  }
477
- i--;
478
- blank();
479
- for (const inner of renderMarkdown(body.join("\n"), w - 2)) {
480
- 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;
481
532
  }
482
- out.push({ spans: [{ text: "" }] });
483
- continue;
484
- }
485
- const list = LIST.exec(line);
486
- if (list) {
487
- flushParagraph();
488
- const lead = list[1].replace(/\t/g, " ");
489
- const depth = Math.min(3, Math.floor(lead.length / 2));
490
- const marker = list[2];
491
- let rest = list[4];
492
- // Lazy continuation: fold following plain lines into this item.
493
- const parts = [rest];
494
- while (i + 1 < raw.length && !isBlockStart(raw[i + 1])) {
495
- parts.push(raw[i + 1].trim());
496
- 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;
497
552
  }
498
- rest = parts.join(" ");
499
- const task = TASK.exec(rest);
500
- let checked = false;
501
- if (task) {
502
- checked = task[1].toLowerCase() === "x";
503
- 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;
504
558
  }
505
- const ordered = /\d/.test(marker);
506
- const glyph = task ? (checked ? "☑" : "☐") : ordered ? marker : BULLETS[depth];
507
- const indent = depth * 2;
508
- const bullet = { text: `${glyph} `, role: "bullet", bold: ordered };
509
- const body = parseInline(rest, checked ? { dim: true } : {});
510
- const lines = wrapSpans([bullet, ...body], w, indent, indent + stringWidth(glyph) + 1);
511
- out.push(...lines);
512
- continue;
513
559
  }
514
- if (TABLE_ROW.test(line) && TABLE_RULE.test(raw[i + 1] ?? "")) {
515
- flushParagraph();
516
- const header = splitRow(line);
517
- const aligns = splitRow(raw[i + 1]).map((cell) => {
518
- const left = cell.startsWith(":");
519
- const right = cell.endsWith(":");
520
- return left && right ? "center" : right ? "right" : "left";
521
- });
522
- const rows = [header];
523
- i += 2;
524
- for (; i < raw.length && TABLE_ROW.test(raw[i]); i++)
525
- rows.push(splitRow(raw[i]));
526
- i--;
527
- blank();
528
- out.push(...renderTable(rows, aligns, w));
529
- out.push({ spans: [{ text: "" }] });
530
- 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);
531
597
  }
532
- // Indented code, but only where a fresh block can start.
533
- const indented = INDENTED_CODE.exec(line);
534
- if (indented && paragraph.length === 0) {
535
- const body = [];
536
- for (; i < raw.length; i++) {
537
- const l = raw[i];
538
- const m = INDENTED_CODE.exec(l);
539
- if (m)
540
- body.push(m[1]);
541
- else if (l.trim() === "")
542
- body.push("");
543
- else
544
- break;
545
- }
546
- i--;
547
- while (body.length > 0 && body[body.length - 1].trim() === "")
548
- body.pop();
549
- blank();
550
- for (const l of body) {
551
- out.push({
552
- spans: [
553
- { text: "│ ", role: "gutter" },
554
- { text: stringWidth(l) > w - 2 ? l.slice(0, w - 3) + "…" : l, role: "fence" },
555
- ],
556
- });
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 }] });
557
628
  }
558
- out.push({ spans: [{ text: "" }] });
559
- continue;
629
+ out.push(blankLine());
630
+ body = lines.slice(end + 1).join("\n");
560
631
  }
561
- paragraph.push(line.trim());
562
632
  }
563
- 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));
564
640
  while (out.length > 0 && plain(out[out.length - 1]).trim() === "")
565
641
  out.pop();
566
- return out;
642
+ return out.map((line) => ({ spans: mergeSpans(line.spans) }));
567
643
  }
568
644
  //# sourceMappingURL=markdown.js.map