feat(source): walk the dom back to source locations

The hinge the whole editor turns on: a picked element is useless to an agent
without the file and line that produced it. Splitting browser and server entry
points keeps the DOM walk out of the node bundle and the filesystem work out of
the browser one.
This commit is contained in:
Nayan
2026-07-27 15:06:00 +05:30
parent b94775e2d5
commit 164eca99d3
7 changed files with 1342 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
{
"name": "@airship/source",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./browser": {
"types": "./dist/browser.d.ts",
"import": "./dist/browser.js"
},
"./server": {
"types": "./dist/server.d.ts",
"import": "./dist/server.js"
},
"./tokens": {
"types": "./dist/tokens.d.ts",
"import": "./dist/tokens.js"
}
},
"scripts": {
"build": "tsup src/browser.ts src/server.ts src/tokens.ts --format esm --dts --sourcemap --clean",
"clean": "rm -rf dist .turbo",
"dev": "tsup src/browser.ts src/server.ts src/tokens.ts --format esm --dts --sourcemap --watch",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@airship/protocol": "workspace:*",
"@jridgewell/trace-mapping": "^0.3.31",
"bippy": "^0.5.32",
"element-source": "^0.0.5"
},
"devDependencies": {
"vitest": "^4.1.10"
}
}
+256
View File
@@ -0,0 +1,256 @@
/**
* @airship/source/browser — wraps `element-source` to map a picked DOM node back to
* its source file/line and component name across React, Vue, Svelte, Solid and
* Preact. Bundled into the overlay IIFE.
*/
import type { ElementContext, SourceLocation } from "@airship/protocol";
import { originalPositionFor, TraceMap } from "@jridgewell/trace-mapping";
import { getFiberFromHostInstance } from "bippy";
import {
formatOwnerStack,
isSourceFile,
normalizeFileName,
parseStack,
} from "bippy/source";
import {
createSourceResolver,
type ElementInfo,
getTagName,
preactResolver,
reactResolver,
solidResolver,
svelteResolver,
vueResolver,
} from "element-source";
type Resolver = ReturnType<typeof createSourceResolver>;
let resolver: Resolver | null = null;
export function initSourceResolver(): Resolver {
if (!resolver) {
resolver = createSourceResolver({
resolvers: [
reactResolver,
vueResolver,
svelteResolver,
solidResolver,
preactResolver,
],
});
}
return resolver;
}
function toSourceLocation(
info: {
filePath: string;
lineNumber: number | null;
columnNumber: number | null;
} | null
): SourceLocation | null {
if (info?.filePath && info.lineNumber !== null) {
return {
column: info.columnNumber ?? undefined,
file: info.filePath,
line: info.lineNumber,
};
}
return null;
}
export async function extractSource(
el: Element
): Promise<SourceLocation | null> {
return (
(await elementSource(el)) ??
toSourceLocation(await initSourceResolver().resolveSource(el))
);
}
export async function extractElementInfo(el: Element): Promise<{
context: ElementContext;
source: SourceLocation | null;
}> {
const info: ElementInfo = await initSourceResolver().resolveElementInfo(el);
const html = el as HTMLElement;
const context: ElementContext = {
classes: Array.from(el.classList ?? []),
displayName: info.componentName ?? null,
selector: buildSelector(el),
tagName: getTagName(el) || el.tagName.toLowerCase(),
textPreview: (html.textContent ?? "")
.replace(/\s+/g, " ")
.trim()
.slice(0, 80),
};
return {
context,
source: (await elementSource(el)) ?? toSourceLocation(info.source),
};
}
// -- Per-element source, from React 19's owner stack --------------------------
/**
* `element-source` resolves the location of the *component*, not of the element
* you picked. Its `getOwnerStack` builds every frame from bippy's *fallback*
* stack, which describes a fiber by invoking its type with a nulled dispatcher
* and reading where it throws — so a function component resolves to its first
* hook call, and the whole tree App renders reports one line: App's `useState`.
* That is the "always App.tsx:32" you get on a single-component page.
*
* React 19 does record the real per-element JSX call site, in
* `fiber._debugStack` (which is why `hook.ts` installs bippy's hook before the
* app renders). `_debugSource` — the old per-element `__source` prop — is gone
* in 19, so the owner stack is the only per-element signal left. This reads it,
* and maps the frame back through the module's sourcemap because those
* coordinates belong to the *served* module: a dev-server transform prepends an
* HMR preamble and rewrites JSX, so raw frame lines are off by tens of lines
* and name a URL rather than a file the agent can edit.
*/
async function elementSource(el: Element): Promise<SourceLocation | null> {
const stack = ownerStackOf(el);
if (!stack) {
return null;
}
// Pick the frame first, then map it. The loop only ever resolved one frame —
// it returned on the first match — so the await belongs outside it.
const frame = parseStack(formatOwnerStack(stack)).find(
(f) => f.fileName && isSourceFile(f.fileName) && f.lineNumber !== null
);
if (!frame?.fileName || frame.lineNumber === null) {
return null;
}
return (
(await mapThroughSourceMap(frame.fileName, frame)) ?? {
column: frame.columnNumber ?? undefined,
file: normalizeFileName(frame.fileName),
line: frame.lineNumber,
}
);
}
/** React attaches its debug stack to the fiber, not to the DOM node. */
interface DebugFiber {
_debugStack?: { stack?: unknown };
}
/**
* The picked node's own owner stack, or the nearest ancestor's.
*
* Walking up matters for text-ish picks and for nodes a library rendered
* outside React's knowledge; a location one element up is worth far more than
* none. It stops at the first fiber it finds — that node is the one React
* actually rendered.
*/
function ownerStackOf(el: Element): string | null {
let node: Element | null = el;
while (node) {
const fiber = getFiberFromHostInstance(node) as DebugFiber | null;
if (fiber) {
const stack = fiber._debugStack?.stack;
return typeof stack === "string" ? stack : null;
}
node = node.parentElement;
}
return null;
}
/** Parsed sourcemaps by module URL. Vite gives every edit a fresh `?t=`, so
* this never serves a stale map across HMR. `null` caches "there isn't one". */
const sourceMaps = new Map<string, Promise<TraceMap | null>>();
async function mapThroughSourceMap(
url: string,
frame: { columnNumber?: number | null; lineNumber?: number | null }
): Promise<SourceLocation | null> {
if (typeof frame.lineNumber !== "number") {
return null;
}
const map = await sourceMapFor(url);
if (!map) {
return null;
}
// Stack frames are 1-based in both axes; sourcemaps are 1-based in lines and
// 0-based in columns.
const pos = originalPositionFor(map, {
column: Math.max((frame.columnNumber ?? 1) - 1, 0),
line: frame.lineNumber,
});
if (!pos.source || pos.line === null) {
return null;
}
return {
column: pos.column === null ? undefined : pos.column + 1,
file: normalizeFileName(pos.source),
line: pos.line,
};
}
function sourceMapFor(url: string): Promise<TraceMap | null> {
const hit = sourceMaps.get(url);
if (hit) {
return hit;
}
const pending = loadSourceMap(url).catch(() => null);
sourceMaps.set(url, pending);
return pending;
}
const SOURCE_MAPPING_URL = /\/\/# sourceMappingURL=(\S+)[\s]*$/;
async function loadSourceMap(url: string): Promise<TraceMap | null> {
const res = await fetch(url);
if (!res.ok) {
return null;
}
const ref = SOURCE_MAPPING_URL.exec(await res.text())?.[1];
if (!ref) {
return null;
}
// Vite inlines the map as a base64 data URL — which is exactly the case
// bippy's own `symbolicateStack` declines to fetch, and the reason we do this
// rather than call it.
const inline = ref.startsWith("data:");
const mapUrl = inline ? url : new URL(ref, url).href;
const json = inline ? decodeDataUrl(ref) : await (await fetch(mapUrl)).text();
// The map URL is what `sources` are relative to, and they usually are: Vite
// emits `["App.tsx"]` for `/src/App.tsx`. Passing it is what makes
// `originalPositionFor` hand back a whole path instead of a bare filename.
return json ? new TraceMap(JSON.parse(json), mapUrl) : null;
}
function decodeDataUrl(url: string): string | null {
const comma = url.indexOf(",");
if (comma === -1) {
return null;
}
const body = url.slice(comma + 1);
return url.slice(0, comma).includes(";base64")
? atob(body)
: decodeURIComponent(body);
}
function buildSelector(el: Element): string {
const parts: string[] = [];
let node: Element | null = el;
let depth = 0;
while (node && depth < 4 && node.nodeType === 1) {
let part = node.tagName.toLowerCase();
if (node.id) {
part += `#${node.id}`;
parts.unshift(part);
break;
}
const cls = Array.from(node.classList ?? []).slice(0, 2);
if (cls.length) {
part += `.${cls.join(".")}`;
}
parts.unshift(part);
node = node.parentElement;
depth += 1;
}
return parts.join(" > ");
}
+184
View File
@@ -0,0 +1,184 @@
/**
* @airship/source/server — server-side source resolution. When the browser already
* resolved a file/line (via element-source), we just attach surrounding code
* context. Otherwise we fall back to a scored text search across project source
* files (ported/cleaned from layrr's source-mapper).
*/
import { existsSync, readFileSync } from "node:fs";
import { relative, resolve } from "node:path";
import type { ElementContext, SourceLocation } from "@airship/protocol";
import { readCapped, walkFiles } from "./walk";
export interface ResolveInput {
element?: ElementContext;
source?: SourceLocation | null;
}
const SOURCE_EXT = new Set([
".tsx",
".jsx",
".ts",
".js",
".vue",
".svelte",
".astro",
".html",
]);
const CONTEXT_BEFORE = 5;
const CONTEXT_AFTER = 6;
// Stop early once a candidate is strong enough (exact text + good file kind).
const GOOD_ENOUGH_SCORE = 13;
const LEADING_SLASHES = /^\/+/;
/** Route-ish and component-ish directories, on either path separator. */
const ROUTE_DIR = /[\\/](pages|app|routes)[\\/]/;
const COMPONENT_DIR = /[\\/]components?[\\/]/;
export function resolveServerSource(
cwd: string,
input: ResolveInput
): SourceLocation | null {
if (input.source?.file && typeof input.source.line === "number") {
const abs = resolveExistingSource(cwd, input.source.file);
// Normalize to a project-relative path so the agent gets a path it can
// open; fall back to the reported path if we can't locate the file.
const file = abs ? relative(cwd, abs) : input.source.file;
const context = abs ? readContext(abs, input.source.line) : undefined;
return { ...input.source, context: context ?? input.source.context, file };
}
if (input.element) {
return searchByElement(cwd, input.element);
}
return null;
}
/**
* Dev servers report root-relative URLs like `/src/App.tsx`; `resolve(cwd, …)`
* would treat the leading slash as absolute and miss the file. Try the reported
* path first, then a cwd-relative form. Returns the absolute path that exists,
* or null.
*/
function resolveExistingSource(cwd: string, file: string): string | null {
const direct = resolve(cwd, file);
if (existsSync(direct)) {
return direct;
}
if (file.startsWith("/")) {
const stripped = resolve(cwd, file.replace(LEADING_SLASHES, ""));
if (existsSync(stripped)) {
return stripped;
}
}
return null;
}
function readContext(absPath: string, line: number): string | undefined {
if (!existsSync(absPath)) {
return;
}
try {
const lines = readFileSync(absPath, "utf8").split("\n");
const start = Math.max(0, line - 1 - CONTEXT_BEFORE);
const end = Math.min(lines.length, line + CONTEXT_AFTER);
return lines.slice(start, end).join("\n");
} catch {
// Unreadable file — context is a nicety, so drop it rather than fail.
}
}
/**
* Heuristic search: prefer files whose content contains the element's text and
* classes, lightly weighting by file kind (pages/components over generic utils).
*/
/** What a candidate line is worth, before the file-kind bonus. */
interface Needles {
classes: string[];
tagOpen: string;
text: string;
}
function scoreLine(line: string, needles: Needles): number {
let score = 0;
if (needles.text && line.includes(needles.text)) {
score += 10;
}
for (const cls of needles.classes) {
if (line.includes(cls)) {
score += 3;
}
}
if (line.includes(needles.tagOpen)) {
score += 1;
}
return score;
}
type Candidate = { file: string; line: number; score: number } | null;
/** The best-scoring line in one file, or `best` unchanged if nothing beats it. */
function bestInFile(
file: string,
content: string,
needles: Needles,
best: Candidate
): Candidate {
const bonus = kindBonus(file);
let winner = best;
const lines = content.split("\n");
for (let i = 0; i < lines.length; i += 1) {
const score = scoreLine(lines[i] ?? "", needles);
if (score > 0 && (!winner || score + bonus > winner.score)) {
winner = { file, line: i + 1, score: score + bonus };
}
}
return winner;
}
function searchByElement(
cwd: string,
element: ElementContext
): SourceLocation | null {
const files = walkFiles(cwd, { extensions: SOURCE_EXT });
const needles: Needles = {
classes: element.classes.filter((c) => c.length > 2),
tagOpen: `<${element.tagName}`,
text: element.textPreview.trim(),
};
let best: Candidate = null;
for (const file of files) {
const content = readCapped(file);
if (content !== null) {
best = bestInFile(file, content, needles, best);
}
if (best && best.score >= GOOD_ENOUGH_SCORE) {
break;
}
}
if (!best) {
return null;
}
return {
context: readContext(best.file, best.line),
file: relative(cwd, best.file),
line: best.line,
};
}
function kindBonus(file: string): number {
if (ROUTE_DIR.test(file)) {
return 3;
}
if (COMPONENT_DIR.test(file)) {
return 2;
}
if (
file.endsWith(".vue") ||
file.endsWith(".svelte") ||
file.endsWith(".astro")
) {
return 2;
}
return 0;
}
+183
View File
@@ -0,0 +1,183 @@
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import {
invalidateTokenCache,
scanProjectTokens,
tokenScanRoot,
} from "./tokens";
/** Build a throwaway project tree and return its root. */
function fixture(files: Record<string, string>): string {
const root = mkdtempSync(join(tmpdir(), "airship-tokens-"));
for (const [path, contents] of Object.entries(files)) {
const full = join(root, path);
mkdirSync(join(full, ".."), { recursive: true });
writeFileSync(full, contents);
}
invalidateTokenCache();
return root;
}
describe("tokenScanRoot", () => {
it("climbs to the workspace root, not the dev server's cwd", () => {
// The case this exists for: the app is `apps/web`, and the design tokens
// are a sibling package. Scanning from the app finds aliases and never
// finds what they alias to.
const root = fixture({
"apps/web/package.json": "{}",
"apps/web/src/styles.css": ":root { --x: 1px; }",
"pnpm-workspace.yaml": "packages:\n - apps/*\n",
});
expect(tokenScanRoot(join(root, "apps/web"))).toBe(root);
});
it("stops at the nearest marker, not the outermost one", () => {
// A nested workspace must not escalate to the repo above it and scan a
// completely unrelated project.
const root = fixture({
".git": "",
"example/apps/web/package.json": "{}",
"example/pnpm-workspace.yaml": "packages:\n - apps/*\n",
});
expect(tokenScanRoot(join(root, "example/apps/web"))).toBe(
join(root, "example")
);
});
it("falls back to cwd when there is no marker anywhere above", () => {
const root = mkdtempSync(join(tmpdir(), "airship-bare-"));
mkdirSync(join(root, "a/b/c/d/e/f/g"), { recursive: true });
const deep = join(root, "a/b/c/d/e/f/g");
expect(tokenScanRoot(deep)).toBe(deep);
});
});
describe("scanProjectTokens", () => {
it("resolves a var() alias chain across packages to its literal", () => {
const root = fixture({
"apps/web/src/styles.css":
'@import "tailwindcss";\n@theme {\n --radius-md: var(--pk-radius-md);\n --color-page: var(--pk-color-page);\n}\n',
// The primitive lives in a sibling package's build output, which is where
// token packages actually ship it.
"packages/tokens/dist/tokens.css":
":root {\n --pk-radius-md: 8px;\n --pk-color-page: #fafaf9;\n}\n",
"pnpm-workspace.yaml": "packages:\n - apps/*\n - packages/*\n",
});
const scan = scanProjectTokens(join(root, "apps/web"), { refresh: true });
const byName = Object.fromEntries(scan.tokens.map((t) => [t.name, t]));
expect(scan.framework).toBe("tailwind");
expect(byName["--radius-md"].values[""]).toBe("8px");
expect(byName["--color-page"].values[""]).toBe("#fafaf9");
// The alias is recorded so the registry can collapse the duplicate pair.
expect(byName["--radius-md"].aliasOf).toBe("--pk-radius-md");
// Categorised from the property it is used on, not from its name alone.
expect(byName["--color-page"].category).toBe("colors");
});
it("records a file and line for every token", () => {
const root = fixture({
"pnpm-workspace.yaml": "",
"src/tokens.css":
"/* a comment\n spanning lines */\n:root {\n --gap: 8px;\n}\n",
});
const scan = scanProjectTokens(root, { refresh: true });
const gap = scan.tokens.find((t) => t.name === "--gap");
expect(gap?.file).toBe("src/tokens.css");
// Line 4 — comments are blanked in place so offsets stay truthful.
expect(gap?.line).toBe(4);
});
it("skips bundler output but keeps a token package's dist", () => {
const root = fixture({
"packages/tokens/dist/tokens.css": ":root { --keep: 4px; }",
"pnpm-workspace.yaml": "",
"web/dist/assets/styles-DSZLOMh8.css": ":root { --dropped: 9px; }",
});
const names = scanProjectTokens(root, { refresh: true }).tokens.map(
(t) => t.name
);
expect(names).toContain("--keep");
expect(names).not.toContain("--dropped");
});
it("ignores framework-internal custom properties", () => {
const root = fixture({
"a.css": ":root { --tw-ring-offset-width: 0px; --real: 4px; }",
"pnpm-workspace.yaml": "",
});
const names = scanProjectTokens(root, { refresh: true }).tokens.map(
(t) => t.name
);
expect(names).toContain("--real");
expect(names).not.toContain("--tw-ring-offset-width");
});
it("picks up single-declaration utility classes", () => {
const root = fixture({
"a.css":
".pt-4 { padding-top: 16px; }\n.card { padding: 8px; margin: 4px; }\n",
"pnpm-workspace.yaml": "",
});
const scan = scanProjectTokens(root, { refresh: true });
const utilities = scan.tokens.filter((t) => t.kind === "utility-class");
expect(utilities.map((t) => t.name)).toEqual([".pt-4"]);
// `.card` declares two properties, so it is a component, not a token.
});
it("ignores the editor's own chrome palette", () => {
// The scan climbs to the workspace root by design, which in airship's own
// repo walks straight into the package that emits the inspector's colours.
// Those were being offered as the user's design system, and applying one
// wrote a `var()` the app could not resolve.
const root = fixture({
"apps/web/package.json": '{"name":"@acme/web"}',
"apps/web/src/a.css": ":root { --brand: #0af; }",
"packages/editor-tokens/dist/tokens.css":
".ap-mock { --ap-surface-panel: #313131; }",
"packages/editor-tokens/package.json":
'{"name":"@airship/editor-tokens"}',
"pnpm-workspace.yaml": "packages:\n - apps/*\n - packages/*\n",
});
const names = scanProjectTokens(join(root, "apps/web"), {
refresh: true,
}).tokens.map((t) => t.name);
expect(names).toContain("--brand");
expect(names).not.toContain("--ap-surface-panel");
});
it("keeps the app's own tokens even though it shares our scope", () => {
/*
* The regression the exact-name list exists to prevent. Excluding
* "anything scoped `@airship/`" also excluded `@airship/web` — the app being
* edited — and the scan returned nothing at all. Being scope-mates does not
* make a package the editor's chrome.
*/
const root = fixture({
"apps/web/package.json": '{"name":"@airship/web"}',
"apps/web/src/a.css": ":root { --brand: #0af; }",
"pnpm-workspace.yaml": "packages:\n - apps/*\n",
});
const names = scanProjectTokens(join(root, "apps/web"), {
refresh: true,
}).tokens.map((t) => t.name);
expect(names).toContain("--brand");
});
it("keeps a design-token package that is a sibling of the app", () => {
// The whole reason the scan climbs. This must survive the exclusions.
const root = fixture({
"apps/web/package.json": '{"name":"@acme/web"}',
"apps/web/src/a.css": ":root { --radius-md: var(--pk-radius-md); }",
"packages/tokens/dist/tokens.css": ":root { --pk-radius-md: 8px; }",
"packages/tokens/package.json": '{"name":"@acme/tokens"}',
"pnpm-workspace.yaml": "packages:\n - apps/*\n - packages/*\n",
});
const names = scanProjectTokens(join(root, "apps/web"), {
refresh: true,
}).tokens.map((t) => t.name);
expect(names).toContain("--pk-radius-md");
});
});
+562
View File
@@ -0,0 +1,562 @@
/**
* @airship/source/tokens — the project's design tokens, read from the CSS on
* disk.
*
* This is the half of token discovery that only a daemon can do. An in-page
* overlay can read `document.styleSheets`, but by then Tailwind has been
* compiled, `@theme` has become `:root`, and every authored name is gone. Here
* we read what the author actually wrote — including the file and line it lives
* on, which is what lets the agent go and edit the scale itself rather than
* guessing at a value.
*
* The runtime scan in the overlay stays worth doing: it catches CSS-in-JS and
* anything injected after build, which never touches a file we can see. The two
* are merged client-side, with these entries winning.
*
* Deliberately a text scan, not a real CSS parse. Pulling in postcss to find
* `--foo: 4px` would be a dependency, a build step and a class of version
* conflicts, in exchange for precision this does not need: a token declaration
* that a brace-matching scan gets wrong is a token we simply do not offer.
*/
import { existsSync, readFileSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import {
type CssFramework,
categorizeToken,
categoryForProperty,
type DesignToken,
isInternalToken,
isTokenizableValue,
type TokenScanResult,
} from "@airship/protocol/tokens";
import { readCapped, walkFiles } from "./walk";
const CSS_EXT: ReadonlySet<string> = new Set([
".css",
".scss",
".sass",
".less",
".pcss",
".postcss",
]);
/**
* Like the shared {@link IGNORE_DIRS}, but **without `dist` and `build`**.
*
* This looks wrong and is not. A design-token package's whole job is to emit
* CSS custom properties, and it emits them to `dist` — that is the artifact its
* consumers import. Skipping `dist` here means finding `--radius-md:
* var(--pk-radius-md)` in the app and never finding what `--pk-radius-md` is,
* which is precisely the case in this repo's own example app.
*
* What we do *not* want from `dist` is bundler output: a compiled Tailwind
* sheet is tens of thousands of post-purge rules with no authored names in it,
* which is the exact low-quality data the static scan exists to beat. That is
* filtered by name instead — see {@link isBuildArtifact}.
*/
const TOKEN_IGNORE_DIRS: ReadonlySet<string> = new Set([
"node_modules",
".git",
".next",
".turbo",
"coverage",
".cache",
]);
/** `styles-DSZLOMh8.css` — a bundler content hash, so: build output. */
const CONTENT_HASHED = /-[A-Za-z0-9_-]{8,}\.\w+$/;
function isBuildArtifact(name: string): boolean {
return CONTENT_HASHED.test(name) || name.includes(".min.");
}
/**
* The editor's own chrome packages, by exact name.
*
* These emit the `--ap-*` palette and the icon set the inspector itself is drawn
* with. The scan starts at the *workspace* root — deliberately, because a
* design-token package is usually a sibling of the app rather than inside it —
* which in this repo means walking straight into them: 93 of the 144 colour
* tokens `apps/web` was offered came from `packages/editor-tokens/dist`, a
* stylesheet that app does not load, and applying one wrote a `var()` the page
* could not resolve.
*
* Deliberately an exact-name list and **not** "anything scoped `@airship/`".
* That broader rule was tried first and excluded `@airship/web` — the app being
* edited — taking every one of its real tokens with it. Nothing about being
* scope-mates makes a package the editor's chrome; only these three are.
* `@airship/site-tokens` is absent on purpose: `--pk-*` is the demo site's own
* design system and exactly what the picker should offer.
*/
const OWN_CHROME_PACKAGES: ReadonlySet<string> = new Set([
"@airship/editor-icons",
"@airship/editor-tokens",
"@airship/overlay",
]);
/**
* Does this file belong to one of the editor's own chrome packages?
*
* `--ap-` is also on {@link INTERNAL_TOKEN_PREFIXES}, which catches those
* packages' custom properties. This catches everything else they declare —
* utility classes, and any token one day renamed off the prefix — and it works
* wherever airship is installed from, because it asks the package who it is
* rather than matching a path.
*
* Memoised per directory: candidate files cluster into a handful of them, and
* the alternative is a `package.json` read per file.
*/
function ownedByEditor(file: string, owned: Map<string, boolean>): boolean {
let dir = dirname(file);
const seen: string[] = [];
for (;;) {
const known = owned.get(dir);
if (known !== undefined) {
for (const d of seen) {
owned.set(d, known);
}
return known;
}
seen.push(dir);
const manifest = join(dir, "package.json");
if (existsSync(manifest)) {
let isOurs = false;
try {
const name: unknown = JSON.parse(readFileSync(manifest, "utf8")).name;
isOurs = typeof name === "string" && OWN_CHROME_PACKAGES.has(name);
} catch {
// An unreadable or malformed manifest says nothing about ownership.
}
for (const d of seen) {
owned.set(d, isOurs);
}
return isOurs;
}
const parent = dirname(dir);
if (parent === dir) {
for (const d of seen) {
owned.set(d, false);
}
return false;
}
dir = parent;
}
}
/** Tailwind's config, in the forms it actually ships as. */
const TAILWIND_CONFIGS = [
"tailwind.config.js",
"tailwind.config.ts",
"tailwind.config.cjs",
"tailwind.config.mjs",
];
// ---------------------------------------------------------------------------
// Regexes (top-level: building these per file, per rule would be the hot path)
// ---------------------------------------------------------------------------
const BLOCK_COMMENT = /\/\*[\s\S]*?\*\//g;
/** Innermost rule blocks only — the body pattern excludes braces, so a nested
* `@media { .a { … } }` yields the `.a` rule rather than the wrapper. */
const RULE = /([^{}]*)\{([^{}]*)\}/g;
const DECLARATION = /([\w-]+)\s*:\s*([^;]+)/g;
const CUSTOM_PROPERTY = /^--[\w-]+$/;
const VAR_REFERENCE = /var\(\s*(--[\w-]+)/g;
/** A value that is *only* a var reference — an alias, not a derivation. */
const EXACT_VAR = /^var\(\s*(--[\w-]+)\s*\)$/;
/** A single class selector and nothing else: `.p-4`, not `.a .b` or `.a:hover`. */
const SIMPLE_CLASS = /^\.(-?[_a-zA-Z][\w-]*)$/;
const TAILWIND_MARKER = /@tailwind\b|@import\s+["']tailwindcss/;
const NEWLINE = /\n/g;
// ---------------------------------------------------------------------------
// Public entry
// ---------------------------------------------------------------------------
export interface ScanOptions {
/** Bypass the cache. Used by the overlay's explicit re-scan request. */
refresh?: boolean;
}
interface CacheEntry {
result: TokenScanResult;
scannedAt: number;
}
const cache = new Map<string, CacheEntry>();
/** How long a scan stands before a `refresh`-less request re-walks the tree. */
const CACHE_TTL_MS = 30_000;
/**
* Every design token declared in the project's CSS.
*
* Cached per `cwd`: this walks the whole tree, and it is called on every socket
* connection and every edit turn. The TTL rather than an mtime watch is
* deliberate — a watcher over an arbitrary project tree is a resource leak and a
* portability problem, and a token scale that is 30 seconds stale has never
* mattered to anyone.
*/
export function scanProjectTokens(
cwd: string,
options: ScanOptions = {}
): TokenScanResult {
const hit = cache.get(cwd);
if (!options.refresh && hit && Date.now() - hit.scannedAt < CACHE_TTL_MS) {
return hit.result;
}
const result = scanUncached(cwd);
cache.set(cwd, { result, scannedAt: Date.now() });
return result;
}
/** Files that mark the top of a project or workspace. */
const ROOT_MARKERS = [
"pnpm-workspace.yaml",
"pnpm-lock.yaml",
"yarn.lock",
"package-lock.json",
"bun.lockb",
"lerna.json",
".git",
];
/** How far up to look before giving up and scanning from `cwd`. */
const MAX_ROOT_DEPTH = 6;
/**
* Where to start the token scan.
*
* **Not `cwd`.** `--cwd` is the directory the *dev server* treats as its root —
* `apps/web` in a monorepo — and a design-token package is almost always a
* sibling of that, under `packages/`. Scanning from `cwd` finds the app's
* `@theme { --radius-md: var(--pk-radius-md) }` and never finds what
* `--pk-radius-md` is, which is exactly what happens in this repo's own example.
*
* So walk up to the nearest workspace or repository root and scan from there.
* The walk is capped and stops at the *first* marker found, which keeps a nested
* workspace (the examples in this repo are each their own) from escalating all
* the way to the outer repository and scanning something unrelated.
*/
export function tokenScanRoot(cwd: string): string {
let dir = resolve(cwd);
for (let depth = 0; depth < MAX_ROOT_DEPTH; depth += 1) {
if (ROOT_MARKERS.some((marker) => existsSync(join(dir, marker)))) {
return dir;
}
const parent = dirname(dir);
if (parent === dir) {
break;
}
dir = parent;
}
return cwd;
}
/** Drop the cache. Exported for tests and for a daemon-side file watcher. */
export function invalidateTokenCache(cwd?: string): void {
if (cwd === undefined) {
cache.clear();
return;
}
cache.delete(cwd);
}
// ---------------------------------------------------------------------------
// The scan
// ---------------------------------------------------------------------------
interface RawCustomProperty {
file: string;
line: number;
name: string;
value: string;
}
function scanUncached(cwd: string): TokenScanResult {
const files = walkFiles(tokenScanRoot(cwd), {
extensions: CSS_EXT,
ignoreDirs: TOKEN_IGNORE_DIRS,
reject: isBuildArtifact,
});
const customProperties = new Map<string, RawCustomProperty>();
const utilities: DesignToken[] = [];
/** `--name` → the CSS properties it is used on. The best category signal. */
const usage = new Map<string, Set<string>>();
let sawTailwindMarker = false;
const root = tokenScanRoot(cwd);
const ownership = new Map<string, boolean>();
for (const file of files) {
if (ownedByEditor(file, ownership)) {
continue;
}
const raw = readCapped(file);
if (raw === null) {
continue;
}
if (TAILWIND_MARKER.test(raw)) {
sawTailwindMarker = true;
}
const text = stripComments(raw);
const lines = lineIndex(text);
// Relative to the scan root, which is what the agent's own cwd-relative
// paths are resolved against.
const rel = relative(root, file) || file;
scanFile(text, lines, rel, { customProperties, usage, utilities });
}
const tokens: DesignToken[] = [];
for (const prop of customProperties.values()) {
if (isInternalToken(prop.name)) {
sawTailwindMarker = sawTailwindMarker || prop.name.startsWith("--tw-");
continue;
}
const value = resolveValue(prop.value, customProperties);
const category = categorizeToken({
name: prop.name,
usedOn: usage.get(prop.name),
value,
});
if (!category) {
continue;
}
tokens.push({
aliasOf: aliasTarget(prop.value, customProperties),
category,
file: prop.file,
kind: "css-var",
line: prop.line,
name: prop.name,
origin: "static",
values: { "": value },
});
}
tokens.push(...utilities);
return {
framework: detectFramework(cwd, sawTailwindMarker, utilities.length),
tokens,
};
}
interface Sink {
customProperties: Map<string, RawCustomProperty>;
usage: Map<string, Set<string>>;
utilities: DesignToken[];
}
function scanFile(
text: string,
lines: number[],
file: string,
sink: Sink
): void {
RULE.lastIndex = 0;
let rule: RegExpExecArray | null = RULE.exec(text);
while (rule !== null) {
const [, rawSelector, body] = rule;
const selector = rawSelector.trim();
const bodyOffset = rule.index + rawSelector.length + 1;
collectDeclarations(body, bodyOffset, lines, file, selector, sink);
rule = RULE.exec(text);
}
}
function collectDeclarations(
body: string,
bodyOffset: number,
lines: number[],
file: string,
selector: string,
sink: Sink
): void {
const simpleClass = SIMPLE_CLASS.exec(selector);
const declarations: { property: string; value: string }[] = [];
DECLARATION.lastIndex = 0;
let decl: RegExpExecArray | null = DECLARATION.exec(body);
while (decl !== null) {
const property = decl[1].trim();
const value = decl[2].trim();
declarations.push({ property, value });
if (CUSTOM_PROPERTY.test(property)) {
// First declaration wins. A token redefined in a dark-theme block is the
// same token; recording the override would list it twice with two values.
if (!sink.customProperties.has(property)) {
sink.customProperties.set(property, {
file,
line: lineOf(lines, bodyOffset + decl.index),
name: property,
value,
});
}
} else {
// `color: var(--brand)` is the strongest possible evidence that
// `--brand` is a colour — stronger than any name or value heuristic.
recordUsage(sink.usage, property, value);
}
decl = DECLARATION.exec(body);
}
if (simpleClass && declarations.length === 1) {
addUtility(
sink.utilities,
simpleClass[0],
declarations[0],
file,
lines,
bodyOffset
);
}
}
function recordUsage(
usage: Map<string, Set<string>>,
property: string,
value: string
): void {
if (!value.includes("var(")) {
return;
}
VAR_REFERENCE.lastIndex = 0;
let ref: RegExpExecArray | null = VAR_REFERENCE.exec(value);
while (ref !== null) {
const [, name] = ref;
const set = usage.get(name);
if (set) {
set.add(property);
} else {
usage.set(name, new Set([property]));
}
ref = VAR_REFERENCE.exec(value);
}
}
function addUtility(
utilities: DesignToken[],
selector: string,
declaration: { property: string; value: string },
file: string,
lines: number[],
bodyOffset: number
): void {
const category = categoryForProperty(declaration.property);
if (!category) {
return;
}
// `.h-auto { height: auto }` is a Tailwind utility, not a design token — see
// `isTokenizableValue`.
if (!isTokenizableValue(declaration.value)) {
return;
}
utilities.push({
category,
file,
kind: "utility-class",
line: lineOf(lines, bodyOffset),
name: selector,
origin: "static",
values: { [declaration.property]: declaration.value },
});
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Follow `var(--a)` chains to a literal, so `--primary: var(--blue-500)` is
* offered as the colour it actually resolves to.
*
* Depth-capped rather than cycle-tracked: the cap is what makes a mutually
* recursive pair terminate, and no real token chain is more than a few deep.
*/
function resolveValue(
value: string,
properties: Map<string, RawCustomProperty>,
depth = 0
): string {
if (depth >= 8 || !value.includes("var(")) {
return value.trim();
}
VAR_REFERENCE.lastIndex = 0;
const match = VAR_REFERENCE.exec(value);
if (!match) {
return value.trim();
}
const [, referencedName] = match;
const referenced = properties.get(referencedName);
if (!referenced) {
return value.trim();
}
return resolveValue(referenced.value, properties, depth + 1);
}
/**
* The token this declaration is a pure alias of, if any.
*
* Only an *exact* `var(--other)` counts. `calc(var(--x) * 2)` and
* `var(--x, 4px)` are derivations, not aliases, and collapsing them would lose
* a real distinction.
*/
function aliasTarget(
rawValue: string,
properties: Map<string, RawCustomProperty>
): string | undefined {
const exact = EXACT_VAR.exec(rawValue.trim());
if (!exact) {
return;
}
return properties.has(exact[1]) ? exact[1] : undefined;
}
/**
* Replace comments with same-length whitespace rather than removing them, so
* every later match index still points at the right line.
*/
function stripComments(text: string): string {
return text.replace(BLOCK_COMMENT, (comment) =>
comment.replace(/[^\n]/g, " ")
);
}
/** Offsets of every line start, for turning a match index into a line number. */
function lineIndex(text: string): number[] {
const offsets = [0];
NEWLINE.lastIndex = 0;
let m: RegExpExecArray | null = NEWLINE.exec(text);
while (m !== null) {
offsets.push(m.index + 1);
m = NEWLINE.exec(text);
}
return offsets;
}
/** 1-based line containing `offset`, by binary search over line starts. */
function lineOf(offsets: number[], offset: number): number {
let lo = 0;
let hi = offsets.length - 1;
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (offsets[mid] <= offset) {
lo = mid;
} else {
hi = mid - 1;
}
}
return lo + 1;
}
function detectFramework(
cwd: string,
sawTailwindMarker: boolean,
utilityCount: number
): CssFramework {
if (
sawTailwindMarker ||
TAILWIND_CONFIGS.some((name) => existsSync(join(cwd, name)))
) {
return "tailwind";
}
return utilityCount > 0 ? "custom" : "unknown";
}
+110
View File
@@ -0,0 +1,110 @@
/**
* Shared project-tree walking, used by both server-side consumers: the source
* resolver (`server.ts`, looking for JSX) and the token scanner (`tokens.ts`,
* looking for CSS).
*
* Extracted rather than copied because the two must agree on what "the project"
* means. A `node_modules` that one skips and the other descends into is not a
* style difference — it is a scan that walks a hundred thousand files and a
* daemon that appears to hang on startup.
*/
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
export const IGNORE_DIRS: ReadonlySet<string> = new Set([
"node_modules",
".git",
"dist",
"build",
".next",
".turbo",
"coverage",
".cache",
]);
/** Ceiling on how many files one walk will collect. */
export const MAX_FILES = 4000;
/** Files bigger than this are generated or vendored; scanning them is waste. */
export const MAX_FILE_BYTES = 256 * 1024;
export function safeReaddir(dir: string) {
try {
return readdirSync(dir, { withFileTypes: true });
} catch {
return [];
}
}
export function extOf(name: string): string {
const dot = name.lastIndexOf(".");
return dot === -1 ? "" : name.slice(dot);
}
export function isMinified(name: string): boolean {
return name.includes(".min.") || name.endsWith(".bundle.js");
}
export interface WalkOptions {
/** Extensions to collect, with the leading dot (`.css`). */
extensions: ReadonlySet<string>;
/** Override the directories to skip. Defaults to {@link IGNORE_DIRS}. */
ignoreDirs?: ReadonlySet<string>;
maxFiles?: number;
/** Reject a file by name after it passes the extension filter. */
reject?: (name: string) => boolean;
}
/**
* Every matching file under `root`, skipping hidden entries, known build and
* vendor directories, and minified bundles. Depth-first and bounded.
*/
export function walkFiles(root: string, options: WalkOptions): string[] {
const acc: string[] = [];
walkInto(root, acc, {
extensions: options.extensions,
ignoreDirs: options.ignoreDirs ?? IGNORE_DIRS,
maxFiles: options.maxFiles ?? MAX_FILES,
reject: options.reject ?? isMinified,
});
return acc;
}
type ResolvedWalk = Required<Omit<WalkOptions, "maxFiles">> & {
maxFiles: number;
};
function walkInto(dir: string, acc: string[], opts: ResolvedWalk): void {
if (acc.length >= opts.maxFiles) {
return;
}
for (const entry of safeReaddir(dir)) {
if (acc.length >= opts.maxFiles) {
return;
}
if (entry.name.startsWith(".") || opts.ignoreDirs.has(entry.name)) {
continue;
}
const full = join(dir, entry.name);
if (entry.isDirectory()) {
walkInto(full, acc, opts);
} else if (
opts.extensions.has(extOf(entry.name)) &&
!opts.reject(entry.name)
) {
acc.push(full);
}
}
}
/** File contents, or null if unreadable or too big to be worth scanning. */
export function readCapped(
file: string,
maxBytes = MAX_FILE_BYTES
): string | null {
try {
const buf = readFileSync(file);
return buf.byteLength > maxBytes ? null : buf.toString("utf8");
} catch {
return null;
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"types": ["node"],
"strictNullChecks": true
},
"include": ["src"]
}