@xp266/dshtui 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/bin/dshtui.js +65 -0
- package/cordis.patch.yml +81 -0
- package/lib/contract/index.d.mts +2 -0
- package/lib/contract/index.mjs +5 -0
- package/lib/dialog.d.mts +156 -0
- package/lib/dialog.mjs +3 -0
- package/lib/index-DqnaSXqP.d.mts +823 -0
- package/lib/index.d.mts +17 -0
- package/lib/index.mjs +11404 -0
- package/lib/prod-react-Dzof4qJx.mjs +4 -0
- package/lib/surface-DdxzIgIY.mjs +3126 -0
- package/lib/vendor.d.mts +3 -0
- package/lib/vendor.mjs +4 -0
- package/package.json +128 -0
- package/src/apply-theme.ts +8 -0
- package/src/boot-log.ts +35 -0
- package/src/chat/blocks.ts +41 -0
- package/src/chat/bridge.ts +1076 -0
- package/src/chat/builtin-tool-views.ts +53 -0
- package/src/chat/chat-face.ts +16 -0
- package/src/chat/chat-nodes.ts +53 -0
- package/src/chat/efforts.ts +4 -0
- package/src/chat/interactions.ts +329 -0
- package/src/chat/models.ts +263 -0
- package/src/chat/partial-json.ts +125 -0
- package/src/chat/presets.ts +26 -0
- package/src/chat/question-view.ts +106 -0
- package/src/chat/retry-status.ts +33 -0
- package/src/chat/session-groups.ts +34 -0
- package/src/chat/session-list.ts +311 -0
- package/src/chat/store.ts +636 -0
- package/src/chat/todo-view.ts +66 -0
- package/src/chat/tool-view.ts +311 -0
- package/src/chat/tool-views.ts +114 -0
- package/src/contract/index.ts +704 -0
- package/src/contract/upstream.ts +165 -0
- package/src/core/caret-nonce.ts +19 -0
- package/src/core/composer-layout.ts +65 -0
- package/src/core/edit.ts +152 -0
- package/src/core/field-view.ts +52 -0
- package/src/core/fields.ts +185 -0
- package/src/core/metrics.ts +26 -0
- package/src/core/paste.ts +149 -0
- package/src/core/segments.ts +112 -0
- package/src/core/text.ts +290 -0
- package/src/dialog.ts +11 -0
- package/src/env.ts +38 -0
- package/src/harness-home.ts +22 -0
- package/src/hot-theme.ts +56 -0
- package/src/index.tsx +185 -0
- package/src/kernel/registry.ts +102 -0
- package/src/kernel/surface.ts +33 -0
- package/src/log.ts +249 -0
- package/src/model/message.ts +89 -0
- package/src/model/selection.ts +63 -0
- package/src/performance-guard.ts +28 -0
- package/src/runtime/prod-react.ts +10 -0
- package/src/terminal/background.ts +66 -0
- package/src/terminal/capabilities.ts +54 -0
- package/src/terminal/clipboard-backends.ts +69 -0
- package/src/terminal/clipboard.ts +161 -0
- package/src/terminal/cursor-shape.ts +13 -0
- package/src/terminal/glyphs.ts +44 -0
- package/src/terminal/mouse.ts +154 -0
- package/src/terminal/probe.ts +61 -0
- package/src/terminal/screen.ts +349 -0
- package/src/theme-settings.ts +31 -0
- package/src/theme.ts +319 -0
- package/src/ui/app.tsx +454 -0
- package/src/ui/chrome/caps.tsx +9 -0
- package/src/ui/chrome/hint-service.ts +87 -0
- package/src/ui/chrome/input-status.ts +27 -0
- package/src/ui/chrome/key-gate.tsx +29 -0
- package/src/ui/chrome/status-bar.tsx +147 -0
- package/src/ui/contributions.ts +47 -0
- package/src/ui/dialog/defaults-dialog.tsx +152 -0
- package/src/ui/dialog/dialog-item.tsx +38 -0
- package/src/ui/dialog/dialog.tsx +386 -0
- package/src/ui/dialog/effort-dialog.tsx +45 -0
- package/src/ui/dialog/geometry.ts +131 -0
- package/src/ui/dialog/items.ts +124 -0
- package/src/ui/dialog/list-dialog.tsx +63 -0
- package/src/ui/dialog/models-dialog.tsx +203 -0
- package/src/ui/dialog/presets-dialog.tsx +46 -0
- package/src/ui/dialog/providers-dialog.tsx +313 -0
- package/src/ui/dialog/sessions-dialog.tsx +163 -0
- package/src/ui/dialog/sizes.ts +7 -0
- package/src/ui/dialog/status-lines.ts +11 -0
- package/src/ui/dialog/todo-dialog.tsx +29 -0
- package/src/ui/dialog/use-dialog-input.ts +186 -0
- package/src/ui/extension-point.ts +188 -0
- package/src/ui/home-logo.ts +132 -0
- package/src/ui/hooks/use-async-action.ts +25 -0
- package/src/ui/hooks/use-async-list.ts +71 -0
- package/src/ui/hooks/use-caret.ts +24 -0
- package/src/ui/hooks/use-chat-events.ts +153 -0
- package/src/ui/hooks/use-mouse-selection.ts +352 -0
- package/src/ui/hooks/use-scroll.ts +49 -0
- package/src/ui/hooks/use-terminal-size.ts +20 -0
- package/src/ui/input/commands.ts +118 -0
- package/src/ui/input/composer-bus.ts +14 -0
- package/src/ui/input/composer-fields.ts +111 -0
- package/src/ui/input/composer-keys.ts +21 -0
- package/src/ui/input/composer-paste.ts +20 -0
- package/src/ui/input/input-bar.tsx +279 -0
- package/src/ui/input/use-composer.ts +495 -0
- package/src/ui/key-arbiter.ts +37 -0
- package/src/ui/keymap.ts +15 -0
- package/src/ui/layout-service.ts +77 -0
- package/src/ui/message/layout.ts +763 -0
- package/src/ui/message/md/block.ts +247 -0
- package/src/ui/message/md/engine.ts +430 -0
- package/src/ui/message/md/extensions.ts +58 -0
- package/src/ui/message/md/highlight.ts +626 -0
- package/src/ui/message/md/index.ts +14 -0
- package/src/ui/message/md/inline.ts +97 -0
- package/src/ui/message/md/palette.ts +87 -0
- package/src/ui/message/message-list.tsx +69 -0
- package/src/ui/message/message-row.tsx +137 -0
- package/src/ui/message/message-views.ts +16 -0
- package/src/ui/message/renderers.ts +39 -0
- package/src/ui/message/tool-diff.ts +188 -0
- package/src/ui/message/warmup.ts +41 -0
- package/src/ui/message/wave.ts +98 -0
- package/src/ui/overlay.ts +22 -0
- package/src/ui/panels/approval-panel.tsx +193 -0
- package/src/ui/panels/exports.ts +3 -0
- package/src/ui/panels/question-model.ts +424 -0
- package/src/ui/panels/question-panel.tsx +154 -0
- package/src/ui/panels/surface.tsx +106 -0
- package/src/ui/panels-builtin.tsx +86 -0
- package/src/ui/pointer/builtins.ts +281 -0
- package/src/ui/pointer/registry.ts +25 -0
- package/src/ui/region.tsx +31 -0
- package/src/ui/selection/service.ts +92 -0
- package/src/ui/selection-registry.ts +88 -0
- package/src/ui/selection.tsx +148 -0
- package/src/ui/spinner-tick.tsx +24 -0
- package/src/ui/use-available-windows.ts +31 -0
- package/src/ui/widgets/actions.tsx +84 -0
- package/src/ui/widgets/button.tsx +57 -0
- package/src/ui/widgets/checkbox.tsx +58 -0
- package/src/ui/widgets/header.tsx +36 -0
- package/src/ui/widgets/index.ts +8 -0
- package/src/ui/widgets/input.tsx +76 -0
- package/src/ui/widgets/registry.ts +32 -0
- package/src/ui/widgets/search.tsx +59 -0
- package/src/ui/widgets/select.tsx +97 -0
- package/src/ui/widgets/static.tsx +37 -0
- package/src/ui/widgets/types.ts +1 -0
- package/src/ui/window-services-bridge.ts +71 -0
- package/src/ui/window-services.ts +20 -0
- package/src/ui/windows/models-window.tsx +15 -0
- package/src/ui/windows/providers-window.tsx +14 -0
- package/src/ui/windows/service-window.tsx +18 -0
- package/src/ui/windows/sessions-window.tsx +25 -0
- package/src/ui/windows-builtin.tsx +94 -0
- package/src/ui/windows.ts +18 -0
- package/src/vendor.ts +12 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import type { Token, Tokens } from 'marked'
|
|
2
|
+
import { mergeRuns, wrapSegments } from '../../../core/segments.ts'
|
|
3
|
+
import type { Segment } from '../../../core/segments.ts'
|
|
4
|
+
import { charWidth, segmentGraphemes, textWidth } from '../../../core/text.ts'
|
|
5
|
+
import { glyphs } from '../../../terminal/glyphs.ts'
|
|
6
|
+
import { highlightCodeBlock } from './highlight.ts'
|
|
7
|
+
import { renderInline } from './inline.ts'
|
|
8
|
+
import { markdownExtensions } from './extensions.ts'
|
|
9
|
+
import type { MdPalette } from './palette.ts'
|
|
10
|
+
|
|
11
|
+
export interface BlockContext {
|
|
12
|
+
palette: MdPalette
|
|
13
|
+
thinking: boolean
|
|
14
|
+
width: number
|
|
15
|
+
streamId: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function plainOf(segments: Segment[]): string {
|
|
19
|
+
return segments.map(segment => segment.text).join('')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function renderHeading(token: Tokens.Heading, ctx: BlockContext): Segment[][] {
|
|
23
|
+
const style = ctx.palette.heading(token.depth)
|
|
24
|
+
const segments = renderInline(token.tokens, ctx.palette, style)
|
|
25
|
+
return wrapSegments(segments, ctx.width)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function renderCode(token: Tokens.Code, ctx: BlockContext): Segment[][] {
|
|
29
|
+
if (token.text === '') return []
|
|
30
|
+
const lang = token.lang?.trim().split(/\s+/)[0]?.toLowerCase() ?? ''
|
|
31
|
+
const highlighted = highlightCodeBlock(token.text, lang, ctx.thinking, ctx.streamId)
|
|
32
|
+
const segments = highlighted ?? [{ text: token.text, style: lang === '' ? ctx.palette.codeFallback : ctx.palette.codePlain }]
|
|
33
|
+
return wrapSegments(segments, ctx.width)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function renderParagraphish(tokens: Token[], ctx: BlockContext): Segment[][] {
|
|
37
|
+
return wrapSegments(renderInline(tokens, ctx.palette), ctx.width)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function renderHr(ctx: BlockContext): Segment[][] {
|
|
41
|
+
return [[{ text: glyphs.horizontal.repeat(Math.max(ctx.width, 8)), style: ctx.palette.hr }]]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const TABLE_SAFETY_COLS = 2
|
|
45
|
+
|
|
46
|
+
interface TableCell {
|
|
47
|
+
text: string
|
|
48
|
+
segments: Segment[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function renderTable(token: Tokens.Table, ctx: BlockContext): Segment[][] {
|
|
52
|
+
const headerCells: TableCell[] = token.header.map(cell => ({
|
|
53
|
+
text: cell.text,
|
|
54
|
+
segments: renderInline(cell.tokens, ctx.palette),
|
|
55
|
+
}))
|
|
56
|
+
const bodyRows = token.rows.map(row => row.map<TableCell>(cell => ({
|
|
57
|
+
text: cell.text,
|
|
58
|
+
segments: renderInline(cell.tokens, ctx.palette),
|
|
59
|
+
})))
|
|
60
|
+
const allRows = [headerCells, ...bodyRows]
|
|
61
|
+
const cols = Math.max(1, ...allRows.map(cells => cells.length))
|
|
62
|
+
const widths: number[] = []
|
|
63
|
+
const mins: number[] = []
|
|
64
|
+
for (let c = 0; c < cols; c++) {
|
|
65
|
+
let natural = 1
|
|
66
|
+
let min = 1
|
|
67
|
+
for (const cells of allRows) {
|
|
68
|
+
const text = (cells[c]?.text ?? '').trim()
|
|
69
|
+
natural = Math.max(natural, textWidth(text))
|
|
70
|
+
for (const { segment } of segmentGraphemes(text)) min = Math.max(min, charWidth(segment))
|
|
71
|
+
}
|
|
72
|
+
widths.push(natural)
|
|
73
|
+
mins.push(min)
|
|
74
|
+
}
|
|
75
|
+
const budget = Math.max(cols, ctx.width - (cols * 3 + 1) - TABLE_SAFETY_COLS)
|
|
76
|
+
const sum = (): number => widths.reduce((a, b) => a + b, 0)
|
|
77
|
+
while (sum() > budget) {
|
|
78
|
+
let maxIdx = -1
|
|
79
|
+
for (let c = 0; c < cols; c++) {
|
|
80
|
+
if (widths[c]! <= mins[c]!) continue
|
|
81
|
+
if (maxIdx === -1 || widths[c]! > widths[maxIdx]!) maxIdx = c
|
|
82
|
+
}
|
|
83
|
+
if (maxIdx === -1) break
|
|
84
|
+
widths[maxIdx] -= 1
|
|
85
|
+
}
|
|
86
|
+
const blocksByRow = allRows.map(cells => {
|
|
87
|
+
const blocks: Segment[][][] = []
|
|
88
|
+
for (let c = 0; c < cols; c++) {
|
|
89
|
+
const segs = cells[c]?.segments ?? []
|
|
90
|
+
blocks.push(segs.length === 0 ? [[]] : wrapSegments(segs, widths[c]!))
|
|
91
|
+
}
|
|
92
|
+
return blocks
|
|
93
|
+
})
|
|
94
|
+
const effective = widths.slice()
|
|
95
|
+
for (const blocks of blocksByRow) {
|
|
96
|
+
for (let c = 0; c < cols; c++) {
|
|
97
|
+
for (const rowSegs of blocks[c]!) {
|
|
98
|
+
effective[c] = Math.max(effective[c]!, textWidth(plainOf(rowSegs)))
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const borderStyle = ctx.palette.hr
|
|
103
|
+
const borderRow = (left: string, mid: string, right: string): Segment[] =>
|
|
104
|
+
[{ text: left + widths.map(w => glyphs.horizontal.repeat(w + 2)).join(mid) + right, style: borderStyle }]
|
|
105
|
+
const renderRowBlock = (blocks: Segment[][][]): Segment[][] => {
|
|
106
|
+
let height = 1
|
|
107
|
+
for (let c = 0; c < cols; c++) height = Math.max(height, blocks[c]!.length)
|
|
108
|
+
const out: Segment[][] = []
|
|
109
|
+
for (let h = 0; h < height; h++) {
|
|
110
|
+
const line: Segment[] = [{ text: glyphs.tableVertical, style: borderStyle }]
|
|
111
|
+
for (let c = 0; c < cols; c++) {
|
|
112
|
+
const rowSegs = blocks[c]![h] ?? []
|
|
113
|
+
const used = textWidth(plainOf(rowSegs))
|
|
114
|
+
line.push({ text: ' ', style: ctx.palette.plain })
|
|
115
|
+
line.push(...rowSegs)
|
|
116
|
+
line.push(
|
|
117
|
+
{ text: ' '.repeat(Math.max(0, effective[c]! - used + 1)), style: ctx.palette.plain },
|
|
118
|
+
{ text: glyphs.tableVertical, style: borderStyle },
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
out.push(mergeRuns(line))
|
|
122
|
+
}
|
|
123
|
+
return out
|
|
124
|
+
}
|
|
125
|
+
return [
|
|
126
|
+
borderRow(...glyphs.tableBorders.top),
|
|
127
|
+
...renderRowBlock(blocksByRow[0]!),
|
|
128
|
+
borderRow(...glyphs.tableBorders.middle),
|
|
129
|
+
...blocksByRow.slice(1).flatMap(blocks => renderRowBlock(blocks)),
|
|
130
|
+
borderRow(...glyphs.tableBorders.bottom),
|
|
131
|
+
]
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface ItemParts {
|
|
135
|
+
headTokens: Token[]
|
|
136
|
+
childTokens: Token[]
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function splitItemTokens(item: Tokens.ListItem): ItemParts {
|
|
140
|
+
const headTokens: Token[] = []
|
|
141
|
+
const childTokens: Token[] = []
|
|
142
|
+
let scanningHead = true
|
|
143
|
+
for (const token of item.tokens ?? []) {
|
|
144
|
+
if (scanningHead && (token.type === 'text' || token.type === 'paragraph' || token.type === 'checkbox')) {
|
|
145
|
+
if (token.type !== 'checkbox') headTokens.push(token)
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
scanningHead = false
|
|
149
|
+
childTokens.push(token)
|
|
150
|
+
}
|
|
151
|
+
return { headTokens, childTokens }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function markerSegments(item: Tokens.ListItem, ordered: boolean, label: string, depth: number, palette: MdPalette): Segment[] {
|
|
155
|
+
if (item.task === true) {
|
|
156
|
+
return [{ text: item.checked ? glyphs.taskChecked : glyphs.taskUnchecked, style: item.checked ? palette.taskDone : palette.taskTodo }]
|
|
157
|
+
}
|
|
158
|
+
if (ordered) return [{ text: label, style: palette.listMarker }]
|
|
159
|
+
return [{ text: glyphs.bullets[Math.min(depth, glyphs.bullets.length - 1)]!, style: palette.listMarker }]
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function indentRows(rows: Segment[][], indent: string, palette: MdPalette): Segment[][] {
|
|
163
|
+
const pad: Segment = { text: indent, style: palette.plain }
|
|
164
|
+
return rows.map(row => [pad, ...row])
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function renderList(token: Tokens.List, depth: number, ctx: BlockContext): Segment[][] {
|
|
168
|
+
const rows: Segment[][] = []
|
|
169
|
+
const ordered = token.ordered === true
|
|
170
|
+
const start = typeof token.start === 'number' && Number.isFinite(token.start) ? Math.trunc(token.start) : 1
|
|
171
|
+
for (let index = 0; index < token.items.length; index++) {
|
|
172
|
+
const item = token.items[index]!
|
|
173
|
+
const { headTokens, childTokens } = splitItemTokens(item)
|
|
174
|
+
const markerSegs = markerSegments(item, ordered, `${start + index}.`, depth, ctx.palette)
|
|
175
|
+
const markerText = plainOf(markerSegs)
|
|
176
|
+
const hang = Math.min(textWidth(markerText) + 1, Math.max(2, Math.floor(ctx.width / 2)))
|
|
177
|
+
const innerCtx: BlockContext = { ...ctx, width: Math.max(4, ctx.width - hang) }
|
|
178
|
+
const pad = ' '.repeat(hang)
|
|
179
|
+
const itemRows: Segment[][] = []
|
|
180
|
+
const inlineRows = renderParagraphish(headTokens.flatMap(token => (token as Tokens.Text).tokens ?? []), innerCtx)
|
|
181
|
+
if (inlineRows.length === 0) {
|
|
182
|
+
itemRows.push(mergeRuns(markerSegs))
|
|
183
|
+
} else {
|
|
184
|
+
itemRows.push(mergeRuns([...markerSegs, { text: ' ', style: ctx.palette.plain }, ...inlineRows[0]!]))
|
|
185
|
+
for (let r = 1; r < inlineRows.length; r++) {
|
|
186
|
+
itemRows.push([{ text: pad, style: ctx.palette.plain }, ...inlineRows[r]!])
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
for (const child of childTokens) {
|
|
190
|
+
if (child.type === 'list') {
|
|
191
|
+
itemRows.push(...indentRows(renderList(child as Tokens.List, depth + 1, innerCtx), pad, ctx.palette))
|
|
192
|
+
} else {
|
|
193
|
+
itemRows.push(...indentRows(renderBlockRows(child, innerCtx), pad, ctx.palette))
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
rows.push(...itemRows)
|
|
197
|
+
}
|
|
198
|
+
return rows
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function renderBlockquote(token: Tokens.Blockquote, ctx: BlockContext): Segment[][] {
|
|
202
|
+
const innerCtx: BlockContext = { ...ctx, width: Math.max(4, ctx.width - 2) }
|
|
203
|
+
const inner: Segment[][] = []
|
|
204
|
+
for (const child of token.tokens ?? []) {
|
|
205
|
+
inner.push(...renderBlockRows(child, innerCtx))
|
|
206
|
+
}
|
|
207
|
+
const bar: Segment = { text: glyphs.quoteBar, style: ctx.palette.quoteBar }
|
|
208
|
+
return inner.map(row => mergeRuns([bar, { text: ' ', style: ctx.palette.plain }, ...row]))
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function renderBlockRows(token: Token, ctx: BlockContext): Segment[][] {
|
|
212
|
+
const contributed = markdownExtensions.markdownBlockOf(token)
|
|
213
|
+
if (contributed !== undefined) return contributed.render(token, ctx)
|
|
214
|
+
switch (token.type) {
|
|
215
|
+
case 'heading':
|
|
216
|
+
return renderHeading(token as Tokens.Heading, ctx)
|
|
217
|
+
case 'paragraph':
|
|
218
|
+
return renderParagraphish((token as Tokens.Paragraph).tokens, ctx)
|
|
219
|
+
case 'text':
|
|
220
|
+
return renderParagraphish((token as Tokens.Text).tokens ?? [], ctx)
|
|
221
|
+
case 'code':
|
|
222
|
+
return renderCode(token as Tokens.Code, ctx)
|
|
223
|
+
case 'hr':
|
|
224
|
+
return renderHr(ctx)
|
|
225
|
+
case 'table':
|
|
226
|
+
return renderTable(token as Tokens.Table, ctx)
|
|
227
|
+
case 'blockquote':
|
|
228
|
+
return renderBlockquote(token as Tokens.Blockquote, ctx)
|
|
229
|
+
case 'list':
|
|
230
|
+
return renderList(token as Tokens.List, 0, ctx)
|
|
231
|
+
case 'html': {
|
|
232
|
+
const raw = (token as Tokens.HTML).raw.replace(/\n$/, '')
|
|
233
|
+
return wrapSegments([{ text: raw, style: ctx.palette.codeFallback }], ctx.width)
|
|
234
|
+
}
|
|
235
|
+
default: {
|
|
236
|
+
// Unknown block tokens (from marked extensions) render their raw text
|
|
237
|
+
// instead of silently disappearing. Structural no-op tokens (link
|
|
238
|
+
// reference definitions, blank runs) stay invisible.
|
|
239
|
+
if (token.type === 'def' || token.type === 'space') return []
|
|
240
|
+
const raw = (token as { raw?: string }).raw ?? ''
|
|
241
|
+
if (raw.trim() === '') return []
|
|
242
|
+
return raw.replace(/\n$/, '')
|
|
243
|
+
.split('\n')
|
|
244
|
+
.map(line => [{ text: line, style: ctx.palette.plain }])
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
import { marked } from 'marked'
|
|
2
|
+
import type { Token } from 'marked'
|
|
3
|
+
import type { Segment } from '../../../core/segments.ts'
|
|
4
|
+
import { renderBlockRows } from './block.ts'
|
|
5
|
+
import type { BlockContext } from './block.ts'
|
|
6
|
+
import { createMdPalette } from './palette.ts'
|
|
7
|
+
|
|
8
|
+
export interface MarkdownRenderResult {
|
|
9
|
+
rows: Segment[][]
|
|
10
|
+
lines: string[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface MarkdownRenderer {
|
|
14
|
+
update(content: string): MarkdownRenderResult
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizeEol(content: string): string {
|
|
18
|
+
return content.includes('\r') ? content.replace(/\r\n?/g, '\n') : content
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface FenceScan {
|
|
22
|
+
insideFence: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface FenceOpen {
|
|
26
|
+
char: string
|
|
27
|
+
len: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* CommonMark fence rules: an opening fence's info string may not contain the
|
|
32
|
+
* fence character (so "``` ```ts" is a paragraph, not a fence), and a closing
|
|
33
|
+
* fence may carry only trailing whitespace. marked follows the same rules, so
|
|
34
|
+
* the streaming scanner must too or its fence state diverges from the lexer.
|
|
35
|
+
*/
|
|
36
|
+
function fenceOpenOf(line: string): FenceOpen | null {
|
|
37
|
+
const match = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line)
|
|
38
|
+
if (match === null) return null
|
|
39
|
+
const marker = match[1]!
|
|
40
|
+
const info = match[2] ?? ''
|
|
41
|
+
if (marker[0] === '`' && info.includes('`')) return null
|
|
42
|
+
if (marker[0] === '~' && info.includes('~')) return null
|
|
43
|
+
return { char: marker[0]!, len: marker.length }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function fenceCloseOf(line: string, open: FenceOpen): boolean {
|
|
47
|
+
const match = /^ {0,3}(`{3,}|~{3,})[ \t]*$/.exec(line)
|
|
48
|
+
if (match === null) return false
|
|
49
|
+
const marker = match[1]!
|
|
50
|
+
return marker[0] === open.char && marker.length >= open.len
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function scanFences(content: string): FenceScan {
|
|
54
|
+
let open: FenceOpen | null = null
|
|
55
|
+
for (const line of content.split('\n')) {
|
|
56
|
+
if (open !== null) {
|
|
57
|
+
if (fenceCloseOf(line, open)) open = null
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
open = fenceOpenOf(line)
|
|
61
|
+
}
|
|
62
|
+
return { insideFence: open !== null }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function healInlineTail(content: string): string {
|
|
66
|
+
if (content === '' || scanFences(content).insideFence) return content
|
|
67
|
+
const breakAt = content.lastIndexOf('\n\n')
|
|
68
|
+
const head = breakAt === -1 ? '' : content.slice(0, breakAt + 2)
|
|
69
|
+
const tail = content.slice(head.length)
|
|
70
|
+
let suffix = ''
|
|
71
|
+
const matches = [...tail.matchAll(/(`+|~~|\*\*|__)/g)]
|
|
72
|
+
if (matches.length > 0) {
|
|
73
|
+
const last = matches[matches.length - 1]![1]!
|
|
74
|
+
const count = matches.filter(match => match[1] === last).length
|
|
75
|
+
if (count % 2 === 1) suffix = last
|
|
76
|
+
}
|
|
77
|
+
return suffix === '' ? content : head + tail + suffix
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface BlockEntry {
|
|
81
|
+
rows: Segment[][]
|
|
82
|
+
lines: string[]
|
|
83
|
+
bytes: number
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const BLOCK_CACHE_MAX = 512
|
|
87
|
+
// Rows retain one segment per styled run plus the joined plain line, so an
|
|
88
|
+
// entry for a large code block can reach megabytes; the byte cap bounds the
|
|
89
|
+
// cache independent of how many blocks it holds.
|
|
90
|
+
const BLOCK_CACHE_MAX_BYTES = 16 * 1024 * 1024
|
|
91
|
+
|
|
92
|
+
const blockCache = new Map<string, BlockEntry>()
|
|
93
|
+
let blockCacheBytes = 0
|
|
94
|
+
|
|
95
|
+
export function clearMarkdownBlockCache(): void {
|
|
96
|
+
blockCache.clear()
|
|
97
|
+
blockCacheBytes = 0
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function evictBlockCacheIfNeeded(): void {
|
|
101
|
+
while (blockCache.size > 0 && (blockCache.size >= BLOCK_CACHE_MAX || blockCacheBytes >= BLOCK_CACHE_MAX_BYTES)) {
|
|
102
|
+
const oldest = blockCache.keys().next()
|
|
103
|
+
if (oldest.done) return
|
|
104
|
+
const entry = blockCache.get(oldest.value)
|
|
105
|
+
if (entry !== undefined) blockCacheBytes -= entry.bytes
|
|
106
|
+
blockCache.delete(oldest.value)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Segment objects cost far more heap than their text; the constant keeps the
|
|
111
|
+
// byte accounting within ~2x of actual heap usage.
|
|
112
|
+
const SEGMENT_HEAP_BYTES = 128
|
|
113
|
+
|
|
114
|
+
function rowsHeapBytes(rows: Segment[][]): number {
|
|
115
|
+
let bytes = 0
|
|
116
|
+
for (const row of rows) {
|
|
117
|
+
for (const segment of row) bytes += SEGMENT_HEAP_BYTES + segment.text.length
|
|
118
|
+
}
|
|
119
|
+
return bytes
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function cachedBlockRows(token: Token, ctx: BlockContext, streamId: string): BlockEntry {
|
|
123
|
+
const key = `${ctx.thinking ? 't' : 'm'}\u0000${ctx.width}\u0000${streamId}\u0000${token.raw}`
|
|
124
|
+
const hit = blockCache.get(key)
|
|
125
|
+
if (hit !== undefined) return hit
|
|
126
|
+
const rows = renderBlockRows(token, { ...ctx, streamId })
|
|
127
|
+
const lines = rows.map(row => {
|
|
128
|
+
let line = ''
|
|
129
|
+
for (const segment of row) line += segment.text
|
|
130
|
+
return line
|
|
131
|
+
})
|
|
132
|
+
let bytes = key.length
|
|
133
|
+
for (const line of lines) bytes += line.length
|
|
134
|
+
bytes += rowsHeapBytes(rows)
|
|
135
|
+
const entry: BlockEntry = { rows, lines, bytes }
|
|
136
|
+
evictBlockCacheIfNeeded()
|
|
137
|
+
blockCache.set(key, entry)
|
|
138
|
+
blockCacheBytes += bytes
|
|
139
|
+
return entry
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function renderMarkdown(content: string, width: number, thinking = false, streamId = 'static'): MarkdownRenderResult {
|
|
143
|
+
if (streamId !== 'static') dropStreamState(streamId)
|
|
144
|
+
const palette = createMdPalette(thinking)
|
|
145
|
+
const ctx: BlockContext = { palette, thinking, width: Math.max(4, width), streamId }
|
|
146
|
+
const tokens = marked.lexer(healInlineTail(normalizeEol(content))) as Token[]
|
|
147
|
+
const rows: Segment[][] = []
|
|
148
|
+
const lines: string[] = []
|
|
149
|
+
let pendingSeparator = false
|
|
150
|
+
for (const [index, token] of tokens.entries()) {
|
|
151
|
+
const entry = cachedBlockRows(token, ctx, `${streamId}:${index}`)
|
|
152
|
+
if (entry.rows.length === 0) continue
|
|
153
|
+
if (pendingSeparator && rows.length > 0) {
|
|
154
|
+
rows.push([])
|
|
155
|
+
lines.push('')
|
|
156
|
+
}
|
|
157
|
+
pendingSeparator = true
|
|
158
|
+
rows.push(...entry.rows)
|
|
159
|
+
lines.push(...entry.lines)
|
|
160
|
+
}
|
|
161
|
+
while (rows.length > 0 && rows[rows.length - 1]!.length === 0) {
|
|
162
|
+
rows.pop()
|
|
163
|
+
lines.pop()
|
|
164
|
+
}
|
|
165
|
+
return { rows, lines }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function createMarkdownRenderer(width: number, thinking = false): MarkdownRenderer {
|
|
169
|
+
return {
|
|
170
|
+
update(content: string) {
|
|
171
|
+
return renderMarkdown(content, width, thinking)
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
// Streaming render
|
|
178
|
+
//
|
|
179
|
+
// Re-lexing the full content on every streamed chunk makes per-frame cost
|
|
180
|
+
// O(total length); a 128KB answer spends tens of milliseconds per frame in
|
|
181
|
+
// marked's lexer alone. The streaming path finalizes blocks at blank-line
|
|
182
|
+
// boundaries outside fences and only lexes the unfinished tail each frame;
|
|
183
|
+
// the final settled render always goes through renderMarkdown, which is
|
|
184
|
+
// exact by construction. Adversarial mid-stream content (unclosed inline
|
|
185
|
+
// spans reaching across a finalized boundary) can render one transient frame
|
|
186
|
+
// differently before the next frame or the settled render corrects it.
|
|
187
|
+
//
|
|
188
|
+
// The boundary rule mirrors marked's block tokenizer: a blank run ends the
|
|
189
|
+
// previous block unless the next content line continues an open list —
|
|
190
|
+
// marked merges same-marker list items across blank lines (loose lists), so
|
|
191
|
+
// those runs must not cut. Fence semantics match marked's fence regex
|
|
192
|
+
// exactly (fenceOpenOf/fenceCloseOf), otherwise fence state diverges from
|
|
193
|
+
// the lexer.
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
type ListKind = 'bullet' | 'ordered'
|
|
197
|
+
|
|
198
|
+
function listMarkerOf(line: string): ListKind | undefined {
|
|
199
|
+
const match = /^ {0,3}(?:([-+*])|(\d{1,9})[.)])(?:[ \t]+|$)/.exec(line)
|
|
200
|
+
if (match === null) return undefined
|
|
201
|
+
return match[1] !== undefined ? 'bullet' : 'ordered'
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface StreamRenderState {
|
|
205
|
+
epoch: number
|
|
206
|
+
width: number
|
|
207
|
+
thinking: boolean
|
|
208
|
+
/** Offset: content[:cut] is finalized into finalRows. */
|
|
209
|
+
cut: number
|
|
210
|
+
/** Offset: fence/boundary scan has consumed content[:scanPos]. */
|
|
211
|
+
scanPos: number
|
|
212
|
+
fence: FenceOpen | null
|
|
213
|
+
blankRun: boolean
|
|
214
|
+
/** Marker kind of the open list, when the current region is a list. */
|
|
215
|
+
listKind: ListKind | undefined
|
|
216
|
+
finalRows: Segment[][]
|
|
217
|
+
finalLines: string[]
|
|
218
|
+
/** Ordinal of the next block token; keeps blockCache keys stable as the tail finalizes. */
|
|
219
|
+
tokenCount: number
|
|
220
|
+
pendingSeparator: boolean
|
|
221
|
+
prevContent: string
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const STREAM_STATE_MAX = 32
|
|
225
|
+
// A state retains the content prefix plus the finalized row references, so a
|
|
226
|
+
// byte cap (content length as the proxy) prevents a handful of very large
|
|
227
|
+
// streams from pinning unbounded memory.
|
|
228
|
+
const STREAM_STATE_MAX_BYTES = 4 * 1024 * 1024
|
|
229
|
+
|
|
230
|
+
const streamRenderStates = new Map<string, StreamRenderState>()
|
|
231
|
+
let streamStateBytes = 0
|
|
232
|
+
let streamEpoch = 0
|
|
233
|
+
|
|
234
|
+
export function clearMarkdownStreamStates(): void {
|
|
235
|
+
streamEpoch += 1
|
|
236
|
+
streamRenderStates.clear()
|
|
237
|
+
streamStateBytes = 0
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function dropStreamState(streamId: string): void {
|
|
241
|
+
const dropped = streamRenderStates.get(streamId)
|
|
242
|
+
if (dropped !== undefined) streamStateBytes -= dropped.prevContent.length
|
|
243
|
+
streamRenderStates.delete(streamId)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function touchStreamState(streamId: string, state: StreamRenderState): void {
|
|
247
|
+
dropStreamState(streamId)
|
|
248
|
+
streamRenderStates.set(streamId, state)
|
|
249
|
+
streamStateBytes += state.prevContent.length
|
|
250
|
+
while (streamRenderStates.size > 1 && (streamRenderStates.size > STREAM_STATE_MAX || streamStateBytes > STREAM_STATE_MAX_BYTES)) {
|
|
251
|
+
const oldest = streamRenderStates.keys().next()
|
|
252
|
+
if (oldest.done) break
|
|
253
|
+
dropStreamState(oldest.value)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Advances the block-boundary scan over complete lines. A cut is recorded
|
|
259
|
+
* where a blank run is followed by content that cannot continue the previous
|
|
260
|
+
* block (lists continue across blank lines until a different block or a
|
|
261
|
+
* different marker kind appears). Only complete lines (terminated by '\n')
|
|
262
|
+
* are consumed: a partial trailing line still grows and may reclassify
|
|
263
|
+
* (e.g. "```" completing into "``` para"), so it is re-scanned every frame.
|
|
264
|
+
*/
|
|
265
|
+
function scanStreamTail(
|
|
266
|
+
content: string,
|
|
267
|
+
from: number,
|
|
268
|
+
fence: FenceOpen | null,
|
|
269
|
+
blankRun: boolean,
|
|
270
|
+
listKind: ListKind | undefined,
|
|
271
|
+
): { fence: FenceOpen | null; blankRun: boolean; listKind: ListKind | undefined; scanPos: number; boundary: number } {
|
|
272
|
+
let open = fence
|
|
273
|
+
let run = blankRun
|
|
274
|
+
let kind = listKind
|
|
275
|
+
let pos = from
|
|
276
|
+
let boundary = -1
|
|
277
|
+
const cut = (at: number): void => {
|
|
278
|
+
if (boundary === -1) boundary = at
|
|
279
|
+
}
|
|
280
|
+
while (pos < content.length) {
|
|
281
|
+
const nl = content.indexOf('\n', pos)
|
|
282
|
+
if (nl === -1) break
|
|
283
|
+
const line = content.slice(pos, nl)
|
|
284
|
+
if (open !== null) {
|
|
285
|
+
if (fenceCloseOf(line, open)) open = null
|
|
286
|
+
} else {
|
|
287
|
+
const opened = fenceOpenOf(line)
|
|
288
|
+
if (opened !== null) {
|
|
289
|
+
open = opened
|
|
290
|
+
kind = undefined
|
|
291
|
+
run = false
|
|
292
|
+
} else if (/^\s*$/.test(line)) {
|
|
293
|
+
run = true
|
|
294
|
+
} else {
|
|
295
|
+
const marker = listMarkerOf(line)
|
|
296
|
+
if (marker !== undefined) {
|
|
297
|
+
// Same marker kind across a blank run keeps one loose list.
|
|
298
|
+
if (run && marker !== kind) cut(pos)
|
|
299
|
+
kind = marker
|
|
300
|
+
run = false
|
|
301
|
+
} else if (/^ {2,}/.test(line) && kind !== undefined) {
|
|
302
|
+
// Indented continuation of an open list item.
|
|
303
|
+
run = false
|
|
304
|
+
} else if (run) {
|
|
305
|
+
// A blank run plus a non-list line always ends the open list.
|
|
306
|
+
cut(pos)
|
|
307
|
+
kind = undefined
|
|
308
|
+
run = false
|
|
309
|
+
}
|
|
310
|
+
// A non-list line attached without a blank is a lazy continuation
|
|
311
|
+
// (marked keeps it inside the item's paragraph), so kind persists.
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
pos = nl + 1
|
|
315
|
+
}
|
|
316
|
+
if (boundary === -1 && run && open === null && kind === undefined && content.endsWith('\n')) {
|
|
317
|
+
boundary = content.length
|
|
318
|
+
}
|
|
319
|
+
return { fence: open, blankRun: run, listKind: kind, scanPos: pos, boundary }
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function renderMarkdownStreaming(content: string, width: number, thinking: boolean, streamId: string): MarkdownRenderResult {
|
|
323
|
+
if (content.includes('\r')) return renderMarkdown(content, width, thinking, streamId)
|
|
324
|
+
let state = streamRenderStates.get(streamId)
|
|
325
|
+
const valid = state !== undefined
|
|
326
|
+
&& state.epoch === streamEpoch
|
|
327
|
+
&& state.width === width
|
|
328
|
+
&& state.thinking === thinking
|
|
329
|
+
&& content.length >= state.prevContent.length
|
|
330
|
+
&& content.startsWith(state.prevContent)
|
|
331
|
+
if (!valid) {
|
|
332
|
+
state = {
|
|
333
|
+
epoch: streamEpoch,
|
|
334
|
+
width,
|
|
335
|
+
thinking,
|
|
336
|
+
cut: 0,
|
|
337
|
+
scanPos: 0,
|
|
338
|
+
fence: null,
|
|
339
|
+
blankRun: false,
|
|
340
|
+
listKind: undefined,
|
|
341
|
+
finalRows: [],
|
|
342
|
+
finalLines: [],
|
|
343
|
+
tokenCount: 0,
|
|
344
|
+
pendingSeparator: false,
|
|
345
|
+
prevContent: '',
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
state = state!
|
|
349
|
+
const palette = createMdPalette(thinking)
|
|
350
|
+
const ctx: BlockContext = { palette, thinking, width: Math.max(4, width), streamId }
|
|
351
|
+
const scan = scanStreamTail(content, state.scanPos, state.fence, state.blankRun, state.listKind)
|
|
352
|
+
state.fence = scan.fence
|
|
353
|
+
state.blankRun = scan.blankRun
|
|
354
|
+
state.listKind = scan.listKind
|
|
355
|
+
state.scanPos = scan.scanPos
|
|
356
|
+
// The per-frame accumulator copies the finalized prefix; tail rows are
|
|
357
|
+
// transient (the same region is re-lexed next frame), so the state snapshot
|
|
358
|
+
// is taken after the finalize pass and must not include them.
|
|
359
|
+
const rows = state.finalRows.slice()
|
|
360
|
+
const lines = state.finalLines.slice()
|
|
361
|
+
let pendingSeparator = state.pendingSeparator
|
|
362
|
+
if (scan.boundary > state.cut) {
|
|
363
|
+
const tokens = marked.lexer(content.slice(state.cut, scan.boundary)) as Token[]
|
|
364
|
+
for (const token of tokens) {
|
|
365
|
+
const entry = cachedBlockRows(token, ctx, `${streamId}:${state.tokenCount}`)
|
|
366
|
+
state.tokenCount += 1
|
|
367
|
+
if (entry.rows.length === 0) continue
|
|
368
|
+
if (pendingSeparator && rows.length > 0) {
|
|
369
|
+
rows.push([])
|
|
370
|
+
lines.push('')
|
|
371
|
+
}
|
|
372
|
+
pendingSeparator = true
|
|
373
|
+
rows.push(...entry.rows)
|
|
374
|
+
lines.push(...entry.lines)
|
|
375
|
+
}
|
|
376
|
+
state.cut = scan.boundary
|
|
377
|
+
}
|
|
378
|
+
state.finalRows = rows.slice()
|
|
379
|
+
state.finalLines = lines.slice()
|
|
380
|
+
state.pendingSeparator = pendingSeparator
|
|
381
|
+
const tail = healStreamingTail(content.slice(state.cut), state.fence)
|
|
382
|
+
const tokens = marked.lexer(tail) as Token[]
|
|
383
|
+
const tailRows: Segment[][] = []
|
|
384
|
+
const tailLines: string[] = []
|
|
385
|
+
let tailIndex = 0
|
|
386
|
+
for (const token of tokens) {
|
|
387
|
+
const entry = cachedBlockRows(token, ctx, `${streamId}:${state.tokenCount + tailIndex}`)
|
|
388
|
+
tailIndex += 1
|
|
389
|
+
if (entry.rows.length === 0) continue
|
|
390
|
+
if (pendingSeparator && rows.length + tailRows.length > 0) {
|
|
391
|
+
tailRows.push([])
|
|
392
|
+
tailLines.push('')
|
|
393
|
+
}
|
|
394
|
+
pendingSeparator = true
|
|
395
|
+
tailRows.push(...entry.rows)
|
|
396
|
+
tailLines.push(...entry.lines)
|
|
397
|
+
}
|
|
398
|
+
state.prevContent = content
|
|
399
|
+
touchStreamState(streamId, state)
|
|
400
|
+
const resultRows = rows.concat(tailRows)
|
|
401
|
+
const resultLines = lines.concat(tailLines)
|
|
402
|
+
while (resultRows.length > 0 && resultRows[resultRows.length - 1]!.length === 0) {
|
|
403
|
+
resultRows.pop()
|
|
404
|
+
resultLines.pop()
|
|
405
|
+
}
|
|
406
|
+
return { rows: resultRows, lines: resultLines }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function healStreamingTail(tail: string, fence: FenceOpen | null): string {
|
|
410
|
+
if (tail === '' || fence !== null) return tail
|
|
411
|
+
// An incomplete trailing line that starts with a fence marker may be a
|
|
412
|
+
// fence opening (the scanner holds incomplete lines back), so inline
|
|
413
|
+
// balancing must not treat its marker as an unclosed span.
|
|
414
|
+
const nl = tail.lastIndexOf('\n')
|
|
415
|
+
const lastLine = nl === -1 ? tail : tail.slice(nl + 1)
|
|
416
|
+
if (/^ {0,3}(`{3,}|~{3,})/.test(lastLine)) return tail
|
|
417
|
+
// Inline markers cannot span blank lines, so only the last paragraph-shaped
|
|
418
|
+
// segment of the tail can leave a marker open at the streaming cut; the
|
|
419
|
+
// suffix heals that segment and lands at the end of the tail.
|
|
420
|
+
const breakAt = tail.lastIndexOf('\n\n')
|
|
421
|
+
const last = breakAt === -1 ? tail : tail.slice(breakAt + 2)
|
|
422
|
+
let suffix = ''
|
|
423
|
+
const matches = [...last.matchAll(/(`+|~~|\*\*|__)/g)]
|
|
424
|
+
if (matches.length > 0) {
|
|
425
|
+
const marker = matches[matches.length - 1]![1]!
|
|
426
|
+
const count = matches.filter(match => match[1] === marker).length
|
|
427
|
+
if (count % 2 === 1) suffix = marker
|
|
428
|
+
}
|
|
429
|
+
return suffix === '' ? tail : tail + suffix
|
|
430
|
+
}
|