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:
Brooklyn Nicholson
2026-04-18 09:48:38 -05:00
parent 5e148ca3d0
commit f8becbfbea
3 changed files with 180 additions and 0 deletions

117
ui-tui/src/lib/syntax.ts Normal file
View 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
}