dsh-live-trace 0.1.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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +920 -0
  3. package/README.zh.md +790 -0
  4. package/assets/rain.ogg +0 -0
  5. package/bin/dsh-glyph-probe.js +51 -0
  6. package/bin/dsh-live-trace.js +29 -0
  7. package/bin/dsh-live-working.js +14 -0
  8. package/cordis.patch.yml +31 -0
  9. package/icon.svg +12 -0
  10. package/index.js +328 -0
  11. package/lib/client.js +178 -0
  12. package/lib/instance.js +68 -0
  13. package/lib/normalize.js +850 -0
  14. package/lib/paths.js +66 -0
  15. package/lib/protocol.js +115 -0
  16. package/lib/registry.js +232 -0
  17. package/lib/tools.js +257 -0
  18. package/lib/tracker.js +648 -0
  19. package/lib/transport.js +231 -0
  20. package/locale/en.json +6 -0
  21. package/locale/zh.json +6 -0
  22. package/package.json +94 -0
  23. package/picture/call1.png +0 -0
  24. package/picture/call2.png +0 -0
  25. package/picture/sleep1.png +0 -0
  26. package/picture/sleep2.png +0 -0
  27. package/picture/tui1.png +0 -0
  28. package/picture/tui2.png +0 -0
  29. package/picture/type1.png +0 -0
  30. package/picture/type2.png +0 -0
  31. package/scripts/bench-render.mjs +69 -0
  32. package/scripts/demo-working.mjs +130 -0
  33. package/scripts/demo.mjs +284 -0
  34. package/scripts/install-profile.mjs +174 -0
  35. package/scripts/mock-provider.mjs +211 -0
  36. package/src/cli/cellsize.js +120 -0
  37. package/src/cli/format.js +73 -0
  38. package/src/cli/highlight.js +932 -0
  39. package/src/cli/i18n.js +457 -0
  40. package/src/cli/main.js +630 -0
  41. package/src/cli/markdown.js +753 -0
  42. package/src/cli/renderer.js +1044 -0
  43. package/src/cli/screen.js +270 -0
  44. package/src/cli/theme.js +221 -0
  45. package/src/cli/view-state.js +396 -0
  46. package/src/cli/views.js +406 -0
  47. package/src/cli/width.js +337 -0
  48. package/src/cli/working/art.js +413 -0
  49. package/src/cli/working/main.js +569 -0
  50. package/src/cli/working/packing.js +159 -0
  51. package/src/cli/working/picker.js +75 -0
  52. package/src/cli/working/props.js +385 -0
  53. package/src/cli/working/scene.js +837 -0
  54. package/src/cli/working/sky.js +641 -0
  55. package/src/cli/working/sound.js +400 -0
  56. package/src/cli/working/state.js +528 -0
