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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user