maddox-engine 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,6 +24,7 @@ Options:
24
24
  - `--motion <path>` — path to a JSON file of motion tokens (e.g. a vendored copy of your motion-tokens export)
25
25
  - `--states <path>` — path to a state-contract JSON file (see below). No effect in `--url` mode.
26
26
  - `--tokens-studio <path>` — path to a Tokens Studio / W3C Design Tokens JSON export (see below); merged with, and taking precedence over, `@theme` on any path both define
27
+ - `--figma-file <file-key>` — pull color/spacing tokens from a Figma file's Variables (see below); requires an Enterprise Figma plan
27
28
  - `--url <page-url>` — scan a deployed page instead of local source (see below); pass a placeholder like `-` for `<target-source-dir>` when using this alone
28
29
  - `--apply-fixes` — write near-miss color/font-size/spacing suggestions back into source files (see below); not available with `--url`
29
30
  - `--format text|json|markdown` — output format (default: `text`)
@@ -52,6 +53,19 @@ Ground truth is explicit — nothing is inferred about which states a component
52
53
 
53
54
  If your tokens live in Figma via the Tokens Studio plugin rather than (or alongside) a Tailwind `@theme` block, export them to JSON and point `--tokens-studio` at the file. A single-set export (the whole file is one token tree) and a multi-set export (top-level keys are set names, e.g. `global`, `dark`) are both supported — for a multi-set export, every set is merged, later sets overriding earlier ones by path. `{alias}` references are resolved automatically. Only token types that resolve to a single comparable value (`color`, `spacing`, `sizing`, `fontSizes`, `borderRadius`, `dimension`) are used — composite types like `typography` or `boxShadow` describe a bundle of properties, not one value to diff against, and are skipped rather than misclassified.
54
55
 
56
+ ### Figma Variables ground truth
57
+
58
+ If your tokens live in native Figma Variables rather than Tokens Studio, `--figma-file <file-key>` pulls them directly from Figma's Variables REST API. **This requires an Enterprise Figma plan** — the endpoint returns a 403 for any other plan, regardless of the token's own permissions. Get the file key from the file's URL (`figma.com/design/:file_key/...`).
59
+
60
+ ```bash
61
+ export MADDOX_FIGMA_TOKEN=figd_... # a personal access token with file_variables:read scope
62
+ npx maddox-engine ./src ./src/app/globals.css --figma-file abc123XYZ
63
+ ```
64
+
65
+ The token is read from `MADDOX_FIGMA_TOKEN` (a personal access token, sent via the `X-Figma-Token` header) or `MADDOX_FIGMA_OAUTH_TOKEN` (an OAuth2 access token, sent via `Authorization: Bearer`) — never pass it as a CLI flag, which would leak it into shell history and process listings.
66
+
67
+ Only `COLOR` and `FLOAT`-typed variables are used, resolved to each variable's default mode, following alias references to their underlying value. `FLOAT` variables have no unit in Figma's API — treated as pixels, matching Figma's own UI default for spacing/sizing/radius scales; a variable actually meant as a unitless multiplier will resolve wrong. `STRING`/`BOOLEAN` variables are skipped, same policy as Tokens Studio's composite types. When more than one ground-truth source is given, Figma Variables take precedence over Tokens Studio, which takes precedence over `@theme` CSS — Figma sits earliest in a real design-to-code pipeline, so a later step is more likely to be the stale one.
68
+
55
69
  ### Scanning a live page
56
70
 
57
71
  ```bash
@@ -90,6 +104,38 @@ This is a real edit to your working tree, not a dry run — review the diff (`gi
90
104
 
91
105
  `match` counts fully, `near-miss` counts half (it drifted, but is still recognizably close to a real token), `unrecognized` and a missing required state count for nothing. A scan with zero checks scores 100.
92
106
 
