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.
- package/LICENSE +21 -0
- package/README.md +920 -0
- package/README.zh.md +790 -0
- package/assets/rain.ogg +0 -0
- package/bin/dsh-glyph-probe.js +51 -0
- package/bin/dsh-live-trace.js +29 -0
- package/bin/dsh-live-working.js +14 -0
- package/cordis.patch.yml +31 -0
- package/icon.svg +12 -0
- package/index.js +328 -0
- package/lib/client.js +178 -0
- package/lib/instance.js +68 -0
- package/lib/normalize.js +850 -0
- package/lib/paths.js +66 -0
- package/lib/protocol.js +115 -0
- package/lib/registry.js +232 -0
- package/lib/tools.js +257 -0
- package/lib/tracker.js +648 -0
- package/lib/transport.js +231 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +94 -0
- package/picture/call1.png +0 -0
- package/picture/call2.png +0 -0
- package/picture/sleep1.png +0 -0
- package/picture/sleep2.png +0 -0
- package/picture/tui1.png +0 -0
- package/picture/tui2.png +0 -0
- package/picture/type1.png +0 -0
- package/picture/type2.png +0 -0
- package/scripts/bench-render.mjs +69 -0
- package/scripts/demo-working.mjs +130 -0
- package/scripts/demo.mjs +284 -0
- package/scripts/install-profile.mjs +174 -0
- package/scripts/mock-provider.mjs +211 -0
- package/src/cli/cellsize.js +120 -0
- package/src/cli/format.js +73 -0
- package/src/cli/highlight.js +932 -0
- package/src/cli/i18n.js +457 -0
- package/src/cli/main.js +630 -0
- package/src/cli/markdown.js +753 -0
- package/src/cli/renderer.js +1044 -0
- package/src/cli/screen.js +270 -0
- package/src/cli/theme.js +221 -0
- package/src/cli/view-state.js +396 -0
- package/src/cli/views.js +406 -0
- package/src/cli/width.js +337 -0
- package/src/cli/working/art.js +413 -0
- package/src/cli/working/main.js +569 -0
- package/src/cli/working/packing.js +159 -0
- package/src/cli/working/picker.js +75 -0
- package/src/cli/working/props.js +385 -0
- package/src/cli/working/scene.js +837 -0
- package/src/cli/working/sky.js +641 -0
- package/src/cli/working/sound.js +400 -0
- 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
|
+
}
|