@vincemakes/kiso-tui-cells 0.9.0 → 0.11.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/approval-panel.d.ts +75 -0
- package/dist/approval-panel.js +93 -1
- package/dist/components.d.ts +45 -2
- package/dist/components.js +94 -29
- package/dist/index.d.ts +2 -1
- package/dist/index.js +4 -1
- package/dist/md.d.ts +137 -0
- package/dist/md.js +720 -0
- package/dist/render.d.ts +18 -0
- package/dist/render.js +2 -2
- package/dist/strings.d.ts +4 -0
- package/dist/strings.js +34 -0
- package/dist/width.d.ts +30 -6
- package/dist/width.js +145 -26
- package/package.json +1 -1
package/dist/md.js
ADDED
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI2-MD — the markdown renderer. Hand-rolled, zero dependencies, and
|
|
3
|
+
* deliberately a SUBSET: the constructs assistant prose actually uses,
|
|
4
|
+
* rendered under the mono discipline (attributes over colours, zero
|
|
5
|
+
* syntax highlighting).
|
|
6
|
+
*
|
|
7
|
+
* THE BLOCK-FREEZE DISCIPLINE. kiso's committed bytes are never
|
|
8
|
+
* re-emitted (ADR-0046) — the terminal's own scrollback is the
|
|
9
|
+
* transcript. A renderer that re-lexes the whole message per delta (the
|
|
10
|
+
* shape a whole-text parser forces on you) can therefore not be used
|
|
11
|
+
* here at all: it would need to repaint lines that have already left
|
|
12
|
+
* the live region. So the scanner below IS the streaming state machine.
|
|
13
|
+
* It consumes appended text and yields two things:
|
|
14
|
+
*
|
|
15
|
+
* - CLOSED blocks: their source is final, so their render is final;
|
|
16
|
+
* the compositor commits them through the path it already had.
|
|
17
|
+
* - the OPEN TAIL block: the live region's occupant, re-rendered in
|
|
18
|
+
* place per delta, bounded by construction (one block).
|
|
19
|
+
*
|
|
20
|
+
* The freeze property — a closed block's rendered lines never change as
|
|
21
|
+
* more text arrives — is earned by ONE rule: block boundaries are
|
|
22
|
+
* decided only on COMPLETE lines. A trailing partial line renders
|
|
23
|
+
* eagerly but can never close anything, because a decision taken on an
|
|
24
|
+
* incomplete line can be wrong ("#" is a heading until it becomes
|
|
25
|
+
* "#hashtag") and a wrong decision here is a wrong commit.
|
|
26
|
+
*
|
|
27
|
+
* Everything else follows: an unclosed `**` renders literal and flips
|
|
28
|
+
* when it closes, but only ever inside the open block; a fence body
|
|
29
|
+
* line is line-local (no highlighting means no cross-line lexer state),
|
|
30
|
+
* so it closes the instant its newline arrives and long code blocks
|
|
31
|
+
* never bloat the live region; a table re-layouts as rows stream, and
|
|
32
|
+
* only inside the tail.
|
|
33
|
+
*
|
|
34
|
+
* The style table is the round's normative one (the owner's circled
|
|
35
|
+
* group D): the BLOCK half is `blockBody` below, the INLINE half is
|
|
36
|
+
* `inlineSpans`, and every entry is pinned by a fixture rather than
|
|
37
|
+
* described twice.
|
|
38
|
+
*/
|
|
39
|
+
import { palette } from "./render.js";
|
|
40
|
+
import { breakable, charWidth, displayWidth } from "./width.js";
|
|
41
|
+
import { escapeTerminal } from "./render.js";
|
|
42
|
+
import { visibleWidth } from "./components.js";
|
|
43
|
+
// ---- line classification -------------------------------------------
|
|
44
|
+
const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
|
|
45
|
+
/** ATX only, and the space is REQUIRED: `#hashtag` is prose. */
|
|
46
|
+
const HEADING = /^ {0,3}(#{1,6}) +(\S.*)$/;
|
|
47
|
+
const RULE = /^ {0,3}(?:-{3,}|\*{3,}|_{3,}) *$/;
|
|
48
|
+
const QUOTE = /^ {0,3}> ?(.*)$/;
|
|
49
|
+
const TABLE = /^ {0,3}\|/;
|
|
50
|
+
const ITEM = /^( *)([-*+]|\d{1,9}[.)])[ \t]+(.*)$/;
|
|
51
|
+
/** The kind a line would START. null = blank (a block separator). */
|
|
52
|
+
function classify(line) {
|
|
53
|
+
if (line.trim() === "")
|
|
54
|
+
return null;
|
|
55
|
+
if (FENCE.test(line))
|
|
56
|
+
return "fence-open";
|
|
57
|
+
if (RULE.test(line))
|
|
58
|
+
return "rule";
|
|
59
|
+
if (HEADING.test(line))
|
|
60
|
+
return "heading";
|
|
61
|
+
if (QUOTE.test(line))
|
|
62
|
+
return "quote";
|
|
63
|
+
if (TABLE.test(line))
|
|
64
|
+
return "table";
|
|
65
|
+
if (ITEM.test(line))
|
|
66
|
+
return "list";
|
|
67
|
+
return "para";
|
|
68
|
+
}
|
|
69
|
+
/** Can `line` JOIN an open block of this kind? Headings, rules and the
|
|
70
|
+
* fence rows are single-line blocks and never take a second line. */
|
|
71
|
+
function joins(kind, line) {
|
|
72
|
+
const c = classify(line);
|
|
73
|
+
if (c === null)
|
|
74
|
+
return false; // a blank closes everything
|
|
75
|
+
switch (kind) {
|
|
76
|
+
case "para":
|
|
77
|
+
return c === "para";
|
|
78
|
+
case "list":
|
|
79
|
+
// a list item's continuation must be INDENTED — an unindented
|
|
80
|
+
// paragraph after a list starts a paragraph (the lazy-continuation
|
|
81
|
+
// rule is a documented deviation: predictable beats compliant when
|
|
82
|
+
// the output is committed).
|
|
83
|
+
return c === "list" || (c === "para" && /^[ \t]/.test(line));
|
|
84
|
+
case "table":
|
|
85
|
+
return c === "table";
|
|
86
|
+
case "quote":
|
|
87
|
+
return c === "quote";
|
|
88
|
+
default:
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** The fence marker a line opens with (``` or ~~~, 3+). */
|
|
93
|
+
function fenceMark(line) {
|
|
94
|
+
return FENCE.exec(line)?.[1] ?? "```";
|
|
95
|
+
}
|
|
96
|
+
/** A line that CLOSES a fence: the same char, at least as long, alone. */
|
|
97
|
+
function closesFence(line, mark) {
|
|
98
|
+
const m = FENCE.exec(line);
|
|
99
|
+
return m !== null && m[1].startsWith(mark[0]) && m[1].length >= mark.length && m[2].trim() === "";
|
|
100
|
+
}
|
|
101
|
+
/** The partial line is nothing but the HEAD of a closing fence — the
|
|
102
|
+
* one visible flicker in fence-close streaming (a stray ``` `` ``` on
|
|
103
|
+
* screen for a frame). Both reference implementations trim it. */
|
|
104
|
+
function partialClose(partial, mark) {
|
|
105
|
+
const t = partial.trim();
|
|
106
|
+
return t !== "" && t.length <= mark.length && t.split("").every((c) => c === mark[0]);
|
|
107
|
+
}
|
|
108
|
+
function frozen(b, extra) {
|
|
109
|
+
return { kind: b.kind, lines: extra === undefined ? [...b.lines] : [...b.lines, extra], gap: b.gap, lang: b.lang };
|
|
110
|
+
}
|
|
111
|
+
export class MdStream {
|
|
112
|
+
#closed = [];
|
|
113
|
+
#open = null;
|
|
114
|
+
#partial = "";
|
|
115
|
+
/** the opener's marker while inside a fence; null outside one. */
|
|
116
|
+
#fence = null;
|
|
117
|
+
/** blocks STARTED so far — the gap rule's only input (the first block
|
|
118
|
+
* of a message opens tight; every later one carries its own blank). */
|
|
119
|
+
#started = 0;
|
|
120
|
+
/** Append streamed text. Only COMPLETE lines reach the state machine. */
|
|
121
|
+
push(text) {
|
|
122
|
+
// the renderer consumes already-scrubbed text and never re-introduces
|
|
123
|
+
// ESC from data: the styling below is applied by this module, never
|
|
124
|
+
// carried in the content.
|
|
125
|
+
this.#partial += escapeTerminal(text);
|
|
126
|
+
for (let at = this.#partial.indexOf("\n"); at >= 0; at = this.#partial.indexOf("\n")) {
|
|
127
|
+
this.#line(this.#partial.slice(0, at));
|
|
128
|
+
this.#partial = this.#partial.slice(at + 1);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/** The message ended: the trailing partial line is a complete line
|
|
132
|
+
* after all, and the open block closes. */
|
|
133
|
+
end() {
|
|
134
|
+
if (this.#partial !== "") {
|
|
135
|
+
this.#line(this.#partial);
|
|
136
|
+
this.#partial = "";
|
|
137
|
+
}
|
|
138
|
+
this.#shut();
|
|
139
|
+
this.#fence = null;
|
|
140
|
+
}
|
|
141
|
+
/** Every block so far: `closed()` of them are FINAL, and at most one
|
|
142
|
+
* open tail follows. Fresh objects — a closed block's source can
|
|
143
|
+
* never be reached through this. */
|
|
144
|
+
blocks() {
|
|
145
|
+
const out = [...this.#closed];
|
|
146
|
+
const p = this.#partial;
|
|
147
|
+
if (this.#fence !== null) {
|
|
148
|
+
if (p !== "" && !partialClose(p, this.#fence))
|
|
149
|
+
out.push({ kind: "fence-line", lines: [p], gap: false, lang: "" });
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
if (this.#open !== null) {
|
|
153
|
+
out.push(p === "" ? frozen(this.#open) : frozen(this.#open, p));
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
const k = classify(p);
|
|
157
|
+
if (k !== null)
|
|
158
|
+
out.push({ kind: k, lines: [p], gap: this.#started > 0, lang: k === "fence-open" ? fenceLang(p) : "" });
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
/** How many leading blocks are CLOSED — the commit-eligible count. */
|
|
162
|
+
closed() {
|
|
163
|
+
return this.#closed.length;
|
|
164
|
+
}
|
|
165
|
+
#line(line) {
|
|
166
|
+
if (this.#fence !== null) {
|
|
167
|
+
// the closer emits no block: a bottom border is drawn only by an
|
|
168
|
+
// actual close, and under committed lines a phantom one would be
|
|
169
|
+
// a lie the force-commit path could freeze.
|
|
170
|
+
if (closesFence(line, this.#fence)) {
|
|
171
|
+
this.#fence = null;
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
this.#push({ kind: "fence-line", lines: [line], gap: false, lang: "" });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const k = classify(line);
|
|
178
|
+
if (k === null) {
|
|
179
|
+
this.#shut();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (this.#open !== null && joins(this.#open.kind, line)) {
|
|
183
|
+
this.#open.lines.push(line);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
this.#shut();
|
|
187
|
+
if (k === "fence-open") {
|
|
188
|
+
this.#fence = fenceMark(line);
|
|
189
|
+
this.#push({ kind: k, lines: [line], gap: this.#started > 0, lang: fenceLang(line) });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (k === "heading" || k === "rule") {
|
|
193
|
+
this.#push({ kind: k, lines: [line], gap: this.#started > 0, lang: "" });
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
this.#open = { kind: k, lines: [line], gap: this.#started > 0, lang: "" };
|
|
197
|
+
this.#started += 1;
|
|
198
|
+
}
|
|
199
|
+
/** A block that is final the moment its line is. */
|
|
200
|
+
#push(b) {
|
|
201
|
+
this.#closed.push(b);
|
|
202
|
+
this.#started += 1;
|
|
203
|
+
}
|
|
204
|
+
#shut() {
|
|
205
|
+
if (this.#open === null)
|
|
206
|
+
return;
|
|
207
|
+
this.#closed.push(frozen(this.#open));
|
|
208
|
+
this.#open = null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function fenceLang(line) {
|
|
212
|
+
return (FENCE.exec(line)?.[2] ?? "").trim();
|
|
213
|
+
}
|
|
214
|
+
// ---- rendering ------------------------------------------------------
|
|
215
|
+
/** The whole message at once — the freeze property's oracle, and the
|
|
216
|
+
* path a non-streaming caller takes. */
|
|
217
|
+
export function renderMarkdown(text, W) {
|
|
218
|
+
const s = new MdStream();
|
|
219
|
+
s.push(text);
|
|
220
|
+
s.end();
|
|
221
|
+
return s.blocks().flatMap((b) => renderBlock(b, W));
|
|
222
|
+
}
|
|
223
|
+
/** One block's screen rows. Pure in (block, W) — this is the whole
|
|
224
|
+
* freeze guarantee: same source, same width, same bytes, forever. */
|
|
225
|
+
export function renderBlock(b, W) {
|
|
226
|
+
const rows = blockBody(b, Math.max(1, W));
|
|
227
|
+
return b.gap ? ["", ...rows] : rows;
|
|
228
|
+
}
|
|
229
|
+
function blockBody(b, W) {
|
|
230
|
+
const p = palette();
|
|
231
|
+
switch (b.kind) {
|
|
232
|
+
case "heading": {
|
|
233
|
+
// the marker is stripped, the numbering kept, and the levels are
|
|
234
|
+
// NOT differentiated by colour — attributes only. A `**bold**`
|
|
235
|
+
// inside a heading is therefore a no-op, which is the mono
|
|
236
|
+
// discipline paying for itself: the nested-style restore machinery
|
|
237
|
+
// both reference implementations need for this exact input has
|
|
238
|
+
// nothing to restore here.
|
|
239
|
+
const m = HEADING.exec(b.lines[0] ?? "");
|
|
240
|
+
return wrap(`${p.bold}${inlineSpans(m?.[2] ?? b.lines[0] ?? "", p.bold)}${p.reset}`, W, "", "");
|
|
241
|
+
}
|
|
242
|
+
case "rule":
|
|
243
|
+
return [`${p.dim}${"─".repeat(Math.min(W, 28))}${p.reset}`];
|
|
244
|
+
case "fence-open":
|
|
245
|
+
// the dim gutter names the block; the language tag rides the
|
|
246
|
+
// opening row. Zero highlighting — which is exactly what makes a
|
|
247
|
+
// fence body line committable on its own.
|
|
248
|
+
return [`${p.dim}│${b.lang === "" ? "" : ` ${b.lang}`}${p.reset}`];
|
|
249
|
+
case "fence-line": {
|
|
250
|
+
// a fence body's INDENTATION is its content. The wrapper drops
|
|
251
|
+
// leading spaces \u2014 right for prose, a lie for code \u2014 so the indent
|
|
252
|
+
// rides as the row prefix instead, and a wrapped long line hangs
|
|
253
|
+
// under it rather than returning to the gutter.
|
|
254
|
+
const src = (b.lines[0] ?? "").replace(/\t/g, " ");
|
|
255
|
+
const indent = /^ */.exec(src)[0];
|
|
256
|
+
const gutter = `${p.dim}\u2502${p.reset} `;
|
|
257
|
+
return foldLineWidth(`${p.code}${src.slice(indent.length)}${p.reset}`, W - visibleWidth(gutter), indent).map((r) => `${gutter}${r}`);
|
|
258
|
+
}
|
|
259
|
+
case "quote": {
|
|
260
|
+
const text = b.lines.map((l) => QUOTE.exec(l)?.[1] ?? l).join(" ");
|
|
261
|
+
const gutter = `${p.dim}\u258f${p.reset} `;
|
|
262
|
+
return wrap(`${p.dim}${inlineSpans(text, p.dim)}${p.reset}`, W - visibleWidth(gutter), "", "").map((r) => `${gutter}${r}`);
|
|
263
|
+
}
|
|
264
|
+
case "list":
|
|
265
|
+
return listRows(b, W);
|
|
266
|
+
case "table":
|
|
267
|
+
return tableRows(b, W);
|
|
268
|
+
default:
|
|
269
|
+
// a paragraph's soft line breaks are spaces — the block reflows at
|
|
270
|
+
// the terminal's width, which is the whole point of rendering it.
|
|
271
|
+
return wrap(inlineSpans(b.lines.join(" "), ""), W, "", "");
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* The inline pass — the mono style table applied to one block's text.
|
|
276
|
+
*
|
|
277
|
+
* Scoped to `**bold**`, `*italic*`, `` `code` ``, `[text](url)` and the
|
|
278
|
+
* backslash escape, with two rules that matter more than coverage:
|
|
279
|
+
*
|
|
280
|
+
* RAW UNTIL CLOSED — an opener with no closer in this text stays
|
|
281
|
+
* literal. That is what lets a half-streamed `**` show its asterisks
|
|
282
|
+
* and flip the instant the closer lands, inside the live block and
|
|
283
|
+
* nowhere else.
|
|
284
|
+
*
|
|
285
|
+
* CLOSE BACK TO `base` — the block's own style (a heading's bold, a
|
|
286
|
+
* quote's dim) is passed in, and every span reopens it on the way
|
|
287
|
+
* out, so a nested span can never strand it. Italic is the one span
|
|
288
|
+
* that closes surgically (SGR 23), because it can.
|
|
289
|
+
*
|
|
290
|
+
* Documented deviations from CommonMark: `_` never emphasizes (it is a
|
|
291
|
+
* character in identifiers far more often than a marker in prose);
|
|
292
|
+
* emphasis does not nest across a code span; `~~` is not a construct at
|
|
293
|
+
* all — the markers are content (the strict tokenizer both reference
|
|
294
|
+
* implementations converged on, taken to its honest conclusion, since
|
|
295
|
+
* SGR 9's terminal support is too fragmented to promise).
|
|
296
|
+
*/
|
|
297
|
+
export function inlineSpans(text, base) {
|
|
298
|
+
const p = palette();
|
|
299
|
+
let out = "";
|
|
300
|
+
let i = 0;
|
|
301
|
+
while (i < text.length) {
|
|
302
|
+
const ch = text[i];
|
|
303
|
+
if (ch === "\\" && i + 1 < text.length && ESCAPABLE.test(text[i + 1])) {
|
|
304
|
+
out += text[i + 1];
|
|
305
|
+
i += 2;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (ch === "`") {
|
|
309
|
+
const end = text.indexOf("`", i + 1);
|
|
310
|
+
if (end > i) {
|
|
311
|
+
// a code span's content is LITERAL — no markers inside it mean
|
|
312
|
+
// anything, which is what makes `x | y` survive a table split
|
|
313
|
+
out += `${p.code}${text.slice(i + 1, end)}${p.reset}${base}`;
|
|
314
|
+
i = end + 1;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (text.startsWith("**", i)) {
|
|
319
|
+
const end = closerAt(text, i + 2, "**");
|
|
320
|
+
if (end >= 0) {
|
|
321
|
+
out += `${p.bold}${inlineSpans(text.slice(i + 2, end), `${base}${p.bold}`)}${p.reset}${base}`;
|
|
322
|
+
i = end + 2;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (ch === "*") {
|
|
327
|
+
const end = closerAt(text, i + 1, "*");
|
|
328
|
+
if (end >= 0) {
|
|
329
|
+
out += `${p.italic}${inlineSpans(text.slice(i + 1, end), `${base}${p.italic}`)}${p.italicEnd}`;
|
|
330
|
+
i = end + 1;
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (ch === "[") {
|
|
335
|
+
const link = LINK.exec(text.slice(i));
|
|
336
|
+
if (link !== null) {
|
|
337
|
+
// the text bright, the url dim in parentheses. NO OSC 8 this
|
|
338
|
+
// round: a hyperlink escape is bytes the human cannot see, and
|
|
339
|
+
// the byte discipline gets to decide that separately.
|
|
340
|
+
out += `${p.bold}${link[1]}${p.reset}${p.dim} (${link[2]})${p.reset}${base}`;
|
|
341
|
+
i += link[0].length;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
out += ch;
|
|
346
|
+
i += 1;
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
const ESCAPABLE = /[\\`*_~[\]()#|+.!>-]/;
|
|
351
|
+
const LINK = /^\[([^\]\n]*)\]\(([^)\s\n]*)\)/;
|
|
352
|
+
/** The closing delimiter for an emphasis opener at `from`, or −1.
|
|
353
|
+
* Strict on both edges: an opener followed by a space, or a closer
|
|
354
|
+
* preceded by one, is arithmetic or prose, not emphasis (`2 * 3 * 4`
|
|
355
|
+
* must survive). An empty span is not a span. */
|
|
356
|
+
function closerAt(text, from, delim) {
|
|
357
|
+
if (from >= text.length || text[from] === " ")
|
|
358
|
+
return -1;
|
|
359
|
+
for (let i = from; i >= 0;) {
|
|
360
|
+
const at = text.indexOf(delim, i);
|
|
361
|
+
if (at < 0)
|
|
362
|
+
return -1;
|
|
363
|
+
if (at > from && text[at - 1] !== " " && !(delim === "*" && text[at - 1] === "*"))
|
|
364
|
+
return at;
|
|
365
|
+
i = at + delim.length;
|
|
366
|
+
}
|
|
367
|
+
return -1;
|
|
368
|
+
}
|
|
369
|
+
// ---- the table tokenizer (the two convergent patches) ---------------
|
|
370
|
+
/** Split a table line into cells. The `|` walls are found on the RAW
|
|
371
|
+
* line, but a pipe INSIDE a code span is content — two independent
|
|
372
|
+
* reference implementations both patched exactly this, because a
|
|
373
|
+
* command in a cell (`grep a | wc`) is common and splitting it puts
|
|
374
|
+
* the human's own text in the wrong column. A backslash-escaped pipe
|
|
375
|
+
* is content too. */
|
|
376
|
+
export function splitCells(line) {
|
|
377
|
+
const cells = [];
|
|
378
|
+
let cur = "";
|
|
379
|
+
let code = false;
|
|
380
|
+
const body = line.trim();
|
|
381
|
+
for (let i = 0; i < body.length; i += 1) {
|
|
382
|
+
const ch = body[i];
|
|
383
|
+
if (ch === "\\" && i + 1 < body.length) {
|
|
384
|
+
cur += ch + body[i + 1];
|
|
385
|
+
i += 1;
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (ch === "`")
|
|
389
|
+
code = !code;
|
|
390
|
+
if (ch === "|" && !code) {
|
|
391
|
+
cells.push(cur);
|
|
392
|
+
cur = "";
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
cur += ch;
|
|
396
|
+
}
|
|
397
|
+
cells.push(cur);
|
|
398
|
+
// the leading and trailing walls produce empty edge cells
|
|
399
|
+
if (cells.length > 0 && cells[0].trim() === "")
|
|
400
|
+
cells.shift();
|
|
401
|
+
if (cells.length > 0 && cells[cells.length - 1].trim() === "")
|
|
402
|
+
cells.pop();
|
|
403
|
+
return cells.map((c) => c.trim());
|
|
404
|
+
}
|
|
405
|
+
const DELIM_CELL = /^:?-{1,}:?$/;
|
|
406
|
+
/** The table shape, or null when these lines are NOT a table.
|
|
407
|
+
*
|
|
408
|
+
* Two rejections, both borrowed: a second line that is not a delimiter
|
|
409
|
+
* row means this is prose that contains pipes; and a body row carrying
|
|
410
|
+
* MORE columns than the header is malformed — rendering it would have
|
|
411
|
+
* to guess where the extra content belongs, and a guess printed into
|
|
412
|
+
* scrollback is indistinguishable from a fact. Rejected tables fall
|
|
413
|
+
* back to their own source bytes, which are still valid markdown. */
|
|
414
|
+
export function tableShape(lines) {
|
|
415
|
+
if (lines.length < 2)
|
|
416
|
+
return null;
|
|
417
|
+
const header = splitCells(lines[0]);
|
|
418
|
+
const delim = splitCells(lines[1]);
|
|
419
|
+
if (header.length === 0 || delim.length !== header.length)
|
|
420
|
+
return null;
|
|
421
|
+
if (!delim.every((c) => DELIM_CELL.test(c)))
|
|
422
|
+
return null;
|
|
423
|
+
const align = delim.map((c) => (c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : "left"));
|
|
424
|
+
const rows = [];
|
|
425
|
+
for (const line of lines.slice(2)) {
|
|
426
|
+
const cells = splitCells(line);
|
|
427
|
+
if (cells.length > header.length)
|
|
428
|
+
return null;
|
|
429
|
+
while (cells.length < header.length)
|
|
430
|
+
cells.push(""); // a short row is padded — nothing is invented
|
|
431
|
+
rows.push(cells);
|
|
432
|
+
}
|
|
433
|
+
return { header, align, rows };
|
|
434
|
+
}
|
|
435
|
+
/** The list: `•` normalization, numbers kept, 2 spaces per nesting
|
|
436
|
+
* level, and the HANGING INDENT — a wrapped item's continuation lines
|
|
437
|
+
* align to the text column, never back to the left margin. */
|
|
438
|
+
function listRows(b, W) {
|
|
439
|
+
const p = palette();
|
|
440
|
+
const out = [];
|
|
441
|
+
let lead = "";
|
|
442
|
+
let text = "";
|
|
443
|
+
const flush = () => {
|
|
444
|
+
if (lead === "")
|
|
445
|
+
return;
|
|
446
|
+
// the HANGING INDENT: the continuation prefix is the marker's own
|
|
447
|
+
// visible width, so a wrapped item's later rows align to the text
|
|
448
|
+
// column instead of returning to the margin.
|
|
449
|
+
out.push(...wrap(inlineSpans(text, ""), W, lead, " ".repeat(displayWidth(lead))));
|
|
450
|
+
};
|
|
451
|
+
for (const line of b.lines) {
|
|
452
|
+
const m = ITEM.exec(line);
|
|
453
|
+
if (m === null) {
|
|
454
|
+
text += ` ${line.trim()}`; // an indented continuation of the item
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
flush();
|
|
458
|
+
const depth = Math.min(5, Math.floor(m[1].length / 2));
|
|
459
|
+
// `•` normalization for bullets; a numbered list KEEPS its numbers
|
|
460
|
+
// (they are the author's meaning, not decoration).
|
|
461
|
+
const marker = /^\d/.test(m[2]) ? `${m[2]} ` : "• ";
|
|
462
|
+
lead = `${" ".repeat(depth + 1)}${marker}`;
|
|
463
|
+
text = m[3];
|
|
464
|
+
}
|
|
465
|
+
flush();
|
|
466
|
+
return out.length > 0 ? out : [""];
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* The table. Columns are measured at their NATURAL widths, on the
|
|
470
|
+
* inline-rendered text with the SGR stripped (a bold cell is four
|
|
471
|
+
* columns, not twelve). If the whole table fits, it is drawn aligned
|
|
472
|
+
* with the dim rails; if it does not, it does NOT shrink and it does
|
|
473
|
+
* NOT cut — every row becomes a record, and every cell survives.
|
|
474
|
+
*
|
|
475
|
+
* A rejected shape (no delimiter row, or a body row wider than the
|
|
476
|
+
* header) falls back to its own source lines, which are still valid
|
|
477
|
+
* markdown. That is the honest exit: a guess about where the extra
|
|
478
|
+
* content belongs would be indistinguishable, once committed, from a
|
|
479
|
+
* fact.
|
|
480
|
+
*/
|
|
481
|
+
function tableRows(b, W) {
|
|
482
|
+
const p = palette();
|
|
483
|
+
const t = tableShape(b.lines);
|
|
484
|
+
if (t === null)
|
|
485
|
+
return b.lines.flatMap((l) => wrap(l, W, "", ""));
|
|
486
|
+
const cols = t.header.map((h, i) => Math.max(cellWidth(h), ...t.rows.map((r) => cellWidth(r[i] ?? ""))));
|
|
487
|
+
// the drawn width: one rail, then each column as "│ cell " + its pad
|
|
488
|
+
const total = cols.reduce((n, w) => n + w + 3, 1);
|
|
489
|
+
if (total > W)
|
|
490
|
+
return recordRows(t, W);
|
|
491
|
+
const rail = `${p.dim}│${p.reset}`;
|
|
492
|
+
const row = (cells, bold) => `${rail}${cells.map((c, i) => ` ${pad(c, cols[i], t.align[i], bold)} `).join(rail)}${rail}`;
|
|
493
|
+
return [
|
|
494
|
+
row(t.header, true),
|
|
495
|
+
`${p.dim}├${cols.map((w) => "─".repeat(w + 2)).join("┼")}┤${p.reset}`,
|
|
496
|
+
...t.rows.map((r) => row(r, false)),
|
|
497
|
+
];
|
|
498
|
+
}
|
|
499
|
+
/** A cell's column count: what a human sees, styling removed. */
|
|
500
|
+
function cellWidth(cell) {
|
|
501
|
+
return visibleWidth(inlineSpans(cell, ""));
|
|
502
|
+
}
|
|
503
|
+
/** One padded cell — the styling goes on AFTER the measure, so it can
|
|
504
|
+
* never move a column. */
|
|
505
|
+
function pad(cell, w, align, bold) {
|
|
506
|
+
const p = palette();
|
|
507
|
+
const body = bold ? `${p.bold}${inlineSpans(cell, p.bold)}${p.reset}` : inlineSpans(cell, "");
|
|
508
|
+
const slack = Math.max(0, w - cellWidth(cell));
|
|
509
|
+
const left = align === "right" ? slack : align === "center" ? Math.floor(slack / 2) : 0;
|
|
510
|
+
return `${" ".repeat(left)}${body}${" ".repeat(slack - left)}`;
|
|
511
|
+
}
|
|
512
|
+
/** The narrow degradation: one record per row. The first column names
|
|
513
|
+
* the record (bold, with a dim colon); the rest is a dim `label:
|
|
514
|
+
* value` run joined by `·`, wrapped rather than cut. A blank row
|
|
515
|
+
* separates records — nothing is dropped at any width. */
|
|
516
|
+
function recordRows(t, W) {
|
|
517
|
+
const p = palette();
|
|
518
|
+
const out = [];
|
|
519
|
+
for (const r of t.rows) {
|
|
520
|
+
if (out.length > 0)
|
|
521
|
+
out.push("");
|
|
522
|
+
out.push(...wrap(`${p.bold}${inlineSpans(t.header[0] ?? "", p.bold)}${p.reset}${p.dim}:${p.reset} ${inlineSpans(r[0] ?? "", "")}`, W, "", ""));
|
|
523
|
+
const rest = t.header.slice(1).map((h, i) => `${h}: ${r[i + 1] ?? ""}`);
|
|
524
|
+
if (rest.length > 0)
|
|
525
|
+
out.push(...wrap(`${p.dim}${inlineSpans(rest.join(" · "), p.dim)}${p.reset}`, W, "", ""));
|
|
526
|
+
}
|
|
527
|
+
return out.length > 0 ? out : [row0(t)];
|
|
528
|
+
}
|
|
529
|
+
/** A table with a header and no body rows yet (the streaming case):
|
|
530
|
+
* the header alone, so the block still says what it is. */
|
|
531
|
+
function row0(t) {
|
|
532
|
+
const p = palette();
|
|
533
|
+
return `${p.bold}${t.header.join(" · ")}${p.reset}`;
|
|
534
|
+
}
|
|
535
|
+
// ---- the wrapper ----------------------------------------------------
|
|
536
|
+
const SGR_AT = /^\x1b\[[0-9;]*m/;
|
|
537
|
+
/** CJK closing punctuation — may not OPEN a row (a line that begins
|
|
538
|
+
* with a comma reads as broken). The smallest honest kinsoku set. */
|
|
539
|
+
const NO_START = "、。,.:;?!)」』】〕·…”’";
|
|
540
|
+
/** CJK opening punctuation — may not END one. */
|
|
541
|
+
const NO_END = "(「『【〔“‘";
|
|
542
|
+
/** May a row break between `prev` and `ch`? Only where a script allows
|
|
543
|
+
* it — and never so that a closing mark opens a row or an opening mark
|
|
544
|
+
* ends one. */
|
|
545
|
+
function breaks(prev, ch) {
|
|
546
|
+
if (NO_START.includes(ch) || NO_END.includes(prev))
|
|
547
|
+
return false;
|
|
548
|
+
return breakable(ch.codePointAt(0)) || breakable(prev.codePointAt(0));
|
|
549
|
+
}
|
|
550
|
+
/** Split styled text into break-eligible tokens. SGR sequences are
|
|
551
|
+
* zero-width and ride the token they precede; spaces are their own
|
|
552
|
+
* tokens (they vanish at a break); a CJK character is its own token,
|
|
553
|
+
* which is the whole fix — a space-free run stops being one word. */
|
|
554
|
+
function tokens(text) {
|
|
555
|
+
const out = [];
|
|
556
|
+
let cur = "";
|
|
557
|
+
let w = 0;
|
|
558
|
+
let prev = "";
|
|
559
|
+
const flush = () => {
|
|
560
|
+
if (cur === "")
|
|
561
|
+
return;
|
|
562
|
+
out.push({ text: cur, w, space: false });
|
|
563
|
+
cur = "";
|
|
564
|
+
w = 0;
|
|
565
|
+
};
|
|
566
|
+
for (let i = 0; i < text.length;) {
|
|
567
|
+
const m = SGR_AT.exec(text.slice(i));
|
|
568
|
+
if (m !== null) {
|
|
569
|
+
cur += m[0];
|
|
570
|
+
i += m[0].length;
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
// code POINT stepping — a surrogate pair is one character and can
|
|
574
|
+
// never be cut in half (the halves would measure one column each
|
|
575
|
+
// while the terminal draws two replacement glyphs)
|
|
576
|
+
const cp = text.codePointAt(i);
|
|
577
|
+
const ch = String.fromCodePoint(cp);
|
|
578
|
+
i += ch.length;
|
|
579
|
+
if (ch === " " || ch === "\t") {
|
|
580
|
+
flush();
|
|
581
|
+
out.push({ text: " ", w: 1, space: true });
|
|
582
|
+
prev = ch;
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (cur !== "" && prev !== "" && breaks(prev, ch))
|
|
586
|
+
flush();
|
|
587
|
+
cur += ch;
|
|
588
|
+
w += charWidth(cp);
|
|
589
|
+
prev = ch;
|
|
590
|
+
}
|
|
591
|
+
flush();
|
|
592
|
+
return out;
|
|
593
|
+
}
|
|
594
|
+
/** The SGR spans still open after `text`, given those open before it. */
|
|
595
|
+
function opensAfter(text, before) {
|
|
596
|
+
let open = [...before];
|
|
597
|
+
for (const m of text.matchAll(/\x1b\[[0-9;]*m/g)) {
|
|
598
|
+
if (m[0] === "\x1b[0m")
|
|
599
|
+
open = [];
|
|
600
|
+
else if (m[0] === "\x1b[23m")
|
|
601
|
+
open = open.filter((s) => s !== "\x1b[3m");
|
|
602
|
+
else
|
|
603
|
+
open.push(m[0]);
|
|
604
|
+
}
|
|
605
|
+
return open;
|
|
606
|
+
}
|
|
607
|
+
/** Break one over-wide token by code point — a long identifier or URL
|
|
608
|
+
* that cannot fit a whole row. Never a truncation: every piece is
|
|
609
|
+
* emitted. */
|
|
610
|
+
function pieces(text, room) {
|
|
611
|
+
const out = [];
|
|
612
|
+
let cur = "";
|
|
613
|
+
let w = 0;
|
|
614
|
+
for (let i = 0; i < text.length;) {
|
|
615
|
+
const m = SGR_AT.exec(text.slice(i));
|
|
616
|
+
if (m !== null) {
|
|
617
|
+
cur += m[0];
|
|
618
|
+
i += m[0].length;
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
const cp = text.codePointAt(i);
|
|
622
|
+
const ch = String.fromCodePoint(cp);
|
|
623
|
+
const cw = charWidth(cp);
|
|
624
|
+
if (w + cw > room && w > 0) {
|
|
625
|
+
out.push(cur);
|
|
626
|
+
cur = "";
|
|
627
|
+
w = 0;
|
|
628
|
+
}
|
|
629
|
+
cur += ch;
|
|
630
|
+
w += cw;
|
|
631
|
+
i += ch.length;
|
|
632
|
+
}
|
|
633
|
+
if (cur !== "")
|
|
634
|
+
out.push(cur);
|
|
635
|
+
return out;
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* Wrap styled text into rows of at most W columns, with a HANGING
|
|
639
|
+
* INDENT: `first` prefixes the first row, `hang` every later one, and
|
|
640
|
+
* the text column is what continuations align to.
|
|
641
|
+
*
|
|
642
|
+
* The SGR spans open at a break are closed at the row's end and
|
|
643
|
+
* reopened at the next row's start, so no style leaks into the padding
|
|
644
|
+
* and none is lost across the break. Italic's own close (23) is
|
|
645
|
+
* understood, so `\x1b[3m…\x1b[23m` inside a bold heading tracks
|
|
646
|
+
* correctly.
|
|
647
|
+
*
|
|
648
|
+
* Every emitted row measures ≤ W through the SAME width authority the
|
|
649
|
+
* compositor's invariant ① measures with — which is the only way the
|
|
650
|
+
* two can agree.
|
|
651
|
+
*/
|
|
652
|
+
export function mdWrap(text, W, first, hang) {
|
|
653
|
+
const rows = [];
|
|
654
|
+
// a degenerate geometry (a deeply nested marker in a very narrow
|
|
655
|
+
// terminal) can make the PREFIX itself wider than the row. The prefix
|
|
656
|
+
// is chrome we generate, so it yields: it is cut to leave room for one
|
|
657
|
+
// WIDE character (two columns — a row that cannot hold one CJK glyph
|
|
658
|
+
// cannot hold the content it exists for), and the invariant holds
|
|
659
|
+
// instead of throwing on our own decoration.
|
|
660
|
+
const fit = (s) => (visibleWidth(s) <= W - 2 ? s : (pieces(s, Math.max(0, W - 2))[0] ?? ""));
|
|
661
|
+
let prefix = fit(first);
|
|
662
|
+
let room = Math.max(1, W - visibleWidth(prefix));
|
|
663
|
+
let line = "";
|
|
664
|
+
let w = 0;
|
|
665
|
+
let open = [];
|
|
666
|
+
let pend = "";
|
|
667
|
+
let pendW = 0;
|
|
668
|
+
const close = () => {
|
|
669
|
+
rows.push(`${prefix}${line}${open.length > 0 ? "\x1b[0m" : ""}`);
|
|
670
|
+
prefix = fit(hang);
|
|
671
|
+
room = Math.max(1, W - visibleWidth(prefix));
|
|
672
|
+
line = open.join("");
|
|
673
|
+
w = 0;
|
|
674
|
+
pend = "";
|
|
675
|
+
pendW = 0;
|
|
676
|
+
};
|
|
677
|
+
for (const t of tokens(text)) {
|
|
678
|
+
if (t.space) {
|
|
679
|
+
// a space at a break vanishes; inside a row it is held until the
|
|
680
|
+
// next word earns it
|
|
681
|
+
if (w > 0) {
|
|
682
|
+
pend += t.text;
|
|
683
|
+
pendW += t.w;
|
|
684
|
+
}
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
if (w > 0 && w + pendW + t.w > room)
|
|
688
|
+
close();
|
|
689
|
+
if (t.w > room) {
|
|
690
|
+
// too wide for any row: break it by code point, each piece its own
|
|
691
|
+
// row except the last, which carries on
|
|
692
|
+
const parts = pieces(t.text, room);
|
|
693
|
+
for (let k = 0; k < parts.length; k += 1) {
|
|
694
|
+
if (k > 0)
|
|
695
|
+
close();
|
|
696
|
+
line += parts[k];
|
|
697
|
+
w += k === parts.length - 1 ? visibleWidth(parts[k]) : room;
|
|
698
|
+
open = opensAfter(parts[k], open);
|
|
699
|
+
}
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
line += pend + t.text;
|
|
703
|
+
w += pendW + t.w;
|
|
704
|
+
open = opensAfter(pend + t.text, open);
|
|
705
|
+
pend = "";
|
|
706
|
+
pendW = 0;
|
|
707
|
+
}
|
|
708
|
+
rows.push(`${prefix}${line}${open.length > 0 ? "\x1b[0m" : ""}`);
|
|
709
|
+
return rows;
|
|
710
|
+
}
|
|
711
|
+
/** The block-level entry: wrap `text` under a first/hang prefix pair. */
|
|
712
|
+
function wrap(text, W, first, hang) {
|
|
713
|
+
return mdWrap(text, W, first, hang);
|
|
714
|
+
}
|
|
715
|
+
/** A fence body line: code, not prose — it still wraps rather than
|
|
716
|
+
* truncating (the no-silent-truncate ruling), every row carries the
|
|
717
|
+
* gutter, and the source line's own indent prefixes every row. */
|
|
718
|
+
function foldLineWidth(line, W, indent = "") {
|
|
719
|
+
return mdWrap(line, Math.max(1, W), indent, indent);
|
|
720
|
+
}
|