feat(ui): highlight source server-side with a near-monochrome Shiki theme (CG-43)

The viewer's code block stops lexing with a hand-rolled dialect table and
reads real TextMate grammars instead, run once in `/api/source`.

Three things make that safe to depend on:

* Highlighting never fails a request. A missing grammar, an oversized
  slice, an ESM import that did not resolve — every one of them answers
  `engine: 'plain'` with a reason and the source still goes out.
* Identifiers survive whatever token boundaries a grammar chose. Every
  code token is split into identifier runs before it goes on the wire, so
  the graph's call-site overlay claims a token the highlighter produced
  rather than re-cutting the line. `assignRefs` now matches on a token's
  text rather than on the class a grammar gave it, so a language that
  scopes type names as `storage.type` still links.
* The theme classifies rather than colours: its foregrounds are sentinels
  the server maps back to class names, and the viewer paints them from
  CSS custom properties — one token stream serves light and dark with no
  refetch, and the ramp lives only in app.css.

Comments move from --ink-3 to a new --code-comment. --ink-3 measures
3.46:1 on paper and 3.00:1 on the hot-line tint, both under AA for 12.5px
text; --code-comment is the smallest step along the same ramp that clears
4.5:1 on every background a code line can have, and stays quieter than
the strings and numbers above it.

Shipping: @shikijs/core and @shikijs/engine-javascript are runtime
dependencies (no wasm, no native module); @shikijs/langs stays a
devDependency and `npm run build:textmate` writes only the closure the
engine's 40-odd languages reach — 56 grammars, 2.6 MB, against 11 MB for
all 722. check-ui-build.mjs asserts the tree after every build and inside
every release archive.
This commit is contained in:
Colby McHenry
2026-08-27 01:23:10 -05:00
parent 87afc50e76
commit 2ad836d935
22 changed files with 2117 additions and 405 deletions
+4 -2
View File
@@ -87,7 +87,9 @@ const API_INDEX = {
export function createGraphApi(options: GraphApiOptions): GraphApi {
const session = new GraphSession(options.projectRoot);
const handler: UiApiHandler = (_req, res, ctx) => {
// Async because `/api/source` highlights: everything else answers straight
// out of SQLite and resolves on the same tick.
const handler: UiApiHandler = async (_req, res, ctx) => {
const route = normalize(ctx.pathname);
try {
switch (route) {
@@ -104,7 +106,7 @@ export function createGraphApi(options: GraphApiOptions): GraphApi {
case '/api/nodes':
return ok(res, buildNodeRefs(session.acquire(), ctx.query), ctx.method);
case '/api/source':
return ok(res, buildSource(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
return ok(res, await buildSource(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
default:
return dispatchPathRoutes(route, res, ctx, session);
}
+22 -3
View File
@@ -30,6 +30,7 @@ import * as path from 'path';
import type { FileRecord } from '../../types';
import type { CodeGraph } from '../../index';
import { resolveProjectFile } from '../security';
import { highlightLines, type HighlightResult } from '../highlight';
import { ApiError, badRequest, intParam, notFound, textParam } from './respond';
/**
@@ -186,13 +187,23 @@ export interface SourceResult {
lines?: string[];
truncated?: boolean;
reason?: string;
/**
* The same lines, classified for the code block — one entry per line, each a
* list of `[classId, text]` pairs indexed into `highlight.classes`.
*
* It rides with the slice rather than living behind its own endpoint because
* the two are only ever wanted together, and because a second round-trip
* would let the viewer paint unhighlighted source and then reflow it. Absent
* whenever `lines` is — a drifted file is not served at all.
*/
highlight?: HighlightResult;
}
export function buildSource(
export async function buildSource(
cg: CodeGraph,
projectRoot: string,
query: URLSearchParams
): SourceResult {
): Promise<SourceResult> {
const requested = textParam(query, 'file');
// Refusal first, index lookup second — see `resolveRequestedFile`.
const { record, storedPath, absolute } = resolveRequestedFile(cg, projectRoot, requested);
@@ -265,13 +276,21 @@ export function buildSource(
const start = from;
const requestedEnd = to === 0 ? all.length : Math.min(to, all.length);
const end = Math.min(requestedEnd, start + MAX_SOURCE_LINES - 1);
const slice = all.slice(start - 1, end);
return {
...base,
totalLines: all.length,
from: start,
to: end,
lines: all.slice(start - 1, end),
lines: slice,
truncated: end < requestedEnd,
// Keyed on the content hash, so the cache is invalidated by the file
// changing rather than by a clock, and two viewers looking at the same
// symbol share one tokenisation.
highlight: await highlightLines(slice, {
language: record.language,
cacheKey: `${record.contentHash}:${start}:${end}`,
}),
};
}