107
+ ## Programmatic use: route mapping
108
+
109
+ The CLI's own text/markdown/JSON output stays a flat file:line list — the right shape for a PR comment or a CI log. If you're building your own dashboard or report on top of this package, `fileToRoute(file)` and `groupByRoute(findings)` are also exported for grouping findings by the actual Next.js App Router route a file belongs to, rather than its raw path:
110
+
111
+ ```ts
112
+ import { audit, loadGroundTruth, fileToRoute, groupByRoute } from "maddox-engine";
113
+
114
+ const groundTruth = loadGroundTruth("./src/app/globals.css", {});
115
+ const { findings } = await audit("./src", groundTruth);
116
+
117
+ fileToRoute("app/dashboard/page.tsx"); // "/dashboard"
118
+ fileToRoute("app/(auth)/login/page.tsx"); // "/login" — route groups are invisible in the real URL
119
+ fileToRoute("app/work/[id]/page.tsx"); // "/work/[id]" — dynamic segments kept as Next.js represents them
120
+ fileToRoute("components/Button.tsx"); // "Shared (non-route files)"
121
+
122
+ groupByRoute(findings); // [{ route, findings }, ...], real routes first, shared bucket last
123
+ ```
124
+
125
+ Only `page.*`/`layout.*` files directly under an `app/` (or `src/app/`) directory get a real route — this deliberately does not trace the import graph to attribute a shared component to the page(s) that render it, since a component used by five different pages has no single "real" route, and guessing one would misattribute drift. Everything else lands in a `"Shared (non-route files)"` bucket instead. A layout keeps its route group in its own label (`/(auth) (layout)`) even though a page at the same URL doesn't, since two different layouts can legitimately wrap the same URL from different subtrees.
126
+
127
+ ### Using this from a React client component
128
+
129
+ `import ... from "maddox-engine"` pulls in everything, including `scan.ts`/`groundTruth.ts`/`tokensStudio.ts`/`applyFixes.ts` — all of which read the filesystem (`node:fs`, `node:path`). A bundler building a browser/client bundle (Next.js's `"use client"`, Vite, etc.) can't resolve those, and the build fails even if the client code never actually calls a Node-dependent function — a bundler loads a module's own top-level imports regardless of which export is used.
130
+
131
+ If you're grouping or diffing findings inside a client component (e.g. rendering `fileToRoute`/`groupByRoute` output, or re-running `diffUsages` against data already fetched server-side), import from the `/client` subpath instead — it only re-exports modules with zero `node:*` imports anywhere in their own graph:
132
+
133
+ ```ts
134
+ import { fileToRoute, groupByRoute, diffUsages, healthScore } from "maddox-engine/client";
135
+ ```
136
+
137
+ `audit`/`auditUrl`/`loadGroundTruth`/anything that reads a file or fetches a URL still needs the package root, and stays server-side (an API route, a server component, a build script) — `/client` only has the pure data-transformation half.
138
+
93
139
  ## Using in CI
94
140
 
95
141
  This repo is also a GitHub Action — `omrdev1/maddox-cli` — that runs a scan, posts the markdown report as a PR comment (updating the same comment on later pushes rather than piling up new ones), and optionally fails the build via `fail-below`.
@@ -111,6 +157,7 @@ Inputs:
111
157
  - `motion-tokens` — path to a motion-tokens JSON file
112
158
  - `states` — path to a state-contract JSON file. No effect when `url` is set.
113
159
  - `tokens-studio` — path to a Tokens Studio / W3C Design Tokens JSON export
160
+ - `figma-file` / `figma-token` / `figma-token-type` — pull tokens from a Figma file's Variables; requires an Enterprise Figma plan. `figma-token` should be a secret (`${{ secrets.FIGMA_TOKEN }}`), never a literal value.
114
161
  - `url` — a deployed page URL to scan instead of local source
115
162
  - `github-token` *(required)* — for posting the PR comment, usually `${{ secrets.GITHUB_TOKEN }}`
116
163
  - `fail-below` — fail the build below this health score; omit for comment-only
package/package.json CHANGED
@@ -1,9 +1,21 @@
1
1
  {
2
2
  "name": "maddox-engine",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Scans source code or a live deployed page for design-token, motion, and component-state drift against your own design system — the same engine behind Maddox Engine's CI checks.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
+ "main": "./src/core.ts",
8
+ "types": "./src/core.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./src/core.ts",
12
+ "default": "./src/core.ts"
13
+ },
14
+ "./client": {
15
+ "types": "./src/client.ts",
16
+ "default": "./src/client.ts"
17
+ }
18
+ },
7
19
  "bin": {
8
20
  "maddox": "./src/cli.ts"
9
21
  },
package/src/cli.ts CHANGED
@@ -1,7 +1,16 @@
1
1
  #!/usr/bin/env -S npx tsx
2
2
 
3
3
  import { readFileSync } from "node:fs";
4
- import { applyFixes, audit, auditUrl, healthScore, loadGroundTruth, loadStateContract } from "./core.js";
4
+ import {
5
+ applyFixes,
6
+ audit,
7
+ auditUrl,
8
+ FigmaVariablesUnavailableError,
9
+ healthScore,
10
+ loadGroundTruth,
11
+ loadGroundTruthWithFigma,
12
+ loadStateContract,
13
+ } from "./core.js";
5
14
  import { summarize, toJson, toMarkdown, toText, type ScanSummary } from "./format.js";
6
15
 
