@vincemakes/kiso-tui-cells 0.33.0 → 0.34.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/md.d.ts +3 -2
- package/dist/md.js +334 -52
- package/package.json +1 -1
package/dist/md.d.ts
CHANGED
|
@@ -37,8 +37,9 @@
|
|
|
37
37
|
* described twice.
|
|
38
38
|
*/
|
|
39
39
|
/** The block kinds. `fence-open`/`fence-line` are separate kinds on
|
|
40
|
-
* purpose: a fence's rows must be able to freeze ONE AT A TIME
|
|
41
|
-
|
|
40
|
+
* purpose: a fence's rows must be able to freeze ONE AT A TIME, and
|
|
41
|
+
* MD-1.4's `code-line` is the same shape for the same reason. */
|
|
42
|
+
export type MdKind = "para" | "heading" | "list" | "table" | "quote" | "rule" | "fence-open" | "fence-line" | "fence-close" | "code-line";
|
|
42
43
|
/** One block: its SOURCE lines, never a rendered form. The render is a
|
|
43
44
|
* pure function of (block, width), which is what makes the freeze
|
|
44
45
|
* property a property of the scanner alone. */
|
package/dist/md.js
CHANGED
|
@@ -52,6 +52,46 @@ const RULE = /^ {0,3}(?:-{3,}|\*{3,}|_{3,}) *$/;
|
|
|
52
52
|
const QUOTE = /^ {0,3}> ?(.*)$/;
|
|
53
53
|
const TABLE = /^ {0,3}\|/;
|
|
54
54
|
const ITEM = /^( *)([-*+]|\d{1,9}[.)])[ \t]+(.*)$/;
|
|
55
|
+
/** MD-1.4 — a SETEXT underline. Only meaningful under an OPEN paragraph,
|
|
56
|
+
* which is the only place `#line` consults it. Before this, `=` was not a
|
|
57
|
+
* construct at all, so it leaked into the paragraph's own text, and a `-`
|
|
58
|
+
* underline classified as a RULE, so the heading's text became a
|
|
59
|
+
* paragraph with a full-width divider under it. Both outputs were
|
|
60
|
+
* CORRUPTED, not merely unstyled, and a guess printed into scrollback is
|
|
61
|
+
* indistinguishable from a fact. */
|
|
62
|
+
const SETEXT = /^ {0,3}(=+|-+) *$/;
|
|
63
|
+
/** MD-1.4 — an indented code block's line. Four columns, the markdown
|
|
64
|
+
* marker; tested AFTER `ITEM`, so a nested list written with four spaces
|
|
65
|
+
* stays a list. That is a deliberate deviation, and the reason is
|
|
66
|
+
* frequency: a four-space nested list is far more common in model prose
|
|
67
|
+
* than an indented code block that opens with a list marker. A TAB indent
|
|
68
|
+
* is not recognised here (it stays prose) — stated rather than implied.
|
|
69
|
+
*
|
|
70
|
+
* MD1-F4 — and the indent alone is NOT enough: a line that would classify
|
|
71
|
+
* here is PROSE when the most recent block is a `list`. For `1. ` the
|
|
72
|
+
* content indent is three columns, so a line indented four after a blank
|
|
73
|
+
* is the item's continuation paragraph and code inside that item would
|
|
74
|
+
* need seven. Models write that shape constantly — numbered steps, each
|
|
75
|
+
* with an explanatory paragraph under it — and rendering it verbatim
|
|
76
|
+
* destroyed the reflow and kept a four-space indent. `code-line` is
|
|
77
|
+
* therefore reached only after a paragraph, heading, rule, quote, table
|
|
78
|
+
* or fence, or at the start of a message.
|
|
79
|
+
*
|
|
80
|
+
* MD1-F4b — and the context OUTLIVES the paragraph it demoted. A demoted
|
|
81
|
+
* block is the item's continuation, so closing one leaves the context as
|
|
82
|
+
* `list` and the SECOND and third indented paragraphs under one item are
|
|
83
|
+
* prose as well. An earlier version of this rule consulted only the most
|
|
84
|
+
* recent block, and a numbered step with two paragraphs under it rendered
|
|
85
|
+
* the second one verbatim — the same defect one paragraph later. The
|
|
86
|
+
* context ends where it should: at the first unindented block, or at a
|
|
87
|
+
* heading, rule, quote, table or fence, each of which names its own kind.
|
|
88
|
+
*
|
|
89
|
+
* Two consequences, stated rather than discovered: an indented code block
|
|
90
|
+
* nested INSIDE a list item is not recognised (it renders as the item's
|
|
91
|
+
* paragraph), and blank lines inside an indented block collapse to ONE gap
|
|
92
|
+
* row, exactly as they do between paragraphs — two blank lines in a code
|
|
93
|
+
* block come back as one. */
|
|
94
|
+
const CODE = /^ {4,}/;
|
|
55
95
|
/** The kind a line would START. null = blank (a block separator). */
|
|
56
96
|
function classify(line) {
|
|
57
97
|
if (line.trim() === "")
|
|
@@ -68,6 +108,8 @@ function classify(line) {
|
|
|
68
108
|
return "table";
|
|
69
109
|
if (ITEM.test(line))
|
|
70
110
|
return "list";
|
|
111
|
+
if (CODE.test(line))
|
|
112
|
+
return "code-line";
|
|
71
113
|
return "para";
|
|
72
114
|
}
|
|
73
115
|
/** Can `line` JOIN an open block of this kind? Headings, rules and the
|
|
@@ -78,13 +120,16 @@ function joins(kind, line) {
|
|
|
78
120
|
return false; // a blank closes everything
|
|
79
121
|
switch (kind) {
|
|
80
122
|
case "para":
|
|
81
|
-
|
|
123
|
+
// MD-1.4: an indented line CONTINUES a paragraph rather than opening
|
|
124
|
+
// a code block — indented code cannot interrupt a paragraph, so a
|
|
125
|
+
// `code-line` only ever starts one after a blank.
|
|
126
|
+
return c === "para" || c === "code-line";
|
|
82
127
|
case "list":
|
|
83
128
|
// a list item's continuation must be INDENTED — an unindented
|
|
84
129
|
// paragraph after a list starts a paragraph (the lazy-continuation
|
|
85
130
|
// rule is a documented deviation: predictable beats compliant when
|
|
86
131
|
// the output is committed).
|
|
87
|
-
return c === "list" || (c === "para" && /^[ \t]/.test(line));
|
|
132
|
+
return c === "list" || ((c === "para" || c === "code-line") && /^[ \t]/.test(line));
|
|
88
133
|
case "table":
|
|
89
134
|
return c === "table";
|
|
90
135
|
case "quote":
|
|
@@ -93,6 +138,16 @@ function joins(kind, line) {
|
|
|
93
138
|
return false;
|
|
94
139
|
}
|
|
95
140
|
}
|
|
141
|
+
/** A heading's LEVEL and TEXT, ATX or setext. A setext block's lines are
|
|
142
|
+
* the paragraph's own with the underline last — the SOURCE, never a
|
|
143
|
+
* rewritten ATX form, so the block still says what the model sent. */
|
|
144
|
+
function headingShape(lines) {
|
|
145
|
+
const last = lines[lines.length - 1] ?? "";
|
|
146
|
+
if (lines.length > 1 && SETEXT.test(last))
|
|
147
|
+
return { level: last.trimStart().startsWith("=") ? 1 : 2, text: lines.slice(0, -1).join(" ") };
|
|
148
|
+
const m = HEADING.exec(lines[0] ?? "");
|
|
149
|
+
return { level: (m?.[1] ?? "#").length, text: m?.[2] ?? lines[0] ?? "" };
|
|
150
|
+
}
|
|
96
151
|
/** The fence marker a line opens with (``` or ~~~, 3+). */
|
|
97
152
|
function fenceMark(line) {
|
|
98
153
|
return FENCE.exec(line)?.[1] ?? "```";
|
|
@@ -121,6 +176,18 @@ export class MdStream {
|
|
|
121
176
|
/** blocks STARTED so far — the gap rule's only input (the first block
|
|
122
177
|
* of a message opens tight; every later one carries its own blank). */
|
|
123
178
|
#started = 0;
|
|
179
|
+
/** MD-1.4 / MD1-F4 — the kind of the most recent BLOCK, which is what
|
|
180
|
+
* decides whether an indented line is code or prose. Kept HERE rather
|
|
181
|
+
* than read back off `#closed`: the tail block is not in `#closed` at
|
|
182
|
+
* all, and `blocks()` hands out fresh objects. It is only ever read
|
|
183
|
+
* while `#open` is null, so "most recent" is never ambiguous. */
|
|
184
|
+
#lastKind = null;
|
|
185
|
+
/** MD-1.4 — was the last COMPLETE line an indented code line? Each such
|
|
186
|
+
* line is its own block (line-local, so a long indented block streams
|
|
187
|
+
* through the live region exactly as a fence body does), which means the
|
|
188
|
+
* gap rule needs to know that the block before it was the same block:
|
|
189
|
+
* the FIRST line of a block carries the blank, the rest do not. */
|
|
190
|
+
#code = false;
|
|
124
191
|
/** Append streamed text. Only COMPLETE lines reach the state machine. */
|
|
125
192
|
push(text) {
|
|
126
193
|
// the renderer consumes already-scrubbed text and never re-introduces
|
|
@@ -157,16 +224,30 @@ export class MdStream {
|
|
|
157
224
|
out.push(p === "" ? frozen(this.#open) : frozen(this.#open, p));
|
|
158
225
|
return out;
|
|
159
226
|
}
|
|
160
|
-
const k =
|
|
227
|
+
const { kind: k } = this.#kindOf(p);
|
|
161
228
|
if (k !== null)
|
|
162
|
-
out.push({ kind: k, lines: [p], gap: this.#started > 0, lang: k === "fence-open" ? fenceLang(p) : "" });
|
|
229
|
+
out.push({ kind: k, lines: [p], gap: this.#started > 0 && !(k === "code-line" && this.#code), lang: k === "fence-open" ? fenceLang(p) : "" });
|
|
163
230
|
return out;
|
|
164
231
|
}
|
|
232
|
+
/** MD1-F4 — the kind a line starts HERE: `classify` plus the one piece of
|
|
233
|
+
* context that decides code from prose, an indented line after a list
|
|
234
|
+
* being the list item's continuation paragraph. `demoted` says the
|
|
235
|
+
* answer CAME from that context, which is what the open block is marked
|
|
236
|
+
* with so the context can outlive it (MD1-F4b). */
|
|
237
|
+
#kindOf(line) {
|
|
238
|
+
const k = classify(line);
|
|
239
|
+
const demoted = k === "code-line" && this.#lastKind === "list";
|
|
240
|
+
return { kind: demoted ? "para" : k, demoted };
|
|
241
|
+
}
|
|
165
242
|
/** How many leading blocks are CLOSED — the commit-eligible count. */
|
|
166
243
|
closed() {
|
|
167
244
|
return this.#closed.length;
|
|
168
245
|
}
|
|
169
246
|
#line(line) {
|
|
247
|
+
// MD-1.4: `#code` describes the PREVIOUS complete line, so it is read
|
|
248
|
+
// and cleared here and set again only on the code-line path.
|
|
249
|
+
const wasCode = this.#code;
|
|
250
|
+
this.#code = false;
|
|
170
251
|
if (this.#fence !== null) {
|
|
171
252
|
// E2: the closer emits its OWN block now. The rule it used to
|
|
172
253
|
// obey — "a bottom border is drawn only by an actual close, and
|
|
@@ -183,7 +264,31 @@ export class MdStream {
|
|
|
183
264
|
this.#push({ kind: "fence-line", lines: [line], gap: false, lang: "" });
|
|
184
265
|
return;
|
|
185
266
|
}
|
|
186
|
-
|
|
267
|
+
// MD-1.4 — a SETEXT underline closes the OPEN paragraph as a HEADING.
|
|
268
|
+
// The decision is taken on the COMPLETE underline line, which is the
|
|
269
|
+
// freeze rule itself: the paragraph has not closed yet when the
|
|
270
|
+
// underline arrives, so no committed row changes. This is also the
|
|
271
|
+
// whole of the `rule` interaction — a `-` underline outranks RULE
|
|
272
|
+
// exactly where a paragraph is open, which is the only condition under
|
|
273
|
+
// which it could be an underline at all. The block keeps its SOURCE
|
|
274
|
+
// lines, underline last; `headingShape` reads the level off it.
|
|
275
|
+
//
|
|
276
|
+
// One interaction worth naming rather than leaving to be discovered:
|
|
277
|
+
// the live cell holding an OPEN paragraph is eligible for the
|
|
278
|
+
// compositor's force-commit (`#capLive` excludes only a RUNNING tool
|
|
279
|
+
// card), so on a short terminal a long paragraph can reach scrollback
|
|
280
|
+
// before its underline arrives — and the promotion then applies to
|
|
281
|
+
// rows that are already committed and plain. Pre-existing class (the
|
|
282
|
+
// report's §4.9 escalated risk, FINDING TUI2-MD-1's neighbourhood);
|
|
283
|
+
// what is new here is that the flip it misses is a STYLE flip. Not
|
|
284
|
+
// reproduced in this round.
|
|
285
|
+
if (this.#open !== null && this.#open.kind === "para" && SETEXT.test(line)) {
|
|
286
|
+
this.#closed.push({ kind: "heading", lines: [...this.#open.lines, line], gap: this.#open.gap, lang: "" });
|
|
287
|
+
this.#open = null;
|
|
288
|
+
this.#lastKind = "heading";
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const { kind: k, demoted } = this.#kindOf(line);
|
|
187
292
|
if (k === null) {
|
|
188
293
|
this.#shut();
|
|
189
294
|
return;
|
|
@@ -202,18 +307,32 @@ export class MdStream {
|
|
|
202
307
|
this.#push({ kind: k, lines: [line], gap: this.#started > 0, lang: "" });
|
|
203
308
|
return;
|
|
204
309
|
}
|
|
205
|
-
|
|
310
|
+
if (k === "code-line") {
|
|
311
|
+
// line-local like a fence body: final the moment its newline lands.
|
|
312
|
+
// Only the FIRST line of the block carries the blank above it.
|
|
313
|
+
this.#push({ kind: k, lines: [line], gap: this.#started > 0 && !wasCode, lang: "" });
|
|
314
|
+
this.#code = true;
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
this.#open = { kind: k, lines: [line], gap: this.#started > 0, lang: "", cont: demoted };
|
|
206
318
|
this.#started += 1;
|
|
207
319
|
}
|
|
208
320
|
/** A block that is final the moment its line is. */
|
|
209
321
|
#push(b) {
|
|
210
322
|
this.#closed.push(b);
|
|
211
323
|
this.#started += 1;
|
|
324
|
+
this.#lastKind = b.kind;
|
|
212
325
|
}
|
|
213
326
|
#shut() {
|
|
214
327
|
if (this.#open === null)
|
|
215
328
|
return;
|
|
216
329
|
this.#closed.push(frozen(this.#open));
|
|
330
|
+
// MD1-F4b — a DEMOTED block is the list item's continuation, so the
|
|
331
|
+
// list context outlives it: the second and third indented paragraphs
|
|
332
|
+
// under one item are prose too. The context ends where it should, at
|
|
333
|
+
// the first unindented block or at a heading, rule, quote, table or
|
|
334
|
+
// fence, because those set `#lastKind` to their own kind.
|
|
335
|
+
this.#lastKind = this.#open.cont ? "list" : this.#open.kind;
|
|
217
336
|
this.#open = null;
|
|
218
337
|
}
|
|
219
338
|
}
|
|
@@ -232,10 +351,22 @@ export function renderMarkdown(text, W) {
|
|
|
232
351
|
/** One block's screen rows. Pure in (block, W) — this is the whole
|
|
233
352
|
* freeze guarantee: same source, same width, same bytes, forever. */
|
|
234
353
|
export function renderBlock(b, W) {
|
|
235
|
-
|
|
236
|
-
return b.gap ? ["", ...rows] : rows;
|
|
354
|
+
return withGap(b, blockBody(b, Math.max(1, W), 0));
|
|
237
355
|
}
|
|
238
|
-
|
|
356
|
+
/** The markdown rhythm: a block that carries `gap` opens with a blank row.
|
|
357
|
+
* One helper because there are two callers — this module's entry point and
|
|
358
|
+
* the nested render inside a quote, which has to reproduce the rhythm of
|
|
359
|
+
* the blocks it contains. */
|
|
360
|
+
function withGap(b, rows) {
|
|
361
|
+
return b.gap ? ["", ...rows] : [...rows];
|
|
362
|
+
}
|
|
363
|
+
/** MD-1.5 — how deep a quote may nest before it stops being rendered as
|
|
364
|
+
* blocks. A `>>>>>` quote in a narrow terminal spends two columns per
|
|
365
|
+
* level, and past this it degrades to the flattened form rather than
|
|
366
|
+
* recursing on nothing. Cheap insurance: the wrapper already degrades an
|
|
367
|
+
* over-wide prefix instead of throwing. */
|
|
368
|
+
const QUOTE_DEPTH = 4;
|
|
369
|
+
function blockBody(b, W, depth) {
|
|
239
370
|
const p = palette();
|
|
240
371
|
switch (b.kind) {
|
|
241
372
|
case "heading": {
|
|
@@ -247,18 +378,31 @@ function blockBody(b, W) {
|
|
|
247
378
|
// and a marker is the only carrier that survives a pipe. A
|
|
248
379
|
// `**bold**` inside a heading is still a no-op, which is the mono
|
|
249
380
|
// discipline paying for itself.
|
|
250
|
-
const
|
|
251
|
-
const level = (m?.[1] ?? "#").length;
|
|
252
|
-
const text = m?.[2] ?? b.lines[0] ?? "";
|
|
381
|
+
const { level, text } = headingShape(b.lines);
|
|
253
382
|
const style = level === 1 ? `${p.bold}${p.underline}` : p.bold;
|
|
254
383
|
const marker = level >= 3 ? `${"#".repeat(level)} ` : "";
|
|
255
384
|
return wrap(`${style}${marker}${inlineSpans(text, style)}${p.reset}`, W, "", "");
|
|
256
385
|
}
|
|
257
|
-
case "rule":
|
|
258
|
-
// R2: the
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
|
|
386
|
+
case "rule": {
|
|
387
|
+
// R2: the rule at the block's own width. The 28 was a guess that
|
|
388
|
+
// read as a short line rather than a divider.
|
|
389
|
+
//
|
|
390
|
+
// MD-1.6 — and it takes the BLOCK INSET, two columns, like a fence
|
|
391
|
+
// body. Without it this row and `boxTop` emitted identical bytes —
|
|
392
|
+
// same glyph, same dim, same full width — so in scrollback you could
|
|
393
|
+
// not tell "the model drew a divider" from "kiso closed a panel".
|
|
394
|
+
// The fix is the inset and NOT a new glyph: R3 (owner, 2026-08-27)
|
|
395
|
+
// ruled the rule is a solid hairline everywhere, "one line, one
|
|
396
|
+
// weight, no exceptions to remember", and a new glyph would reverse
|
|
397
|
+
// that. Content and chrome differ by their LEFT EDGE instead, which
|
|
398
|
+
// is how every other register is told apart under R13 E3.
|
|
399
|
+
//
|
|
400
|
+
// The inset is chrome this renderer generates, so at a width that
|
|
401
|
+
// cannot pay for it, it yields — the same rule `mdWrap` applies to
|
|
402
|
+
// an over-wide prefix.
|
|
403
|
+
const inset = W >= 4 ? " " : "";
|
|
404
|
+
return [`${inset}${p.dim}${"\u2500".repeat(Math.max(1, W - inset.length))}${p.reset}`];
|
|
405
|
+
}
|
|
262
406
|
case "fence-open":
|
|
263
407
|
// E2: the RAIL, not a gutter. A block drawn with ``` is still a
|
|
264
408
|
// fenced block when a human selects it and pastes it somewhere
|
|
@@ -271,14 +415,20 @@ function blockBody(b, W) {
|
|
|
271
415
|
// unterminated fence draws no bottom, which is the truth about
|
|
272
416
|
// an unterminated fence.
|
|
273
417
|
return [`${p.dim}${RAIL}${p.reset}`];
|
|
274
|
-
case "fence-line":
|
|
418
|
+
case "fence-line":
|
|
419
|
+
case "code-line": {
|
|
275
420
|
// a fence body's INDENTATION is its content. The wrapper drops
|
|
276
421
|
// leading spaces \u2014 right for prose, a lie for code \u2014 so the indent
|
|
277
422
|
// rides as the row prefix instead, and a wrapped long line hangs
|
|
278
423
|
// under it rather than returning to the gutter.
|
|
279
424
|
const src = (b.lines[0] ?? "").replace(/\t/g, " ");
|
|
280
425
|
const indent = /^ */.exec(src)[0];
|
|
281
|
-
|
|
426
|
+
// MD-1.4: a fence body insets under its own ``` rails. An indented
|
|
427
|
+
// code block has no rails, so it takes no gutter either: its four
|
|
428
|
+
// spaces ARE the marker the model wrote, and a block that pastes
|
|
429
|
+
// back as indented code is the copy-fidelity goal. Saying "verbatim"
|
|
430
|
+
// twice would cost four columns and buy nothing.
|
|
431
|
+
const gutter = b.kind === "fence-line" ? " " : "";
|
|
282
432
|
// DC-3: a fenced BODY carries no colour token. It used to take
|
|
283
433
|
// `code` — 1.54:1 on a white terminal, applied to whole blocks,
|
|
284
434
|
// which made the code the model just wrote the least readable
|
|
@@ -288,15 +438,8 @@ function blockBody(b, W) {
|
|
|
288
438
|
// nothing.
|
|
289
439
|
return foldLineWidth(src.slice(indent.length), W - visibleWidth(gutter), indent).map((r) => `${gutter}${r}`);
|
|
290
440
|
}
|
|
291
|
-
case "quote":
|
|
292
|
-
|
|
293
|
-
// R2: one gutter glyph. A quote and a fenced block both say "this
|
|
294
|
-
// text is not mine", and the screen was saying it two ways — ▏
|
|
295
|
-
// here and │ for code. The fences took their own ``` rails, so │
|
|
296
|
-
// is free and the quote takes it.
|
|
297
|
-
const gutter = `${p.dim}\u2502${p.reset} `;
|
|
298
|
-
return wrap(`${p.dim}${inlineSpans(text, p.dim)}${p.reset}`, W - visibleWidth(gutter), "", "").map((r) => `${gutter}${r}`);
|
|
299
|
-
}
|
|
441
|
+
case "quote":
|
|
442
|
+
return quoteRows(b, W, depth);
|
|
300
443
|
case "list":
|
|
301
444
|
return listRows(b, W);
|
|
302
445
|
case "table":
|
|
@@ -507,12 +650,53 @@ function listRows(b, W) {
|
|
|
507
650
|
flush();
|
|
508
651
|
return out.length > 0 ? out : [""];
|
|
509
652
|
}
|
|
653
|
+
/**
|
|
654
|
+
* The quote. Its content is BLOCKS, and it is rendered as blocks: the `> `
|
|
655
|
+
* markers come off, the stripped text goes through a NESTED stream — a
|
|
656
|
+
* local, built per render, so `renderBlock` stays pure in (block, W) — and
|
|
657
|
+
* every row it produces takes the `│ ` gutter.
|
|
658
|
+
*
|
|
659
|
+
* R2: one gutter glyph. A quote and a fenced block both say "this text is
|
|
660
|
+
* not mine", and the screen was saying it two ways — ▏ here and │ for code.
|
|
661
|
+
* The fences took their own ``` rails, so │ is free and the quote takes it.
|
|
662
|
+
*
|
|
663
|
+
* MD-1.5 — joining the quote's lines with spaces made a quoted list and a
|
|
664
|
+
* quoted second paragraph into one reflowed line, which is the same class
|
|
665
|
+
* of defect as MD-1.4's: the structure the model sent was destroyed, not
|
|
666
|
+
* merely unstyled.
|
|
667
|
+
*
|
|
668
|
+
* MD-1.5's judgement call: the blanket `dim` over the whole quote is GONE
|
|
669
|
+
* and the gutter carries "not mine" alone. With inner blocks the blanket
|
|
670
|
+
* dim would put dim OVER bold in a quoted heading, which is a
|
|
671
|
+
* contradiction, and over a fence body's inset in a quoted fence. It is
|
|
672
|
+
* also MD-1.2's argument one item earlier: `dim` is a LABEL tier and a
|
|
673
|
+
* quote is body text. The gutter stays dim, because a gutter IS a label.
|
|
674
|
+
*/
|
|
675
|
+
function quoteRows(b, W, depth) {
|
|
676
|
+
const p = palette();
|
|
677
|
+
const gutter = `${p.dim}\u2502${p.reset} `;
|
|
678
|
+
const bar = `${p.dim}\u2502${p.reset}`;
|
|
679
|
+
const text = b.lines.map((l) => QUOTE.exec(l)?.[1] ?? l).join("\n");
|
|
680
|
+
const room = Math.max(1, W - visibleWidth(gutter));
|
|
681
|
+
if (depth >= QUOTE_DEPTH)
|
|
682
|
+
return wrap(inlineSpans(text.split("\n").join(" "), ""), room, "", "").map((r) => `${gutter}${r}`);
|
|
683
|
+
const inner = new MdStream();
|
|
684
|
+
inner.push(text);
|
|
685
|
+
inner.end();
|
|
686
|
+
const rows = inner.blocks().flatMap((blk) => withGap(blk, blockBody(blk, room, depth + 1)));
|
|
687
|
+
// a blank row inside a quote takes the bar and no trailing space: the
|
|
688
|
+
// gutter says "still the quote", the space would be whitespace a human
|
|
689
|
+
// copies for nothing.
|
|
690
|
+
return rows.map((r) => (r === "" ? bar : `${gutter}${r}`));
|
|
691
|
+
}
|
|
510
692
|
/**
|
|
511
693
|
* The table. Columns are measured at their NATURAL widths, on the
|
|
512
694
|
* inline-rendered text with the SGR stripped (a bold cell is four
|
|
513
|
-
* columns, not twelve). If the
|
|
514
|
-
*
|
|
515
|
-
*
|
|
695
|
+
* columns, not twelve). If the natural widths fit, the table is drawn at
|
|
696
|
+
* them; if they do not, the columns SHRINK and the cells wrap inside
|
|
697
|
+
* them (MD-1.1); only when every column has reached its floor and the
|
|
698
|
+
* table still does not fit does every row become a record. It never
|
|
699
|
+
* cuts, at any width, in any form.
|
|
516
700
|
*
|
|
517
701
|
* A rejected shape (no delimiter row, or a body row wider than the
|
|
518
702
|
* header) falls back to its own source lines, which are still valid
|
|
@@ -521,40 +705,126 @@ function listRows(b, W) {
|
|
|
521
705
|
* fact.
|
|
522
706
|
*/
|
|
523
707
|
function tableRows(b, W) {
|
|
524
|
-
const p = palette();
|
|
525
708
|
const t = tableShape(b.lines);
|
|
526
709
|
if (t === null)
|
|
527
710
|
return b.lines.flatMap((l) => wrap(l, W, "", ""));
|
|
528
|
-
const
|
|
711
|
+
const natural = t.header.map((h, i) => Math.max(cellWidth(h), ...t.rows.map((r) => cellWidth(r[i] ?? ""))));
|
|
712
|
+
const cols = shrinkCols(natural, W);
|
|
713
|
+
if (cols === null)
|
|
714
|
+
return recordRows(t, W);
|
|
715
|
+
const p = palette();
|
|
529
716
|
// R2: no rails. The drawn width is two columns of inset plus the
|
|
530
717
|
// columns and their two-space gutters — a table is bounded by the
|
|
531
718
|
// blank lines above and below it, exactly as every other block on the
|
|
532
719
|
// screen is, and it was the last box left on a screen that has decided
|
|
533
720
|
// not to have boxes. Alignment does the work the rails were doing, and
|
|
534
721
|
// a copied table is closer to markdown without them.
|
|
535
|
-
const
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
722
|
+
const row = (cells, bold) => {
|
|
723
|
+
const boxes = cells.map((c, i) => cellBox(c, cols[i], t.align[i], bold));
|
|
724
|
+
const rows = [];
|
|
725
|
+
for (let k = 0; k < Math.max(...boxes.map((x) => x.length)); k += 1) {
|
|
726
|
+
// a short box pays its blanks so the columns to its right do not
|
|
727
|
+
// move: a cell is a BOX, and the row is as tall as its tallest.
|
|
728
|
+
rows.push(` ${boxes.map((x, i) => x[k] ?? " ".repeat(cols[i])).join(" ")}`.replace(/\s+$/, ""));
|
|
729
|
+
}
|
|
730
|
+
return rows;
|
|
731
|
+
};
|
|
732
|
+
// MD-1.3 / R2 AMENDMENT 1 (owner ruling, 2026-09-11) — ONE rule under
|
|
733
|
+
// the header row, at the grid's own width. R2 removed the RAILS: the
|
|
734
|
+
// four-sided box that BOUNDS a table. This bounds nothing; it SEPARATES
|
|
735
|
+
// the header from the body, which is the one job the round's governing
|
|
736
|
+
// distinction gives a rule — a rule separates, a gutter scopes, a rail
|
|
737
|
+
// bounds. Rails stay out.
|
|
738
|
+
//
|
|
739
|
+
// What it buys is not decoration: without it a six-row table's header
|
|
740
|
+
// was carried by SGR bold ALONE, so in a pipe, under NO_COLOR, or on a
|
|
741
|
+
// terminal with weak bold, seven identical rows arrived with nothing
|
|
742
|
+
// saying which one names the columns.
|
|
743
|
+
const ruleW = cols.reduce((n, w) => n + w, 0) + Math.max(0, cols.length - 1) * 2;
|
|
744
|
+
return [...row(t.header, true), ` ${p.dim}${"\u2500".repeat(ruleW)}${p.reset}`, ...t.rows.flatMap((r) => row(r, false))];
|
|
540
745
|
}
|
|
541
746
|
/** A cell's column count: what a human sees, styling removed. */
|
|
542
747
|
function cellWidth(cell) {
|
|
543
748
|
return visibleWidth(inlineSpans(cell, ""));
|
|
544
749
|
}
|
|
545
|
-
/**
|
|
546
|
-
*
|
|
547
|
-
|
|
750
|
+
/** MD-1.1 — the SHRINK FLOOR: eight columns, four CJK characters. A
|
|
751
|
+
* column whose natural width is already at or below it never shrinks at
|
|
752
|
+
* all. The 8 is a judgement and not a measurement — it is where the
|
|
753
|
+
* report's sample stopped reading as a table — and it is the one number
|
|
754
|
+
* that decides when the record form is still the better answer. */
|
|
755
|
+
const CELL_FLOOR = 8;
|
|
756
|
+
/** The drawn width of a grid with these columns, by the measure the R2
|
|
757
|
+
* table has always used: the two-column inset plus every column AND its
|
|
758
|
+
* two-space gutter. Conservative by one gutter (the last column has
|
|
759
|
+
* none), which is where the table's right margin comes from — kept as
|
|
760
|
+
* it was, because the record threshold has always been stated in it. */
|
|
761
|
+
function gridWidth(cols) {
|
|
762
|
+
return cols.reduce((n, w) => n + w + 2, 2);
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* MD-1.1 — take one column off the WIDEST column until the grid fits.
|
|
766
|
+
* Returns null when every column has reached its floor and it still does
|
|
767
|
+
* not, which is the one case the record form exists for.
|
|
768
|
+
*
|
|
769
|
+
* There was no shrink step at all before this: a table either fitted at
|
|
770
|
+
* its natural width or the whole block was abandoned. That made the
|
|
771
|
+
* degradation a cliff — the owner's 6-column sample missed the grid by 8
|
|
772
|
+
* columns at terminal 80 and collapsed into eleven rows of records, when
|
|
773
|
+
* the two columns carrying long CJK phrases would each have wrapped
|
|
774
|
+
* inside their cell for free.
|
|
775
|
+
*
|
|
776
|
+
* Greedy, and therefore PREDICTABLE rather than optimal: always the
|
|
777
|
+
* widest column, ties to the left. A column holding one long unbreakable
|
|
778
|
+
* token will spend its way to the floor and force its neighbours
|
|
779
|
+
* narrower — the cost of a rule a human can hold in their head.
|
|
780
|
+
*/
|
|
781
|
+
function shrinkCols(natural, W) {
|
|
782
|
+
const floor = natural.map((w) => Math.min(w, CELL_FLOOR));
|
|
783
|
+
const cols = [...natural];
|
|
784
|
+
while (gridWidth(cols) > W) {
|
|
785
|
+
let at = -1;
|
|
786
|
+
for (let i = 0; i < cols.length; i += 1)
|
|
787
|
+
if (cols[i] > floor[i] && (at < 0 || cols[i] > cols[at]))
|
|
788
|
+
at = i;
|
|
789
|
+
if (at < 0)
|
|
790
|
+
return null;
|
|
791
|
+
cols[at] = cols[at] - 1;
|
|
792
|
+
}
|
|
793
|
+
return cols;
|
|
794
|
+
}
|
|
795
|
+
/** One cell as a BOX: its text wrapped inside the column, every row
|
|
796
|
+
* padded to the column's width by the column's alignment. The styling
|
|
797
|
+
* goes on AFTER the measure, so it can never move a column, and the
|
|
798
|
+
* wrapper is the same one every other block folds through — so a cell
|
|
799
|
+
* obeys the kinsoku set and the width authority like all other text. */
|
|
800
|
+
function cellBox(cell, w, align, bold) {
|
|
548
801
|
const p = palette();
|
|
549
802
|
const body = bold ? `${p.bold}${inlineSpans(cell, p.bold)}${p.reset}` : inlineSpans(cell, "");
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
*
|
|
803
|
+
return mdWrap(body, w, "", "").map((r) => {
|
|
804
|
+
const slack = Math.max(0, w - visibleWidth(r));
|
|
805
|
+
const left = align === "right" ? slack : align === "center" ? Math.floor(slack / 2) : 0;
|
|
806
|
+
return `${" ".repeat(left)}${r}${" ".repeat(slack - left)}`;
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* The narrow degradation: one record per row. The first column names the
|
|
811
|
+
* record (bold, with a dim colon); the rest is a `label: value` run
|
|
812
|
+
* joined by `·`, wrapped rather than cut. A blank row separates records —
|
|
813
|
+
* nothing is dropped at any width.
|
|
814
|
+
*
|
|
815
|
+
* MD-1.2 — the LABEL is dim and the VALUE is not. This used to wrap every
|
|
816
|
+
* label AND every value in ONE `p.dim` span, so a table's actual content
|
|
817
|
+
* arrived at the lowest contrast tier on the screen while the labels —
|
|
818
|
+
* scaffolding the reader already read in the header row — carried equal
|
|
819
|
+
* weight. The emphasis was exactly inverted.
|
|
820
|
+
*
|
|
821
|
+
* Per-token contrast was never the defect: `dim` measures 4.54:1 on a
|
|
822
|
+
* resolved light ground, which is legal for a LABEL. Setting a whole
|
|
823
|
+
* paragraph of body text in it is a different thing, and on a terminal
|
|
824
|
+
* that never answered OSC 11 it is worse — the palette keeps SGR 2 there
|
|
825
|
+
* rather than an absolute grey. `dim` is a label tier; this is the first
|
|
826
|
+
* place it was asked to be a body tier, and it is no longer asked.
|
|
827
|
+
*/
|
|
558
828
|
function recordRows(t, W) {
|
|
559
829
|
const p = palette();
|
|
560
830
|
const out = [];
|
|
@@ -562,9 +832,10 @@ function recordRows(t, W) {
|
|
|
562
832
|
if (out.length > 0)
|
|
563
833
|
out.push("");
|
|
564
834
|
out.push(...wrap(`${p.bold}${inlineSpans(t.header[0] ?? "", p.bold)}${p.reset}${p.dim}:${p.reset} ${inlineSpans(r[0] ?? "", "")}`, W, "", ""));
|
|
565
|
-
const rest = t.header.slice(1).map((h, i) => `${h}
|
|
835
|
+
const rest = t.header.slice(1).map((h, i) => `${p.dim}${inlineSpans(h, p.dim)}:${p.reset} ${inlineSpans(r[i + 1] ?? "", "")}`);
|
|
836
|
+
// the `·` stays dim: it is punctuation between pairs, not content.
|
|
566
837
|
if (rest.length > 0)
|
|
567
|
-
out.push(...wrap(`${p.dim}
|
|
838
|
+
out.push(...wrap(rest.join(`${p.dim} · ${p.reset}`), W, "", ""));
|
|
568
839
|
}
|
|
569
840
|
return out.length > 0 ? out : [row0(t)];
|
|
570
841
|
}
|
|
@@ -624,7 +895,18 @@ function tokens(text) {
|
|
|
624
895
|
prev = ch;
|
|
625
896
|
continue;
|
|
626
897
|
}
|
|
627
|
-
|
|
898
|
+
// MD1-F1: a `cur` holding nothing but SGR is ZERO-WIDTH and RIDES the
|
|
899
|
+
// token it precedes — this function's own contract, which the flush
|
|
900
|
+
// below used to break. Emitted as a token of its own it measures 0, so
|
|
901
|
+
// `w + pendW + 0 > room` can take a break AT it: the pending space is
|
|
902
|
+
// dropped, the style lands at the head of the next row, and the word it
|
|
903
|
+
// belonged to goes to the row after. Invisible while every style
|
|
904
|
+
// boundary sat at a space (every ASCII case), reachable the moment one
|
|
905
|
+
// sits before a CJK character, which MD-1.2's per-label dim made
|
|
906
|
+
// common in the record form. What it costs there is a trailing space
|
|
907
|
+
// and an empty style span; what it does NOT cause is FINDING MD1-F3
|
|
908
|
+
// below, which is older than this and survives the guard.
|
|
909
|
+
if (cur !== "" && w > 0 && prev !== "" && breaks(prev, ch))
|
|
628
910
|
flush();
|
|
629
911
|
cur += ch;
|
|
630
912
|
w += charWidth(cp);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-tui-cells",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"description": "kiso tui-cells — the components cell renderer (components, diff, width, the render slice). Zero runtime dependencies: input is data, output is bytes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|