@@ -0,0 +1,753 @@
1
+ /**
2
+ * Zero-dependency Markdown rendering into styled *rows* for the dashboard.
3
+ *
4
+ * `renderMarkdown()` turns a Markdown document into `Segment[][]` — each row is
5
+ * already wrapped to the requested cell width, so `renderRow(row)` can be
6
+ * emitted directly. Styling comes from two caller-supplied palettes
7
+ * (`md` for Markdown structure, `syn` for fenced code highlighting); every
8
+ * palette value may be `''` to disable color.
9
+ *
10
+ * Design rules:
11
+ * - All incoming text is passed through `sanitize()` first, then every row is
12
+ * produced through `wrapSegments()`/`truncateSegments()`, so
13
+ * `rowWidth(row) <= width` holds for every row and every `width >= 8`.
14
+ * - Malformed input never throws: the public entry point has a last-resort
15
+ * plain-text fallback.
16
+ * - Code is highlighted per line with `highlightLine`/`highlightCode` from
17
+ * `highlight.js`; long code lines wrap under a two-space continuation
18
+ * indent instead of overflowing.
19
+ *
20
+ * @module dsh-live-trace/markdown
21
+ */
22
+
23
+ import { highlightCode } from './highlight.js'
24
+ import { displayWidth, rowWidth, sanitize, truncateSegments, wrapSegments } from './width.js'
25
+
26
+ /**
27
+ * @typedef {{ text: string, sgr?: string }} Segment
28
+ */
29
+
30
+ /** Rows narrower than this cannot carry a gutter; widths are clamped up to it. */
31
+ const MIN_WIDTH = 4
32
+ /** Cheap guard against pathological emphasis nesting. */
33
+ const MAX_INLINE_DEPTH = 6
34
+
35
+ /* ------------------------------------------------------------------ *
36
+ * Segment plumbing
37
+ * ------------------------------------------------------------------ */
38
+
39
+ /**
40
+ * Resolve a palette entry, collapsing `''`/missing to "unstyled".
41
+ * @param {Record<string, string> | undefined} palette
42
+ * @param {string} key
43
+ * @returns {string | undefined}
44
+ */
45
+ function styleOf(palette, key) {
46
+ if (palette === null || palette === undefined) return undefined
47
+ const value = palette[key]
48
+ return typeof value === 'string' && value.length > 0 ? value : undefined
49
+ }
50
+
51
+ /**
52
+ * Concatenate two SGR parameter lists so styles nest.
53
+ * @param {string | undefined} outer
54
+ * @param {string | undefined} inner
55
+ * @returns {string | undefined}
56
+ */
57
+ function combine(outer, inner) {
58
+ if (outer !== undefined && inner !== undefined) return `${outer};${inner}`
59
+ return outer ?? inner
60
+ }
61
+
62
+ /**
63
+ * Append text to a segment list, merging equal styles.
64
+ * @param {Segment[]} out
65
+ * @param {string} text
66
+ * @param {string | undefined} [sgr]
67
+ */
68
+ function pushSeg(out, text, sgr) {
69
+ if (text.length === 0) return
70
+ const style = sgr === '' ? undefined : sgr
71
+ const last = out[out.length - 1]
72
+ if (last !== undefined && last.sgr === style) {
73
+ last.text += text
74
+ return
75
+ }
76
+ if (style === undefined) out.push({ text })
77
+ else out.push({ text, sgr: style })
78
+ }
79
+
80
+ /**
81
+ * Apply an outer style to a segment list (unstyled parts inherit it).
82
+ * @param {Segment[]} segments
83
+ * @param {string | undefined} style
84
+ * @returns {Segment[]}
85
+ */
86
+ function decorate(segments, style) {
87
+ if (style === undefined) return segments
88
+ return segments.map((segment) => ({ text: segment.text, sgr: combine(style, segment.sgr) }))
89
+ }
90
+
91
+ /** Wrap to a positive budget; a non-positive budget yields no rows. */
92
+ function wrapTo(segments, budget) {
93
+ if (budget <= 0) return []
94
+ return wrapSegments(segments, budget)
95
+ }
96
+
97
+ /* ------------------------------------------------------------------ *
98
+ * Inline rendering
99
+ * ------------------------------------------------------------------ */
100
+
101
+ const ESCAPABLE = '\\`*_{}[]()#+-.!>~|'
102
+
103
+ /** Find an unescaped marker; -1 when absent. */
104
+ function findClosing(text, from, marker) {
105
+ let index = from
106
+ while (index < text.length) {
107
+ if (text[index] === '\\') {
108
+ index += 2
109
+ continue
110
+ }
111
+ if (text.startsWith(marker, index)) return index
112
+ index += 1
113
+ }
114
+ return -1
115
+ }
116
+
117
+ /** Count a run of one character. */
118
+ function countRun(text, index, ch) {
119
+ let end = index
120
+ while (end < text.length && text[end] === ch) end += 1
121
+ return end - index
122
+ }
123
+
124
+ /** The faint variant used for the URL trailing a link. */
125
+ function dimStyle(md) {
126
+ const link = styleOf(md, 'link')
127
+ return link === undefined ? undefined : `2;${link}`
128
+ }
129
+
130
+ /**
131
+ * Render inline Markdown to styled segments. Markup characters are removed;
132
+ * escaped punctuation is emitted literally.
133
+ *
134
+ * @param {string} text
135
+ * @param {Record<string, string>} md
136
+ * @param {string | undefined} [baseStyle]
137
+ * @param {number} [depth]
138
+ * @returns {Segment[]}
139
+ */
140
+ function renderInline(text, md, baseStyle, depth = 0) {
141
+ if (text.length === 0) return []
142
+ if (depth > MAX_INLINE_DEPTH) {
143
+ const plain = []
144
+ let index = 0
145
+ while (index < text.length) {
146
+ if (text[index] === '\\' && ESCAPABLE.includes(text[index + 1] ?? '')) {
147
+ pushSeg(plain, text[index + 1])
148
+ index += 2
149
+ continue
150
+ }
151
+ pushSeg(plain, text[index])
152
+ index += 1
153
+ }
154
+ return decorate(plain, baseStyle)
155
+ }
156
+
157
+ /** @type {Segment[]} */
158
+ const out = []
159
+ const n = text.length
160
+ const bold = styleOf(md, 'bold')
161
+ const italic = styleOf(md, 'italic')
162
+ const strike = styleOf(md, 'strike')
163
+ const linkStyle = styleOf(md, 'link')
164
+ const codeStyle = styleOf(md, 'inlineCode')
165
+ let index = 0
166
+
167
+ while (index < n) {
168
+ const ch = text[index]
169
+
170
+ if (ch === '\\' && ESCAPABLE.includes(text[index + 1] ?? '')) {
171
+ pushSeg(out, text[index + 1])
172
+ index += 2
173
+ continue
174
+ }
175
+
176
+ if (ch === '`') {
177
+ const run = countRun(text, index, '`')
178
+ const close = text.indexOf('`'.repeat(run), index + run)
179
+ if (close !== -1) {
180
+ pushSeg(out, text.slice(index + run, close), codeStyle)
181
+ index = close + run
182
+ continue
183
+ }
184
+ for (const segment of decorate([{ text: text.slice(index) }], baseStyle)) {
185
+ pushSeg(out, segment.text, segment.sgr)
186
+ }
187
+ break
188
+ }
189
+
190
+ if (text.startsWith('**', index) || text.startsWith('__', index)) {
191
+ const marker = text.slice(index, index + 2)
192
+ const close = findClosing(text, index + 2, marker)
193
+ if (close !== -1 && close > index + 2) {
194
+ const inner = decorate(renderInline(text.slice(index + 2, close), md, undefined, depth + 1), bold)
195
+ for (const segment of inner) pushSeg(out, segment.text, combine(baseStyle, segment.sgr))
196
+ index = close + 2
197
+ continue
198
+ }
199
+ }
200
+
201
+ if (text.startsWith('~~', index)) {
202
+ const close = findClosing(text, index + 2, '~~')
203
+ if (close !== -1 && close > index + 2) {
204
+ const inner = decorate(renderInline(text.slice(index + 2, close), md, undefined, depth + 1), strike)
205
+ for (const segment of inner) pushSeg(out, segment.text, combine(baseStyle, segment.sgr))
206
+ index = close + 2
207
+ continue
208
+ }
209
+ }
210
+
211
+ if (ch === '*' || ch === '_') {
212
+ const beforeOk = ch !== '_' || index === 0 || !/[A-Za-z0-9]/.test(text[index - 1])
213
+ const close = beforeOk ? findClosing(text, index + 1, ch) : -1
214
+ const afterOk = close !== -1 && close + 1 < n ? !/[A-Za-z0-9]/.test(text[close + 1]) : close !== -1
215
+ if (close !== -1 && close > index + 1 && afterOk) {
216
+ const inner = decorate(renderInline(text.slice(index + 1, close), md, undefined, depth + 1), italic)
217
+ for (const segment of inner) pushSeg(out, segment.text, combine(baseStyle, segment.sgr))
218
+ index = close + 1
219
+ continue
220
+ }
221
+ }
222
+
223
+ if (ch === '[') {
224
+ const labelEnd = text.indexOf(']', index + 1)
225
+ if (labelEnd !== -1 && text[labelEnd + 1] === '(') {
226
+ const urlEnd = text.indexOf(')', labelEnd + 2)
227
+ if (urlEnd !== -1) {
228
+ const label = text.slice(index + 1, labelEnd)
229
+ let url = text.slice(labelEnd + 2, urlEnd).trim()
230
+ const title = /[ \t]+"[^"]*"$/.exec(url)
231
+ if (title !== null) url = url.slice(0, title.index).trim()
232
+ const rendered = decorate(renderInline(label, md, undefined, depth + 1), linkStyle)
233
+ for (const segment of rendered) pushSeg(out, segment.text, combine(baseStyle, segment.sgr))
234
+ if (url.length > 0 && url !== label) {
235
+ pushSeg(out, ` (${url})`, combine(baseStyle, dimStyle(md)))
236
+ }
237
+ index = urlEnd + 1
238
+ continue
239
+ }
240
+ }
241
+ }
242
+
243
+ if (ch === '<') {
244
+ const autolink = /^<([A-Za-z][A-Za-z0-9+.-]*:[^<>\s]+)>/.exec(text.slice(index))
245
+ if (autolink !== null) {
246
+ pushSeg(out, autolink[1], combine(baseStyle, linkStyle))
247
+ index += autolink[0].length
248
+ continue
249
+ }
250
+ }
251
+
252
+ if (ch === 'h' && text.startsWith('http', index)) {
253
+ const bare = /^https?:\/\/[^\s<>()[\]{}"']+/.exec(text.slice(index))
254
+ if (bare !== null) {
255
+ const url = bare[0].replace(/[.,;:!?]+$/, '')
256
+ if (url.length > 0) {
257
+ pushSeg(out, url, combine(baseStyle, linkStyle))
258
+ index += url.length
259
+ continue
260
+ }
261
+ }
262
+ }
263
+
264
+ // Plain run up to the next potentially meaningful character.
265
+ let end = index + 1
266
+ while (end < n && !'\\`*_~[<h'.includes(text[end])) end += 1
267
+ pushSeg(out, text.slice(index, end), baseStyle)
268
+ index = end
269
+ }
270
+
271
+ return out
272
+ }
273
+
274
+ /* ------------------------------------------------------------------ *
275
+ * Block helpers
276
+ * ------------------------------------------------------------------ */
277
+
278
+ /** True when a line opens a block that ends the current paragraph/item. */
279
+ function isBlockStart(line) {
280
+ return (
281
+ /^ {0,3}(?:`{3,}|~{3,})/.test(line) ||
282
+ /^ {0,3}#{1,6}(?:[ \t]|$)/.test(line) ||
283
+ /^ {0,3}>/.test(line) ||
284
+ /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/.test(line) ||
285
+ /^(\s*)([-*+]|\d{1,9}[.)])[ \t]+/.test(line) ||
286
+ /^(?: {4}|\t)/.test(line)
287
+ )
288
+ }
289
+
290
+ /** Push wrapped rows for one logical paragraph-like line. */
291
+ function pushWrapped(segments, ctx) {
292
+ for (const row of wrapTo(segments, ctx.width)) ctx.rows.push(row)
293
+ }
294
+
295
+ /** Blank-line separator, only between existing, non-empty rows. */
296
+ function addSeparator(ctx) {
297
+ const rows = ctx.rows
298
+ if (rows.length === 0) return
299
+ if (rows[rows.length - 1].length > 0) rows.push([])
300
+ }
301
+
302
+ /** Split a GFM table row on unescaped pipes. */
303
+ function splitRow(line) {
304
+ let text = line.trim()
305
+ if (text.startsWith('|')) text = text.slice(1)
306
+ if (text.endsWith('|') && !text.endsWith('\\|')) text = text.slice(0, -1)
307
+ const cells = []
308
+ let current = ''
309
+ for (let index = 0; index < text.length; index += 1) {
310
+ if (text[index] === '\\' && text[index + 1] === '|') {
311
+ current += '|'
312
+ index += 1
313
+ continue
314
+ }
315
+ if (text[index] === '|') {
316
+ cells.push(current.trim())
317
+ current = ''
318
+ continue
319
+ }
320
+ current += text[index]
321
+ }
322
+ cells.push(current.trim())
323
+ return cells
324
+ }
325
+
326
+ /** Is `lines[index]` a table header followed by a delimiter row? */
327
+ function isTableStart(lines, index) {
328
+ if (index + 1 >= lines.length) return false
329
+ if (!lines[index].includes('|')) return false
330
+ const delimiter = lines[index + 1].trim()
331
+ if (!delimiter.includes('|') || !delimiter.includes('-')) return false
332
+ return /^\|?[ \t]*:?-+:?[ \t]*(?:\|[ \t]*:?-+:?[ \t]*)*\|?$/.test(delimiter)
333
+ }
334
+
335
+ /** @returns {Array<'left' | 'right' | 'center'>} */
336
+ function parseAligns(line) {
337
+ return splitRow(line).map((cell) => {
338
+ const left = cell.startsWith(':')
339
+ const right = cell.endsWith(':')
340
+ if (left && right) return 'center'
341
+ if (right) return 'right'
342
+ return 'left'
343
+ })
344
+ }
345
+
346
+ /* ------------------------------------------------------------------ *
347
+ * Block rendering
348
+ * ------------------------------------------------------------------ */
349
+
350
+ /** Gutter-prefixed code rows: first line at `width - 2`, continuations at `- 4`. */
351
+ function codeRows(segments, width, borderStyle) {
352
+ const rows = []
353
+ const chunks = wrapTo(segments, Math.max(1, width - 2))
354
+ const lines = [chunks[0] ?? []]
355
+ for (let index = 1; index < chunks.length; index += 1) {
356
+ for (const sub of wrapTo(chunks[index], Math.max(1, width - 4))) lines.push(sub)
357
+ }
358
+ for (let index = 0; index < lines.length; index += 1) {
359
+ const row = []
360
+ pushSeg(row, index === 0 ? '│ ' : '│ ', borderStyle)
361
+ for (const segment of lines[index]) pushSeg(row, segment.text, segment.sgr)
362
+ rows.push(row)
363
+ }
364
+ return rows
365
+ }
366
+
367
+ /** Style highlighted code segments with the `md.code` fallback. */
368
+ function withCodeStyle(segments, md) {
369
+ const fallback = styleOf(md, 'code')
370
+ if (fallback === undefined) return segments
371
+ return segments.map((segment) => (segment.sgr === undefined ? { text: segment.text, sgr: fallback } : segment))
372
+ }
373
+
374
+ /** Render a fenced code block, including a fence left open at end of input. */
375
+ function renderFence(lines, start, open, ctx) {
376
+ const markerChar = open[1][0]
377
+ const info = (open[2] ?? '').trim()
378
+ const langHint = info.split(/\s+/)[0] ?? ''
379
+ const content = []
380
+ const closeRe = markerChar === '`' ? /^ {0,3}`{3,}[ \t]*$/ : /^ {0,3}~{3,}[ \t]*$/
381
+ let index = start + 1
382
+ while (index < lines.length) {
383
+ if (closeRe.test(lines[index])) {
384
+ index += 1
385
+ break
386
+ }
387
+ content.push(lines[index])
388
+ index += 1
389
+ }
390
+
391
+ const borderStyle = styleOf(ctx.md, 'fenceBorder')
392
+ const langStyle = styleOf(ctx.md, 'fenceLang')
393
+ if (langHint.length > 0) {
394
+ const label = truncateSegments([{ text: langHint, sgr: langStyle }], Math.max(0, ctx.width - 2), '…')
395
+ const row = []
396
+ pushSeg(row, '│ ', borderStyle)
397
+ const pad = ctx.width - 2 - rowWidth(label)
398
+ if (pad > 0) pushSeg(row, ' '.repeat(pad))
399
+ for (const segment of label) pushSeg(row, segment.text, segment.sgr)
400
+ ctx.rows.push(row)
401
+ }
402
+
403
+ for (const raw of content) {
404
+ const highlighted = highlightCode(raw, langHint, ctx.syn)
405
+ for (const row of codeRows(withCodeStyle(highlighted, ctx.md), ctx.width, borderStyle)) ctx.rows.push(row)
406
+ }
407
+ return index
408
+ }
409
+
410
+ /** Render a four-space indented code block. */
411
+ function renderIndentedCode(lines, start, ctx) {
412
+ const collected = []
413
+ let index = start
414
+ while (index < lines.length) {
415
+ const line = lines[index]
416
+ if (line.trim() === '') {
417
+ let probe = index + 1
418
+ while (probe < lines.length && lines[probe].trim() === '') probe += 1
419
+ if (probe < lines.length && /^(?: {4}|\t)/.test(lines[probe])) {
420
+ for (let blank = index; blank < probe; blank += 1) collected.push('')
421
+ index = probe
422
+ continue
423
+ }
424
+ break
425
+ }
426
+ if (!/^(?: {4}|\t)/.test(line)) break
427
+ collected.push(line.replace(/^(?: {4}|\t)/, ''))
428
+ index += 1
429
+ }
430
+
431
+ const borderStyle = styleOf(ctx.md, 'fenceBorder')
432
+ for (const raw of collected) {
433
+ const highlighted = highlightCode(raw, 'text', ctx.syn)
434
+ for (const row of codeRows(withCodeStyle(highlighted, ctx.md), ctx.width, borderStyle)) ctx.rows.push(row)
435
+ }
436
+ return index
437
+ }
438
+
439
+ /** Render an ATX heading. */
440
+ function renderHeading(level, content, ctx) {
441
+ const style = styleOf(ctx.md, level <= 2 ? 'heading' : 'heading2')
442
+ const segments = decorate(renderInline(content, ctx.md, undefined, 0), style)
443
+ pushWrapped(segments, ctx)
444
+ }
445
+
446
+ /** Render a blockquote block, repeating the `│ ` gutter on every row. */
447
+ function renderQuote(lines, start, ctx) {
448
+ const content = []
449
+ let depth = 1
450
+ let index = start
451
+ while (index < lines.length) {
452
+ const match = /^ {0,3}(>{1,})[ \t]?(.*)$/.exec(lines[index])
453
+ if (match !== null) {
454
+ depth = Math.max(depth, match[1].length)
455
+ content.push(match[2])
456
+ index += 1
457
+ continue
458
+ }
459
+ if (lines[index].trim() !== '' && !isBlockStart(lines[index])) {
460
+ content.push(lines[index].trim())
461
+ index += 1
462
+ continue
463
+ }
464
+ break
465
+ }
466
+
467
+ const maxDepth = Math.max(1, Math.floor((ctx.width - 1) / 2))
468
+ const gutter = '│ '.repeat(Math.min(depth, maxDepth))
469
+ const gutterStyle = styleOf(ctx.md, 'quote')
470
+ const segments = renderInline(content.join(' '), ctx.md, undefined, 0)
471
+ const budget = ctx.width - displayWidth(gutter)
472
+ const wrapped = wrapTo(segments, budget)
473
+ const out = wrapped.length === 0 ? [[]] : wrapped
474
+ for (const line of out) {
475
+ const row = []
476
+ pushSeg(row, gutter, gutterStyle)
477
+ for (const segment of line) pushSeg(row, segment.text, segment.sgr)
478
+ ctx.rows.push(row)
479
+ }
480
+ return index
481
+ }
482
+
483
+ /** Render a run of list items with a hanging indent and one nesting level. */
484
+ function renderList(lines, start, ctx) {
485
+ let index = start
486
+ const markerStyle = styleOf(ctx.md, 'bullet')
487
+ while (index < lines.length) {
488
+ const match = /^(\s*)([-*+]|\d{1,9}[.)])[ \t]+(.*)$/.exec(lines[index])
489
+ if (match === null) break
490
+ const indent = match[1].replace(/\t/g, ' ').length
491
+ const level = indent >= 2 ? 1 : 0
492
+ const ordered = /^\d/.test(match[2])
493
+ const markerText = ordered ? `${match[2]} ` : level === 0 ? '• ' : '◦ '
494
+ const content = [match[3].trim()]
495
+ index += 1
496
+
497
+ while (index < lines.length) {
498
+ const next = lines[index]
499
+ if (next.trim() === '') break
500
+ if (/^(\s*)([-*+]|\d{1,9}[.)])[ \t]+/.test(next)) break
501
+ if (isBlockStart(next)) break
502
+ const nextIndent = (next.match(/^\s*/)?.[0] ?? '').replace(/\t/g, ' ').length
503
+ if (nextIndent < indent + 2) break
504
+ content.push(next.trim())
505
+ index += 1
506
+ }
507
+
508
+ const prefix = (level === 0 ? '' : ' ') + markerText
509
+ const prefixWidth = displayWidth(prefix)
510
+ const budget = ctx.width - prefixWidth
511
+ const segments = renderInline(content.join(' '), ctx.md, undefined, 0)
512
+
513
+ if (budget <= 0) {
514
+ // A very wide marker at a very narrow width: keep the text, drop the marker.
515
+ pushWrapped(segments, ctx)
516
+ continue
517
+ }
518
+
519
+ const wrapped = wrapTo(segments, budget)
520
+ let first = true
521
+ for (const line of wrapped) {
522
+ const row = []
523
+ pushSeg(row, first ? prefix : ' '.repeat(prefixWidth), markerStyle)
524
+ for (const segment of line) pushSeg(row, segment.text, segment.sgr)
525
+ ctx.rows.push(row)
526
+ first = false
527
+ }
528
+ }
529
+ return index
530
+ }
531
+
532
+ /** Render a GFM table with aligned columns. Returns the next line index. */
533
+ function renderTable(lines, start, ctx) {
534
+ const header = splitRow(lines[start])
535
+ const aligns = parseAligns(lines[start + 1])
536
+ const body = []
537
+ let index = start + 2
538
+ while (index < lines.length && lines[index].trim() !== '' && lines[index].includes('|')) {
539
+ body.push(splitRow(lines[index]))
540
+ index += 1
541
+ }
542
+
543
+ const columnCount = Math.max(header.length, aligns.length, ...body.map((row) => row.length))
544
+ const available = ctx.width - (3 * columnCount + 1)
545
+ if (columnCount === 0 || available < columnCount) {
546
+ // Too narrow for aligned columns: fall back to plain wrapped lines.
547
+ for (let line = start; line < index; line += 1) {
548
+ pushWrapped(renderInline(lines[line].trim(), ctx.md, undefined, 0), ctx)
549
+ }
550
+ return index
551
+ }
552
+
553
+ const widths = new Array(columnCount).fill(3)
554
+ const measure = (cells) => {
555
+ for (let column = 0; column < columnCount; column += 1) {
556
+ widths[column] = Math.max(widths[column], displayWidth(cells[column] ?? ''))
557
+ }
558
+ }
559
+ measure(header)
560
+ for (const row of body) measure(row)
561
+ let total = widths.reduce((sum, value) => sum + value, 0)
562
+ while (total > available) {
563
+ let widest = 0
564
+ for (let column = 1; column < columnCount; column += 1) {
565
+ if (widths[column] > widths[widest]) widest = column
566
+ }
567
+ if (widths[widest] <= 1) break
568
+ widths[widest] -= 1
569
+ total -= 1
570
+ }
571
+
572
+ const borderStyle = styleOf(ctx.md, 'fenceBorder')
573
+ for (const cells of [header, ...body]) {
574
+ const row = []
575
+ pushSeg(row, '│ ', borderStyle)
576
+ for (let column = 0; column < columnCount; column += 1) {
577
+ if (column > 0) pushSeg(row, ' │ ', borderStyle)
578
+ const rendered = truncateSegments(renderInline(cells[column] ?? '', ctx.md, undefined, 0), widths[column], '…')
579
+ const pad = widths[column] - rowWidth(rendered)
580
+ const align = aligns[column] ?? 'left'
581
+ let leftPad = 0
582
+ let rightPad = pad
583
+ if (align === 'right') {
584
+ leftPad = pad
585
+ rightPad = 0
586
+ } else if (align === 'center') {
587
+ leftPad = Math.floor(pad / 2)
588
+ rightPad = pad - leftPad
589
+ }
590
+ if (leftPad > 0) pushSeg(row, ' '.repeat(leftPad))
591
+ for (const segment of rendered) pushSeg(row, segment.text, segment.sgr)
592
+ if (rightPad > 0) pushSeg(row, ' '.repeat(rightPad))
593
+ }
594
+ pushSeg(row, ' │', borderStyle)
595
+ ctx.rows.push(row)
596
+ }
597
+ return index
598
+ }
599
+
600
+ /** Render a paragraph, merging soft-wrapped source lines. */
601
+ function renderParagraph(lines, start, ctx) {
602
+ const parts = []
603
+ let index = start
604
+ while (index < lines.length && lines[index].trim() !== '') {
605
+ if (index > start && isBlockStart(lines[index])) break
606
+ parts.push(lines[index].trim())
607
+ index += 1
608
+ }
609
+ pushWrapped(renderInline(parts.join(' '), ctx.md, undefined, 0), ctx)
610
+ return index
611
+ }
612
+
613
+ /* ------------------------------------------------------------------ *
614
+ * Public API
615
+ * ------------------------------------------------------------------ */
616
+
617
+ /** Clamp a requested width into a gutter-safe range. */
618
+ function clampWidth(value) {
619
+ const numeric = Number(value)
620
+ if (!Number.isFinite(numeric)) return 80
621
+ return Math.max(MIN_WIDTH, Math.floor(numeric))
622
+ }
623
+
624
+ /** Last-resort renderer used if the structured path ever throws. */
625
+ function fallbackRows(source, width, md) {
626
+ const rows = []
627
+ const style = styleOf(md, 'code')
628
+ for (const line of source.split('\n')) {
629
+ for (const row of wrapSegments([style === undefined ? { text: line } : { text: line, sgr: style }], width)) {
630
+ rows.push(row)
631
+ }
632
+ }
633
+ return rows
634
+ }
635
+
636
+ /**
637
+ * Render Markdown into width-bounded rows.
638
+ *
639
+ * @param {string} text
640
+ * @param {{ width?: number, md?: Record<string, string>, syn?: Record<string, string>, maxLines?: number }} [options]
641
+ * @returns {Segment[][]}
642
+ */
643
+ export function renderMarkdown(text, options = {}) {
644
+ const source = sanitize(text).replace(/\r\n?/g, '\n')
645
+ const width = clampWidth(options?.width)
646
+ const md = options?.md !== null && typeof options?.md === 'object' ? options.md : {}
647
+ const syn = options?.syn !== null && typeof options?.syn === 'object' ? options.syn : {}
648
+
649
+ if (source.trim().length === 0) return []
650
+
651
+ let rows
652
+ try {
653
+ rows = renderBlocks(source, width, md, syn)
654
+ } catch {
655
+ rows = fallbackRows(source, width, md)
656
+ }
657
+
658
+ const maxLines = options?.maxLines
659
+ if (typeof maxLines === 'number' && Number.isFinite(maxLines) && maxLines >= 0 && rows.length > maxLines) {
660
+ const limit = Math.floor(maxLines)
661
+ if (limit === 0) return []
662
+ const kept = rows.slice(0, limit - 1)
663
+ const remaining = rows.length - kept.length
664
+ const more = truncateSegments(
665
+ [{ text: `… ${remaining} more lines`, sgr: styleOf(md, 'more') }],
666
+ width,
667
+ '…'
668
+ )
669
+ rows = [...kept, more]
670
+ }
671
+
672
+ return rows
673
+ }
674
+
675
+ /**
676
+ * The structured block walk. Kept separate so `renderMarkdown` can wrap it in a
677
+ * try/catch without duplicating option handling.
678
+ *
679
+ * @param {string} source
680
+ * @param {number} width
681
+ * @param {Record<string, string>} md
682
+ * @param {Record<string, string>} syn
683
+ * @returns {Segment[][]}
684
+ */
685
+ function renderBlocks(source, width, md, syn) {
686
+ /** @type {Segment[][]} */
687
+ const rows = []
688
+ const ctx = { width, md, syn, rows }
689
+ const lines = source.split('\n')
690
+ let index = 0
691
+
692
+ while (index < lines.length) {
693
+ const before = index
694
+ const line = lines[index]
695
+
696
+ if (line.trim() === '') {
697
+ addSeparator(ctx)
698
+ index += 1
699
+ continue
700
+ }
701
+
702
+ const fence = /^ {0,3}(`{3,}|~{3,})[ \t]*(.*)$/.exec(line)
703
+ if (fence !== null) {
704
+ index = renderFence(lines, index, fence, ctx)
705
+ if (index <= before) index = before + 1
706
+ continue
707
+ }
708
+
709
+ if (/^(?: {4}|\t)/.test(line)) {
710
+ index = renderIndentedCode(lines, index, ctx)
711
+ if (index <= before) index = before + 1
712
+ continue
713
+ }
714
+
715
+ const heading = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?[ \t]*#*[ \t]*$/.exec(line)
716
+ if (heading !== null) {
717
+ renderHeading(heading[1].length, heading[2] ?? '', ctx)
718
+ index += 1
719
+ continue
720
+ }
721
+
722
+ if (/^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/.test(line)) {
723
+ const rule = []
724
+ pushSeg(rule, '─'.repeat(width), styleOf(md, 'rule'))
725
+ rows.push(rule)
726
+ index += 1
727
+ continue
728
+ }
729
+
730
+ if (/^ {0,3}>/.test(line)) {
731
+ index = renderQuote(lines, index, ctx)
732
+ if (index <= before) index = before + 1
733
+ continue
734
+ }
735
+
736
+ if (/^(\s*)([-*+]|\d{1,9}[.)])[ \t]+/.test(line)) {
737
+ index = renderList(lines, index, ctx)
738
+ if (index <= before) index = before + 1
739
+ continue
740
+ }
741
+
742
+ if (isTableStart(lines, index)) {
743
+ index = renderTable(lines, index, ctx)
744
+ if (index <= before) index = before + 1
745
+ continue
746
+ }
747
+
748
+ index = renderParagraph(lines, index, ctx)
749
+ if (index <= before) index += 1
750
+ }
751
+
752
+ return rows
753
+ }