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 +47 -0
- package/package.json +13 -1
- package/src/cli.ts +45 -4
- package/src/client.ts +19 -0
- package/src/core.ts +3 -0
- package/src/crawl.ts +1 -1
- package/src/diff.ts +1 -1
- package/src/extract.ts +87 -0
- package/src/figmaVariables.ts +182 -0
- package/src/groundTruth.ts +37 -0
- package/src/routeMap.ts +102 -0
- package/src/scan.ts +9 -76
- package/src/stateCheck.ts +1 -1
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.
|
|
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 {
|
|
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
|
-
|
|
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 "./
|
|
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
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
|
+
}
|
package/src/groundTruth.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/routeMap.ts
ADDED
|
@@ -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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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