7
16
  async function uploadResult(apiUrl: string, apiKey: string, projectName: string, summary: ScanSummary) {
@@ -44,13 +53,17 @@ async function main() {
44
53
  "[--project <name>] [--motion <path-to-motion-tokens.json>] " +
45
54
  "[--states <path-to-state-contract.json>] [--format text|json|markdown] " +
46
55
  "[--fail-below <0-100>] [--tokens-studio <path-to-tokens-studio-export.json>] " +
47
- "[--url <live-page-url>] [--apply-fixes]\n" +
56
+ "[--url <live-page-url>] [--apply-fixes] [--figma-file <file-key>]\n" +
48
57
  " --url scans a deployed page's rendered HTML/CSS instead of local source " +
49
58
  "(pass a placeholder for <target-source-dir>, e.g. '-', when using --url alone). " +
50
59
  "--states has no effect in --url mode: state-completeness is a source-code check.\n" +
51
60
  " --apply-fixes writes near-miss color/font-size/spacing suggestions back into " +
52
61
  "source files (color/font-size/spacing only — motion and state findings are " +
53
- "never auto-applied). Not available with --url, which has no source to write to."
62
+ "never auto-applied). Not available with --url, which has no source to write to.\n" +
63
+ " --figma-file <file-key> pulls color/spacing tokens from a Figma file's " +
64
+ "Variables (the file key from its URL). Requires an Enterprise Figma plan and " +
65
+ "MADDOX_FIGMA_TOKEN set to a personal access token (or MADDOX_FIGMA_OAUTH_TOKEN " +
66
+ "for an OAuth2 token) — never pass the token as a CLI flag."
54
67
  );
55
68
  process.exit(1);
56
69
  }
