@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/README.md +100 -12
- package/dist/cli.d.ts +5 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +45 -5
- package/dist/cli.js.map +1 -1
- package/dist/editor.d.ts +77 -0
- package/dist/editor.d.ts.map +1 -0
- package/dist/editor.js +308 -0
- package/dist/editor.js.map +1 -0
- package/dist/emoji.d.ts +12 -0
- package/dist/emoji.d.ts.map +1 -0
- package/dist/emoji.js +73 -0
- package/dist/emoji.js.map +1 -0
- package/dist/flavors.d.ts +68 -0
- package/dist/flavors.d.ts.map +1 -0
- package/dist/flavors.js +223 -0
- package/dist/flavors.js.map +1 -0
- package/dist/index.d.ts +10 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -4
- package/dist/index.js.map +1 -1
- package/dist/markdown.d.ts +12 -3
- package/dist/markdown.d.ts.map +1 -1
- package/dist/markdown.js +424 -379
- package/dist/markdown.js.map +1 -1
- package/dist/print.d.ts +3 -3
- package/dist/print.d.ts.map +1 -1
- package/dist/print.js +8 -3
- package/dist/print.js.map +1 -1
- package/dist/roles.d.ts.map +1 -1
- package/dist/roles.js +17 -0
- package/dist/roles.js.map +1 -1
- package/dist/viewer.d.ts +5 -0
- package/dist/viewer.d.ts.map +1 -1
- package/dist/viewer.js +375 -60
- package/dist/viewer.js.map +1 -1
- package/package.json +9 -3
package/dist/markdown.js
CHANGED
|
@@ -1,29 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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(/[49];/g, "'")
|
|
136
90
|
.replace(/&/g, "&");
|
|
137
91
|
}
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
217
|
+
/** marked escapes for HTML output; a terminal wants the characters back. */
|
|
218
|
+
function decode(text) {
|
|
219
|
+
return text
|
|
220
|
+
.replace(/</g, "<")
|
|
221
|
+
.replace(/>/g, ">")
|
|
222
|
+
.replace(/"/g, '"')
|
|
223
|
+
.replace(/'/g, "'")
|
|
224
|
+
.replace(/&/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
|
-
|
|
287
|
-
function
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
309
|
-
|
|
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(
|
|
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
|
|
327
|
-
const
|
|
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
|
-
|
|
378
|
+
cells.forEach((row, r) => {
|
|
340
379
|
const spans = [];
|
|
341
|
-
|
|
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))
|
|
352
|
-
clipped.push(
|
|
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
|
-
*
|
|
486
|
+
* A run of block tokens to lines, with one blank line between blocks.
|
|
373
487
|
*
|
|
374
|
-
* `
|
|
375
|
-
*
|
|
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
|
-
|
|
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
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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(
|
|
590
|
-
|
|
629
|
+
out.push(blankLine());
|
|
630
|
+
body = lines.slice(end + 1).join("\n");
|
|
591
631
|
}
|
|
592
|
-
paragraph.push(line.trim());
|
|
593
632
|
}
|
|
594
|
-
|
|
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) }));
|