maddox-engine 0.3.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 +64 -1
- package/package.json +13 -1
- package/src/applyFixes.ts +116 -0
- package/src/cli.ts +67 -4
- package/src/client.ts +19 -0
- package/src/core.ts +4 -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,7 +24,9 @@ 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
|
|
29
|
+
- `--apply-fixes` — write near-miss color/font-size/spacing suggestions back into source files (see below); not available with `--url`
|
|
28
30
|
- `--format text|json|markdown` — output format (default: `text`)
|
|
29
31
|
- `--fail-below <0-100>` — exit non-zero if the drift health score falls below this threshold; omit to never fail
|
|
30
32
|
|
|
@@ -51,6 +53,19 @@ Ground truth is explicit — nothing is inferred about which states a component
|
|
|
51
53
|
|
|
52
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.
|
|
53
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
|
+
|
|
54
69
|
### Scanning a live page
|
|
55
70
|
|
|
56
71
|
```bash
|
|
@@ -68,12 +83,59 @@ Third-party stylesheets (a different origin than the page itself, e.g. a font CD
|
|
|
68
83
|
|
|
69
84
|
### Suggested fixes
|
|
70
85
|
|
|
71
|
-
Every `near-miss` finding with a resolved nearest token gets a `suggestion` — the concrete replacement text (`var(--token-name)` for a CSS value, or `motionTokens.path.to.value` for a motion token).
|
|
86
|
+
Every `near-miss` finding with a resolved nearest token gets a `suggestion` — the concrete replacement text (`var(--token-name)` for a CSS value, or `motionTokens.path.to.value` for a motion token). By default it's advisory text for you to apply yourself; pass `--apply-fixes` to write it back into the file. An `unrecognized` value with no close match, and a `missing`-state finding, never get a suggestion — there's no safe mechanical fix for either.
|
|
87
|
+
|
|
88
|
+
### Applying fixes automatically
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
npx maddox-engine ./src ./src/app/globals.css --apply-fixes
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`--apply-fixes` writes every `color`/`font-size`/`spacing` near-miss suggestion straight into the source file it was found in, replacing the exact literal with `var(--token-name)`. It's scoped deliberately narrow:
|
|
95
|
+
|
|
96
|
+
- **Motion suggestions are never applied.** `motionTokens.path.to.value` assumes an import named `motionTokens` exists in that file's scope — there's no way to verify that per-file, and applying it blind could silently break the build. Motion findings always stay advisory-only.
|
|
97
|
+
- **State findings never had a suggestion to apply.**
|
|
98
|
+
- If a finding's line no longer contains its reported value (the file changed since the scan ran), it's skipped rather than guessed at.
|
|
99
|
+
- Not available with `--url` — a fetched page has no local file to write to.
|
|
100
|
+
|
|
101
|
+
This is a real edit to your working tree, not a dry run — review the diff (`git diff`) before committing, same as you would any other automated change.
|
|
72
102
|
|
|
73
103
|
### Health score
|
|
74
104
|
|
|
75
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.
|
|
76
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
|
+
|
|
77
139
|
## Using in CI
|
|
78
140
|
|
|
79
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`.
|
|
@@ -95,6 +157,7 @@ Inputs:
|
|
|
95
157
|
- `motion-tokens` — path to a motion-tokens JSON file
|
|
96
158
|
- `states` — path to a state-contract JSON file. No effect when `url` is set.
|
|
97
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.
|
|
98
161
|
- `url` — a deployed page URL to scan instead of local source
|
|
99
162
|
- `github-token` *(required)* — for posting the PR comment, usually `${{ secrets.GITHUB_TOKEN }}`
|
|
100
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
|
},
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { Finding } from "./types.js";
|
|
4
|
+
|
|
5
|
+
// Only these kinds get a self-contained, always-valid replacement
|
|
6
|
+
// expression (var(--token-name), which needs no import and can't break
|
|
7
|
+
// compilation in any file it lands in). "motion" suggestions read
|
|
8
|
+
// motionTokens.path.to.value — text that assumes an import named
|
|
9
|
+
// motionTokens exists in scope, which suggestFix.ts has no way to verify
|
|
10
|
+
// per-file, so applying it mechanically could silently break the build.
|
|
11
|
+
// "state" findings never get a suggestion at all (see suggestFix.ts).
|
|
12
|
+
const AUTO_APPLIABLE_KINDS = new Set<Finding["kind"]>(["color", "font-size", "spacing"]);
|
|
13
|
+
|
|
14
|
+
export interface AppliedFix {
|
|
15
|
+
file: string;
|
|
16
|
+
line: number;
|
|
17
|
+
rawValue: string;
|
|
18
|
+
suggestion: string;
|
|
19
|
+
occurrences: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ApplyFixesResult {
|
|
23
|
+
applied: AppliedFix[];
|
|
24
|
+
// Findings that had a suggestion but were skipped — motion (unsafe
|
|
25
|
+
// import assumption) or a line whose current content no longer
|
|
26
|
+
// contains rawValue (the file changed since the scan ran; applying
|
|
27
|
+
// blind here risks touching the wrong text).
|
|
28
|
+
skipped: Finding[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Writes suggestFix's suggestions back into source files, for findings
|
|
33
|
+
* whose kind has a self-contained replacement expression. Never touches
|
|
34
|
+
* "motion" or "state" findings — see AUTO_APPLIABLE_KINDS.
|
|
35
|
+
*
|
|
36
|
+
* Applies per-file: reads once, replaces every exact-value occurrence
|
|
37
|
+
* on each finding's reported line (same literal repeated on one line —
|
|
38
|
+
* e.g. `color: #0d0d0d; border-color: #0d0d0d;` — is the same drift
|
|
39
|
+
* twice, both get fixed), writes once. A finding whose line no longer
|
|
40
|
+
* contains its rawValue (file changed since the scan that produced this
|
|
41
|
+
* Finding) is skipped rather than guessed at.
|
|
42
|
+
*
|
|
43
|
+
* rootDir is the same directory audit() was called with — Finding.file
|
|
44
|
+
* is relative to it, matching how scanSource reports paths.
|
|
45
|
+
*/
|
|
46
|
+
export function applyFixes(rootDir: string, findings: Finding[]): ApplyFixesResult {
|
|
47
|
+
const applied: AppliedFix[] = [];
|
|
48
|
+
const skipped: Finding[] = [];
|
|
49
|
+
|
|
50
|
+
const byFile = new Map<string, Finding[]>();
|
|
51
|
+
for (const finding of findings) {
|
|
52
|
+
if (!finding.suggestion || !AUTO_APPLIABLE_KINDS.has(finding.kind)) {
|
|
53
|
+
if (finding.suggestion) skipped.push(finding);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const existing = byFile.get(finding.file) ?? [];
|
|
57
|
+
existing.push(finding);
|
|
58
|
+
byFile.set(finding.file, existing);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (const [file, fileFindings] of byFile) {
|
|
62
|
+
const fullPath = join(rootDir, file);
|
|
63
|
+
let content: string;
|
|
64
|
+
try {
|
|
65
|
+
content = readFileSync(fullPath, "utf-8");
|
|
66
|
+
} catch {
|
|
67
|
+
skipped.push(...fileFindings);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const lines = content.split("\n");
|
|
72
|
+
let changed = false;
|
|
73
|
+
|
|
74
|
+
// Dedupe by (line, rawValue): the replace below already handles every
|
|
75
|
+
// occurrence of a value on its line in one pass, so if the scan
|
|
76
|
+
// reported the same value on the same line more than once (e.g. two
|
|
77
|
+
// separate regex matches for one literal repeated in a line), only
|
|
78
|
+
// the first needs to actually run the replacement — without this, a
|
|
79
|
+
// second finding for an already-fixed line would find nothing left to
|
|
80
|
+
// replace and land in `skipped`, misreporting an applied fix as one
|
|
81
|
+
// that failed.
|
|
82
|
+
const seen = new Set<string>();
|
|
83
|
+
const uniqueFindings = fileFindings.filter((f) => {
|
|
84
|
+
const key = `${f.line}:${f.rawValue}`;
|
|
85
|
+
if (seen.has(key)) return false;
|
|
86
|
+
seen.add(key);
|
|
87
|
+
return true;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
for (const finding of uniqueFindings) {
|
|
91
|
+
const lineIndex = finding.line - 1;
|
|
92
|
+
const line = lines[lineIndex];
|
|
93
|
+
if (line === undefined || !line.includes(finding.rawValue)) {
|
|
94
|
+
skipped.push(finding);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const occurrences = line.split(finding.rawValue).length - 1;
|
|
99
|
+
lines[lineIndex] = line.split(finding.rawValue).join(finding.suggestion!);
|
|
100
|
+
changed = true;
|
|
101
|
+
applied.push({
|
|
102
|
+
file,
|
|
103
|
+
line: finding.line,
|
|
104
|
+
rawValue: finding.rawValue,
|
|
105
|
+
suggestion: finding.suggestion!,
|
|
106
|
+
occurrences,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (changed) {
|
|
111
|
+
writeFileSync(fullPath, lines.join("\n"));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { applied, skipped };
|
|
116
|
+
}
|
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,10 +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>]\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
|
-
"--states has no effect in --url mode: state-completeness is a source-code check
|
|
59
|
+
"--states has no effect in --url mode: state-completeness is a source-code check.\n" +
|
|
60
|
+
" --apply-fixes writes near-miss color/font-size/spacing suggestions back into " +
|
|
61
|
+
"source files (color/font-size/spacing only — motion and state findings are " +
|
|
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."
|
|
51
67
|
);
|
|
52
68
|
process.exit(1);
|
|
53
69
|
}
|
|
@@ -68,11 +84,58 @@ async function main() {
|
|
|
68
84
|
const stateContract = statesPath ? loadStateContract(statesPath) : undefined;
|
|
69
85
|
|
|
70
86
|
const tokensStudioPath = flagValue("--tokens-studio");
|
|
71
|
-
|
|
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
|
+
}
|
|
72
116
|
|
|
73
117
|
const result = url ? await auditUrl(url, groundTruth) : await audit(targetDir, groundTruth, stateContract);
|
|
74
118
|
const summary = summarize(result.findings, result.filesScanned);
|
|
75
119
|
|
|
120
|
+
const applyFixesFlag = process.argv.includes("--apply-fixes");
|
|
121
|
+
if (applyFixesFlag) {
|
|
122
|
+
if (url) {
|
|
123
|
+
console.error("\n--apply-fixes has no effect with --url: there is no local source to write to.");
|
|
124
|
+
} else {
|
|
125
|
+
const { applied, skipped } = applyFixes(targetDir, result.findings);
|
|
126
|
+
if (applied.length > 0) {
|
|
127
|
+
console.error(`\nApplied ${applied.length} fix${applied.length === 1 ? "" : "es"}:`);
|
|
128
|
+
for (const fix of applied) {
|
|
129
|
+
console.error(` ${fix.file}:${fix.line} ${fix.rawValue} → ${fix.suggestion}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const motionOrStateSkipped = skipped.filter((f) => f.kind === "motion" || f.kind === "state").length;
|
|
133
|
+
if (motionOrStateSkipped > 0) {
|
|
134
|
+
console.error(`\n${motionOrStateSkipped} motion/state finding${motionOrStateSkipped === 1 ? "" : "s"} skipped — not auto-applied (see usage).`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
76
139
|
if (format === "json") {
|
|
77
140
|
console.log(toJson(summary));
|
|
78
141
|
} else if (format === "markdown") {
|
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,12 +1,16 @@
|
|
|
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";
|
|
7
9
|
export * from "./stateCheck.js";
|
|
8
10
|
export * from "./healthScore.js";
|
|
9
11
|
export * from "./suggestFix.js";
|
|
12
|
+
export * from "./applyFixes.js";
|
|
13
|
+
export * from "./routeMap.js";
|
|
10
14
|
|
|
11
15
|
import { scanSource, scanFiles } from "./scan.js";
|
|
12
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