feat(ui): a language is a table row, not a switch statement #68
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/highlight-query-driven"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Syntax highlighting had two languages — yaml and env — because adding one meant
writing another
classify()switch by hand, mapping that grammar's node typesto colours. The grammar authors already ship that mapping as
queries/highlights.scm, sohighlight.tsis now a table and nothing else:Twelve languages where there were two: yaml, toml, json, ini, env, bash,
python, javascript, typescript, css, go, rust.
Where the pieces come from
WASM from
@vscode/tree-sitter-wasm, already a dependency, which shipsgrammars and nothing else. Two small packages join it for languages it lacks:
tree-sitter-json(592 KB) and@tree-sitter-grammars/tree-sitter-toml(852 KB).
Queries are vendored into
packages/ui/src/queries/*.tsbydeno task queries(tools/refresh-queries.ts), pinned by the version in thattool's table, MIT throughout, provenance in each generated header. Not
imported, because a grammar's own package is mostly generated C —
tree-sitter-typescriptis 38 MB for 8 KB of query text — anddocker/Dockerfilecopies the build stage's/app,node_modulesand all,into the runtime image. Roughly 100 MB of parser sources would have ridden
along for nothing.
typescriptconcatenates JavaScript's query with TypeScript's, becauseupstream's TypeScript query is only the delta over it.
What stays ours
highlight-spans.ts, which is the half a query cannot decide:string.special.pathlands on
stringand an unmapped name is left unpainted.variableisdeliberately unmapped: a JS query captures nearly every identifier, and
colouring them all is confetti.
captures of one range keep the later pattern — the precedence convention the
query files are written against (general first, specific after).
is what keeps
{@html}safe over a file nobody vetted. Classes come from aclosed map, never from the query text.
It is pure, so it is tested.
highlight.tscannot be — its imports are Viteinstructions — which is why the split exists at all.
Tests
highlight_spans_test.ts(10) covers precedence, nesting, crossing spans,out-of-range spans, and the invariant that highlighting never changes the
text it highlights.
highlight_grammars_test.ts(14) is the tripwire: it parses theGRAMMARStable out of
highlight.tsrather than restating it, then loads every grammar,compiles every query against the WASM actually shipped, and asserts a sample of
each language comes back painted. A grammar bump that renames a node type fails
the suite instead of quietly turning one language back into plain text. It
caught its own regression during development, when the table's declaration line
changed shape.
Also
CodeBlocktakeslang: nullfor plain text, and the schema'scodeListresolves through
isHighlightLang— it used to render anything that was not"env"as YAML, which with twelve grammars would be actively wrong rather thanmerely unhelpful.
Verification
deno task check,check:svelte:shell(0 errors),check:svelte:modules(0 errors),
deno lintclean,deno task buildgreen,deno task testforpackages/ui,packages/sdk,packages/shellandpackages/servergreen.Built output: 12 grammar WASM under
/shared/assets/(fetched only when a fileof that language is opened) and one lazy query chunk per language under
/shared/chunks/. Served locally with the right content types(
application/wasm,text/javascript).Rendered by hand against a running server: YAML keys keep the colour they had
(no regression on compose files), TypeScript gets keywords, types, template
holes and comments, and a systemd unit gets section names, directive names and
values.
Downstream
module_filebrowserusesisHighlightLangfor its file preview. That modulemust not be re-pinned on a deployment until this is merged and the image is
built — a missing export fails the whole module graph, which is a blank page
rather than a degraded one.
🤖 Generated with Claude Code
The kit classified tree-sitter node types by hand, which is why it had two languages: each new one meant another switch nobody but its author could review. A grammar already ships that mapping — `highlights.scm`, written by the people who wrote the grammar — so highlight.ts is now a table of (WASM, query), and twelve languages fit where two did. The queries are vendored by tools/refresh-queries.ts rather than imported. A grammar's npm package is mostly generated C — tree-sitter-typescript is 38 MB for 8 KB of query text — and docker/Dockerfile copies the build stage's /app, node_modules included, into the runtime image. The WASM does come from packages, because @vscode/tree-sitter-wasm ships grammars and nothing else; json and toml are the two small additions for languages it lacks. What a capture NAME means to this design system stays ours, in highlight-spans.ts: prefix-matched to a token class, unmapped names left unpainted (a query captures nearly every identifier, and colouring them all is confetti), captures nested so the innermost wins, and output built only from escaped slices plus class-carrying spans — which is what keeps {@html} safe over a file nobody vetted. That file is pure, so it is tested; highlight.ts cannot be, its imports being bundler instructions. highlight_grammars_test.ts closes the gap by parsing the table out of the source and compiling every query against the grammar actually shipped, which is the only thing that catches a WASM bump renaming a node type. CodeBlock takes lang: null for plain text, and the schema's codeList now recognises every language the kit ships instead of rendering anything that was not "env" as YAML.Ran both new test files on the branch (24/24 green) and probed
query.captures()ordering on PythonFoo()/MAX: captures arrive in ascending pattern index, so "later pattern wins" inrenderSpansis correct. The highlighting itself looks right. One real problem, and it undoes the PR's own stated reason for vendoring.Bug —
tools/refresh-queries.ts,ensureCached:deno info npm:<spec>writes every query-source package intodeno.lock.deno install --frozen(docker/Dockerfile L19) then installs all of them intonode_modules, andCOPY --from=build /appships them in the runtime image. Measured on a fresh checkout of this branch:Fix:
deno info --no-lock npm:<spec>inensureCached, then scrubdeno.lockof thetree-sitter-{bash,css,go,javascript,python,rust,typescript}andtree-sitter@0.21.1entries (json/yaml/toml stay — those are real deps), and verifyls node_modules/.denoafterdeno install --frozenshows only the WASM packages.Risk —
deno.lock: stray@tree-sitter-grammars/tree-sitter-markdown@0.3.2andtree-sitter-javascript@0.23.1, neither in theLANGStable. Same scrub.Nit —
tools/refresh-queries.tsL134: thedeno fmtresult is unchecked; a failed fmt silently leaves CI-failing files. Check.successand throw.Nit —
tools/refresh-queries.tsL114: cache path hardcodesregistry.npmjs.org; breaks underNPM_CONFIG_REGISTRY. Fine for a dev tool, worth a comment.Everything else LGTM:
renderSpansnesting and escaping are sound,Object.hasOwnguard is right, the cached rejection inload()is a good call, and the grammar tripwire test does what it claims.Thanks. I checked each point before changing anything. All four hold, and they're fixed in
555f3ee.Bug:
ensureCachedwriting to the lock. Confirmed. On a freshgit archive HEADof4823c80,deno install --frozengave a 348Mnode_modules, withtree-sitter-typescript@0.23.2at 38M,tree-sitter-bashat 20M,tree-sitter-rustat 15M, and so on.ensureCachednow callsdeno info --no-lock(the flag exists in 2.9.x). I re-randeno task queriesafterwards:deno.lockcame out byte-identical, and so did the generated queries.Risk: stray lock entries. Scrubbed. One correction to the suggested fix: deleting
tree-sitter@0.21.1on its own leaves the lock corrupt (Could not find '@tree-sitter-grammars/tree-sitter-toml@0.7.0' in the list of packages). json, yaml and toml were resolved with it as their optional peer (0.7.0_tree-sitter@0.21.1). So I re-keyed those three without the peer suffix, which is howorigin/mainalready had yaml. The yaml block is now identical to main's. I edited the text directly so the rest of the lock keeps its formatting and the diff shows only tree-sitter lines (+8/−138).I checked the result on a fresh copy:
deno install --frozenpasses, andnode_modules/.denocontains onlytree-sitter-{json,toml,yaml},@vscode/tree-sitter-wasmandweb-tree-sitterfrom the tree-sitter family.node_moduleswent from 348M to 242M.deno installwithout--frozenleaves the lock byte-identical, so it's in canonical form.deno task checkpasses.Nit: unchecked
deno fmt. Now checks.successand throws with fmt's stderr.Nit: hardcoded registry. Added a one-line comment. I didn't change the code, since this is a dev tool.
Local results:
deno fmt --checkanddeno lintclean,deno task checkclean,deno task test packages/ tools/570 passed / 0 failed / 1 ignored.Re-reviewed at
555f3ee. All four items from the previous review are resolved, and I checked them by running the code, not just reading it:--no-lockinensureCached: randeno run -A tools/refresh-queries.tsin a clean worktree. It exits 0 and leavesgit statusempty, sodeno.lockis untouched and the vendored queries regenerate byte-identical.deno.lockare the real deps (toml, yaml, json,@vscode/tree-sitter-wasm,web-tree-sitter). The stray markdown and javascript@0.23.1 entries are gone. Afterdeno install --frozen,node_modules/.denocontains only those packages and none of the ~107M of parser sources.I also swept the full diff again.
{@html}output is built only from escaped slices plus classes from the closedCLASSESmap. The CodeBlock callers (CodeListBlock throughisHighlightLang, and the docker StackDetailPage with hardcoded langs) are compatible withlang: null.LGTM. Reminder from the PR body: re-pin
module_filebrowseronly after this image is built.