@agent-scope/cli 1.19.0 → 1.20.1
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 +59 -8
- package/dist/cli.js +2200 -436
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +1980 -245
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +33 -4
- package/dist/index.d.ts +33 -4
- package/dist/index.js +1982 -248
- package/dist/index.js.map +1 -1
- package/package.json +9 -8
package/dist/index.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { existsSync, writeFileSync, mkdirSync, readFileSync, statSync, appendFileSync, readdirSync, rmSync, createReadStream } from 'fs';
|
|
2
|
-
import { resolve, join,
|
|
1
|
+
import { existsSync, writeFileSync, mkdirSync, readFileSync, statSync, appendFileSync, readdirSync, rmSync, createReadStream, constants, watch } from 'fs';
|
|
2
|
+
import { resolve, join, dirname, extname } from 'path';
|
|
3
3
|
import { generateManifest } from '@agent-scope/manifest';
|
|
4
4
|
import { SpriteSheetGenerator, safeRender, BrowserPool, ALL_CONTEXT_IDS, contextAxis, stressAxis, ALL_STRESS_IDS, RenderMatrix, SatoriRenderer } from '@agent-scope/render';
|
|
5
5
|
import { TokenResolver, ComplianceEngine, parseTokenFileSync, ThemeResolver, exportTokens, validateTokenFile, TokenValidationError, TokenParseError, ImpactAnalyzer } from '@agent-scope/tokens';
|
|
6
6
|
import { Command } from 'commander';
|
|
7
7
|
import * as esbuild2 from 'esbuild';
|
|
8
8
|
import { createRequire } from 'module';
|
|
9
|
+
import { mkdir, access, writeFile } from 'fs/promises';
|
|
9
10
|
import * as readline from 'readline';
|
|
10
11
|
import { loadTrace, generateTest, getBrowserEntryScript } from '@agent-scope/playwright';
|
|
11
12
|
import { chromium } from 'playwright';
|
|
@@ -13,10 +14,15 @@ import { tmpdir } from 'os';
|
|
|
13
14
|
import { createServer } from 'http';
|
|
14
15
|
import { buildSite } from '@agent-scope/site';
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
18
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
19
|
+
}) : x)(function(x) {
|
|
20
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
21
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
22
|
+
});
|
|
23
|
+
async function buildComponentHarness(filePath, componentName, props, viewportWidth, projectCss, wrapperScript, screenshotPadding = 0) {
|
|
18
24
|
const bundledScript = await bundleComponentToIIFE(filePath, componentName, props);
|
|
19
|
-
return wrapInHtml(bundledScript, viewportWidth, projectCss, wrapperScript);
|
|
25
|
+
return wrapInHtml(bundledScript, viewportWidth, projectCss, wrapperScript, screenshotPadding);
|
|
20
26
|
}
|
|
21
27
|
async function bundleComponentToIIFE(filePath, componentName, props) {
|
|
22
28
|
const propsJson = JSON.stringify(props).replace(/<\/script>/gi, "<\\/script>");
|
|
@@ -122,7 +128,7 @@ ${msg}`);
|
|
|
122
128
|
}
|
|
123
129
|
return outputFile.text;
|
|
124
130
|
}
|
|
125
|
-
function wrapInHtml(bundledScript, viewportWidth, projectCss, wrapperScript) {
|
|
131
|
+
function wrapInHtml(bundledScript, viewportWidth, projectCss, wrapperScript, screenshotPadding = 0) {
|
|
126
132
|
const projectStyleBlock = projectCss != null && projectCss.length > 0 ? `<style id="scope-project-css">
|
|
127
133
|
${projectCss.replace(/<\/style>/gi, "<\\/style>")}
|
|
128
134
|
</style>` : "";
|
|
@@ -132,10 +138,17 @@ ${projectCss.replace(/<\/style>/gi, "<\\/style>")}
|
|
|
132
138
|
<head>
|
|
133
139
|
<meta charset="UTF-8" />
|
|
134
140
|
<meta name="viewport" content="width=${viewportWidth}, initial-scale=1.0" />
|
|
141
|
+
<script>
|
|
142
|
+
// Reset globals that persist on window across page.setContent() calls
|
|
143
|
+
// (document.open/write/close clears the DOM but NOT the JS global scope)
|
|
144
|
+
window.__SCOPE_WRAPPER__ = null;
|
|
145
|
+
window.__SCOPE_RENDER_COMPLETE__ = false;
|
|
146
|
+
window.__SCOPE_RENDER_ERROR__ = null;
|
|
147
|
+
</script>
|
|
135
148
|
<style>
|
|
136
149
|
*, *::before, *::after { box-sizing: border-box; }
|
|
137
150
|
html, body { margin: 0; padding: 0; background: #fff; font-family: system-ui, sans-serif; }
|
|
138
|
-
#scope-root { display: inline-block; min-width: 1px; min-height: 1px; }
|
|
151
|
+
#scope-root { display: inline-block; min-width: 1px; min-height: 1px; margin: ${screenshotPadding}px; }
|
|
139
152
|
</style>
|
|
140
153
|
${projectStyleBlock}
|
|
141
154
|
</head>
|
|
@@ -167,13 +180,15 @@ function buildTable(headers, rows) {
|
|
|
167
180
|
}
|
|
168
181
|
function formatListTable(rows) {
|
|
169
182
|
if (rows.length === 0) return "No components found.";
|
|
170
|
-
const headers = ["NAME", "FILE", "COMPLEXITY", "HOOKS", "CONTEXTS"];
|
|
183
|
+
const headers = ["NAME", "FILE", "COMPLEXITY", "HOOKS", "CONTEXTS", "COLLECTION", "INTERNAL"];
|
|
171
184
|
const tableRows = rows.map((r) => [
|
|
172
185
|
r.name,
|
|
173
186
|
r.file,
|
|
174
187
|
r.complexityClass,
|
|
175
188
|
String(r.hookCount),
|
|
176
|
-
String(r.contextCount)
|
|
189
|
+
String(r.contextCount),
|
|
190
|
+
r.collection ?? "\u2014",
|
|
191
|
+
r.internal ? "yes" : "no"
|
|
177
192
|
]);
|
|
178
193
|
return buildTable(headers, tableRows);
|
|
179
194
|
}
|
|
@@ -204,6 +219,8 @@ function formatGetTable(name, descriptor) {
|
|
|
204
219
|
` Composes: ${descriptor.composes.join(", ") || "none"}`,
|
|
205
220
|
` Composed By: ${descriptor.composedBy.join(", ") || "none"}`,
|
|
206
221
|
` Side Effects: ${formatSideEffects(descriptor.sideEffects)}`,
|
|
222
|
+
` Collection: ${descriptor.collection ?? "\u2014"}`,
|
|
223
|
+
` Internal: ${descriptor.internal}`,
|
|
207
224
|
"",
|
|
208
225
|
` Props (${propNames.length}):`
|
|
209
226
|
];
|
|
@@ -226,8 +243,16 @@ function formatGetJson(name, descriptor) {
|
|
|
226
243
|
}
|
|
227
244
|
function formatQueryTable(rows, queryDesc) {
|
|
228
245
|
if (rows.length === 0) return `No components match: ${queryDesc}`;
|
|
229
|
-
const headers = ["NAME", "FILE", "COMPLEXITY", "HOOKS", "CONTEXTS"];
|
|
230
|
-
const tableRows = rows.map((r) => [
|
|
246
|
+
const headers = ["NAME", "FILE", "COMPLEXITY", "HOOKS", "CONTEXTS", "COLLECTION", "INTERNAL"];
|
|
247
|
+
const tableRows = rows.map((r) => [
|
|
248
|
+
r.name,
|
|
249
|
+
r.file,
|
|
250
|
+
r.complexityClass,
|
|
251
|
+
r.hooks,
|
|
252
|
+
r.contexts,
|
|
253
|
+
r.collection ?? "\u2014",
|
|
254
|
+
r.internal ? "yes" : "no"
|
|
255
|
+
]);
|
|
231
256
|
return `Query: ${queryDesc}
|
|
232
257
|
|
|
233
258
|
${buildTable(headers, tableRows)}`;
|
|
@@ -505,22 +530,22 @@ async function getTailwindCompiler(cwd) {
|
|
|
505
530
|
from: entryPath,
|
|
506
531
|
loadStylesheet
|
|
507
532
|
});
|
|
508
|
-
const
|
|
509
|
-
compilerCache = { cwd, build:
|
|
510
|
-
return
|
|
533
|
+
const build4 = result.build.bind(result);
|
|
534
|
+
compilerCache = { cwd, build: build4 };
|
|
535
|
+
return build4;
|
|
511
536
|
}
|
|
512
537
|
async function getCompiledCssForClasses(cwd, classes) {
|
|
513
|
-
const
|
|
514
|
-
if (
|
|
538
|
+
const build4 = await getTailwindCompiler(cwd);
|
|
539
|
+
if (build4 === null) return null;
|
|
515
540
|
const deduped = [...new Set(classes)].filter(Boolean);
|
|
516
541
|
if (deduped.length === 0) return null;
|
|
517
|
-
return
|
|
542
|
+
return build4(deduped);
|
|
518
543
|
}
|
|
519
544
|
async function compileGlobalCssFile(cssFilePath, cwd) {
|
|
520
|
-
const { existsSync:
|
|
545
|
+
const { existsSync: existsSync18, readFileSync: readFileSync18 } = await import('fs');
|
|
521
546
|
const { createRequire: createRequire3 } = await import('module');
|
|
522
|
-
if (!
|
|
523
|
-
const raw =
|
|
547
|
+
if (!existsSync18(cssFilePath)) return null;
|
|
548
|
+
const raw = readFileSync18(cssFilePath, "utf-8");
|
|
524
549
|
const needsCompile = /@tailwind|@import\s+['"]tailwindcss/.test(raw);
|
|
525
550
|
if (!needsCompile) {
|
|
526
551
|
return raw;
|
|
@@ -603,8 +628,17 @@ async function shutdownPool() {
|
|
|
603
628
|
}
|
|
604
629
|
}
|
|
605
630
|
async function renderComponent(filePath, componentName, props, viewportWidth, viewportHeight) {
|
|
631
|
+
const PAD = 24;
|
|
606
632
|
const pool = await getPool(viewportWidth, viewportHeight);
|
|
607
|
-
const htmlHarness = await buildComponentHarness(
|
|
633
|
+
const htmlHarness = await buildComponentHarness(
|
|
634
|
+
filePath,
|
|
635
|
+
componentName,
|
|
636
|
+
props,
|
|
637
|
+
viewportWidth,
|
|
638
|
+
void 0,
|
|
639
|
+
void 0,
|
|
640
|
+
PAD
|
|
641
|
+
);
|
|
608
642
|
const slot = await pool.acquire();
|
|
609
643
|
const { page } = slot;
|
|
610
644
|
try {
|
|
@@ -644,7 +678,6 @@ async function renderComponent(filePath, componentName, props, viewportWidth, vi
|
|
|
644
678
|
`Component "${componentName}" rendered with zero bounding box \u2014 it may be invisible or not mounted`
|
|
645
679
|
);
|
|
646
680
|
}
|
|
647
|
-
const PAD = 24;
|
|
648
681
|
const MIN_W = 320;
|
|
649
682
|
const MIN_H = 200;
|
|
650
683
|
const clipX = Math.max(0, boundingBox.x - PAD);
|
|
@@ -946,7 +979,7 @@ function parseChecks(raw) {
|
|
|
946
979
|
}
|
|
947
980
|
function createCiCommand() {
|
|
948
981
|
return new Command("ci").description(
|
|
949
|
-
"Run
|
|
982
|
+
"Run the full Scope pipeline non-interactively and exit with a structured code.\n\nPIPELINE STEPS (in order):\n 1. manifest generate scan source, build .reactscope/manifest.json\n 2. render all screenshot every component\n 3. tokens compliance score on-token CSS coverage\n 4. visual regression pixel diff against --baseline (if provided)\n\nCHECKS (--checks flag, comma-separated):\n compliance token coverage below --threshold \u2192 exit 1\n a11y accessibility violations \u2192 exit 2\n console-errors console.error during render \u2192 exit 3\n visual-regression pixel diff against baseline \u2192 exit 4\n (render failures always \u2192 exit 5 regardless of --checks)\n\nEXIT CODES:\n 0 all checks passed\n 1 compliance below threshold\n 2 accessibility violations\n 3 console errors during render\n 4 visual regression detected\n 5 component render failures\n\nExamples:\n scope ci\n scope ci --baseline .reactscope/baseline --threshold 0.95\n scope ci --checks compliance,a11y --json -o ci-result.json\n scope ci --viewport 1280x720"
|
|
950
983
|
).option(
|
|
951
984
|
"-b, --baseline <dir>",
|
|
952
985
|
"Baseline directory for visual regression comparison (omit to skip)"
|
|
@@ -989,6 +1022,124 @@ function createCiCommand() {
|
|
|
989
1022
|
}
|
|
990
1023
|
);
|
|
991
1024
|
}
|
|
1025
|
+
var PLAYWRIGHT_BROWSER_HINTS = [
|
|
1026
|
+
"executable doesn't exist",
|
|
1027
|
+
"browserType.launch",
|
|
1028
|
+
"looks like playwright was just installed or updated",
|
|
1029
|
+
"please run the following command to download new browsers",
|
|
1030
|
+
"could not find chromium"
|
|
1031
|
+
];
|
|
1032
|
+
var MISSING_DEPENDENCY_HINTS = ["could not resolve", "cannot find module", "module not found"];
|
|
1033
|
+
var REQUIRED_HARNESS_DEPENDENCIES = ["react", "react-dom", "react/jsx-runtime"];
|
|
1034
|
+
function getEffectivePlaywrightBrowsersPath() {
|
|
1035
|
+
const value = process.env.PLAYWRIGHT_BROWSERS_PATH;
|
|
1036
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
1037
|
+
}
|
|
1038
|
+
function getPlaywrightBrowserRemediation(status) {
|
|
1039
|
+
const effectivePath = status?.effectiveBrowserPath ?? getEffectivePlaywrightBrowsersPath();
|
|
1040
|
+
if (effectivePath !== null) {
|
|
1041
|
+
const pathProblem = status?.browserPathExists === false ? "missing" : status?.browserPathWritable === false ? "unwritable" : "unavailable";
|
|
1042
|
+
return `PLAYWRIGHT_BROWSERS_PATH is set to ${effectivePath}, but that browser cache is ${pathProblem}. Unset PLAYWRIGHT_BROWSERS_PATH and run \`bunx playwright install chromium\`, or install and run with the same writable path: \`PLAYWRIGHT_BROWSERS_PATH=${effectivePath} bunx playwright install chromium\`.`;
|
|
1043
|
+
}
|
|
1044
|
+
return "Run `bunx playwright install chromium` in this sandbox, then retry the Scope command.";
|
|
1045
|
+
}
|
|
1046
|
+
function diagnoseScopeError(error, cwd = process.cwd()) {
|
|
1047
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1048
|
+
const normalized = message.toLowerCase();
|
|
1049
|
+
if (PLAYWRIGHT_BROWSER_HINTS.some((hint) => normalized.includes(hint))) {
|
|
1050
|
+
const browserPath = extractPlaywrightBrowserPath(message);
|
|
1051
|
+
const browserPathHint = browserPath === null ? "" : ` Scope tried to launch Chromium from ${browserPath}.`;
|
|
1052
|
+
return {
|
|
1053
|
+
code: "PLAYWRIGHT_BROWSERS_MISSING",
|
|
1054
|
+
message: "Playwright Chromium is unavailable for Scope browser rendering.",
|
|
1055
|
+
recovery: getPlaywrightBrowserRemediation() + browserPathHint + " Use `scope doctor --json` to verify the browser check passes before rerunning render/site/instrument."
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
if (MISSING_DEPENDENCY_HINTS.some((hint) => normalized.includes(hint))) {
|
|
1059
|
+
const packageManager = detectPackageManager(cwd);
|
|
1060
|
+
return {
|
|
1061
|
+
code: "TARGET_PROJECT_DEPENDENCIES_MISSING",
|
|
1062
|
+
message: "The target project's dependencies appear to be missing or incomplete.",
|
|
1063
|
+
recovery: `Run \`${packageManager} install\` in ${cwd}, then rerun \`scope doctor\` and retry the Scope command.`
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
return null;
|
|
1067
|
+
}
|
|
1068
|
+
function formatScopeDiagnostic(error, cwd = process.cwd()) {
|
|
1069
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1070
|
+
const diagnostic = diagnoseScopeError(error, cwd);
|
|
1071
|
+
if (diagnostic === null) return `Error: ${message}`;
|
|
1072
|
+
return `Error [${diagnostic.code}]: ${diagnostic.message}
|
|
1073
|
+
Recovery: ${diagnostic.recovery}
|
|
1074
|
+
Cause: ${message}`;
|
|
1075
|
+
}
|
|
1076
|
+
async function getPlaywrightBrowserStatus(cwd = process.cwd()) {
|
|
1077
|
+
const effectiveBrowserPath = getEffectivePlaywrightBrowsersPath();
|
|
1078
|
+
const executablePath = getPlaywrightChromiumExecutablePath(cwd);
|
|
1079
|
+
const available = executablePath !== null && existsSync(executablePath);
|
|
1080
|
+
const browserPathExists = effectiveBrowserPath === null ? null : existsSync(effectiveBrowserPath);
|
|
1081
|
+
const browserPathWritable = effectiveBrowserPath === null ? null : await isWritableBrowserPath(effectiveBrowserPath);
|
|
1082
|
+
return {
|
|
1083
|
+
effectiveBrowserPath,
|
|
1084
|
+
executablePath,
|
|
1085
|
+
available,
|
|
1086
|
+
browserPathExists,
|
|
1087
|
+
browserPathWritable,
|
|
1088
|
+
remediation: getPlaywrightBrowserRemediation({
|
|
1089
|
+
effectiveBrowserPath,
|
|
1090
|
+
browserPathExists,
|
|
1091
|
+
browserPathWritable
|
|
1092
|
+
})
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
function getPlaywrightChromiumExecutablePath(cwd = process.cwd()) {
|
|
1096
|
+
try {
|
|
1097
|
+
const packageJsonPath = __require.resolve("playwright/package.json", { paths: [cwd] });
|
|
1098
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|
1099
|
+
if (!packageJson.version) return null;
|
|
1100
|
+
const playwrightPath = __require.resolve("playwright", { paths: [cwd] });
|
|
1101
|
+
const { chromium: chromium5 } = __require(playwrightPath);
|
|
1102
|
+
const executablePath = chromium5?.executablePath?.();
|
|
1103
|
+
if (typeof executablePath !== "string" || executablePath.length === 0) return null;
|
|
1104
|
+
return executablePath;
|
|
1105
|
+
} catch {
|
|
1106
|
+
return null;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
async function isWritableBrowserPath(browserPath) {
|
|
1110
|
+
const candidate = existsSync(browserPath) ? browserPath : dirname(browserPath);
|
|
1111
|
+
try {
|
|
1112
|
+
await access(candidate, constants.W_OK);
|
|
1113
|
+
return true;
|
|
1114
|
+
} catch {
|
|
1115
|
+
return false;
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
function detectPackageManager(cwd = process.cwd()) {
|
|
1119
|
+
if (existsSync(join(cwd, "bun.lock")) || existsSync(join(cwd, "bun.lockb"))) return "bun";
|
|
1120
|
+
if (existsSync(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
1121
|
+
if (existsSync(join(cwd, "yarn.lock"))) return "yarn";
|
|
1122
|
+
return "npm";
|
|
1123
|
+
}
|
|
1124
|
+
function hasLikelyInstalledDependencies(cwd = process.cwd()) {
|
|
1125
|
+
return existsSync(join(cwd, "node_modules"));
|
|
1126
|
+
}
|
|
1127
|
+
function getMissingHarnessDependencies(cwd = process.cwd()) {
|
|
1128
|
+
return REQUIRED_HARNESS_DEPENDENCIES.filter((dependencyName) => {
|
|
1129
|
+
try {
|
|
1130
|
+
__require.resolve(dependencyName, { paths: [cwd] });
|
|
1131
|
+
return false;
|
|
1132
|
+
} catch {
|
|
1133
|
+
return true;
|
|
1134
|
+
}
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
function extractPlaywrightBrowserPath(message) {
|
|
1138
|
+
const match = message.match(/Executable doesn't exist at\s+([^\n]+)/i);
|
|
1139
|
+
return match?.[1]?.trim() ?? null;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// src/doctor-commands.ts
|
|
992
1143
|
function collectSourceFiles(dir) {
|
|
993
1144
|
if (!existsSync(dir)) return [];
|
|
994
1145
|
const results = [];
|
|
@@ -1002,13 +1153,43 @@ function collectSourceFiles(dir) {
|
|
|
1002
1153
|
}
|
|
1003
1154
|
return results;
|
|
1004
1155
|
}
|
|
1156
|
+
var TAILWIND_CONFIG_FILES = [
|
|
1157
|
+
"tailwind.config.js",
|
|
1158
|
+
"tailwind.config.cjs",
|
|
1159
|
+
"tailwind.config.mjs",
|
|
1160
|
+
"tailwind.config.ts",
|
|
1161
|
+
"postcss.config.js",
|
|
1162
|
+
"postcss.config.cjs",
|
|
1163
|
+
"postcss.config.mjs",
|
|
1164
|
+
"postcss.config.ts"
|
|
1165
|
+
];
|
|
1166
|
+
function hasTailwindSetup(cwd) {
|
|
1167
|
+
if (TAILWIND_CONFIG_FILES.some((file) => existsSync(resolve(cwd, file)))) {
|
|
1168
|
+
return true;
|
|
1169
|
+
}
|
|
1170
|
+
const packageJsonPath = resolve(cwd, "package.json");
|
|
1171
|
+
if (!existsSync(packageJsonPath)) return false;
|
|
1172
|
+
try {
|
|
1173
|
+
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|
1174
|
+
return [pkg.dependencies, pkg.devDependencies].some(
|
|
1175
|
+
(deps) => deps && Object.keys(deps).some(
|
|
1176
|
+
(name) => name === "tailwindcss" || name.startsWith("@tailwindcss/")
|
|
1177
|
+
)
|
|
1178
|
+
);
|
|
1179
|
+
} catch {
|
|
1180
|
+
return false;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
function getPlaywrightInstallCommand(effectiveBrowserPath) {
|
|
1184
|
+
return effectiveBrowserPath === null ? "bunx playwright install chromium" : `PLAYWRIGHT_BROWSERS_PATH=${effectiveBrowserPath} bunx playwright install chromium`;
|
|
1185
|
+
}
|
|
1005
1186
|
function checkConfig(cwd) {
|
|
1006
1187
|
const configPath = resolve(cwd, "reactscope.config.json");
|
|
1007
1188
|
if (!existsSync(configPath)) {
|
|
1008
1189
|
return {
|
|
1009
1190
|
name: "config",
|
|
1010
1191
|
status: "error",
|
|
1011
|
-
message: "reactscope.config.json not found \u2014 run `scope init`"
|
|
1192
|
+
message: "reactscope.config.json not found \u2014 run `scope init` in the target project root"
|
|
1012
1193
|
};
|
|
1013
1194
|
}
|
|
1014
1195
|
try {
|
|
@@ -1056,6 +1237,13 @@ function checkGlobalCss(cwd) {
|
|
|
1056
1237
|
}
|
|
1057
1238
|
}
|
|
1058
1239
|
if (globalCss.length === 0) {
|
|
1240
|
+
if (!hasTailwindSetup(cwd)) {
|
|
1241
|
+
return {
|
|
1242
|
+
name: "globalCSS",
|
|
1243
|
+
status: "ok",
|
|
1244
|
+
message: "No globalCSS configured \u2014 skipping CSS injection for this non-Tailwind project"
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1059
1247
|
return {
|
|
1060
1248
|
name: "globalCSS",
|
|
1061
1249
|
status: "warn",
|
|
@@ -1082,7 +1270,7 @@ function checkManifest(cwd) {
|
|
|
1082
1270
|
return {
|
|
1083
1271
|
name: "manifest",
|
|
1084
1272
|
status: "warn",
|
|
1085
|
-
message: "Manifest not found \u2014 run `scope manifest generate`"
|
|
1273
|
+
message: "Manifest not found \u2014 run `scope manifest generate` in the target project root"
|
|
1086
1274
|
};
|
|
1087
1275
|
}
|
|
1088
1276
|
const manifestMtime = statSync(manifestPath).mtimeMs;
|
|
@@ -1099,23 +1287,105 @@ function checkManifest(cwd) {
|
|
|
1099
1287
|
return { name: "manifest", status: "ok", message: "Manifest present and up to date" };
|
|
1100
1288
|
}
|
|
1101
1289
|
var ICONS = { ok: "\u2713", warn: "!", error: "\u2717" };
|
|
1290
|
+
function checkDependencies(cwd) {
|
|
1291
|
+
const packageManager = detectPackageManager(cwd);
|
|
1292
|
+
if (!hasLikelyInstalledDependencies(cwd)) {
|
|
1293
|
+
return {
|
|
1294
|
+
name: "dependencies",
|
|
1295
|
+
status: "error",
|
|
1296
|
+
remediationCode: "TARGET_PROJECT_DEPENDENCIES_MISSING",
|
|
1297
|
+
fixCommand: `${packageManager} install`,
|
|
1298
|
+
message: `node_modules not found \u2014 run \`${packageManager} install\` in ${cwd} before render/site/instrument`
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
const missingHarnessDependencies = getMissingHarnessDependencies(cwd);
|
|
1302
|
+
if (missingHarnessDependencies.length > 0) {
|
|
1303
|
+
return {
|
|
1304
|
+
name: "dependencies",
|
|
1305
|
+
status: "error",
|
|
1306
|
+
remediationCode: "TARGET_PROJECT_HARNESS_DEPENDENCIES_MISSING",
|
|
1307
|
+
fixCommand: `${packageManager} install`,
|
|
1308
|
+
message: `Missing React harness dependencies: ${missingHarnessDependencies.join(", ")}. Run \`${packageManager} install\` in ${cwd}, then retry render/site/instrument.`
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
return {
|
|
1312
|
+
name: "dependencies",
|
|
1313
|
+
status: "ok",
|
|
1314
|
+
message: "node_modules and React harness dependencies present"
|
|
1315
|
+
};
|
|
1316
|
+
}
|
|
1317
|
+
async function checkPlaywright(cwd) {
|
|
1318
|
+
const status = await getPlaywrightBrowserStatus(cwd);
|
|
1319
|
+
const pathDetails = status.effectiveBrowserPath === null ? "PLAYWRIGHT_BROWSERS_PATH is unset" : `PLAYWRIGHT_BROWSERS_PATH=${status.effectiveBrowserPath}; exists=${status.browserPathExists}; writable=${status.browserPathWritable}`;
|
|
1320
|
+
if (status.available) {
|
|
1321
|
+
return {
|
|
1322
|
+
name: "playwright",
|
|
1323
|
+
status: "ok",
|
|
1324
|
+
message: `Playwright package available (${pathDetails})`
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
return {
|
|
1328
|
+
name: "playwright",
|
|
1329
|
+
status: "error",
|
|
1330
|
+
remediationCode: "PLAYWRIGHT_BROWSERS_MISSING",
|
|
1331
|
+
fixCommand: getPlaywrightInstallCommand(status.effectiveBrowserPath),
|
|
1332
|
+
message: `Playwright Chromium unavailable (${pathDetails}) \u2014 ${status.remediation}`
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
function collectFixCommands(checks) {
|
|
1336
|
+
return checks.filter((check) => check.status === "error" && check.fixCommand !== void 0).map((check) => check.fixCommand).filter((command, index, commands) => commands.indexOf(command) === index);
|
|
1337
|
+
}
|
|
1102
1338
|
function formatCheck(check) {
|
|
1103
1339
|
return ` [${ICONS[check.status]}] ${check.name.padEnd(12)} ${check.message}`;
|
|
1104
1340
|
}
|
|
1105
1341
|
function createDoctorCommand() {
|
|
1106
|
-
return new Command("doctor").description(
|
|
1342
|
+
return new Command("doctor").description(
|
|
1343
|
+
`Verify your Scope project setup before running other commands.
|
|
1344
|
+
|
|
1345
|
+
CHECKS PERFORMED:
|
|
1346
|
+
config reactscope.config.json exists and is valid JSON
|
|
1347
|
+
tokens reactscope.tokens.json exists and passes validation
|
|
1348
|
+
css globalCSS files referenced in config actually exist
|
|
1349
|
+
manifest .reactscope/manifest.json exists and is not stale
|
|
1350
|
+
dependencies node_modules exists in the target project root
|
|
1351
|
+
playwright Playwright browser runtime is available
|
|
1352
|
+
(stale = source files modified after last generate)
|
|
1353
|
+
|
|
1354
|
+
STATUS LEVELS: ok | warn | error
|
|
1355
|
+
|
|
1356
|
+
Run this first whenever renders fail or produce unexpected output.
|
|
1357
|
+
|
|
1358
|
+
Examples:
|
|
1359
|
+
scope doctor
|
|
1360
|
+
scope doctor --json
|
|
1361
|
+
scope doctor --print-fix-commands
|
|
1362
|
+
scope doctor --json | jq '.checks[] | select(.status == "error")'`
|
|
1363
|
+
).option("--json", "Emit structured JSON output", false).option(
|
|
1364
|
+
"--print-fix-commands",
|
|
1365
|
+
"Print deduplicated shell remediation commands for failing checks",
|
|
1366
|
+
false
|
|
1367
|
+
).action(async (opts) => {
|
|
1107
1368
|
const cwd = process.cwd();
|
|
1108
1369
|
const checks = [
|
|
1109
1370
|
checkConfig(cwd),
|
|
1110
1371
|
checkTokens(cwd),
|
|
1111
1372
|
checkGlobalCss(cwd),
|
|
1112
|
-
checkManifest(cwd)
|
|
1373
|
+
checkManifest(cwd),
|
|
1374
|
+
checkDependencies(cwd),
|
|
1375
|
+
await checkPlaywright(cwd)
|
|
1113
1376
|
];
|
|
1114
1377
|
const errors = checks.filter((c) => c.status === "error").length;
|
|
1115
1378
|
const warnings = checks.filter((c) => c.status === "warn").length;
|
|
1379
|
+
const fixCommands = collectFixCommands(checks);
|
|
1380
|
+
if (opts.printFixCommands) {
|
|
1381
|
+
process.stdout.write(`${JSON.stringify({ cwd, fixCommands }, null, 2)}
|
|
1382
|
+
`);
|
|
1383
|
+
if (errors > 0) process.exit(1);
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1116
1386
|
if (opts.json) {
|
|
1117
1387
|
process.stdout.write(
|
|
1118
|
-
`${JSON.stringify({ passed: checks.length - errors - warnings, warnings, errors, checks }, null, 2)}
|
|
1388
|
+
`${JSON.stringify({ passed: checks.length - errors - warnings, warnings, errors, fixCommands, checks }, null, 2)}
|
|
1119
1389
|
`
|
|
1120
1390
|
);
|
|
1121
1391
|
if (errors > 0) process.exit(1);
|
|
@@ -1140,6 +1410,23 @@ function createDoctorCommand() {
|
|
|
1140
1410
|
}
|
|
1141
1411
|
});
|
|
1142
1412
|
}
|
|
1413
|
+
|
|
1414
|
+
// src/skill-content.ts
|
|
1415
|
+
var SKILL_CONTENT = '# Scope \u2014 Agent Skill\n\n## TLDR\nScope is a React codebase introspection toolkit. Use it to answer questions about component structure, props, context dependencies, side effects, and visual output \u2014 without running the app.\n\n**When to reach for it:** Any task requiring "which components use X", "what props does Y accept", "render Z for visual verification", "does this component depend on a provider", or "what design tokens are in use".\n\n**Canonical agent workflow:**\n```\nscope get-skill > /tmp/scope-skill.md # bootstrap command semantics into agent context\nscope init --yes # scaffold config + auto-generate manifest\nscope doctor --json # validate config, tokens, globalCSS, manifest freshness, deps, Playwright\nscope manifest query --context ThemeContext --format json\nscope render all --format json --output-dir .reactscope/renders\nscope site build --output .reactscope/site\n```\n\n---\n\n---\n\n## Mental Model\n\nUnderstanding how Scope\'s data flows is the key to using it effectively as an agent.\n\n```\nSource TypeScript files\n \u2193 (ts-morph AST parse)\n manifest.json \u2190 structural facts: props, hooks, contexts, complexity\n \u2193 (esbuild + Playwright)\n renders/*.json \u2190 visual facts: screenshot, computedStyles, dom, a11y\n \u2193 (token engine)\n compliance-styles.json \u2190 audit facts: which CSS values match tokens, which don\'t\n \u2193 (site generator)\n site/ \u2190 human-readable docs combining all of the above\n```\n\nEach layer depends on the previous. If you\'re getting unexpected results, check whether the earlier layers are stale (run `scope doctor` to diagnose).\n\n---\n\n## The Four Subsystems\n\n### 1. Manifest (`scope manifest *`)\nThe manifest is a static analysis snapshot of your TypeScript source. It tells you:\n- What components exist, where they live, and how they\'re exported\n- What props each component accepts (types, defaults, required/optional)\n- What React hooks they call (`detectedHooks`)\n- What contexts they consume (`requiredContexts`) \u2014 must be provided for a render to succeed\n- Whether they compose other components (`composes` / `composedBy`)\n- Their **complexity class** \u2014 `"simple"` or `"complex"` \u2014 which determines the render engine\n\nThe manifest never runs your code. It only reads TypeScript. This means it\'s fast and safe, but it can\'t know about runtime values.\n\n### 2. Render Engine (`scope render *`)\nThe render engine compiles components with esbuild and renders them in Chromium (Playwright). Two paths exist:\n\n| Path | When | Speed | Capability |\n|------|------|-------|------------|\n| **Satori** | `complexityClass: "simple"` | ~8ms | Flexbox only, no JS, no CSS-in-JS |\n| **BrowserPool** | `complexityClass: "complex"` | ~200\u2013800ms | Full DOM, CSS, Tailwind, animations |\n\nMost real-world components route through BrowserPool. Scope defaults to `"complex"` when uncertain (safe fallback).\n\nEach render produces:\n- `screenshot` \u2014 retina-quality PNG (2\xD7 `deviceScaleFactor`; display at CSS px dimensions)\n- `width` / `height` \u2014 CSS pixel dimensions of the component root\n- `computedStyles` \u2014 per-node computed CSS keyed by `#node-0`, `#node-1`, etc.\n- `dom` \u2014 full DOM tree with bounding boxes (BrowserPool only)\n- `accessibility` \u2014 role, aria-name, violation list (BrowserPool only)\n- `renderTimeMs` \u2014 wall-clock render duration\n\n### 3. Scope Files (`.scope.tsx` / `.scope.ts`)\nScope files define **named rendering scenarios** and optional provider wrappers next to a component. They are the primary way to ensure `render all` produces meaningful screenshots.\n\nDiscovery is deterministic: for `Button.tsx`, Scope checks `Button.scope.tsx`, then `.scope.ts`, `.scope.jsx`, `.scope.js` in the same directory and uses the first file that exists.\n\n```tsx\n// Button.scope.tsx\nexport const scenarios = {\n default: { variant: \'primary\', children: \'Click me\' },\n ghost: { variant: \'ghost\', children: \'Cancel\' },\n danger: { variant: \'danger\', children: \'Delete\' },\n disabled: { variant: \'primary\', children: \'Disabled\', disabled: true },\n} satisfies Record<string, Record<string, unknown>>;\n```\n\nOptional wrapper:\n\n```tsx\nimport type { ReactNode } from \'react\';\nimport { ThemeProvider } from \'../providers/ThemeProvider\';\n\nexport function wrapper({ children }: { children: ReactNode }) {\n return <ThemeProvider theme="dark">{children}</ThemeProvider>;\n}\n```\n\nContract:\n- Export `scenarios` as a plain object of scenario-name \u2192 props-object, either as a named export or under `default.scenarios`\n- Export `wrapper` as a function, either as a named export or under `default.wrapper`\n- Non-object scenario values are skipped with a warning\n- If no scope file or no valid scenarios exist, Scope falls back to one bare render and inferred required-prop defaults\n- If 2+ scenarios exist, `render all` automatically runs a matrix and merges cells into each component JSON\n\nWhen a component renders blank with `{}` props, **the fix is usually to create a `.scope.tsx` file** with real props.\n\n### 4. Token Compliance\nThe compliance pipeline:\n1. `scope render all` captures `computedStyles` for every element in every component\n2. These are written to `.reactscope/compliance-styles.json`\n3. The token engine compares each computed CSS value against your `reactscope.tokens.json`\n4. `scope tokens compliance` reports the aggregate on-system percentage\n5. `scope ci` fails if the percentage is below `complianceThreshold` (default 90%)\n\n**On-system** means the value exactly matches a resolved token value. Off-system means it\'s a hardcoded value with no token backing it.\n\n---\n\n## Complexity Classes \u2014 Practical Guide\n\nThe `complexityClass` field determines which render engine runs. Scope auto-detects it, but agents should understand it:\n\n**`"simple"` components:**\n- Pure presentational, flexbox layout only\n- No CSS grid, no absolute/fixed/sticky positioning\n- No CSS animations, transitions, or transforms\n- No `className` values Scope can\'t statically trace (e.g. dynamic Tailwind classes)\n- Renders in ~8ms via Satori (SVG-based, no browser needed)\n\n**`"complex"` components:**\n- Anything using Tailwind (CSS injection required)\n- CSS grid, positioned elements, overflow, z-index\n- Components that read from context at render time\n- Any component Scope isn\'t sure about (conservative default)\n- Renders in ~200\u2013800ms via Playwright BrowserPool\n\nWhen in doubt: complex is always safe. Simple is an optimization.\n\n---\n\n## Required Contexts \u2014 Why Renders Fail\n\nIf `requiredContexts` is non-empty, the component calls `useContext` on one or more contexts. Without a provider, it will either render broken or throw entirely.\n\nTwo ways to fix:\n1. **Provider presets in config** (recommended): add provider names to `reactscope.config.json \u2192 components.wrappers.providers`\n2. **Scope file with wrapper**: wrap the component in a provider in the scenario itself\n\nBuilt-in mocks (always provided): `ThemeContext \u2192 { theme: \'light\' }`, `LocaleContext \u2192 { locale: \'en-US\' }`.\n\n---\n\n## `scope doctor` \u2014 Always Run This First\n\nBefore debugging any render issue, run:\n```bash\nscope doctor --json\n```\n\nIt checks:\n- `reactscope.config.json` is valid JSON\n- Token file exists and has a `version` field\n- Every path in `globalCSS` resolves on disk\n- Manifest is present and up to date (not stale relative to source)\n- Target-project dependencies are installed (`node_modules` exists)\n- Playwright Chromium is available for render/site/instrument commands\n\n**If `globalCSS` is empty or missing**: Tailwind styles won\'t apply to renders. Every component will look unstyled. This is the most common footgun. Fix: add your CSS entry file (the one with `@tailwind base; @tailwind components; @tailwind utilities;`) to `globalCSS` in config.\n\n---\n\n## Agent Decision Tree\n\n**"I want to know what props Component X accepts"**\n\u2192 `scope manifest get X --format json | jq \'.props\'`\n\n**"I want to know which components will break if I change a context"**\n\u2192 `scope manifest query --context MyContext --format json`\n\n**"I want to render a component to verify visual output"**\n\u2192 Create a `.scope.tsx` file with real props first, then `scope render component X --format json`\n\n**"I want to render all variants of a component"**\n\u2192 Define all variants in `.scope.tsx`, then `scope render all --format json --output-dir .reactscope/renders` (auto-matrix)\n\u2192 Or: `scope render matrix X --axes \'variant:primary,secondary,danger\' --format json`\n\n**"I want to audit token compliance"**\n\u2192 `scope render all` first (populates computedStyles), then `scope tokens compliance`\n\n**"Renders look unstyled / blank"**\n\u2192 Run `scope doctor` \u2014 likely missing `globalCSS`\n\u2192 If props are the issue: create/update the `.scope.tsx` file\n\n**"I want to understand blast radius of a token change"**\n\u2192 `scope tokens impact color.primary.500 --new-value \'#0077dd\'`\n\u2192 `scope tokens preview color.primary.500 --new-value \'#0077dd\'` for visual diff\n\n**"I need to set up Scope in a new project"**\n\u2192 `scope init --yes` (auto-detects Tailwind + CSS, generates manifest automatically)\n\u2192 `scope doctor` to validate\n\u2192 Create `.scope.tsx` files for key components\n\u2192 `scope render all`\n\n**"I want to profile a live SPA"**\n\u2192 Generate a manifest, then `scope instrument profile SearchPage --interaction \'[{"action":"click","target":"button"}]\'`\n\u2192 For auth/timing-heavy apps, follow `docs/profiling-production-spas.md`\n\n**"I want to run Scope in CI"**\n\u2192 `scope ci --json --output ci-result.json`\n\u2192 Exit code 0 = pass, non-zero = specific failure type\n\n---\n\n\n## Installation\n\nPublished package:\n\n```bash\nnpm install -g @agent-scope/cli # global\nnpm install --save-dev @agent-scope/cli # per-project\n```\n\nLocal-from-source quickstart for this repo:\n\n```bash\nbun install\nbun run build\nbunx playwright install chromium\ncd fixtures/tailwind-showcase\nbun install\n../../packages/cli/dist/cli.js init --yes\n../../packages/cli/dist/cli.js doctor --json\n../../packages/cli/dist/cli.js render all --format json --output-dir .reactscope/renders\n../../packages/cli/dist/cli.js site build --output .reactscope/site\n```\n\nBinary: `scope` (published install) or `packages/cli/dist/cli.js` (local build)\n\n---\n\n## Core Workflow\n\n```\nget-skill \u2192 init --yes \u2192 doctor --json \u2192 manifest query/get/list --format json \u2192 render all --format json \u2192 site build \u2192 instrument profile \u2192 ci\n```\n\n- **init**: Scaffold `reactscope.config.json` + token stub, auto-detect framework/globalCSS, **immediately runs `manifest generate`** so you see results right away.\n- **doctor**: Health-check command \u2014 validates config, token file, globalCSS presence, manifest staleness, target-project dependencies, and Playwright Chromium availability.\n- **generate**: Parse TypeScript AST and emit `.reactscope/manifest.json`. Run once per codebase change (or automatically via `scope init`).\n- **query / get / list**: Ask structural questions. No network required. Works from manifest alone. Supports filtering by `--collection` and `--internal`.\n- **render**: Produce PNGs of components via esbuild + Playwright (BrowserPool). Requires manifest for file paths. Auto-injects required prop defaults and globalCSS.\n- **token audit**: Validate design tokens via `@scope/tokens` CLI commands (`tokens list`, `tokens compliance`, `tokens impact`, `tokens preview`, `tokens export`).\n- **ci**: Run compliance checks and exit with code 0/1 for CI pipelines. `report pr-comment` posts results to GitHub PRs.\n\n---\n\n## Full CLI Reference\n\n### `scope init`\nScaffold config, detect framework, extract Tailwind tokens, detect globalCSS files, and **automatically run `scope manifest generate`**.\n\n```bash\nscope init\nscope init --force # overwrite existing config\n```\n\nAfter init completes, the manifest is already written \u2014 no manual `scope manifest generate` step needed.\n\n**Tailwind token extraction**: reads `tailwind.config.js`, extracts colors (with nested scale support), spacing, fontFamily, borderRadius. Stored in `reactscope.tokens.json`.\n\n**globalCSS detection**: checks 9 common patterns (`src/styles.css`, `src/index.css`, `app/globals.css`, etc.). Stored in `components.wrappers.globalCSS` in config.\n\n---\n\n### `scope doctor`\nValidate the Scope setup. Exits non-zero on errors, zero on warnings-only.\n\n```bash\nscope doctor --json\nscope doctor --json\n```\n\nChecks:\n- `config` \u2014 `reactscope.config.json` is valid JSON with required fields\n- `tokens` \u2014 token file is present and has a valid `version` field\n- `globalCSS` \u2014 globalCSS files listed in config exist on disk\n- `manifest` \u2014 manifest exists and is not stale (compares source file mtimes)\n- `dependencies` \u2014 `node_modules` exists in the target project root\n- `playwright` \u2014 Playwright Chromium is available before render/site/instrument\n\nIf `dependencies` fails, run `bun install` (or the package manager detected by your lockfile) in the target project root, then rerun `scope doctor --json`.\n\nIf `playwright` fails, run `bunx playwright install chromium`, then rerun `scope doctor --json` before retrying `scope render component`, `scope render all`, `scope site build`, or `scope instrument ...`.\n\n```\n$ scope doctor --json\nScope Doctor\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n [\u2713] config reactscope.config.json valid\n [\u2713] tokens Token file valid\n [\u2713] globalCSS 1 globalCSS file(s) present\n [!] manifest Manifest may be stale \u2014 5 source file(s) modified since last generate\n\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n 1 warning(s) \u2014 everything works but could be better\n```\n\n---\n\n### `scope capture <url>`\nCapture a live React component tree from a running app URL.\n\n```bash\nscope capture http://localhost:3000\nscope capture http://localhost:3000 --output report.json --pretty\nscope capture http://localhost:3000 --timeout 15000 --wait 2000\n```\n\nFlags:\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `-o, --output <path>` | string | stdout | Write JSON to file |\n| `--pretty` | bool | false | Pretty-print JSON |\n| `--timeout <ms>` | number | 10000 | Max wait for React to mount |\n| `--wait <ms>` | number | 0 | Additional wait after page load |\n\nOutput (stdout): serialized PageReport JSON (or path when `--output` is set)\n\n---\n\n### `scope tree <url>`\nPrint the React component tree from a live URL.\n\n```bash\nscope tree http://localhost:3000\nscope tree http://localhost:3000 --depth 3 --show-props --show-hooks\n```\n\nFlags:\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--depth <n>` | number | unlimited | Max depth to display |\n| `--show-props` | bool | false | Include prop names next to components |\n| `--show-hooks` | bool | false | Show hook counts per component |\n| `--timeout <ms>` | number | 10000 | Max wait for React to mount |\n| `--wait <ms>` | number | 0 | Additional wait after page load |\n\n---\n\n### `scope report <url>`\nCapture and print a human-readable summary of a React app.\n\n```bash\nscope report http://localhost:3000\nscope report http://localhost:3000 --json\n```\n\nFlags:\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--json` | bool | false | Emit structured JSON instead of text |\n| `--timeout <ms>` | number | 10000 | Max wait for React to mount |\n| `--wait <ms>` | number | 0 | Additional wait after page load |\n\n---\n\n### `scope report baseline`\nSave a baseline snapshot for future diff comparisons.\n\n```bash\nscope report baseline\nscope report baseline --output baselines/my-baseline.json\n```\n\n---\n\n### `scope report diff`\nDiff the current app state against a saved baseline.\n\n```bash\nscope report diff\nscope report diff --baseline baselines/my-baseline.json\nscope report diff --json\n```\n\n---\n\n### `scope report pr-comment`\nPost a Scope CI report as a GitHub PR comment. Used in CI pipelines via the reusable `scope-ci` workflow.\n\n```bash\nscope report pr-comment --report-path scope-ci-report.json\n```\n\nRequires `GITHUB_TOKEN`, `GITHUB_REPOSITORY`, and `GITHUB_PR_NUMBER` in environment.\n\n---\n\n### `scope manifest generate`\nScan source files and write `.reactscope/manifest.json`.\n\n```bash\nscope manifest generate\nscope manifest generate --root ./packages/ui\nscope manifest generate --include "src/**/*.tsx" --exclude "**/*.test.tsx"\nscope manifest generate --output custom/manifest.json\n```\n\nFlags:\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--root <path>` | string | cwd | Project root directory |\n| `--output <path>` | string | `.reactscope/manifest.json` | Output path |\n| `--include <globs>` | string | `src/**/*.tsx,src/**/*.ts` | Comma-separated include globs |\n| `--exclude <globs>` | string | `**/node_modules/**,...` | Comma-separated exclude globs |\n\n---\n\n### `scope manifest list`\nList all components in the manifest.\n\n```bash\nscope manifest list\nscope manifest list --filter "Button*"\nscope manifest list --format json\nscope manifest list --collection Forms # filter to named collection\nscope manifest list --internal # only internal components\nscope manifest list --no-internal # hide internal components\n```\n\nFlags:\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--format <fmt>` | `json\\|table` | auto (TTY\u2192table, pipe\u2192json) | Output format |\n| `--filter <glob>` | string | \u2014 | Filter component names by glob |\n| `--collection <name>` | string | \u2014 | Filter to named collection |\n| `--internal` | bool | false | Show only internal components |\n| `--no-internal` | bool | false | Hide internal components |\n| `--manifest <path>` | string | `.reactscope/manifest.json` | Manifest path |\n\nTTY table output (includes COLLECTION and INTERNAL columns):\n```\nNAME FILE COMPLEXITY HOOKS CONTEXTS COLLECTION INTERNAL\n------------ --------------------------- ---------- ----- -------- ---------- --------\nButton src/components/Button.tsx simple 1 0 \u2014 no\nThemeToggle src/components/Toggle.tsx complex 3 1 Forms no\n```\n\n---\n\n### `scope manifest get <name>`\nGet full details of a single component.\n\n```bash\nscope manifest get Button\nscope manifest get Button --format json\n```\n\nJSON output includes `collection` and `internal` fields:\n```json\n{\n "name": "Button",\n "filePath": "src/components/Button.tsx",\n "collection": "Primitives",\n "internal": false,\n ...\n}\n```\n\n---\n\n### `scope manifest query`\nQuery components by attributes.\n\n```bash\nscope manifest query --context ThemeContext\nscope manifest query --hook useEffect\nscope manifest query --complexity complex\nscope manifest query --side-effects\nscope manifest query --has-fetch\nscope manifest query --has-prop <propName>\nscope manifest query --composed-by <ComponentName>\nscope manifest query --internal\nscope manifest query --collection Forms\nscope manifest query --context ThemeContext --format json\n```\n\nFlags:\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--context <name>` | string | \u2014 | Find components consuming a context by name |\n| `--hook <name>` | string | \u2014 | Find components using a specific hook |\n| `--complexity <class>` | `simple\\|complex` | \u2014 | Filter by complexity class |\n| `--side-effects` | bool | false | Any side effects detected |\n| `--has-fetch` | bool | false | Components with fetch calls specifically |\n| `--has-prop <name>` | string | \u2014 | Components that accept a specific prop |\n| `--composed-by <name>` | string | \u2014 | Components rendered inside a specific parent |\n| `--internal` | bool | false | Only internal components |\n| `--collection <name>` | string | \u2014 | Filter to named collection |\n| `--format <fmt>` | `json\\|table` | auto | Output format |\n| `--manifest <path>` | string | `.reactscope/manifest.json` | Manifest path |\n\n---\n\n### `scope render <component>`\nRender a single component to PNG (TTY) or JSON (pipe).\n\n**Auto prop defaults**: if `--props` is omitted, Scope injects sensible defaults so required props don\'t produce blank renders: strings/nodes \u2192 component name, unions \u2192 first value, booleans \u2192 `false`, numbers \u2192 `0`.\n\n**globalCSS auto-injection**: reads `components.wrappers.globalCSS` from config and compiles/injects CSS (supports Tailwind v3 via PostCSS) into the render harness. A warning is printed to stderr if no globalCSS is configured (common cause of unstyled renders).\n\n```bash\nscope render component Button\nscope render component Button --props \'{"variant":"primary","children":"Click me"}\'\nscope render component Button --viewport 375x812\nscope render component Button --output button.png\nscope render component Button --format json\n```\n\nFlags:\n| Flag | Type | Default | Description |\n|------|------|---------|-------------|\n| `--props <json>` | string | `{}` | Inline props as JSON string |\n| `--viewport <WxH>` | string | `375x812` | Viewport size |\n| `--theme <name>` | string | \u2014 | Theme name from token system |\n| `-o, --output <path>` | string | \u2014 | Write PNG to specific path |\n| `--format <fmt>` | `png\\|json` | auto (TTY\u2192file, pipe\u2192json) | Output format |\n| `--manifest <path>` | string | `.reactscope/manifest.json` | Manifest path |\n\n---\n\n### `scope render matrix <component>`\nRender across a Cartesian product of prop axes. Accepts both `key:v1,v2` and `{"key":["v1","v2"]}` JSON format for `--axes`.\n\n```bash\nscope render matrix Button --axes \'variant:primary,secondary,danger\'\nscope render matrix Button --axes \'{"variant":["primary","secondary"]}\'\nscope render matrix Button --axes \'variant:primary,secondary size:sm,md,lg\'\nscope render matrix Button --sprite button-matrix.png --format json\n```\n\n---\n\n### `scope render all`\nRender every component in the manifest.\n\n```bash\nscope render all\nscope render all --concurrency 4 --output-dir renders/\n```\n\nHandles imports of CSS files in components (maps to empty loader so styles are injected at page level). SVG and font imports are handled via dataurl loaders.\n\n---\n\n### `scope instrument tree`\nCapture the live React component tree with instrumentation metadata.\n\n```bash\nscope instrument tree http://localhost:3000\nscope instrument tree http://localhost:3000 --depth 5 --show-props\n```\n\n**Implementation note**: uses a fresh `chromium.launch()` + `newContext()` + `newPage()` per call (not BrowserPool), with `addInitScript` called before `setContent` to ensure the Scope runtime is injected at document-start before React loads.\n\n---\n\n### `scope instrument hooks`\nProfile hook execution in live components.\n\n```bash\nscope instrument hooks http://localhost:3000\nscope instrument hooks http://localhost:3000 --component Button\n```\n\n**Implementation note**: requires `addInitScript({ content: getBrowserEntryScript() })` before `setContent` so `__REACT_DEVTOOLS_GLOBAL_HOOK__` is present when React loads its renderer.\n\n---\n\n### `scope instrument profile`\nProfile render performance of live components.\n\n```bash\nscope instrument profile SearchPage --interaction \'[{"action":"click","target":"button"}]\'\n```\n\n---\n\n### `scope instrument renders`\nRe-render causality analysis \u2014 what triggered each render.\n\n```bash\nscope instrument renders http://localhost:3000\n```\n\n---\n\n### `scope tokens get <name>`\nGet details of a single design token.\n\n### `scope tokens list`\nList all tokens. Token file must have a `version` field (written by `scope init`).\n\n```bash\nscope tokens list\nscope tokens list --type color\nscope tokens list --format json\n```\n\n### `scope tokens search <query>`\nFull-text search across token names/values.\n\n### `scope tokens resolve <value>`\nResolve a CSS value or alias back to its token name.\n\n### `scope tokens validate`\nValidate token file schema.\n\n### `scope tokens compliance`\nCheck rendered components for design token compliance.\n\n```bash\nscope tokens compliance\nscope tokens compliance --threshold 95\n```\n\n### `scope tokens impact <token>`\nAnalyze impact of changing a token \u2014 which components use it.\n\n```bash\nscope tokens impact --token color.primary.500\n```\n\n### `scope tokens preview <token>`\nPreview a token value change visually before committing.\n\n### `scope tokens export`\nExport tokens in multiple formats.\n\n```bash\nscope tokens export --format flat-json\nscope tokens export --format css\nscope tokens export --format scss\nscope tokens export --format ts\nscope tokens export --format tailwind\nscope tokens export --format style-dictionary\n```\n\n**Format aliases** (auto-corrected with "Did you mean?" hint):\n- `json` \u2192 `flat-json`\n- `js` \u2192 `ts`\n- `sass` \u2192 `scss`\n- `tw` \u2192 `tailwind`\n\n---\n\n### `scope ci`\nRun all CI checks (compliance, accessibility, console errors) and exit 0/1.\n\n```bash\nscope ci\nscope ci --json\nscope ci --threshold 90 # compliance threshold (default: 90)\n```\n\n```\n$ scope ci --json\n\u2192 CI passed in 3.2s\n\u2192 Compliance 100.0% >= threshold 90.0% \u2705\n\u2192 Accessibility audit not yet implemented \u2014 skipped \u2705\n\u2192 No console errors detected \u2705\n\u2192 Exit code 0\n```\n\nThe `scope-ci` **reusable GitHub Actions workflow** is available at `.github/workflows/scope-ci.yml` and can be included in any repo\'s CI to run `scope ci` and post results as a PR comment via `scope report pr-comment`.\n\n---\n\n### `scope site build`\nGenerate a static HTML component gallery site from the manifest.\n\n```bash\nscope site build\nscope site build --output ./dist/site\n```\n\n**Collections support**: components are grouped under named collection sections in the sidebar and index grid. Internal components are hidden from the sidebar and card grid but appear in composition detail sections with an `internal` badge.\n\n**Collection display rules**:\n- Sidebar: one section divider per collection + an "Ungrouped" section; internal components excluded\n- Index page: named sections with heading + optional description; internal components excluded\n- Component detail page: Composes/Composed By lists ALL components including internal ones (with subtle badge)\n- Falls back to flat list when no collections configured (backwards-compatible)\n\n### `scope site serve`\nServe the generated site locally.\n\n```bash\nscope site serve\nscope site serve --port 4000\n```\n\n---\n\n## Collections & Internal Components\n\nComponents can be organized into named **collections** and flagged as **internal** (library implementation details not shown in the public gallery).\n\n### Defining collections\n\n**1. TSDoc tag** (highest precedence):\n```tsx\n/**\n * @collection Forms\n */\nexport function Input() { ... }\n```\n\n**2. `.scope.ts` co-located file**:\n```ts\n// Input.scope.ts\nexport const collection = "Forms"\n```\n\n**3. Config-level glob patterns**:\n```json\n// reactscope.config.json\n{\n "collections": [\n { "name": "Forms", "description": "Form inputs and controls", "patterns": ["src/forms/**"] },\n { "name": "Primitives", "patterns": ["src/primitives/**"] }\n ]\n}\n```\n\nResolution precedence: TSDoc `@collection` > `.scope.ts` export > config pattern.\n\n### Flagging internal components\n\n**TSDoc tag**:\n```tsx\n/**\n * @internal\n */\nexport function InternalHelperButton() { ... }\n```\n\n**Config glob patterns**:\n```json\n{\n "internalPatterns": ["src/internal/**", "src/**/*Internal*"]\n}\n```\n\n---\n\n## Manifest Output Schema\n\nFile: `.reactscope/manifest.json`\n\n```typescript\n{\n version: "0.1",\n generatedAt: string, // ISO 8601\n collections: CollectionConfig[], // echoes config.collections, [] when not set\n components: Record<string, ComponentDescriptor>,\n tree: Record<string, { children: string[], parents: string[] }>\n}\n```\n\n### `ComponentDescriptor` fields\n\n| Field | Type | Description |\n|-------|------|-------------|\n| `filePath` | `string` | Relative path from project root to source file |\n| `exportType` | `"named" \\| "default" \\| "none"` | How the component is exported |\n| `displayName` | `string` | `displayName` if set, else function/class name |\n| `collection` | `string?` | Resolved collection name (`undefined` = ungrouped) |\n| `internal` | `boolean` | `true` if flagged as internal (default: `false`) |\n| `props` | `Record<string, PropDescriptor>` | Extracted prop types keyed by prop name |\n| `composes` | `string[]` | Components this one renders in its JSX |\n| `composedBy` | `string[]` | Components that render this one in their JSX |\n| `complexityClass` | `"simple" \\| "complex"` | Render path: simple = Satori-safe, complex = requires BrowserPool |\n| `requiredContexts` | `string[]` | React context names consumed |\n| `detectedHooks` | `string[]` | All hooks called, sorted alphabetically |\n| `sideEffects` | `SideEffects` | Side effect categories detected |\n| `memoized` | `boolean` | Wrapped with `React.memo` |\n| `forwardedRef` | `boolean` | Wrapped with `React.forwardRef` |\n| `hocWrappers` | `string[]` | HOC wrapper names (excluding memo/forwardRef) |\n| `loc` | `{ start: number, end: number }` | Line numbers in source file |\n\n---\n\n## Common Agent Workflows\n\n### Structural queries\n\n```bash\n# Which components use ThemeContext?\nscope manifest query --context ThemeContext\n\n# What props does Button accept?\nscope manifest get Button --format json | jq \'.props\'\n\n# Which components are safe to render without a provider?\nscope manifest query --complexity simple # + check requiredContexts === []\n\n# Show all components with side effects\nscope manifest query --side-effects\n\n# Which components make fetch calls?\nscope manifest query --has-fetch\n\n# Which components use useEffect?\nscope manifest query --hook useEffect\n\n# Which components accept a disabled prop?\nscope manifest query --has-prop disabled\n\n# Which components are composed inside Modal?\nscope manifest query --composed-by Modal\n\n# All components in the Forms collection\nscope manifest list --collection Forms\n\n# All internal components (library implementation details)\nscope manifest list --internal\n\n# Public components only (hide internals)\nscope manifest list --no-internal\n```\n\n### Render workflows\n\n```bash\n# Render Button in all variants (auto-defaults props if not provided)\nscope render matrix Button --axes \'variant:primary,secondary,danger\'\n\n# Render with JSON axes format\nscope render matrix Button --axes \'{"variant":["primary","secondary"]}\'\n\n# Render with explicit props\nscope render component Button --props \'{"variant":"primary","disabled":true}\'\n\n# Render all components (handles CSS/SVG/font imports automatically)\nscope render all --concurrency 8\n\n# Get render as JSON\nscope render component Button --format json | jq \'.screenshot\' | base64 -d > button.png\n```\n\n### Token workflows\n\n```bash\n# List all tokens\nscope tokens list\n\n# Check compliance\nscope tokens compliance --threshold 95\n\n# See what a token change impacts\nscope tokens impact --token color.primary.500\n\n# Export for Tailwind\nscope tokens export --format tailwind\n```\n\n### CI workflow\n\n```bash\n# Full compliance check\nscope ci --json\n\n# In GitHub Actions \u2014 use the reusable workflow\n# .github/workflows/ci.yml:\n# uses: FlatFilers/Scope/.github/workflows/scope-ci.yml@main\n```\n\n---\n\n## Error Patterns\n\n| Error | Cause | Fix |\n|-------|-------|-----|\n| `"React root not found"` | App not running, wrong URL, or Vite HMR interfering | Use `scope capture --wait 2000` |\n| `"Component not in manifest"` | Manifest is stale | Run `scope manifest generate` first |\n| `"Manifest not found"` | Missing manifest | Run `scope init` or `scope manifest generate` |\n| `"requiredContexts missing"` | Component needs a provider | Add provider presets to `reactscope.config.json` |\n| Blank PNG / 16\xD76px renders | No globalCSS injected (common with Tailwind) | Set `components.wrappers.globalCSS` in config; run `scope doctor` to verify |\n| `PLAYWRIGHT_BROWSERS_MISSING` / `browserType.launch: Executable doesn\'t exist` | Playwright Chromium is not installed in this sandbox | Run `bunx playwright install chromium`, then `scope doctor --json`, then retry render/site/instrument |\n| `TARGET_PROJECT_DEPENDENCIES_MISSING` / `Could not resolve "react"` | Target project dependencies are missing | Run `bun install` (or the detected package manager) in the target project root, then `scope doctor --json` |\n| `"Invalid props JSON"` | Malformed JSON in `--props` | Use single outer quotes: `--props \'{"key":"val"}\'` |\n| `"SCOPE_CAPTURE_JSON not available"` | Scope runtime not injected before React loaded | Fixed in PR #83 \u2014 update CLI |\n| `"No React DevTools hook found"` | Hook instrumentation init order bug | Fixed in PR #83 \u2014 update CLI |\n| `"ERR_MODULE_NOT_FOUND"` after tokens commands | Old Node shebang in CLI binary | Fixed in PR #90 \u2014 CLI now uses `#!/usr/bin/env bun` |\n| `"version" field missing in tokens` | Token stub written by old `scope init` | Re-run `scope init --force` or add `"version": "1"` to token file |\n| `"unknown option --has-prop"` | Old CLI version | Fixed in PR #90 \u2014 update CLI |\n| Format alias error (`json`, `js`, `sass`, `tw`) | Wrong format name for `tokens export` | Use `flat-json`, `ts`, `scss`, `tailwind`; CLI shows "Did you mean?" hint |\n\n---\n\n## `reactscope.config.json`\n\n```json\n{\n "components": {\n "wrappers": {\n "globalCSS": ["src/styles.css"]\n }\n },\n "tokens": {\n "file": "reactscope.tokens.json"\n },\n "collections": [\n { "name": "Forms", "description": "Form inputs and controls", "patterns": ["src/forms/**"] },\n { "name": "Primitives", "patterns": ["src/primitives/**"] }\n ],\n "internalPatterns": ["src/internal/**"],\n "providers": {\n "theme": { "component": "ThemeProvider", "props": { "theme": "light" } },\n "router": { "component": "MemoryRouter", "props": { "initialEntries": ["/"] } }\n }\n}\n```\n\n**Built-in mock providers** (always available, no config needed):\n- `ThemeContext` \u2192 `{ theme: \'light\' }` (or `--theme <name>`)\n- `LocaleContext` \u2192 `{ locale: \'en-US\' }`\n\n---\n\n## What Scope Cannot Do\n\n- **Runtime state**: `useState` values after user interaction\n- **Network requests**: `fetch`, `XHR`, `WebSocket`\n- **User interactions**: click, type, hover, drag\n- **Auth/session-gated components**: components that redirect or throw without a session\n- **Server components (RSC)**: React Server Components\n- **Dynamic CSS**: CSS-in-JS styles computed at runtime from props Scope can\'t infer\n\n---\n\n## Version History\n\n| Version | Date | Summary |\n|---------|------|---------|\n| v1.0 | 2026-03-11 | Initial SKILL.md (PR #36) \u2014 manifest, render, capture, tree, report, tokens, ci commands |\n| v1.1 | 2026-03-11 | Updated through PR #82 \u2014 Phase 2 CLI commands complete |\n| v1.2 | 2026-03-13 | PRs #83\u2013#95: runtime injection fix, dogfooding fixes (12 bugs), `scope doctor`, `scope init` auto-manifest, globalCSS render warning, collections & internal components feature |\n';
|
|
1416
|
+
|
|
1417
|
+
// src/get-skill-command.ts
|
|
1418
|
+
function createGetSkillCommand() {
|
|
1419
|
+
return new Command("get-skill").description(
|
|
1420
|
+
'Print the embedded Scope SKILL.md to stdout.\n\nAgents: load this first, then follow the canonical workflow in the skill:\n init --yes \u2192 doctor --json \u2192 manifest --format json \u2192 render all --format json \u2192 site build\nThe skill documents .scope.tsx discovery/exports, config paths, profiler usage,\nand failure recovery.\n\nEMBEDDED AT BUILD TIME \u2014 works in any install context (global npm, npx, local).\n\nExamples:\n scope get-skill # raw markdown to stdout\n scope get-skill --json # { "skill": "..." } for structured ingestion\n scope get-skill | head -50 # preview the skill\n scope get-skill > /tmp/SKILL.md # save locally'
|
|
1421
|
+
).option("--json", "Wrap output in JSON { skill: string } instead of raw markdown").action((opts) => {
|
|
1422
|
+
if (opts.json) {
|
|
1423
|
+
process.stdout.write(`${JSON.stringify({ skill: SKILL_CONTENT }, null, 2)}
|
|
1424
|
+
`);
|
|
1425
|
+
} else {
|
|
1426
|
+
process.stdout.write(SKILL_CONTENT);
|
|
1427
|
+
}
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1143
1430
|
function hasConfigFile(dir, stem) {
|
|
1144
1431
|
if (!existsSync(dir)) return false;
|
|
1145
1432
|
try {
|
|
@@ -1163,7 +1450,7 @@ function detectFramework(rootDir, packageDeps) {
|
|
|
1163
1450
|
if ("react-scripts" in packageDeps) return "cra";
|
|
1164
1451
|
return "unknown";
|
|
1165
1452
|
}
|
|
1166
|
-
function
|
|
1453
|
+
function detectPackageManager2(rootDir) {
|
|
1167
1454
|
if (existsSync(join(rootDir, "bun.lock"))) return "bun";
|
|
1168
1455
|
if (existsSync(join(rootDir, "yarn.lock"))) return "yarn";
|
|
1169
1456
|
if (existsSync(join(rootDir, "pnpm-lock.yaml"))) return "pnpm";
|
|
@@ -1239,6 +1526,31 @@ var TAILWIND_STEMS = ["tailwind.config"];
|
|
|
1239
1526
|
var CSS_EXTS = [".css", ".scss", ".sass", ".less"];
|
|
1240
1527
|
var THEME_SUFFIXES = [".theme.ts", ".theme.js", ".theme.tsx"];
|
|
1241
1528
|
var CSS_CUSTOM_PROPS_RE = /:root\s*\{[^}]*--[a-zA-Z]/;
|
|
1529
|
+
var TAILWIND_V4_THEME_RE = /@theme\s*(?:inline\s*)?\{[^}]*--[a-zA-Z]/;
|
|
1530
|
+
var MAX_SCAN_DEPTH = 4;
|
|
1531
|
+
var SKIP_CSS_NAMES = ["compiled", ".min."];
|
|
1532
|
+
function collectCSSFiles(dir, depth) {
|
|
1533
|
+
if (depth > MAX_SCAN_DEPTH) return [];
|
|
1534
|
+
const results = [];
|
|
1535
|
+
try {
|
|
1536
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
1537
|
+
for (const entry of entries) {
|
|
1538
|
+
if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".next") {
|
|
1539
|
+
continue;
|
|
1540
|
+
}
|
|
1541
|
+
const full = join(dir, entry.name);
|
|
1542
|
+
if (entry.isFile() && CSS_EXTS.some((x) => entry.name.endsWith(x))) {
|
|
1543
|
+
if (!SKIP_CSS_NAMES.some((skip) => entry.name.includes(skip))) {
|
|
1544
|
+
results.push(full);
|
|
1545
|
+
}
|
|
1546
|
+
} else if (entry.isDirectory()) {
|
|
1547
|
+
results.push(...collectCSSFiles(full, depth + 1));
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
} catch {
|
|
1551
|
+
}
|
|
1552
|
+
return results;
|
|
1553
|
+
}
|
|
1242
1554
|
function detectTokenSources(rootDir) {
|
|
1243
1555
|
const sources = [];
|
|
1244
1556
|
for (const stem of TAILWIND_STEMS) {
|
|
@@ -1254,32 +1566,53 @@ function detectTokenSources(rootDir) {
|
|
|
1254
1566
|
}
|
|
1255
1567
|
}
|
|
1256
1568
|
const srcDir = join(rootDir, "src");
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
}
|
|
1569
|
+
if (existsSync(srcDir)) {
|
|
1570
|
+
const cssFiles = collectCSSFiles(srcDir, 0);
|
|
1571
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1572
|
+
for (const filePath of cssFiles) {
|
|
1573
|
+
const content = readSafe(filePath);
|
|
1574
|
+
if (content === null) continue;
|
|
1575
|
+
if (TAILWIND_V4_THEME_RE.test(content) && !seen.has(filePath)) {
|
|
1576
|
+
sources.push({ kind: "tailwind-v4-theme", path: filePath });
|
|
1577
|
+
seen.add(filePath);
|
|
1578
|
+
}
|
|
1579
|
+
if (CSS_CUSTOM_PROPS_RE.test(content) && !seen.has(filePath)) {
|
|
1580
|
+
sources.push({ kind: "css-custom-properties", path: filePath });
|
|
1581
|
+
seen.add(filePath);
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
for (const tokenDir of ["tokens", "styles", "theme"]) {
|
|
1586
|
+
const dir = join(rootDir, tokenDir);
|
|
1587
|
+
if (!existsSync(dir)) continue;
|
|
1588
|
+
const cssFiles = collectCSSFiles(dir, 0);
|
|
1589
|
+
for (const filePath of cssFiles) {
|
|
1590
|
+
const content = readSafe(filePath);
|
|
1591
|
+
if (content === null) continue;
|
|
1592
|
+
if (TAILWIND_V4_THEME_RE.test(content)) {
|
|
1593
|
+
sources.push({ kind: "tailwind-v4-theme", path: filePath });
|
|
1594
|
+
} else if (CSS_CUSTOM_PROPS_RE.test(content)) {
|
|
1595
|
+
sources.push({ kind: "css-custom-properties", path: filePath });
|
|
1269
1596
|
}
|
|
1270
|
-
} catch {
|
|
1271
1597
|
}
|
|
1272
1598
|
}
|
|
1273
1599
|
if (existsSync(srcDir)) {
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1600
|
+
const scanThemeFiles = (dir, depth) => {
|
|
1601
|
+
if (depth > MAX_SCAN_DEPTH) return;
|
|
1602
|
+
try {
|
|
1603
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
1604
|
+
for (const entry of entries) {
|
|
1605
|
+
if (entry.name === "node_modules" || entry.name === "dist") continue;
|
|
1606
|
+
if (entry.isFile() && THEME_SUFFIXES.some((s) => entry.name.endsWith(s))) {
|
|
1607
|
+
sources.push({ kind: "theme-file", path: join(dir, entry.name) });
|
|
1608
|
+
} else if (entry.isDirectory()) {
|
|
1609
|
+
scanThemeFiles(join(dir, entry.name), depth + 1);
|
|
1610
|
+
}
|
|
1279
1611
|
}
|
|
1612
|
+
} catch {
|
|
1280
1613
|
}
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1614
|
+
};
|
|
1615
|
+
scanThemeFiles(srcDir, 0);
|
|
1283
1616
|
}
|
|
1284
1617
|
return sources;
|
|
1285
1618
|
}
|
|
@@ -1299,7 +1632,7 @@ function detectProject(rootDir) {
|
|
|
1299
1632
|
}
|
|
1300
1633
|
const framework = detectFramework(rootDir, packageDeps);
|
|
1301
1634
|
const { typescript, tsconfigPath } = detectTypeScript(rootDir);
|
|
1302
|
-
const packageManager =
|
|
1635
|
+
const packageManager = detectPackageManager2(rootDir);
|
|
1303
1636
|
const componentPatterns = detectComponentPatterns(rootDir, typescript);
|
|
1304
1637
|
const tokenSources = detectTokenSources(rootDir);
|
|
1305
1638
|
const globalCSSFiles = detectGlobalCSSFiles(rootDir);
|
|
@@ -1350,9 +1683,9 @@ function createRL() {
|
|
|
1350
1683
|
});
|
|
1351
1684
|
}
|
|
1352
1685
|
async function ask(rl, question) {
|
|
1353
|
-
return new Promise((
|
|
1686
|
+
return new Promise((resolve21) => {
|
|
1354
1687
|
rl.question(question, (answer) => {
|
|
1355
|
-
|
|
1688
|
+
resolve21(answer.trim());
|
|
1356
1689
|
});
|
|
1357
1690
|
});
|
|
1358
1691
|
}
|
|
@@ -1613,7 +1946,9 @@ async function runInit(options) {
|
|
|
1613
1946
|
};
|
|
1614
1947
|
}
|
|
1615
1948
|
function createInitCommand() {
|
|
1616
|
-
return new Command("init").description(
|
|
1949
|
+
return new Command("init").description(
|
|
1950
|
+
"Auto-detect your project layout and scaffold reactscope.config.json.\n\nWHAT IT DOES:\n - Detects component glob patterns from tsconfig / package.json / directory scan\n - Detects globalCSS files (Tailwind, PostCSS)\n - Writes reactscope.config.json with all detected values\n - Adds .reactscope/ to .gitignore\n - Creates .reactscope/ output directory\n\nCONFIG FIELDS GENERATED:\n components.include glob patterns for component discovery\n components.wrappers providers + globalCSS to inject on every render\n render.viewport default viewport (1280\xD7800)\n tokens.file path to reactscope.tokens.json\n output.dir .reactscope/ (all outputs go here)\n ci.complianceThreshold 0.90 (90% on-token required to pass CI)\n\nSafe to re-run \u2014 will not overwrite existing config unless --force.\n\nExamples:\n scope init\n scope init --yes # accept all detected defaults, no prompts\n scope init --force # overwrite existing config"
|
|
1951
|
+
).option("-y, --yes", "Accept all detected defaults without prompting", false).option("--force", "Overwrite existing reactscope.config.json if present", false).action(async (opts) => {
|
|
1617
1952
|
try {
|
|
1618
1953
|
const result = await runInit({ yes: opts.yes, force: opts.force });
|
|
1619
1954
|
if (!result.success && !result.skipped) {
|
|
@@ -1642,34 +1977,56 @@ function resolveFormat(formatFlag) {
|
|
|
1642
1977
|
return isTTY() ? "table" : "json";
|
|
1643
1978
|
}
|
|
1644
1979
|
function registerList(manifestCmd) {
|
|
1645
|
-
manifestCmd.command("list").description(
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1980
|
+
manifestCmd.command("list").description(
|
|
1981
|
+
`List all components in the manifest as a table (TTY) or JSON (piped).
|
|
1982
|
+
|
|
1983
|
+
Examples:
|
|
1984
|
+
scope manifest list
|
|
1985
|
+
scope manifest list --format json | jq '.[].name'
|
|
1986
|
+
scope manifest list --filter "Button*"`
|
|
1987
|
+
).option("--format <fmt>", "Output format: json or table (default: auto-detect)").option("--filter <glob>", "Filter components by name glob pattern").option("--collection <name>", "Filter to only components in the named collection").option("--internal", "Show only internal components").option("--no-internal", "Hide internal components from output").option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH).action(
|
|
1988
|
+
(opts) => {
|
|
1989
|
+
try {
|
|
1990
|
+
const manifest = loadManifest(opts.manifest);
|
|
1991
|
+
const format = resolveFormat(opts.format);
|
|
1992
|
+
let entries = Object.entries(manifest.components);
|
|
1993
|
+
if (opts.filter !== void 0) {
|
|
1994
|
+
const filterPattern = opts.filter ?? "";
|
|
1995
|
+
entries = entries.filter(([name]) => matchGlob(filterPattern, name));
|
|
1996
|
+
}
|
|
1997
|
+
if (opts.collection !== void 0) {
|
|
1998
|
+
const col = opts.collection;
|
|
1999
|
+
entries = entries.filter(([, d]) => d.collection === col);
|
|
2000
|
+
}
|
|
2001
|
+
if (opts.internal === true) {
|
|
2002
|
+
entries = entries.filter(([, d]) => d.internal);
|
|
2003
|
+
} else if (opts.internal === false) {
|
|
2004
|
+
entries = entries.filter(([, d]) => !d.internal);
|
|
2005
|
+
}
|
|
2006
|
+
const rows = entries.map(([name, descriptor]) => ({
|
|
2007
|
+
name,
|
|
2008
|
+
file: descriptor.filePath,
|
|
2009
|
+
complexityClass: descriptor.complexityClass,
|
|
2010
|
+
hookCount: descriptor.detectedHooks.length,
|
|
2011
|
+
contextCount: descriptor.requiredContexts.length,
|
|
2012
|
+
collection: descriptor.collection,
|
|
2013
|
+
internal: descriptor.internal
|
|
2014
|
+
}));
|
|
2015
|
+
const output = format === "json" ? formatListJson(rows) : formatListTable(rows);
|
|
2016
|
+
process.stdout.write(`${output}
|
|
1663
2017
|
`);
|
|
1664
|
-
|
|
1665
|
-
|
|
2018
|
+
} catch (err) {
|
|
2019
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
1666
2020
|
`);
|
|
1667
|
-
|
|
2021
|
+
process.exit(1);
|
|
2022
|
+
}
|
|
1668
2023
|
}
|
|
1669
|
-
|
|
2024
|
+
);
|
|
1670
2025
|
}
|
|
1671
2026
|
function registerGet(manifestCmd) {
|
|
1672
|
-
manifestCmd.command("get <name>").description(
|
|
2027
|
+
manifestCmd.command("get <name>").description(
|
|
2028
|
+
"Get full details of a single component: props, hooks, complexity class, file path.\n\nExamples:\n scope manifest get Button\n scope manifest get Button --format json\n scope manifest get Button --format json | jq '.complexity'"
|
|
2029
|
+
).option("--format <fmt>", "Output format: json or table (default: auto-detect)").option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH).action((name, opts) => {
|
|
1673
2030
|
try {
|
|
1674
2031
|
const manifest = loadManifest(opts.manifest);
|
|
1675
2032
|
const format = resolveFormat(opts.format);
|
|
@@ -1693,10 +2050,12 @@ Available: ${available}${hint}`
|
|
|
1693
2050
|
});
|
|
1694
2051
|
}
|
|
1695
2052
|
function registerQuery(manifestCmd) {
|
|
1696
|
-
manifestCmd.command("query").description(
|
|
2053
|
+
manifestCmd.command("query").description(
|
|
2054
|
+
'Filter components by structural attributes. All flags are AND-combined.\n\nCOMPLEXITY CLASSES:\n simple \u2014 pure/presentational, no side effects, Satori-renderable\n complex \u2014 uses context/hooks/effects, requires BrowserPool to render\n\nExamples:\n scope manifest query --complexity simple\n scope manifest query --has-fetch\n scope manifest query --hook useContext --side-effects\n scope manifest query --has-prop "variant:union" --format json\n scope manifest query --composed-by Layout'
|
|
2055
|
+
).option("--context <name>", "Find components consuming a context").option("--hook <name>", "Find components using a specific hook").option("--complexity <class>", "Filter by complexity class: simple or complex").option("--side-effects", "Find components with any side effects", false).option("--has-fetch", "Find components with fetch calls", false).option(
|
|
1697
2056
|
"--has-prop <spec>",
|
|
1698
2057
|
"Find components with a prop matching name or name:type (e.g. 'loading' or 'variant:union')"
|
|
1699
|
-
).option("--composed-by <name>", "Find components that compose the named component").option("--format <fmt>", "Output format: json or table (default: auto-detect)").option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH).action(
|
|
2058
|
+
).option("--composed-by <name>", "Find components that compose the named component").option("--internal", "Find only internal components", false).option("--collection <name>", "Filter to only components in the named collection").option("--format <fmt>", "Output format: json or table (default: auto-detect)").option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH).action(
|
|
1700
2059
|
(opts) => {
|
|
1701
2060
|
try {
|
|
1702
2061
|
const manifest = loadManifest(opts.manifest);
|
|
@@ -1709,9 +2068,11 @@ function registerQuery(manifestCmd) {
|
|
|
1709
2068
|
if (opts.hasFetch) queryParts.push("has-fetch");
|
|
1710
2069
|
if (opts.hasProp !== void 0) queryParts.push(`has-prop=${opts.hasProp}`);
|
|
1711
2070
|
if (opts.composedBy !== void 0) queryParts.push(`composed-by=${opts.composedBy}`);
|
|
2071
|
+
if (opts.internal) queryParts.push("internal");
|
|
2072
|
+
if (opts.collection !== void 0) queryParts.push(`collection=${opts.collection}`);
|
|
1712
2073
|
if (queryParts.length === 0) {
|
|
1713
2074
|
process.stderr.write(
|
|
1714
|
-
"No query flags specified. Use --context, --hook, --complexity, --side-effects, --has-fetch, --has-prop,
|
|
2075
|
+
"No query flags specified. Use --context, --hook, --complexity, --side-effects, --has-fetch, --has-prop, --composed-by, --internal, or --collection.\n"
|
|
1715
2076
|
);
|
|
1716
2077
|
process.exit(1);
|
|
1717
2078
|
}
|
|
@@ -1756,15 +2117,24 @@ function registerQuery(manifestCmd) {
|
|
|
1756
2117
|
const targetName = opts.composedBy;
|
|
1757
2118
|
entries = entries.filter(([, d]) => {
|
|
1758
2119
|
const composedBy = d.composedBy;
|
|
1759
|
-
return composedBy
|
|
2120
|
+
return composedBy?.includes(targetName);
|
|
1760
2121
|
});
|
|
1761
2122
|
}
|
|
2123
|
+
if (opts.internal) {
|
|
2124
|
+
entries = entries.filter(([, d]) => d.internal);
|
|
2125
|
+
}
|
|
2126
|
+
if (opts.collection !== void 0) {
|
|
2127
|
+
const col = opts.collection;
|
|
2128
|
+
entries = entries.filter(([, d]) => d.collection === col);
|
|
2129
|
+
}
|
|
1762
2130
|
const rows = entries.map(([name, d]) => ({
|
|
1763
2131
|
name,
|
|
1764
2132
|
file: d.filePath,
|
|
1765
2133
|
complexityClass: d.complexityClass,
|
|
1766
2134
|
hooks: d.detectedHooks.join(", ") || "\u2014",
|
|
1767
|
-
contexts: d.requiredContexts.join(", ") || "\u2014"
|
|
2135
|
+
contexts: d.requiredContexts.join(", ") || "\u2014",
|
|
2136
|
+
collection: d.collection,
|
|
2137
|
+
internal: d.internal
|
|
1768
2138
|
}));
|
|
1769
2139
|
const output = format === "json" ? formatQueryJson(rows) : formatQueryTable(rows, queryDesc);
|
|
1770
2140
|
process.stdout.write(`${output}
|
|
@@ -1777,21 +2147,64 @@ function registerQuery(manifestCmd) {
|
|
|
1777
2147
|
}
|
|
1778
2148
|
);
|
|
1779
2149
|
}
|
|
2150
|
+
function loadReactScopeConfig(rootDir) {
|
|
2151
|
+
const configPath = resolve(rootDir, "reactscope.config.json");
|
|
2152
|
+
if (!existsSync(configPath)) return null;
|
|
2153
|
+
try {
|
|
2154
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
2155
|
+
const cfg = JSON.parse(raw);
|
|
2156
|
+
const result = {};
|
|
2157
|
+
const components = cfg.components;
|
|
2158
|
+
if (components !== void 0 && typeof components === "object" && components !== null) {
|
|
2159
|
+
if (Array.isArray(components.include)) {
|
|
2160
|
+
result.include = components.include;
|
|
2161
|
+
}
|
|
2162
|
+
if (Array.isArray(components.exclude)) {
|
|
2163
|
+
result.exclude = components.exclude;
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
if (Array.isArray(cfg.internalPatterns)) {
|
|
2167
|
+
result.internalPatterns = cfg.internalPatterns;
|
|
2168
|
+
}
|
|
2169
|
+
if (Array.isArray(cfg.collections)) {
|
|
2170
|
+
result.collections = cfg.collections;
|
|
2171
|
+
}
|
|
2172
|
+
const icons = cfg.icons;
|
|
2173
|
+
if (icons !== void 0 && typeof icons === "object" && icons !== null) {
|
|
2174
|
+
if (Array.isArray(icons.patterns)) {
|
|
2175
|
+
result.iconPatterns = icons.patterns;
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
return result;
|
|
2179
|
+
} catch {
|
|
2180
|
+
return null;
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
1780
2183
|
function registerGenerate(manifestCmd) {
|
|
1781
2184
|
manifestCmd.command("generate").description(
|
|
1782
|
-
|
|
2185
|
+
'Scan source files and generate .reactscope/manifest.json.\n\nUses Babel static analysis \u2014 no runtime or bundler required.\nRe-run whenever components are added, removed, or significantly changed.\n\nReads reactscope.config.json for:\n components.include glob patterns for source files\n components.exclude glob patterns to skip\n internalPatterns globs to flag components as internal\n collections named groups of components\n\nCLI flags (--include, --exclude) override config-file values.\n\nWHAT IT CAPTURES per component:\n - File path and export name\n - All props with types and default values\n - Hook usage (useState, useEffect, useContext, custom hooks)\n - Side effects (fetch, timers, subscriptions)\n - Complexity class: simple | complex\n - Context dependencies and composed child components\n\nExamples:\n scope manifest generate\n scope manifest generate --root ./packages/ui\n scope manifest generate --include "src/components/**/*.tsx" --exclude "**/*.stories.tsx"\n scope manifest generate --output ./custom-manifest.json'
|
|
1783
2186
|
).option("--root <path>", "Project root directory (default: cwd)").option("--output <path>", "Output path for manifest.json", MANIFEST_PATH).option("--include <globs>", "Comma-separated glob patterns to include").option("--exclude <globs>", "Comma-separated glob patterns to exclude").action(async (opts) => {
|
|
1784
2187
|
try {
|
|
1785
2188
|
const rootDir = resolve(process.cwd(), opts.root ?? ".");
|
|
1786
2189
|
const outputPath = resolve(process.cwd(), opts.output);
|
|
1787
|
-
const
|
|
1788
|
-
const
|
|
2190
|
+
const configValues = loadReactScopeConfig(rootDir);
|
|
2191
|
+
const include = opts.include?.split(",").map((s) => s.trim()) ?? configValues?.include;
|
|
2192
|
+
const exclude = opts.exclude?.split(",").map((s) => s.trim()) ?? configValues?.exclude;
|
|
1789
2193
|
process.stderr.write(`Scanning ${rootDir} for React components...
|
|
1790
2194
|
`);
|
|
1791
2195
|
const manifest = await generateManifest({
|
|
1792
2196
|
rootDir,
|
|
1793
2197
|
...include !== void 0 && { include },
|
|
1794
|
-
...exclude !== void 0 && { exclude }
|
|
2198
|
+
...exclude !== void 0 && { exclude },
|
|
2199
|
+
...configValues?.internalPatterns !== void 0 && {
|
|
2200
|
+
internalPatterns: configValues.internalPatterns
|
|
2201
|
+
},
|
|
2202
|
+
...configValues?.collections !== void 0 && {
|
|
2203
|
+
collections: configValues.collections
|
|
2204
|
+
},
|
|
2205
|
+
...configValues?.iconPatterns !== void 0 && {
|
|
2206
|
+
iconPatterns: configValues.iconPatterns
|
|
2207
|
+
}
|
|
1795
2208
|
});
|
|
1796
2209
|
const componentCount = Object.keys(manifest.components).length;
|
|
1797
2210
|
process.stderr.write(`Found ${componentCount} components.
|
|
@@ -1814,7 +2227,7 @@ function registerGenerate(manifestCmd) {
|
|
|
1814
2227
|
}
|
|
1815
2228
|
function createManifestCommand() {
|
|
1816
2229
|
const manifestCmd = new Command("manifest").description(
|
|
1817
|
-
"Query and explore the component manifest"
|
|
2230
|
+
"Query and explore the component manifest (.reactscope/manifest.json).\n\nThe manifest is the source-of-truth registry of every React component\nin your codebase \u2014 generated by static analysis (no runtime needed).\n\nRun `scope manifest generate` first, then use list/get/query to explore.\n\nExamples:\n scope manifest generate\n scope manifest list\n scope manifest get Button\n scope manifest query --complexity complex --has-fetch"
|
|
1818
2231
|
);
|
|
1819
2232
|
registerList(manifestCmd);
|
|
1820
2233
|
registerGet(manifestCmd);
|
|
@@ -2130,7 +2543,19 @@ async function runHooksProfiling(componentName, filePath, props) {
|
|
|
2130
2543
|
}
|
|
2131
2544
|
function createInstrumentHooksCommand() {
|
|
2132
2545
|
const cmd = new Command("hooks").description(
|
|
2133
|
-
|
|
2546
|
+
`Profile per-hook-instance data for a component.
|
|
2547
|
+
|
|
2548
|
+
METRICS CAPTURED per hook instance:
|
|
2549
|
+
useState update count, current value
|
|
2550
|
+
useCallback cache hit rate (stable reference %)
|
|
2551
|
+
useMemo cache hit rate (recompute %)
|
|
2552
|
+
useEffect execution count
|
|
2553
|
+
useRef current value snapshot
|
|
2554
|
+
|
|
2555
|
+
Examples:
|
|
2556
|
+
scope instrument hooks SearchInput
|
|
2557
|
+
scope instrument hooks SearchInput --props '{"value":"hello"}' --json
|
|
2558
|
+
scope instrument hooks Dropdown --json | jq '.hooks[] | select(.type == "useMemo")' `
|
|
2134
2559
|
).argument("<component>", "Component name (must exist in the manifest)").option("--props <json>", "Inline props JSON passed to the component", "{}").option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH2).option("--format <fmt>", "Output format: json|text (default: auto)", "json").option("--show-flags", "Show heuristic flags only (useful for CI checks)", false).action(
|
|
2135
2560
|
async (componentName, opts) => {
|
|
2136
2561
|
try {
|
|
@@ -2168,7 +2593,7 @@ Available: ${available}`
|
|
|
2168
2593
|
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
2169
2594
|
`);
|
|
2170
2595
|
} catch (err) {
|
|
2171
|
-
process.stderr.write(
|
|
2596
|
+
process.stderr.write(`${formatScopeDiagnostic(err)}
|
|
2172
2597
|
`);
|
|
2173
2598
|
process.exit(1);
|
|
2174
2599
|
}
|
|
@@ -2271,13 +2696,11 @@ function buildProfilingCollectScript() {
|
|
|
2271
2696
|
// mount commit), it *may* have been wasted if it didn't actually need to re-render.
|
|
2272
2697
|
// For the initial snapshot we approximate: wastedRenders = max(0, totalCommits - 1) * 0.3
|
|
2273
2698
|
// This is a heuristic \u2014 real wasted render detection needs shouldComponentUpdate/React.memo tracing.
|
|
2274
|
-
var wastedRenders = Math.max(0, Math.round((totalCommits - 1) * uniqueNames.length * 0.3));
|
|
2275
|
-
|
|
2276
2699
|
return {
|
|
2277
2700
|
commitCount: totalCommits,
|
|
2278
2701
|
uniqueComponents: uniqueNames.length,
|
|
2279
2702
|
componentNames: uniqueNames,
|
|
2280
|
-
wastedRenders:
|
|
2703
|
+
wastedRenders: null,
|
|
2281
2704
|
layoutTime: window.__scopeLayoutTime || 0,
|
|
2282
2705
|
paintTime: window.__scopePaintTime || 0,
|
|
2283
2706
|
layoutShifts: window.__scopeLayoutShifts || { count: 0, score: 0 }
|
|
@@ -2325,7 +2748,7 @@ async function replayInteraction(page, steps) {
|
|
|
2325
2748
|
}
|
|
2326
2749
|
function analyzeProfileFlags(totalRenders, wastedRenders, timing, layoutShifts) {
|
|
2327
2750
|
const flags = /* @__PURE__ */ new Set();
|
|
2328
|
-
if (wastedRenders > 0 && wastedRenders / Math.max(1, totalRenders) > 0.3) {
|
|
2751
|
+
if (wastedRenders !== null && wastedRenders > 0 && wastedRenders / Math.max(1, totalRenders) > 0.3) {
|
|
2329
2752
|
flags.add("WASTED_RENDER");
|
|
2330
2753
|
}
|
|
2331
2754
|
if (totalRenders > 10) {
|
|
@@ -2396,13 +2819,18 @@ async function runInteractionProfile(componentName, filePath, props, interaction
|
|
|
2396
2819
|
};
|
|
2397
2820
|
const totalRenders = profileData.commitCount ?? 0;
|
|
2398
2821
|
const uniqueComponents = profileData.uniqueComponents ?? 0;
|
|
2399
|
-
const wastedRenders = profileData.wastedRenders ??
|
|
2822
|
+
const wastedRenders = profileData.wastedRenders ?? null;
|
|
2400
2823
|
const flags = analyzeProfileFlags(totalRenders, wastedRenders, timing, layoutShifts);
|
|
2401
2824
|
return {
|
|
2402
2825
|
component: componentName,
|
|
2403
2826
|
totalRenders,
|
|
2404
2827
|
uniqueComponents,
|
|
2405
2828
|
wastedRenders,
|
|
2829
|
+
wastedRendersHeuristic: {
|
|
2830
|
+
measured: false,
|
|
2831
|
+
value: null,
|
|
2832
|
+
note: "profile.wastedRenders is retained for compatibility but set to null because Scope does not directly measure wasted renders yet."
|
|
2833
|
+
},
|
|
2406
2834
|
timing,
|
|
2407
2835
|
layoutShifts,
|
|
2408
2836
|
flags,
|
|
@@ -2414,7 +2842,19 @@ async function runInteractionProfile(componentName, filePath, props, interaction
|
|
|
2414
2842
|
}
|
|
2415
2843
|
function createInstrumentProfileCommand() {
|
|
2416
2844
|
const cmd = new Command("profile").description(
|
|
2417
|
-
|
|
2845
|
+
`Capture a full performance profile for an interaction sequence.
|
|
2846
|
+
|
|
2847
|
+
PROFILE INCLUDES:
|
|
2848
|
+
renders total re-renders triggered by the interaction
|
|
2849
|
+
timing interaction start \u2192 paint time (ms)
|
|
2850
|
+
layoutShifts cumulative layout shift (CLS) score
|
|
2851
|
+
scriptTime JS execution time (ms)
|
|
2852
|
+
longTasks count of tasks >50ms
|
|
2853
|
+
|
|
2854
|
+
Examples:
|
|
2855
|
+
scope instrument profile Button --interaction '[{"action":"click","target":"button"}]'
|
|
2856
|
+
scope instrument profile Modal --interaction '[{"action":"click","target":".open-btn"}]' --json
|
|
2857
|
+
scope instrument profile Form --json | jq '.summary.renderCount'`
|
|
2418
2858
|
).argument("<component>", "Component name (must exist in the manifest)").option(
|
|
2419
2859
|
"--interaction <json>",
|
|
2420
2860
|
`Interaction steps JSON, e.g. '[{"action":"click","target":"button.primary"}]'`,
|
|
@@ -2465,7 +2905,7 @@ Available: ${available}`
|
|
|
2465
2905
|
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
2466
2906
|
`);
|
|
2467
2907
|
} catch (err) {
|
|
2468
|
-
process.stderr.write(
|
|
2908
|
+
process.stderr.write(`${formatScopeDiagnostic(err)}
|
|
2469
2909
|
`);
|
|
2470
2910
|
process.exit(1);
|
|
2471
2911
|
}
|
|
@@ -2750,7 +3190,21 @@ async function runInstrumentTree(options) {
|
|
|
2750
3190
|
}
|
|
2751
3191
|
}
|
|
2752
3192
|
function createInstrumentTreeCommand() {
|
|
2753
|
-
return new Command("tree").description(
|
|
3193
|
+
return new Command("tree").description(
|
|
3194
|
+
`Render a component and output the full instrumentation tree:
|
|
3195
|
+
DOM structure, computed styles per node, a11y roles, and React fibers.
|
|
3196
|
+
|
|
3197
|
+
OUTPUT STRUCTURE per node:
|
|
3198
|
+
tag / id / className DOM identity
|
|
3199
|
+
computedStyles resolved CSS properties
|
|
3200
|
+
a11y role, name, focusable
|
|
3201
|
+
children nested child nodes
|
|
3202
|
+
|
|
3203
|
+
Examples:
|
|
3204
|
+
scope instrument tree Card
|
|
3205
|
+
scope instrument tree Button --props '{"variant":"primary"}' --json
|
|
3206
|
+
scope instrument tree Input --json | jq '.tree.computedStyles'`
|
|
3207
|
+
).argument("<component>", "Component name to instrument (must exist in the manifest)").option("--sort-by <field>", "Sort nodes by field: renderCount | depth").option("--limit <n>", "Limit output to the first N nodes (depth-first)").option("--uses-context <name>", "Filter to components that use a specific context").option("--provider-depth", "Annotate each node with its context-provider nesting depth", false).option(
|
|
2754
3208
|
"--wasted-renders",
|
|
2755
3209
|
"Filter to components with wasted renders (no prop/state/context changes, not memoized)",
|
|
2756
3210
|
false
|
|
@@ -2796,7 +3250,7 @@ Available: ${available}`
|
|
|
2796
3250
|
`);
|
|
2797
3251
|
}
|
|
2798
3252
|
} catch (err) {
|
|
2799
|
-
process.stderr.write(
|
|
3253
|
+
process.stderr.write(`${formatScopeDiagnostic(err)}
|
|
2800
3254
|
`);
|
|
2801
3255
|
process.exit(1);
|
|
2802
3256
|
}
|
|
@@ -3144,7 +3598,8 @@ Available: ${available}`
|
|
|
3144
3598
|
}
|
|
3145
3599
|
const rootDir = process.cwd();
|
|
3146
3600
|
const filePath = resolve(rootDir, descriptor.filePath);
|
|
3147
|
-
const preScript = getBrowserEntryScript()
|
|
3601
|
+
const preScript = `${getBrowserEntryScript()}
|
|
3602
|
+
${buildInstrumentationScript()}`;
|
|
3148
3603
|
const htmlHarness = await buildComponentHarness(
|
|
3149
3604
|
filePath,
|
|
3150
3605
|
options.componentName,
|
|
@@ -3233,7 +3688,24 @@ function formatRendersTable(result) {
|
|
|
3233
3688
|
return lines.join("\n");
|
|
3234
3689
|
}
|
|
3235
3690
|
function createInstrumentRendersCommand() {
|
|
3236
|
-
return new Command("renders").description(
|
|
3691
|
+
return new Command("renders").description(
|
|
3692
|
+
`Trace every re-render triggered by an interaction and identify root causes.
|
|
3693
|
+
|
|
3694
|
+
OUTPUT INCLUDES per render event:
|
|
3695
|
+
component which component re-rendered
|
|
3696
|
+
trigger why it re-rendered: state_change | props_change | context_change |
|
|
3697
|
+
parent_rerender | force_update | hook_dependency
|
|
3698
|
+
wasted true if re-rendered with no changed inputs and not memoized
|
|
3699
|
+
chain full causality chain from root cause to this render
|
|
3700
|
+
|
|
3701
|
+
WASTED RENDERS: propsChanged=false AND stateChanged=false AND contextChanged=false
|
|
3702
|
+
AND memoized=false \u2014 these are optimisation opportunities.
|
|
3703
|
+
|
|
3704
|
+
Examples:
|
|
3705
|
+
scope instrument renders SearchPage --interaction '[{"action":"type","target":"input","text":"hello"}]'
|
|
3706
|
+
scope instrument renders Button --interaction '[{"action":"click","target":"button"}]' --json
|
|
3707
|
+
scope instrument renders Form --json | jq '.events[] | select(.wasted == true)'`
|
|
3708
|
+
).argument("<component>", "Component name to instrument (must be in manifest)").option(
|
|
3237
3709
|
"--interaction <json>",
|
|
3238
3710
|
`Interaction sequence JSON, e.g. '[{"action":"click","target":"button"}]'`,
|
|
3239
3711
|
"[]"
|
|
@@ -3270,7 +3742,7 @@ function createInstrumentRendersCommand() {
|
|
|
3270
3742
|
}
|
|
3271
3743
|
} catch (err) {
|
|
3272
3744
|
await shutdownPool2();
|
|
3273
|
-
process.stderr.write(
|
|
3745
|
+
process.stderr.write(`${formatScopeDiagnostic(err)}
|
|
3274
3746
|
`);
|
|
3275
3747
|
process.exit(1);
|
|
3276
3748
|
}
|
|
@@ -3279,7 +3751,28 @@ function createInstrumentRendersCommand() {
|
|
|
3279
3751
|
}
|
|
3280
3752
|
function createInstrumentCommand() {
|
|
3281
3753
|
const instrumentCmd = new Command("instrument").description(
|
|
3282
|
-
|
|
3754
|
+
`Runtime instrumentation for React component behaviour analysis.
|
|
3755
|
+
|
|
3756
|
+
All instrument commands:
|
|
3757
|
+
1. Build an esbuild harness for the component
|
|
3758
|
+
2. Load it in a Playwright browser
|
|
3759
|
+
3. Inject instrumentation hooks into React DevTools fiber
|
|
3760
|
+
4. Execute interactions and collect events
|
|
3761
|
+
|
|
3762
|
+
PREREQUISITES:
|
|
3763
|
+
scope manifest generate (component must be in manifest)
|
|
3764
|
+
reactscope.config.json (for wrappers/globalCSS)
|
|
3765
|
+
|
|
3766
|
+
INTERACTION FORMAT:
|
|
3767
|
+
JSON array of step objects: [{action, target, text?}]
|
|
3768
|
+
Actions: click | type | focus | blur | hover | key
|
|
3769
|
+
Target: CSS selector for the element to interact with
|
|
3770
|
+
|
|
3771
|
+
Examples:
|
|
3772
|
+
scope instrument renders Button --interaction '[{"action":"click","target":"button"}]'
|
|
3773
|
+
scope instrument hooks SearchInput --props '{"value":"hello"}'
|
|
3774
|
+
scope instrument profile Modal --interaction '[{"action":"click","target":".open-btn"}]'
|
|
3775
|
+
scope instrument tree Card`
|
|
3283
3776
|
);
|
|
3284
3777
|
instrumentCmd.addCommand(createInstrumentRendersCommand());
|
|
3285
3778
|
instrumentCmd.addCommand(createInstrumentHooksCommand());
|
|
@@ -3327,6 +3820,54 @@ function writeReportToFile(report, outputPath, pretty) {
|
|
|
3327
3820
|
const json = pretty ? JSON.stringify(report, null, 2) : JSON.stringify(report);
|
|
3328
3821
|
writeFileSync(outputPath, json, "utf-8");
|
|
3329
3822
|
}
|
|
3823
|
+
var RUN_SUMMARY_PATH = ".reactscope/run-summary.json";
|
|
3824
|
+
function buildNextActions(summary) {
|
|
3825
|
+
const actions = /* @__PURE__ */ new Set();
|
|
3826
|
+
for (const failure of summary.failures) {
|
|
3827
|
+
if (failure.stage === "render" || failure.stage === "matrix") {
|
|
3828
|
+
actions.add(
|
|
3829
|
+
`Inspect ${failure.outputPath ?? ".reactscope/renders"} and add/fix ${failure.component}.scope.tsx scenarios or wrappers.`
|
|
3830
|
+
);
|
|
3831
|
+
} else if (failure.stage === "playground") {
|
|
3832
|
+
actions.add(
|
|
3833
|
+
`Open the generated component page and inspect the playground bundling error for ${failure.component}.`
|
|
3834
|
+
);
|
|
3835
|
+
} else if (failure.stage === "compliance") {
|
|
3836
|
+
actions.add(
|
|
3837
|
+
"Run `scope render all` first, then inspect .reactscope/compliance-styles.json and reactscope.tokens.json."
|
|
3838
|
+
);
|
|
3839
|
+
} else if (failure.stage === "site") {
|
|
3840
|
+
actions.add(
|
|
3841
|
+
"Inspect .reactscope/site output and rerun `scope site build` after fixing render/playground failures."
|
|
3842
|
+
);
|
|
3843
|
+
}
|
|
3844
|
+
}
|
|
3845
|
+
if (summary.compliance && summary.compliance.auditedProperties === 0) {
|
|
3846
|
+
actions.add(
|
|
3847
|
+
"No CSS properties were audited. Verify renders produced computed styles and your token file contains matching token categories."
|
|
3848
|
+
);
|
|
3849
|
+
} else if (summary.compliance && summary.compliance.threshold !== void 0 && summary.compliance.score < summary.compliance.threshold) {
|
|
3850
|
+
actions.add(
|
|
3851
|
+
"Inspect .reactscope/compliance-report.json for off-system values and update tokens or component styles."
|
|
3852
|
+
);
|
|
3853
|
+
}
|
|
3854
|
+
if (actions.size === 0) {
|
|
3855
|
+
actions.add("No follow-up needed. Outputs are ready for inspection.");
|
|
3856
|
+
}
|
|
3857
|
+
return [...actions];
|
|
3858
|
+
}
|
|
3859
|
+
function writeRunSummary(summary, summaryPath = RUN_SUMMARY_PATH) {
|
|
3860
|
+
const outputPath = resolve(process.cwd(), summaryPath);
|
|
3861
|
+
mkdirSync(dirname(outputPath), { recursive: true });
|
|
3862
|
+
const payload = {
|
|
3863
|
+
...summary,
|
|
3864
|
+
generatedAt: summary.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
3865
|
+
nextActions: summary.nextActions ?? buildNextActions(summary)
|
|
3866
|
+
};
|
|
3867
|
+
writeFileSync(outputPath, `${JSON.stringify(payload, null, 2)}
|
|
3868
|
+
`, "utf-8");
|
|
3869
|
+
return outputPath;
|
|
3870
|
+
}
|
|
3330
3871
|
var SCOPE_EXTENSIONS = [".scope.tsx", ".scope.ts", ".scope.jsx", ".scope.js"];
|
|
3331
3872
|
function findScopeFile(componentFilePath) {
|
|
3332
3873
|
const dir = dirname(componentFilePath);
|
|
@@ -3454,6 +3995,63 @@ function loadGlobalCssFilesFromConfig(cwd) {
|
|
|
3454
3995
|
return [];
|
|
3455
3996
|
}
|
|
3456
3997
|
}
|
|
3998
|
+
var TAILWIND_CONFIG_FILES2 = [
|
|
3999
|
+
"tailwind.config.js",
|
|
4000
|
+
"tailwind.config.cjs",
|
|
4001
|
+
"tailwind.config.mjs",
|
|
4002
|
+
"tailwind.config.ts",
|
|
4003
|
+
"postcss.config.js",
|
|
4004
|
+
"postcss.config.cjs",
|
|
4005
|
+
"postcss.config.mjs",
|
|
4006
|
+
"postcss.config.ts"
|
|
4007
|
+
];
|
|
4008
|
+
function shouldWarnForMissingGlobalCss(cwd) {
|
|
4009
|
+
if (TAILWIND_CONFIG_FILES2.some((file) => existsSync(resolve(cwd, file)))) {
|
|
4010
|
+
return true;
|
|
4011
|
+
}
|
|
4012
|
+
const packageJsonPath = resolve(cwd, "package.json");
|
|
4013
|
+
if (!existsSync(packageJsonPath)) return false;
|
|
4014
|
+
try {
|
|
4015
|
+
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|
4016
|
+
return [pkg.dependencies, pkg.devDependencies].some(
|
|
4017
|
+
(deps) => deps && Object.keys(deps).some(
|
|
4018
|
+
(name) => name === "tailwindcss" || name.startsWith("@tailwindcss/")
|
|
4019
|
+
)
|
|
4020
|
+
);
|
|
4021
|
+
} catch {
|
|
4022
|
+
return false;
|
|
4023
|
+
}
|
|
4024
|
+
}
|
|
4025
|
+
function loadIconPatternsFromConfig(cwd) {
|
|
4026
|
+
const configPath = resolve(cwd, "reactscope.config.json");
|
|
4027
|
+
if (!existsSync(configPath)) return [];
|
|
4028
|
+
try {
|
|
4029
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
4030
|
+
const cfg = JSON.parse(raw);
|
|
4031
|
+
return cfg.icons?.patterns ?? [];
|
|
4032
|
+
} catch {
|
|
4033
|
+
return [];
|
|
4034
|
+
}
|
|
4035
|
+
}
|
|
4036
|
+
function matchGlob2(pattern, value) {
|
|
4037
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
4038
|
+
const regexStr = escaped.replace(/\*\*/g, "\xA7GLOBSTAR\xA7").replace(/\*/g, "[^/]*").replace(/§GLOBSTAR§/g, ".*");
|
|
4039
|
+
return new RegExp(`^${regexStr}$`, "i").test(value);
|
|
4040
|
+
}
|
|
4041
|
+
function isIconComponent(filePath, displayName, patterns) {
|
|
4042
|
+
return patterns.length > 0 && patterns.some((p) => matchGlob2(p, filePath) || matchGlob2(p, displayName));
|
|
4043
|
+
}
|
|
4044
|
+
function formatAggregateRenderFailureJson(componentName, failures, scenarioCount, runSummaryPath) {
|
|
4045
|
+
return {
|
|
4046
|
+
command: `scope render component ${componentName}`,
|
|
4047
|
+
status: "failed",
|
|
4048
|
+
component: componentName,
|
|
4049
|
+
scenarioCount,
|
|
4050
|
+
failureCount: failures.length,
|
|
4051
|
+
failures,
|
|
4052
|
+
runSummaryPath
|
|
4053
|
+
};
|
|
4054
|
+
}
|
|
3457
4055
|
var MANIFEST_PATH6 = ".reactscope/manifest.json";
|
|
3458
4056
|
var DEFAULT_OUTPUT_DIR = ".reactscope/renders";
|
|
3459
4057
|
var _pool3 = null;
|
|
@@ -3474,7 +4072,7 @@ async function shutdownPool3() {
|
|
|
3474
4072
|
_pool3 = null;
|
|
3475
4073
|
}
|
|
3476
4074
|
}
|
|
3477
|
-
function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, globalCssFiles = [], projectCwd = process.cwd(), wrapperScript) {
|
|
4075
|
+
function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, globalCssFiles = [], projectCwd = process.cwd(), wrapperScript, iconMode = false) {
|
|
3478
4076
|
const satori = new SatoriRenderer({
|
|
3479
4077
|
defaultViewport: { width: viewportWidth, height: viewportHeight }
|
|
3480
4078
|
});
|
|
@@ -3484,13 +4082,15 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, g
|
|
|
3484
4082
|
const startMs = performance.now();
|
|
3485
4083
|
const pool = await getPool3(viewportWidth, viewportHeight);
|
|
3486
4084
|
const projectCss = await loadGlobalCss(globalCssFiles, projectCwd);
|
|
4085
|
+
const PAD = 8;
|
|
3487
4086
|
const htmlHarness = await buildComponentHarness(
|
|
3488
4087
|
filePath,
|
|
3489
4088
|
componentName,
|
|
3490
4089
|
props,
|
|
3491
4090
|
viewportWidth,
|
|
3492
4091
|
projectCss ?? void 0,
|
|
3493
|
-
wrapperScript
|
|
4092
|
+
wrapperScript,
|
|
4093
|
+
PAD
|
|
3494
4094
|
);
|
|
3495
4095
|
const slot = await pool.acquire();
|
|
3496
4096
|
const { page } = slot;
|
|
@@ -3531,17 +4131,28 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, g
|
|
|
3531
4131
|
`Component "${componentName}" rendered with zero bounding box \u2014 it may be invisible or not mounted`
|
|
3532
4132
|
);
|
|
3533
4133
|
}
|
|
3534
|
-
const PAD = 8;
|
|
3535
4134
|
const clipX = Math.max(0, boundingBox.x - PAD);
|
|
3536
4135
|
const clipY = Math.max(0, boundingBox.y - PAD);
|
|
3537
4136
|
const rawW = boundingBox.width + PAD * 2;
|
|
3538
4137
|
const rawH = boundingBox.height + PAD * 2;
|
|
3539
4138
|
const safeW = Math.min(rawW, viewportWidth - clipX);
|
|
3540
4139
|
const safeH = Math.min(rawH, viewportHeight - clipY);
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
4140
|
+
let svgContent;
|
|
4141
|
+
let screenshot;
|
|
4142
|
+
if (iconMode) {
|
|
4143
|
+
svgContent = await page.evaluate((sel) => {
|
|
4144
|
+
const root = document.querySelector(sel);
|
|
4145
|
+
const el = root?.firstElementChild;
|
|
4146
|
+
if (!el) return void 0;
|
|
4147
|
+
return el.outerHTML;
|
|
4148
|
+
}, "[data-reactscope-root]") ?? void 0;
|
|
4149
|
+
screenshot = Buffer.alloc(0);
|
|
4150
|
+
} else {
|
|
4151
|
+
screenshot = await page.screenshot({
|
|
4152
|
+
clip: { x: clipX, y: clipY, width: safeW, height: safeH },
|
|
4153
|
+
type: "png"
|
|
4154
|
+
});
|
|
4155
|
+
}
|
|
3545
4156
|
const STYLE_PROPS = [
|
|
3546
4157
|
"display",
|
|
3547
4158
|
"width",
|
|
@@ -3664,7 +4275,7 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, g
|
|
|
3664
4275
|
name: a11yInfo.name,
|
|
3665
4276
|
violations: imgViolations
|
|
3666
4277
|
};
|
|
3667
|
-
|
|
4278
|
+
const renderResult = {
|
|
3668
4279
|
screenshot,
|
|
3669
4280
|
width: Math.round(safeW),
|
|
3670
4281
|
height: Math.round(safeH),
|
|
@@ -3673,6 +4284,10 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, g
|
|
|
3673
4284
|
dom,
|
|
3674
4285
|
accessibility
|
|
3675
4286
|
};
|
|
4287
|
+
if (iconMode && svgContent) {
|
|
4288
|
+
renderResult.svgContent = svgContent;
|
|
4289
|
+
}
|
|
4290
|
+
return renderResult;
|
|
3676
4291
|
} finally {
|
|
3677
4292
|
pool.release(slot);
|
|
3678
4293
|
}
|
|
@@ -3709,7 +4324,27 @@ Available: ${available}`
|
|
|
3709
4324
|
return { __default__: {} };
|
|
3710
4325
|
}
|
|
3711
4326
|
function registerRenderSingle(renderCmd) {
|
|
3712
|
-
renderCmd.command("component <component>", { isDefault: true }).description(
|
|
4327
|
+
renderCmd.command("component <component>", { isDefault: true }).description(
|
|
4328
|
+
`Render one component to a PNG screenshot or JSON data object.
|
|
4329
|
+
|
|
4330
|
+
PROP SOURCES (in priority order):
|
|
4331
|
+
--scenario <name> named scenario from <ComponentName>.scope file
|
|
4332
|
+
--props <json> inline props JSON string
|
|
4333
|
+
(no flag) component rendered with all-default props
|
|
4334
|
+
|
|
4335
|
+
FORMAT DETECTION:
|
|
4336
|
+
--format png always write PNG
|
|
4337
|
+
--format json always write JSON render data
|
|
4338
|
+
auto (default) PNG when -o has .png extension or stdout is file;
|
|
4339
|
+
JSON when stdout is a pipe
|
|
4340
|
+
|
|
4341
|
+
Examples:
|
|
4342
|
+
scope render component Button
|
|
4343
|
+
scope render component Button --props '{"variant":"primary","size":"lg"}'
|
|
4344
|
+
scope render component Button --scenario hover-state -o button-hover.png
|
|
4345
|
+
scope render component Card --viewport 375x812 --theme dark
|
|
4346
|
+
scope render component Badge --format json | jq '.a11y'`
|
|
4347
|
+
).option("--props <json>", `Inline props JSON, e.g. '{"variant":"primary"}'`).option("--scenario <name>", "Run a named scenario from the component's .scope file").option("--viewport <WxH>", "Viewport size e.g. 1280x720", "375x812").option("--theme <name>", "Theme name from the token system").option("-o, --output <path>", "Write PNG to file instead of stdout").option("--format <fmt>", "Output format: png or json (default: auto)").option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH6).action(
|
|
3713
4348
|
async (componentName, opts) => {
|
|
3714
4349
|
try {
|
|
3715
4350
|
const manifest = loadManifest(opts.manifest);
|
|
@@ -3752,7 +4387,7 @@ Available: ${available}`
|
|
|
3752
4387
|
const wrapperScript = scopeData?.hasWrapper === true ? await buildWrapperScript(scopeData.filePath) : void 0;
|
|
3753
4388
|
const scenarios = buildScenarioMap(opts, scopeData);
|
|
3754
4389
|
const globalCssFiles = loadGlobalCssFilesFromConfig(rootDir);
|
|
3755
|
-
if (globalCssFiles.length === 0) {
|
|
4390
|
+
if (globalCssFiles.length === 0 && shouldWarnForMissingGlobalCss(rootDir)) {
|
|
3756
4391
|
process.stderr.write(
|
|
3757
4392
|
"warning: No globalCSS files configured. Tailwind/CSS styles will not be applied to renders.\n Add `components.wrappers.globalCSS` to reactscope.config.json\n"
|
|
3758
4393
|
);
|
|
@@ -3771,7 +4406,8 @@ Available: ${available}`
|
|
|
3771
4406
|
`
|
|
3772
4407
|
);
|
|
3773
4408
|
const fmt2 = resolveSingleFormat(opts.format);
|
|
3774
|
-
|
|
4409
|
+
const failures = [];
|
|
4410
|
+
const outputPaths = [];
|
|
3775
4411
|
for (const [scenarioName, props2] of Object.entries(scenarios)) {
|
|
3776
4412
|
const isNamed = scenarioName !== "__default__";
|
|
3777
4413
|
const label = isNamed ? `${componentName}:${scenarioName}` : componentName;
|
|
@@ -3794,7 +4430,14 @@ Available: ${available}`
|
|
|
3794
4430
|
process.stderr.write(` Hints: ${hintList}
|
|
3795
4431
|
`);
|
|
3796
4432
|
}
|
|
3797
|
-
|
|
4433
|
+
failures.push({
|
|
4434
|
+
component: componentName,
|
|
4435
|
+
scenario: isNamed ? scenarioName : void 0,
|
|
4436
|
+
stage: "render",
|
|
4437
|
+
message: outcome.error.message,
|
|
4438
|
+
outputPath: `${DEFAULT_OUTPUT_DIR}/${isNamed ? `${componentName}-${scenarioName}.error.json` : `${componentName}.error.json`}`,
|
|
4439
|
+
hints: outcome.error.heuristicFlags
|
|
4440
|
+
});
|
|
3798
4441
|
continue;
|
|
3799
4442
|
}
|
|
3800
4443
|
const result = outcome.result;
|
|
@@ -3802,6 +4445,7 @@ Available: ${available}`
|
|
|
3802
4445
|
if (opts.output !== void 0 && !isNamed) {
|
|
3803
4446
|
const outPath = resolve(process.cwd(), opts.output);
|
|
3804
4447
|
writeFileSync(outPath, result.screenshot);
|
|
4448
|
+
outputPaths.push(outPath);
|
|
3805
4449
|
process.stdout.write(
|
|
3806
4450
|
`\u2713 ${label} \u2192 ${opts.output} (${result.width}\xD7${result.height}, ${result.renderTimeMs.toFixed(0)}ms)
|
|
3807
4451
|
`
|
|
@@ -3816,17 +4460,36 @@ Available: ${available}`
|
|
|
3816
4460
|
const outPath = resolve(dir, outFileName);
|
|
3817
4461
|
writeFileSync(outPath, result.screenshot);
|
|
3818
4462
|
const relPath = `${DEFAULT_OUTPUT_DIR}/${outFileName}`;
|
|
4463
|
+
outputPaths.push(relPath);
|
|
3819
4464
|
process.stdout.write(
|
|
3820
4465
|
`\u2713 ${label} \u2192 ${relPath} (${result.width}\xD7${result.height}, ${result.renderTimeMs.toFixed(0)}ms)
|
|
3821
4466
|
`
|
|
3822
4467
|
);
|
|
3823
4468
|
}
|
|
3824
4469
|
}
|
|
4470
|
+
const summaryPath = writeRunSummary({
|
|
4471
|
+
command: `scope render ${componentName}`,
|
|
4472
|
+
status: failures.length > 0 ? "failed" : "success",
|
|
4473
|
+
outputPaths,
|
|
4474
|
+
failures
|
|
4475
|
+
});
|
|
4476
|
+
process.stderr.write(`[scope/render] Run summary written to ${summaryPath}
|
|
4477
|
+
`);
|
|
4478
|
+
if (fmt2 === "json" && failures.length > 0) {
|
|
4479
|
+
const aggregateFailure = formatAggregateRenderFailureJson(
|
|
4480
|
+
componentName,
|
|
4481
|
+
failures,
|
|
4482
|
+
Object.keys(scenarios).length,
|
|
4483
|
+
summaryPath
|
|
4484
|
+
);
|
|
4485
|
+
process.stderr.write(`${JSON.stringify(aggregateFailure, null, 2)}
|
|
4486
|
+
`);
|
|
4487
|
+
}
|
|
3825
4488
|
await shutdownPool3();
|
|
3826
|
-
if (
|
|
4489
|
+
if (failures.length > 0) process.exit(1);
|
|
3827
4490
|
} catch (err) {
|
|
3828
4491
|
await shutdownPool3();
|
|
3829
|
-
process.stderr.write(
|
|
4492
|
+
process.stderr.write(`${formatScopeDiagnostic(err)}
|
|
3830
4493
|
`);
|
|
3831
4494
|
process.exit(1);
|
|
3832
4495
|
}
|
|
@@ -3834,7 +4497,9 @@ Available: ${available}`
|
|
|
3834
4497
|
);
|
|
3835
4498
|
}
|
|
3836
4499
|
function registerRenderMatrix(renderCmd) {
|
|
3837
|
-
renderCmd.command("matrix <component>").description(
|
|
4500
|
+
renderCmd.command("matrix <component>").description(
|
|
4501
|
+
'Render every combination of values across one or more prop axes.\nProduces a matrix of screenshots \u2014 one cell per combination.\n\nAXES FORMAT (two equivalent forms):\n Short: --axes "variant:primary,ghost size:sm,md,lg"\n JSON: --axes {"variant":["primary","ghost"],"size":["sm","md","lg"]}\n\nCOMPOSITION CONTEXTS (--contexts):\n Test component in different layout environments.\n Available IDs: centered, rtl, sidebar, dark-bg, light-bg\n (Define custom contexts in reactscope.config.json)\n\nSTRESS PRESETS (--stress):\n Inject adversarial content to test edge cases.\n Available IDs: text.long, text.unicode, text.empty\n\nExamples:\n scope render matrix Button --axes "variant:primary,ghost,destructive"\n scope render matrix Button --axes "variant:primary,ghost size:sm,lg" --sprite matrix.png\n scope render matrix Badge --axes "type:info,warn,error" --contexts centered,rtl\n scope render matrix Input --stress text.long,text.unicode --format json'
|
|
4502
|
+
).option(
|
|
3838
4503
|
"--axes <spec>",
|
|
3839
4504
|
`Axis definitions: key:v1,v2 space-separated OR JSON object e.g. 'variant:primary,ghost size:sm,lg' or '{"variant":["primary","ghost"],"size":["sm","lg"]}'`
|
|
3840
4505
|
).option(
|
|
@@ -3985,7 +4650,7 @@ Available: ${available}`
|
|
|
3985
4650
|
}
|
|
3986
4651
|
} catch (err) {
|
|
3987
4652
|
await shutdownPool3();
|
|
3988
|
-
process.stderr.write(
|
|
4653
|
+
process.stderr.write(`${formatScopeDiagnostic(err)}
|
|
3989
4654
|
`);
|
|
3990
4655
|
process.exit(1);
|
|
3991
4656
|
}
|
|
@@ -3993,7 +4658,9 @@ Available: ${available}`
|
|
|
3993
4658
|
);
|
|
3994
4659
|
}
|
|
3995
4660
|
function registerRenderAll(renderCmd) {
|
|
3996
|
-
renderCmd.command("all").description(
|
|
4661
|
+
renderCmd.command("all").description(
|
|
4662
|
+
"Render every component in the manifest and write to .reactscope/renders/.\n\nAlso emits .reactscope/compliance-styles.json (computed CSS class\u2192value map)\nwhich is required by `scope tokens compliance`.\n\nSCENARIO SELECTION:\n Each component is rendered using its default scenario from its .scope file\n if one exists, otherwise with all-default props.\n\nMATRIX AUTO-DETECTION:\n If a component has a .scope file with a matrix block, render all\n will render the matrix cells in addition to the default screenshot.\n\nExamples:\n scope render all\n scope render all --concurrency 8\n scope render all --format json --output-dir .reactscope/renders\n scope render all --manifest ./custom/manifest.json"
|
|
4663
|
+
).option("--concurrency <n>", "Max parallel renders", "4").option("--output-dir <dir>", "Output directory for renders", DEFAULT_OUTPUT_DIR).option("--manifest <path>", "Path to manifest.json", MANIFEST_PATH6).option("--format <fmt>", "Output format: json|png (default: png)", "png").action(
|
|
3997
4664
|
async (opts) => {
|
|
3998
4665
|
try {
|
|
3999
4666
|
const manifest = loadManifest(opts.manifest);
|
|
@@ -4001,7 +4668,21 @@ function registerRenderAll(renderCmd) {
|
|
|
4001
4668
|
const total = componentNames.length;
|
|
4002
4669
|
if (total === 0) {
|
|
4003
4670
|
process.stderr.write("No components found in manifest.\n");
|
|
4004
|
-
|
|
4671
|
+
const summaryPath2 = writeRunSummary({
|
|
4672
|
+
command: "scope render all",
|
|
4673
|
+
status: "failed",
|
|
4674
|
+
outputPaths: [],
|
|
4675
|
+
failures: [
|
|
4676
|
+
{
|
|
4677
|
+
component: "*",
|
|
4678
|
+
stage: "render",
|
|
4679
|
+
message: "No components found in manifest; refusing to report a false-green batch render."
|
|
4680
|
+
}
|
|
4681
|
+
]
|
|
4682
|
+
});
|
|
4683
|
+
process.stderr.write(`[scope/render] Run summary written to ${summaryPath2}
|
|
4684
|
+
`);
|
|
4685
|
+
process.exit(1);
|
|
4005
4686
|
}
|
|
4006
4687
|
const concurrency = Math.max(1, parseInt(opts.concurrency, 10) || 4);
|
|
4007
4688
|
const outputDir = resolve(process.cwd(), opts.outputDir);
|
|
@@ -4010,13 +4691,17 @@ function registerRenderAll(renderCmd) {
|
|
|
4010
4691
|
process.stderr.write(`Rendering ${total} components (concurrency: ${concurrency})\u2026
|
|
4011
4692
|
`);
|
|
4012
4693
|
const results = [];
|
|
4694
|
+
const failures = [];
|
|
4695
|
+
const outputPaths = [];
|
|
4013
4696
|
const complianceStylesMap = {};
|
|
4014
4697
|
let completed = 0;
|
|
4698
|
+
const iconPatterns = loadIconPatternsFromConfig(process.cwd());
|
|
4015
4699
|
const renderOne = async (name) => {
|
|
4016
4700
|
const descriptor = manifest.components[name];
|
|
4017
4701
|
if (descriptor === void 0) return;
|
|
4018
4702
|
const filePath = resolve(rootDir, descriptor.filePath);
|
|
4019
4703
|
const allCssFiles = loadGlobalCssFilesFromConfig(process.cwd());
|
|
4704
|
+
const isIcon = isIconComponent(descriptor.filePath, name, iconPatterns);
|
|
4020
4705
|
const scopeData = await loadScopeFileForComponent(filePath);
|
|
4021
4706
|
const scenarioEntries = scopeData !== null ? Object.entries(scopeData.scenarios) : [];
|
|
4022
4707
|
const defaultEntry = scenarioEntries.find(([k]) => k === "default") ?? scenarioEntries[0];
|
|
@@ -4029,7 +4714,8 @@ function registerRenderAll(renderCmd) {
|
|
|
4029
4714
|
812,
|
|
4030
4715
|
allCssFiles,
|
|
4031
4716
|
process.cwd(),
|
|
4032
|
-
wrapperScript
|
|
4717
|
+
wrapperScript,
|
|
4718
|
+
isIcon
|
|
4033
4719
|
);
|
|
4034
4720
|
const outcome = await safeRender(
|
|
4035
4721
|
() => renderer.renderCell(renderProps, descriptor.complexityClass),
|
|
@@ -4066,14 +4752,32 @@ function registerRenderAll(renderCmd) {
|
|
|
4066
4752
|
2
|
|
4067
4753
|
)
|
|
4068
4754
|
);
|
|
4755
|
+
failures.push({
|
|
4756
|
+
component: name,
|
|
4757
|
+
stage: "render",
|
|
4758
|
+
message: outcome.error.message,
|
|
4759
|
+
outputPath: errPath,
|
|
4760
|
+
hints: outcome.error.heuristicFlags
|
|
4761
|
+
});
|
|
4762
|
+
outputPaths.push(errPath);
|
|
4069
4763
|
return;
|
|
4070
4764
|
}
|
|
4071
4765
|
const result = outcome.result;
|
|
4072
4766
|
results.push({ name, renderTimeMs: result.renderTimeMs, success: true });
|
|
4073
|
-
|
|
4074
|
-
|
|
4767
|
+
if (!isIcon) {
|
|
4768
|
+
const pngPath = resolve(outputDir, `${name}.png`);
|
|
4769
|
+
writeFileSync(pngPath, result.screenshot);
|
|
4770
|
+
outputPaths.push(pngPath);
|
|
4771
|
+
}
|
|
4075
4772
|
const jsonPath = resolve(outputDir, `${name}.json`);
|
|
4076
|
-
|
|
4773
|
+
const renderJson = formatRenderJson(name, {}, result);
|
|
4774
|
+
const extResult = result;
|
|
4775
|
+
if (isIcon && extResult.svgContent) {
|
|
4776
|
+
renderJson.svgContent = extResult.svgContent;
|
|
4777
|
+
delete renderJson.screenshot;
|
|
4778
|
+
}
|
|
4779
|
+
writeFileSync(jsonPath, JSON.stringify(renderJson, null, 2));
|
|
4780
|
+
outputPaths.push(jsonPath);
|
|
4077
4781
|
const rawStyles = result.computedStyles["[data-reactscope-root] > *"] ?? {};
|
|
4078
4782
|
const compStyles = {
|
|
4079
4783
|
colors: {},
|
|
@@ -4139,15 +4843,21 @@ function registerRenderAll(renderCmd) {
|
|
|
4139
4843
|
existingJson.axisLabels = [scenarioAxis.values];
|
|
4140
4844
|
writeFileSync(jsonPath, JSON.stringify(existingJson, null, 2));
|
|
4141
4845
|
} catch (matrixErr) {
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
`
|
|
4145
|
-
|
|
4846
|
+
const message = matrixErr instanceof Error ? matrixErr.message : String(matrixErr);
|
|
4847
|
+
process.stderr.write(` [warn] Matrix render for ${name} failed: ${message}
|
|
4848
|
+
`);
|
|
4849
|
+
failures.push({
|
|
4850
|
+
component: name,
|
|
4851
|
+
stage: "matrix",
|
|
4852
|
+
message,
|
|
4853
|
+
outputPath: jsonPath
|
|
4854
|
+
});
|
|
4146
4855
|
}
|
|
4147
4856
|
}
|
|
4148
4857
|
if (isTTY()) {
|
|
4858
|
+
const suffix = isIcon ? " [icon/svg]" : "";
|
|
4149
4859
|
process.stdout.write(
|
|
4150
|
-
`\u2713 ${name} \u2192 ${opts.outputDir}/${name}.png (${result.width}\xD7${result.height}, ${result.renderTimeMs.toFixed(0)}ms)
|
|
4860
|
+
`\u2713 ${name} \u2192 ${opts.outputDir}/${name}${isIcon ? ".json" : ".png"} (${result.width}\xD7${result.height}, ${result.renderTimeMs.toFixed(0)}ms)${suffix}
|
|
4151
4861
|
`
|
|
4152
4862
|
);
|
|
4153
4863
|
}
|
|
@@ -4174,15 +4884,25 @@ function registerRenderAll(renderCmd) {
|
|
|
4174
4884
|
"compliance-styles.json"
|
|
4175
4885
|
);
|
|
4176
4886
|
writeFileSync(compStylesPath, JSON.stringify(complianceStylesMap, null, 2));
|
|
4887
|
+
outputPaths.push(compStylesPath);
|
|
4177
4888
|
process.stderr.write(`[scope/render] \u2713 Wrote compliance-styles.json
|
|
4178
4889
|
`);
|
|
4179
4890
|
process.stderr.write("\n");
|
|
4180
4891
|
const summary = formatSummaryText(results, outputDir);
|
|
4181
4892
|
process.stderr.write(`${summary}
|
|
4182
4893
|
`);
|
|
4894
|
+
const summaryPath = writeRunSummary({
|
|
4895
|
+
command: "scope render all",
|
|
4896
|
+
status: failures.length > 0 ? "failed" : "success",
|
|
4897
|
+
outputPaths,
|
|
4898
|
+
failures
|
|
4899
|
+
});
|
|
4900
|
+
process.stderr.write(`[scope/render] Run summary written to ${summaryPath}
|
|
4901
|
+
`);
|
|
4902
|
+
if (failures.length > 0) process.exit(1);
|
|
4183
4903
|
} catch (err) {
|
|
4184
4904
|
await shutdownPool3();
|
|
4185
|
-
process.stderr.write(
|
|
4905
|
+
process.stderr.write(`${formatScopeDiagnostic(err)}
|
|
4186
4906
|
`);
|
|
4187
4907
|
process.exit(1);
|
|
4188
4908
|
}
|
|
@@ -4215,7 +4935,7 @@ function resolveMatrixFormat(formatFlag, spriteAlreadyWritten) {
|
|
|
4215
4935
|
}
|
|
4216
4936
|
function createRenderCommand() {
|
|
4217
4937
|
const renderCmd = new Command("render").description(
|
|
4218
|
-
|
|
4938
|
+
'Render React components to PNG screenshots or JSON render data.\n\nScope uses two render engines depending on component complexity:\n Satori \u2014 fast SVG\u2192PNG renderer for simple/presentational components\n BrowserPool \u2014 Playwright headless browser for complex components\n (context providers, hooks, async, global CSS)\n\nPREREQUISITES:\n 1. reactscope.config.json exists (scope init)\n 2. manifest.json is up to date (scope manifest generate)\n 3. If using globalCSS: Tailwind/PostCSS is configured in the project\n\nOUTPUTS (written to .reactscope/renders/<ComponentName>/):\n screenshot.png retina-quality PNG (2\xD7 physical pixels, displayed at 1\xD7)\n render.json props, dimensions, DOM snapshot, a11y, computed styles\n compliance-styles.json (render all only) \u2014 token matching input\n\nExamples:\n scope render component Button\n scope render matrix Button --axes "variant:primary,ghost size:sm,md,lg"\n scope render all\n scope render all --format json --output-dir ./out'
|
|
4219
4939
|
);
|
|
4220
4940
|
registerRenderSingle(renderCmd);
|
|
4221
4941
|
registerRenderMatrix(renderCmd);
|
|
@@ -4242,8 +4962,17 @@ async function shutdownPool4() {
|
|
|
4242
4962
|
}
|
|
4243
4963
|
}
|
|
4244
4964
|
async function renderComponent2(filePath, componentName, props, viewportWidth, viewportHeight) {
|
|
4965
|
+
const PAD = 24;
|
|
4245
4966
|
const pool = await getPool4(viewportWidth, viewportHeight);
|
|
4246
|
-
const htmlHarness = await buildComponentHarness(
|
|
4967
|
+
const htmlHarness = await buildComponentHarness(
|
|
4968
|
+
filePath,
|
|
4969
|
+
componentName,
|
|
4970
|
+
props,
|
|
4971
|
+
viewportWidth,
|
|
4972
|
+
void 0,
|
|
4973
|
+
void 0,
|
|
4974
|
+
PAD
|
|
4975
|
+
);
|
|
4247
4976
|
const slot = await pool.acquire();
|
|
4248
4977
|
const { page } = slot;
|
|
4249
4978
|
try {
|
|
@@ -4283,7 +5012,6 @@ async function renderComponent2(filePath, componentName, props, viewportWidth, v
|
|
|
4283
5012
|
`Component "${componentName}" rendered with zero bounding box \u2014 it may be invisible or not mounted`
|
|
4284
5013
|
);
|
|
4285
5014
|
}
|
|
4286
|
-
const PAD = 24;
|
|
4287
5015
|
const MIN_W = 320;
|
|
4288
5016
|
const MIN_H = 200;
|
|
4289
5017
|
const clipX = Math.max(0, boundingBox.x - PAD);
|
|
@@ -4375,12 +5103,12 @@ async function runBaseline(options = {}) {
|
|
|
4375
5103
|
mkdirSync(rendersDir, { recursive: true });
|
|
4376
5104
|
let manifest;
|
|
4377
5105
|
if (manifestPath !== void 0) {
|
|
4378
|
-
const { readFileSync:
|
|
5106
|
+
const { readFileSync: readFileSync18 } = await import('fs');
|
|
4379
5107
|
const absPath = resolve(rootDir, manifestPath);
|
|
4380
5108
|
if (!existsSync(absPath)) {
|
|
4381
5109
|
throw new Error(`Manifest not found at ${absPath}.`);
|
|
4382
5110
|
}
|
|
4383
|
-
manifest = JSON.parse(
|
|
5111
|
+
manifest = JSON.parse(readFileSync18(absPath, "utf-8"));
|
|
4384
5112
|
process.stderr.write(`Loaded manifest from ${manifestPath}
|
|
4385
5113
|
`);
|
|
4386
5114
|
} else {
|
|
@@ -4572,8 +5300,17 @@ async function shutdownPool5() {
|
|
|
4572
5300
|
}
|
|
4573
5301
|
}
|
|
4574
5302
|
async function renderComponent3(filePath, componentName, props, viewportWidth, viewportHeight) {
|
|
5303
|
+
const PAD = 24;
|
|
4575
5304
|
const pool = await getPool5(viewportWidth, viewportHeight);
|
|
4576
|
-
const htmlHarness = await buildComponentHarness(
|
|
5305
|
+
const htmlHarness = await buildComponentHarness(
|
|
5306
|
+
filePath,
|
|
5307
|
+
componentName,
|
|
5308
|
+
props,
|
|
5309
|
+
viewportWidth,
|
|
5310
|
+
void 0,
|
|
5311
|
+
void 0,
|
|
5312
|
+
PAD
|
|
5313
|
+
);
|
|
4577
5314
|
const slot = await pool.acquire();
|
|
4578
5315
|
const { page } = slot;
|
|
4579
5316
|
try {
|
|
@@ -4613,7 +5350,6 @@ async function renderComponent3(filePath, componentName, props, viewportWidth, v
|
|
|
4613
5350
|
`Component "${componentName}" rendered with zero bounding box \u2014 it may be invisible or not mounted`
|
|
4614
5351
|
);
|
|
4615
5352
|
}
|
|
4616
|
-
const PAD = 24;
|
|
4617
5353
|
const MIN_W = 320;
|
|
4618
5354
|
const MIN_H = 200;
|
|
4619
5355
|
const clipX = Math.max(0, boundingBox.x - PAD);
|
|
@@ -4710,6 +5446,7 @@ function classifyComponent(entry, regressionThreshold) {
|
|
|
4710
5446
|
async function runDiff(options = {}) {
|
|
4711
5447
|
const {
|
|
4712
5448
|
baselineDir: baselineDirRaw = DEFAULT_BASELINE_DIR2,
|
|
5449
|
+
complianceTokens = [],
|
|
4713
5450
|
componentsGlob,
|
|
4714
5451
|
manifestPath,
|
|
4715
5452
|
viewportWidth = 375,
|
|
@@ -4825,7 +5562,7 @@ async function runDiff(options = {}) {
|
|
|
4825
5562
|
if (isTTY() && total > 0) {
|
|
4826
5563
|
process.stderr.write("\n");
|
|
4827
5564
|
}
|
|
4828
|
-
const resolver = new TokenResolver(
|
|
5565
|
+
const resolver = new TokenResolver(complianceTokens);
|
|
4829
5566
|
const engine = new ComplianceEngine(resolver);
|
|
4830
5567
|
const currentBatchReport = engine.auditBatch(computedStylesMap);
|
|
4831
5568
|
const entries = [];
|
|
@@ -5421,6 +6158,161 @@ function buildStructuredReport(report) {
|
|
|
5421
6158
|
route: report.route?.pattern ?? null
|
|
5422
6159
|
};
|
|
5423
6160
|
}
|
|
6161
|
+
async function buildPlaygroundHarness(filePath, componentName, projectCss, wrapperScript) {
|
|
6162
|
+
const bundledScript = await bundlePlaygroundIIFE(filePath, componentName);
|
|
6163
|
+
return wrapPlaygroundHtml(bundledScript, projectCss);
|
|
6164
|
+
}
|
|
6165
|
+
async function bundlePlaygroundIIFE(filePath, componentName) {
|
|
6166
|
+
const wrapperCode = (
|
|
6167
|
+
/* ts */
|
|
6168
|
+
`
|
|
6169
|
+
import * as __scopeMod from ${JSON.stringify(filePath)};
|
|
6170
|
+
import { createRoot } from "react-dom/client";
|
|
6171
|
+
import { createElement, Component as ReactComponent } from "react";
|
|
6172
|
+
|
|
6173
|
+
(function scopePlaygroundHarness() {
|
|
6174
|
+
var Target =
|
|
6175
|
+
__scopeMod["default"] ||
|
|
6176
|
+
__scopeMod[${JSON.stringify(componentName)}] ||
|
|
6177
|
+
(Object.values(__scopeMod).find(
|
|
6178
|
+
function(v) { return typeof v === "function" && /^[A-Z]/.test(v.name || ""); }
|
|
6179
|
+
));
|
|
6180
|
+
|
|
6181
|
+
if (!Target) {
|
|
6182
|
+
document.getElementById("scope-root").innerHTML =
|
|
6183
|
+
'<p style="color:#dc2626;font-family:system-ui;font-size:13px">No renderable component found.</p>';
|
|
6184
|
+
return;
|
|
6185
|
+
}
|
|
6186
|
+
|
|
6187
|
+
// Error boundary to catch async render errors (React unmounts the whole
|
|
6188
|
+
// root when an error is uncaught \u2014 this keeps the error visible instead).
|
|
6189
|
+
var errorStyle = "color:#dc2626;font-family:system-ui;font-size:13px;padding:12px";
|
|
6190
|
+
class ScopeBoundary extends ReactComponent {
|
|
6191
|
+
constructor(p) { super(p); this.state = { error: null }; }
|
|
6192
|
+
static getDerivedStateFromError(err) { return { error: err }; }
|
|
6193
|
+
render() {
|
|
6194
|
+
if (this.state.error) {
|
|
6195
|
+
return createElement("pre", { style: errorStyle },
|
|
6196
|
+
"Render error: " + (this.state.error.message || String(this.state.error)));
|
|
6197
|
+
}
|
|
6198
|
+
return this.props.children;
|
|
6199
|
+
}
|
|
6200
|
+
}
|
|
6201
|
+
|
|
6202
|
+
var rootEl = document.getElementById("scope-root");
|
|
6203
|
+
var root = createRoot(rootEl);
|
|
6204
|
+
var Wrapper = window.__SCOPE_WRAPPER__;
|
|
6205
|
+
|
|
6206
|
+
function render(props) {
|
|
6207
|
+
var inner = createElement(Target, props);
|
|
6208
|
+
if (Wrapper) inner = createElement(Wrapper, null, inner);
|
|
6209
|
+
root.render(createElement(ScopeBoundary, null, inner));
|
|
6210
|
+
}
|
|
6211
|
+
|
|
6212
|
+
// Render immediately with empty props
|
|
6213
|
+
render({});
|
|
6214
|
+
|
|
6215
|
+
// Listen for messages from the parent frame
|
|
6216
|
+
window.addEventListener("message", function(e) {
|
|
6217
|
+
if (!e.data) return;
|
|
6218
|
+
if (e.data.type === "scope-playground-props") {
|
|
6219
|
+
render(e.data.props || {});
|
|
6220
|
+
} else if (e.data.type === "scope-playground-theme") {
|
|
6221
|
+
document.documentElement.classList.toggle("dark", e.data.theme === "dark");
|
|
6222
|
+
}
|
|
6223
|
+
});
|
|
6224
|
+
|
|
6225
|
+
// Report content height changes to the parent frame
|
|
6226
|
+
var ro = new ResizeObserver(function() {
|
|
6227
|
+
var h = rootEl.scrollHeight;
|
|
6228
|
+
if (parent !== window) {
|
|
6229
|
+
parent.postMessage({ type: "scope-playground-height", height: h }, "*");
|
|
6230
|
+
}
|
|
6231
|
+
});
|
|
6232
|
+
ro.observe(rootEl);
|
|
6233
|
+
})();
|
|
6234
|
+
`
|
|
6235
|
+
);
|
|
6236
|
+
const result = await esbuild2.build({
|
|
6237
|
+
stdin: {
|
|
6238
|
+
contents: wrapperCode,
|
|
6239
|
+
resolveDir: dirname(filePath),
|
|
6240
|
+
loader: "tsx",
|
|
6241
|
+
sourcefile: "__scope_playground__.tsx"
|
|
6242
|
+
},
|
|
6243
|
+
bundle: true,
|
|
6244
|
+
format: "iife",
|
|
6245
|
+
write: false,
|
|
6246
|
+
platform: "browser",
|
|
6247
|
+
jsx: "automatic",
|
|
6248
|
+
jsxImportSource: "react",
|
|
6249
|
+
target: "es2020",
|
|
6250
|
+
external: [],
|
|
6251
|
+
define: {
|
|
6252
|
+
"process.env.NODE_ENV": '"production"',
|
|
6253
|
+
global: "globalThis"
|
|
6254
|
+
},
|
|
6255
|
+
logLevel: "silent",
|
|
6256
|
+
banner: {
|
|
6257
|
+
js: "/* @agent-scope/cli playground harness */"
|
|
6258
|
+
},
|
|
6259
|
+
loader: {
|
|
6260
|
+
".css": "empty",
|
|
6261
|
+
".svg": "dataurl",
|
|
6262
|
+
".png": "dataurl",
|
|
6263
|
+
".jpg": "dataurl",
|
|
6264
|
+
".jpeg": "dataurl",
|
|
6265
|
+
".gif": "dataurl",
|
|
6266
|
+
".webp": "dataurl",
|
|
6267
|
+
".ttf": "dataurl",
|
|
6268
|
+
".woff": "dataurl",
|
|
6269
|
+
".woff2": "dataurl"
|
|
6270
|
+
}
|
|
6271
|
+
});
|
|
6272
|
+
if (result.errors.length > 0) {
|
|
6273
|
+
const msg = result.errors.map((e) => `${e.text}${e.location ? ` (${e.location.file}:${e.location.line})` : ""}`).join("\n");
|
|
6274
|
+
throw new Error(`esbuild failed to bundle playground component:
|
|
6275
|
+
${msg}`);
|
|
6276
|
+
}
|
|
6277
|
+
const outputFile = result.outputFiles?.[0];
|
|
6278
|
+
if (outputFile === void 0 || outputFile.text.length === 0) {
|
|
6279
|
+
throw new Error("esbuild produced no playground output");
|
|
6280
|
+
}
|
|
6281
|
+
return outputFile.text;
|
|
6282
|
+
}
|
|
6283
|
+
function wrapPlaygroundHtml(bundledScript, projectCss, wrapperScript) {
|
|
6284
|
+
const projectStyleBlock = projectCss != null && projectCss.length > 0 ? `<style id="scope-project-css">
|
|
6285
|
+
${projectCss.replace(/<\/style>/gi, "<\\/style>")}
|
|
6286
|
+
</style>` : "";
|
|
6287
|
+
const wrapperScriptBlock = "";
|
|
6288
|
+
return `<!DOCTYPE html>
|
|
6289
|
+
<html lang="en">
|
|
6290
|
+
<head>
|
|
6291
|
+
<meta charset="UTF-8" />
|
|
6292
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6293
|
+
<script>
|
|
6294
|
+
window.__SCOPE_WRAPPER__ = null;
|
|
6295
|
+
// Prevent React DevTools from interfering with the embedded playground.
|
|
6296
|
+
// The hook causes render instability in same-origin iframes.
|
|
6297
|
+
delete window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
6298
|
+
</script>
|
|
6299
|
+
<style>
|
|
6300
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
6301
|
+
html, body { margin: 0; padding: 0; font-family: system-ui, sans-serif; }
|
|
6302
|
+
#scope-root { padding: 16px; min-width: 1px; min-height: 1px; }
|
|
6303
|
+
</style>
|
|
6304
|
+
${projectStyleBlock}
|
|
6305
|
+
<style>html, body { background: transparent !important; }</style>
|
|
6306
|
+
</head>
|
|
6307
|
+
<body>
|
|
6308
|
+
<div id="scope-root" data-reactscope-root></div>
|
|
6309
|
+
${wrapperScriptBlock}
|
|
6310
|
+
<script>${bundledScript}</script>
|
|
6311
|
+
</body>
|
|
6312
|
+
</html>`;
|
|
6313
|
+
}
|
|
6314
|
+
|
|
6315
|
+
// src/site-commands.ts
|
|
5424
6316
|
var MIME_TYPES = {
|
|
5425
6317
|
".html": "text/html; charset=utf-8",
|
|
5426
6318
|
".css": "text/css; charset=utf-8",
|
|
@@ -5432,8 +6324,419 @@ var MIME_TYPES = {
|
|
|
5432
6324
|
".svg": "image/svg+xml",
|
|
5433
6325
|
".ico": "image/x-icon"
|
|
5434
6326
|
};
|
|
6327
|
+
function slugify(name) {
|
|
6328
|
+
return name.replace(/([A-Z])/g, (m) => `-${m.toLowerCase()}`).replace(/^-/, "").replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
6329
|
+
}
|
|
6330
|
+
function loadGlobalCssFilesFromConfig2(cwd) {
|
|
6331
|
+
const configPath = resolve(cwd, "reactscope.config.json");
|
|
6332
|
+
if (!existsSync(configPath)) return [];
|
|
6333
|
+
try {
|
|
6334
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
6335
|
+
const cfg = JSON.parse(raw);
|
|
6336
|
+
return cfg.components?.wrappers?.globalCSS ?? [];
|
|
6337
|
+
} catch {
|
|
6338
|
+
return [];
|
|
6339
|
+
}
|
|
6340
|
+
}
|
|
6341
|
+
function loadIconPatternsFromConfig2(cwd) {
|
|
6342
|
+
const configPath = resolve(cwd, "reactscope.config.json");
|
|
6343
|
+
if (!existsSync(configPath)) return [];
|
|
6344
|
+
try {
|
|
6345
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
6346
|
+
const cfg = JSON.parse(raw);
|
|
6347
|
+
return cfg.icons?.patterns ?? [];
|
|
6348
|
+
} catch {
|
|
6349
|
+
return [];
|
|
6350
|
+
}
|
|
6351
|
+
}
|
|
6352
|
+
var LIVERELOAD_SCRIPT = `<script>(function(){var s=new EventSource("/__livereload");s.onmessage=function(e){if(e.data==="reload")location.reload()};s.onerror=function(){setTimeout(function(){location.reload()},2000)}})()</script>`;
|
|
6353
|
+
function injectLiveReloadScript(html) {
|
|
6354
|
+
const idx = html.lastIndexOf("</body>");
|
|
6355
|
+
if (idx >= 0) return html.slice(0, idx) + LIVERELOAD_SCRIPT + html.slice(idx);
|
|
6356
|
+
return html + LIVERELOAD_SCRIPT;
|
|
6357
|
+
}
|
|
6358
|
+
function loadWatchConfig(rootDir) {
|
|
6359
|
+
const configPath = resolve(rootDir, "reactscope.config.json");
|
|
6360
|
+
if (!existsSync(configPath)) return null;
|
|
6361
|
+
try {
|
|
6362
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
6363
|
+
const cfg = JSON.parse(raw);
|
|
6364
|
+
const result = {};
|
|
6365
|
+
const components = cfg.components;
|
|
6366
|
+
if (components && typeof components === "object") {
|
|
6367
|
+
if (Array.isArray(components.include)) result.include = components.include;
|
|
6368
|
+
if (Array.isArray(components.exclude)) result.exclude = components.exclude;
|
|
6369
|
+
}
|
|
6370
|
+
if (Array.isArray(cfg.internalPatterns))
|
|
6371
|
+
result.internalPatterns = cfg.internalPatterns;
|
|
6372
|
+
if (Array.isArray(cfg.collections)) result.collections = cfg.collections;
|
|
6373
|
+
const icons = cfg.icons;
|
|
6374
|
+
if (icons && typeof icons === "object" && Array.isArray(icons.patterns)) {
|
|
6375
|
+
result.iconPatterns = icons.patterns;
|
|
6376
|
+
}
|
|
6377
|
+
return result;
|
|
6378
|
+
} catch {
|
|
6379
|
+
return null;
|
|
6380
|
+
}
|
|
6381
|
+
}
|
|
6382
|
+
function watchGlob(pattern, filePath) {
|
|
6383
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
6384
|
+
const regexStr = escaped.replace(/\*\*/g, "\xA7GLOBSTAR\xA7").replace(/\*/g, "[^/]*").replace(/\u00a7GLOBSTAR\u00a7/g, ".*");
|
|
6385
|
+
return new RegExp(`^${regexStr}$`, "i").test(filePath);
|
|
6386
|
+
}
|
|
6387
|
+
function matchesWatchPatterns(filePath, include, exclude) {
|
|
6388
|
+
for (const pattern of exclude) {
|
|
6389
|
+
if (watchGlob(pattern, filePath)) return false;
|
|
6390
|
+
}
|
|
6391
|
+
for (const pattern of include) {
|
|
6392
|
+
if (watchGlob(pattern, filePath)) return true;
|
|
6393
|
+
}
|
|
6394
|
+
return false;
|
|
6395
|
+
}
|
|
6396
|
+
function findAffectedComponents(manifest, changedFiles, previousManifest) {
|
|
6397
|
+
const affected = /* @__PURE__ */ new Set();
|
|
6398
|
+
const normalised = changedFiles.map((f) => f.replace(/\\/g, "/"));
|
|
6399
|
+
for (const [name, descriptor] of Object.entries(manifest.components)) {
|
|
6400
|
+
const componentFile = descriptor.filePath.replace(/\\/g, "/");
|
|
6401
|
+
for (const changed of normalised) {
|
|
6402
|
+
if (componentFile === changed) {
|
|
6403
|
+
affected.add(name);
|
|
6404
|
+
break;
|
|
6405
|
+
}
|
|
6406
|
+
const scopeBase = changed.replace(/\.scope\.(ts|tsx|js|jsx)$/, "");
|
|
6407
|
+
const compBase = componentFile.replace(/\.(tsx|ts|jsx|js)$/, "");
|
|
6408
|
+
if (scopeBase !== changed && compBase === scopeBase) {
|
|
6409
|
+
affected.add(name);
|
|
6410
|
+
break;
|
|
6411
|
+
}
|
|
6412
|
+
}
|
|
6413
|
+
}
|
|
6414
|
+
if (previousManifest) {
|
|
6415
|
+
const oldNames = new Set(Object.keys(previousManifest.components));
|
|
6416
|
+
for (const name of Object.keys(manifest.components)) {
|
|
6417
|
+
if (!oldNames.has(name)) affected.add(name);
|
|
6418
|
+
}
|
|
6419
|
+
}
|
|
6420
|
+
return [...affected];
|
|
6421
|
+
}
|
|
6422
|
+
async function renderComponentsForWatch(manifest, componentNames, rootDir, inputDir) {
|
|
6423
|
+
if (componentNames.length === 0) return;
|
|
6424
|
+
const rendersDir = join(inputDir, "renders");
|
|
6425
|
+
await mkdir(rendersDir, { recursive: true });
|
|
6426
|
+
const cssFiles = loadGlobalCssFilesFromConfig2(rootDir);
|
|
6427
|
+
const iconPatterns = loadIconPatternsFromConfig2(rootDir);
|
|
6428
|
+
const complianceStylesPath = join(inputDir, "compliance-styles.json");
|
|
6429
|
+
let complianceStyles = {};
|
|
6430
|
+
if (existsSync(complianceStylesPath)) {
|
|
6431
|
+
try {
|
|
6432
|
+
complianceStyles = JSON.parse(readFileSync(complianceStylesPath, "utf-8"));
|
|
6433
|
+
} catch {
|
|
6434
|
+
}
|
|
6435
|
+
}
|
|
6436
|
+
for (const name of componentNames) {
|
|
6437
|
+
const descriptor = manifest.components[name];
|
|
6438
|
+
if (!descriptor) continue;
|
|
6439
|
+
const filePath = resolve(rootDir, descriptor.filePath);
|
|
6440
|
+
const isIcon = isIconComponent(descriptor.filePath, name, iconPatterns);
|
|
6441
|
+
let scopeData = null;
|
|
6442
|
+
try {
|
|
6443
|
+
scopeData = await loadScopeFileForComponent(filePath);
|
|
6444
|
+
} catch {
|
|
6445
|
+
}
|
|
6446
|
+
const scenarioEntries = scopeData ? Object.entries(scopeData.scenarios) : [];
|
|
6447
|
+
const defaultEntry = scenarioEntries.find(([k]) => k === "default") ?? scenarioEntries[0];
|
|
6448
|
+
const renderProps = defaultEntry?.[1] ?? {};
|
|
6449
|
+
let wrapperScript;
|
|
6450
|
+
try {
|
|
6451
|
+
wrapperScript = scopeData?.hasWrapper ? await buildWrapperScript(scopeData.filePath) : void 0;
|
|
6452
|
+
} catch {
|
|
6453
|
+
}
|
|
6454
|
+
const renderer = buildRenderer(
|
|
6455
|
+
filePath,
|
|
6456
|
+
name,
|
|
6457
|
+
375,
|
|
6458
|
+
812,
|
|
6459
|
+
cssFiles,
|
|
6460
|
+
rootDir,
|
|
6461
|
+
wrapperScript,
|
|
6462
|
+
isIcon
|
|
6463
|
+
);
|
|
6464
|
+
const outcome = await safeRender(
|
|
6465
|
+
() => renderer.renderCell(renderProps, descriptor.complexityClass),
|
|
6466
|
+
{
|
|
6467
|
+
props: renderProps,
|
|
6468
|
+
sourceLocation: { file: descriptor.filePath, line: descriptor.loc.start, column: 0 }
|
|
6469
|
+
}
|
|
6470
|
+
);
|
|
6471
|
+
if (outcome.crashed) {
|
|
6472
|
+
process.stderr.write(` \u2717 ${name}: ${outcome.error.message}
|
|
6473
|
+
`);
|
|
6474
|
+
continue;
|
|
6475
|
+
}
|
|
6476
|
+
const result = outcome.result;
|
|
6477
|
+
if (!isIcon) {
|
|
6478
|
+
writeFileSync(join(rendersDir, `${name}.png`), result.screenshot);
|
|
6479
|
+
}
|
|
6480
|
+
const renderJson = formatRenderJson(name, renderProps, result);
|
|
6481
|
+
const extResult = result;
|
|
6482
|
+
if (isIcon && extResult.svgContent) {
|
|
6483
|
+
renderJson.svgContent = extResult.svgContent;
|
|
6484
|
+
delete renderJson.screenshot;
|
|
6485
|
+
}
|
|
6486
|
+
writeFileSync(join(rendersDir, `${name}.json`), JSON.stringify(renderJson, null, 2));
|
|
6487
|
+
const rawStyles = result.computedStyles["[data-reactscope-root] > *"] ?? {};
|
|
6488
|
+
const compStyles = {
|
|
6489
|
+
colors: {},
|
|
6490
|
+
spacing: {},
|
|
6491
|
+
typography: {},
|
|
6492
|
+
borders: {},
|
|
6493
|
+
shadows: {}
|
|
6494
|
+
};
|
|
6495
|
+
for (const [prop, val] of Object.entries(rawStyles)) {
|
|
6496
|
+
if (!val || val === "none" || val === "") continue;
|
|
6497
|
+
const lower = prop.toLowerCase();
|
|
6498
|
+
if (lower.includes("color") || lower.includes("background")) {
|
|
6499
|
+
compStyles.colors[prop] = val;
|
|
6500
|
+
} else if (lower.includes("padding") || lower.includes("margin") || lower.includes("gap") || lower.includes("width") || lower.includes("height")) {
|
|
6501
|
+
compStyles.spacing[prop] = val;
|
|
6502
|
+
} else if (lower.includes("font") || lower.includes("lineheight") || lower.includes("letterspacing") || lower.includes("texttransform")) {
|
|
6503
|
+
compStyles.typography[prop] = val;
|
|
6504
|
+
} else if (lower.includes("border") || lower.includes("radius") || lower.includes("outline")) {
|
|
6505
|
+
compStyles.borders[prop] = val;
|
|
6506
|
+
} else if (lower.includes("shadow")) {
|
|
6507
|
+
compStyles.shadows[prop] = val;
|
|
6508
|
+
}
|
|
6509
|
+
}
|
|
6510
|
+
complianceStyles[name] = compStyles;
|
|
6511
|
+
process.stderr.write(` \u2713 ${name} (${result.renderTimeMs.toFixed(0)}ms)
|
|
6512
|
+
`);
|
|
6513
|
+
}
|
|
6514
|
+
await shutdownPool3();
|
|
6515
|
+
writeFileSync(complianceStylesPath, JSON.stringify(complianceStyles, null, 2), "utf-8");
|
|
6516
|
+
}
|
|
6517
|
+
async function watchRebuildSite(inputDir, outputDir, title, basePath) {
|
|
6518
|
+
const rootDir = process.cwd();
|
|
6519
|
+
await generatePlaygrounds(inputDir, outputDir);
|
|
6520
|
+
const iconPatterns = loadIconPatternsFromConfig2(rootDir);
|
|
6521
|
+
let tokenFilePath;
|
|
6522
|
+
const autoPath = resolve(rootDir, "reactscope.tokens.json");
|
|
6523
|
+
if (existsSync(autoPath)) tokenFilePath = autoPath;
|
|
6524
|
+
let compliancePath;
|
|
6525
|
+
const crPath = join(inputDir, "compliance-report.json");
|
|
6526
|
+
if (existsSync(crPath)) compliancePath = crPath;
|
|
6527
|
+
await buildSite({
|
|
6528
|
+
inputDir,
|
|
6529
|
+
outputDir,
|
|
6530
|
+
basePath,
|
|
6531
|
+
...compliancePath && { compliancePath },
|
|
6532
|
+
...tokenFilePath && { tokenFilePath },
|
|
6533
|
+
title,
|
|
6534
|
+
iconPatterns
|
|
6535
|
+
});
|
|
6536
|
+
}
|
|
6537
|
+
function findStaleComponents(manifest, previousManifest, rendersDir) {
|
|
6538
|
+
const stale = [];
|
|
6539
|
+
for (const [name, descriptor] of Object.entries(manifest.components)) {
|
|
6540
|
+
const jsonPath = join(rendersDir, `${name}.json`);
|
|
6541
|
+
if (!existsSync(jsonPath)) {
|
|
6542
|
+
stale.push(name);
|
|
6543
|
+
continue;
|
|
6544
|
+
}
|
|
6545
|
+
if (!previousManifest) continue;
|
|
6546
|
+
const prev = previousManifest.components[name];
|
|
6547
|
+
if (!prev) {
|
|
6548
|
+
stale.push(name);
|
|
6549
|
+
continue;
|
|
6550
|
+
}
|
|
6551
|
+
if (JSON.stringify(prev) !== JSON.stringify(descriptor)) {
|
|
6552
|
+
stale.push(name);
|
|
6553
|
+
}
|
|
6554
|
+
}
|
|
6555
|
+
return stale;
|
|
6556
|
+
}
|
|
6557
|
+
async function runFullBuild(rootDir, inputDir, outputDir, title, basePath) {
|
|
6558
|
+
process.stderr.write("[watch] Starting\u2026\n");
|
|
6559
|
+
const config = loadWatchConfig(rootDir);
|
|
6560
|
+
const manifestPath = join(inputDir, "manifest.json");
|
|
6561
|
+
let previousManifest = null;
|
|
6562
|
+
if (existsSync(manifestPath)) {
|
|
6563
|
+
try {
|
|
6564
|
+
previousManifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
6565
|
+
} catch {
|
|
6566
|
+
}
|
|
6567
|
+
}
|
|
6568
|
+
process.stderr.write("[watch] Generating manifest\u2026\n");
|
|
6569
|
+
const manifest = await generateManifest({
|
|
6570
|
+
rootDir,
|
|
6571
|
+
...config?.include && { include: config.include },
|
|
6572
|
+
...config?.exclude && { exclude: config.exclude },
|
|
6573
|
+
...config?.internalPatterns && { internalPatterns: config.internalPatterns },
|
|
6574
|
+
...config?.collections && { collections: config.collections },
|
|
6575
|
+
...config?.iconPatterns && { iconPatterns: config.iconPatterns }
|
|
6576
|
+
});
|
|
6577
|
+
await mkdir(inputDir, { recursive: true });
|
|
6578
|
+
writeFileSync(join(inputDir, "manifest.json"), JSON.stringify(manifest, null, 2), "utf-8");
|
|
6579
|
+
const count = Object.keys(manifest.components).length;
|
|
6580
|
+
process.stderr.write(`[watch] Found ${count} components
|
|
6581
|
+
`);
|
|
6582
|
+
const rendersDir = join(inputDir, "renders");
|
|
6583
|
+
const stale = findStaleComponents(manifest, previousManifest, rendersDir);
|
|
6584
|
+
if (stale.length > 0) {
|
|
6585
|
+
process.stderr.write(
|
|
6586
|
+
`[watch] Rendering ${stale.length} component(s) (${count - stale.length} already up-to-date)
|
|
6587
|
+
`
|
|
6588
|
+
);
|
|
6589
|
+
await renderComponentsForWatch(manifest, stale, rootDir, inputDir);
|
|
6590
|
+
} else {
|
|
6591
|
+
process.stderr.write("[watch] All renders up-to-date, skipping render step\n");
|
|
6592
|
+
}
|
|
6593
|
+
process.stderr.write("[watch] Building site\u2026\n");
|
|
6594
|
+
await watchRebuildSite(inputDir, outputDir, title, basePath);
|
|
6595
|
+
process.stderr.write("[watch] Ready\n");
|
|
6596
|
+
return manifest;
|
|
6597
|
+
}
|
|
6598
|
+
function startFileWatcher(opts) {
|
|
6599
|
+
const { rootDir, inputDir, outputDir, title, basePath, notifyReload } = opts;
|
|
6600
|
+
let previousManifest = opts.previousManifest;
|
|
6601
|
+
const config = loadWatchConfig(rootDir);
|
|
6602
|
+
const includePatterns = config?.include ?? ["src/**/*.tsx", "src/**/*.ts"];
|
|
6603
|
+
const excludePatterns = config?.exclude ?? [
|
|
6604
|
+
"**/node_modules/**",
|
|
6605
|
+
"**/*.test.*",
|
|
6606
|
+
"**/*.spec.*",
|
|
6607
|
+
"**/dist/**",
|
|
6608
|
+
"**/*.d.ts"
|
|
6609
|
+
];
|
|
6610
|
+
let debounceTimer = null;
|
|
6611
|
+
const pendingFiles = /* @__PURE__ */ new Set();
|
|
6612
|
+
let isRunning = false;
|
|
6613
|
+
const IGNORE_PREFIXES = ["node_modules/", ".reactscope/", "dist/", ".git/", ".next/", ".turbo/"];
|
|
6614
|
+
const handleChange = async () => {
|
|
6615
|
+
if (isRunning) return;
|
|
6616
|
+
isRunning = true;
|
|
6617
|
+
const changedFiles = [...pendingFiles];
|
|
6618
|
+
pendingFiles.clear();
|
|
6619
|
+
try {
|
|
6620
|
+
process.stderr.write(`
|
|
6621
|
+
[watch] ${changedFiles.length} file(s) changed
|
|
6622
|
+
`);
|
|
6623
|
+
process.stderr.write("[watch] Regenerating manifest\u2026\n");
|
|
6624
|
+
const newManifest = await generateManifest({
|
|
6625
|
+
rootDir,
|
|
6626
|
+
...config?.include && { include: config.include },
|
|
6627
|
+
...config?.exclude && { exclude: config.exclude },
|
|
6628
|
+
...config?.internalPatterns && { internalPatterns: config.internalPatterns },
|
|
6629
|
+
...config?.collections && { collections: config.collections },
|
|
6630
|
+
...config?.iconPatterns && { iconPatterns: config.iconPatterns }
|
|
6631
|
+
});
|
|
6632
|
+
writeFileSync(join(inputDir, "manifest.json"), JSON.stringify(newManifest, null, 2), "utf-8");
|
|
6633
|
+
const affected = findAffectedComponents(newManifest, changedFiles, previousManifest);
|
|
6634
|
+
if (affected.length > 0) {
|
|
6635
|
+
process.stderr.write(`[watch] Re-rendering: ${affected.join(", ")}
|
|
6636
|
+
`);
|
|
6637
|
+
await renderComponentsForWatch(newManifest, affected, rootDir, inputDir);
|
|
6638
|
+
} else {
|
|
6639
|
+
process.stderr.write("[watch] No components directly affected\n");
|
|
6640
|
+
}
|
|
6641
|
+
process.stderr.write("[watch] Rebuilding site\u2026\n");
|
|
6642
|
+
await watchRebuildSite(inputDir, outputDir, title, basePath);
|
|
6643
|
+
previousManifest = newManifest;
|
|
6644
|
+
process.stderr.write("[watch] Done\n");
|
|
6645
|
+
notifyReload();
|
|
6646
|
+
} catch (err) {
|
|
6647
|
+
process.stderr.write(`[watch] Error: ${err instanceof Error ? err.message : String(err)}
|
|
6648
|
+
`);
|
|
6649
|
+
} finally {
|
|
6650
|
+
isRunning = false;
|
|
6651
|
+
if (pendingFiles.size > 0) {
|
|
6652
|
+
handleChange();
|
|
6653
|
+
}
|
|
6654
|
+
}
|
|
6655
|
+
};
|
|
6656
|
+
const onFileChange = (_eventType, filename) => {
|
|
6657
|
+
if (!filename) return;
|
|
6658
|
+
const normalised = filename.replace(/\\/g, "/");
|
|
6659
|
+
for (const prefix of IGNORE_PREFIXES) {
|
|
6660
|
+
if (normalised.startsWith(prefix)) return;
|
|
6661
|
+
}
|
|
6662
|
+
if (!matchesWatchPatterns(normalised, includePatterns, excludePatterns)) return;
|
|
6663
|
+
pendingFiles.add(normalised);
|
|
6664
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
6665
|
+
debounceTimer = setTimeout(() => {
|
|
6666
|
+
debounceTimer = null;
|
|
6667
|
+
handleChange();
|
|
6668
|
+
}, 500);
|
|
6669
|
+
};
|
|
6670
|
+
try {
|
|
6671
|
+
watch(rootDir, { recursive: true }, onFileChange);
|
|
6672
|
+
process.stderr.write(`[watch] Watching for changes (${includePatterns.join(", ")})
|
|
6673
|
+
`);
|
|
6674
|
+
} catch (err) {
|
|
6675
|
+
process.stderr.write(
|
|
6676
|
+
`[watch] Warning: Could not start watcher: ${err instanceof Error ? err.message : String(err)}
|
|
6677
|
+
`
|
|
6678
|
+
);
|
|
6679
|
+
}
|
|
6680
|
+
}
|
|
6681
|
+
async function generatePlaygrounds(inputDir, outputDir) {
|
|
6682
|
+
const manifestPath = join(inputDir, "manifest.json");
|
|
6683
|
+
const raw = readFileSync(manifestPath, "utf-8");
|
|
6684
|
+
const manifest = JSON.parse(raw);
|
|
6685
|
+
const rootDir = process.cwd();
|
|
6686
|
+
const componentNames = Object.keys(manifest.components);
|
|
6687
|
+
if (componentNames.length === 0) return [];
|
|
6688
|
+
const playgroundDir = join(outputDir, "playground");
|
|
6689
|
+
await mkdir(playgroundDir, { recursive: true });
|
|
6690
|
+
const cssFiles = loadGlobalCssFilesFromConfig2(rootDir);
|
|
6691
|
+
const projectCss = await loadGlobalCss(cssFiles, rootDir) ?? void 0;
|
|
6692
|
+
let succeeded = 0;
|
|
6693
|
+
const failures = [];
|
|
6694
|
+
const allDefaults = {};
|
|
6695
|
+
for (const name of componentNames) {
|
|
6696
|
+
const descriptor = manifest.components[name];
|
|
6697
|
+
if (!descriptor) continue;
|
|
6698
|
+
const filePath = resolve(rootDir, descriptor.filePath);
|
|
6699
|
+
const slug = slugify(name);
|
|
6700
|
+
try {
|
|
6701
|
+
const scopeData = await loadScopeFileForComponent(filePath);
|
|
6702
|
+
if (scopeData) {
|
|
6703
|
+
const defaultScenario = scopeData.scenarios.default ?? Object.values(scopeData.scenarios)[0];
|
|
6704
|
+
if (defaultScenario) allDefaults[name] = defaultScenario;
|
|
6705
|
+
}
|
|
6706
|
+
} catch {
|
|
6707
|
+
}
|
|
6708
|
+
try {
|
|
6709
|
+
const html = await buildPlaygroundHarness(filePath, name, projectCss);
|
|
6710
|
+
await writeFile(join(playgroundDir, `${slug}.html`), html, "utf-8");
|
|
6711
|
+
succeeded++;
|
|
6712
|
+
} catch (err) {
|
|
6713
|
+
process.stderr.write(
|
|
6714
|
+
`[scope/site] \u26A0 playground skip: ${name} \u2014 ${err instanceof Error ? err.message : String(err)}
|
|
6715
|
+
`
|
|
6716
|
+
);
|
|
6717
|
+
failures.push({
|
|
6718
|
+
component: name,
|
|
6719
|
+
stage: "playground",
|
|
6720
|
+
message: err instanceof Error ? err.message : String(err),
|
|
6721
|
+
outputPath: join(playgroundDir, `${slug}.html`)
|
|
6722
|
+
});
|
|
6723
|
+
}
|
|
6724
|
+
}
|
|
6725
|
+
await writeFile(
|
|
6726
|
+
join(inputDir, "playground-defaults.json"),
|
|
6727
|
+
JSON.stringify(allDefaults, null, 2),
|
|
6728
|
+
"utf-8"
|
|
6729
|
+
);
|
|
6730
|
+
process.stderr.write(
|
|
6731
|
+
`[scope/site] Playgrounds: ${succeeded} built${failures.length > 0 ? `, ${failures.length} failed` : ""}
|
|
6732
|
+
`
|
|
6733
|
+
);
|
|
6734
|
+
return failures;
|
|
6735
|
+
}
|
|
5435
6736
|
function registerBuild(siteCmd) {
|
|
5436
|
-
siteCmd.command("build").description(
|
|
6737
|
+
siteCmd.command("build").description(
|
|
6738
|
+
'Build the static HTML site from manifest + render outputs.\n\nINPUT DIRECTORY (.reactscope/ by default) must contain:\n manifest.json component registry\n renders/ screenshots and render.json files from `scope render all`\n\nOPTIONAL:\n --compliance <path> include token compliance scores on detail pages\n --base-path <path> set if deploying to a subdirectory (e.g. /ui-docs)\n\nExamples:\n scope site build\n scope site build --title "Design System" -o .reactscope/site\n scope site build --compliance .reactscope/compliance-report.json\n scope site build --tokens reactscope.tokens.json\n scope site build --base-path /ui'
|
|
6739
|
+
).option("-i, --input <path>", "Path to .reactscope input directory", ".reactscope").option("-o, --output <path>", "Output directory for generated site", ".reactscope/site").option("--base-path <path>", "Base URL path prefix for subdirectory deployment", "/").option("--compliance <path>", "Path to compliance batch report JSON").option("--tokens <path>", "Path to reactscope.tokens.json (enables token browser page)").option("--title <text>", "Site title", "Scope \u2014 Component Gallery").action(
|
|
5437
6740
|
async (opts) => {
|
|
5438
6741
|
try {
|
|
5439
6742
|
const inputDir = resolve(process.cwd(), opts.input);
|
|
@@ -5453,6 +6756,16 @@ Run \`scope manifest generate\` first.`
|
|
|
5453
6756
|
}
|
|
5454
6757
|
process.stderr.write(`Building site from ${inputDir}\u2026
|
|
5455
6758
|
`);
|
|
6759
|
+
process.stderr.write("Bundling playgrounds\u2026\n");
|
|
6760
|
+
const failures = await generatePlaygrounds(inputDir, outputDir);
|
|
6761
|
+
const iconPatterns = loadIconPatternsFromConfig2(process.cwd());
|
|
6762
|
+
let tokenFilePath = opts.tokens ? resolve(process.cwd(), opts.tokens) : void 0;
|
|
6763
|
+
if (tokenFilePath === void 0) {
|
|
6764
|
+
const autoPath = resolve(process.cwd(), "reactscope.tokens.json");
|
|
6765
|
+
if (existsSync(autoPath)) {
|
|
6766
|
+
tokenFilePath = autoPath;
|
|
6767
|
+
}
|
|
6768
|
+
}
|
|
5456
6769
|
await buildSite({
|
|
5457
6770
|
inputDir,
|
|
5458
6771
|
outputDir,
|
|
@@ -5460,14 +6773,44 @@ Run \`scope manifest generate\` first.`
|
|
|
5460
6773
|
...opts.compliance !== void 0 && {
|
|
5461
6774
|
compliancePath: resolve(process.cwd(), opts.compliance)
|
|
5462
6775
|
},
|
|
5463
|
-
|
|
6776
|
+
...tokenFilePath !== void 0 && { tokenFilePath },
|
|
6777
|
+
title: opts.title,
|
|
6778
|
+
iconPatterns
|
|
6779
|
+
});
|
|
6780
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
6781
|
+
const componentCount = Object.keys(manifest.components).length;
|
|
6782
|
+
const generatedPlaygroundCount = componentCount === 0 ? 0 : statSync(join(outputDir, "playground")).isDirectory() ? componentCount - failures.length : 0;
|
|
6783
|
+
const siteFailures = [...failures];
|
|
6784
|
+
if (componentCount === 0) {
|
|
6785
|
+
siteFailures.push({
|
|
6786
|
+
component: "*",
|
|
6787
|
+
stage: "site",
|
|
6788
|
+
message: "Manifest contains zero components; generated site is structurally degraded.",
|
|
6789
|
+
outputPath: manifestPath
|
|
6790
|
+
});
|
|
6791
|
+
} else if (generatedPlaygroundCount === 0) {
|
|
6792
|
+
siteFailures.push({
|
|
6793
|
+
component: "*",
|
|
6794
|
+
stage: "site",
|
|
6795
|
+
message: "No playground pages were generated successfully; site build is degraded and should not be treated as green.",
|
|
6796
|
+
outputPath: join(outputDir, "playground")
|
|
6797
|
+
});
|
|
6798
|
+
}
|
|
6799
|
+
const summaryPath = writeRunSummary({
|
|
6800
|
+
command: "scope site build",
|
|
6801
|
+
status: siteFailures.length > 0 ? "failed" : "success",
|
|
6802
|
+
outputPaths: [outputDir, join(outputDir, "index.html")],
|
|
6803
|
+
failures: siteFailures
|
|
5464
6804
|
});
|
|
5465
6805
|
process.stderr.write(`Site written to ${outputDir}
|
|
6806
|
+
`);
|
|
6807
|
+
process.stderr.write(`[scope/site] Run summary written to ${summaryPath}
|
|
5466
6808
|
`);
|
|
5467
6809
|
process.stdout.write(`${outputDir}
|
|
5468
6810
|
`);
|
|
6811
|
+
if (siteFailures.length > 0) process.exit(1);
|
|
5469
6812
|
} catch (err) {
|
|
5470
|
-
process.stderr.write(
|
|
6813
|
+
process.stderr.write(`${formatScopeDiagnostic(err)}
|
|
5471
6814
|
`);
|
|
5472
6815
|
process.exit(1);
|
|
5473
6816
|
}
|
|
@@ -5475,71 +6818,143 @@ Run \`scope manifest generate\` first.`
|
|
|
5475
6818
|
);
|
|
5476
6819
|
}
|
|
5477
6820
|
function registerServe(siteCmd) {
|
|
5478
|
-
siteCmd.command("serve").description(
|
|
5479
|
-
|
|
5480
|
-
|
|
5481
|
-
|
|
5482
|
-
|
|
5483
|
-
|
|
5484
|
-
|
|
5485
|
-
|
|
5486
|
-
|
|
5487
|
-
|
|
5488
|
-
|
|
5489
|
-
|
|
5490
|
-
|
|
5491
|
-
|
|
5492
|
-
|
|
5493
|
-
const
|
|
5494
|
-
|
|
5495
|
-
|
|
5496
|
-
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
5497
|
-
res.end("Forbidden");
|
|
5498
|
-
return;
|
|
6821
|
+
siteCmd.command("serve").description(
|
|
6822
|
+
"Start a local HTTP server for the built site directory.\n\nRun `scope site build` first, or use --watch to auto-rebuild on changes.\nCtrl+C to stop.\n\nExamples:\n scope site serve\n scope site serve --port 8080\n scope site serve --dir ./my-site-output\n scope site serve --watch"
|
|
6823
|
+
).option("-p, --port <number>", "Port to listen on", "3000").option("-d, --dir <path>", "Directory to serve", ".reactscope/site").option("-w, --watch", "Watch source files and rebuild on changes").option(
|
|
6824
|
+
"-i, --input <path>",
|
|
6825
|
+
"Input directory for .reactscope data (watch mode)",
|
|
6826
|
+
".reactscope"
|
|
6827
|
+
).option("--title <text>", "Site title (watch mode)", "Scope \u2014 Component Gallery").option("--base-path <path>", "Base URL path prefix (watch mode)", "/").action(
|
|
6828
|
+
async (opts) => {
|
|
6829
|
+
try {
|
|
6830
|
+
let notifyReload2 = function() {
|
|
6831
|
+
for (const client of sseClients) {
|
|
6832
|
+
client.write("data: reload\n\n");
|
|
6833
|
+
}
|
|
6834
|
+
};
|
|
6835
|
+
var notifyReload = notifyReload2;
|
|
6836
|
+
const port = Number.parseInt(opts.port, 10);
|
|
6837
|
+
if (Number.isNaN(port) || port < 1 || port > 65535) {
|
|
6838
|
+
throw new Error(`Invalid port: ${opts.port}`);
|
|
5499
6839
|
}
|
|
5500
|
-
|
|
5501
|
-
|
|
5502
|
-
|
|
5503
|
-
|
|
5504
|
-
|
|
5505
|
-
return;
|
|
6840
|
+
const serveDir = resolve(process.cwd(), opts.dir);
|
|
6841
|
+
const watchMode = opts.watch === true;
|
|
6842
|
+
const sseClients = /* @__PURE__ */ new Set();
|
|
6843
|
+
if (watchMode) {
|
|
6844
|
+
await mkdir(serveDir, { recursive: true });
|
|
5506
6845
|
}
|
|
5507
|
-
|
|
5508
|
-
|
|
5509
|
-
|
|
5510
|
-
|
|
5511
|
-
|
|
6846
|
+
if (!watchMode && !existsSync(serveDir)) {
|
|
6847
|
+
throw new Error(
|
|
6848
|
+
`Serve directory not found: ${serveDir}
|
|
6849
|
+
Run \`scope site build\` first.`
|
|
6850
|
+
);
|
|
5512
6851
|
}
|
|
5513
|
-
|
|
5514
|
-
|
|
5515
|
-
|
|
5516
|
-
|
|
5517
|
-
|
|
6852
|
+
const server = createServer((req, res) => {
|
|
6853
|
+
const rawUrl = req.url ?? "/";
|
|
6854
|
+
const urlPath = decodeURIComponent(rawUrl.split("?")[0] ?? "/");
|
|
6855
|
+
if (watchMode && urlPath === "/__livereload") {
|
|
6856
|
+
res.writeHead(200, {
|
|
6857
|
+
"Content-Type": "text/event-stream",
|
|
6858
|
+
"Cache-Control": "no-cache",
|
|
6859
|
+
Connection: "keep-alive",
|
|
6860
|
+
"Access-Control-Allow-Origin": "*"
|
|
6861
|
+
});
|
|
6862
|
+
res.write("data: connected\n\n");
|
|
6863
|
+
sseClients.add(res);
|
|
6864
|
+
req.on("close", () => sseClients.delete(res));
|
|
6865
|
+
return;
|
|
6866
|
+
}
|
|
6867
|
+
const filePath = join(
|
|
6868
|
+
serveDir,
|
|
6869
|
+
urlPath.endsWith("/") ? `${urlPath}index.html` : urlPath
|
|
6870
|
+
);
|
|
6871
|
+
if (!filePath.startsWith(serveDir)) {
|
|
6872
|
+
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
6873
|
+
res.end("Forbidden");
|
|
6874
|
+
return;
|
|
6875
|
+
}
|
|
6876
|
+
if (existsSync(filePath) && statSync(filePath).isFile()) {
|
|
6877
|
+
const ext = extname(filePath).toLowerCase();
|
|
6878
|
+
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
|
|
6879
|
+
if (watchMode && ext === ".html") {
|
|
6880
|
+
const html = injectLiveReloadScript(readFileSync(filePath, "utf-8"));
|
|
6881
|
+
res.writeHead(200, { "Content-Type": contentType });
|
|
6882
|
+
res.end(html);
|
|
6883
|
+
return;
|
|
6884
|
+
}
|
|
6885
|
+
res.writeHead(200, { "Content-Type": contentType });
|
|
6886
|
+
createReadStream(filePath).pipe(res);
|
|
6887
|
+
return;
|
|
6888
|
+
}
|
|
6889
|
+
const htmlPath = `${filePath}.html`;
|
|
6890
|
+
if (existsSync(htmlPath) && statSync(htmlPath).isFile()) {
|
|
6891
|
+
if (watchMode) {
|
|
6892
|
+
const html = injectLiveReloadScript(readFileSync(htmlPath, "utf-8"));
|
|
6893
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
6894
|
+
res.end(html);
|
|
6895
|
+
return;
|
|
6896
|
+
}
|
|
6897
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
6898
|
+
createReadStream(htmlPath).pipe(res);
|
|
6899
|
+
return;
|
|
6900
|
+
}
|
|
6901
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
6902
|
+
res.end(`Not found: ${urlPath}`);
|
|
6903
|
+
});
|
|
6904
|
+
server.listen(port, () => {
|
|
6905
|
+
process.stderr.write(`Scope site running at http://localhost:${port}
|
|
5518
6906
|
`);
|
|
5519
|
-
|
|
6907
|
+
process.stderr.write(`Serving ${serveDir}
|
|
5520
6908
|
`);
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
|
|
5524
|
-
|
|
5525
|
-
|
|
6909
|
+
if (watchMode) {
|
|
6910
|
+
process.stderr.write(
|
|
6911
|
+
"Watch mode enabled \u2014 source changes trigger rebuild + browser reload\n"
|
|
6912
|
+
);
|
|
6913
|
+
}
|
|
6914
|
+
process.stderr.write("Press Ctrl+C to stop.\n");
|
|
6915
|
+
});
|
|
6916
|
+
server.on("error", (err) => {
|
|
6917
|
+
if (err.code === "EADDRINUSE") {
|
|
6918
|
+
process.stderr.write(`Error: Port ${port} is already in use.
|
|
5526
6919
|
`);
|
|
5527
|
-
|
|
5528
|
-
|
|
6920
|
+
} else {
|
|
6921
|
+
process.stderr.write(`Server error: ${err.message}
|
|
5529
6922
|
`);
|
|
6923
|
+
}
|
|
6924
|
+
process.exit(1);
|
|
6925
|
+
});
|
|
6926
|
+
if (watchMode) {
|
|
6927
|
+
const rootDir = process.cwd();
|
|
6928
|
+
const inputDir = resolve(rootDir, opts.input);
|
|
6929
|
+
const initialManifest = await runFullBuild(
|
|
6930
|
+
rootDir,
|
|
6931
|
+
inputDir,
|
|
6932
|
+
serveDir,
|
|
6933
|
+
opts.title,
|
|
6934
|
+
opts.basePath
|
|
6935
|
+
);
|
|
6936
|
+
notifyReload2();
|
|
6937
|
+
startFileWatcher({
|
|
6938
|
+
rootDir,
|
|
6939
|
+
inputDir,
|
|
6940
|
+
outputDir: serveDir,
|
|
6941
|
+
title: opts.title,
|
|
6942
|
+
basePath: opts.basePath,
|
|
6943
|
+
previousManifest: initialManifest,
|
|
6944
|
+
notifyReload: notifyReload2
|
|
6945
|
+
});
|
|
5530
6946
|
}
|
|
5531
|
-
|
|
5532
|
-
|
|
5533
|
-
} catch (err) {
|
|
5534
|
-
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
6947
|
+
} catch (err) {
|
|
6948
|
+
process.stderr.write(`${formatScopeDiagnostic(err)}
|
|
5535
6949
|
`);
|
|
5536
|
-
|
|
6950
|
+
process.exit(1);
|
|
6951
|
+
}
|
|
5537
6952
|
}
|
|
5538
|
-
|
|
6953
|
+
);
|
|
5539
6954
|
}
|
|
5540
6955
|
function createSiteCommand() {
|
|
5541
6956
|
const siteCmd = new Command("site").description(
|
|
5542
|
-
|
|
6957
|
+
'Build and serve the static HTML component gallery site.\n\nPREREQUISITES:\n scope manifest generate (manifest.json)\n scope render all (renders/ + compliance-styles.json)\n\nSITE CONTENTS:\n /index.html component gallery with screenshots + metadata\n /<component>/index.html detail page: props, renders, matrix, X-Ray, compliance\n\nExamples:\n scope site build && scope site serve\n scope site build --title "Acme UI" --compliance .reactscope/compliance-report.json\n scope site serve --port 8080'
|
|
5543
6958
|
);
|
|
5544
6959
|
registerBuild(siteCmd);
|
|
5545
6960
|
registerServe(siteCmd);
|
|
@@ -5583,11 +6998,11 @@ function categoryForProperty(property) {
|
|
|
5583
6998
|
}
|
|
5584
6999
|
function buildCategorySummary(batch) {
|
|
5585
7000
|
const cats = {
|
|
5586
|
-
color: { total: 0, onSystem: 0, offSystem: 0, compliance:
|
|
5587
|
-
spacing: { total: 0, onSystem: 0, offSystem: 0, compliance:
|
|
5588
|
-
typography: { total: 0, onSystem: 0, offSystem: 0, compliance:
|
|
5589
|
-
border: { total: 0, onSystem: 0, offSystem: 0, compliance:
|
|
5590
|
-
shadow: { total: 0, onSystem: 0, offSystem: 0, compliance:
|
|
7001
|
+
color: { total: 0, onSystem: 0, offSystem: 0, compliance: 0 },
|
|
7002
|
+
spacing: { total: 0, onSystem: 0, offSystem: 0, compliance: 0 },
|
|
7003
|
+
typography: { total: 0, onSystem: 0, offSystem: 0, compliance: 0 },
|
|
7004
|
+
border: { total: 0, onSystem: 0, offSystem: 0, compliance: 0 },
|
|
7005
|
+
shadow: { total: 0, onSystem: 0, offSystem: 0, compliance: 0 }
|
|
5591
7006
|
};
|
|
5592
7007
|
for (const report of Object.values(batch.components)) {
|
|
5593
7008
|
for (const [property, result] of Object.entries(report.properties)) {
|
|
@@ -5603,7 +7018,7 @@ function buildCategorySummary(batch) {
|
|
|
5603
7018
|
}
|
|
5604
7019
|
}
|
|
5605
7020
|
for (const summary of Object.values(cats)) {
|
|
5606
|
-
summary.compliance = summary.total === 0 ?
|
|
7021
|
+
summary.compliance = summary.total === 0 ? 0 : summary.onSystem / summary.total;
|
|
5607
7022
|
}
|
|
5608
7023
|
return cats;
|
|
5609
7024
|
}
|
|
@@ -5644,6 +7059,11 @@ function formatComplianceReport(batch, threshold) {
|
|
|
5644
7059
|
const lines = [];
|
|
5645
7060
|
const thresholdLabel = threshold !== void 0 ? pct >= threshold ? " \u2713 (pass)" : ` \u2717 (below threshold ${threshold}%)` : "";
|
|
5646
7061
|
lines.push(`Overall compliance score: ${pct}%${thresholdLabel}`);
|
|
7062
|
+
if (batch.totalProperties === 0) {
|
|
7063
|
+
lines.push(
|
|
7064
|
+
"No CSS properties were audited; run `scope render all` and inspect .reactscope/compliance-styles.json before treating compliance as green."
|
|
7065
|
+
);
|
|
7066
|
+
}
|
|
5647
7067
|
lines.push("");
|
|
5648
7068
|
const cats = buildCategorySummary(batch);
|
|
5649
7069
|
const catEntries = Object.entries(cats).filter(([, s]) => s.total > 0);
|
|
@@ -5677,41 +7097,85 @@ function formatComplianceReport(batch, threshold) {
|
|
|
5677
7097
|
return lines.join("\n");
|
|
5678
7098
|
}
|
|
5679
7099
|
function registerCompliance(tokensCmd) {
|
|
5680
|
-
tokensCmd.command("compliance").description(
|
|
5681
|
-
|
|
5682
|
-
|
|
5683
|
-
|
|
5684
|
-
|
|
5685
|
-
|
|
5686
|
-
|
|
5687
|
-
|
|
5688
|
-
|
|
5689
|
-
|
|
5690
|
-
|
|
5691
|
-
|
|
5692
|
-
|
|
5693
|
-
|
|
7100
|
+
tokensCmd.command("compliance").description(
|
|
7101
|
+
"Compute a token compliance score across all rendered components.\n\nCompares computed CSS values from .reactscope/compliance-styles.json\nagainst the token file \u2014 reports what % of style values are on-token.\n\nPREREQUISITES:\n scope render all must have run first (produces compliance-styles.json)\n\nSCORING:\n compliant value exactly matches a token\n near-match value is within tolerance of a token (e.g. close color)\n off-token value not found in token file\n\nEXIT CODES:\n 0 compliance >= threshold (or no --threshold set)\n 1 compliance < threshold\n\nExamples:\n scope tokens compliance\n scope tokens compliance --threshold 90\n scope tokens compliance --format json | jq '.summary'\n scope tokens compliance --out .reactscope/compliance-report.json\n scope tokens compliance --styles ./custom/compliance-styles.json"
|
|
7102
|
+
).option("--file <path>", "Path to token file (overrides config)").option("--styles <path>", `Path to compliance styles JSON (default: ${DEFAULT_STYLES_PATH})`).option(
|
|
7103
|
+
"--out <path>",
|
|
7104
|
+
"Write JSON report to file (for use with scope site build --compliance)"
|
|
7105
|
+
).option("--threshold <n>", "Exit code 1 if compliance score is below this percentage (0-100)").option("--format <fmt>", "Output format: json or text (default: auto-detect)").action(
|
|
7106
|
+
(opts) => {
|
|
7107
|
+
try {
|
|
7108
|
+
const tokenFilePath = resolveTokenFilePath(opts.file);
|
|
7109
|
+
const { tokens } = loadTokens(tokenFilePath);
|
|
7110
|
+
const resolver = new TokenResolver(tokens);
|
|
7111
|
+
const engine = new ComplianceEngine(resolver);
|
|
7112
|
+
const stylesPath = opts.styles ?? DEFAULT_STYLES_PATH;
|
|
7113
|
+
const stylesFile = loadStylesFile(stylesPath);
|
|
7114
|
+
const componentMap = /* @__PURE__ */ new Map();
|
|
7115
|
+
for (const [name, styles] of Object.entries(stylesFile)) {
|
|
7116
|
+
componentMap.set(name, styles);
|
|
7117
|
+
}
|
|
7118
|
+
if (componentMap.size === 0) {
|
|
7119
|
+
process.stderr.write(`Warning: No components found in styles file at ${stylesPath}
|
|
5694
7120
|
`);
|
|
5695
|
-
|
|
5696
|
-
|
|
5697
|
-
|
|
5698
|
-
|
|
5699
|
-
|
|
5700
|
-
|
|
7121
|
+
}
|
|
7122
|
+
const batch = engine.auditBatch(componentMap);
|
|
7123
|
+
const threshold = opts.threshold !== void 0 ? Number.parseInt(opts.threshold, 10) : void 0;
|
|
7124
|
+
const failures = [];
|
|
7125
|
+
if (batch.totalProperties === 0) {
|
|
7126
|
+
failures.push({
|
|
7127
|
+
component: "*",
|
|
7128
|
+
stage: "compliance",
|
|
7129
|
+
message: `No CSS properties were audited from ${stylesPath}; refusing to report silent success.`,
|
|
7130
|
+
outputPath: stylesPath
|
|
7131
|
+
});
|
|
7132
|
+
} else if (threshold !== void 0 && Math.round(batch.aggregateCompliance * 100) < threshold) {
|
|
7133
|
+
failures.push({
|
|
7134
|
+
component: "*",
|
|
7135
|
+
stage: "compliance",
|
|
7136
|
+
message: `Compliance ${Math.round(batch.aggregateCompliance * 100)}% is below threshold ${threshold}%.`,
|
|
7137
|
+
outputPath: opts.out ?? ".reactscope/compliance-report.json"
|
|
7138
|
+
});
|
|
7139
|
+
}
|
|
7140
|
+
if (opts.out !== void 0) {
|
|
7141
|
+
const outPath = resolve(process.cwd(), opts.out);
|
|
7142
|
+
writeFileSync(outPath, JSON.stringify(batch, null, 2), "utf-8");
|
|
7143
|
+
process.stderr.write(`Compliance report written to ${outPath}
|
|
5701
7144
|
`);
|
|
5702
|
-
|
|
5703
|
-
|
|
7145
|
+
}
|
|
7146
|
+
const useJson = opts.format === "json" || opts.format !== "text" && !isTTY();
|
|
7147
|
+
if (useJson) {
|
|
7148
|
+
process.stdout.write(`${JSON.stringify(batch, null, 2)}
|
|
7149
|
+
`);
|
|
7150
|
+
} else {
|
|
7151
|
+
process.stdout.write(`${formatComplianceReport(batch, threshold)}
|
|
7152
|
+
`);
|
|
7153
|
+
}
|
|
7154
|
+
const summaryPath = writeRunSummary({
|
|
7155
|
+
command: "scope tokens compliance",
|
|
7156
|
+
status: failures.length > 0 ? "failed" : "success",
|
|
7157
|
+
outputPaths: [opts.out ?? ".reactscope/compliance-report.json", stylesPath],
|
|
7158
|
+
compliance: {
|
|
7159
|
+
auditedProperties: batch.totalProperties,
|
|
7160
|
+
onSystemProperties: batch.totalOnSystem,
|
|
7161
|
+
offSystemProperties: batch.totalOffSystem,
|
|
7162
|
+
score: Math.round(batch.aggregateCompliance * 100),
|
|
7163
|
+
threshold
|
|
7164
|
+
},
|
|
7165
|
+
failures
|
|
7166
|
+
});
|
|
7167
|
+
process.stderr.write(`[scope/tokens] Run summary written to ${summaryPath}
|
|
7168
|
+
`);
|
|
7169
|
+
if (failures.length > 0) {
|
|
7170
|
+
process.exit(1);
|
|
7171
|
+
}
|
|
7172
|
+
} catch (err) {
|
|
7173
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
5704
7174
|
`);
|
|
5705
|
-
}
|
|
5706
|
-
if (threshold !== void 0 && Math.round(batch.aggregateCompliance * 100) < threshold) {
|
|
5707
7175
|
process.exit(1);
|
|
5708
7176
|
}
|
|
5709
|
-
} catch (err) {
|
|
5710
|
-
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
5711
|
-
`);
|
|
5712
|
-
process.exit(1);
|
|
5713
7177
|
}
|
|
5714
|
-
|
|
7178
|
+
);
|
|
5715
7179
|
}
|
|
5716
7180
|
var DEFAULT_TOKEN_FILE = "reactscope.tokens.json";
|
|
5717
7181
|
var CONFIG_FILE = "reactscope.config.json";
|
|
@@ -5735,7 +7199,9 @@ function resolveTokenFilePath2(fileFlag) {
|
|
|
5735
7199
|
return resolve(process.cwd(), DEFAULT_TOKEN_FILE);
|
|
5736
7200
|
}
|
|
5737
7201
|
function createTokensExportCommand() {
|
|
5738
|
-
return new Command("export").description(
|
|
7202
|
+
return new Command("export").description(
|
|
7203
|
+
'Export design tokens to CSS variables, TypeScript, SCSS, Tailwind config, Figma, or flat JSON.\n\nFORMATS:\n css CSS custom properties (:root { --color-primary-500: #3b82f6; })\n scss SCSS variables ($color-primary-500: #3b82f6;)\n ts TypeScript const export (export const tokens = {...})\n tailwind Tailwind theme.extend block (paste into tailwind.config.js)\n flat-json Flat { path: value } map (useful for tooling integration)\n figma Figma Tokens plugin format\n\nExamples:\n scope tokens export --format css --out src/tokens.css\n scope tokens export --format css --prefix brand --selector ":root, [data-theme]"\n scope tokens export --format tailwind --out tailwind-tokens.js\n scope tokens export --format ts --out src/tokens.ts\n scope tokens export --format css --theme dark --out dark-tokens.css'
|
|
7204
|
+
).requiredOption("--format <fmt>", `Output format: ${SUPPORTED_FORMATS.join(", ")}`).option("--file <path>", "Path to token file (overrides config)").option("--out <path>", "Write output to file instead of stdout").option("--prefix <prefix>", "CSS/SCSS: prefix for variable names (e.g. 'scope')").option("--selector <selector>", "CSS: custom root selector (default: ':root')").option(
|
|
5739
7205
|
"--theme <name>",
|
|
5740
7206
|
"Include theme overrides for the named theme (applies to css, ts, scss, tailwind, figma)"
|
|
5741
7207
|
).action(
|
|
@@ -5875,7 +7341,9 @@ function formatImpactSummary(report) {
|
|
|
5875
7341
|
return `\u2192 ${parts.join(", ")}`;
|
|
5876
7342
|
}
|
|
5877
7343
|
function registerImpact(tokensCmd) {
|
|
5878
|
-
tokensCmd.command("impact <path>").description(
|
|
7344
|
+
tokensCmd.command("impact <path>").description(
|
|
7345
|
+
"List every component and CSS element that uses a given token.\nUse this to understand the blast radius before changing a token value.\n\nPREREQUISITE: scope render all (populates compliance-styles.json)\n\nExamples:\n scope tokens impact color.primary.500\n scope tokens impact spacing.4 --format json\n scope tokens impact font.size.base | grep Button"
|
|
7346
|
+
).option("--file <path>", "Path to token file (overrides config)").option("--styles <path>", `Path to compliance styles JSON (default: ${DEFAULT_STYLES_PATH2})`).option("--new-value <value>", "Proposed new value \u2014 report visual severity of the change").option("--format <fmt>", "Output format: json or text (default: auto-detect)").action(
|
|
5879
7347
|
(tokenPath, opts) => {
|
|
5880
7348
|
try {
|
|
5881
7349
|
const tokenFilePath = resolveTokenFilePath(opts.file);
|
|
@@ -5912,18 +7380,242 @@ ${formatImpactSummary(report)}
|
|
|
5912
7380
|
}
|
|
5913
7381
|
);
|
|
5914
7382
|
}
|
|
7383
|
+
var DEFAULT_TOKEN_FILE2 = "reactscope.tokens.json";
|
|
7384
|
+
var CONFIG_FILE2 = "reactscope.config.json";
|
|
7385
|
+
function resolveOutputPath(fileFlag) {
|
|
7386
|
+
if (fileFlag !== void 0) {
|
|
7387
|
+
return resolve(process.cwd(), fileFlag);
|
|
7388
|
+
}
|
|
7389
|
+
const configPath = resolve(process.cwd(), CONFIG_FILE2);
|
|
7390
|
+
if (existsSync(configPath)) {
|
|
7391
|
+
try {
|
|
7392
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
7393
|
+
const config = JSON.parse(raw);
|
|
7394
|
+
if (typeof config === "object" && config !== null && "tokens" in config && typeof config.tokens === "object" && config.tokens !== null && typeof config.tokens?.file === "string") {
|
|
7395
|
+
const file = config.tokens.file;
|
|
7396
|
+
return resolve(process.cwd(), file);
|
|
7397
|
+
}
|
|
7398
|
+
} catch {
|
|
7399
|
+
}
|
|
7400
|
+
}
|
|
7401
|
+
return resolve(process.cwd(), DEFAULT_TOKEN_FILE2);
|
|
7402
|
+
}
|
|
7403
|
+
var CSS_VAR_RE = /--([\w-]+)\s*:\s*([^;]+)/g;
|
|
7404
|
+
var HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
|
7405
|
+
var COLOR_FN_RE = /^(?:rgba?|hsla?|oklch|oklab|lch|lab|color|hwb)\(/;
|
|
7406
|
+
var DIMENSION_RE = /^-?\d+(?:\.\d+)?(?:px|rem|em|%|vw|vh|ch|ex|cap|lh|dvh|svh|lvh)$/;
|
|
7407
|
+
var DURATION_RE = /^-?\d+(?:\.\d+)?(?:ms|s)$/;
|
|
7408
|
+
var FONT_FAMILY_RE = /^["']|,\s*(?:sans-serif|serif|monospace|cursive|fantasy|system-ui)/;
|
|
7409
|
+
var NUMBER_RE = /^-?\d+(?:\.\d+)?$/;
|
|
7410
|
+
var CUBIC_BEZIER_RE = /^cubic-bezier\(/;
|
|
7411
|
+
var SHADOW_RE = /^\d.*(?:px|rem|em)\s+(?:#|rgba?|hsla?|oklch|oklab)/i;
|
|
7412
|
+
function inferTokenType(value) {
|
|
7413
|
+
const v = value.trim();
|
|
7414
|
+
if (HEX_COLOR_RE.test(v) || COLOR_FN_RE.test(v)) return "color";
|
|
7415
|
+
if (DURATION_RE.test(v)) return "duration";
|
|
7416
|
+
if (DIMENSION_RE.test(v)) return "dimension";
|
|
7417
|
+
if (FONT_FAMILY_RE.test(v)) return "fontFamily";
|
|
7418
|
+
if (CUBIC_BEZIER_RE.test(v)) return "cubicBezier";
|
|
7419
|
+
if (SHADOW_RE.test(v)) return "shadow";
|
|
7420
|
+
if (NUMBER_RE.test(v)) return "number";
|
|
7421
|
+
return "color";
|
|
7422
|
+
}
|
|
7423
|
+
function setNestedToken(root, segments, value, type) {
|
|
7424
|
+
let node = root;
|
|
7425
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
7426
|
+
const seg = segments[i];
|
|
7427
|
+
if (seg === void 0) continue;
|
|
7428
|
+
if (!(seg in node) || typeof node[seg] !== "object" || node[seg] === null) {
|
|
7429
|
+
node[seg] = {};
|
|
7430
|
+
}
|
|
7431
|
+
node = node[seg];
|
|
7432
|
+
}
|
|
7433
|
+
const leaf = segments[segments.length - 1];
|
|
7434
|
+
if (leaf === void 0) return;
|
|
7435
|
+
node[leaf] = { value, type };
|
|
7436
|
+
}
|
|
7437
|
+
function extractBlockBody(css, openBrace) {
|
|
7438
|
+
let depth = 0;
|
|
7439
|
+
let end = -1;
|
|
7440
|
+
for (let i = openBrace; i < css.length; i++) {
|
|
7441
|
+
if (css[i] === "{") depth++;
|
|
7442
|
+
else if (css[i] === "}") {
|
|
7443
|
+
depth--;
|
|
7444
|
+
if (depth === 0) {
|
|
7445
|
+
end = i;
|
|
7446
|
+
break;
|
|
7447
|
+
}
|
|
7448
|
+
}
|
|
7449
|
+
}
|
|
7450
|
+
if (end === -1) return "";
|
|
7451
|
+
return css.slice(openBrace + 1, end);
|
|
7452
|
+
}
|
|
7453
|
+
function parseScopedBlocks(css) {
|
|
7454
|
+
const blocks = [];
|
|
7455
|
+
const blockRe = /(?::root|@theme(?:\s+inline)?|\.dark\.high-contrast|\.dark)\s*\{/g;
|
|
7456
|
+
let match = blockRe.exec(css);
|
|
7457
|
+
while (match !== null) {
|
|
7458
|
+
const selector = match[0];
|
|
7459
|
+
const braceIdx = css.indexOf("{", match.index);
|
|
7460
|
+
if (braceIdx === -1) {
|
|
7461
|
+
match = blockRe.exec(css);
|
|
7462
|
+
continue;
|
|
7463
|
+
}
|
|
7464
|
+
const body = extractBlockBody(css, braceIdx);
|
|
7465
|
+
let scope;
|
|
7466
|
+
if (selector.includes(".dark.high-contrast")) scope = "dark-high-contrast";
|
|
7467
|
+
else if (selector.includes(".dark")) scope = "dark";
|
|
7468
|
+
else if (selector.includes("@theme")) scope = "theme";
|
|
7469
|
+
else scope = "root";
|
|
7470
|
+
blocks.push({ scope, body });
|
|
7471
|
+
match = blockRe.exec(css);
|
|
7472
|
+
}
|
|
7473
|
+
return blocks;
|
|
7474
|
+
}
|
|
7475
|
+
function extractVarsFromBody(body) {
|
|
7476
|
+
const results = [];
|
|
7477
|
+
for (const m of body.matchAll(CSS_VAR_RE)) {
|
|
7478
|
+
const name = m[1];
|
|
7479
|
+
const value = m[2]?.trim();
|
|
7480
|
+
if (name === void 0 || value === void 0 || value.length === 0) continue;
|
|
7481
|
+
if (value.startsWith("var(") || value.startsWith("calc(")) continue;
|
|
7482
|
+
results.push({ name, value });
|
|
7483
|
+
}
|
|
7484
|
+
return results;
|
|
7485
|
+
}
|
|
7486
|
+
function extractCSSCustomProperties(tokenSources) {
|
|
7487
|
+
const cssSources = tokenSources.filter(
|
|
7488
|
+
(s) => s.kind === "css-custom-properties" || s.kind === "tailwind-v4-theme"
|
|
7489
|
+
);
|
|
7490
|
+
if (cssSources.length === 0) return null;
|
|
7491
|
+
const tokens = {};
|
|
7492
|
+
const themes = {};
|
|
7493
|
+
let found = false;
|
|
7494
|
+
for (const source of cssSources) {
|
|
7495
|
+
try {
|
|
7496
|
+
if (source.path.includes("compiled") || source.path.includes(".min.")) continue;
|
|
7497
|
+
const raw = readFileSync(source.path, "utf-8");
|
|
7498
|
+
const blocks = parseScopedBlocks(raw);
|
|
7499
|
+
for (const block of blocks) {
|
|
7500
|
+
const vars = extractVarsFromBody(block.body);
|
|
7501
|
+
for (const { name, value } of vars) {
|
|
7502
|
+
const segments = name.split("-").filter(Boolean);
|
|
7503
|
+
if (segments.length === 0) continue;
|
|
7504
|
+
if (block.scope === "root" || block.scope === "theme") {
|
|
7505
|
+
const type = inferTokenType(value);
|
|
7506
|
+
setNestedToken(tokens, segments, value, type);
|
|
7507
|
+
found = true;
|
|
7508
|
+
} else {
|
|
7509
|
+
const themeName = block.scope;
|
|
7510
|
+
if (!themes[themeName]) themes[themeName] = {};
|
|
7511
|
+
const path = segments.join(".");
|
|
7512
|
+
themes[themeName][path] = value;
|
|
7513
|
+
found = true;
|
|
7514
|
+
}
|
|
7515
|
+
}
|
|
7516
|
+
}
|
|
7517
|
+
} catch {
|
|
7518
|
+
}
|
|
7519
|
+
}
|
|
7520
|
+
return found ? { tokens, themes } : null;
|
|
7521
|
+
}
|
|
7522
|
+
function registerTokensInit(tokensCmd) {
|
|
7523
|
+
tokensCmd.command("init").description(
|
|
7524
|
+
"Detect design-token sources in the project and generate a token file.\n\nDETECTED SOURCES:\n - Tailwind config (colors, spacing, fontFamily, borderRadius)\n - CSS custom properties (:root { --color-primary: #0070f3; })\n\nWill not overwrite an existing token file unless --force is passed.\n\nOUTPUT PATH RESOLUTION (in priority order):\n 1. --file <path> explicit override\n 2. tokens.file in reactscope.config.json\n 3. reactscope.tokens.json default (project root)\n\nExamples:\n scope tokens init\n scope tokens init --force\n scope tokens init --file tokens/brand.json\n scope tokens init --force --file custom-tokens.json"
|
|
7525
|
+
).option("--file <path>", "Output path for the token file (overrides config)").option("--force", "Overwrite existing token file", false).action((opts) => {
|
|
7526
|
+
try {
|
|
7527
|
+
const outPath = resolveOutputPath(opts.file);
|
|
7528
|
+
if (existsSync(outPath) && !opts.force) {
|
|
7529
|
+
process.stderr.write(
|
|
7530
|
+
`Token file already exists at ${outPath}.
|
|
7531
|
+
Run with --force to overwrite.
|
|
7532
|
+
`
|
|
7533
|
+
);
|
|
7534
|
+
process.exit(1);
|
|
7535
|
+
}
|
|
7536
|
+
const rootDir = process.cwd();
|
|
7537
|
+
const detected = detectProject(rootDir);
|
|
7538
|
+
const tailwindTokens = extractTailwindTokens(detected.tokenSources);
|
|
7539
|
+
const cssResult = extractCSSCustomProperties(detected.tokenSources);
|
|
7540
|
+
const mergedTokens = {};
|
|
7541
|
+
const mergedThemes = {};
|
|
7542
|
+
if (tailwindTokens !== null) {
|
|
7543
|
+
Object.assign(mergedTokens, tailwindTokens);
|
|
7544
|
+
}
|
|
7545
|
+
if (cssResult !== null) {
|
|
7546
|
+
for (const [key, value] of Object.entries(cssResult.tokens)) {
|
|
7547
|
+
if (!(key in mergedTokens)) {
|
|
7548
|
+
mergedTokens[key] = value;
|
|
7549
|
+
}
|
|
7550
|
+
}
|
|
7551
|
+
for (const [themeName, overrides] of Object.entries(cssResult.themes)) {
|
|
7552
|
+
if (!mergedThemes[themeName]) mergedThemes[themeName] = {};
|
|
7553
|
+
Object.assign(mergedThemes[themeName], overrides);
|
|
7554
|
+
}
|
|
7555
|
+
}
|
|
7556
|
+
const tokenFile = {
|
|
7557
|
+
$schema: "https://raw.githubusercontent.com/FlatFilers/Scope/main/packages/tokens/schema.json",
|
|
7558
|
+
version: "1.0.0",
|
|
7559
|
+
meta: {
|
|
7560
|
+
name: "Design Tokens",
|
|
7561
|
+
lastUpdated: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
|
|
7562
|
+
},
|
|
7563
|
+
tokens: mergedTokens
|
|
7564
|
+
};
|
|
7565
|
+
if (Object.keys(mergedThemes).length > 0) {
|
|
7566
|
+
tokenFile.themes = mergedThemes;
|
|
7567
|
+
}
|
|
7568
|
+
writeFileSync(outPath, `${JSON.stringify(tokenFile, null, 2)}
|
|
7569
|
+
`);
|
|
7570
|
+
const tokenGroupCount = Object.keys(mergedTokens).length;
|
|
7571
|
+
const themeNames = Object.keys(mergedThemes);
|
|
7572
|
+
if (detected.tokenSources.length > 0) {
|
|
7573
|
+
process.stdout.write("Detected token sources:\n");
|
|
7574
|
+
for (const source of detected.tokenSources) {
|
|
7575
|
+
process.stdout.write(` ${source.kind}: ${source.path}
|
|
7576
|
+
`);
|
|
7577
|
+
}
|
|
7578
|
+
process.stdout.write("\n");
|
|
7579
|
+
}
|
|
7580
|
+
if (tokenGroupCount > 0) {
|
|
7581
|
+
process.stdout.write(`Extracted ${tokenGroupCount} token group(s) \u2192 ${outPath}
|
|
7582
|
+
`);
|
|
7583
|
+
if (themeNames.length > 0) {
|
|
7584
|
+
for (const name of themeNames) {
|
|
7585
|
+
const count = Object.keys(mergedThemes[name] ?? {}).length;
|
|
7586
|
+
process.stdout.write(` theme "${name}": ${count} override(s)
|
|
7587
|
+
`);
|
|
7588
|
+
}
|
|
7589
|
+
}
|
|
7590
|
+
} else {
|
|
7591
|
+
process.stdout.write(
|
|
7592
|
+
`No token sources detected. Created empty token file \u2192 ${outPath}
|
|
7593
|
+
Add tokens manually or re-run after configuring a design system.
|
|
7594
|
+
`
|
|
7595
|
+
);
|
|
7596
|
+
}
|
|
7597
|
+
} catch (err) {
|
|
7598
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
7599
|
+
`);
|
|
7600
|
+
process.exit(1);
|
|
7601
|
+
}
|
|
7602
|
+
});
|
|
7603
|
+
}
|
|
5915
7604
|
var DEFAULT_STYLES_PATH3 = ".reactscope/compliance-styles.json";
|
|
5916
7605
|
var DEFAULT_MANIFEST_PATH = ".reactscope/manifest.json";
|
|
5917
7606
|
var DEFAULT_OUTPUT_DIR2 = ".reactscope/previews";
|
|
5918
7607
|
async function renderComponentWithCssOverride(filePath, componentName, cssOverride, vpWidth, vpHeight, timeoutMs) {
|
|
7608
|
+
const PAD = 16;
|
|
5919
7609
|
const htmlHarness = await buildComponentHarness(
|
|
5920
7610
|
filePath,
|
|
5921
7611
|
componentName,
|
|
5922
7612
|
{},
|
|
5923
7613
|
// no props
|
|
5924
7614
|
vpWidth,
|
|
5925
|
-
cssOverride
|
|
7615
|
+
cssOverride,
|
|
5926
7616
|
// injected as <style>
|
|
7617
|
+
void 0,
|
|
7618
|
+
PAD
|
|
5927
7619
|
);
|
|
5928
7620
|
const pool = new BrowserPool({
|
|
5929
7621
|
size: { browsers: 1, pagesPerBrowser: 1 },
|
|
@@ -5944,7 +7636,6 @@ async function renderComponentWithCssOverride(filePath, componentName, cssOverri
|
|
|
5944
7636
|
);
|
|
5945
7637
|
const rootLocator = page.locator("[data-reactscope-root]");
|
|
5946
7638
|
const bb = await rootLocator.boundingBox();
|
|
5947
|
-
const PAD = 16;
|
|
5948
7639
|
const MIN_W = 320;
|
|
5949
7640
|
const MIN_H = 120;
|
|
5950
7641
|
const clipX = Math.max(0, (bb?.x ?? 0) - PAD);
|
|
@@ -5964,7 +7655,9 @@ async function renderComponentWithCssOverride(filePath, componentName, cssOverri
|
|
|
5964
7655
|
}
|
|
5965
7656
|
}
|
|
5966
7657
|
function registerPreview(tokensCmd) {
|
|
5967
|
-
tokensCmd.command("preview <path>").description(
|
|
7658
|
+
tokensCmd.command("preview <path>").description(
|
|
7659
|
+
'Render before/after screenshots of all components affected by a token change.\nUseful for visual review before committing a token value update.\n\nPREREQUISITE: scope render all (provides baseline renders)\n\nExamples:\n scope tokens preview color.primary.500\n scope tokens preview color.primary.500 --new-value "#2563eb" -o preview.png'
|
|
7660
|
+
).requiredOption("--new-value <value>", "The proposed new resolved value for the token").option("--sprite", "Output a PNG sprite sheet (default when TTY)", false).option("-o, --output <path>", "Output PNG path (default: .reactscope/previews/<token>.png)").option("--file <path>", "Path to token file (overrides config)").option("--styles <path>", `Path to compliance styles JSON (default: ${DEFAULT_STYLES_PATH3})`).option("--manifest <path>", "Path to manifest.json", DEFAULT_MANIFEST_PATH).option("--format <fmt>", "Output format: json or text (default: auto-detect)").option("--timeout <ms>", "Browser timeout per render (ms)", "10000").option("--viewport-width <px>", "Viewport width in pixels", "1280").option("--viewport-height <px>", "Viewport height in pixels", "720").action(
|
|
5968
7661
|
async (tokenPath, opts) => {
|
|
5969
7662
|
try {
|
|
5970
7663
|
const tokenFilePath = resolveTokenFilePath(opts.file);
|
|
@@ -6131,8 +7824,8 @@ function registerPreview(tokensCmd) {
|
|
|
6131
7824
|
}
|
|
6132
7825
|
|
|
6133
7826
|
// src/tokens/commands.ts
|
|
6134
|
-
var
|
|
6135
|
-
var
|
|
7827
|
+
var DEFAULT_TOKEN_FILE3 = "reactscope.tokens.json";
|
|
7828
|
+
var CONFIG_FILE3 = "reactscope.config.json";
|
|
6136
7829
|
function isTTY2() {
|
|
6137
7830
|
return process.stdout.isTTY === true;
|
|
6138
7831
|
}
|
|
@@ -6154,7 +7847,7 @@ function resolveTokenFilePath(fileFlag) {
|
|
|
6154
7847
|
if (fileFlag !== void 0) {
|
|
6155
7848
|
return resolve(process.cwd(), fileFlag);
|
|
6156
7849
|
}
|
|
6157
|
-
const configPath = resolve(process.cwd(),
|
|
7850
|
+
const configPath = resolve(process.cwd(), CONFIG_FILE3);
|
|
6158
7851
|
if (existsSync(configPath)) {
|
|
6159
7852
|
try {
|
|
6160
7853
|
const raw = readFileSync(configPath, "utf-8");
|
|
@@ -6166,7 +7859,7 @@ function resolveTokenFilePath(fileFlag) {
|
|
|
6166
7859
|
} catch {
|
|
6167
7860
|
}
|
|
6168
7861
|
}
|
|
6169
|
-
return resolve(process.cwd(),
|
|
7862
|
+
return resolve(process.cwd(), DEFAULT_TOKEN_FILE3);
|
|
6170
7863
|
}
|
|
6171
7864
|
function loadTokens(absPath) {
|
|
6172
7865
|
if (!existsSync(absPath)) {
|
|
@@ -6211,7 +7904,9 @@ function buildResolutionChain(startPath, rawTokens) {
|
|
|
6211
7904
|
return chain;
|
|
6212
7905
|
}
|
|
6213
7906
|
function registerGet2(tokensCmd) {
|
|
6214
|
-
tokensCmd.command("get <path>").description(
|
|
7907
|
+
tokensCmd.command("get <path>").description(
|
|
7908
|
+
"Resolve a token path and print its final computed value.\nFollows all {ref} chains to the raw value.\n\nExamples:\n scope tokens get color.primary.500\n scope tokens get spacing.4 --format json\n scope tokens get font.size.base --file ./tokens/brand.json"
|
|
7909
|
+
).option("--file <path>", "Path to token file (overrides config)").option("--format <fmt>", "Output format: json or text (default: auto-detect)").action((tokenPath, opts) => {
|
|
6215
7910
|
try {
|
|
6216
7911
|
const filePath = resolveTokenFilePath(opts.file);
|
|
6217
7912
|
const { tokens } = loadTokens(filePath);
|
|
@@ -6236,7 +7931,18 @@ function registerGet2(tokensCmd) {
|
|
|
6236
7931
|
});
|
|
6237
7932
|
}
|
|
6238
7933
|
function registerList2(tokensCmd) {
|
|
6239
|
-
tokensCmd.command("list [category]").description(
|
|
7934
|
+
tokensCmd.command("list [category]").description(
|
|
7935
|
+
`List all tokens, optionally filtered by category prefix or type.
|
|
7936
|
+
|
|
7937
|
+
CATEGORY: top-level token namespace (e.g. "color", "spacing", "typography")
|
|
7938
|
+
TYPE: token value type \u2014 color | spacing | typography | shadow | radius | opacity
|
|
7939
|
+
|
|
7940
|
+
Examples:
|
|
7941
|
+
scope tokens list
|
|
7942
|
+
scope tokens list color
|
|
7943
|
+
scope tokens list --type spacing
|
|
7944
|
+
scope tokens list color --format json | jq '.[].path'`
|
|
7945
|
+
).option("--type <type>", "Filter by token type (color, dimension, fontFamily, etc.)").option("--file <path>", "Path to token file (overrides config)").option("--format <fmt>", "Output format: json or table (default: auto-detect)").action(
|
|
6240
7946
|
(category, opts) => {
|
|
6241
7947
|
try {
|
|
6242
7948
|
const filePath = resolveTokenFilePath(opts.file);
|
|
@@ -6266,7 +7972,9 @@ function registerList2(tokensCmd) {
|
|
|
6266
7972
|
);
|
|
6267
7973
|
}
|
|
6268
7974
|
function registerSearch(tokensCmd) {
|
|
6269
|
-
tokensCmd.command("search <value>").description(
|
|
7975
|
+
tokensCmd.command("search <value>").description(
|
|
7976
|
+
'Find the token(s) whose computed value matches the given raw value.\nSupports fuzzy color matching (hex \u2194 rgb \u2194 hsl equivalence).\n\nExamples:\n scope tokens search "#3b82f6"\n scope tokens search "16px"\n scope tokens search "rgb(59, 130, 246)" # fuzzy-matches #3b82f6'
|
|
7977
|
+
).option("--type <type>", "Restrict search to a specific token type").option("--fuzzy", "Return nearest match even if no exact match exists", false).option("--file <path>", "Path to token file (overrides config)").option("--format <fmt>", "Output format: json or table (default: auto-detect)").action(
|
|
6270
7978
|
(value, opts) => {
|
|
6271
7979
|
try {
|
|
6272
7980
|
const filePath = resolveTokenFilePath(opts.file);
|
|
@@ -6349,7 +8057,9 @@ Tip: use --fuzzy for nearest-match search.
|
|
|
6349
8057
|
);
|
|
6350
8058
|
}
|
|
6351
8059
|
function registerResolve(tokensCmd) {
|
|
6352
|
-
tokensCmd.command("resolve <path>").description(
|
|
8060
|
+
tokensCmd.command("resolve <path>").description(
|
|
8061
|
+
"Print the full reference chain from a token path down to its raw value.\nUseful for debugging circular references or understanding token inheritance.\n\nExamples:\n scope tokens resolve color.primary.500\n scope tokens resolve button.background --format json"
|
|
8062
|
+
).option("--file <path>", "Path to token file (overrides config)").option("--format <fmt>", "Output format: json or text (default: auto-detect)").action((tokenPath, opts) => {
|
|
6353
8063
|
try {
|
|
6354
8064
|
const filePath = resolveTokenFilePath(opts.file);
|
|
6355
8065
|
const absFilePath = filePath;
|
|
@@ -6385,7 +8095,19 @@ function registerResolve(tokensCmd) {
|
|
|
6385
8095
|
}
|
|
6386
8096
|
function registerValidate(tokensCmd) {
|
|
6387
8097
|
tokensCmd.command("validate").description(
|
|
6388
|
-
|
|
8098
|
+
`Validate the token file and report errors.
|
|
8099
|
+
|
|
8100
|
+
CHECKS:
|
|
8101
|
+
- Circular reference chains (A \u2192 B \u2192 A)
|
|
8102
|
+
- Broken references ({path.that.does.not.exist})
|
|
8103
|
+
- Type mismatches (token declared as "color" but value is a number)
|
|
8104
|
+
- Duplicate paths
|
|
8105
|
+
|
|
8106
|
+
Exits 1 if any errors are found (suitable for CI).
|
|
8107
|
+
|
|
8108
|
+
Examples:
|
|
8109
|
+
scope tokens validate
|
|
8110
|
+
scope tokens validate --format json | jq '.errors'`
|
|
6389
8111
|
).option("--file <path>", "Path to token file (overrides config)").option("--format <fmt>", "Output format: json or text (default: auto-detect)").action((opts) => {
|
|
6390
8112
|
try {
|
|
6391
8113
|
const filePath = resolveTokenFilePath(opts.file);
|
|
@@ -6464,8 +8186,9 @@ function outputValidationResult(filePath, errors, useJson) {
|
|
|
6464
8186
|
}
|
|
6465
8187
|
function createTokensCommand() {
|
|
6466
8188
|
const tokensCmd = new Command("tokens").description(
|
|
6467
|
-
|
|
8189
|
+
'Query, validate, and export design tokens from reactscope.tokens.json.\n\nTOKEN FILE RESOLUTION (in priority order):\n 1. --file <path> explicit override\n 2. tokens.file in reactscope.config.json\n 3. reactscope.tokens.json default (project root)\n\nTOKEN FILE FORMAT (reactscope.tokens.json):\n Nested JSON. Each leaf is a token with { value, type } or just a raw value.\n Paths use dot notation: color.primary.500, spacing.4, font.size.base\n References use {path.to.other.token} syntax.\n Themes: top-level "themes" key with named override maps.\n\nTOKEN TYPES: color | spacing | typography | shadow | radius | opacity | other\n\nExamples:\n scope tokens validate\n scope tokens list color\n scope tokens get color.primary.500\n scope tokens compliance\n scope tokens export --format css --out tokens.css'
|
|
6468
8190
|
);
|
|
8191
|
+
registerTokensInit(tokensCmd);
|
|
6469
8192
|
registerGet2(tokensCmd);
|
|
6470
8193
|
registerList2(tokensCmd);
|
|
6471
8194
|
registerSearch(tokensCmd);
|
|
@@ -6480,8 +8203,12 @@ function createTokensCommand() {
|
|
|
6480
8203
|
|
|
6481
8204
|
// src/program.ts
|
|
6482
8205
|
function createProgram(options = {}) {
|
|
6483
|
-
const program = new Command("scope").version(options.version ?? "0.1.0").description(
|
|
6484
|
-
|
|
8206
|
+
const program = new Command("scope").version(options.version ?? "0.1.0").description(
|
|
8207
|
+
'Scope \u2014 static analysis + visual rendering toolkit for React component libraries.\n\nScope answers questions about React codebases \u2014 structure, props, visual output,\ndesign token compliance \u2014 without running the full application.\n\nAGENT QUICKSTART:\n scope get-skill > /tmp/scope-skill.md\n scope init --yes\n scope doctor --json\n scope manifest list --format json\n scope render all --format json --output-dir .reactscope/renders\n scope site build --output .reactscope/site\n scope instrument profile http://localhost:5173\n\nCI QUICKSTART:\n scope ci --json --output .reactscope/ci-result.json\n\nCONFIG FILE: reactscope.config.json (created by `scope init`)\n components.include glob patterns for component files (e.g. "src/**/*.tsx")\n components.wrappers providers and globalCSS to wrap every render\n render.viewport default viewport width\xD7height in px\n tokens.file path to reactscope.tokens.json (default)\n output.dir output root (default: .reactscope/)\n ci.complianceThreshold fail threshold for `scope ci` (default: 0.90)\n\nOUTPUT DIRECTORY: .reactscope/\n manifest.json component registry \u2014 updated by `scope manifest generate`\n renders/<Name>/ PNGs + render.json per component\n compliance-styles.json computed-style map for token matching\n site/ static HTML gallery (built by `scope site build`)\n\nRun `scope <command> --help` for detailed flags and examples.'
|
|
8208
|
+
);
|
|
8209
|
+
program.command("capture <url>").description(
|
|
8210
|
+
"Capture the live React component tree from a running app and emit it as JSON.\nRequires a running dev server at the given URL (e.g. http://localhost:5173).\n\nExamples:\n scope capture http://localhost:5173\n scope capture http://localhost:5173 -o report.json --pretty\n scope capture http://localhost:5173 --timeout 15000 --wait 500"
|
|
8211
|
+
).option("-o, --output <path>", "Write JSON to file instead of stdout").option("--pretty", "Pretty-print JSON output (default: minified)", false).option("--timeout <ms>", "Max wait time for React to mount (ms)", "10000").option("--wait <ms>", "Additional wait after page load before capture (ms)", "0").action(
|
|
6485
8212
|
async (url, opts) => {
|
|
6486
8213
|
try {
|
|
6487
8214
|
const { report } = await browserCapture({
|
|
@@ -6505,7 +8232,9 @@ function createProgram(options = {}) {
|
|
|
6505
8232
|
}
|
|
6506
8233
|
}
|
|
6507
8234
|
);
|
|
6508
|
-
program.command("tree <url>").description(
|
|
8235
|
+
program.command("tree <url>").description(
|
|
8236
|
+
"Print a formatted React component tree from a running app.\nUseful for quickly understanding component hierarchy without full capture.\n\nExamples:\n scope tree http://localhost:5173\n scope tree http://localhost:5173 --show-props --show-hooks\n scope tree http://localhost:5173 --depth 4"
|
|
8237
|
+
).option("--depth <n>", "Max depth to display (default: unlimited)").option("--show-props", "Include prop names next to components", false).option("--show-hooks", "Show hook counts per component", false).option("--timeout <ms>", "Max wait time for React to mount (ms)", "10000").option("--wait <ms>", "Additional wait after page load before capture (ms)", "0").action(
|
|
6509
8238
|
async (url, opts) => {
|
|
6510
8239
|
try {
|
|
6511
8240
|
const { report } = await browserCapture({
|
|
@@ -6528,7 +8257,9 @@ function createProgram(options = {}) {
|
|
|
6528
8257
|
}
|
|
6529
8258
|
}
|
|
6530
8259
|
);
|
|
6531
|
-
program.command("report <url>").description(
|
|
8260
|
+
program.command("report <url>").description(
|
|
8261
|
+
"Capture a React app and print a human-readable analysis summary.\nIncludes component count, hook usage, side-effect summary, and more.\n\nExamples:\n scope report http://localhost:5173\n scope report http://localhost:5173 --json\n scope report http://localhost:5173 --json -o report.json"
|
|
8262
|
+
).option("--json", "Output as structured JSON instead of human-readable text", false).option("--timeout <ms>", "Max wait time for React to mount (ms)", "10000").option("--wait <ms>", "Additional wait after page load before capture (ms)", "0").action(
|
|
6532
8263
|
async (url, opts) => {
|
|
6533
8264
|
try {
|
|
6534
8265
|
const { report } = await browserCapture({
|
|
@@ -6552,7 +8283,9 @@ function createProgram(options = {}) {
|
|
|
6552
8283
|
}
|
|
6553
8284
|
}
|
|
6554
8285
|
);
|
|
6555
|
-
program.command("generate").description(
|
|
8286
|
+
program.command("generate").description(
|
|
8287
|
+
'Generate a Playwright test file from a Scope trace (.json).\nTraces are produced by scope instrument renders or scope capture.\n\nExamples:\n scope generate trace.json\n scope generate trace.json -o tests/scope.spec.ts -d "User login flow"'
|
|
8288
|
+
).argument("<trace>", "Path to a serialized Scope trace (.json)").option("-o, --output <path>", "Output file path", "scope.spec.ts").option("-d, --description <text>", "Test description").action((tracePath, opts) => {
|
|
6556
8289
|
const raw = readFileSync(tracePath, "utf-8");
|
|
6557
8290
|
const trace = loadTrace(raw);
|
|
6558
8291
|
const source = generateTest(trace, {
|
|
@@ -6569,6 +8302,7 @@ function createProgram(options = {}) {
|
|
|
6569
8302
|
program.addCommand(createInitCommand());
|
|
6570
8303
|
program.addCommand(createCiCommand());
|
|
6571
8304
|
program.addCommand(createDoctorCommand());
|
|
8305
|
+
program.addCommand(createGetSkillCommand());
|
|
6572
8306
|
const existingReportCmd = program.commands.find((c) => c.name() === "report");
|
|
6573
8307
|
if (existingReportCmd !== void 0) {
|
|
6574
8308
|
registerBaselineSubCommand(existingReportCmd);
|
|
@@ -6579,6 +8313,6 @@ function createProgram(options = {}) {
|
|
|
6579
8313
|
return program;
|
|
6580
8314
|
}
|
|
6581
8315
|
|
|
6582
|
-
export { CI_EXIT, createCiCommand, createDoctorCommand, createInitCommand, createInstrumentCommand, createManifestCommand, createProgram, createTokensCommand, createTokensExportCommand, formatCiReport, isTTY, matchGlob, resolveTokenFilePath2 as resolveTokenFilePath, runCi, runInit };
|
|
8316
|
+
export { CI_EXIT, createCiCommand, createDoctorCommand, createGetSkillCommand, createInitCommand, createInstrumentCommand, createManifestCommand, createProgram, createTokensCommand, createTokensExportCommand, formatCiReport, isTTY, matchGlob, resolveTokenFilePath2 as resolveTokenFilePath, runCi, runInit };
|
|
6583
8317
|
//# sourceMappingURL=index.js.map
|
|
6584
8318
|
//# sourceMappingURL=index.js.map
|