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,932 @@
1
+ /**
2
+ * Zero-dependency syntax highlighting for the dashboard's code panes.
3
+ *
4
+ * The highlighter never emits escape sequences: it returns {@link Segment}
5
+ * lists whose `sgr` fields are raw SGR parameter strings handed to
6
+ * `renderRow()` in `width.js`. Every palette value may be the empty string
7
+ * (color disabled), which this module normalizes to "no style".
8
+ *
9
+ * The one hard invariant is *character preservation*: for any input line `l`,
10
+ *
11
+ * highlightLine(l, lang, syn).map((s) => s.text).join('') === sanitize(l)
12
+ *
13
+ * The scanner achieves this structurally — it walks the sanitized line and
14
+ * emits contiguous slices, so a malformed string, an unterminated comment or a
15
+ * stray control character can never drop or duplicate text. Unknown languages
16
+ * degrade to plain text rather than throwing.
17
+ *
18
+ * @module dsh-live-trace/highlight
19
+ */
20
+
21
+ import { sanitize } from './width.js'
22
+
23
+ /**
24
+ * @typedef {{ text: string, sgr?: string }} Segment
25
+ */
26
+
27
+ /** Shared empty set so every config has a Set to query. */
28
+ const EMPTY_SET = new Set()
29
+
30
+ /** Sticky word regex for C-like identifiers. */
31
+ const RE_WORD = /[A-Za-z_$][A-Za-z0-9_$]*/y
32
+ /** CSS identifiers may start with one or two dashes and contain dashes. */
33
+ const RE_WORD_CSS = /(?:--|-)?[A-Za-z_][A-Za-z0-9_-]*/y
34
+ /** Numbers: hex / binary / octal / decimal / fraction / exponent, optional BigInt `n`. */
35
+ const RE_NUMBER =
36
+ /(?:0[xX][0-9a-fA-F_]+|0[bB][01_]+|0[oO][0-7_]+|(?:\d[\d_]*(?:\.[\d_]*)?|\.[\d_]+)(?:[eE][+-]?\d[\d_]*)?)(?:[nN])?/y
37
+ /** CSS numbers may carry a unit (`10px`) or a percentage (`50%`). */
38
+ const RE_NUMBER_UNITS =
39
+ /(?:\d[\d_]*(?:\.[\d_]*)?|\.[\d_]+)(?:[eE][+-]?\d+)?(?:%|[A-Za-z]+)?/y
40
+ /** Bash `-x` / `--long-flag`. */
41
+ const RE_DASH_FLAG = /--?[A-Za-z][A-Za-z0-9-]*/y
42
+ /** Multi-character operators first, then the single-character operator class. */
43
+ const RE_OPERATOR =
44
+ /(?:=>|->|::|\.\.\.|===|!==|==|!=|<=|>=|&&|\|\||\?\?|\?\.|\*\*|<<=|>>=|\+=|-=|\*=|\/=|%=|&=|\|=|\^=|<<|>>|\+\+|--|[-+*/%=<>!&|^~])/y
45
+ /** Structural punctuation. */
46
+ const RE_PUNCT = /[()[\]{},;.:?]/y
47
+ /** YAML/JSON-ish key colon: `key:` followed by whitespace or a flow bracket. */
48
+ const RE_STRICT_COLON_TAIL = /^[\s[{,}\]]/
49
+ /** CSS hex colour. */
50
+ const RE_HEX_COLOR = /^#[0-9a-fA-F]{3,8}/
51
+ /** Diff hunk header. */
52
+ const RE_HUNK = /^@@/
53
+
54
+ function set(...items) {
55
+ return new Set(items)
56
+ }
57
+
58
+ function isDigit(ch) {
59
+ return ch >= '0' && ch <= '9'
60
+ }
61
+
62
+ function isWordStart(ch) {
63
+ return typeof ch === 'string' && /[A-Za-z_$]/.test(ch)
64
+ }
65
+
66
+ /**
67
+ * Resolve a palette entry to a style, collapsing `''`/missing/`undefined` to
68
+ * "unstyled" so disabled colors merge cleanly.
69
+ * @param {Record<string, string> | undefined | null} palette
70
+ * @param {string | undefined} key
71
+ * @returns {string | undefined}
72
+ */
73
+ function styleOf(palette, key) {
74
+ if (key === undefined || palette === null || palette === undefined) return undefined
75
+ const value = palette[key]
76
+ return typeof value === 'string' && value.length > 0 ? value : undefined
77
+ }
78
+
79
+ /**
80
+ * Append text to a segment list, merging with the previous segment when the
81
+ * style is identical. Unstyled runs are stored without an `sgr` key.
82
+ * @param {Segment[]} out
83
+ * @param {string} text
84
+ * @param {string | undefined} sgr
85
+ */
86
+ function pushSegment(out, text, sgr) {
87
+ if (text.length === 0) return
88
+ const style = sgr === '' ? undefined : sgr
89
+ const last = out[out.length - 1]
90
+ if (last !== undefined && last.sgr === style) {
91
+ last.text += text
92
+ return
93
+ }
94
+ if (style === undefined) out.push({ text })
95
+ else out.push({ text, sgr: style })
96
+ }
97
+
98
+ /** Count a run of identical characters starting at `index`. */
99
+ function countRun(line, index, ch) {
100
+ let end = index
101
+ while (end < line.length && line[end] === ch) end += 1
102
+ return end - index
103
+ }
104
+
105
+ /** Match a config word at `index`, or `null`. */
106
+ function readWord(line, index, cfg) {
107
+ const re = cfg.wordPattern ?? RE_WORD
108
+ re.lastIndex = index
109
+ const match = re.exec(line)
110
+ return match === null ? null : match[0]
111
+ }
112
+
113
+ /** Strict key colon: whitespace or a flow bracket must follow. */
114
+ function isStrictKeyColon(line, index) {
115
+ if (line[index] !== ':') return false
116
+ const next = line[index + 1]
117
+ if (next === undefined) return true
118
+ return RE_STRICT_COLON_TAIL.test(next)
119
+ }
120
+
121
+ /** Fill in the shared scanner defaults. */
122
+ function makeConfig(partial) {
123
+ return {
124
+ lineComments: [],
125
+ blockComments: [],
126
+ tripleStrings: [],
127
+ strings: [],
128
+ templates: [],
129
+ keywords: EMPTY_SET,
130
+ types: EMPTY_SET,
131
+ literals: EMPTY_SET,
132
+ builtins: EMPTY_SET,
133
+ selfWords: EMPTY_SET,
134
+ globals: EMPTY_SET,
135
+ ...partial
136
+ }
137
+ }
138
+
139
+ /* ------------------------------------------------------------------ *
140
+ * Per-language token sets
141
+ * ------------------------------------------------------------------ */
142
+
143
+ const JS_KEYWORDS = set(
144
+ 'as', 'async', 'await', 'break', 'case', 'catch', 'class', 'const', 'continue',
145
+ 'debugger', 'default', 'delete', 'do', 'else', 'export', 'extends', 'finally',
146
+ 'for', 'from', 'function', 'get', 'if', 'import', 'in', 'instanceof', 'let',
147
+ 'new', 'of', 'return', 'set', 'static', 'switch', 'throw', 'try', 'typeof',
148
+ 'var', 'void', 'while', 'with', 'yield'
149
+ )
150
+ const JS_TYPES = set(
151
+ 'Array', 'ArrayBuffer', 'BigInt', 'Boolean', 'DataView', 'Date', 'Error',
152
+ 'EvalError', 'Float32Array', 'Float64Array', 'Function', 'Int8Array',
153
+ 'Int16Array', 'Int32Array', 'JSON', 'Map', 'Math', 'Number', 'Object',
154
+ 'Promise', 'Proxy', 'RangeError', 'ReferenceError', 'Reflect', 'RegExp', 'Set',
155
+ 'String', 'Symbol', 'SyntaxError', 'TypeError', 'Uint8Array',
156
+ 'Uint8ClampedArray', 'Uint16Array', 'Uint32Array', 'URIError', 'WeakMap',
157
+ 'WeakSet'
158
+ )
159
+ const JS_LITERALS = set('true', 'false', 'null', 'undefined', 'NaN', 'Infinity')
160
+ const JS_BUILTINS = set(
161
+ 'parseInt', 'parseFloat', 'isNaN', 'isFinite', 'decodeURI',
162
+ 'decodeURIComponent', 'encodeURI', 'encodeURIComponent', 'require',
163
+ 'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval', 'queueMicrotask',
164
+ 'structuredClone', 'fetch'
165
+ )
166
+ const JS_GLOBALS = set(
167
+ 'console', 'process', 'globalThis', 'window', 'document', 'global', 'module',
168
+ 'exports', '__dirname', '__filename', 'Buffer'
169
+ )
170
+ const JS_CONFIG = makeConfig({
171
+ lineComments: ['//'],
172
+ blockComments: [['/*', '*/']],
173
+ strings: ["'", '"'],
174
+ templates: ['`'],
175
+ keywords: JS_KEYWORDS,
176
+ types: JS_TYPES,
177
+ literals: JS_LITERALS,
178
+ builtins: JS_BUILTINS,
179
+ globals: JS_GLOBALS,
180
+ decorators: true
181
+ })
182
+
183
+ const TS_KEYWORDS = new Set([
184
+ ...JS_KEYWORDS,
185
+ 'abstract', 'declare', 'enum', 'implements', 'infer', 'interface', 'is',
186
+ 'keyof', 'module', 'namespace', 'override', 'private', 'protected', 'public',
187
+ 'readonly', 'satisfies', 'type', 'unique', 'using', 'accessor'
188
+ ])
189
+ const TS_TYPES = new Set([
190
+ ...JS_TYPES,
191
+ 'any', 'bigint', 'boolean', 'never', 'number', 'object', 'string', 'symbol',
192
+ 'unknown'
193
+ ])
194
+ const TS_CONFIG = makeConfig({ ...JS_CONFIG, keywords: TS_KEYWORDS, types: TS_TYPES })
195
+
196
+ const JSON_CONFIG = makeConfig({
197
+ strings: ['"'],
198
+ literals: set('true', 'false', 'null'),
199
+ jsonKeys: true
200
+ })
201
+
202
+ const BASH_KEYWORDS = set(
203
+ 'if', 'then', 'else', 'elif', 'fi', 'for', 'while', 'until', 'do', 'done',
204
+ 'case', 'esac', 'in', 'function', 'select', 'time', 'coproc', 'return',
205
+ 'local', 'declare', 'typeset', 'export', 'readonly', 'unset', 'shift', 'exit',
206
+ 'break', 'continue', 'eval', 'exec', 'trap', 'set', 'source'
207
+ )
208
+ const BASH_BUILTINS = set(
209
+ 'echo', 'printf', 'read', 'cd', 'pwd', 'ls', 'cat', 'grep', 'sed', 'awk',
210
+ 'cut', 'sort', 'uniq', 'head', 'tail', 'wc', 'tr', 'find', 'xargs', 'mkdir',
211
+ 'rmdir', 'rm', 'cp', 'mv', 'touch', 'chmod', 'chown', 'ln', 'tar', 'gzip',
212
+ 'curl', 'wget', 'git', 'npm', 'pnpm', 'yarn', 'node', 'python', 'python3',
213
+ 'pip', 'make', 'docker', 'sudo', 'env', 'which', 'type', 'test', 'true',
214
+ 'false', 'kill', 'jobs', 'wait', 'sleep', 'date', 'basename', 'dirname',
215
+ 'realpath', 'tee', 'diff', 'patch'
216
+ )
217
+ const BASH_CONFIG = makeConfig({
218
+ lineComments: ['#'],
219
+ strings: ["'", '"'],
220
+ keywords: BASH_KEYWORDS,
221
+ builtins: BASH_BUILTINS,
222
+ dollarVariables: true,
223
+ dashFlags: true
224
+ })
225
+
226
+ const PYTHON_KEYWORDS = set(
227
+ 'and', 'as', 'assert', 'async', 'await', 'break', 'class', 'continue', 'def',
228
+ 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global', 'if',
229
+ 'import', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise',
230
+ 'return', 'try', 'while', 'with', 'yield', 'match', 'case'
231
+ )
232
+ const PYTHON_TYPES = set(
233
+ 'int', 'float', 'complex', 'str', 'bytes', 'bytearray', 'bool', 'list',
234
+ 'tuple', 'dict', 'set', 'frozenset', 'range', 'object', 'type', 'memoryview'
235
+ )
236
+ const PYTHON_BUILTINS = set(
237
+ 'print', 'len', 'enumerate', 'zip', 'map', 'filter', 'sum', 'min', 'max',
238
+ 'abs', 'sorted', 'reversed', 'open', 'input', 'isinstance', 'issubclass',
239
+ 'getattr', 'setattr', 'hasattr', 'delattr', 'super', 'next', 'iter', 'any',
240
+ 'all', 'repr', 'format', 'id', 'round', 'divmod', 'pow', 'hash', 'callable',
241
+ 'vars', 'dir', 'globals', 'locals', 'exec', 'compile', 'staticmethod',
242
+ 'classmethod', 'property', 'ord', 'chr', 'hex', 'oct', 'bin'
243
+ )
244
+ const PYTHON_CONFIG = makeConfig({
245
+ lineComments: ['#'],
246
+ tripleStrings: ['"""', "'''"],
247
+ strings: ["'", '"'],
248
+ keywords: PYTHON_KEYWORDS,
249
+ types: PYTHON_TYPES,
250
+ literals: set('True', 'False', 'None', 'NotImplemented', 'Ellipsis'),
251
+ builtins: PYTHON_BUILTINS,
252
+ selfWords: set('self', 'cls'),
253
+ decorators: true
254
+ })
255
+
256
+ const GO_KEYWORDS = set(
257
+ 'break', 'case', 'chan', 'const', 'continue', 'default', 'defer', 'else',
258
+ 'fallthrough', 'for', 'func', 'go', 'goto', 'if', 'import', 'interface',
259
+ 'map', 'package', 'range', 'return', 'select', 'struct', 'switch', 'type',
260
+ 'var'
261
+ )
262
+ const GO_TYPES = set(
263
+ 'bool', 'byte', 'complex64', 'complex128', 'error', 'float32', 'float64',
264
+ 'int', 'int8', 'int16', 'int32', 'int64', 'rune', 'string', 'uint', 'uint8',
265
+ 'uint16', 'uint32', 'uint64', 'uintptr', 'any', 'comparable'
266
+ )
267
+ const GO_BUILTINS = set(
268
+ 'make', 'len', 'cap', 'new', 'append', 'copy', 'delete', 'panic', 'recover',
269
+ 'print', 'println', 'close', 'complex', 'real', 'imag', 'min', 'max', 'clear'
270
+ )
271
+ const GO_CONFIG = makeConfig({
272
+ lineComments: ['//'],
273
+ blockComments: [['/*', '*/']],
274
+ strings: ['"', "'"],
275
+ templates: ['`'],
276
+ keywords: GO_KEYWORDS,
277
+ types: GO_TYPES,
278
+ literals: set('true', 'false', 'nil', 'iota'),
279
+ builtins: GO_BUILTINS
280
+ })
281
+
282
+ const RUST_KEYWORDS = set(
283
+ 'as', 'async', 'await', 'break', 'const', 'continue', 'crate', 'dyn', 'else',
284
+ 'enum', 'extern', 'fn', 'for', 'if', 'impl', 'in', 'let', 'loop', 'match',
285
+ 'mod', 'move', 'mut', 'pub', 'ref', 'return', 'self', 'Self', 'static',
286
+ 'struct', 'super', 'trait', 'type', 'unsafe', 'use', 'where', 'while', 'union',
287
+ 'macro', 'yield', 'try'
288
+ )
289
+ const RUST_TYPES = set(
290
+ 'i8', 'i16', 'i32', 'i64', 'i128', 'isize', 'u8', 'u16', 'u32', 'u64', 'u128',
291
+ 'usize', 'f32', 'f64', 'bool', 'char', 'str', 'String', 'Vec', 'Option',
292
+ 'Result', 'Box', 'Rc', 'Arc', 'Cell', 'RefCell', 'Mutex', 'RwLock',
293
+ 'HashMap', 'HashSet', 'BTreeMap', 'BTreeSet', 'Cow', 'Pin', 'PhantomData',
294
+ 'Ok', 'Err', 'Some', 'None'
295
+ )
296
+ const RUST_BUILTINS = set(
297
+ 'println', 'print', 'eprintln', 'eprint', 'format', 'vec', 'panic', 'assert',
298
+ 'assert_eq', 'assert_ne', 'debug_assert', 'write', 'writeln', 'matches',
299
+ 'todo', 'unimplemented', 'unreachable', 'include_str', 'include_bytes', 'env',
300
+ 'cfg', 'stringify', 'concat', 'dbg'
301
+ )
302
+ const RUST_CONFIG = makeConfig({
303
+ lineComments: ['//'],
304
+ blockComments: [['/*', '*/']],
305
+ strings: ['"', "'"],
306
+ keywords: RUST_KEYWORDS,
307
+ types: RUST_TYPES,
308
+ literals: set('true', 'false'),
309
+ builtins: RUST_BUILTINS,
310
+ macros: true,
311
+ lifetime: true
312
+ })
313
+
314
+ const SQL_KEYWORDS = set(
315
+ 'SELECT', 'FROM', 'WHERE', 'INSERT', 'INTO', 'UPDATE', 'DELETE', 'CREATE',
316
+ 'TABLE', 'ALTER', 'DROP', 'INDEX', 'VIEW', 'JOIN', 'LEFT', 'RIGHT', 'INNER',
317
+ 'OUTER', 'FULL', 'CROSS', 'ON', 'AS', 'AND', 'OR', 'NOT', 'NULL', 'IS', 'IN',
318
+ 'LIKE', 'BETWEEN', 'EXISTS', 'DISTINCT', 'UNION', 'ALL', 'GROUP', 'BY',
319
+ 'ORDER', 'HAVING', 'LIMIT', 'OFFSET', 'VALUES', 'SET', 'PRIMARY', 'KEY',
320
+ 'FOREIGN', 'REFERENCES', 'DEFAULT', 'CASCADE', 'CONSTRAINT', 'UNIQUE', 'CHECK',
321
+ 'WITH', 'RETURNING', 'CASE', 'WHEN', 'THEN', 'ELSE', 'END', 'ASC', 'DESC',
322
+ 'USING', 'NATURAL', 'TRUNCATE', 'EXPLAIN', 'ANALYZE', 'VACUUM', 'BEGIN',
323
+ 'COMMIT', 'ROLLBACK', 'TRANSACTION', 'GRANT', 'REVOKE', 'IF', 'REPLACE'
324
+ )
325
+ const SQL_TYPES = set(
326
+ 'INT', 'INTEGER', 'BIGINT', 'SMALLINT', 'TINYINT', 'SERIAL', 'BIGSERIAL',
327
+ 'TEXT', 'VARCHAR', 'CHAR', 'BOOLEAN', 'BOOL', 'DATE', 'TIME', 'TIMESTAMP',
328
+ 'TIMESTAMPTZ', 'INTERVAL', 'DECIMAL', 'NUMERIC', 'REAL', 'DOUBLE', 'FLOAT',
329
+ 'JSON', 'JSONB', 'UUID', 'BLOB', 'BYTEA', 'ARRAY'
330
+ )
331
+ const SQL_BUILTINS = set(
332
+ 'COUNT', 'SUM', 'AVG', 'MIN', 'MAX', 'COALESCE', 'NULLIF', 'CAST', 'NOW',
333
+ 'CURRENT_DATE', 'CURRENT_TIMESTAMP', 'LOWER', 'UPPER', 'LENGTH', 'TRIM',
334
+ 'SUBSTRING', 'ROUND', 'ABS', 'CONCAT', 'STRING_AGG', 'ARRAY_AGG',
335
+ 'ROW_NUMBER', 'RANK', 'DENSE_RANK'
336
+ )
337
+ const SQL_CONFIG = makeConfig({
338
+ lineComments: ['--'],
339
+ blockComments: [['/*', '*/']],
340
+ strings: ["'", '"'],
341
+ keywords: SQL_KEYWORDS,
342
+ types: SQL_TYPES,
343
+ literals: set('TRUE', 'FALSE', 'NULL'),
344
+ builtins: SQL_BUILTINS,
345
+ caseInsensitive: true
346
+ })
347
+
348
+ const CSS_CONFIG = makeConfig({
349
+ blockComments: [['/*', '*/']],
350
+ strings: ['"', "'"],
351
+ wordPattern: RE_WORD_CSS,
352
+ keywords: set(
353
+ 'important', 'inherit', 'initial', 'unset', 'revert', 'none', 'auto',
354
+ 'block', 'inline', 'flex', 'grid', 'absolute', 'relative', 'fixed',
355
+ 'sticky', 'transparent', 'currentColor'
356
+ ),
357
+ dashWord: true,
358
+ keyColonLoose: true,
359
+ hashColor: true,
360
+ numberUnits: true,
361
+ decorators: true
362
+ })
363
+
364
+ const YAML_CONFIG = makeConfig({
365
+ lineComments: ['#'],
366
+ strings: ["'", '"'],
367
+ literals: set(
368
+ 'true', 'false', 'null', 'yes', 'no', 'on', 'off', 'True', 'False', 'Null',
369
+ 'NULL', 'TRUE', 'FALSE', 'Yes', 'No', 'On', 'Off', '~'
370
+ ),
371
+ keyColon: true,
372
+ anchorChars: '&*',
373
+ metaAtStart: true,
374
+ tildeLiteral: true
375
+ })
376
+
377
+ /* ------------------------------------------------------------------ *
378
+ * Custom line handlers
379
+ * ------------------------------------------------------------------ */
380
+
381
+ /**
382
+ * HTML/XML: comments, doctype, tag names, attributes and quoted values.
383
+ * Text outside tags is left unstyled. Never loses a character.
384
+ * @returns {Segment[]}
385
+ */
386
+ function htmlLine(line, syn, state) {
387
+ const out = []
388
+ const n = line.length
389
+ let i = 0
390
+ while (i < n) {
391
+ if (state.block) {
392
+ const idx = line.indexOf(state.block, i)
393
+ if (idx === -1) {
394
+ pushSegment(out, line.slice(i), styleOf(syn, 'comment'))
395
+ return out
396
+ }
397
+ pushSegment(out, line.slice(i, idx + state.block.length), styleOf(syn, 'comment'))
398
+ i = idx + state.block.length
399
+ state.block = null
400
+ continue
401
+ }
402
+ if (line.startsWith('<!--', i)) {
403
+ const idx = line.indexOf('-->', i + 4)
404
+ if (idx === -1) {
405
+ state.block = '-->'
406
+ pushSegment(out, line.slice(i), styleOf(syn, 'comment'))
407
+ return out
408
+ }
409
+ pushSegment(out, line.slice(i, idx + 3), styleOf(syn, 'comment'))
410
+ i = idx + 3
411
+ continue
412
+ }
413
+ if (line[i] === '<') {
414
+ if (/^<!doctype/i.test(line.slice(i, i + 9)) || line.startsWith('<!', i) || line.startsWith('<?', i)) {
415
+ const idx = line.indexOf('>', i)
416
+ const end = idx === -1 ? n : idx + 1
417
+ pushSegment(out, line.slice(i, end), styleOf(syn, 'meta'))
418
+ i = end
419
+ continue
420
+ }
421
+ const idx = line.indexOf('>', i)
422
+ const end = idx === -1 ? n : idx + 1
423
+ highlightTag(line.slice(i, end), out, syn)
424
+ i = end
425
+ continue
426
+ }
427
+ const next = line.indexOf('<', i)
428
+ const end = next === -1 ? n : next
429
+ pushSegment(out, line.slice(i, end))
430
+ i = end
431
+ }
432
+ return out
433
+ }
434
+
435
+ /** Tokenize the inside of a single `<...>` tag. */
436
+ function highlightTag(text, out, syn) {
437
+ const n = text.length
438
+ let i = 0
439
+ if (text.startsWith('</')) {
440
+ pushSegment(out, '</', styleOf(syn, 'tag'))
441
+ i = 2
442
+ } else if (text.startsWith('<')) {
443
+ pushSegment(out, '<', styleOf(syn, 'tag'))
444
+ i = 1
445
+ }
446
+ let j = i
447
+ while (j < n && /[A-Za-z0-9:_.-]/.test(text[j])) j += 1
448
+ if (j > i) {
449
+ pushSegment(out, text.slice(i, j), styleOf(syn, 'tag'))
450
+ i = j
451
+ }
452
+ while (i < n) {
453
+ const ch = text[i]
454
+ if (ch === '>' || ch === '/') {
455
+ pushSegment(out, ch, styleOf(syn, 'tag'))
456
+ i += 1
457
+ continue
458
+ }
459
+ if (ch === '=') {
460
+ pushSegment(out, '=', styleOf(syn, 'operator'))
461
+ i += 1
462
+ continue
463
+ }
464
+ if (ch === '"' || ch === "'") {
465
+ let end = i + 1
466
+ while (end < n && text[end] !== ch) end += 1
467
+ if (end < n) end += 1
468
+ pushSegment(out, text.slice(i, end), styleOf(syn, 'string'))
469
+ i = end
470
+ continue
471
+ }
472
+ if (/\s/.test(ch)) {
473
+ let end = i
474
+ while (end < n && /\s/.test(text[end])) end += 1
475
+ pushSegment(out, text.slice(i, end))
476
+ i = end
477
+ continue
478
+ }
479
+ let end = i
480
+ while (end < n && /[A-Za-z0-9_:.@[\]-]/.test(text[end])) end += 1
481
+ if (end === i) end = i + 1
482
+ pushSegment(out, text.slice(i, end), styleOf(syn, 'attribute'))
483
+ i = end
484
+ }
485
+ }
486
+
487
+ /**
488
+ * Markdown source highlighting: fences, headings, quotes, inline code, emphasis
489
+ * and links. Purely cosmetic — the block renderer lives in `markdown.js`.
490
+ * @returns {Segment[]}
491
+ */
492
+ function markdownLine(line, syn) {
493
+ const out = []
494
+ const fence = /^ {0,3}(`{3,}|~{3,})/.exec(line)
495
+ if (fence) {
496
+ pushSegment(out, line, styleOf(syn, 'meta'))
497
+ return out
498
+ }
499
+ const heading = /^(#{1,6})(\s.*)?$/.exec(line)
500
+ if (heading) {
501
+ pushSegment(out, heading[1], styleOf(syn, 'keyword'))
502
+ if (heading[2] !== undefined) pushSegment(out, heading[2], styleOf(syn, 'type'))
503
+ return out
504
+ }
505
+ const n = line.length
506
+ let i = 0
507
+ while (i < n) {
508
+ const ch = line[i]
509
+ if (ch === '`') {
510
+ const run = countRun(line, i, '`')
511
+ const close = line.indexOf('`'.repeat(run), i + run)
512
+ if (close === -1) {
513
+ pushSegment(out, line.slice(i), styleOf(syn, 'string'))
514
+ i = n
515
+ continue
516
+ }
517
+ pushSegment(out, line.slice(i, i + run), styleOf(syn, 'punctuation'))
518
+ pushSegment(out, line.slice(i + run, close), styleOf(syn, 'string'))
519
+ pushSegment(out, line.slice(close, close + run), styleOf(syn, 'punctuation'))
520
+ i = close + run
521
+ continue
522
+ }
523
+ if (line.startsWith('**', i) || line.startsWith('__', i) || line.startsWith('~~', i)) {
524
+ pushSegment(out, line.slice(i, i + 2), styleOf(syn, 'keyword'))
525
+ i += 2
526
+ continue
527
+ }
528
+ if (ch === '*' || ch === '_' || ch === '>') {
529
+ pushSegment(out, ch, styleOf(syn, 'keyword'))
530
+ i += 1
531
+ continue
532
+ }
533
+ if (ch === '[') {
534
+ const close = line.indexOf(']', i + 1)
535
+ const open = close === -1 ? -1 : line.indexOf('(', close + 1)
536
+ if (close !== -1 && open === close + 1) {
537
+ const end = line.indexOf(')', open + 1)
538
+ if (end !== -1) {
539
+ pushSegment(out, line.slice(i, close + 1), styleOf(syn, 'function'))
540
+ pushSegment(out, line.slice(close + 1, end + 1), styleOf(syn, 'string'))
541
+ i = end + 1
542
+ continue
543
+ }
544
+ }
545
+ }
546
+ let end = i
547
+ while (end < n && !'`*_~[]>#-'.includes(line[end])) end += 1
548
+ if (end === i) end = i + 1
549
+ pushSegment(out, line.slice(i, end))
550
+ i = end
551
+ }
552
+ return out
553
+ }
554
+
555
+ /**
556
+ * Diff: whole-line colouring so additions and removals read at a glance.
557
+ * @returns {Segment[]}
558
+ */
559
+ function diffLine(line, syn) {
560
+ const out = []
561
+ if (RE_HUNK.test(line)) pushSegment(out, line, styleOf(syn, 'meta'))
562
+ else if (line.startsWith('+++')) pushSegment(out, line, styleOf(syn, 'added'))
563
+ else if (line.startsWith('---')) pushSegment(out, line, styleOf(syn, 'removed'))
564
+ else if (line.startsWith('+')) pushSegment(out, line, styleOf(syn, 'added'))
565
+ else if (line.startsWith('-')) pushSegment(out, line, styleOf(syn, 'removed'))
566
+ else if (/^(?:diff |index |new file|deleted file|similarity |rename |old mode|new mode)/.test(line)) {
567
+ pushSegment(out, line, styleOf(syn, 'meta'))
568
+ } else pushSegment(out, line)
569
+ return out
570
+ }
571
+
572
+ /* ------------------------------------------------------------------ *
573
+ * Scanner
574
+ * ------------------------------------------------------------------ */
575
+
576
+ const CONFIGS = {
577
+ js: JS_CONFIG,
578
+ ts: TS_CONFIG,
579
+ json: JSON_CONFIG,
580
+ bash: BASH_CONFIG,
581
+ python: PYTHON_CONFIG,
582
+ diff: makeConfig({ handler: diffLine }),
583
+ css: CSS_CONFIG,
584
+ html: makeConfig({ handler: htmlLine }),
585
+ sql: SQL_CONFIG,
586
+ go: GO_CONFIG,
587
+ rust: RUST_CONFIG,
588
+ yaml: YAML_CONFIG,
589
+ markdown: makeConfig({ handler: markdownLine }),
590
+ text: makeConfig({ plain: true })
591
+ }
592
+
593
+ /**
594
+ * Read one token starting at `index`.
595
+ * @returns {{ end: number, key?: string }}
596
+ */
597
+ function readToken(line, index, cfg, syn, state) {
598
+ const n = line.length
599
+
600
+ // Resume a construct that ran off the end of the previous line.
601
+ if (state.block !== undefined && state.block !== null) {
602
+ const close = state.block
603
+ const idx = line.indexOf(close, index)
604
+ if (idx === -1) return { end: n, key: 'comment' }
605
+ state.block = null
606
+ return { end: idx + close.length, key: 'comment' }
607
+ }
608
+ if (state.triple !== undefined && state.triple !== null) {
609
+ const close = state.triple
610
+ const idx = line.indexOf(close, index)
611
+ if (idx === -1) return { end: n, key: 'string' }
612
+ state.triple = null
613
+ return { end: idx + close.length, key: 'string' }
614
+ }
615
+
616
+ if (cfg.metaAtStart === true && index === 0) {
617
+ if (/^(?:---|\.\.\.)(?:\s|$)/.test(line)) return { end: 3, key: 'meta' }
618
+ }
619
+
620
+ for (const marker of cfg.lineComments) {
621
+ if (line.startsWith(marker, index)) return { end: n, key: 'comment' }
622
+ }
623
+
624
+ for (const [open, close] of cfg.blockComments) {
625
+ if (line.startsWith(open, index)) {
626
+ const idx = line.indexOf(close, index + open.length)
627
+ if (idx === -1) {
628
+ state.block = close
629
+ return { end: n, key: 'comment' }
630
+ }
631
+ return { end: idx + close.length, key: 'comment' }
632
+ }
633
+ }
634
+
635
+ for (const quote of cfg.tripleStrings) {
636
+ if (line.startsWith(quote, index)) {
637
+ const idx = line.indexOf(quote, index + quote.length)
638
+ if (idx === -1) {
639
+ state.triple = quote
640
+ return { end: n, key: 'string' }
641
+ }
642
+ return { end: idx + quote.length, key: 'string' }
643
+ }
644
+ }
645
+
646
+ const ch = line[index]
647
+
648
+ if (cfg.dashFlags === true && ch === '-' && /[-A-Za-z]/.test(line[index + 1] ?? '')) {
649
+ RE_DASH_FLAG.lastIndex = index
650
+ const match = RE_DASH_FLAG.exec(line)
651
+ if (match !== null) return { end: index + match[0].length, key: 'attribute' }
652
+ }
653
+
654
+ if (cfg.dollarVariables === true && ch === '$') {
655
+ if (line[index + 1] === '{') {
656
+ const idx = line.indexOf('}', index + 2)
657
+ return { end: idx === -1 ? n : idx + 1, key: 'variable' }
658
+ }
659
+ const word = readWord(line, index + 1, cfg)
660
+ if (word !== null) return { end: index + 1 + word.length, key: 'variable' }
661
+ if ('0123456789?@*#$!'.includes(line[index + 1] ?? '')) {
662
+ return { end: index + 2, key: 'variable' }
663
+ }
664
+ return { end: index + 1, key: 'operator' }
665
+ }
666
+
667
+ if (
668
+ cfg.anchorChars !== undefined &&
669
+ cfg.anchorChars.includes(ch) &&
670
+ /[A-Za-z0-9_]/.test(line[index + 1] ?? '')
671
+ ) {
672
+ const word = readWord(line, index + 1, cfg)
673
+ return { end: word === null ? index + 1 : index + 1 + word.length, key: 'variable' }
674
+ }
675
+
676
+ if (cfg.decorators === true && ch === '@') {
677
+ const word = readWord(line, index + 1, cfg)
678
+ if (word !== null) return { end: index + 1 + word.length, key: 'meta' }
679
+ return { end: index + 1, key: 'punctuation' }
680
+ }
681
+
682
+ if (cfg.hashColor === true && ch === '#') {
683
+ const match = RE_HEX_COLOR.exec(line.slice(index))
684
+ if (match !== null) return { end: index + match[0].length, key: 'number' }
685
+ }
686
+
687
+ if (cfg.tildeLiteral === true && ch === '~') return { end: index + 1, key: 'keyword' }
688
+
689
+ // Rust: a lifetime looks like a char literal but has no closing quote.
690
+ if (cfg.lifetime === true && ch === "'") {
691
+ const word = readWord(line, index + 1, cfg)
692
+ if (word !== null && line[index + 1 + word.length] !== "'") {
693
+ return { end: index + 1 + word.length, key: 'type' }
694
+ }
695
+ }
696
+
697
+ for (const quote of cfg.templates) {
698
+ if (ch === quote) {
699
+ let end = index + 1
700
+ while (end < n) {
701
+ if (line[end] === '\\') {
702
+ end += 2
703
+ continue
704
+ }
705
+ if (line[end] === quote) {
706
+ end += 1
707
+ break
708
+ }
709
+ end += 1
710
+ }
711
+ return { end: Math.min(end, n), key: 'string' }
712
+ }
713
+ }
714
+
715
+ for (const quote of cfg.strings) {
716
+ if (ch === quote) {
717
+ let end = index + 1
718
+ while (end < n) {
719
+ if (line[end] === '\\') {
720
+ end += 2
721
+ continue
722
+ }
723
+ if (line[end] === quote) {
724
+ end += 1
725
+ break
726
+ }
727
+ end += 1
728
+ }
729
+ const stop = Math.min(end, n)
730
+ if (cfg.jsonKeys === true && /^\s*:/.test(line.slice(stop))) {
731
+ return { end: stop, key: 'property' }
732
+ }
733
+ return { end: stop, key: 'string' }
734
+ }
735
+ }
736
+
737
+ if (isDigit(ch) || (ch === '.' && isDigit(line[index + 1] ?? ''))) {
738
+ const re = cfg.numberUnits === true ? RE_NUMBER_UNITS : RE_NUMBER
739
+ re.lastIndex = index
740
+ const match = re.exec(line)
741
+ if (match !== null && match[0].length > 0) {
742
+ return { end: index + match[0].length, key: 'number' }
743
+ }
744
+ }
745
+
746
+ if (
747
+ isWordStart(ch) ||
748
+ (cfg.dashWord === true && ch === '-' && /[A-Za-z_]/.test(line[index + 1] ?? ''))
749
+ ) {
750
+ const word = readWord(line, index, cfg)
751
+ if (word !== null && word.length > 0) {
752
+ const end = index + word.length
753
+ const after = line[end]
754
+ const before = index > 0 ? line[index - 1] : ''
755
+ const lookup = cfg.caseInsensitive === true ? word.toUpperCase() : word
756
+ let key
757
+ if (cfg.literals.has(lookup)) key = 'keyword'
758
+ else if (cfg.keywords.has(lookup)) key = 'keyword'
759
+ else if (cfg.types.has(lookup)) key = 'type'
760
+ else if (cfg.builtins.has(lookup)) key = 'function'
761
+ else if (cfg.selfWords.has(lookup)) key = 'variable'
762
+ else if (cfg.globals.has(lookup)) key = 'variable'
763
+ else if (after === '(') key = 'function'
764
+ else if (cfg.macros === true && after === '!') key = 'function'
765
+ else if (after === ':' && (cfg.keyColon === true ? isStrictKeyColon(line, end) : cfg.keyColonLoose === true)) {
766
+ key = 'property'
767
+ } else if (before === '.') key = 'property'
768
+ return { end, key }
769
+ }
770
+ }
771
+
772
+ RE_OPERATOR.lastIndex = index
773
+ const operator = RE_OPERATOR.exec(line)
774
+ if (operator !== null && operator[0].length > 0) {
775
+ return { end: index + operator[0].length, key: 'operator' }
776
+ }
777
+
778
+ RE_PUNCT.lastIndex = index
779
+ if (RE_PUNCT.test(line)) return { end: index + 1, key: 'punctuation' }
780
+
781
+ return { end: index + 1 }
782
+ }
783
+
784
+ /**
785
+ * Tokenize one already-sanitized line into contiguous segments.
786
+ * @param {string} line
787
+ * @param {string} lang
788
+ * @param {Record<string, string> | undefined | null} syn
789
+ * @param {{ block?: string | null, triple?: string | null }} state
790
+ * @returns {Segment[]}
791
+ */
792
+ function scan(line, lang, syn, state) {
793
+ const cfg = CONFIGS[lang] ?? CONFIGS.text
794
+ if (cfg.plain === true) return line.length === 0 ? [] : [{ text: line }]
795
+ if (typeof cfg.handler === 'function') return cfg.handler(line, syn, state)
796
+
797
+ /** @type {Segment[]} */
798
+ const out = []
799
+ const n = line.length
800
+ let index = 0
801
+ while (index < n) {
802
+ const token = readToken(line, index, cfg, syn, state)
803
+ let end = token !== null && token !== undefined && Number.isFinite(token.end) ? token.end : index + 1
804
+ if (end <= index) end = index + 1
805
+ if (end > n) end = n
806
+ pushSegment(out, line.slice(index, end), styleOf(syn, token?.key))
807
+ index = end
808
+ }
809
+ return out
810
+ }
811
+
812
+ /* ------------------------------------------------------------------ *
813
+ * Public API
814
+ * ------------------------------------------------------------------ */
815
+
816
+ /** Canonical identifiers this module can highlight, including `text`. */
817
+ export const SUPPORTED_LANGUAGES = Object.freeze([
818
+ 'js',
819
+ 'ts',
820
+ 'json',
821
+ 'bash',
822
+ 'python',
823
+ 'diff',
824
+ 'css',
825
+ 'html',
826
+ 'sql',
827
+ 'go',
828
+ 'rust',
829
+ 'yaml',
830
+ 'markdown',
831
+ 'text'
832
+ ])
833
+
834
+ /** Alias table; the first whitespace-delimited token of a hint is looked up. */
835
+ const ALIASES = new Map(
836
+ Object.entries({
837
+ js: 'js',
838
+ jsx: 'js',
839
+ mjs: 'js',
840
+ cjs: 'js',
841
+ javascript: 'js',
842
+ node: 'js',
843
+ ts: 'ts',
844
+ tsx: 'ts',
845
+ typescript: 'ts',
846
+ sh: 'bash',
847
+ bash: 'bash',
848
+ zsh: 'bash',
849
+ shell: 'bash',
850
+ console: 'bash',
851
+ py: 'python',
852
+ python3: 'python',
853
+ python: 'python',
854
+ yml: 'yaml',
855
+ yaml: 'yaml',
856
+ html: 'html',
857
+ xml: 'html',
858
+ svg: 'html',
859
+ rs: 'rust',
860
+ rust: 'rust',
861
+ golang: 'go',
862
+ go: 'go',
863
+ md: 'markdown',
864
+ markdown: 'markdown',
865
+ patch: 'diff',
866
+ udiff: 'diff',
867
+ diff: 'diff',
868
+ jsonc: 'json',
869
+ json: 'json',
870
+ css: 'css',
871
+ sql: 'sql',
872
+ text: 'text',
873
+ txt: 'text',
874
+ plain: 'text'
875
+ })
876
+ )
877
+
878
+ /**
879
+ * Canonicalize a language hint. Unknown, empty and non-string hints become
880
+ * `'text'`, never an error.
881
+ * @param {unknown} hint
882
+ * @returns {string}
883
+ */
884
+ export function normalizeLanguage(hint) {
885
+ if (typeof hint !== 'string') return 'text'
886
+ const trimmed = hint.trim().toLowerCase()
887
+ if (trimmed.length === 0) return 'text'
888
+ const first = trimmed.split(/\s+/)[0]
889
+ return ALIASES.get(first) ?? 'text'
890
+ }
891
+
892
+ /**
893
+ * Highlight a single line. The result's concatenated text always equals
894
+ * `sanitize(line)`; malformed input is tolerated by construction.
895
+ * @param {string} line
896
+ * @param {string} [lang]
897
+ * @param {Record<string, string>} [syn]
898
+ * @returns {Segment[]}
899
+ */
900
+ export function highlightLine(line, lang, syn) {
901
+ const text = sanitize(line)
902
+ if (text.length === 0) return []
903
+ const id = normalizeLanguage(lang)
904
+ return scan(text, id, syn, { block: null, triple: null })
905
+ }
906
+
907
+ /**
908
+ * Highlight a whole snippet. Multi-line block comments and Python triple-quoted
909
+ * strings carry state from line to line; the returned segments may contain
910
+ * `'\n'` and concatenate back to `sanitize(code)`.
911
+ * @param {string} code
912
+ * @param {string} [lang]
913
+ * @param {Record<string, string>} [syn]
914
+ * @returns {Segment[]}
915
+ */
916
+ export function highlightCode(code, lang, syn) {
917
+ const text = sanitize(code)
918
+ if (text.length === 0) return []
919
+ const id = normalizeLanguage(lang)
920
+ if (id === 'text') return [{ text }]
921
+ const state = { block: null, triple: null }
922
+ /** @type {Segment[]} */
923
+ const out = []
924
+ const lines = text.split('\n')
925
+ for (let i = 0; i < lines.length; i += 1) {
926
+ if (i > 0) pushSegment(out, '\n')
927
+ for (const segment of scan(lines[i], id, syn, state)) {
928
+ pushSegment(out, segment.text, segment.sgr)
929
+ }
930
+ }
931
+ return out
932
+ }