feat(tui): per-language syntax highlighting in markdown code fences
Adds a minimal hand-rolled highlighter for ts/js/jsx/tsx, py, sh/bash, go, rust, json, yaml, sql. Recognizes whole-line comments, single/double/backtick strings, numbers, and per-language keyword sets. Unknown langs fall through to the current plain rendering; the existing diff-specific colorization is preserved. Closes the §8 "Markdown syntax highlighting is missing (only diff gets colored)" finding from the TUI v2 audit without pulling in a highlighter library.
This commit is contained in:
45
ui-tui/src/__tests__/syntax.test.ts
Normal file
45
ui-tui/src/__tests__/syntax.test.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { highlightLine, isHighlightable } from '../lib/syntax.js'
|
||||
import { DEFAULT_THEME } from '../theme.js'
|
||||
|
||||
const t = DEFAULT_THEME
|
||||
|
||||
describe('syntax highlighter', () => {
|
||||
it('recognizes supported langs and aliases', () => {
|
||||
expect(isHighlightable('ts')).toBe(true)
|
||||
expect(isHighlightable('js')).toBe(true)
|
||||
expect(isHighlightable('python')).toBe(true)
|
||||
expect(isHighlightable('rs')).toBe(true)
|
||||
expect(isHighlightable('bash')).toBe(true)
|
||||
expect(isHighlightable('whatever')).toBe(false)
|
||||
expect(isHighlightable('')).toBe(false)
|
||||
})
|
||||
|
||||
it('paints a whole-line comment dim', () => {
|
||||
const tokens = highlightLine('// hello', 'ts', t)
|
||||
|
||||
expect(tokens).toEqual([[t.color.dim, '// hello']])
|
||||
})
|
||||
|
||||
it('paints keywords, strings, and numbers in a ts line', () => {
|
||||
const tokens = highlightLine(`const x = 'hi' + 42`, 'ts', t)
|
||||
const colors = tokens.map(tok => tok[0])
|
||||
|
||||
expect(colors).toContain(t.color.bronze) // const
|
||||
expect(colors).toContain(t.color.amber) // 'hi'
|
||||
expect(colors).toContain(t.color.cornsilk) // 42
|
||||
})
|
||||
|
||||
it('falls through unchanged for unknown langs', () => {
|
||||
const tokens = highlightLine(`const x = 1`, 'zzz', t)
|
||||
|
||||
expect(tokens).toEqual([['', 'const x = 1']])
|
||||
})
|
||||
|
||||
it('treats `#` as a python comment, not a selector', () => {
|
||||
const tokens = highlightLine('# comment', 'py', t)
|
||||
|
||||
expect(tokens).toEqual([[t.color.dim, '# comment']])
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,7 @@
|
||||
import { Box, Text } from '@hermes/ink'
|
||||
import { memo, type ReactNode, useMemo } from 'react'
|
||||
|
||||
import { highlightLine, isHighlightable } from '../lib/syntax.js'
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
const FENCE_RE = /^\s*(`{3,}|~{3,})(.*)$/
|
||||
@ -282,11 +283,28 @@ function MdImpl({ compact, t, text }: MdProps) {
|
||||
start('code')
|
||||
|
||||
const isDiff = lang === 'diff'
|
||||
const highlighted = !isDiff && isHighlightable(lang)
|
||||
|
||||
nodes.push(
|
||||
<Box flexDirection="column" key={key} paddingLeft={2}>
|
||||
{lang && !isDiff && <Text color={t.color.dim}>{'─ ' + lang}</Text>}
|
||||
{block.map((l, j) => {
|
||||
if (highlighted) {
|
||||
return (
|
||||
<Text key={j}>
|
||||
{highlightLine(l, lang, t).map(([color, text], k) =>
|
||||
color ? (
|
||||
<Text color={color} key={k}>
|
||||
{text}
|
||||
</Text>
|
||||
) : (
|
||||
<Text key={k}>{text}</Text>
|
||||
)
|
||||
)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
const add = isDiff && l.startsWith('+')
|
||||
const del = isDiff && l.startsWith('-')
|
||||
const hunk = isDiff && l.startsWith('@@')
|
||||
|
||||
117
ui-tui/src/lib/syntax.ts
Normal file
117
ui-tui/src/lib/syntax.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import type { Theme } from '../theme.js'
|
||||
|
||||
export type Token = [string, string]
|
||||
|
||||
interface LangSpec {
|
||||
comment: null | string
|
||||
keywords: Set<string>
|
||||
}
|
||||
|
||||
const KW = (s: string) => new Set(s.split(/\s+/).filter(Boolean))
|
||||
|
||||
const TS = KW(`
|
||||
abstract as async await break case catch class const continue debugger default delete do else enum export extends
|
||||
false finally for from function get if implements import in instanceof interface is let new null of package private
|
||||
protected public readonly return set static super switch this throw true try type typeof undefined var void while
|
||||
with yield
|
||||
`)
|
||||
|
||||
const PY = KW(`
|
||||
False None True and as assert async await break class continue def del elif else except finally for from global if
|
||||
import in is lambda nonlocal not or pass raise return try while with yield
|
||||
`)
|
||||
|
||||
const SH = KW(`
|
||||
if then else elif fi for in do done while until case esac function return break continue local export readonly
|
||||
declare typeset
|
||||
`)
|
||||
|
||||
const GO = KW(`
|
||||
break case chan const continue default defer else fallthrough for func go goto if import interface map package range
|
||||
return select struct switch type var nil true false
|
||||
`)
|
||||
|
||||
const RUST = KW(`
|
||||
as async await break const continue crate dyn else enum extern false fn for if impl in let loop match mod move mut
|
||||
pub ref return self Self static struct super trait true type unsafe use where while yield
|
||||
`)
|
||||
|
||||
const SQL = KW(`
|
||||
select from where and or not in is null as by group order limit offset insert into values update set delete create
|
||||
table drop alter add column primary key foreign references join left right inner outer on
|
||||
`)
|
||||
|
||||
const LANGS: Record<string, LangSpec> = {
|
||||
go: { comment: '//', keywords: GO },
|
||||
json: { comment: null, keywords: KW('true false null') },
|
||||
py: { comment: '#', keywords: PY },
|
||||
rust: { comment: '//', keywords: RUST },
|
||||
sh: { comment: '#', keywords: SH },
|
||||
sql: { comment: '--', keywords: SQL },
|
||||
ts: { comment: '//', keywords: TS },
|
||||
yaml: { comment: '#', keywords: KW('true false null yes no on off') }
|
||||
}
|
||||
|
||||
const ALIAS: Record<string, string> = {
|
||||
bash: 'sh',
|
||||
javascript: 'ts',
|
||||
js: 'ts',
|
||||
jsx: 'ts',
|
||||
python: 'py',
|
||||
rs: 'rust',
|
||||
shell: 'sh',
|
||||
tsx: 'ts',
|
||||
typescript: 'ts',
|
||||
yml: 'yaml',
|
||||
zsh: 'sh'
|
||||
}
|
||||
|
||||
const resolve = (lang: string): LangSpec | null => LANGS[ALIAS[lang] ?? lang] ?? null
|
||||
|
||||
export const isHighlightable = (lang: string): boolean => resolve(lang) !== null
|
||||
|
||||
const TOKEN_RE = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`|\b\d+(?:\.\d+)?\b|[A-Za-z_$][\w$]*/g
|
||||
|
||||
export function highlightLine(line: string, lang: string, t: Theme): Token[] {
|
||||
const spec = resolve(lang)
|
||||
|
||||
if (!spec) {
|
||||
return [['', line]]
|
||||
}
|
||||
|
||||
if (spec.comment && line.trimStart().startsWith(spec.comment)) {
|
||||
return [[t.color.dim, line]]
|
||||
}
|
||||
|
||||
const tokens: Token[] = []
|
||||
let last = 0
|
||||
|
||||
for (const m of line.matchAll(TOKEN_RE)) {
|
||||
const start = m.index ?? 0
|
||||
|
||||
if (start > last) {
|
||||
tokens.push(['', line.slice(last, start)])
|
||||
}
|
||||
|
||||
const tok = m[0]
|
||||
const ch = tok[0]!
|
||||
|
||||
if (ch === '"' || ch === "'" || ch === '`') {
|
||||
tokens.push([t.color.amber, tok])
|
||||
} else if (ch >= '0' && ch <= '9') {
|
||||
tokens.push([t.color.cornsilk, tok])
|
||||
} else if (spec.keywords.has(tok)) {
|
||||
tokens.push([t.color.bronze, tok])
|
||||
} else {
|
||||
tokens.push(['', tok])
|
||||
}
|
||||
|
||||
last = start + tok.length
|
||||
}
|
||||
|
||||
if (last < line.length) {
|
||||
tokens.push(['', line.slice(last)])
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
Reference in New Issue
Block a user