@@ -71,7 +84,35 @@ async function main() {
71
84
  const stateContract = statesPath ? loadStateContract(statesPath) : undefined;
72
85
 
73
86
  const tokensStudioPath = flagValue("--tokens-studio");
74
- const groundTruth = loadGroundTruth(themeCssPath, motionTokens, tokensStudioPath);
87
+
88
+ const figmaFileKey = flagValue("--figma-file");
89
+ let groundTruth;
90
+ if (figmaFileKey) {
91
+ const patToken = process.env.MADDOX_FIGMA_TOKEN;
92
+ const oauthToken = process.env.MADDOX_FIGMA_OAUTH_TOKEN;
93
+ if (!patToken && !oauthToken) {
94
+ console.error(
95
+ "\n--figma-file requires MADDOX_FIGMA_TOKEN (a personal access token) or " +
96
+ "MADDOX_FIGMA_OAUTH_TOKEN (an OAuth2 access token) to be set."
97
+ );
98
+ process.exit(1);
99
+ }
100
+ try {
101
+ groundTruth = await loadGroundTruthWithFigma(themeCssPath, motionTokens, tokensStudioPath, {
102
+ fileKey: figmaFileKey,
103
+ token: (patToken ?? oauthToken)!,
104
+ authMode: patToken ? "pat" : "oauth",
105
+ });
106
+ } catch (err) {
107
+ if (err instanceof FigmaVariablesUnavailableError) {
108
+ console.error(`\n${err.message}`);
109
+ process.exit(1);
110
+ }
111
+ throw err;
112
+ }
113
+ } else {
114
+ groundTruth = loadGroundTruth(themeCssPath, motionTokens, tokensStudioPath);
115
+ }
75
116
 
76
117
  const result = url ? await auditUrl(url, groundTruth) : await audit(targetDir, groundTruth, stateContract);
77
118
  const summary = summarize(result.findings, result.filesScanned);
package/src/client.ts ADDED
@@ -0,0 +1,19 @@
1
+ // Browser-safe subset of this package, for a "use client" React component
2
+ // in a consuming app. Only re-exports modules with no node:* import
3
+ // anywhere in their own import graph (verified by hand for each — see the
4
+ // comment at the top of scan.ts for why a re-export from a Node-dependent
5
+ // file, even of a browser-safe name, still breaks a client bundle).
6
+ //
7
+ // Import this as "maddox-engine/client", never the package root
8
+ // ("maddox-engine") — the root barrel (core.ts) also exports
9
+ // audit()/auditUrl() and re-exports scan.ts/groundTruth.ts/
10
+ // tokensStudio.ts/applyFixes.ts, all of which import node:fs/node:path.
11
+ export * from "./types.js";
12
+ export * from "./extract.js";
13
+ export * from "./diff.js";
14
+ export * from "./stateCheck.js";
15
+ export * from "./healthScore.js";
16
+ export * from "./routeMap.js";
17
+ export * from "./crawl.js";
18
+ export * from "./suggestFix.js";
19
+ export * from "./figmaVariables.js";
package/src/core.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  export * from "./types.js";
2
+ export * from "./extract.js";
2
3
  export * from "./groundTruth.js";
3
4
  export * from "./tokensStudio.js";
5
+ export * from "./figmaVariables.js";
4
6
  export * from "./scan.js";
5
7
  export * from "./crawl.js";
6
8
  export * from "./diff.js";
@@ -8,6 +10,7 @@ export * from "./stateCheck.js";
8
10
  export * from "./healthScore.js";
9
11
  export * from "./suggestFix.js";
10
12
  export * from "./applyFixes.js";
13
+ export * from "./routeMap.js";
11
14
 
12
15
  import { scanSource, scanFiles } from "./scan.js";
13
16
  import { crawlUrl } from "./crawl.js";
package/src/crawl.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { extractFromContent, type RawUsage } from "./scan.js";
1
+ import { extractFromContent, type RawUsage } from "./extract.js";
2
2
 
3
3
  // Only ever follow relative/absolute-path stylesheet links resolved
4
4
  // against the page's own origin — never a third-party CDN's CSS, since
package/src/diff.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { RawUsage } from "./scan.js";
1
+ import type { RawUsage } from "./extract.js";
2
2
  import type { Finding, GroundTruth } from "./types.js";
3
3
 
4
4
  const NEAR_MISS_RGB_THRESHOLD = 25;
package/src/extract.ts ADDED
@@ -0,0 +1,87 @@
1
+ // Browser-safe extraction — no Node built-ins (fs/path), so this module
2
+ // (and anything that only imports from it) can be bundled into a "use
3
+ // client" component without breaking a webpack/browser build. Keep it
4
+ // that way: this file must never gain a `node:` import. Filesystem
5
+ // walking (scanSource/scanFiles) lives in scan.ts instead, which does
6
+ // import Node built-ins and is meant only for server-side/CLI use.
7
+
8
+ export interface RawUsage {
9
+ file: string;
10
+ line: number;
11
+ kind: "color" | "spacing" | "font-size" | "motion";
12
+ rawValue: string;
13
+ }
14
+
15
+ // A single scanned file's relative path + raw content — produced by
16
+ // scan.ts's filesystem walk, but the shape itself has no Node dependency
17
+ // so it lives here where anything (including a client component) can
18
+ // reference the type.
19
+ export interface ScannedFile {
20
+ file: string; // relative to rootDir
21
+ content: string;
22
+ }
23
+
24
+ const HEX_RE = /#([a-f0-9]{3}|[a-f0-9]{6})\b/gi;
25
+ const FONT_SIZE_RE = /font-size:\s*([\d.]+(?:px|rem|em))/gi;
26
+ const MOTION_DURATION_RE = /duration:\s*([\d.]+)/gi;
27
+ const MOTION_EASE_RE = /ease:\s*['"]([\w-]+)['"]/gi;
28
+
29
+ function lineNumberAt(content: string, index: number): number {
30
+ return content.slice(0, index).split("\n").length;
31
+ }
32
+
33
+ /**
34
+ * Extracts raw color/font-size/motion literals from a single blob of
35
+ * content. Used both by scan.ts's filesystem walk and by crawl.ts's
36
+ * fetched-HTML/CSS path — the same extraction either way, source doesn't
37
+ * matter.
38
+ */
39
+ export function extractFromContent(file: string, content: string): RawUsage[] {
40
+ const usages: RawUsage[] = [];
41
+
42
+ for (const re of [HEX_RE]) {
43
+ re.lastIndex = 0;
44
+ let m: RegExpExecArray | null;
45
+ while ((m = re.exec(content)) !== null) {
46
+ usages.push({
47
+ file,
48
+ line: lineNumberAt(content, m.index),
49
+ kind: "color",
50
+ rawValue: m[0].toLowerCase(),
51
+ });
52
+ }
53
+ }
54
+
55
+ FONT_SIZE_RE.lastIndex = 0;
56
+ let m: RegExpExecArray | null;
57
+ while ((m = FONT_SIZE_RE.exec(content)) !== null) {
58
+ usages.push({
59
+ file,
60
+ line: lineNumberAt(content, m.index),
61
+ kind: "font-size",
62
+ rawValue: m[1],
63
+ });
64
+ }
65
+
66
+ MOTION_DURATION_RE.lastIndex = 0;
67
+ while ((m = MOTION_DURATION_RE.exec(content)) !== null) {
68
+ usages.push({
69
+ file,
70
+ line: lineNumberAt(content, m.index),
71
+ kind: "motion",
72
+ rawValue: m[1],
73
+ });
74
+ }
75
+
76
+ MOTION_EASE_RE.lastIndex = 0;
77
+ while ((m = MOTION_EASE_RE.exec(content)) !== null) {
78
+ usages.push({
79
+ file,
80
+ line: lineNumberAt(content, m.index),
81
+ kind: "motion",
82
+ rawValue: m[1],
83
+ });
84
+ }
85
+
86
+ return usages;
87
+ }
@@ -0,0 +1,182 @@
1
+ import type { TokenMap } from "./types.js";
2
+
3
+ // The Variables REST API is an Enterprise-plan-only Figma feature — a
4
+ // request against a file on a lower plan returns a real error, not an
5
+ // empty result. Surfaced as a specific error rather than a generic fetch
6
+ // failure so a caller can tell "you can't use this" apart from "the
7
+ // network broke" or "your token is wrong".
8
+ export class FigmaVariablesUnavailableError extends Error {
9
+ constructor(message: string) {
10
+ super(message);
11
+ this.name = "FigmaVariablesUnavailableError";
12
+ }
13
+ }
14
+
15
+ interface FigmaRGBA {
16
+ r: number;
17
+ g: number;
18
+ b: number;
19
+ a: number;
20
+ }
21
+
22
+ interface FigmaVariableAlias {
23
+ type: "VARIABLE_ALIAS";
24
+ id: string;
25
+ }
26
+
27
+ type FigmaVariableValue = boolean | number | string | FigmaRGBA | FigmaVariableAlias;
28
+
29
+ function isAlias(value: FigmaVariableValue): value is FigmaVariableAlias {
30
+ return typeof value === "object" && value !== null && "type" in value && value.type === "VARIABLE_ALIAS";
31
+ }
32
+
33
+ function isRgba(value: FigmaVariableValue): value is FigmaRGBA {
34
+ return typeof value === "object" && value !== null && "r" in value;
35
+ }
36
+
37
+ interface FigmaVariable {
38
+ id: string;
39
+ name: string;
40
+ variableCollectionId: string;
41
+ resolvedType: "BOOLEAN" | "FLOAT" | "STRING" | "COLOR";
42
+ valuesByMode: Record<string, FigmaVariableValue>;
43
+ }
44
+
45
+ interface FigmaVariableCollection {
46
+ id: string;
47
+ name: string;
48
+ defaultModeId: string;
49
+ }
50
+
51
+ interface FigmaVariablesResponse {
52
+ status: number;
53
+ error: boolean;
54
+ meta?: {
55
+ variables: Record<string, FigmaVariable>;
56
+ variableCollections: Record<string, FigmaVariableCollection>;
57
+ };
58
+ }
59
+
60
+ // 0-1 float channel (Figma's RGBA convention) -> a 2-digit hex pair.
61
+ function channelToHex(value: number): string {
62
+ const clamped = Math.max(0, Math.min(1, value));
63
+ return Math.round(clamped * 255).toString(16).padStart(2, "0");
64
+ }
65
+
66
+ function rgbaToHex(rgba: FigmaRGBA): string {
67
+ // Alpha is dropped for a fully-opaque color (the common case, and the
68
+ // same #rrggbb shape the rest of the codebase's TokenMap already
69
+ // expects) — only emit an 8-digit #rrggbbaa when the variable is
70
+ // genuinely translucent, so an opaque color's hex stays comparable
71
+ // against a hand-written 6-digit literal in diff.ts's exact-match path.
72
+ const { r, g, b, a } = rgba;
73
+ const hex = `#${channelToHex(r)}${channelToHex(g)}${channelToHex(b)}`;
74
+ return a >= 0.999 ? hex : `${hex}${channelToHex(a)}`;
75
+ }
76
+
77
+ // Resolves a variable's default-mode value to a raw CSS-comparable string,
78
+ // following VariableAlias references. depth guards against a circular
79
+ // alias chain (shouldn't exist in a real Figma file, but a corrupted one
80
+ // or an API bug shouldn't infinite-loop this).
81
+ function resolveValue(
82
+ variable: FigmaVariable,
83
+ variables: Record<string, FigmaVariable>,
84
+ collections: Record<string, FigmaVariableCollection>,
85
+ depth = 0
86
+ ): string | undefined {
87
+ if (depth > 10) return undefined;
88
+
89
+ const collection = collections[variable.variableCollectionId];
90
+ if (!collection) return undefined;
91
+
92
+ const raw = variable.valuesByMode[collection.defaultModeId];
93
+ if (raw === undefined) return undefined;
94
+
95
+ if (isAlias(raw)) {
96
+ const target = variables[raw.id];
97
+ if (!target) return undefined;
98
+ return resolveValue(target, variables, collections, depth + 1);
99
+ }
100
+
101
+ if (isRgba(raw)) return rgbaToHex(raw);
102
+
103
+ if (variable.resolvedType === "FLOAT" && typeof raw === "number") {
104
+ // Figma's Variables API has no unit on a FLOAT value — it's a bare
105
+ // number. Figma's own UI treats spacing/sizing/radius scales as
106
+ // pixel values by default, so this assumes px; a variable that's
107
+ // actually meant as a unitless multiplier or a different unit will
108
+ // be wrong here. Documented, not silently guessed past.
109
+ return `${raw}px`;
110
+ }
111
+
112
+ // STRING and BOOLEAN resolvedTypes don't reduce to a single comparable
113
+ // CSS value the way color/spacing do (a string variable might be a
114
+ // font-family name, an ID reference, anything) — skipped rather than
115
+ // misclassified, same policy tokensStudio.ts uses for composite types.
116
+ return undefined;
117
+ }
118
+
119
+ /**
120
+ * Fetches a Figma file's local Variables (Enterprise plan only) and
121
+ * flattens color/spacing-typed variables, resolved to their default
122
+ * mode, into the same TokenMap shape parseThemeTokens/
123
+ * loadTokensStudioFile produce. Each variable's own name becomes its
124
+ * token key, `--`-prefixed and slash/space-normalized to match the
125
+ * existing --token-name convention.
126
+ *
127
+ * Only ever reads the DEFAULT mode of each variable's collection — a
128
+ * collection with light/dark or brand-A/B modes has one value per mode,
129
+ * but Maddox's ground truth is a single flat TokenMap, the same
130
+ * constraint loadTokensStudioFile's multi-set merge already has to work
131
+ * within. Non-default modes are not read at all, not even as a fallback.
132
+ *
133
+ * @param fileKey - the file key from a Figma file's URL
134
+ * (figma.com/design/:file_key/...)
135
+ * @param token - a Personal Access Token or OAuth2 access token
136
+ * @param authMode - "pat" sends the token via X-Figma-Token (the header
137
+ * Figma's own PAT documentation specifies); "oauth" sends it via
138
+ * Authorization: Bearer (the OAuth2 convention). Both are real,
139
+ * independently-confirmed-working auth mechanisms against the live
140
+ * Figma API — which one to use depends on how the token was obtained,
141
+ * not a fixed choice this function can make for the caller.
142
+ */
143
+ export async function loadFigmaVariables(
144
+ fileKey: string,
145
+ token: string,
146
+ authMode: "pat" | "oauth" = "pat"
147
+ ): Promise<TokenMap> {
148
+ const headers: Record<string, string> =
149
+ authMode === "pat" ? { "X-Figma-Token": token } : { Authorization: `Bearer ${token}` };
150
+
151
+ const response = await fetch(`https://api.figma.com/v1/files/${fileKey}/variables/local`, { headers });
152
+
153
+ if (response.status === 403) {
154
+ throw new FigmaVariablesUnavailableError(
155
+ "Figma Variables API access denied (403) — this endpoint requires an Enterprise Figma plan " +
156
+ "and a token with the file_variables:read scope. Free/Professional/Organization plans cannot use it."
157
+ );
158
+ }
159
+ if (!response.ok) {
160
+ throw new Error(`Figma Variables API request failed: ${response.status} ${response.statusText}`);
161
+ }
162
+
163
+ const body = (await response.json()) as FigmaVariablesResponse;
164
+ if (body.error || !body.meta) {
165
+ throw new Error(`Figma Variables API returned an error response (status ${body.status})`);
166
+ }
167
+
168
+ const { variables, variableCollections } = body.meta;
169
+ const tokens: TokenMap = {};
170
+
171
+ for (const variable of Object.values(variables)) {
172
+ if (variable.resolvedType !== "COLOR" && variable.resolvedType !== "FLOAT") continue;
173
+
174
+ const value = resolveValue(variable, variables, variableCollections);
175
+ if (value === undefined) continue;
176
+
177
+ const key = `--${variable.name.replace(/[\s/]+/g, "-").toLowerCase()}`;
178
+ tokens[key] = value;
179
+ }
180
+
181
+ return tokens;
182
+ }
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { loadTokensStudioFile } from "./tokensStudio.js";
3
+ import { loadFigmaVariables } from "./figmaVariables.js";
3
4
  import type { GroundTruth, MotionToken, StateContract, TokenMap } from "./types.js";
4
5
 
5
6
  /**
@@ -71,3 +72,39 @@ export function loadGroundTruth(
71
72
  motion: flattenMotionTokens(motionTokensObj),
72
73
  };
73
74
  }
75
+
76
+ export interface FigmaVariablesSource {
77
+ fileKey: string;
78
+ token: string;
79
+ authMode?: "pat" | "oauth";
80
+ }
81
+
82
+ /**
83
+ * Same as loadGroundTruth, but with a fourth, optional live Figma
84
+ * Variables source — necessarily async, since it's a real network call,
85
+ * unlike the CSS/Tokens Studio file reads loadGroundTruth wraps. Kept as
86
+ * a separate function rather than making loadGroundTruth itself async,
87
+ * so every existing synchronous call site stays unaffected.
88
+ *
89
+ * Precedence when more than one source defines the same token path:
90
+ * Figma Variables wins over Tokens Studio, which wins over @theme CSS —
91
+ * Figma is the earliest point in a real design-to-code pipeline, and
92
+ * each step after it is more likely to be stale than the one before.
93
+ */
94
+ export async function loadGroundTruthWithFigma(
95
+ themeCssPath: string,
96
+ motionTokensObj: Record<string, unknown>,
97
+ tokensStudioPath?: string,
98
+ figma?: FigmaVariablesSource
99
+ ): Promise<GroundTruth> {
100
+ const tokens = {
101
+ ...parseThemeTokens(themeCssPath),
102
+ ...(tokensStudioPath ? loadTokensStudioFile(tokensStudioPath) : {}),
103
+ ...(figma ? await loadFigmaVariables(figma.fileKey, figma.token, figma.authMode) : {}),
104
+ };
105
+
106
+ return {
107
+ tokens,
108
+ motion: flattenMotionTokens(motionTokensObj),
109
+ };
110
+ }
@@ -0,0 +1,102 @@
1
+ import type { Finding } from "./types.js";
2
+
3
+ // File basenames Next.js's App Router treats as route-owning — a page
4
+ // renders the route itself; a layout wraps every route beneath it. Both
5
+ // are real "this route" attributions; anything else under app/ (loading,
6
+ // error, not-found, template, route.ts API handlers) is left out
7
+ // deliberately — those aren't the page's own content and attributing a
8
+ // color/token finding to them would be misleading.
9
+ const ROUTE_OWNING_BASENAMES = new Set(["page", "layout"]);
10
+ const APP_ROUTER_EXTENSIONS = new Set([".tsx", ".ts", ".jsx", ".js"]);
11
+
12
+ const SHARED_BUCKET = "Shared (non-route files)";
13
+
14
+ /**
15
+ * Converts a file path segment sequence into the URL path Next.js's App
16
+ * Router would actually serve it at: route groups "(name)" are stripped
17
+ * (they don't appear in the URL), everything else — including dynamic
18
+ * segments like "[id]" or "[...slug]" — is kept exactly as Next.js
19
+ * itself represents them, since that IS the real route shape.
20
+ */
21
+ function segmentsToRoute(segments: string[]): string {
22
+ const kept = segments.filter((s) => !(s.startsWith("(") && s.endsWith(")")));
23
+ return "/" + kept.join("/");
24
+ }
25
+
26
+ /**
27
+ * Finds the "app" (or "src/app") directory within a file's own path
28
+ * segments and returns the index right after it — the point from which
29
+ * App Router route segments begin. Returns -1 if this file isn't under
30
+ * an app/ directory at all (a plain components/lib layout, or a Pages
31
+ * Router project — this module only understands App Router).
32
+ */
33
+ function appDirIndex(segments: string[]): number {
34
+ const i = segments.lastIndexOf("app");
35
+ return i === -1 ? -1 : i + 1;
36
+ }
37
+
38
+ /**
39
+ * Maps a single scanned file's path (relative to the project rootDir, as
40
+ * Finding.file already is) to the route it belongs to, or the shared
41
+ * bucket if it isn't a page/layout file under app/. Deliberately does
42
+ * NOT trace imports — a component imported by five different pages has
43
+ * no single "real" route, and guessing one would misattribute drift to
44
+ * routes that don't actually render it. Only page.tsx/layout.tsx (and
45
+ * their .ts/.jsx/.js variants) get a real route; everything else is
46
+ * "Shared (non-route files)".
47
+ */
48
+ export function fileToRoute(file: string): string {
49
+ const segments = file.split("/");
50
+ const filename = segments[segments.length - 1];
51
+ const dotIndex = filename.lastIndexOf(".");
52
+ if (dotIndex === -1) return SHARED_BUCKET;
53
+
54
+ const basename = filename.slice(0, dotIndex);
55
+ const ext = filename.slice(dotIndex);
56
+ if (!ROUTE_OWNING_BASENAMES.has(basename) || !APP_ROUTER_EXTENSIONS.has(ext)) {
57
+ return SHARED_BUCKET;
58
+ }
59
+
60
+ const startIndex = appDirIndex(segments);
61
+ if (startIndex === -1) return SHARED_BUCKET;
62
+
63
+ const routeSegments = segments.slice(startIndex, segments.length - 1);
64
+
65
+ if (basename === "layout") {
66
+ // A layout's identity is its position in the file tree, not the URL
67
+ // it happens to share with a route — app/layout.tsx and
68
+ // app/(auth)/layout.tsx both wrap "/", but they're different files
69
+ // wrapping different subtrees, so route groups are kept here (unlike
70
+ // segmentsToRoute's page-route stripping) to keep the two distinct.
71
+ const label = routeSegments.length === 0 ? "/" : "/" + routeSegments.join("/");
72
+ return `${label} (layout)`;
73
+ }
74
+
75
+ return segmentsToRoute(routeSegments);
76
+ }
77
+
78
+ export interface RouteGroup {
79
+ route: string;
80
+ findings: Finding[];
81
+ }
82
+
83
+ /**
84
+ * Groups findings by the route (or the shared bucket) their file maps
85
+ * to, via fileToRoute. Route order: real routes first (alphabetical),
86
+ * then the shared bucket last — shared files are real drift too, but a
87
+ * route-focused view should lead with actual pages.
88
+ */
89
+ export function groupByRoute(findings: Finding[]): RouteGroup[] {
90
+ const groups = new Map<string, Finding[]>();
91
+ for (const finding of findings) {
92
+ const route = fileToRoute(finding.file);
93
+ const existing = groups.get(route) ?? [];
94
+ existing.push(finding);
95
+ groups.set(route, existing);
96
+ }
97
+
98
+ const routes = [...groups.keys()].filter((r) => r !== SHARED_BUCKET).sort();
99
+ if (groups.has(SHARED_BUCKET)) routes.push(SHARED_BUCKET);
100
+
101
+ return routes.map((route) => ({ route, findings: groups.get(route)! }));
102
+ }
package/src/scan.ts CHANGED
@@ -1,77 +1,15 @@
1
1
  import { readFileSync, readdirSync } from "node:fs";
2
2
  import { join, relative } from "node:path";
3
+ import { extractFromContent, type RawUsage, type ScannedFile } from "./extract.js";
3
4
 
4
- export interface RawUsage {
5
- file: string;
6
- line: number;
7
- kind: "color" | "spacing" | "font-size" | "motion";
8
- rawValue: string;
9
- }
10
-
11
- const HEX_RE = /#([a-f0-9]{3}|[a-f0-9]{6})\b/gi;
12
- const FONT_SIZE_RE = /font-size:\s*([\d.]+(?:px|rem|em))/gi;
13
- const MOTION_DURATION_RE = /duration:\s*([\d.]+)/gi;
14
- const MOTION_EASE_RE = /ease:\s*['"]([\w-]+)['"]/gi;
15
-
16
- function lineNumberAt(content: string, index: number): number {
17
- return content.slice(0, index).split("\n").length;
18
- }
19
-
20
- /**
21
- * Extracts raw color/font-size/motion literals from a single blob of
22
- * content. Exported directly (not just via scanSource's filesystem walk)
23
- * so callers with in-memory content — e.g. fetched HTML/CSS from a public
24
- * URL, with no local checkout — can run the same extraction.
25
- */
26
- export function extractFromContent(file: string, content: string): RawUsage[] {
27
- const usages: RawUsage[] = [];
28
-
29
- for (const re of [HEX_RE]) {
30
- re.lastIndex = 0;
31
- let m: RegExpExecArray | null;
32
- while ((m = re.exec(content)) !== null) {
33
- usages.push({
34
- file,
35
- line: lineNumberAt(content, m.index),
36
- kind: "color",
37
- rawValue: m[0].toLowerCase(),
38
- });
39
- }
40
- }
41
-
42
- FONT_SIZE_RE.lastIndex = 0;
43
- let m: RegExpExecArray | null;
44
- while ((m = FONT_SIZE_RE.exec(content)) !== null) {
45
- usages.push({
46
- file,
47
- line: lineNumberAt(content, m.index),
48
- kind: "font-size",
49
- rawValue: m[1],
50
- });
51
- }
52
-
53
- MOTION_DURATION_RE.lastIndex = 0;
54
- while ((m = MOTION_DURATION_RE.exec(content)) !== null) {
55
- usages.push({
56
- file,
57
- line: lineNumberAt(content, m.index),
58
- kind: "motion",
59
- rawValue: m[1],
60
- });
61
- }
62
-
63
- MOTION_EASE_RE.lastIndex = 0;
64
- while ((m = MOTION_EASE_RE.exec(content)) !== null) {
65
- usages.push({
66
- file,
67
- line: lineNumberAt(content, m.index),
68
- kind: "motion",
69
- rawValue: m[1],
70
- });
71
- }
72
-
73
- return usages;
74
- }
5
+ // Deliberately NOT re-exporting RawUsage/ScannedFile/extractFromContent
6
+ // here — anything that needs those and must stay browser-safe (crawl.ts,
7
+ // diff.ts, or a future client component) should import them from
8
+ // ./extract.js directly, not through this file. This file's own
9
+ // node:fs/node:path imports are unconditional at module scope, so ANY
10
+ // import from scan.ts — even just for a re-exported browser-safe name —
11
+ // pulls those into a webpack client bundle and breaks it, exactly the
12
+ // bug this split exists to prevent.
75
13
 
76
14
  const SKIP_DIRS = new Set(["node_modules", ".next", ".git", ".turbo", "dist"]);
77
15
  const SCAN_EXTENSIONS = new Set([".ts", ".tsx", ".css"]);
@@ -88,11 +26,6 @@ function walkDir(dir: string, out: string[]): void {
88
26
  }
89
27
  }
90
28
 
91
- export interface ScannedFile {
92
- file: string; // relative to rootDir
93
- content: string;
94
- }
95
-
96
29
  /**
97
30
  * Walks .ts/.tsx/.css files under `rootDir` (skipping node_modules/.next)
98
31
  * and returns each file's relative path and content. Shared by the
package/src/stateCheck.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ScannedFile } from "./scan.js";
1
+ import type { ScannedFile } from "./extract.js";
2
2
  import type { Finding, StateContract } from "./types.js";
3
3
 
4
4
  // Heuristic identifiers that count as "this state is handled" when they