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:
@@ -658,6 +658,29 @@ describe('GET /api/source', () => {
|
||||
expect(body.lines).toHaveLength(node.node.lines);
|
||||
});
|
||||
|
||||
it('carries the classified source beside the lines, one entry per line', async () => {
|
||||
const body = await getJson('/api/source?file=src/cache.ts&from=1&to=3');
|
||||
// Highlighting rides with the slice rather than behind its own endpoint:
|
||||
// the two are only ever wanted together, and a second round-trip would let
|
||||
// the code block paint unhighlighted source and then reflow it.
|
||||
expect(body.highlight).toBeTruthy();
|
||||
expect(body.highlight.classes).toEqual([
|
||||
'other',
|
||||
'ident',
|
||||
'comment',
|
||||
'string',
|
||||
'keyword',
|
||||
'number',
|
||||
]);
|
||||
expect(body.highlight.lines).toHaveLength(body.lines.length);
|
||||
// Every line's tokens reproduce that line exactly — the code block renders
|
||||
// these, not the raw string.
|
||||
for (let i = 0; i < body.lines.length; i++) {
|
||||
const rebuilt = body.highlight.lines[i].map(([, text]: [number, string]) => text).join('');
|
||||
expect(rebuilt).toBe(body.lines[i]);
|
||||
}
|
||||
});
|
||||
|
||||
it('refuses to slice a file that changed on disk after the last sync', async () => {
|
||||
const target = path.join(projectRoot, 'src', 'handler.ts');
|
||||
const original = fs.readFileSync(target);
|
||||
@@ -668,6 +691,9 @@ describe('GET /api/source', () => {
|
||||
expect(body.drift).toBe(true);
|
||||
// The whole point: no slice, rather than a slice of the wrong lines.
|
||||
expect(body.lines).toBeUndefined();
|
||||
// And nothing to render it with either — a highlight with no source is
|
||||
// just a second way to draw the wrong lines.
|
||||
expect(body.highlight).toBeUndefined();
|
||||
expect(body.reason).toContain('changed on disk after the last index sync');
|
||||
|
||||
// And every screen that renders indexed line ranges is told.
|
||||
|
||||
Reference in New Issue
Block a user