@promptctl/rich-js 0.18.1 → 0.19.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 +9 -7
- package/dist/core/box.d.ts +3 -1
- package/dist/core/box.js +6 -13
- package/dist/core/cells.d.ts +61 -28
- package/dist/core/cells.js +171 -124
- package/dist/core/clock.d.ts +48 -0
- package/dist/core/clock.js +42 -0
- package/dist/core/color.d.ts +98 -18
- package/dist/core/color.js +331 -144
- package/dist/core/console.d.ts +38 -7
- package/dist/core/console.js +159 -90
- package/dist/core/easing.d.ts +86 -0
- package/dist/core/easing.js +171 -0
- package/dist/core/env.d.ts +16 -0
- package/dist/core/env.js +16 -0
- package/dist/core/export-html.js +1 -4
- package/dist/core/export-lines.d.ts +22 -13
- package/dist/core/export-lines.js +37 -19
- package/dist/core/export-svg.d.ts +49 -0
- package/dist/core/export-svg.js +213 -0
- package/dist/core/fnv1a.d.ts +10 -0
- package/dist/core/fnv1a.js +17 -0
- package/dist/core/highlighter.d.ts +2 -2
- package/dist/core/highlighter.js +113 -47
- package/dist/core/json.js +1 -1
- package/dist/core/markup.d.ts +28 -7
- package/dist/core/markup.js +235 -312
- package/dist/core/measure.d.ts +3 -3
- package/dist/core/measure.js +12 -7
- package/dist/core/memo.d.ts +19 -0
- package/dist/core/memo.js +34 -0
- package/dist/core/osc8.d.ts +6 -6
- package/dist/core/osc8.js +9 -21
- package/dist/core/paint.d.ts +107 -0
- package/dist/core/paint.js +150 -0
- package/dist/core/place.d.ts +1 -1
- package/dist/core/place.js +4 -6
- package/dist/core/pretty.d.ts +105 -2
- package/dist/core/pretty.js +527 -97
- package/dist/core/protocol.d.ts +41 -3
- package/dist/core/protocol.js +20 -0
- package/dist/core/render.d.ts +33 -30
- package/dist/core/render.js +43 -83
- package/dist/core/segment.d.ts +6 -5
- package/dist/core/segment.js +11 -15
- package/dist/core/strip.d.ts +13 -2
- package/dist/core/strip.js +44 -23
- package/dist/core/style.d.ts +27 -7
- package/dist/core/style.js +97 -34
- package/dist/core/text.d.ts +43 -16
- package/dist/core/text.js +180 -59
- package/dist/core/wrap.d.ts +5 -0
- package/dist/core/wrap.js +16 -3
- package/dist/host/app.d.ts +31 -19
- package/dist/host/app.js +90 -60
- package/dist/host/index.d.ts +2 -1
- package/dist/host/terminal-host.d.ts +3 -2
- package/dist/index.d.ts +17 -10
- package/dist/index.js +11 -4
- package/dist/node/prompt.d.ts +13 -0
- package/dist/node/prompt.js +30 -9
- package/dist/node/save.d.ts +4 -3
- package/dist/node/save.js +6 -3
- package/dist/node/terminal-host.d.ts +3 -2
- package/dist/node/traceback.d.ts +2 -1
- package/dist/node/traceback.js +3 -39
- package/dist/renderables/align.js +2 -9
- package/dist/renderables/columns.d.ts +45 -29
- package/dist/renderables/columns.js +142 -103
- package/dist/renderables/constrain.js +6 -12
- package/dist/renderables/effect.d.ts +98 -0
- package/dist/renderables/effect.js +160 -0
- package/dist/renderables/embed.d.ts +87 -30
- package/dist/renderables/embed.js +134 -45
- package/dist/renderables/group.d.ts +6 -3
- package/dist/renderables/group.js +5 -0
- package/dist/renderables/layout.d.ts +6 -23
- package/dist/renderables/layout.js +24 -113
- package/dist/renderables/live.d.ts +40 -16
- package/dist/renderables/live.js +96 -83
- package/dist/renderables/markdown.d.ts +16 -2
- package/dist/renderables/markdown.js +521 -118
- package/dist/renderables/padding.d.ts +22 -1
- package/dist/renderables/padding.js +30 -24
- package/dist/renderables/panel.d.ts +31 -33
- package/dist/renderables/panel.js +137 -141
- package/dist/renderables/progress.d.ts +104 -16
- package/dist/renderables/progress.js +205 -81
- package/dist/renderables/progressBar.d.ts +2 -2
- package/dist/renderables/progressBar.js +30 -19
- package/dist/renderables/prompt.d.ts +37 -15
- package/dist/renderables/prompt.js +125 -85
- package/dist/renderables/ratio.d.ts +123 -0
- package/dist/renderables/ratio.js +213 -0
- package/dist/renderables/rule.d.ts +1 -0
- package/dist/renderables/rule.js +44 -48
- package/dist/renderables/spinner.d.ts +28 -2
- package/dist/renderables/spinner.js +52 -19
- package/dist/renderables/status.d.ts +13 -7
- package/dist/renderables/status.js +15 -29
- package/dist/renderables/syntax.d.ts +37 -7
- package/dist/renderables/syntax.js +134 -83
- package/dist/renderables/table.d.ts +58 -20
- package/dist/renderables/table.js +370 -192
- package/dist/renderables/traceback.d.ts +5 -4
- package/dist/renderables/traceback.js +239 -68
- package/dist/renderables/tree.d.ts +10 -2
- package/dist/renderables/tree.js +61 -34
- package/dist/renderables/viewport.js +2 -4
- package/dist/template-bindings/color-funcs.d.ts +31 -9
- package/dist/template-bindings/color-funcs.js +40 -14
- package/dist/template-bindings/helpers.d.ts +4 -2
- package/dist/template-bindings/helpers.js +5 -20
- package/dist/template-bindings/index.d.ts +29 -17
- package/dist/template-bindings/index.js +36 -20
- package/dist/template-bindings/palette-funcs.d.ts +3 -1
- package/dist/template-bindings/palette-funcs.js +11 -7
- package/dist/template-bindings/style-funcs.js +4 -2
- package/dist/themes/buildPalette.d.ts +24 -3
- package/dist/themes/buildPalette.js +52 -8
- package/dist/themes/colorMath.d.ts +29 -17
- package/dist/themes/colorMath.js +74 -48
- package/dist/themes/colorRef.d.ts +2 -17
- package/dist/themes/colorRef.js +13 -31
- package/dist/themes/palette.d.ts +24 -0
- package/dist/themes/palette.js +46 -3
- package/dist/themes/ramp.d.ts +21 -34
- package/dist/themes/ramp.js +23 -42
- package/dist/themes/registry.js +39 -28
- package/dist/widgets/button.d.ts +2 -6
- package/dist/widgets/button.js +9 -40
- package/dist/widgets/checkbox.d.ts +2 -5
- package/dist/widgets/checkbox.js +9 -31
- package/dist/widgets/dropdown.d.ts +2 -5
- package/dist/widgets/dropdown.js +14 -48
- package/dist/widgets/ink.d.ts +22 -0
- package/dist/widgets/ink.js +31 -0
- package/dist/widgets/slider.d.ts +2 -5
- package/dist/widgets/slider.js +11 -34
- package/dist/widgets/text-input.d.ts +19 -10
- package/dist/widgets/text-input.js +156 -147
- package/dist/widgets/themed-widget.d.ts +20 -0
- package/dist/widgets/themed-widget.js +89 -0
- package/dist/widgets/toggle.d.ts +2 -5
- package/dist/widgets/toggle.js +8 -31
- package/dist/widgets/widget-app.d.ts +2 -0
- package/dist/widgets/widget-app.js +13 -6
- package/dist/widgets/widget-base.d.ts +2 -0
- package/dist/widgets/widget-base.js +9 -2
- package/package.json +11 -1
|
@@ -1,182 +1,585 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Markdown — renders Markdown content to the terminal.
|
|
3
3
|
* Uses built-in parsing (no external dependency).
|
|
4
|
+
*
|
|
5
|
+
* Block structure is read as CommonMark reads it: a blockquote and a list
|
|
6
|
+
* item are both containers, their lines tokenized as Markdown of their own
|
|
7
|
+
* once the container's marker or indentation is cut off, and drawn beside a
|
|
8
|
+
* gutter — the quote's bar, the item's marker then its hang. Where CommonMark
|
|
9
|
+
* leaves the drawing open, Rich's is followed.
|
|
4
10
|
*/
|
|
11
|
+
import { cellLen, expandTabs, parseTabSize } from "../core/cells.js";
|
|
5
12
|
import { Segment } from "../core/segment.js";
|
|
6
13
|
import { Style } from "../core/style.js";
|
|
7
|
-
import { RichText } from "../core/text.js";
|
|
14
|
+
import { RichText, resolveStyle } from "../core/text.js";
|
|
8
15
|
import { Rule } from "./rule.js";
|
|
9
|
-
import { drawable, getStyle, stackedHeight } from "../core/protocol.js";
|
|
10
|
-
|
|
11
|
-
|
|
16
|
+
import { drawable, getStyle, groundedStyle, stackedHeight } from "../core/protocol.js";
|
|
17
|
+
const HEADING = /^(#{1,6})\s+(.+)$/;
|
|
18
|
+
/** A thematic break: three or more of one of `*`, `-` or `_`, spaces and tabs between them allowed. */
|
|
19
|
+
const RULE = /^(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/;
|
|
20
|
+
const SETEXT = /^(=+|-+)[ \t]*$/;
|
|
21
|
+
const FENCE = /^```(\w*)/;
|
|
22
|
+
const QUOTE = /^>/;
|
|
23
|
+
const ITEM_MARKER = /^(?:[*+-]|(\d{1,9})\.)/;
|
|
24
|
+
const LEAD = /^[ \t]*/;
|
|
25
|
+
const HARD_BREAK = /(?: {2,}|\\)$/;
|
|
26
|
+
const BLOCK_STARTS = [HEADING, FENCE, QUOTE, RULE];
|
|
27
|
+
/** Indentation that makes a line indented code when it opens a block, as CommonMark counts it. */
|
|
28
|
+
const CODE_INDENT = 4;
|
|
29
|
+
/** The tab stop code is drawn with, as Rich's `Syntax` draws it. */
|
|
30
|
+
const CODE_TAB_SIZE = parseTabSize(8);
|
|
31
|
+
const BLANK_LINE = { text: "", margin: 0, lazy: false };
|
|
32
|
+
/** The column `ws` ends at when it starts at column `from`, a tab reaching the next stop of 4 as CommonMark counts it. */
|
|
33
|
+
function advance(from, ws) {
|
|
34
|
+
return [...ws].reduce((col, ch) => (ch === "\t" ? col + 4 - (col % 4) : col + 1), from);
|
|
35
|
+
}
|
|
36
|
+
/** The column `line`'s leading whitespace ends at. */
|
|
37
|
+
function leadEnd(line) {
|
|
38
|
+
return advance(0, LEAD.exec(line.text)[0]);
|
|
39
|
+
}
|
|
40
|
+
/** How far `line` is indented inside its container. */
|
|
41
|
+
function indentOf(line) {
|
|
42
|
+
return Math.max(0, leadEnd(line) - line.margin);
|
|
43
|
+
}
|
|
44
|
+
/** `line` from its first character that is not leading whitespace. */
|
|
45
|
+
function body(line) {
|
|
46
|
+
return line.text.slice(LEAD.exec(line.text)[0].length);
|
|
47
|
+
}
|
|
48
|
+
function isBlank(line) {
|
|
49
|
+
return line.text.trim() === "";
|
|
50
|
+
}
|
|
51
|
+
/** Whether `line` sits less than a code indent into its container and its text matches `re`. */
|
|
52
|
+
function startsWith(line, re) {
|
|
53
|
+
return indentOf(line) < CODE_INDENT && re.test(body(line));
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* `line` from `n` columns into its container on: a block's own indentation
|
|
57
|
+
* cut off, the rest as written. A tab straddling the cut leaves the columns
|
|
58
|
+
* past it as spaces.
|
|
59
|
+
*/
|
|
60
|
+
function dropColumns(line, n) {
|
|
61
|
+
const to = line.margin + n;
|
|
62
|
+
let col = 0;
|
|
63
|
+
let i = 0;
|
|
64
|
+
for (; i < line.text.length && col < to && (line.text[i] === " " || line.text[i] === "\t"); i++) {
|
|
65
|
+
const next = advance(col, line.text[i]);
|
|
66
|
+
if (next > to)
|
|
67
|
+
return " ".repeat(next - to) + line.text.slice(i + 1);
|
|
68
|
+
col = next;
|
|
69
|
+
}
|
|
70
|
+
return line.text.slice(i);
|
|
71
|
+
}
|
|
72
|
+
function itemStart(line) {
|
|
73
|
+
const text = body(line);
|
|
74
|
+
const m = ITEM_MARKER.exec(text);
|
|
75
|
+
if (!m || indentOf(line) >= CODE_INDENT || RULE.test(text))
|
|
76
|
+
return undefined;
|
|
77
|
+
const after = text.slice(m[0].length);
|
|
78
|
+
const spacing = LEAD.exec(after)[0];
|
|
79
|
+
const rest = after.slice(spacing.length);
|
|
80
|
+
if (spacing === "" && rest !== "")
|
|
81
|
+
return undefined;
|
|
82
|
+
const markerEnd = leadEnd(line) + m[0].length;
|
|
83
|
+
const spaced = advance(markerEnd, spacing) - markerEnd;
|
|
84
|
+
const content = rest === "" || spaced > CODE_INDENT ? markerEnd + 1 : markerEnd + spaced;
|
|
85
|
+
const lead = line.text.slice(0, line.text.length - text.length);
|
|
86
|
+
return {
|
|
87
|
+
bullet: m[1] === undefined ? m[0] : undefined,
|
|
88
|
+
start: m[1] === undefined ? 0 : parseInt(m[1], 10),
|
|
89
|
+
content,
|
|
90
|
+
first: rest === "" ? undefined : { text: lead + " ".repeat(m[0].length) + after, margin: content, lazy: false },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/** Whether two item starts belong to one list: the same bullet character, or both numbered. */
|
|
94
|
+
function sameList(a, b) {
|
|
95
|
+
return a.bullet === b.bullet;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* A `>` line as a line of its quote: the marker blanked, and the quote's
|
|
99
|
+
* content starting past it and the one column of space that may follow it —
|
|
100
|
+
* one column of a tab, when a tab follows it.
|
|
101
|
+
*/
|
|
102
|
+
function quoted(line) {
|
|
103
|
+
const at = line.text.length - body(line).length;
|
|
104
|
+
const spaced = /[ \t]/.test(line.text[at + 1] ?? "");
|
|
105
|
+
return { text: `${line.text.slice(0, at)} ${line.text.slice(at + 1)}`, margin: leadEnd(line) + 1 + (spaced ? 1 : 0), lazy: false };
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Whether `line` opens a block of its own — a heading, a fence, a quote, a
|
|
109
|
+
* rule or a list item. A line that does not is text, and under an open
|
|
110
|
+
* paragraph it continues that paragraph.
|
|
111
|
+
*/
|
|
112
|
+
function opensBlock(line) {
|
|
113
|
+
return BLOCK_STARTS.some((re) => startsWith(line, re)) || itemStart(line) !== undefined;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Whether `line` ends a paragraph: `opensBlock`, read as CommonMark reads it
|
|
117
|
+
* under a paragraph. Indented code cannot interrupt one; a list item does only
|
|
118
|
+
* when it has text and, numbered, counts from 1, so prose wrapped onto "2024.
|
|
119
|
+
* That year" stays prose; and a line of `=` or `-` is the paragraph's heading
|
|
120
|
+
* underline.
|
|
121
|
+
*/
|
|
122
|
+
function endsParagraph(line) {
|
|
123
|
+
if (line.lazy)
|
|
124
|
+
return false;
|
|
125
|
+
if (underline(line))
|
|
126
|
+
return true;
|
|
127
|
+
const item = itemStart(line);
|
|
128
|
+
return item ? item.first !== undefined && (item.bullet !== undefined || item.start === 1) : opensBlock(line);
|
|
129
|
+
}
|
|
130
|
+
/** The setext underline `line` is — a line of `=` or `-` under a paragraph — if it is one. A lazy line never is. */
|
|
131
|
+
function underline(line) {
|
|
132
|
+
return line && !line.lazy && indentOf(line) < CODE_INDENT ? SETEXT.exec(body(line)) : null;
|
|
133
|
+
}
|
|
134
|
+
/** Whether the last block of `tokens`, followed into the containers it closes, is a paragraph still open. */
|
|
135
|
+
function endsInParagraph(tokens) {
|
|
136
|
+
const last = tokens[tokens.length - 1];
|
|
137
|
+
switch (last?.type) {
|
|
138
|
+
case "paragraph":
|
|
139
|
+
return true;
|
|
140
|
+
case "list_item":
|
|
141
|
+
case "blockquote":
|
|
142
|
+
return endsInParagraph(last.children);
|
|
143
|
+
default:
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Whether `line`, short of a container's marker or indentation, still
|
|
149
|
+
* belongs to it as a lazy continuation: CommonMark's paragraph continuation
|
|
150
|
+
* text, which continues the paragraph open at the end of the container's
|
|
151
|
+
* `inner` lines and opens no block of its own. Outside the container a line
|
|
152
|
+
* is read without the paragraph under it, so any list item — whatever it
|
|
153
|
+
* counts from — ends the container rather than continuing it. A line after a
|
|
154
|
+
* lazy continuation finds that paragraph still open, so `afterLazy` answers
|
|
155
|
+
* without reading `inner` again: a container's lazy lines cost one read of
|
|
156
|
+
* it, not one each.
|
|
157
|
+
*/
|
|
158
|
+
function continuesLazily(inner, line, afterLazy) {
|
|
159
|
+
return !isBlank(line) && !opensBlock(line) && (afterLazy || endsInParagraph(tokenize(inner)));
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The text of the paragraph begun at `start`, every line after it up to a
|
|
163
|
+
* blank or a line that ends a paragraph, and the index of the line that ended
|
|
164
|
+
* it. Soft-broken lines are joined by one space, as a Markdown renderer
|
|
165
|
+
* reflows them, with their own indentation dropped; a line ending in two
|
|
166
|
+
* spaces or a backslash breaks hard, and its break is kept.
|
|
167
|
+
*/
|
|
168
|
+
function continuation(lines, start) {
|
|
169
|
+
const parts = [lines[start].text];
|
|
170
|
+
let i = start + 1;
|
|
171
|
+
for (; i < lines.length && !isBlank(lines[i]) && !endsParagraph(lines[i]); i++)
|
|
172
|
+
parts.push(lines[i].text);
|
|
173
|
+
const text = parts
|
|
174
|
+
.map((part, n) => {
|
|
175
|
+
const words = (n === parts.length - 1 ? part : part.replace(HARD_BREAK, "")).trim();
|
|
176
|
+
return n === 0 ? words : (HARD_BREAK.test(parts[n - 1]) ? "\n" : " ") + words;
|
|
177
|
+
})
|
|
178
|
+
.join("");
|
|
179
|
+
return { text, next: i };
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The lines of the list item opened at `start` — every line after it that is
|
|
183
|
+
* blank, indented to the content column, or a lazy continuation — and the
|
|
184
|
+
* index of the line that ended it. Blank lines trailing the item are left to
|
|
185
|
+
* whatever follows it, and an item that opens with a blank line ends at the
|
|
186
|
+
* next one: it is empty.
|
|
187
|
+
*/
|
|
188
|
+
function itemLines(item, lines, start) {
|
|
189
|
+
const inner = item.first === undefined ? [] : [item.first];
|
|
190
|
+
let lazy = false;
|
|
191
|
+
let i = start + 1;
|
|
192
|
+
for (; i < lines.length; i++) {
|
|
193
|
+
const line = lines[i];
|
|
194
|
+
const blank = isBlank(line);
|
|
195
|
+
if (blank && inner.length === 0)
|
|
196
|
+
break;
|
|
197
|
+
const held = blank || leadEnd(line) >= item.content;
|
|
198
|
+
lazy = !held && continuesLazily(inner, line, lazy);
|
|
199
|
+
if (!held && !lazy)
|
|
200
|
+
break;
|
|
201
|
+
inner.push(held ? { text: line.text, margin: item.content, lazy: false } : { ...line, lazy: true });
|
|
202
|
+
}
|
|
203
|
+
for (; inner.length > 0 && isBlank(inner[inner.length - 1]); i--)
|
|
204
|
+
inner.pop();
|
|
205
|
+
return { inner, next: i };
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The list opened at `start`: each item as a token holding its blocks, and
|
|
209
|
+
* between items the blank lines that separated them in the source. Numbers
|
|
210
|
+
* count on from the first item's, as Rich draws them, right-aligned to the
|
|
211
|
+
* widest so every item hangs at one column.
|
|
212
|
+
*/
|
|
213
|
+
function list(first, lines, start) {
|
|
214
|
+
const items = [];
|
|
215
|
+
let i = start;
|
|
216
|
+
let gap = 0;
|
|
217
|
+
for (let item = first; item && sameList(first, item); item = i < lines.length ? itemStart(lines[i]) : undefined) {
|
|
218
|
+
const { inner, next } = itemLines(item, lines, i);
|
|
219
|
+
items.push({ gap, children: tokenize(inner.length === 0 ? [BLANK_LINE] : inner) });
|
|
220
|
+
let after = next;
|
|
221
|
+
while (after < lines.length && isBlank(lines[after]))
|
|
222
|
+
after++;
|
|
223
|
+
gap = after - next;
|
|
224
|
+
i = after;
|
|
225
|
+
}
|
|
226
|
+
// The blank lines after the last item belong to whatever follows the list.
|
|
227
|
+
const next = i - gap;
|
|
228
|
+
const width = String(first.start + items.length - 1).length + 1;
|
|
229
|
+
const tokens = items.flatMap(({ gap: blanks, children }, n) => [
|
|
230
|
+
...Array.from({ length: blanks }, () => ({ type: "blank" })),
|
|
231
|
+
{
|
|
232
|
+
type: "list_item",
|
|
233
|
+
marker: first.bullet === undefined ? { kind: "number", label: `${first.start + n}.`.padStart(width) } : { kind: "bullet" },
|
|
234
|
+
children,
|
|
235
|
+
},
|
|
236
|
+
]);
|
|
237
|
+
return { tokens, next };
|
|
238
|
+
}
|
|
239
|
+
function tokenize(lines) {
|
|
12
240
|
const tokens = [];
|
|
13
241
|
let i = 0;
|
|
14
242
|
while (i < lines.length) {
|
|
15
243
|
const line = lines[i];
|
|
16
244
|
// Blank line
|
|
17
|
-
if (line
|
|
245
|
+
if (isBlank(line)) {
|
|
18
246
|
tokens.push({ type: "blank" });
|
|
19
247
|
i++;
|
|
20
248
|
continue;
|
|
21
249
|
}
|
|
250
|
+
// Indented code: its lines, and the blank lines between them, with the indent cut off
|
|
251
|
+
if (indentOf(line) >= CODE_INDENT) {
|
|
252
|
+
const code = [];
|
|
253
|
+
for (; i < lines.length && (isBlank(lines[i]) || indentOf(lines[i]) >= CODE_INDENT); i++) {
|
|
254
|
+
code.push(dropColumns(lines[i], CODE_INDENT));
|
|
255
|
+
}
|
|
256
|
+
for (; code[code.length - 1].trim() === ""; i--)
|
|
257
|
+
code.pop();
|
|
258
|
+
tokens.push({ type: "code_block", code: code.join("\n") });
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
22
261
|
// Heading
|
|
23
|
-
const headingMatch =
|
|
262
|
+
const headingMatch = HEADING.exec(body(line));
|
|
24
263
|
if (headingMatch) {
|
|
25
264
|
tokens.push({ type: "heading", level: headingMatch[1].length, text: headingMatch[2] });
|
|
26
265
|
i++;
|
|
27
266
|
continue;
|
|
28
267
|
}
|
|
29
268
|
// Horizontal rule
|
|
30
|
-
if (
|
|
269
|
+
if (startsWith(line, RULE)) {
|
|
31
270
|
tokens.push({ type: "hr" });
|
|
32
271
|
i++;
|
|
33
272
|
continue;
|
|
34
273
|
}
|
|
35
|
-
// Fenced code block
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const lang = codeMatch[1] ?? "";
|
|
274
|
+
// Fenced code block: its lines with the fence's own indentation cut off
|
|
275
|
+
if (startsWith(line, FENCE)) {
|
|
276
|
+
const indent = indentOf(line);
|
|
39
277
|
const codeLines = [];
|
|
40
|
-
i++;
|
|
41
|
-
|
|
42
|
-
codeLines.push(lines[i]);
|
|
43
|
-
i++;
|
|
44
|
-
}
|
|
278
|
+
for (i++; i < lines.length && !startsWith(lines[i], FENCE); i++)
|
|
279
|
+
codeLines.push(dropColumns(lines[i], indent));
|
|
45
280
|
i++; // skip closing ```
|
|
46
|
-
tokens.push({ type: "code_block",
|
|
281
|
+
tokens.push({ type: "code_block", code: codeLines.join("\n") });
|
|
47
282
|
continue;
|
|
48
283
|
}
|
|
49
|
-
// Blockquote
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
284
|
+
// Blockquote: Markdown of its own — its `>` lines with the marker blanked,
|
|
285
|
+
// and any unmarked line lazily continuing the paragraph open inside it
|
|
286
|
+
if (startsWith(line, QUOTE)) {
|
|
287
|
+
const inner = [];
|
|
288
|
+
let lazy = false;
|
|
289
|
+
for (; i < lines.length; i++) {
|
|
290
|
+
const next = lines[i];
|
|
291
|
+
const marked = startsWith(next, QUOTE);
|
|
292
|
+
lazy = !marked && continuesLazily(inner, next, lazy);
|
|
293
|
+
if (!marked && !lazy)
|
|
294
|
+
break;
|
|
295
|
+
inner.push(marked ? quoted(next) : { ...next, lazy: true });
|
|
55
296
|
}
|
|
56
|
-
tokens.push({ type: "blockquote",
|
|
297
|
+
tokens.push({ type: "blockquote", children: tokenize(inner) });
|
|
57
298
|
continue;
|
|
58
299
|
}
|
|
59
|
-
//
|
|
60
|
-
const
|
|
61
|
-
if (
|
|
62
|
-
|
|
63
|
-
|
|
300
|
+
// List: its items, each Markdown of its own, as a quote is
|
|
301
|
+
const item = itemStart(line);
|
|
302
|
+
if (item) {
|
|
303
|
+
const { tokens: items, next } = list(item, lines, i);
|
|
304
|
+
tokens.push(...items);
|
|
305
|
+
i = next;
|
|
64
306
|
continue;
|
|
65
307
|
}
|
|
66
|
-
//
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
308
|
+
// Paragraph, or a setext heading when a line of `=` or `-` underlines it
|
|
309
|
+
const { text, next } = continuation(lines, i);
|
|
310
|
+
const setext = underline(lines[next]);
|
|
311
|
+
tokens.push(setext
|
|
312
|
+
? { type: "heading", level: setext[1].startsWith("=") ? 1 : 2, text }
|
|
313
|
+
: { type: "paragraph", text });
|
|
314
|
+
i = setext ? next + 1 : next;
|
|
315
|
+
}
|
|
316
|
+
return tokens;
|
|
317
|
+
}
|
|
318
|
+
const TICKS = /`+/y;
|
|
319
|
+
const STRONG = /\*\*(.+?)\*\*/y;
|
|
320
|
+
const EMPHASIS = /\*(.+?)\*/y;
|
|
321
|
+
const ANGLED_DESTINATION = /\(\s*<([^<>\n]*)>/y;
|
|
322
|
+
const LINK_TAIL = /\s*(?:"[^"]*"|'[^']*'|\([^()]*\))?\s*\)/y;
|
|
323
|
+
/** `re`, a sticky pattern, matched exactly at `i` in `text`. */
|
|
324
|
+
function matchAt(re, text, i) {
|
|
325
|
+
re.lastIndex = i;
|
|
326
|
+
return re.exec(text);
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Each `[` in `text` that a `]` closes, to the index of that `]`: brackets
|
|
330
|
+
* nest, and a backslash escapes the character after it. One pass, so a run of
|
|
331
|
+
* text full of brackets that close nothing costs no more than one without.
|
|
332
|
+
*/
|
|
333
|
+
function bracketPairs(text) {
|
|
334
|
+
const pairs = new Map();
|
|
335
|
+
const open = [];
|
|
336
|
+
for (let i = 0; i < text.length; i++) {
|
|
337
|
+
if (text[i] === "\\")
|
|
70
338
|
i++;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
339
|
+
else if (text[i] === "[")
|
|
340
|
+
open.push(i);
|
|
341
|
+
else if (text[i] === "]" && open.length > 0)
|
|
342
|
+
pairs.set(open.pop(), i);
|
|
343
|
+
}
|
|
344
|
+
return pairs;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* The `(destination "title")` of a link opening at `open`, as CommonMark reads
|
|
348
|
+
* it: a destination in angle brackets, or a run of non-space characters whose
|
|
349
|
+
* parentheses balance — so a URL may itself hold `(…)` — then an optional
|
|
350
|
+
* quoted title, then the `)`. The destination and the index past the `)`.
|
|
351
|
+
*/
|
|
352
|
+
function linkDestination(text, open) {
|
|
353
|
+
if (text[open] !== "(")
|
|
354
|
+
return undefined;
|
|
355
|
+
const angled = matchAt(ANGLED_DESTINATION, text, open);
|
|
356
|
+
let i;
|
|
357
|
+
let url;
|
|
358
|
+
if (angled) {
|
|
359
|
+
url = angled[1];
|
|
360
|
+
i = open + angled[0].length;
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
i = open + 1;
|
|
364
|
+
while (text[i] === " ")
|
|
77
365
|
i++;
|
|
366
|
+
const from = i;
|
|
367
|
+
for (let depth = 0; i < text.length && !/\s/.test(text[i]) && !(text[i] === ")" && depth === 0); i++) {
|
|
368
|
+
if (text[i] === "\\")
|
|
369
|
+
i++;
|
|
370
|
+
else if (text[i] === "(")
|
|
371
|
+
depth++;
|
|
372
|
+
else if (text[i] === ")")
|
|
373
|
+
depth--;
|
|
78
374
|
}
|
|
79
|
-
|
|
375
|
+
url = text.slice(from, i);
|
|
80
376
|
}
|
|
81
|
-
|
|
377
|
+
const tail = matchAt(LINK_TAIL, text, i);
|
|
378
|
+
return tail ? { url, end: i + tail[0].length } : undefined;
|
|
82
379
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
380
|
+
/** The inline construct opening at `i` in `text`, if one does; `brackets` is `text`'s `bracketPairs`. */
|
|
381
|
+
function inlineAt(text, i, brackets) {
|
|
382
|
+
switch (text[i]) {
|
|
383
|
+
case "`": {
|
|
384
|
+
const ticks = matchAt(TICKS, text, i)[0];
|
|
385
|
+
const close = new RegExp(`(?<!\`)${ticks}(?!\`)`, "g");
|
|
386
|
+
close.lastIndex = i + ticks.length;
|
|
387
|
+
const found = close.exec(text);
|
|
388
|
+
return found
|
|
389
|
+
? { kind: "code", code: text.slice(i + ticks.length, found.index), end: found.index + ticks.length }
|
|
390
|
+
: { kind: "literal", text: ticks, end: i + ticks.length };
|
|
93
391
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
392
|
+
case "*": {
|
|
393
|
+
const strong = matchAt(STRONG, text, i);
|
|
394
|
+
const emphasis = strong ?? matchAt(EMPHASIS, text, i);
|
|
395
|
+
return emphasis
|
|
396
|
+
? { kind: "emphasis", style: strong ? "bold" : "italic", inner: emphasis[1], end: i + emphasis[0].length }
|
|
397
|
+
: undefined;
|
|
97
398
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
399
|
+
case "!":
|
|
400
|
+
case "[": {
|
|
401
|
+
const image = text[i] === "!";
|
|
402
|
+
const open = image ? i + 1 : i;
|
|
403
|
+
const close = brackets.get(open);
|
|
404
|
+
const destination = close === undefined ? undefined : linkDestination(text, close + 1);
|
|
405
|
+
if (!destination)
|
|
406
|
+
return undefined;
|
|
407
|
+
const label = text.slice(open + 1, close);
|
|
408
|
+
return image
|
|
409
|
+
? { kind: "image", alt: label, src: destination.url, end: destination.end }
|
|
410
|
+
: { kind: "link", inner: label, url: destination.url, end: destination.end };
|
|
101
411
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
412
|
+
default:
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* `text` as inline Markdown in `style`: a text of its own whose style sits
|
|
418
|
+
* under every style inside it, so appended where it goes it draws as Rich
|
|
419
|
+
* draws a nested construct — the inner style on top.
|
|
420
|
+
*/
|
|
421
|
+
function styled(text, style, settings, options) {
|
|
422
|
+
const result = new RichText("", { style });
|
|
423
|
+
appendInline(result, text, settings, options);
|
|
424
|
+
return result;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* `text` as inline Markdown appended to `result`: code spans, emphasis, links
|
|
428
|
+
* and images, the text inside emphasis, links and images parsed the same way,
|
|
429
|
+
* and everything else as it is.
|
|
430
|
+
*/
|
|
431
|
+
function appendInline(result, text, settings, options) {
|
|
432
|
+
const brackets = bracketPairs(text);
|
|
433
|
+
let plain = 0;
|
|
434
|
+
for (let i = 0; i < text.length;) {
|
|
435
|
+
const found = inlineAt(text, i, brackets);
|
|
436
|
+
if (!found) {
|
|
437
|
+
i++;
|
|
438
|
+
continue;
|
|
105
439
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
440
|
+
result.append(text.slice(plain, i));
|
|
441
|
+
switch (found.kind) {
|
|
442
|
+
case "code":
|
|
443
|
+
result.append(found.code, groundedStyle(options, resolveStyle(options, settings.inlineCodeStyle)));
|
|
444
|
+
break;
|
|
445
|
+
case "literal":
|
|
446
|
+
result.append(found.text);
|
|
447
|
+
break;
|
|
448
|
+
case "emphasis":
|
|
449
|
+
result.append(styled(found.inner, found.style, settings, options));
|
|
450
|
+
break;
|
|
451
|
+
case "link": {
|
|
452
|
+
// As Rich draws it: without hyperlinks the URL is written out, and it
|
|
453
|
+
// is still the link, for a terminal that can click it.
|
|
454
|
+
const link = new RichText("", { style: new Style({ link: found.url }) });
|
|
455
|
+
if (settings.hyperlinks) {
|
|
456
|
+
result.append(link.append(styled(found.inner, "markdown.link_url", settings, options)));
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
result
|
|
460
|
+
.append(styled(found.inner, "markdown.link", settings, options))
|
|
461
|
+
.append(" (")
|
|
462
|
+
.append(link.append(found.url, "markdown.link_url"))
|
|
463
|
+
.append(")");
|
|
464
|
+
}
|
|
465
|
+
break;
|
|
466
|
+
}
|
|
467
|
+
case "image": {
|
|
468
|
+
// Rich's ImageItem: a picture glyph, then the alt text — or, with
|
|
469
|
+
// none, the image's file name — linked to the image, then a space.
|
|
470
|
+
const title = found.alt || (found.src.replace(/\/+$/, "").split("/").pop() ?? "");
|
|
471
|
+
const link = settings.hyperlinks ? new Style({ link: found.src }) : "";
|
|
472
|
+
result.append(drawable(options, "🌆 ", "")).append(styled(title, link, settings, options)).append(" ");
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
109
475
|
}
|
|
110
|
-
|
|
476
|
+
i = plain = found.end;
|
|
111
477
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
478
|
+
result.append(text.slice(plain));
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* What `draw` renders in what is left of the width beside a gutter, each of
|
|
482
|
+
* its rows ended. The first row's gutter is `first` and every later row's is
|
|
483
|
+
* `rest`, so a list item's text hangs clear of its bullet and a quote's bar
|
|
484
|
+
* runs its full height. A block with no gutter passes two empty segments:
|
|
485
|
+
* every block is laid out the same way. What is drawn keeps at least one
|
|
486
|
+
* column however wide the gutter, so a row may overrun its width but no gutter
|
|
487
|
+
* swallows what it introduces.
|
|
488
|
+
*/
|
|
489
|
+
function* guttered(draw, options, first, rest, style) {
|
|
490
|
+
const width = Math.max(1, options.maxWidth - cellLen(first.text));
|
|
491
|
+
const rows = Segment.splitLines(Segment.applyStyle([...draw({ ...options, maxWidth: width })], style));
|
|
492
|
+
for (const [row, line] of rows.entries()) {
|
|
493
|
+
yield row === 0 ? first : rest;
|
|
494
|
+
yield* line;
|
|
495
|
+
yield Segment.line();
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* `text` as inline Markdown, placed by `justify` in `style`, wrapped, with
|
|
500
|
+
* every row ended. `style` is a span under the inline spans, as Rich's
|
|
501
|
+
* element style is, not the text's base: placement pads in the base style,
|
|
502
|
+
* and a heading's padding is not underlined.
|
|
503
|
+
*/
|
|
504
|
+
function inline(text, settings, justify, style) {
|
|
505
|
+
return function* (options) {
|
|
506
|
+
const body = new RichText("", { end: "", style });
|
|
507
|
+
appendInline(body, text, settings, options);
|
|
508
|
+
// `append` lays the body's base style down as the first span.
|
|
509
|
+
yield* new RichText("", { end: "", justify }).append(body).render(options);
|
|
510
|
+
yield Segment.line();
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
const NO_GUTTER = new Segment("");
|
|
514
|
+
/**
|
|
515
|
+
* Each block in `tokens`, every row it draws ended, body text placed by
|
|
516
|
+
* `settings.justify`. A heading keeps its own placement whatever `settings`
|
|
517
|
+
* or `options` say — Rich's: an h1 centred, every other level left — and its
|
|
518
|
+
* style stops where its text does.
|
|
519
|
+
*/
|
|
520
|
+
function* renderTokens(tokens, options, settings) {
|
|
521
|
+
for (const token of tokens) {
|
|
522
|
+
switch (token.type) {
|
|
523
|
+
case "heading": {
|
|
524
|
+
const style = getStyle(options, `markdown.h${Math.min(token.level, 4)}`);
|
|
525
|
+
const draw = inline(token.text, settings, token.level === 1 ? "center" : undefined, style);
|
|
526
|
+
yield* guttered(draw, { ...options, justify: undefined }, NO_GUTTER, NO_GUTTER);
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
case "paragraph": {
|
|
530
|
+
yield* guttered(inline(token.text, settings, settings.justify), options, NO_GUTTER, NO_GUTTER);
|
|
531
|
+
break;
|
|
532
|
+
}
|
|
533
|
+
case "code_block": {
|
|
534
|
+
const codeStyle = groundedStyle(options, getStyle(options, "markdown.code"));
|
|
535
|
+
// Tabs expanded here, as `Syntax` expands them, since a terminal would
|
|
536
|
+
// count a raw one from its own column 0 and not the code's.
|
|
537
|
+
for (const line of expandTabs(token.code, CODE_TAB_SIZE).text.split("\n")) {
|
|
538
|
+
yield new Segment(line, codeStyle);
|
|
539
|
+
yield Segment.line();
|
|
540
|
+
}
|
|
541
|
+
break;
|
|
542
|
+
}
|
|
543
|
+
case "hr": {
|
|
544
|
+
const rule = new Rule(undefined, { style: "markdown.hr" });
|
|
545
|
+
yield* rule.render(options);
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
case "list_item": {
|
|
549
|
+
const marker = token.marker.kind === "number" ? `${token.marker.label} ` : drawable(options, " • ", " * ");
|
|
550
|
+
const hang = new Segment(" ".repeat(cellLen(marker)));
|
|
551
|
+
const body = (inner) => renderTokens(token.children, inner, settings);
|
|
552
|
+
yield* guttered(body, options, new Segment(marker), hang);
|
|
553
|
+
break;
|
|
554
|
+
}
|
|
555
|
+
case "blockquote": {
|
|
556
|
+
const bar = new Segment(drawable(options, "▎ ", "| "), getStyle(options, "markdown.hr"));
|
|
557
|
+
const body = (inner) => renderTokens(token.children, inner, settings);
|
|
558
|
+
yield* guttered(body, options, bar, bar, Style.parse("dim italic"));
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
561
|
+
case "blank":
|
|
562
|
+
yield Segment.line();
|
|
563
|
+
break;
|
|
564
|
+
}
|
|
115
565
|
}
|
|
116
|
-
return result;
|
|
117
566
|
}
|
|
118
567
|
export class Markdown {
|
|
119
568
|
markdown;
|
|
120
569
|
inlineCodeStyle;
|
|
121
570
|
hyperlinks;
|
|
571
|
+
justify;
|
|
122
572
|
constructor(markdown, options) {
|
|
123
573
|
this.markdown = markdown;
|
|
124
574
|
this.inlineCodeStyle = options?.inlineCodeStyle ?? "markdown.code";
|
|
125
575
|
this.hyperlinks = options?.hyperlinks !== false;
|
|
576
|
+
this.justify = options?.justify ?? "left";
|
|
126
577
|
}
|
|
127
578
|
*render(rawOptions) {
|
|
128
579
|
// Every block below is one of a stack.
|
|
129
580
|
const options = { ...rawOptions, height: stackedHeight(rawOptions.height) };
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
switch (token.type) {
|
|
133
|
-
case "heading": {
|
|
134
|
-
const style = getStyle(options, `markdown.h${Math.min(token.level, 4)}`);
|
|
135
|
-
const text = applyInlineStyles(token.text);
|
|
136
|
-
yield* Segment.applyStyle([...text.render(options)], style);
|
|
137
|
-
yield Segment.line();
|
|
138
|
-
break;
|
|
139
|
-
}
|
|
140
|
-
case "paragraph": {
|
|
141
|
-
const text = applyInlineStyles(token.text);
|
|
142
|
-
yield* text.render(options);
|
|
143
|
-
yield Segment.line();
|
|
144
|
-
break;
|
|
145
|
-
}
|
|
146
|
-
case "code_block": {
|
|
147
|
-
const codeStyle = getStyle(options, "markdown.code");
|
|
148
|
-
const lines = token.code.split("\n");
|
|
149
|
-
for (const line of lines) {
|
|
150
|
-
yield new Segment(line, codeStyle);
|
|
151
|
-
yield Segment.line();
|
|
152
|
-
}
|
|
153
|
-
break;
|
|
154
|
-
}
|
|
155
|
-
case "hr": {
|
|
156
|
-
const rule = new Rule(undefined, { style: "markdown.hr" });
|
|
157
|
-
yield* rule.render(options);
|
|
158
|
-
break;
|
|
159
|
-
}
|
|
160
|
-
case "list_item": {
|
|
161
|
-
const bullet = token.ordered ? `${token.index}. ` : drawable(options, " • ", " * ");
|
|
162
|
-
yield new Segment(bullet);
|
|
163
|
-
const text = applyInlineStyles(token.text);
|
|
164
|
-
yield* text.render({ ...options, maxWidth: options.maxWidth - bullet.length });
|
|
165
|
-
break;
|
|
166
|
-
}
|
|
167
|
-
case "blockquote": {
|
|
168
|
-
const quoteStyle = Style.parse("dim italic");
|
|
169
|
-
yield new Segment(drawable(options, "▎ ", "| "), getStyle(options, "markdown.hr"));
|
|
170
|
-
const text = applyInlineStyles(token.text);
|
|
171
|
-
yield* Segment.applyStyle([...text.render(options)], quoteStyle);
|
|
172
|
-
yield Segment.line();
|
|
173
|
-
break;
|
|
174
|
-
}
|
|
175
|
-
case "blank":
|
|
176
|
-
yield Segment.line();
|
|
177
|
-
break;
|
|
178
|
-
}
|
|
179
|
-
}
|
|
581
|
+
const lines = this.markdown.split(/\r?\n/).map((text) => ({ text, margin: 0, lazy: false }));
|
|
582
|
+
yield* renderTokens(tokenize(lines), options, this);
|
|
180
583
|
}
|
|
181
584
|
measure(options) {
|
|
182
585
|
return { minimum: 1, maximum: options.maxWidth };
|