@agent-scope/cli 1.20.0 → 1.20.2
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 +1940 -411
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +1748 -246
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +12 -3
- package/dist/index.d.ts +12 -3
- package/dist/index.js +1750 -248
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
package/dist/index.cjs
CHANGED
|
@@ -8,6 +8,7 @@ var tokens = require('@agent-scope/tokens');
|
|
|
8
8
|
var commander = require('commander');
|
|
9
9
|
var esbuild2 = require('esbuild');
|
|
10
10
|
var module$1 = require('module');
|
|
11
|
+
var promises = require('fs/promises');
|
|
11
12
|
var readline = require('readline');
|
|
12
13
|
var playwright = require('@agent-scope/playwright');
|
|
13
14
|
var playwright$1 = require('playwright');
|
|
@@ -37,10 +38,15 @@ function _interopNamespace(e) {
|
|
|
37
38
|
var esbuild2__namespace = /*#__PURE__*/_interopNamespace(esbuild2);
|
|
38
39
|
var readline__namespace = /*#__PURE__*/_interopNamespace(readline);
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
42
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
43
|
+
}) : x)(function(x) {
|
|
44
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
45
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
46
|
+
});
|
|
47
|
+
async function buildComponentHarness(filePath, componentName, props, viewportWidth, projectCss, wrapperScript, screenshotPadding = 0) {
|
|
42
48
|
const bundledScript = await bundleComponentToIIFE(filePath, componentName, props);
|
|
43
|
-
return wrapInHtml(bundledScript, viewportWidth, projectCss, wrapperScript);
|
|
49
|
+
return wrapInHtml(bundledScript, viewportWidth, projectCss, wrapperScript, screenshotPadding);
|
|
44
50
|
}
|
|
45
51
|
async function bundleComponentToIIFE(filePath, componentName, props) {
|
|
46
52
|
const propsJson = JSON.stringify(props).replace(/<\/script>/gi, "<\\/script>");
|
|
@@ -146,7 +152,7 @@ ${msg}`);
|
|
|
146
152
|
}
|
|
147
153
|
return outputFile.text;
|
|
148
154
|
}
|
|
149
|
-
function wrapInHtml(bundledScript, viewportWidth, projectCss, wrapperScript) {
|
|
155
|
+
function wrapInHtml(bundledScript, viewportWidth, projectCss, wrapperScript, screenshotPadding = 0) {
|
|
150
156
|
const projectStyleBlock = projectCss != null && projectCss.length > 0 ? `<style id="scope-project-css">
|
|
151
157
|
${projectCss.replace(/<\/style>/gi, "<\\/style>")}
|
|
152
158
|
</style>` : "";
|
|
@@ -156,10 +162,17 @@ ${projectCss.replace(/<\/style>/gi, "<\\/style>")}
|
|
|
156
162
|
<head>
|
|
157
163
|
<meta charset="UTF-8" />
|
|
158
164
|
<meta name="viewport" content="width=${viewportWidth}, initial-scale=1.0" />
|
|
165
|
+
<script>
|
|
166
|
+
// Reset globals that persist on window across page.setContent() calls
|
|
167
|
+
// (document.open/write/close clears the DOM but NOT the JS global scope)
|
|
168
|
+
window.__SCOPE_WRAPPER__ = null;
|
|
169
|
+
window.__SCOPE_RENDER_COMPLETE__ = false;
|
|
170
|
+
window.__SCOPE_RENDER_ERROR__ = null;
|
|
171
|
+
</script>
|
|
159
172
|
<style>
|
|
160
173
|
*, *::before, *::after { box-sizing: border-box; }
|
|
161
174
|
html, body { margin: 0; padding: 0; background: #fff; font-family: system-ui, sans-serif; }
|
|
162
|
-
#scope-root { display: inline-block; min-width: 1px; min-height: 1px; }
|
|
175
|
+
#scope-root { display: inline-block; min-width: 1px; min-height: 1px; margin: ${screenshotPadding}px; }
|
|
163
176
|
</style>
|
|
164
177
|
${projectStyleBlock}
|
|
165
178
|
</head>
|
|
@@ -451,6 +464,11 @@ var STYLE_ENTRY_CANDIDATES = [
|
|
|
451
464
|
"index.css"
|
|
452
465
|
];
|
|
453
466
|
var TAILWIND_IMPORT = /@import\s+["']tailwindcss["']\s*;?/;
|
|
467
|
+
function getElementClassNames(el) {
|
|
468
|
+
const className = el.className;
|
|
469
|
+
const raw = typeof className === "string" ? className : typeof className?.baseVal === "string" ? className.baseVal : el.getAttribute("class") ?? "";
|
|
470
|
+
return raw.split(/\s+/).filter(Boolean);
|
|
471
|
+
}
|
|
454
472
|
var compilerCache = null;
|
|
455
473
|
function getCachedBuild(cwd) {
|
|
456
474
|
if (compilerCache !== null && path.resolve(compilerCache.cwd) === path.resolve(cwd)) {
|
|
@@ -541,22 +559,22 @@ async function getTailwindCompiler(cwd) {
|
|
|
541
559
|
from: entryPath,
|
|
542
560
|
loadStylesheet
|
|
543
561
|
});
|
|
544
|
-
const
|
|
545
|
-
compilerCache = { cwd, build:
|
|
546
|
-
return
|
|
562
|
+
const build4 = result.build.bind(result);
|
|
563
|
+
compilerCache = { cwd, build: build4 };
|
|
564
|
+
return build4;
|
|
547
565
|
}
|
|
548
566
|
async function getCompiledCssForClasses(cwd, classes) {
|
|
549
|
-
const
|
|
550
|
-
if (
|
|
567
|
+
const build4 = await getTailwindCompiler(cwd);
|
|
568
|
+
if (build4 === null) return null;
|
|
551
569
|
const deduped = [...new Set(classes)].filter(Boolean);
|
|
552
570
|
if (deduped.length === 0) return null;
|
|
553
|
-
return
|
|
571
|
+
return build4(deduped);
|
|
554
572
|
}
|
|
555
573
|
async function compileGlobalCssFile(cssFilePath, cwd) {
|
|
556
|
-
const { existsSync:
|
|
574
|
+
const { existsSync: existsSync18, readFileSync: readFileSync18 } = await import('fs');
|
|
557
575
|
const { createRequire: createRequire3 } = await import('module');
|
|
558
|
-
if (!
|
|
559
|
-
const raw =
|
|
576
|
+
if (!existsSync18(cssFilePath)) return null;
|
|
577
|
+
const raw = readFileSync18(cssFilePath, "utf-8");
|
|
560
578
|
const needsCompile = /@tailwind|@import\s+['"]tailwindcss/.test(raw);
|
|
561
579
|
if (!needsCompile) {
|
|
562
580
|
return raw;
|
|
@@ -639,8 +657,17 @@ async function shutdownPool() {
|
|
|
639
657
|
}
|
|
640
658
|
}
|
|
641
659
|
async function renderComponent(filePath, componentName, props, viewportWidth, viewportHeight) {
|
|
660
|
+
const PAD = 24;
|
|
642
661
|
const pool = await getPool(viewportWidth, viewportHeight);
|
|
643
|
-
const htmlHarness = await buildComponentHarness(
|
|
662
|
+
const htmlHarness = await buildComponentHarness(
|
|
663
|
+
filePath,
|
|
664
|
+
componentName,
|
|
665
|
+
props,
|
|
666
|
+
viewportWidth,
|
|
667
|
+
void 0,
|
|
668
|
+
void 0,
|
|
669
|
+
PAD
|
|
670
|
+
);
|
|
644
671
|
const slot = await pool.acquire();
|
|
645
672
|
const { page } = slot;
|
|
646
673
|
try {
|
|
@@ -662,8 +689,8 @@ async function renderComponent(filePath, componentName, props, viewportWidth, vi
|
|
|
662
689
|
const classes = await page.evaluate(() => {
|
|
663
690
|
const set = /* @__PURE__ */ new Set();
|
|
664
691
|
document.querySelectorAll("[class]").forEach((el) => {
|
|
665
|
-
for (const c of el
|
|
666
|
-
|
|
692
|
+
for (const c of getElementClassNames(el)) {
|
|
693
|
+
set.add(c);
|
|
667
694
|
}
|
|
668
695
|
});
|
|
669
696
|
return [...set];
|
|
@@ -680,7 +707,6 @@ async function renderComponent(filePath, componentName, props, viewportWidth, vi
|
|
|
680
707
|
`Component "${componentName}" rendered with zero bounding box \u2014 it may be invisible or not mounted`
|
|
681
708
|
);
|
|
682
709
|
}
|
|
683
|
-
const PAD = 24;
|
|
684
710
|
const MIN_W = 320;
|
|
685
711
|
const MIN_H = 200;
|
|
686
712
|
const clipX = Math.max(0, boundingBox.x - PAD);
|
|
@@ -1025,6 +1051,124 @@ function createCiCommand() {
|
|
|
1025
1051
|
}
|
|
1026
1052
|
);
|
|
1027
1053
|
}
|
|
1054
|
+
var PLAYWRIGHT_BROWSER_HINTS = [
|
|
1055
|
+
"executable doesn't exist",
|
|
1056
|
+
"browserType.launch",
|
|
1057
|
+
"looks like playwright was just installed or updated",
|
|
1058
|
+
"please run the following command to download new browsers",
|
|
1059
|
+
"could not find chromium"
|
|
1060
|
+
];
|
|
1061
|
+
var MISSING_DEPENDENCY_HINTS = ["could not resolve", "cannot find module", "module not found"];
|
|
1062
|
+
var REQUIRED_HARNESS_DEPENDENCIES = ["react", "react-dom", "react/jsx-runtime"];
|
|
1063
|
+
function getEffectivePlaywrightBrowsersPath() {
|
|
1064
|
+
const value = process.env.PLAYWRIGHT_BROWSERS_PATH;
|
|
1065
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
1066
|
+
}
|
|
1067
|
+
function getPlaywrightBrowserRemediation(status) {
|
|
1068
|
+
const effectivePath = status?.effectiveBrowserPath ?? getEffectivePlaywrightBrowsersPath();
|
|
1069
|
+
if (effectivePath !== null) {
|
|
1070
|
+
const pathProblem = status?.browserPathExists === false ? "missing" : status?.browserPathWritable === false ? "unwritable" : "unavailable";
|
|
1071
|
+
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\`.`;
|
|
1072
|
+
}
|
|
1073
|
+
return "Run `bunx playwright install chromium` in this sandbox, then retry the Scope command.";
|
|
1074
|
+
}
|
|
1075
|
+
function diagnoseScopeError(error, cwd = process.cwd()) {
|
|
1076
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1077
|
+
const normalized = message.toLowerCase();
|
|
1078
|
+
if (PLAYWRIGHT_BROWSER_HINTS.some((hint) => normalized.includes(hint))) {
|
|
1079
|
+
const browserPath = extractPlaywrightBrowserPath(message);
|
|
1080
|
+
const browserPathHint = browserPath === null ? "" : ` Scope tried to launch Chromium from ${browserPath}.`;
|
|
1081
|
+
return {
|
|
1082
|
+
code: "PLAYWRIGHT_BROWSERS_MISSING",
|
|
1083
|
+
message: "Playwright Chromium is unavailable for Scope browser rendering.",
|
|
1084
|
+
recovery: getPlaywrightBrowserRemediation() + browserPathHint + " Use `scope doctor --json` to verify the browser check passes before rerunning render/site/instrument."
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
if (MISSING_DEPENDENCY_HINTS.some((hint) => normalized.includes(hint))) {
|
|
1088
|
+
const packageManager = detectPackageManager(cwd);
|
|
1089
|
+
return {
|
|
1090
|
+
code: "TARGET_PROJECT_DEPENDENCIES_MISSING",
|
|
1091
|
+
message: "The target project's dependencies appear to be missing or incomplete.",
|
|
1092
|
+
recovery: `Run \`${packageManager} install\` in ${cwd}, then rerun \`scope doctor\` and retry the Scope command.`
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
return null;
|
|
1096
|
+
}
|
|
1097
|
+
function formatScopeDiagnostic(error, cwd = process.cwd()) {
|
|
1098
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1099
|
+
const diagnostic = diagnoseScopeError(error, cwd);
|
|
1100
|
+
if (diagnostic === null) return `Error: ${message}`;
|
|
1101
|
+
return `Error [${diagnostic.code}]: ${diagnostic.message}
|
|
1102
|
+
Recovery: ${diagnostic.recovery}
|
|
1103
|
+
Cause: ${message}`;
|
|
1104
|
+
}
|
|
1105
|
+
async function getPlaywrightBrowserStatus(cwd = process.cwd()) {
|
|
1106
|
+
const effectiveBrowserPath = getEffectivePlaywrightBrowsersPath();
|
|
1107
|
+
const executablePath = getPlaywrightChromiumExecutablePath(cwd);
|
|
1108
|
+
const available = executablePath !== null && fs.existsSync(executablePath);
|
|
1109
|
+
const browserPathExists = effectiveBrowserPath === null ? null : fs.existsSync(effectiveBrowserPath);
|
|
1110
|
+
const browserPathWritable = effectiveBrowserPath === null ? null : await isWritableBrowserPath(effectiveBrowserPath);
|
|
1111
|
+
return {
|
|
1112
|
+
effectiveBrowserPath,
|
|
1113
|
+
executablePath,
|
|
1114
|
+
available,
|
|
1115
|
+
browserPathExists,
|
|
1116
|
+
browserPathWritable,
|
|
1117
|
+
remediation: getPlaywrightBrowserRemediation({
|
|
1118
|
+
effectiveBrowserPath,
|
|
1119
|
+
browserPathExists,
|
|
1120
|
+
browserPathWritable
|
|
1121
|
+
})
|
|
1122
|
+
};
|
|
1123
|
+
}
|
|
1124
|
+
function getPlaywrightChromiumExecutablePath(cwd = process.cwd()) {
|
|
1125
|
+
try {
|
|
1126
|
+
const packageJsonPath = __require.resolve("playwright/package.json", { paths: [cwd] });
|
|
1127
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
1128
|
+
if (!packageJson.version) return null;
|
|
1129
|
+
const playwrightPath = __require.resolve("playwright", { paths: [cwd] });
|
|
1130
|
+
const { chromium: chromium5 } = __require(playwrightPath);
|
|
1131
|
+
const executablePath = chromium5?.executablePath?.();
|
|
1132
|
+
if (typeof executablePath !== "string" || executablePath.length === 0) return null;
|
|
1133
|
+
return executablePath;
|
|
1134
|
+
} catch {
|
|
1135
|
+
return null;
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
async function isWritableBrowserPath(browserPath) {
|
|
1139
|
+
const candidate = fs.existsSync(browserPath) ? browserPath : path.dirname(browserPath);
|
|
1140
|
+
try {
|
|
1141
|
+
await promises.access(candidate, fs.constants.W_OK);
|
|
1142
|
+
return true;
|
|
1143
|
+
} catch {
|
|
1144
|
+
return false;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
function detectPackageManager(cwd = process.cwd()) {
|
|
1148
|
+
if (fs.existsSync(path.join(cwd, "bun.lock")) || fs.existsSync(path.join(cwd, "bun.lockb"))) return "bun";
|
|
1149
|
+
if (fs.existsSync(path.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
1150
|
+
if (fs.existsSync(path.join(cwd, "yarn.lock"))) return "yarn";
|
|
1151
|
+
return "npm";
|
|
1152
|
+
}
|
|
1153
|
+
function hasLikelyInstalledDependencies(cwd = process.cwd()) {
|
|
1154
|
+
return fs.existsSync(path.join(cwd, "node_modules"));
|
|
1155
|
+
}
|
|
1156
|
+
function getMissingHarnessDependencies(cwd = process.cwd()) {
|
|
1157
|
+
return REQUIRED_HARNESS_DEPENDENCIES.filter((dependencyName) => {
|
|
1158
|
+
try {
|
|
1159
|
+
__require.resolve(dependencyName, { paths: [cwd] });
|
|
1160
|
+
return false;
|
|
1161
|
+
} catch {
|
|
1162
|
+
return true;
|
|
1163
|
+
}
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
function extractPlaywrightBrowserPath(message) {
|
|
1167
|
+
const match = message.match(/Executable doesn't exist at\s+([^\n]+)/i);
|
|
1168
|
+
return match?.[1]?.trim() ?? null;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// src/doctor-commands.ts
|
|
1028
1172
|
function collectSourceFiles(dir) {
|
|
1029
1173
|
if (!fs.existsSync(dir)) return [];
|
|
1030
1174
|
const results = [];
|
|
@@ -1038,13 +1182,43 @@ function collectSourceFiles(dir) {
|
|
|
1038
1182
|
}
|
|
1039
1183
|
return results;
|
|
1040
1184
|
}
|
|
1185
|
+
var TAILWIND_CONFIG_FILES = [
|
|
1186
|
+
"tailwind.config.js",
|
|
1187
|
+
"tailwind.config.cjs",
|
|
1188
|
+
"tailwind.config.mjs",
|
|
1189
|
+
"tailwind.config.ts",
|
|
1190
|
+
"postcss.config.js",
|
|
1191
|
+
"postcss.config.cjs",
|
|
1192
|
+
"postcss.config.mjs",
|
|
1193
|
+
"postcss.config.ts"
|
|
1194
|
+
];
|
|
1195
|
+
function hasTailwindSetup(cwd) {
|
|
1196
|
+
if (TAILWIND_CONFIG_FILES.some((file) => fs.existsSync(path.resolve(cwd, file)))) {
|
|
1197
|
+
return true;
|
|
1198
|
+
}
|
|
1199
|
+
const packageJsonPath = path.resolve(cwd, "package.json");
|
|
1200
|
+
if (!fs.existsSync(packageJsonPath)) return false;
|
|
1201
|
+
try {
|
|
1202
|
+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
1203
|
+
return [pkg.dependencies, pkg.devDependencies].some(
|
|
1204
|
+
(deps) => deps && Object.keys(deps).some(
|
|
1205
|
+
(name) => name === "tailwindcss" || name.startsWith("@tailwindcss/")
|
|
1206
|
+
)
|
|
1207
|
+
);
|
|
1208
|
+
} catch {
|
|
1209
|
+
return false;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
function getPlaywrightInstallCommand(effectiveBrowserPath) {
|
|
1213
|
+
return effectiveBrowserPath === null ? "bunx playwright install chromium" : `PLAYWRIGHT_BROWSERS_PATH=${effectiveBrowserPath} bunx playwright install chromium`;
|
|
1214
|
+
}
|
|
1041
1215
|
function checkConfig(cwd) {
|
|
1042
1216
|
const configPath = path.resolve(cwd, "reactscope.config.json");
|
|
1043
1217
|
if (!fs.existsSync(configPath)) {
|
|
1044
1218
|
return {
|
|
1045
1219
|
name: "config",
|
|
1046
1220
|
status: "error",
|
|
1047
|
-
message: "reactscope.config.json not found \u2014 run `scope init`"
|
|
1221
|
+
message: "reactscope.config.json not found \u2014 run `scope init` in the target project root"
|
|
1048
1222
|
};
|
|
1049
1223
|
}
|
|
1050
1224
|
try {
|
|
@@ -1092,6 +1266,13 @@ function checkGlobalCss(cwd) {
|
|
|
1092
1266
|
}
|
|
1093
1267
|
}
|
|
1094
1268
|
if (globalCss.length === 0) {
|
|
1269
|
+
if (!hasTailwindSetup(cwd)) {
|
|
1270
|
+
return {
|
|
1271
|
+
name: "globalCSS",
|
|
1272
|
+
status: "ok",
|
|
1273
|
+
message: "No globalCSS configured \u2014 skipping CSS injection for this non-Tailwind project"
|
|
1274
|
+
};
|
|
1275
|
+
}
|
|
1095
1276
|
return {
|
|
1096
1277
|
name: "globalCSS",
|
|
1097
1278
|
status: "warn",
|
|
@@ -1118,7 +1299,7 @@ function checkManifest(cwd) {
|
|
|
1118
1299
|
return {
|
|
1119
1300
|
name: "manifest",
|
|
1120
1301
|
status: "warn",
|
|
1121
|
-
message: "Manifest not found \u2014 run `scope manifest generate`"
|
|
1302
|
+
message: "Manifest not found \u2014 run `scope manifest generate` in the target project root"
|
|
1122
1303
|
};
|
|
1123
1304
|
}
|
|
1124
1305
|
const manifestMtime = fs.statSync(manifestPath).mtimeMs;
|
|
@@ -1135,6 +1316,54 @@ function checkManifest(cwd) {
|
|
|
1135
1316
|
return { name: "manifest", status: "ok", message: "Manifest present and up to date" };
|
|
1136
1317
|
}
|
|
1137
1318
|
var ICONS = { ok: "\u2713", warn: "!", error: "\u2717" };
|
|
1319
|
+
function checkDependencies(cwd) {
|
|
1320
|
+
const packageManager = detectPackageManager(cwd);
|
|
1321
|
+
if (!hasLikelyInstalledDependencies(cwd)) {
|
|
1322
|
+
return {
|
|
1323
|
+
name: "dependencies",
|
|
1324
|
+
status: "error",
|
|
1325
|
+
remediationCode: "TARGET_PROJECT_DEPENDENCIES_MISSING",
|
|
1326
|
+
fixCommand: `${packageManager} install`,
|
|
1327
|
+
message: `node_modules not found \u2014 run \`${packageManager} install\` in ${cwd} before render/site/instrument`
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
const missingHarnessDependencies = getMissingHarnessDependencies(cwd);
|
|
1331
|
+
if (missingHarnessDependencies.length > 0) {
|
|
1332
|
+
return {
|
|
1333
|
+
name: "dependencies",
|
|
1334
|
+
status: "error",
|
|
1335
|
+
remediationCode: "TARGET_PROJECT_HARNESS_DEPENDENCIES_MISSING",
|
|
1336
|
+
fixCommand: `${packageManager} install`,
|
|
1337
|
+
message: `Missing React harness dependencies: ${missingHarnessDependencies.join(", ")}. Run \`${packageManager} install\` in ${cwd}, then retry render/site/instrument.`
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
return {
|
|
1341
|
+
name: "dependencies",
|
|
1342
|
+
status: "ok",
|
|
1343
|
+
message: "node_modules and React harness dependencies present"
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
async function checkPlaywright(cwd) {
|
|
1347
|
+
const status = await getPlaywrightBrowserStatus(cwd);
|
|
1348
|
+
const pathDetails = status.effectiveBrowserPath === null ? "PLAYWRIGHT_BROWSERS_PATH is unset" : `PLAYWRIGHT_BROWSERS_PATH=${status.effectiveBrowserPath}; exists=${status.browserPathExists}; writable=${status.browserPathWritable}`;
|
|
1349
|
+
if (status.available) {
|
|
1350
|
+
return {
|
|
1351
|
+
name: "playwright",
|
|
1352
|
+
status: "ok",
|
|
1353
|
+
message: `Playwright package available (${pathDetails})`
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
return {
|
|
1357
|
+
name: "playwright",
|
|
1358
|
+
status: "error",
|
|
1359
|
+
remediationCode: "PLAYWRIGHT_BROWSERS_MISSING",
|
|
1360
|
+
fixCommand: getPlaywrightInstallCommand(status.effectiveBrowserPath),
|
|
1361
|
+
message: `Playwright Chromium unavailable (${pathDetails}) \u2014 ${status.remediation}`
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
function collectFixCommands(checks) {
|
|
1365
|
+
return checks.filter((check) => check.status === "error" && check.fixCommand !== void 0).map((check) => check.fixCommand).filter((command, index, commands) => commands.indexOf(command) === index);
|
|
1366
|
+
}
|
|
1138
1367
|
function formatCheck(check) {
|
|
1139
1368
|
return ` [${ICONS[check.status]}] ${check.name.padEnd(12)} ${check.message}`;
|
|
1140
1369
|
}
|
|
@@ -1147,6 +1376,8 @@ CHECKS PERFORMED:
|
|
|
1147
1376
|
tokens reactscope.tokens.json exists and passes validation
|
|
1148
1377
|
css globalCSS files referenced in config actually exist
|
|
1149
1378
|
manifest .reactscope/manifest.json exists and is not stale
|
|
1379
|
+
dependencies node_modules exists in the target project root
|
|
1380
|
+
playwright Playwright browser runtime is available
|
|
1150
1381
|
(stale = source files modified after last generate)
|
|
1151
1382
|
|
|
1152
1383
|
STATUS LEVELS: ok | warn | error
|
|
@@ -1156,20 +1387,34 @@ Run this first whenever renders fail or produce unexpected output.
|
|
|
1156
1387
|
Examples:
|
|
1157
1388
|
scope doctor
|
|
1158
1389
|
scope doctor --json
|
|
1390
|
+
scope doctor --print-fix-commands
|
|
1159
1391
|
scope doctor --json | jq '.checks[] | select(.status == "error")'`
|
|
1160
|
-
).option("--json", "Emit structured JSON output", false).
|
|
1392
|
+
).option("--json", "Emit structured JSON output", false).option(
|
|
1393
|
+
"--print-fix-commands",
|
|
1394
|
+
"Print deduplicated shell remediation commands for failing checks",
|
|
1395
|
+
false
|
|
1396
|
+
).action(async (opts) => {
|
|
1161
1397
|
const cwd = process.cwd();
|
|
1162
1398
|
const checks = [
|
|
1163
1399
|
checkConfig(cwd),
|
|
1164
1400
|
checkTokens(cwd),
|
|
1165
1401
|
checkGlobalCss(cwd),
|
|
1166
|
-
checkManifest(cwd)
|
|
1402
|
+
checkManifest(cwd),
|
|
1403
|
+
checkDependencies(cwd),
|
|
1404
|
+
await checkPlaywright(cwd)
|
|
1167
1405
|
];
|
|
1168
1406
|
const errors = checks.filter((c) => c.status === "error").length;
|
|
1169
1407
|
const warnings = checks.filter((c) => c.status === "warn").length;
|
|
1408
|
+
const fixCommands = collectFixCommands(checks);
|
|
1409
|
+
if (opts.printFixCommands) {
|
|
1410
|
+
process.stdout.write(`${JSON.stringify({ cwd, fixCommands }, null, 2)}
|
|
1411
|
+
`);
|
|
1412
|
+
if (errors > 0) process.exit(1);
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1170
1415
|
if (opts.json) {
|
|
1171
1416
|
process.stdout.write(
|
|
1172
|
-
`${JSON.stringify({ passed: checks.length - errors - warnings, warnings, errors, checks }, null, 2)}
|
|
1417
|
+
`${JSON.stringify({ passed: checks.length - errors - warnings, warnings, errors, fixCommands, checks }, null, 2)}
|
|
1173
1418
|
`
|
|
1174
1419
|
);
|
|
1175
1420
|
if (errors > 0) process.exit(1);
|
|
@@ -1196,12 +1441,12 @@ Examples:
|
|
|
1196
1441
|
}
|
|
1197
1442
|
|
|
1198
1443
|
// src/skill-content.ts
|
|
1199
|
-
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**3-command workflow:**\n```\nscope init # scaffold config + auto-generate manifest\nscope manifest query --context ThemeContext # ask questions about the codebase\nscope render Button # produce a PNG of a component\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`)\nScope files let you define **named rendering scenarios** for a component alongside it in the source tree. They are the primary way to ensure `render all` produces meaningful screenshots.\n\n```tsx\n// Button.scope.tsx\nimport type { ScopeFile } from \'@agent-scope/cli\';\nimport { Button } from \'./Button\';\n\nexport default {\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 ScopeFile<typeof Button>;\n```\n\nKey rules:\n- The file must be named `<ComponentName>.scope.tsx` in the same directory\n- Export a default object where keys are scenario names and values are props\n- `render all` uses the `default` scenario (or first defined) as the primary screenshot\n- If 2+ scenarios exist, `render all` automatically runs a matrix and merges cells into the component JSON\n- Scenarios also feed the interactive Playground in the docs site\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\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\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 X`\n\n**"I want to render all variants of a component"**\n\u2192 Define all variants in `.scope.tsx`, then `scope render all` (auto-matrix)\n\u2192 Or: `scope render matrix X --axes \'variant:primary,secondary,danger\'`\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 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\n```bash\nnpm install -g @agent-scope/cli # global\nnpm install --save-dev @agent-scope/cli # per-project\n```\n\nBinary: `scope`\n\n---\n\n## Core Workflow\n\n```\ninit \u2192 manifest generate \u2192 manifest query/get/list \u2192 render \u2192 (token audit) \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, and manifest staleness.\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\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\n```\n$ scope doctor\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 Button\nscope render Button --props \'{"variant":"primary","children":"Click me"}\'\nscope render Button --viewport 375x812\nscope render Button --output button.png\nscope render 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 http://localhost:3000\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 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 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| `"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';
|
|
1444
|
+
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';
|
|
1200
1445
|
|
|
1201
1446
|
// src/get-skill-command.ts
|
|
1202
1447
|
function createGetSkillCommand() {
|
|
1203
1448
|
return new commander.Command("get-skill").description(
|
|
1204
|
-
'Print the embedded Scope SKILL.md to stdout.\n\nAgents:
|
|
1449
|
+
'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'
|
|
1205
1450
|
).option("--json", "Wrap output in JSON { skill: string } instead of raw markdown").action((opts) => {
|
|
1206
1451
|
if (opts.json) {
|
|
1207
1452
|
process.stdout.write(`${JSON.stringify({ skill: SKILL_CONTENT }, null, 2)}
|
|
@@ -1234,7 +1479,7 @@ function detectFramework(rootDir, packageDeps) {
|
|
|
1234
1479
|
if ("react-scripts" in packageDeps) return "cra";
|
|
1235
1480
|
return "unknown";
|
|
1236
1481
|
}
|
|
1237
|
-
function
|
|
1482
|
+
function detectPackageManager2(rootDir) {
|
|
1238
1483
|
if (fs.existsSync(path.join(rootDir, "bun.lock"))) return "bun";
|
|
1239
1484
|
if (fs.existsSync(path.join(rootDir, "yarn.lock"))) return "yarn";
|
|
1240
1485
|
if (fs.existsSync(path.join(rootDir, "pnpm-lock.yaml"))) return "pnpm";
|
|
@@ -1310,6 +1555,31 @@ var TAILWIND_STEMS = ["tailwind.config"];
|
|
|
1310
1555
|
var CSS_EXTS = [".css", ".scss", ".sass", ".less"];
|
|
1311
1556
|
var THEME_SUFFIXES = [".theme.ts", ".theme.js", ".theme.tsx"];
|
|
1312
1557
|
var CSS_CUSTOM_PROPS_RE = /:root\s*\{[^}]*--[a-zA-Z]/;
|
|
1558
|
+
var TAILWIND_V4_THEME_RE = /@theme\s*(?:inline\s*)?\{[^}]*--[a-zA-Z]/;
|
|
1559
|
+
var MAX_SCAN_DEPTH = 4;
|
|
1560
|
+
var SKIP_CSS_NAMES = ["compiled", ".min."];
|
|
1561
|
+
function collectCSSFiles(dir, depth) {
|
|
1562
|
+
if (depth > MAX_SCAN_DEPTH) return [];
|
|
1563
|
+
const results = [];
|
|
1564
|
+
try {
|
|
1565
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1566
|
+
for (const entry of entries) {
|
|
1567
|
+
if (entry.name === "node_modules" || entry.name === "dist" || entry.name === ".next") {
|
|
1568
|
+
continue;
|
|
1569
|
+
}
|
|
1570
|
+
const full = path.join(dir, entry.name);
|
|
1571
|
+
if (entry.isFile() && CSS_EXTS.some((x) => entry.name.endsWith(x))) {
|
|
1572
|
+
if (!SKIP_CSS_NAMES.some((skip) => entry.name.includes(skip))) {
|
|
1573
|
+
results.push(full);
|
|
1574
|
+
}
|
|
1575
|
+
} else if (entry.isDirectory()) {
|
|
1576
|
+
results.push(...collectCSSFiles(full, depth + 1));
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
} catch {
|
|
1580
|
+
}
|
|
1581
|
+
return results;
|
|
1582
|
+
}
|
|
1313
1583
|
function detectTokenSources(rootDir) {
|
|
1314
1584
|
const sources = [];
|
|
1315
1585
|
for (const stem of TAILWIND_STEMS) {
|
|
@@ -1325,32 +1595,53 @@ function detectTokenSources(rootDir) {
|
|
|
1325
1595
|
}
|
|
1326
1596
|
}
|
|
1327
1597
|
const srcDir = path.join(rootDir, "src");
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
}
|
|
1598
|
+
if (fs.existsSync(srcDir)) {
|
|
1599
|
+
const cssFiles = collectCSSFiles(srcDir, 0);
|
|
1600
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1601
|
+
for (const filePath of cssFiles) {
|
|
1602
|
+
const content = readSafe(filePath);
|
|
1603
|
+
if (content === null) continue;
|
|
1604
|
+
if (TAILWIND_V4_THEME_RE.test(content) && !seen.has(filePath)) {
|
|
1605
|
+
sources.push({ kind: "tailwind-v4-theme", path: filePath });
|
|
1606
|
+
seen.add(filePath);
|
|
1607
|
+
}
|
|
1608
|
+
if (CSS_CUSTOM_PROPS_RE.test(content) && !seen.has(filePath)) {
|
|
1609
|
+
sources.push({ kind: "css-custom-properties", path: filePath });
|
|
1610
|
+
seen.add(filePath);
|
|
1611
|
+
}
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
for (const tokenDir of ["tokens", "styles", "theme"]) {
|
|
1615
|
+
const dir = path.join(rootDir, tokenDir);
|
|
1616
|
+
if (!fs.existsSync(dir)) continue;
|
|
1617
|
+
const cssFiles = collectCSSFiles(dir, 0);
|
|
1618
|
+
for (const filePath of cssFiles) {
|
|
1619
|
+
const content = readSafe(filePath);
|
|
1620
|
+
if (content === null) continue;
|
|
1621
|
+
if (TAILWIND_V4_THEME_RE.test(content)) {
|
|
1622
|
+
sources.push({ kind: "tailwind-v4-theme", path: filePath });
|
|
1623
|
+
} else if (CSS_CUSTOM_PROPS_RE.test(content)) {
|
|
1624
|
+
sources.push({ kind: "css-custom-properties", path: filePath });
|
|
1340
1625
|
}
|
|
1341
|
-
} catch {
|
|
1342
1626
|
}
|
|
1343
1627
|
}
|
|
1344
1628
|
if (fs.existsSync(srcDir)) {
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1629
|
+
const scanThemeFiles = (dir, depth) => {
|
|
1630
|
+
if (depth > MAX_SCAN_DEPTH) return;
|
|
1631
|
+
try {
|
|
1632
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1633
|
+
for (const entry of entries) {
|
|
1634
|
+
if (entry.name === "node_modules" || entry.name === "dist") continue;
|
|
1635
|
+
if (entry.isFile() && THEME_SUFFIXES.some((s) => entry.name.endsWith(s))) {
|
|
1636
|
+
sources.push({ kind: "theme-file", path: path.join(dir, entry.name) });
|
|
1637
|
+
} else if (entry.isDirectory()) {
|
|
1638
|
+
scanThemeFiles(path.join(dir, entry.name), depth + 1);
|
|
1639
|
+
}
|
|
1350
1640
|
}
|
|
1641
|
+
} catch {
|
|
1351
1642
|
}
|
|
1352
|
-
}
|
|
1353
|
-
|
|
1643
|
+
};
|
|
1644
|
+
scanThemeFiles(srcDir, 0);
|
|
1354
1645
|
}
|
|
1355
1646
|
return sources;
|
|
1356
1647
|
}
|
|
@@ -1370,7 +1661,7 @@ function detectProject(rootDir) {
|
|
|
1370
1661
|
}
|
|
1371
1662
|
const framework = detectFramework(rootDir, packageDeps);
|
|
1372
1663
|
const { typescript, tsconfigPath } = detectTypeScript(rootDir);
|
|
1373
|
-
const packageManager =
|
|
1664
|
+
const packageManager = detectPackageManager2(rootDir);
|
|
1374
1665
|
const componentPatterns = detectComponentPatterns(rootDir, typescript);
|
|
1375
1666
|
const tokenSources = detectTokenSources(rootDir);
|
|
1376
1667
|
const globalCSSFiles = detectGlobalCSSFiles(rootDir);
|
|
@@ -1421,9 +1712,9 @@ function createRL() {
|
|
|
1421
1712
|
});
|
|
1422
1713
|
}
|
|
1423
1714
|
async function ask(rl, question) {
|
|
1424
|
-
return new Promise((
|
|
1715
|
+
return new Promise((resolve21) => {
|
|
1425
1716
|
rl.question(question, (answer) => {
|
|
1426
|
-
|
|
1717
|
+
resolve21(answer.trim());
|
|
1427
1718
|
});
|
|
1428
1719
|
});
|
|
1429
1720
|
}
|
|
@@ -1885,21 +2176,64 @@ function registerQuery(manifestCmd) {
|
|
|
1885
2176
|
}
|
|
1886
2177
|
);
|
|
1887
2178
|
}
|
|
2179
|
+
function loadReactScopeConfig(rootDir) {
|
|
2180
|
+
const configPath = path.resolve(rootDir, "reactscope.config.json");
|
|
2181
|
+
if (!fs.existsSync(configPath)) return null;
|
|
2182
|
+
try {
|
|
2183
|
+
const raw = fs.readFileSync(configPath, "utf-8");
|
|
2184
|
+
const cfg = JSON.parse(raw);
|
|
2185
|
+
const result = {};
|
|
2186
|
+
const components = cfg.components;
|
|
2187
|
+
if (components !== void 0 && typeof components === "object" && components !== null) {
|
|
2188
|
+
if (Array.isArray(components.include)) {
|
|
2189
|
+
result.include = components.include;
|
|
2190
|
+
}
|
|
2191
|
+
if (Array.isArray(components.exclude)) {
|
|
2192
|
+
result.exclude = components.exclude;
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
if (Array.isArray(cfg.internalPatterns)) {
|
|
2196
|
+
result.internalPatterns = cfg.internalPatterns;
|
|
2197
|
+
}
|
|
2198
|
+
if (Array.isArray(cfg.collections)) {
|
|
2199
|
+
result.collections = cfg.collections;
|
|
2200
|
+
}
|
|
2201
|
+
const icons = cfg.icons;
|
|
2202
|
+
if (icons !== void 0 && typeof icons === "object" && icons !== null) {
|
|
2203
|
+
if (Array.isArray(icons.patterns)) {
|
|
2204
|
+
result.iconPatterns = icons.patterns;
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
return result;
|
|
2208
|
+
} catch {
|
|
2209
|
+
return null;
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
1888
2212
|
function registerGenerate(manifestCmd) {
|
|
1889
2213
|
manifestCmd.command("generate").description(
|
|
1890
|
-
'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\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'
|
|
2214
|
+
'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'
|
|
1891
2215
|
).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) => {
|
|
1892
2216
|
try {
|
|
1893
2217
|
const rootDir = path.resolve(process.cwd(), opts.root ?? ".");
|
|
1894
2218
|
const outputPath = path.resolve(process.cwd(), opts.output);
|
|
1895
|
-
const
|
|
1896
|
-
const
|
|
2219
|
+
const configValues = loadReactScopeConfig(rootDir);
|
|
2220
|
+
const include = opts.include?.split(",").map((s) => s.trim()) ?? configValues?.include;
|
|
2221
|
+
const exclude = opts.exclude?.split(",").map((s) => s.trim()) ?? configValues?.exclude;
|
|
1897
2222
|
process.stderr.write(`Scanning ${rootDir} for React components...
|
|
1898
2223
|
`);
|
|
1899
2224
|
const manifest$1 = await manifest.generateManifest({
|
|
1900
2225
|
rootDir,
|
|
1901
2226
|
...include !== void 0 && { include },
|
|
1902
|
-
...exclude !== void 0 && { exclude }
|
|
2227
|
+
...exclude !== void 0 && { exclude },
|
|
2228
|
+
...configValues?.internalPatterns !== void 0 && {
|
|
2229
|
+
internalPatterns: configValues.internalPatterns
|
|
2230
|
+
},
|
|
2231
|
+
...configValues?.collections !== void 0 && {
|
|
2232
|
+
collections: configValues.collections
|
|
2233
|
+
},
|
|
2234
|
+
...configValues?.iconPatterns !== void 0 && {
|
|
2235
|
+
iconPatterns: configValues.iconPatterns
|
|
2236
|
+
}
|
|
1903
2237
|
});
|
|
1904
2238
|
const componentCount = Object.keys(manifest$1.components).length;
|
|
1905
2239
|
process.stderr.write(`Found ${componentCount} components.
|
|
@@ -2288,7 +2622,7 @@ Available: ${available}`
|
|
|
2288
2622
|
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
2289
2623
|
`);
|
|
2290
2624
|
} catch (err) {
|
|
2291
|
-
process.stderr.write(
|
|
2625
|
+
process.stderr.write(`${formatScopeDiagnostic(err)}
|
|
2292
2626
|
`);
|
|
2293
2627
|
process.exit(1);
|
|
2294
2628
|
}
|
|
@@ -2391,13 +2725,11 @@ function buildProfilingCollectScript() {
|
|
|
2391
2725
|
// mount commit), it *may* have been wasted if it didn't actually need to re-render.
|
|
2392
2726
|
// For the initial snapshot we approximate: wastedRenders = max(0, totalCommits - 1) * 0.3
|
|
2393
2727
|
// This is a heuristic \u2014 real wasted render detection needs shouldComponentUpdate/React.memo tracing.
|
|
2394
|
-
var wastedRenders = Math.max(0, Math.round((totalCommits - 1) * uniqueNames.length * 0.3));
|
|
2395
|
-
|
|
2396
2728
|
return {
|
|
2397
2729
|
commitCount: totalCommits,
|
|
2398
2730
|
uniqueComponents: uniqueNames.length,
|
|
2399
2731
|
componentNames: uniqueNames,
|
|
2400
|
-
wastedRenders:
|
|
2732
|
+
wastedRenders: null,
|
|
2401
2733
|
layoutTime: window.__scopeLayoutTime || 0,
|
|
2402
2734
|
paintTime: window.__scopePaintTime || 0,
|
|
2403
2735
|
layoutShifts: window.__scopeLayoutShifts || { count: 0, score: 0 }
|
|
@@ -2445,7 +2777,7 @@ async function replayInteraction(page, steps) {
|
|
|
2445
2777
|
}
|
|
2446
2778
|
function analyzeProfileFlags(totalRenders, wastedRenders, timing, layoutShifts) {
|
|
2447
2779
|
const flags = /* @__PURE__ */ new Set();
|
|
2448
|
-
if (wastedRenders > 0 && wastedRenders / Math.max(1, totalRenders) > 0.3) {
|
|
2780
|
+
if (wastedRenders !== null && wastedRenders > 0 && wastedRenders / Math.max(1, totalRenders) > 0.3) {
|
|
2449
2781
|
flags.add("WASTED_RENDER");
|
|
2450
2782
|
}
|
|
2451
2783
|
if (totalRenders > 10) {
|
|
@@ -2516,13 +2848,18 @@ async function runInteractionProfile(componentName, filePath, props, interaction
|
|
|
2516
2848
|
};
|
|
2517
2849
|
const totalRenders = profileData.commitCount ?? 0;
|
|
2518
2850
|
const uniqueComponents = profileData.uniqueComponents ?? 0;
|
|
2519
|
-
const wastedRenders = profileData.wastedRenders ??
|
|
2851
|
+
const wastedRenders = profileData.wastedRenders ?? null;
|
|
2520
2852
|
const flags = analyzeProfileFlags(totalRenders, wastedRenders, timing, layoutShifts);
|
|
2521
2853
|
return {
|
|
2522
2854
|
component: componentName,
|
|
2523
2855
|
totalRenders,
|
|
2524
2856
|
uniqueComponents,
|
|
2525
2857
|
wastedRenders,
|
|
2858
|
+
wastedRendersHeuristic: {
|
|
2859
|
+
measured: false,
|
|
2860
|
+
value: null,
|
|
2861
|
+
note: "profile.wastedRenders is retained for compatibility but set to null because Scope does not directly measure wasted renders yet."
|
|
2862
|
+
},
|
|
2526
2863
|
timing,
|
|
2527
2864
|
layoutShifts,
|
|
2528
2865
|
flags,
|
|
@@ -2597,7 +2934,7 @@ Available: ${available}`
|
|
|
2597
2934
|
process.stdout.write(`${JSON.stringify(result, null, 2)}
|
|
2598
2935
|
`);
|
|
2599
2936
|
} catch (err) {
|
|
2600
|
-
process.stderr.write(
|
|
2937
|
+
process.stderr.write(`${formatScopeDiagnostic(err)}
|
|
2601
2938
|
`);
|
|
2602
2939
|
process.exit(1);
|
|
2603
2940
|
}
|
|
@@ -2942,7 +3279,7 @@ Available: ${available}`
|
|
|
2942
3279
|
`);
|
|
2943
3280
|
}
|
|
2944
3281
|
} catch (err) {
|
|
2945
|
-
process.stderr.write(
|
|
3282
|
+
process.stderr.write(`${formatScopeDiagnostic(err)}
|
|
2946
3283
|
`);
|
|
2947
3284
|
process.exit(1);
|
|
2948
3285
|
}
|
|
@@ -3434,7 +3771,7 @@ Examples:
|
|
|
3434
3771
|
}
|
|
3435
3772
|
} catch (err) {
|
|
3436
3773
|
await shutdownPool2();
|
|
3437
|
-
process.stderr.write(
|
|
3774
|
+
process.stderr.write(`${formatScopeDiagnostic(err)}
|
|
3438
3775
|
`);
|
|
3439
3776
|
process.exit(1);
|
|
3440
3777
|
}
|
|
@@ -3512,6 +3849,54 @@ function writeReportToFile(report, outputPath, pretty) {
|
|
|
3512
3849
|
const json = pretty ? JSON.stringify(report, null, 2) : JSON.stringify(report);
|
|
3513
3850
|
fs.writeFileSync(outputPath, json, "utf-8");
|
|
3514
3851
|
}
|
|
3852
|
+
var RUN_SUMMARY_PATH = ".reactscope/run-summary.json";
|
|
3853
|
+
function buildNextActions(summary) {
|
|
3854
|
+
const actions = /* @__PURE__ */ new Set();
|
|
3855
|
+
for (const failure of summary.failures) {
|
|
3856
|
+
if (failure.stage === "render" || failure.stage === "matrix") {
|
|
3857
|
+
actions.add(
|
|
3858
|
+
`Inspect ${failure.outputPath ?? ".reactscope/renders"} and add/fix ${failure.component}.scope.tsx scenarios or wrappers.`
|
|
3859
|
+
);
|
|
3860
|
+
} else if (failure.stage === "playground") {
|
|
3861
|
+
actions.add(
|
|
3862
|
+
`Open the generated component page and inspect the playground bundling error for ${failure.component}.`
|
|
3863
|
+
);
|
|
3864
|
+
} else if (failure.stage === "compliance") {
|
|
3865
|
+
actions.add(
|
|
3866
|
+
"Run `scope render all` first, then inspect .reactscope/compliance-styles.json and reactscope.tokens.json."
|
|
3867
|
+
);
|
|
3868
|
+
} else if (failure.stage === "site") {
|
|
3869
|
+
actions.add(
|
|
3870
|
+
"Inspect .reactscope/site output and rerun `scope site build` after fixing render/playground failures."
|
|
3871
|
+
);
|
|
3872
|
+
}
|
|
3873
|
+
}
|
|
3874
|
+
if (summary.compliance && summary.compliance.auditedProperties === 0) {
|
|
3875
|
+
actions.add(
|
|
3876
|
+
"No CSS properties were audited. Verify renders produced computed styles and your token file contains matching token categories."
|
|
3877
|
+
);
|
|
3878
|
+
} else if (summary.compliance && summary.compliance.threshold !== void 0 && summary.compliance.score < summary.compliance.threshold) {
|
|
3879
|
+
actions.add(
|
|
3880
|
+
"Inspect .reactscope/compliance-report.json for off-system values and update tokens or component styles."
|
|
3881
|
+
);
|
|
3882
|
+
}
|
|
3883
|
+
if (actions.size === 0) {
|
|
3884
|
+
actions.add("No follow-up needed. Outputs are ready for inspection.");
|
|
3885
|
+
}
|
|
3886
|
+
return [...actions];
|
|
3887
|
+
}
|
|
3888
|
+
function writeRunSummary(summary, summaryPath = RUN_SUMMARY_PATH) {
|
|
3889
|
+
const outputPath = path.resolve(process.cwd(), summaryPath);
|
|
3890
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
3891
|
+
const payload = {
|
|
3892
|
+
...summary,
|
|
3893
|
+
generatedAt: summary.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
3894
|
+
nextActions: summary.nextActions ?? buildNextActions(summary)
|
|
3895
|
+
};
|
|
3896
|
+
fs.writeFileSync(outputPath, `${JSON.stringify(payload, null, 2)}
|
|
3897
|
+
`, "utf-8");
|
|
3898
|
+
return outputPath;
|
|
3899
|
+
}
|
|
3515
3900
|
var SCOPE_EXTENSIONS = [".scope.tsx", ".scope.ts", ".scope.jsx", ".scope.js"];
|
|
3516
3901
|
function findScopeFile(componentFilePath) {
|
|
3517
3902
|
const dir = path.dirname(componentFilePath);
|
|
@@ -3639,6 +4024,63 @@ function loadGlobalCssFilesFromConfig(cwd) {
|
|
|
3639
4024
|
return [];
|
|
3640
4025
|
}
|
|
3641
4026
|
}
|
|
4027
|
+
var TAILWIND_CONFIG_FILES2 = [
|
|
4028
|
+
"tailwind.config.js",
|
|
4029
|
+
"tailwind.config.cjs",
|
|
4030
|
+
"tailwind.config.mjs",
|
|
4031
|
+
"tailwind.config.ts",
|
|
4032
|
+
"postcss.config.js",
|
|
4033
|
+
"postcss.config.cjs",
|
|
4034
|
+
"postcss.config.mjs",
|
|
4035
|
+
"postcss.config.ts"
|
|
4036
|
+
];
|
|
4037
|
+
function shouldWarnForMissingGlobalCss(cwd) {
|
|
4038
|
+
if (TAILWIND_CONFIG_FILES2.some((file) => fs.existsSync(path.resolve(cwd, file)))) {
|
|
4039
|
+
return true;
|
|
4040
|
+
}
|
|
4041
|
+
const packageJsonPath = path.resolve(cwd, "package.json");
|
|
4042
|
+
if (!fs.existsSync(packageJsonPath)) return false;
|
|
4043
|
+
try {
|
|
4044
|
+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
|
|
4045
|
+
return [pkg.dependencies, pkg.devDependencies].some(
|
|
4046
|
+
(deps) => deps && Object.keys(deps).some(
|
|
4047
|
+
(name) => name === "tailwindcss" || name.startsWith("@tailwindcss/")
|
|
4048
|
+
)
|
|
4049
|
+
);
|
|
4050
|
+
} catch {
|
|
4051
|
+
return false;
|
|
4052
|
+
}
|
|
4053
|
+
}
|
|
4054
|
+
function loadIconPatternsFromConfig(cwd) {
|
|
4055
|
+
const configPath = path.resolve(cwd, "reactscope.config.json");
|
|
4056
|
+
if (!fs.existsSync(configPath)) return [];
|
|
4057
|
+
try {
|
|
4058
|
+
const raw = fs.readFileSync(configPath, "utf-8");
|
|
4059
|
+
const cfg = JSON.parse(raw);
|
|
4060
|
+
return cfg.icons?.patterns ?? [];
|
|
4061
|
+
} catch {
|
|
4062
|
+
return [];
|
|
4063
|
+
}
|
|
4064
|
+
}
|
|
4065
|
+
function matchGlob2(pattern, value) {
|
|
4066
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
4067
|
+
const regexStr = escaped.replace(/\*\*/g, "\xA7GLOBSTAR\xA7").replace(/\*/g, "[^/]*").replace(/§GLOBSTAR§/g, ".*");
|
|
4068
|
+
return new RegExp(`^${regexStr}$`, "i").test(value);
|
|
4069
|
+
}
|
|
4070
|
+
function isIconComponent(filePath, displayName, patterns) {
|
|
4071
|
+
return patterns.length > 0 && patterns.some((p) => matchGlob2(p, filePath) || matchGlob2(p, displayName));
|
|
4072
|
+
}
|
|
4073
|
+
function formatAggregateRenderFailureJson(componentName, failures, scenarioCount, runSummaryPath) {
|
|
4074
|
+
return {
|
|
4075
|
+
command: `scope render component ${componentName}`,
|
|
4076
|
+
status: "failed",
|
|
4077
|
+
component: componentName,
|
|
4078
|
+
scenarioCount,
|
|
4079
|
+
failureCount: failures.length,
|
|
4080
|
+
failures,
|
|
4081
|
+
runSummaryPath
|
|
4082
|
+
};
|
|
4083
|
+
}
|
|
3642
4084
|
var MANIFEST_PATH6 = ".reactscope/manifest.json";
|
|
3643
4085
|
var DEFAULT_OUTPUT_DIR = ".reactscope/renders";
|
|
3644
4086
|
var _pool3 = null;
|
|
@@ -3659,7 +4101,7 @@ async function shutdownPool3() {
|
|
|
3659
4101
|
_pool3 = null;
|
|
3660
4102
|
}
|
|
3661
4103
|
}
|
|
3662
|
-
function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, globalCssFiles = [], projectCwd = process.cwd(), wrapperScript) {
|
|
4104
|
+
function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, globalCssFiles = [], projectCwd = process.cwd(), wrapperScript, iconMode = false) {
|
|
3663
4105
|
const satori = new render.SatoriRenderer({
|
|
3664
4106
|
defaultViewport: { width: viewportWidth, height: viewportHeight }
|
|
3665
4107
|
});
|
|
@@ -3669,13 +4111,15 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, g
|
|
|
3669
4111
|
const startMs = performance.now();
|
|
3670
4112
|
const pool = await getPool3(viewportWidth, viewportHeight);
|
|
3671
4113
|
const projectCss = await loadGlobalCss(globalCssFiles, projectCwd);
|
|
4114
|
+
const PAD = 8;
|
|
3672
4115
|
const htmlHarness = await buildComponentHarness(
|
|
3673
4116
|
filePath,
|
|
3674
4117
|
componentName,
|
|
3675
4118
|
props,
|
|
3676
4119
|
viewportWidth,
|
|
3677
4120
|
projectCss ?? void 0,
|
|
3678
|
-
wrapperScript
|
|
4121
|
+
wrapperScript,
|
|
4122
|
+
PAD
|
|
3679
4123
|
);
|
|
3680
4124
|
const slot = await pool.acquire();
|
|
3681
4125
|
const { page } = slot;
|
|
@@ -3698,8 +4142,8 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, g
|
|
|
3698
4142
|
const classes = await page.evaluate(() => {
|
|
3699
4143
|
const set = /* @__PURE__ */ new Set();
|
|
3700
4144
|
document.querySelectorAll("[class]").forEach((el) => {
|
|
3701
|
-
for (const c of el
|
|
3702
|
-
|
|
4145
|
+
for (const c of getElementClassNames(el)) {
|
|
4146
|
+
set.add(c);
|
|
3703
4147
|
}
|
|
3704
4148
|
});
|
|
3705
4149
|
return [...set];
|
|
@@ -3716,17 +4160,28 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, g
|
|
|
3716
4160
|
`Component "${componentName}" rendered with zero bounding box \u2014 it may be invisible or not mounted`
|
|
3717
4161
|
);
|
|
3718
4162
|
}
|
|
3719
|
-
const PAD = 8;
|
|
3720
4163
|
const clipX = Math.max(0, boundingBox.x - PAD);
|
|
3721
4164
|
const clipY = Math.max(0, boundingBox.y - PAD);
|
|
3722
4165
|
const rawW = boundingBox.width + PAD * 2;
|
|
3723
4166
|
const rawH = boundingBox.height + PAD * 2;
|
|
3724
4167
|
const safeW = Math.min(rawW, viewportWidth - clipX);
|
|
3725
4168
|
const safeH = Math.min(rawH, viewportHeight - clipY);
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
4169
|
+
let svgContent;
|
|
4170
|
+
let screenshot;
|
|
4171
|
+
if (iconMode) {
|
|
4172
|
+
svgContent = await page.evaluate((sel) => {
|
|
4173
|
+
const root = document.querySelector(sel);
|
|
4174
|
+
const el = root?.firstElementChild;
|
|
4175
|
+
if (!el) return void 0;
|
|
4176
|
+
return el.outerHTML;
|
|
4177
|
+
}, "[data-reactscope-root]") ?? void 0;
|
|
4178
|
+
screenshot = Buffer.alloc(0);
|
|
4179
|
+
} else {
|
|
4180
|
+
screenshot = await page.screenshot({
|
|
4181
|
+
clip: { x: clipX, y: clipY, width: safeW, height: safeH },
|
|
4182
|
+
type: "png"
|
|
4183
|
+
});
|
|
4184
|
+
}
|
|
3730
4185
|
const STYLE_PROPS = [
|
|
3731
4186
|
"display",
|
|
3732
4187
|
"width",
|
|
@@ -3849,7 +4304,7 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, g
|
|
|
3849
4304
|
name: a11yInfo.name,
|
|
3850
4305
|
violations: imgViolations
|
|
3851
4306
|
};
|
|
3852
|
-
|
|
4307
|
+
const renderResult = {
|
|
3853
4308
|
screenshot,
|
|
3854
4309
|
width: Math.round(safeW),
|
|
3855
4310
|
height: Math.round(safeH),
|
|
@@ -3858,6 +4313,10 @@ function buildRenderer(filePath, componentName, viewportWidth, viewportHeight, g
|
|
|
3858
4313
|
dom,
|
|
3859
4314
|
accessibility
|
|
3860
4315
|
};
|
|
4316
|
+
if (iconMode && svgContent) {
|
|
4317
|
+
renderResult.svgContent = svgContent;
|
|
4318
|
+
}
|
|
4319
|
+
return renderResult;
|
|
3861
4320
|
} finally {
|
|
3862
4321
|
pool.release(slot);
|
|
3863
4322
|
}
|
|
@@ -3957,7 +4416,7 @@ Available: ${available}`
|
|
|
3957
4416
|
const wrapperScript = scopeData?.hasWrapper === true ? await buildWrapperScript(scopeData.filePath) : void 0;
|
|
3958
4417
|
const scenarios = buildScenarioMap(opts, scopeData);
|
|
3959
4418
|
const globalCssFiles = loadGlobalCssFilesFromConfig(rootDir);
|
|
3960
|
-
if (globalCssFiles.length === 0) {
|
|
4419
|
+
if (globalCssFiles.length === 0 && shouldWarnForMissingGlobalCss(rootDir)) {
|
|
3961
4420
|
process.stderr.write(
|
|
3962
4421
|
"warning: No globalCSS files configured. Tailwind/CSS styles will not be applied to renders.\n Add `components.wrappers.globalCSS` to reactscope.config.json\n"
|
|
3963
4422
|
);
|
|
@@ -3976,7 +4435,8 @@ Available: ${available}`
|
|
|
3976
4435
|
`
|
|
3977
4436
|
);
|
|
3978
4437
|
const fmt2 = resolveSingleFormat(opts.format);
|
|
3979
|
-
|
|
4438
|
+
const failures = [];
|
|
4439
|
+
const outputPaths = [];
|
|
3980
4440
|
for (const [scenarioName, props2] of Object.entries(scenarios)) {
|
|
3981
4441
|
const isNamed = scenarioName !== "__default__";
|
|
3982
4442
|
const label = isNamed ? `${componentName}:${scenarioName}` : componentName;
|
|
@@ -3999,7 +4459,14 @@ Available: ${available}`
|
|
|
3999
4459
|
process.stderr.write(` Hints: ${hintList}
|
|
4000
4460
|
`);
|
|
4001
4461
|
}
|
|
4002
|
-
|
|
4462
|
+
failures.push({
|
|
4463
|
+
component: componentName,
|
|
4464
|
+
scenario: isNamed ? scenarioName : void 0,
|
|
4465
|
+
stage: "render",
|
|
4466
|
+
message: outcome.error.message,
|
|
4467
|
+
outputPath: `${DEFAULT_OUTPUT_DIR}/${isNamed ? `${componentName}-${scenarioName}.error.json` : `${componentName}.error.json`}`,
|
|
4468
|
+
hints: outcome.error.heuristicFlags
|
|
4469
|
+
});
|
|
4003
4470
|
continue;
|
|
4004
4471
|
}
|
|
4005
4472
|
const result = outcome.result;
|
|
@@ -4007,6 +4474,7 @@ Available: ${available}`
|
|
|
4007
4474
|
if (opts.output !== void 0 && !isNamed) {
|
|
4008
4475
|
const outPath = path.resolve(process.cwd(), opts.output);
|
|
4009
4476
|
fs.writeFileSync(outPath, result.screenshot);
|
|
4477
|
+
outputPaths.push(outPath);
|
|
4010
4478
|
process.stdout.write(
|
|
4011
4479
|
`\u2713 ${label} \u2192 ${opts.output} (${result.width}\xD7${result.height}, ${result.renderTimeMs.toFixed(0)}ms)
|
|
4012
4480
|
`
|
|
@@ -4021,17 +4489,36 @@ Available: ${available}`
|
|
|
4021
4489
|
const outPath = path.resolve(dir, outFileName);
|
|
4022
4490
|
fs.writeFileSync(outPath, result.screenshot);
|
|
4023
4491
|
const relPath = `${DEFAULT_OUTPUT_DIR}/${outFileName}`;
|
|
4492
|
+
outputPaths.push(relPath);
|
|
4024
4493
|
process.stdout.write(
|
|
4025
4494
|
`\u2713 ${label} \u2192 ${relPath} (${result.width}\xD7${result.height}, ${result.renderTimeMs.toFixed(0)}ms)
|
|
4026
4495
|
`
|
|
4027
4496
|
);
|
|
4028
4497
|
}
|
|
4029
4498
|
}
|
|
4499
|
+
const summaryPath = writeRunSummary({
|
|
4500
|
+
command: `scope render ${componentName}`,
|
|
4501
|
+
status: failures.length > 0 ? "failed" : "success",
|
|
4502
|
+
outputPaths,
|
|
4503
|
+
failures
|
|
4504
|
+
});
|
|
4505
|
+
process.stderr.write(`[scope/render] Run summary written to ${summaryPath}
|
|
4506
|
+
`);
|
|
4507
|
+
if (fmt2 === "json" && failures.length > 0) {
|
|
4508
|
+
const aggregateFailure = formatAggregateRenderFailureJson(
|
|
4509
|
+
componentName,
|
|
4510
|
+
failures,
|
|
4511
|
+
Object.keys(scenarios).length,
|
|
4512
|
+
summaryPath
|
|
4513
|
+
);
|
|
4514
|
+
process.stderr.write(`${JSON.stringify(aggregateFailure, null, 2)}
|
|
4515
|
+
`);
|
|
4516
|
+
}
|
|
4030
4517
|
await shutdownPool3();
|
|
4031
|
-
if (
|
|
4518
|
+
if (failures.length > 0) process.exit(1);
|
|
4032
4519
|
} catch (err) {
|
|
4033
4520
|
await shutdownPool3();
|
|
4034
|
-
process.stderr.write(
|
|
4521
|
+
process.stderr.write(`${formatScopeDiagnostic(err)}
|
|
4035
4522
|
`);
|
|
4036
4523
|
process.exit(1);
|
|
4037
4524
|
}
|
|
@@ -4192,7 +4679,7 @@ Available: ${available}`
|
|
|
4192
4679
|
}
|
|
4193
4680
|
} catch (err) {
|
|
4194
4681
|
await shutdownPool3();
|
|
4195
|
-
process.stderr.write(
|
|
4682
|
+
process.stderr.write(`${formatScopeDiagnostic(err)}
|
|
4196
4683
|
`);
|
|
4197
4684
|
process.exit(1);
|
|
4198
4685
|
}
|
|
@@ -4210,7 +4697,21 @@ function registerRenderAll(renderCmd) {
|
|
|
4210
4697
|
const total = componentNames.length;
|
|
4211
4698
|
if (total === 0) {
|
|
4212
4699
|
process.stderr.write("No components found in manifest.\n");
|
|
4213
|
-
|
|
4700
|
+
const summaryPath2 = writeRunSummary({
|
|
4701
|
+
command: "scope render all",
|
|
4702
|
+
status: "failed",
|
|
4703
|
+
outputPaths: [],
|
|
4704
|
+
failures: [
|
|
4705
|
+
{
|
|
4706
|
+
component: "*",
|
|
4707
|
+
stage: "render",
|
|
4708
|
+
message: "No components found in manifest; refusing to report a false-green batch render."
|
|
4709
|
+
}
|
|
4710
|
+
]
|
|
4711
|
+
});
|
|
4712
|
+
process.stderr.write(`[scope/render] Run summary written to ${summaryPath2}
|
|
4713
|
+
`);
|
|
4714
|
+
process.exit(1);
|
|
4214
4715
|
}
|
|
4215
4716
|
const concurrency = Math.max(1, parseInt(opts.concurrency, 10) || 4);
|
|
4216
4717
|
const outputDir = path.resolve(process.cwd(), opts.outputDir);
|
|
@@ -4219,13 +4720,17 @@ function registerRenderAll(renderCmd) {
|
|
|
4219
4720
|
process.stderr.write(`Rendering ${total} components (concurrency: ${concurrency})\u2026
|
|
4220
4721
|
`);
|
|
4221
4722
|
const results = [];
|
|
4723
|
+
const failures = [];
|
|
4724
|
+
const outputPaths = [];
|
|
4222
4725
|
const complianceStylesMap = {};
|
|
4223
4726
|
let completed = 0;
|
|
4727
|
+
const iconPatterns = loadIconPatternsFromConfig(process.cwd());
|
|
4224
4728
|
const renderOne = async (name) => {
|
|
4225
4729
|
const descriptor = manifest.components[name];
|
|
4226
4730
|
if (descriptor === void 0) return;
|
|
4227
4731
|
const filePath = path.resolve(rootDir, descriptor.filePath);
|
|
4228
4732
|
const allCssFiles = loadGlobalCssFilesFromConfig(process.cwd());
|
|
4733
|
+
const isIcon = isIconComponent(descriptor.filePath, name, iconPatterns);
|
|
4229
4734
|
const scopeData = await loadScopeFileForComponent(filePath);
|
|
4230
4735
|
const scenarioEntries = scopeData !== null ? Object.entries(scopeData.scenarios) : [];
|
|
4231
4736
|
const defaultEntry = scenarioEntries.find(([k]) => k === "default") ?? scenarioEntries[0];
|
|
@@ -4238,7 +4743,8 @@ function registerRenderAll(renderCmd) {
|
|
|
4238
4743
|
812,
|
|
4239
4744
|
allCssFiles,
|
|
4240
4745
|
process.cwd(),
|
|
4241
|
-
wrapperScript
|
|
4746
|
+
wrapperScript,
|
|
4747
|
+
isIcon
|
|
4242
4748
|
);
|
|
4243
4749
|
const outcome = await render.safeRender(
|
|
4244
4750
|
() => renderer.renderCell(renderProps, descriptor.complexityClass),
|
|
@@ -4275,14 +4781,32 @@ function registerRenderAll(renderCmd) {
|
|
|
4275
4781
|
2
|
|
4276
4782
|
)
|
|
4277
4783
|
);
|
|
4784
|
+
failures.push({
|
|
4785
|
+
component: name,
|
|
4786
|
+
stage: "render",
|
|
4787
|
+
message: outcome.error.message,
|
|
4788
|
+
outputPath: errPath,
|
|
4789
|
+
hints: outcome.error.heuristicFlags
|
|
4790
|
+
});
|
|
4791
|
+
outputPaths.push(errPath);
|
|
4278
4792
|
return;
|
|
4279
4793
|
}
|
|
4280
4794
|
const result = outcome.result;
|
|
4281
4795
|
results.push({ name, renderTimeMs: result.renderTimeMs, success: true });
|
|
4282
|
-
|
|
4283
|
-
|
|
4796
|
+
if (!isIcon) {
|
|
4797
|
+
const pngPath = path.resolve(outputDir, `${name}.png`);
|
|
4798
|
+
fs.writeFileSync(pngPath, result.screenshot);
|
|
4799
|
+
outputPaths.push(pngPath);
|
|
4800
|
+
}
|
|
4284
4801
|
const jsonPath = path.resolve(outputDir, `${name}.json`);
|
|
4285
|
-
|
|
4802
|
+
const renderJson = formatRenderJson(name, {}, result);
|
|
4803
|
+
const extResult = result;
|
|
4804
|
+
if (isIcon && extResult.svgContent) {
|
|
4805
|
+
renderJson.svgContent = extResult.svgContent;
|
|
4806
|
+
delete renderJson.screenshot;
|
|
4807
|
+
}
|
|
4808
|
+
fs.writeFileSync(jsonPath, JSON.stringify(renderJson, null, 2));
|
|
4809
|
+
outputPaths.push(jsonPath);
|
|
4286
4810
|
const rawStyles = result.computedStyles["[data-reactscope-root] > *"] ?? {};
|
|
4287
4811
|
const compStyles = {
|
|
4288
4812
|
colors: {},
|
|
@@ -4348,15 +4872,21 @@ function registerRenderAll(renderCmd) {
|
|
|
4348
4872
|
existingJson.axisLabels = [scenarioAxis.values];
|
|
4349
4873
|
fs.writeFileSync(jsonPath, JSON.stringify(existingJson, null, 2));
|
|
4350
4874
|
} catch (matrixErr) {
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
`
|
|
4354
|
-
|
|
4875
|
+
const message = matrixErr instanceof Error ? matrixErr.message : String(matrixErr);
|
|
4876
|
+
process.stderr.write(` [warn] Matrix render for ${name} failed: ${message}
|
|
4877
|
+
`);
|
|
4878
|
+
failures.push({
|
|
4879
|
+
component: name,
|
|
4880
|
+
stage: "matrix",
|
|
4881
|
+
message,
|
|
4882
|
+
outputPath: jsonPath
|
|
4883
|
+
});
|
|
4355
4884
|
}
|
|
4356
4885
|
}
|
|
4357
4886
|
if (isTTY()) {
|
|
4887
|
+
const suffix = isIcon ? " [icon/svg]" : "";
|
|
4358
4888
|
process.stdout.write(
|
|
4359
|
-
`\u2713 ${name} \u2192 ${opts.outputDir}/${name}.png (${result.width}\xD7${result.height}, ${result.renderTimeMs.toFixed(0)}ms)
|
|
4889
|
+
`\u2713 ${name} \u2192 ${opts.outputDir}/${name}${isIcon ? ".json" : ".png"} (${result.width}\xD7${result.height}, ${result.renderTimeMs.toFixed(0)}ms)${suffix}
|
|
4360
4890
|
`
|
|
4361
4891
|
);
|
|
4362
4892
|
}
|
|
@@ -4383,15 +4913,25 @@ function registerRenderAll(renderCmd) {
|
|
|
4383
4913
|
"compliance-styles.json"
|
|
4384
4914
|
);
|
|
4385
4915
|
fs.writeFileSync(compStylesPath, JSON.stringify(complianceStylesMap, null, 2));
|
|
4916
|
+
outputPaths.push(compStylesPath);
|
|
4386
4917
|
process.stderr.write(`[scope/render] \u2713 Wrote compliance-styles.json
|
|
4387
4918
|
`);
|
|
4388
4919
|
process.stderr.write("\n");
|
|
4389
4920
|
const summary = formatSummaryText(results, outputDir);
|
|
4390
4921
|
process.stderr.write(`${summary}
|
|
4391
4922
|
`);
|
|
4923
|
+
const summaryPath = writeRunSummary({
|
|
4924
|
+
command: "scope render all",
|
|
4925
|
+
status: failures.length > 0 ? "failed" : "success",
|
|
4926
|
+
outputPaths,
|
|
4927
|
+
failures
|
|
4928
|
+
});
|
|
4929
|
+
process.stderr.write(`[scope/render] Run summary written to ${summaryPath}
|
|
4930
|
+
`);
|
|
4931
|
+
if (failures.length > 0) process.exit(1);
|
|
4392
4932
|
} catch (err) {
|
|
4393
4933
|
await shutdownPool3();
|
|
4394
|
-
process.stderr.write(
|
|
4934
|
+
process.stderr.write(`${formatScopeDiagnostic(err)}
|
|
4395
4935
|
`);
|
|
4396
4936
|
process.exit(1);
|
|
4397
4937
|
}
|
|
@@ -4451,8 +4991,17 @@ async function shutdownPool4() {
|
|
|
4451
4991
|
}
|
|
4452
4992
|
}
|
|
4453
4993
|
async function renderComponent2(filePath, componentName, props, viewportWidth, viewportHeight) {
|
|
4994
|
+
const PAD = 24;
|
|
4454
4995
|
const pool = await getPool4(viewportWidth, viewportHeight);
|
|
4455
|
-
const htmlHarness = await buildComponentHarness(
|
|
4996
|
+
const htmlHarness = await buildComponentHarness(
|
|
4997
|
+
filePath,
|
|
4998
|
+
componentName,
|
|
4999
|
+
props,
|
|
5000
|
+
viewportWidth,
|
|
5001
|
+
void 0,
|
|
5002
|
+
void 0,
|
|
5003
|
+
PAD
|
|
5004
|
+
);
|
|
4456
5005
|
const slot = await pool.acquire();
|
|
4457
5006
|
const { page } = slot;
|
|
4458
5007
|
try {
|
|
@@ -4474,8 +5023,8 @@ async function renderComponent2(filePath, componentName, props, viewportWidth, v
|
|
|
4474
5023
|
const classes = await page.evaluate(() => {
|
|
4475
5024
|
const set = /* @__PURE__ */ new Set();
|
|
4476
5025
|
document.querySelectorAll("[class]").forEach((el) => {
|
|
4477
|
-
for (const c of el
|
|
4478
|
-
|
|
5026
|
+
for (const c of getElementClassNames(el)) {
|
|
5027
|
+
set.add(c);
|
|
4479
5028
|
}
|
|
4480
5029
|
});
|
|
4481
5030
|
return [...set];
|
|
@@ -4492,7 +5041,6 @@ async function renderComponent2(filePath, componentName, props, viewportWidth, v
|
|
|
4492
5041
|
`Component "${componentName}" rendered with zero bounding box \u2014 it may be invisible or not mounted`
|
|
4493
5042
|
);
|
|
4494
5043
|
}
|
|
4495
|
-
const PAD = 24;
|
|
4496
5044
|
const MIN_W = 320;
|
|
4497
5045
|
const MIN_H = 200;
|
|
4498
5046
|
const clipX = Math.max(0, boundingBox.x - PAD);
|
|
@@ -4584,12 +5132,12 @@ async function runBaseline(options = {}) {
|
|
|
4584
5132
|
fs.mkdirSync(rendersDir, { recursive: true });
|
|
4585
5133
|
let manifest$1;
|
|
4586
5134
|
if (manifestPath !== void 0) {
|
|
4587
|
-
const { readFileSync:
|
|
5135
|
+
const { readFileSync: readFileSync18 } = await import('fs');
|
|
4588
5136
|
const absPath = path.resolve(rootDir, manifestPath);
|
|
4589
5137
|
if (!fs.existsSync(absPath)) {
|
|
4590
5138
|
throw new Error(`Manifest not found at ${absPath}.`);
|
|
4591
5139
|
}
|
|
4592
|
-
manifest$1 = JSON.parse(
|
|
5140
|
+
manifest$1 = JSON.parse(readFileSync18(absPath, "utf-8"));
|
|
4593
5141
|
process.stderr.write(`Loaded manifest from ${manifestPath}
|
|
4594
5142
|
`);
|
|
4595
5143
|
} else {
|
|
@@ -4781,8 +5329,17 @@ async function shutdownPool5() {
|
|
|
4781
5329
|
}
|
|
4782
5330
|
}
|
|
4783
5331
|
async function renderComponent3(filePath, componentName, props, viewportWidth, viewportHeight) {
|
|
5332
|
+
const PAD = 24;
|
|
4784
5333
|
const pool = await getPool5(viewportWidth, viewportHeight);
|
|
4785
|
-
const htmlHarness = await buildComponentHarness(
|
|
5334
|
+
const htmlHarness = await buildComponentHarness(
|
|
5335
|
+
filePath,
|
|
5336
|
+
componentName,
|
|
5337
|
+
props,
|
|
5338
|
+
viewportWidth,
|
|
5339
|
+
void 0,
|
|
5340
|
+
void 0,
|
|
5341
|
+
PAD
|
|
5342
|
+
);
|
|
4786
5343
|
const slot = await pool.acquire();
|
|
4787
5344
|
const { page } = slot;
|
|
4788
5345
|
try {
|
|
@@ -4804,8 +5361,8 @@ async function renderComponent3(filePath, componentName, props, viewportWidth, v
|
|
|
4804
5361
|
const classes = await page.evaluate(() => {
|
|
4805
5362
|
const set = /* @__PURE__ */ new Set();
|
|
4806
5363
|
document.querySelectorAll("[class]").forEach((el) => {
|
|
4807
|
-
for (const c of el
|
|
4808
|
-
|
|
5364
|
+
for (const c of getElementClassNames(el)) {
|
|
5365
|
+
set.add(c);
|
|
4809
5366
|
}
|
|
4810
5367
|
});
|
|
4811
5368
|
return [...set];
|
|
@@ -4822,7 +5379,6 @@ async function renderComponent3(filePath, componentName, props, viewportWidth, v
|
|
|
4822
5379
|
`Component "${componentName}" rendered with zero bounding box \u2014 it may be invisible or not mounted`
|
|
4823
5380
|
);
|
|
4824
5381
|
}
|
|
4825
|
-
const PAD = 24;
|
|
4826
5382
|
const MIN_W = 320;
|
|
4827
5383
|
const MIN_H = 200;
|
|
4828
5384
|
const clipX = Math.max(0, boundingBox.x - PAD);
|
|
@@ -4919,6 +5475,7 @@ function classifyComponent(entry, regressionThreshold) {
|
|
|
4919
5475
|
async function runDiff(options = {}) {
|
|
4920
5476
|
const {
|
|
4921
5477
|
baselineDir: baselineDirRaw = DEFAULT_BASELINE_DIR2,
|
|
5478
|
+
complianceTokens = [],
|
|
4922
5479
|
componentsGlob,
|
|
4923
5480
|
manifestPath,
|
|
4924
5481
|
viewportWidth = 375,
|
|
@@ -5034,7 +5591,7 @@ async function runDiff(options = {}) {
|
|
|
5034
5591
|
if (isTTY() && total > 0) {
|
|
5035
5592
|
process.stderr.write("\n");
|
|
5036
5593
|
}
|
|
5037
|
-
const resolver = new tokens.TokenResolver(
|
|
5594
|
+
const resolver = new tokens.TokenResolver(complianceTokens);
|
|
5038
5595
|
const engine = new tokens.ComplianceEngine(resolver);
|
|
5039
5596
|
const currentBatchReport = engine.auditBatch(computedStylesMap);
|
|
5040
5597
|
const entries = [];
|
|
@@ -5630,129 +6187,803 @@ function buildStructuredReport(report) {
|
|
|
5630
6187
|
route: report.route?.pattern ?? null
|
|
5631
6188
|
};
|
|
5632
6189
|
}
|
|
5633
|
-
|
|
5634
|
-
|
|
5635
|
-
|
|
5636
|
-
|
|
5637
|
-
|
|
5638
|
-
|
|
5639
|
-
|
|
5640
|
-
|
|
5641
|
-
|
|
5642
|
-
|
|
5643
|
-
};
|
|
5644
|
-
|
|
5645
|
-
|
|
5646
|
-
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
5652
|
-
|
|
5653
|
-
|
|
5654
|
-
|
|
5655
|
-
|
|
5656
|
-
|
|
5657
|
-
|
|
5658
|
-
|
|
5659
|
-
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
|
|
5663
|
-
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
outputDir,
|
|
5670
|
-
basePath: opts.basePath,
|
|
5671
|
-
...opts.compliance !== void 0 && {
|
|
5672
|
-
compliancePath: path.resolve(process.cwd(), opts.compliance)
|
|
5673
|
-
},
|
|
5674
|
-
title: opts.title
|
|
5675
|
-
});
|
|
5676
|
-
process.stderr.write(`Site written to ${outputDir}
|
|
5677
|
-
`);
|
|
5678
|
-
process.stdout.write(`${outputDir}
|
|
5679
|
-
`);
|
|
5680
|
-
} catch (err) {
|
|
5681
|
-
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
5682
|
-
`);
|
|
5683
|
-
process.exit(1);
|
|
6190
|
+
async function buildPlaygroundHarness(filePath, componentName, projectCss, wrapperScript) {
|
|
6191
|
+
const bundledScript = await bundlePlaygroundIIFE(filePath, componentName);
|
|
6192
|
+
return wrapPlaygroundHtml(bundledScript, projectCss);
|
|
6193
|
+
}
|
|
6194
|
+
async function bundlePlaygroundIIFE(filePath, componentName) {
|
|
6195
|
+
const wrapperCode = (
|
|
6196
|
+
/* ts */
|
|
6197
|
+
`
|
|
6198
|
+
import * as __scopeMod from ${JSON.stringify(filePath)};
|
|
6199
|
+
import { createRoot } from "react-dom/client";
|
|
6200
|
+
import { createElement, Component as ReactComponent } from "react";
|
|
6201
|
+
|
|
6202
|
+
(function scopePlaygroundHarness() {
|
|
6203
|
+
var Target =
|
|
6204
|
+
__scopeMod["default"] ||
|
|
6205
|
+
__scopeMod[${JSON.stringify(componentName)}] ||
|
|
6206
|
+
(Object.values(__scopeMod).find(
|
|
6207
|
+
function(v) { return typeof v === "function" && /^[A-Z]/.test(v.name || ""); }
|
|
6208
|
+
));
|
|
6209
|
+
|
|
6210
|
+
if (!Target) {
|
|
6211
|
+
document.getElementById("scope-root").innerHTML =
|
|
6212
|
+
'<p style="color:#dc2626;font-family:system-ui;font-size:13px">No renderable component found.</p>';
|
|
6213
|
+
return;
|
|
6214
|
+
}
|
|
6215
|
+
|
|
6216
|
+
// Error boundary to catch async render errors (React unmounts the whole
|
|
6217
|
+
// root when an error is uncaught \u2014 this keeps the error visible instead).
|
|
6218
|
+
var errorStyle = "color:#dc2626;font-family:system-ui;font-size:13px;padding:12px";
|
|
6219
|
+
class ScopeBoundary extends ReactComponent {
|
|
6220
|
+
constructor(p) { super(p); this.state = { error: null }; }
|
|
6221
|
+
static getDerivedStateFromError(err) { return { error: err }; }
|
|
6222
|
+
render() {
|
|
6223
|
+
if (this.state.error) {
|
|
6224
|
+
return createElement("pre", { style: errorStyle },
|
|
6225
|
+
"Render error: " + (this.state.error.message || String(this.state.error)));
|
|
5684
6226
|
}
|
|
6227
|
+
return this.props.children;
|
|
6228
|
+
}
|
|
6229
|
+
}
|
|
6230
|
+
|
|
6231
|
+
var rootEl = document.getElementById("scope-root");
|
|
6232
|
+
var root = createRoot(rootEl);
|
|
6233
|
+
var Wrapper = window.__SCOPE_WRAPPER__;
|
|
6234
|
+
|
|
6235
|
+
function render(props) {
|
|
6236
|
+
var inner = createElement(Target, props);
|
|
6237
|
+
if (Wrapper) inner = createElement(Wrapper, null, inner);
|
|
6238
|
+
root.render(createElement(ScopeBoundary, null, inner));
|
|
6239
|
+
}
|
|
6240
|
+
|
|
6241
|
+
// Render immediately with empty props
|
|
6242
|
+
render({});
|
|
6243
|
+
|
|
6244
|
+
// Listen for messages from the parent frame
|
|
6245
|
+
window.addEventListener("message", function(e) {
|
|
6246
|
+
if (!e.data) return;
|
|
6247
|
+
if (e.data.type === "scope-playground-props") {
|
|
6248
|
+
render(e.data.props || {});
|
|
6249
|
+
} else if (e.data.type === "scope-playground-theme") {
|
|
6250
|
+
document.documentElement.classList.toggle("dark", e.data.theme === "dark");
|
|
6251
|
+
}
|
|
6252
|
+
});
|
|
6253
|
+
|
|
6254
|
+
// Report content height changes to the parent frame
|
|
6255
|
+
var ro = new ResizeObserver(function() {
|
|
6256
|
+
var h = rootEl.scrollHeight;
|
|
6257
|
+
if (parent !== window) {
|
|
6258
|
+
parent.postMessage({ type: "scope-playground-height", height: h }, "*");
|
|
5685
6259
|
}
|
|
6260
|
+
});
|
|
6261
|
+
ro.observe(rootEl);
|
|
6262
|
+
})();
|
|
6263
|
+
`
|
|
5686
6264
|
);
|
|
6265
|
+
const result = await esbuild2__namespace.build({
|
|
6266
|
+
stdin: {
|
|
6267
|
+
contents: wrapperCode,
|
|
6268
|
+
resolveDir: path.dirname(filePath),
|
|
6269
|
+
loader: "tsx",
|
|
6270
|
+
sourcefile: "__scope_playground__.tsx"
|
|
6271
|
+
},
|
|
6272
|
+
bundle: true,
|
|
6273
|
+
format: "iife",
|
|
6274
|
+
write: false,
|
|
6275
|
+
platform: "browser",
|
|
6276
|
+
jsx: "automatic",
|
|
6277
|
+
jsxImportSource: "react",
|
|
6278
|
+
target: "es2020",
|
|
6279
|
+
external: [],
|
|
6280
|
+
define: {
|
|
6281
|
+
"process.env.NODE_ENV": '"production"',
|
|
6282
|
+
global: "globalThis"
|
|
6283
|
+
},
|
|
6284
|
+
logLevel: "silent",
|
|
6285
|
+
banner: {
|
|
6286
|
+
js: "/* @agent-scope/cli playground harness */"
|
|
6287
|
+
},
|
|
6288
|
+
loader: {
|
|
6289
|
+
".css": "empty",
|
|
6290
|
+
".svg": "dataurl",
|
|
6291
|
+
".png": "dataurl",
|
|
6292
|
+
".jpg": "dataurl",
|
|
6293
|
+
".jpeg": "dataurl",
|
|
6294
|
+
".gif": "dataurl",
|
|
6295
|
+
".webp": "dataurl",
|
|
6296
|
+
".ttf": "dataurl",
|
|
6297
|
+
".woff": "dataurl",
|
|
6298
|
+
".woff2": "dataurl"
|
|
6299
|
+
}
|
|
6300
|
+
});
|
|
6301
|
+
if (result.errors.length > 0) {
|
|
6302
|
+
const msg = result.errors.map((e) => `${e.text}${e.location ? ` (${e.location.file}:${e.location.line})` : ""}`).join("\n");
|
|
6303
|
+
throw new Error(`esbuild failed to bundle playground component:
|
|
6304
|
+
${msg}`);
|
|
6305
|
+
}
|
|
6306
|
+
const outputFile = result.outputFiles?.[0];
|
|
6307
|
+
if (outputFile === void 0 || outputFile.text.length === 0) {
|
|
6308
|
+
throw new Error("esbuild produced no playground output");
|
|
6309
|
+
}
|
|
6310
|
+
return outputFile.text;
|
|
5687
6311
|
}
|
|
5688
|
-
function
|
|
5689
|
-
|
|
5690
|
-
|
|
5691
|
-
|
|
6312
|
+
function wrapPlaygroundHtml(bundledScript, projectCss, wrapperScript) {
|
|
6313
|
+
const projectStyleBlock = projectCss != null && projectCss.length > 0 ? `<style id="scope-project-css">
|
|
6314
|
+
${projectCss.replace(/<\/style>/gi, "<\\/style>")}
|
|
6315
|
+
</style>` : "";
|
|
6316
|
+
const wrapperScriptBlock = "";
|
|
6317
|
+
return `<!DOCTYPE html>
|
|
6318
|
+
<html lang="en">
|
|
6319
|
+
<head>
|
|
6320
|
+
<meta charset="UTF-8" />
|
|
6321
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6322
|
+
<script>
|
|
6323
|
+
window.__SCOPE_WRAPPER__ = null;
|
|
6324
|
+
// Prevent React DevTools from interfering with the embedded playground.
|
|
6325
|
+
// The hook causes render instability in same-origin iframes.
|
|
6326
|
+
delete window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
|
|
6327
|
+
</script>
|
|
6328
|
+
<style>
|
|
6329
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
6330
|
+
html, body { margin: 0; padding: 0; font-family: system-ui, sans-serif; }
|
|
6331
|
+
#scope-root { padding: 16px; min-width: 1px; min-height: 1px; }
|
|
6332
|
+
</style>
|
|
6333
|
+
${projectStyleBlock}
|
|
6334
|
+
<style>html, body { background: transparent !important; }</style>
|
|
6335
|
+
</head>
|
|
6336
|
+
<body>
|
|
6337
|
+
<div id="scope-root" data-reactscope-root></div>
|
|
6338
|
+
${wrapperScriptBlock}
|
|
6339
|
+
<script>${bundledScript}</script>
|
|
6340
|
+
</body>
|
|
6341
|
+
</html>`;
|
|
6342
|
+
}
|
|
6343
|
+
|
|
6344
|
+
// src/site-commands.ts
|
|
6345
|
+
var MIME_TYPES = {
|
|
6346
|
+
".html": "text/html; charset=utf-8",
|
|
6347
|
+
".css": "text/css; charset=utf-8",
|
|
6348
|
+
".js": "application/javascript; charset=utf-8",
|
|
6349
|
+
".json": "application/json; charset=utf-8",
|
|
6350
|
+
".png": "image/png",
|
|
6351
|
+
".jpg": "image/jpeg",
|
|
6352
|
+
".jpeg": "image/jpeg",
|
|
6353
|
+
".svg": "image/svg+xml",
|
|
6354
|
+
".ico": "image/x-icon"
|
|
6355
|
+
};
|
|
6356
|
+
function slugify(name) {
|
|
6357
|
+
return name.replace(/([A-Z])/g, (m) => `-${m.toLowerCase()}`).replace(/^-/, "").replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
6358
|
+
}
|
|
6359
|
+
function loadGlobalCssFilesFromConfig2(cwd) {
|
|
6360
|
+
const configPath = path.resolve(cwd, "reactscope.config.json");
|
|
6361
|
+
if (!fs.existsSync(configPath)) return [];
|
|
6362
|
+
try {
|
|
6363
|
+
const raw = fs.readFileSync(configPath, "utf-8");
|
|
6364
|
+
const cfg = JSON.parse(raw);
|
|
6365
|
+
return cfg.components?.wrappers?.globalCSS ?? [];
|
|
6366
|
+
} catch {
|
|
6367
|
+
return [];
|
|
6368
|
+
}
|
|
6369
|
+
}
|
|
6370
|
+
function loadIconPatternsFromConfig2(cwd) {
|
|
6371
|
+
const configPath = path.resolve(cwd, "reactscope.config.json");
|
|
6372
|
+
if (!fs.existsSync(configPath)) return [];
|
|
6373
|
+
try {
|
|
6374
|
+
const raw = fs.readFileSync(configPath, "utf-8");
|
|
6375
|
+
const cfg = JSON.parse(raw);
|
|
6376
|
+
return cfg.icons?.patterns ?? [];
|
|
6377
|
+
} catch {
|
|
6378
|
+
return [];
|
|
6379
|
+
}
|
|
6380
|
+
}
|
|
6381
|
+
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>`;
|
|
6382
|
+
function injectLiveReloadScript(html) {
|
|
6383
|
+
const idx = html.lastIndexOf("</body>");
|
|
6384
|
+
if (idx >= 0) return html.slice(0, idx) + LIVERELOAD_SCRIPT + html.slice(idx);
|
|
6385
|
+
return html + LIVERELOAD_SCRIPT;
|
|
6386
|
+
}
|
|
6387
|
+
function loadWatchConfig(rootDir) {
|
|
6388
|
+
const configPath = path.resolve(rootDir, "reactscope.config.json");
|
|
6389
|
+
if (!fs.existsSync(configPath)) return null;
|
|
6390
|
+
try {
|
|
6391
|
+
const raw = fs.readFileSync(configPath, "utf-8");
|
|
6392
|
+
const cfg = JSON.parse(raw);
|
|
6393
|
+
const result = {};
|
|
6394
|
+
const components = cfg.components;
|
|
6395
|
+
if (components && typeof components === "object") {
|
|
6396
|
+
if (Array.isArray(components.include)) result.include = components.include;
|
|
6397
|
+
if (Array.isArray(components.exclude)) result.exclude = components.exclude;
|
|
6398
|
+
}
|
|
6399
|
+
if (Array.isArray(cfg.internalPatterns))
|
|
6400
|
+
result.internalPatterns = cfg.internalPatterns;
|
|
6401
|
+
if (Array.isArray(cfg.collections)) result.collections = cfg.collections;
|
|
6402
|
+
const icons = cfg.icons;
|
|
6403
|
+
if (icons && typeof icons === "object" && Array.isArray(icons.patterns)) {
|
|
6404
|
+
result.iconPatterns = icons.patterns;
|
|
6405
|
+
}
|
|
6406
|
+
return result;
|
|
6407
|
+
} catch {
|
|
6408
|
+
return null;
|
|
6409
|
+
}
|
|
6410
|
+
}
|
|
6411
|
+
function watchGlob(pattern, filePath) {
|
|
6412
|
+
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
6413
|
+
const regexStr = escaped.replace(/\*\*/g, "\xA7GLOBSTAR\xA7").replace(/\*/g, "[^/]*").replace(/\u00a7GLOBSTAR\u00a7/g, ".*");
|
|
6414
|
+
return new RegExp(`^${regexStr}$`, "i").test(filePath);
|
|
6415
|
+
}
|
|
6416
|
+
function matchesWatchPatterns(filePath, include, exclude) {
|
|
6417
|
+
for (const pattern of exclude) {
|
|
6418
|
+
if (watchGlob(pattern, filePath)) return false;
|
|
6419
|
+
}
|
|
6420
|
+
for (const pattern of include) {
|
|
6421
|
+
if (watchGlob(pattern, filePath)) return true;
|
|
6422
|
+
}
|
|
6423
|
+
return false;
|
|
6424
|
+
}
|
|
6425
|
+
function findAffectedComponents(manifest, changedFiles, previousManifest) {
|
|
6426
|
+
const affected = /* @__PURE__ */ new Set();
|
|
6427
|
+
const normalised = changedFiles.map((f) => f.replace(/\\/g, "/"));
|
|
6428
|
+
for (const [name, descriptor] of Object.entries(manifest.components)) {
|
|
6429
|
+
const componentFile = descriptor.filePath.replace(/\\/g, "/");
|
|
6430
|
+
for (const changed of normalised) {
|
|
6431
|
+
if (componentFile === changed) {
|
|
6432
|
+
affected.add(name);
|
|
6433
|
+
break;
|
|
6434
|
+
}
|
|
6435
|
+
const scopeBase = changed.replace(/\.scope\.(ts|tsx|js|jsx)$/, "");
|
|
6436
|
+
const compBase = componentFile.replace(/\.(tsx|ts|jsx|js)$/, "");
|
|
6437
|
+
if (scopeBase !== changed && compBase === scopeBase) {
|
|
6438
|
+
affected.add(name);
|
|
6439
|
+
break;
|
|
6440
|
+
}
|
|
6441
|
+
}
|
|
6442
|
+
}
|
|
6443
|
+
if (previousManifest) {
|
|
6444
|
+
const oldNames = new Set(Object.keys(previousManifest.components));
|
|
6445
|
+
for (const name of Object.keys(manifest.components)) {
|
|
6446
|
+
if (!oldNames.has(name)) affected.add(name);
|
|
6447
|
+
}
|
|
6448
|
+
}
|
|
6449
|
+
return [...affected];
|
|
6450
|
+
}
|
|
6451
|
+
async function renderComponentsForWatch(manifest, componentNames, rootDir, inputDir) {
|
|
6452
|
+
if (componentNames.length === 0) return;
|
|
6453
|
+
const rendersDir = path.join(inputDir, "renders");
|
|
6454
|
+
await promises.mkdir(rendersDir, { recursive: true });
|
|
6455
|
+
const cssFiles = loadGlobalCssFilesFromConfig2(rootDir);
|
|
6456
|
+
const iconPatterns = loadIconPatternsFromConfig2(rootDir);
|
|
6457
|
+
const complianceStylesPath = path.join(inputDir, "compliance-styles.json");
|
|
6458
|
+
let complianceStyles = {};
|
|
6459
|
+
if (fs.existsSync(complianceStylesPath)) {
|
|
6460
|
+
try {
|
|
6461
|
+
complianceStyles = JSON.parse(fs.readFileSync(complianceStylesPath, "utf-8"));
|
|
6462
|
+
} catch {
|
|
6463
|
+
}
|
|
6464
|
+
}
|
|
6465
|
+
for (const name of componentNames) {
|
|
6466
|
+
const descriptor = manifest.components[name];
|
|
6467
|
+
if (!descriptor) continue;
|
|
6468
|
+
const filePath = path.resolve(rootDir, descriptor.filePath);
|
|
6469
|
+
const isIcon = isIconComponent(descriptor.filePath, name, iconPatterns);
|
|
6470
|
+
let scopeData = null;
|
|
5692
6471
|
try {
|
|
5693
|
-
|
|
5694
|
-
|
|
5695
|
-
|
|
6472
|
+
scopeData = await loadScopeFileForComponent(filePath);
|
|
6473
|
+
} catch {
|
|
6474
|
+
}
|
|
6475
|
+
const scenarioEntries = scopeData ? Object.entries(scopeData.scenarios) : [];
|
|
6476
|
+
const defaultEntry = scenarioEntries.find(([k]) => k === "default") ?? scenarioEntries[0];
|
|
6477
|
+
const renderProps = defaultEntry?.[1] ?? {};
|
|
6478
|
+
let wrapperScript;
|
|
6479
|
+
try {
|
|
6480
|
+
wrapperScript = scopeData?.hasWrapper ? await buildWrapperScript(scopeData.filePath) : void 0;
|
|
6481
|
+
} catch {
|
|
6482
|
+
}
|
|
6483
|
+
const renderer = buildRenderer(
|
|
6484
|
+
filePath,
|
|
6485
|
+
name,
|
|
6486
|
+
375,
|
|
6487
|
+
812,
|
|
6488
|
+
cssFiles,
|
|
6489
|
+
rootDir,
|
|
6490
|
+
wrapperScript,
|
|
6491
|
+
isIcon
|
|
6492
|
+
);
|
|
6493
|
+
const outcome = await render.safeRender(
|
|
6494
|
+
() => renderer.renderCell(renderProps, descriptor.complexityClass),
|
|
6495
|
+
{
|
|
6496
|
+
props: renderProps,
|
|
6497
|
+
sourceLocation: { file: descriptor.filePath, line: descriptor.loc.start, column: 0 }
|
|
5696
6498
|
}
|
|
5697
|
-
|
|
5698
|
-
|
|
5699
|
-
|
|
5700
|
-
|
|
5701
|
-
|
|
5702
|
-
|
|
6499
|
+
);
|
|
6500
|
+
if (outcome.crashed) {
|
|
6501
|
+
process.stderr.write(` \u2717 ${name}: ${outcome.error.message}
|
|
6502
|
+
`);
|
|
6503
|
+
continue;
|
|
6504
|
+
}
|
|
6505
|
+
const result = outcome.result;
|
|
6506
|
+
if (!isIcon) {
|
|
6507
|
+
fs.writeFileSync(path.join(rendersDir, `${name}.png`), result.screenshot);
|
|
6508
|
+
}
|
|
6509
|
+
const renderJson = formatRenderJson(name, renderProps, result);
|
|
6510
|
+
const extResult = result;
|
|
6511
|
+
if (isIcon && extResult.svgContent) {
|
|
6512
|
+
renderJson.svgContent = extResult.svgContent;
|
|
6513
|
+
delete renderJson.screenshot;
|
|
6514
|
+
}
|
|
6515
|
+
fs.writeFileSync(path.join(rendersDir, `${name}.json`), JSON.stringify(renderJson, null, 2));
|
|
6516
|
+
const rawStyles = result.computedStyles["[data-reactscope-root] > *"] ?? {};
|
|
6517
|
+
const compStyles = {
|
|
6518
|
+
colors: {},
|
|
6519
|
+
spacing: {},
|
|
6520
|
+
typography: {},
|
|
6521
|
+
borders: {},
|
|
6522
|
+
shadows: {}
|
|
6523
|
+
};
|
|
6524
|
+
for (const [prop, val] of Object.entries(rawStyles)) {
|
|
6525
|
+
if (!val || val === "none" || val === "") continue;
|
|
6526
|
+
const lower = prop.toLowerCase();
|
|
6527
|
+
if (lower.includes("color") || lower.includes("background")) {
|
|
6528
|
+
compStyles.colors[prop] = val;
|
|
6529
|
+
} else if (lower.includes("padding") || lower.includes("margin") || lower.includes("gap") || lower.includes("width") || lower.includes("height")) {
|
|
6530
|
+
compStyles.spacing[prop] = val;
|
|
6531
|
+
} else if (lower.includes("font") || lower.includes("lineheight") || lower.includes("letterspacing") || lower.includes("texttransform")) {
|
|
6532
|
+
compStyles.typography[prop] = val;
|
|
6533
|
+
} else if (lower.includes("border") || lower.includes("radius") || lower.includes("outline")) {
|
|
6534
|
+
compStyles.borders[prop] = val;
|
|
6535
|
+
} else if (lower.includes("shadow")) {
|
|
6536
|
+
compStyles.shadows[prop] = val;
|
|
5703
6537
|
}
|
|
5704
|
-
|
|
5705
|
-
|
|
5706
|
-
|
|
5707
|
-
|
|
5708
|
-
|
|
5709
|
-
|
|
5710
|
-
|
|
5711
|
-
|
|
6538
|
+
}
|
|
6539
|
+
complianceStyles[name] = compStyles;
|
|
6540
|
+
process.stderr.write(` \u2713 ${name} (${result.renderTimeMs.toFixed(0)}ms)
|
|
6541
|
+
`);
|
|
6542
|
+
}
|
|
6543
|
+
await shutdownPool3();
|
|
6544
|
+
fs.writeFileSync(complianceStylesPath, JSON.stringify(complianceStyles, null, 2), "utf-8");
|
|
6545
|
+
}
|
|
6546
|
+
async function watchRebuildSite(inputDir, outputDir, title, basePath) {
|
|
6547
|
+
const rootDir = process.cwd();
|
|
6548
|
+
await generatePlaygrounds(inputDir, outputDir);
|
|
6549
|
+
const iconPatterns = loadIconPatternsFromConfig2(rootDir);
|
|
6550
|
+
let tokenFilePath;
|
|
6551
|
+
const autoPath = path.resolve(rootDir, "reactscope.tokens.json");
|
|
6552
|
+
if (fs.existsSync(autoPath)) tokenFilePath = autoPath;
|
|
6553
|
+
let compliancePath;
|
|
6554
|
+
const crPath = path.join(inputDir, "compliance-report.json");
|
|
6555
|
+
if (fs.existsSync(crPath)) compliancePath = crPath;
|
|
6556
|
+
await site.buildSite({
|
|
6557
|
+
inputDir,
|
|
6558
|
+
outputDir,
|
|
6559
|
+
basePath,
|
|
6560
|
+
...compliancePath && { compliancePath },
|
|
6561
|
+
...tokenFilePath && { tokenFilePath },
|
|
6562
|
+
title,
|
|
6563
|
+
iconPatterns
|
|
6564
|
+
});
|
|
6565
|
+
}
|
|
6566
|
+
function findStaleComponents(manifest, previousManifest, rendersDir) {
|
|
6567
|
+
const stale = [];
|
|
6568
|
+
for (const [name, descriptor] of Object.entries(manifest.components)) {
|
|
6569
|
+
const jsonPath = path.join(rendersDir, `${name}.json`);
|
|
6570
|
+
if (!fs.existsSync(jsonPath)) {
|
|
6571
|
+
stale.push(name);
|
|
6572
|
+
continue;
|
|
6573
|
+
}
|
|
6574
|
+
if (!previousManifest) continue;
|
|
6575
|
+
const prev = previousManifest.components[name];
|
|
6576
|
+
if (!prev) {
|
|
6577
|
+
stale.push(name);
|
|
6578
|
+
continue;
|
|
6579
|
+
}
|
|
6580
|
+
if (JSON.stringify(prev) !== JSON.stringify(descriptor)) {
|
|
6581
|
+
stale.push(name);
|
|
6582
|
+
}
|
|
6583
|
+
}
|
|
6584
|
+
return stale;
|
|
6585
|
+
}
|
|
6586
|
+
async function runFullBuild(rootDir, inputDir, outputDir, title, basePath) {
|
|
6587
|
+
process.stderr.write("[watch] Starting\u2026\n");
|
|
6588
|
+
const config = loadWatchConfig(rootDir);
|
|
6589
|
+
const manifestPath = path.join(inputDir, "manifest.json");
|
|
6590
|
+
let previousManifest = null;
|
|
6591
|
+
if (fs.existsSync(manifestPath)) {
|
|
6592
|
+
try {
|
|
6593
|
+
previousManifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
|
|
6594
|
+
} catch {
|
|
6595
|
+
}
|
|
6596
|
+
}
|
|
6597
|
+
process.stderr.write("[watch] Generating manifest\u2026\n");
|
|
6598
|
+
const manifest$1 = await manifest.generateManifest({
|
|
6599
|
+
rootDir,
|
|
6600
|
+
...config?.include && { include: config.include },
|
|
6601
|
+
...config?.exclude && { exclude: config.exclude },
|
|
6602
|
+
...config?.internalPatterns && { internalPatterns: config.internalPatterns },
|
|
6603
|
+
...config?.collections && { collections: config.collections },
|
|
6604
|
+
...config?.iconPatterns && { iconPatterns: config.iconPatterns }
|
|
6605
|
+
});
|
|
6606
|
+
await promises.mkdir(inputDir, { recursive: true });
|
|
6607
|
+
fs.writeFileSync(path.join(inputDir, "manifest.json"), JSON.stringify(manifest$1, null, 2), "utf-8");
|
|
6608
|
+
const count = Object.keys(manifest$1.components).length;
|
|
6609
|
+
process.stderr.write(`[watch] Found ${count} components
|
|
6610
|
+
`);
|
|
6611
|
+
const rendersDir = path.join(inputDir, "renders");
|
|
6612
|
+
const stale = findStaleComponents(manifest$1, previousManifest, rendersDir);
|
|
6613
|
+
if (stale.length > 0) {
|
|
6614
|
+
process.stderr.write(
|
|
6615
|
+
`[watch] Rendering ${stale.length} component(s) (${count - stale.length} already up-to-date)
|
|
6616
|
+
`
|
|
6617
|
+
);
|
|
6618
|
+
await renderComponentsForWatch(manifest$1, stale, rootDir, inputDir);
|
|
6619
|
+
} else {
|
|
6620
|
+
process.stderr.write("[watch] All renders up-to-date, skipping render step\n");
|
|
6621
|
+
}
|
|
6622
|
+
process.stderr.write("[watch] Building site\u2026\n");
|
|
6623
|
+
await watchRebuildSite(inputDir, outputDir, title, basePath);
|
|
6624
|
+
process.stderr.write("[watch] Ready\n");
|
|
6625
|
+
return manifest$1;
|
|
6626
|
+
}
|
|
6627
|
+
function startFileWatcher(opts) {
|
|
6628
|
+
const { rootDir, inputDir, outputDir, title, basePath, notifyReload } = opts;
|
|
6629
|
+
let previousManifest = opts.previousManifest;
|
|
6630
|
+
const config = loadWatchConfig(rootDir);
|
|
6631
|
+
const includePatterns = config?.include ?? ["src/**/*.tsx", "src/**/*.ts"];
|
|
6632
|
+
const excludePatterns = config?.exclude ?? [
|
|
6633
|
+
"**/node_modules/**",
|
|
6634
|
+
"**/*.test.*",
|
|
6635
|
+
"**/*.spec.*",
|
|
6636
|
+
"**/dist/**",
|
|
6637
|
+
"**/*.d.ts"
|
|
6638
|
+
];
|
|
6639
|
+
let debounceTimer = null;
|
|
6640
|
+
const pendingFiles = /* @__PURE__ */ new Set();
|
|
6641
|
+
let isRunning = false;
|
|
6642
|
+
const IGNORE_PREFIXES = ["node_modules/", ".reactscope/", "dist/", ".git/", ".next/", ".turbo/"];
|
|
6643
|
+
const handleChange = async () => {
|
|
6644
|
+
if (isRunning) return;
|
|
6645
|
+
isRunning = true;
|
|
6646
|
+
const changedFiles = [...pendingFiles];
|
|
6647
|
+
pendingFiles.clear();
|
|
6648
|
+
try {
|
|
6649
|
+
process.stderr.write(`
|
|
6650
|
+
[watch] ${changedFiles.length} file(s) changed
|
|
6651
|
+
`);
|
|
6652
|
+
process.stderr.write("[watch] Regenerating manifest\u2026\n");
|
|
6653
|
+
const newManifest = await manifest.generateManifest({
|
|
6654
|
+
rootDir,
|
|
6655
|
+
...config?.include && { include: config.include },
|
|
6656
|
+
...config?.exclude && { exclude: config.exclude },
|
|
6657
|
+
...config?.internalPatterns && { internalPatterns: config.internalPatterns },
|
|
6658
|
+
...config?.collections && { collections: config.collections },
|
|
6659
|
+
...config?.iconPatterns && { iconPatterns: config.iconPatterns }
|
|
6660
|
+
});
|
|
6661
|
+
fs.writeFileSync(path.join(inputDir, "manifest.json"), JSON.stringify(newManifest, null, 2), "utf-8");
|
|
6662
|
+
const affected = findAffectedComponents(newManifest, changedFiles, previousManifest);
|
|
6663
|
+
if (affected.length > 0) {
|
|
6664
|
+
process.stderr.write(`[watch] Re-rendering: ${affected.join(", ")}
|
|
6665
|
+
`);
|
|
6666
|
+
await renderComponentsForWatch(newManifest, affected, rootDir, inputDir);
|
|
6667
|
+
} else {
|
|
6668
|
+
process.stderr.write("[watch] No components directly affected\n");
|
|
6669
|
+
}
|
|
6670
|
+
process.stderr.write("[watch] Rebuilding site\u2026\n");
|
|
6671
|
+
await watchRebuildSite(inputDir, outputDir, title, basePath);
|
|
6672
|
+
previousManifest = newManifest;
|
|
6673
|
+
process.stderr.write("[watch] Done\n");
|
|
6674
|
+
notifyReload();
|
|
6675
|
+
} catch (err) {
|
|
6676
|
+
process.stderr.write(`[watch] Error: ${err instanceof Error ? err.message : String(err)}
|
|
6677
|
+
`);
|
|
6678
|
+
} finally {
|
|
6679
|
+
isRunning = false;
|
|
6680
|
+
if (pendingFiles.size > 0) {
|
|
6681
|
+
handleChange();
|
|
6682
|
+
}
|
|
6683
|
+
}
|
|
6684
|
+
};
|
|
6685
|
+
const onFileChange = (_eventType, filename) => {
|
|
6686
|
+
if (!filename) return;
|
|
6687
|
+
const normalised = filename.replace(/\\/g, "/");
|
|
6688
|
+
for (const prefix of IGNORE_PREFIXES) {
|
|
6689
|
+
if (normalised.startsWith(prefix)) return;
|
|
6690
|
+
}
|
|
6691
|
+
if (!matchesWatchPatterns(normalised, includePatterns, excludePatterns)) return;
|
|
6692
|
+
pendingFiles.add(normalised);
|
|
6693
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
6694
|
+
debounceTimer = setTimeout(() => {
|
|
6695
|
+
debounceTimer = null;
|
|
6696
|
+
handleChange();
|
|
6697
|
+
}, 500);
|
|
6698
|
+
};
|
|
6699
|
+
try {
|
|
6700
|
+
fs.watch(rootDir, { recursive: true }, onFileChange);
|
|
6701
|
+
process.stderr.write(`[watch] Watching for changes (${includePatterns.join(", ")})
|
|
6702
|
+
`);
|
|
6703
|
+
} catch (err) {
|
|
6704
|
+
process.stderr.write(
|
|
6705
|
+
`[watch] Warning: Could not start watcher: ${err instanceof Error ? err.message : String(err)}
|
|
6706
|
+
`
|
|
6707
|
+
);
|
|
6708
|
+
}
|
|
6709
|
+
}
|
|
6710
|
+
async function generatePlaygrounds(inputDir, outputDir) {
|
|
6711
|
+
const manifestPath = path.join(inputDir, "manifest.json");
|
|
6712
|
+
const raw = fs.readFileSync(manifestPath, "utf-8");
|
|
6713
|
+
const manifest = JSON.parse(raw);
|
|
6714
|
+
const rootDir = process.cwd();
|
|
6715
|
+
const componentNames = Object.keys(manifest.components);
|
|
6716
|
+
if (componentNames.length === 0) return [];
|
|
6717
|
+
const playgroundDir = path.join(outputDir, "playground");
|
|
6718
|
+
await promises.mkdir(playgroundDir, { recursive: true });
|
|
6719
|
+
const cssFiles = loadGlobalCssFilesFromConfig2(rootDir);
|
|
6720
|
+
const projectCss = await loadGlobalCss(cssFiles, rootDir) ?? void 0;
|
|
6721
|
+
let succeeded = 0;
|
|
6722
|
+
const failures = [];
|
|
6723
|
+
const allDefaults = {};
|
|
6724
|
+
for (const name of componentNames) {
|
|
6725
|
+
const descriptor = manifest.components[name];
|
|
6726
|
+
if (!descriptor) continue;
|
|
6727
|
+
const filePath = path.resolve(rootDir, descriptor.filePath);
|
|
6728
|
+
const slug = slugify(name);
|
|
6729
|
+
try {
|
|
6730
|
+
const scopeData = await loadScopeFileForComponent(filePath);
|
|
6731
|
+
if (scopeData) {
|
|
6732
|
+
const defaultScenario = scopeData.scenarios.default ?? Object.values(scopeData.scenarios)[0];
|
|
6733
|
+
if (defaultScenario) allDefaults[name] = defaultScenario;
|
|
6734
|
+
}
|
|
6735
|
+
} catch {
|
|
6736
|
+
}
|
|
6737
|
+
try {
|
|
6738
|
+
const html = await buildPlaygroundHarness(filePath, name, projectCss);
|
|
6739
|
+
await promises.writeFile(path.join(playgroundDir, `${slug}.html`), html, "utf-8");
|
|
6740
|
+
succeeded++;
|
|
6741
|
+
} catch (err) {
|
|
6742
|
+
process.stderr.write(
|
|
6743
|
+
`[scope/site] \u26A0 playground skip: ${name} \u2014 ${err instanceof Error ? err.message : String(err)}
|
|
6744
|
+
`
|
|
6745
|
+
);
|
|
6746
|
+
failures.push({
|
|
6747
|
+
component: name,
|
|
6748
|
+
stage: "playground",
|
|
6749
|
+
message: err instanceof Error ? err.message : String(err),
|
|
6750
|
+
outputPath: path.join(playgroundDir, `${slug}.html`)
|
|
6751
|
+
});
|
|
6752
|
+
}
|
|
6753
|
+
}
|
|
6754
|
+
await promises.writeFile(
|
|
6755
|
+
path.join(inputDir, "playground-defaults.json"),
|
|
6756
|
+
JSON.stringify(allDefaults, null, 2),
|
|
6757
|
+
"utf-8"
|
|
6758
|
+
);
|
|
6759
|
+
process.stderr.write(
|
|
6760
|
+
`[scope/site] Playgrounds: ${succeeded} built${failures.length > 0 ? `, ${failures.length} failed` : ""}
|
|
6761
|
+
`
|
|
6762
|
+
);
|
|
6763
|
+
return failures;
|
|
6764
|
+
}
|
|
6765
|
+
function registerBuild(siteCmd) {
|
|
6766
|
+
siteCmd.command("build").description(
|
|
6767
|
+
'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'
|
|
6768
|
+
).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(
|
|
6769
|
+
async (opts) => {
|
|
6770
|
+
try {
|
|
6771
|
+
const inputDir = path.resolve(process.cwd(), opts.input);
|
|
6772
|
+
const outputDir = path.resolve(process.cwd(), opts.output);
|
|
6773
|
+
if (!fs.existsSync(inputDir)) {
|
|
6774
|
+
throw new Error(
|
|
6775
|
+
`Input directory not found: ${inputDir}
|
|
6776
|
+
Run \`scope manifest generate\` and \`scope render\` first.`
|
|
6777
|
+
);
|
|
5712
6778
|
}
|
|
5713
|
-
|
|
5714
|
-
|
|
5715
|
-
|
|
5716
|
-
|
|
5717
|
-
|
|
5718
|
-
|
|
6779
|
+
const manifestPath = path.join(inputDir, "manifest.json");
|
|
6780
|
+
if (!fs.existsSync(manifestPath)) {
|
|
6781
|
+
throw new Error(
|
|
6782
|
+
`Manifest not found at ${manifestPath}
|
|
6783
|
+
Run \`scope manifest generate\` first.`
|
|
6784
|
+
);
|
|
5719
6785
|
}
|
|
5720
|
-
|
|
5721
|
-
|
|
5722
|
-
|
|
5723
|
-
|
|
5724
|
-
|
|
6786
|
+
process.stderr.write(`Building site from ${inputDir}\u2026
|
|
6787
|
+
`);
|
|
6788
|
+
process.stderr.write("Bundling playgrounds\u2026\n");
|
|
6789
|
+
const failures = await generatePlaygrounds(inputDir, outputDir);
|
|
6790
|
+
const iconPatterns = loadIconPatternsFromConfig2(process.cwd());
|
|
6791
|
+
let tokenFilePath = opts.tokens ? path.resolve(process.cwd(), opts.tokens) : void 0;
|
|
6792
|
+
if (tokenFilePath === void 0) {
|
|
6793
|
+
const autoPath = path.resolve(process.cwd(), "reactscope.tokens.json");
|
|
6794
|
+
if (fs.existsSync(autoPath)) {
|
|
6795
|
+
tokenFilePath = autoPath;
|
|
6796
|
+
}
|
|
5725
6797
|
}
|
|
5726
|
-
|
|
5727
|
-
|
|
5728
|
-
|
|
5729
|
-
|
|
5730
|
-
|
|
6798
|
+
await site.buildSite({
|
|
6799
|
+
inputDir,
|
|
6800
|
+
outputDir,
|
|
6801
|
+
basePath: opts.basePath,
|
|
6802
|
+
...opts.compliance !== void 0 && {
|
|
6803
|
+
compliancePath: path.resolve(process.cwd(), opts.compliance)
|
|
6804
|
+
},
|
|
6805
|
+
...tokenFilePath !== void 0 && { tokenFilePath },
|
|
6806
|
+
title: opts.title,
|
|
6807
|
+
iconPatterns
|
|
6808
|
+
});
|
|
6809
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
|
|
6810
|
+
const componentCount = Object.keys(manifest.components).length;
|
|
6811
|
+
const generatedPlaygroundCount = componentCount === 0 ? 0 : fs.statSync(path.join(outputDir, "playground")).isDirectory() ? componentCount - failures.length : 0;
|
|
6812
|
+
const siteFailures = [...failures];
|
|
6813
|
+
if (componentCount === 0) {
|
|
6814
|
+
siteFailures.push({
|
|
6815
|
+
component: "*",
|
|
6816
|
+
stage: "site",
|
|
6817
|
+
message: "Manifest contains zero components; generated site is structurally degraded.",
|
|
6818
|
+
outputPath: manifestPath
|
|
6819
|
+
});
|
|
6820
|
+
} else if (generatedPlaygroundCount === 0) {
|
|
6821
|
+
siteFailures.push({
|
|
6822
|
+
component: "*",
|
|
6823
|
+
stage: "site",
|
|
6824
|
+
message: "No playground pages were generated successfully; site build is degraded and should not be treated as green.",
|
|
6825
|
+
outputPath: path.join(outputDir, "playground")
|
|
6826
|
+
});
|
|
6827
|
+
}
|
|
6828
|
+
const summaryPath = writeRunSummary({
|
|
6829
|
+
command: "scope site build",
|
|
6830
|
+
status: siteFailures.length > 0 ? "failed" : "success",
|
|
6831
|
+
outputPaths: [outputDir, path.join(outputDir, "index.html")],
|
|
6832
|
+
failures: siteFailures
|
|
6833
|
+
});
|
|
6834
|
+
process.stderr.write(`Site written to ${outputDir}
|
|
5731
6835
|
`);
|
|
5732
|
-
process.stderr.write(`
|
|
6836
|
+
process.stderr.write(`[scope/site] Run summary written to ${summaryPath}
|
|
5733
6837
|
`);
|
|
5734
|
-
process.
|
|
5735
|
-
});
|
|
5736
|
-
server.on("error", (err) => {
|
|
5737
|
-
if (err.code === "EADDRINUSE") {
|
|
5738
|
-
process.stderr.write(`Error: Port ${port} is already in use.
|
|
6838
|
+
process.stdout.write(`${outputDir}
|
|
5739
6839
|
`);
|
|
5740
|
-
|
|
5741
|
-
|
|
6840
|
+
if (siteFailures.length > 0) process.exit(1);
|
|
6841
|
+
} catch (err) {
|
|
6842
|
+
process.stderr.write(`${formatScopeDiagnostic(err)}
|
|
5742
6843
|
`);
|
|
5743
|
-
}
|
|
5744
6844
|
process.exit(1);
|
|
5745
|
-
}
|
|
5746
|
-
}
|
|
5747
|
-
|
|
6845
|
+
}
|
|
6846
|
+
}
|
|
6847
|
+
);
|
|
6848
|
+
}
|
|
6849
|
+
function registerServe(siteCmd) {
|
|
6850
|
+
siteCmd.command("serve").description(
|
|
6851
|
+
"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"
|
|
6852
|
+
).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(
|
|
6853
|
+
"-i, --input <path>",
|
|
6854
|
+
"Input directory for .reactscope data (watch mode)",
|
|
6855
|
+
".reactscope"
|
|
6856
|
+
).option("--title <text>", "Site title (watch mode)", "Scope \u2014 Component Gallery").option("--base-path <path>", "Base URL path prefix (watch mode)", "/").action(
|
|
6857
|
+
async (opts) => {
|
|
6858
|
+
try {
|
|
6859
|
+
let notifyReload2 = function() {
|
|
6860
|
+
for (const client of sseClients) {
|
|
6861
|
+
client.write("data: reload\n\n");
|
|
6862
|
+
}
|
|
6863
|
+
};
|
|
6864
|
+
var notifyReload = notifyReload2;
|
|
6865
|
+
const port = Number.parseInt(opts.port, 10);
|
|
6866
|
+
if (Number.isNaN(port) || port < 1 || port > 65535) {
|
|
6867
|
+
throw new Error(`Invalid port: ${opts.port}`);
|
|
6868
|
+
}
|
|
6869
|
+
const serveDir = path.resolve(process.cwd(), opts.dir);
|
|
6870
|
+
const watchMode = opts.watch === true;
|
|
6871
|
+
const sseClients = /* @__PURE__ */ new Set();
|
|
6872
|
+
if (watchMode) {
|
|
6873
|
+
await promises.mkdir(serveDir, { recursive: true });
|
|
6874
|
+
}
|
|
6875
|
+
if (!watchMode && !fs.existsSync(serveDir)) {
|
|
6876
|
+
throw new Error(
|
|
6877
|
+
`Serve directory not found: ${serveDir}
|
|
6878
|
+
Run \`scope site build\` first.`
|
|
6879
|
+
);
|
|
6880
|
+
}
|
|
6881
|
+
const server = http.createServer((req, res) => {
|
|
6882
|
+
const rawUrl = req.url ?? "/";
|
|
6883
|
+
const urlPath = decodeURIComponent(rawUrl.split("?")[0] ?? "/");
|
|
6884
|
+
if (watchMode && urlPath === "/__livereload") {
|
|
6885
|
+
res.writeHead(200, {
|
|
6886
|
+
"Content-Type": "text/event-stream",
|
|
6887
|
+
"Cache-Control": "no-cache",
|
|
6888
|
+
Connection: "keep-alive",
|
|
6889
|
+
"Access-Control-Allow-Origin": "*"
|
|
6890
|
+
});
|
|
6891
|
+
res.write("data: connected\n\n");
|
|
6892
|
+
sseClients.add(res);
|
|
6893
|
+
req.on("close", () => sseClients.delete(res));
|
|
6894
|
+
return;
|
|
6895
|
+
}
|
|
6896
|
+
const filePath = path.join(
|
|
6897
|
+
serveDir,
|
|
6898
|
+
urlPath.endsWith("/") ? `${urlPath}index.html` : urlPath
|
|
6899
|
+
);
|
|
6900
|
+
if (!filePath.startsWith(serveDir)) {
|
|
6901
|
+
res.writeHead(403, { "Content-Type": "text/plain" });
|
|
6902
|
+
res.end("Forbidden");
|
|
6903
|
+
return;
|
|
6904
|
+
}
|
|
6905
|
+
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
|
6906
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
6907
|
+
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
|
|
6908
|
+
if (watchMode && ext === ".html") {
|
|
6909
|
+
const html = injectLiveReloadScript(fs.readFileSync(filePath, "utf-8"));
|
|
6910
|
+
res.writeHead(200, { "Content-Type": contentType });
|
|
6911
|
+
res.end(html);
|
|
6912
|
+
return;
|
|
6913
|
+
}
|
|
6914
|
+
res.writeHead(200, { "Content-Type": contentType });
|
|
6915
|
+
fs.createReadStream(filePath).pipe(res);
|
|
6916
|
+
return;
|
|
6917
|
+
}
|
|
6918
|
+
const htmlPath = `${filePath}.html`;
|
|
6919
|
+
if (fs.existsSync(htmlPath) && fs.statSync(htmlPath).isFile()) {
|
|
6920
|
+
if (watchMode) {
|
|
6921
|
+
const html = injectLiveReloadScript(fs.readFileSync(htmlPath, "utf-8"));
|
|
6922
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
6923
|
+
res.end(html);
|
|
6924
|
+
return;
|
|
6925
|
+
}
|
|
6926
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
6927
|
+
fs.createReadStream(htmlPath).pipe(res);
|
|
6928
|
+
return;
|
|
6929
|
+
}
|
|
6930
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
6931
|
+
res.end(`Not found: ${urlPath}`);
|
|
6932
|
+
});
|
|
6933
|
+
server.listen(port, () => {
|
|
6934
|
+
process.stderr.write(`Scope site running at http://localhost:${port}
|
|
5748
6935
|
`);
|
|
5749
|
-
|
|
6936
|
+
process.stderr.write(`Serving ${serveDir}
|
|
6937
|
+
`);
|
|
6938
|
+
if (watchMode) {
|
|
6939
|
+
process.stderr.write(
|
|
6940
|
+
"Watch mode enabled \u2014 source changes trigger rebuild + browser reload\n"
|
|
6941
|
+
);
|
|
6942
|
+
}
|
|
6943
|
+
process.stderr.write("Press Ctrl+C to stop.\n");
|
|
6944
|
+
});
|
|
6945
|
+
server.on("error", (err) => {
|
|
6946
|
+
if (err.code === "EADDRINUSE") {
|
|
6947
|
+
process.stderr.write(`Error: Port ${port} is already in use.
|
|
6948
|
+
`);
|
|
6949
|
+
} else {
|
|
6950
|
+
process.stderr.write(`Server error: ${err.message}
|
|
6951
|
+
`);
|
|
6952
|
+
}
|
|
6953
|
+
process.exit(1);
|
|
6954
|
+
});
|
|
6955
|
+
if (watchMode) {
|
|
6956
|
+
const rootDir = process.cwd();
|
|
6957
|
+
const inputDir = path.resolve(rootDir, opts.input);
|
|
6958
|
+
const initialManifest = await runFullBuild(
|
|
6959
|
+
rootDir,
|
|
6960
|
+
inputDir,
|
|
6961
|
+
serveDir,
|
|
6962
|
+
opts.title,
|
|
6963
|
+
opts.basePath
|
|
6964
|
+
);
|
|
6965
|
+
notifyReload2();
|
|
6966
|
+
startFileWatcher({
|
|
6967
|
+
rootDir,
|
|
6968
|
+
inputDir,
|
|
6969
|
+
outputDir: serveDir,
|
|
6970
|
+
title: opts.title,
|
|
6971
|
+
basePath: opts.basePath,
|
|
6972
|
+
previousManifest: initialManifest,
|
|
6973
|
+
notifyReload: notifyReload2
|
|
6974
|
+
});
|
|
6975
|
+
}
|
|
6976
|
+
} catch (err) {
|
|
6977
|
+
process.stderr.write(`${formatScopeDiagnostic(err)}
|
|
6978
|
+
`);
|
|
6979
|
+
process.exit(1);
|
|
6980
|
+
}
|
|
5750
6981
|
}
|
|
5751
|
-
|
|
6982
|
+
);
|
|
5752
6983
|
}
|
|
5753
6984
|
function createSiteCommand() {
|
|
5754
6985
|
const siteCmd = new commander.Command("site").description(
|
|
5755
|
-
'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-
|
|
6986
|
+
'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'
|
|
5756
6987
|
);
|
|
5757
6988
|
registerBuild(siteCmd);
|
|
5758
6989
|
registerServe(siteCmd);
|
|
@@ -5796,11 +7027,11 @@ function categoryForProperty(property) {
|
|
|
5796
7027
|
}
|
|
5797
7028
|
function buildCategorySummary(batch) {
|
|
5798
7029
|
const cats = {
|
|
5799
|
-
color: { total: 0, onSystem: 0, offSystem: 0, compliance:
|
|
5800
|
-
spacing: { total: 0, onSystem: 0, offSystem: 0, compliance:
|
|
5801
|
-
typography: { total: 0, onSystem: 0, offSystem: 0, compliance:
|
|
5802
|
-
border: { total: 0, onSystem: 0, offSystem: 0, compliance:
|
|
5803
|
-
shadow: { total: 0, onSystem: 0, offSystem: 0, compliance:
|
|
7030
|
+
color: { total: 0, onSystem: 0, offSystem: 0, compliance: 0 },
|
|
7031
|
+
spacing: { total: 0, onSystem: 0, offSystem: 0, compliance: 0 },
|
|
7032
|
+
typography: { total: 0, onSystem: 0, offSystem: 0, compliance: 0 },
|
|
7033
|
+
border: { total: 0, onSystem: 0, offSystem: 0, compliance: 0 },
|
|
7034
|
+
shadow: { total: 0, onSystem: 0, offSystem: 0, compliance: 0 }
|
|
5804
7035
|
};
|
|
5805
7036
|
for (const report of Object.values(batch.components)) {
|
|
5806
7037
|
for (const [property, result] of Object.entries(report.properties)) {
|
|
@@ -5816,7 +7047,7 @@ function buildCategorySummary(batch) {
|
|
|
5816
7047
|
}
|
|
5817
7048
|
}
|
|
5818
7049
|
for (const summary of Object.values(cats)) {
|
|
5819
|
-
summary.compliance = summary.total === 0 ?
|
|
7050
|
+
summary.compliance = summary.total === 0 ? 0 : summary.onSystem / summary.total;
|
|
5820
7051
|
}
|
|
5821
7052
|
return cats;
|
|
5822
7053
|
}
|
|
@@ -5857,6 +7088,11 @@ function formatComplianceReport(batch, threshold) {
|
|
|
5857
7088
|
const lines = [];
|
|
5858
7089
|
const thresholdLabel = threshold !== void 0 ? pct >= threshold ? " \u2713 (pass)" : ` \u2717 (below threshold ${threshold}%)` : "";
|
|
5859
7090
|
lines.push(`Overall compliance score: ${pct}%${thresholdLabel}`);
|
|
7091
|
+
if (batch.totalProperties === 0) {
|
|
7092
|
+
lines.push(
|
|
7093
|
+
"No CSS properties were audited; run `scope render all` and inspect .reactscope/compliance-styles.json before treating compliance as green."
|
|
7094
|
+
);
|
|
7095
|
+
}
|
|
5860
7096
|
lines.push("");
|
|
5861
7097
|
const cats = buildCategorySummary(batch);
|
|
5862
7098
|
const catEntries = Object.entries(cats).filter(([, s]) => s.total > 0);
|
|
@@ -5891,42 +7127,84 @@ function formatComplianceReport(batch, threshold) {
|
|
|
5891
7127
|
}
|
|
5892
7128
|
function registerCompliance(tokensCmd) {
|
|
5893
7129
|
tokensCmd.command("compliance").description(
|
|
5894
|
-
"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 --styles ./custom/compliance-styles.json"
|
|
5895
|
-
).option("--file <path>", "Path to token file (overrides config)").option("--styles <path>", `Path to compliance styles JSON (default: ${DEFAULT_STYLES_PATH})`).option(
|
|
5896
|
-
|
|
5897
|
-
|
|
5898
|
-
|
|
5899
|
-
|
|
5900
|
-
|
|
5901
|
-
|
|
5902
|
-
|
|
5903
|
-
|
|
5904
|
-
|
|
5905
|
-
|
|
5906
|
-
|
|
5907
|
-
|
|
5908
|
-
|
|
7130
|
+
"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"
|
|
7131
|
+
).option("--file <path>", "Path to token file (overrides config)").option("--styles <path>", `Path to compliance styles JSON (default: ${DEFAULT_STYLES_PATH})`).option(
|
|
7132
|
+
"--out <path>",
|
|
7133
|
+
"Write JSON report to file (for use with scope site build --compliance)"
|
|
7134
|
+
).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(
|
|
7135
|
+
(opts) => {
|
|
7136
|
+
try {
|
|
7137
|
+
const tokenFilePath = resolveTokenFilePath(opts.file);
|
|
7138
|
+
const { tokens: tokens$1 } = loadTokens(tokenFilePath);
|
|
7139
|
+
const resolver = new tokens.TokenResolver(tokens$1);
|
|
7140
|
+
const engine = new tokens.ComplianceEngine(resolver);
|
|
7141
|
+
const stylesPath = opts.styles ?? DEFAULT_STYLES_PATH;
|
|
7142
|
+
const stylesFile = loadStylesFile(stylesPath);
|
|
7143
|
+
const componentMap = /* @__PURE__ */ new Map();
|
|
7144
|
+
for (const [name, styles] of Object.entries(stylesFile)) {
|
|
7145
|
+
componentMap.set(name, styles);
|
|
7146
|
+
}
|
|
7147
|
+
if (componentMap.size === 0) {
|
|
7148
|
+
process.stderr.write(`Warning: No components found in styles file at ${stylesPath}
|
|
5909
7149
|
`);
|
|
5910
|
-
|
|
5911
|
-
|
|
5912
|
-
|
|
5913
|
-
|
|
5914
|
-
|
|
5915
|
-
|
|
7150
|
+
}
|
|
7151
|
+
const batch = engine.auditBatch(componentMap);
|
|
7152
|
+
const threshold = opts.threshold !== void 0 ? Number.parseInt(opts.threshold, 10) : void 0;
|
|
7153
|
+
const failures = [];
|
|
7154
|
+
if (batch.totalProperties === 0) {
|
|
7155
|
+
failures.push({
|
|
7156
|
+
component: "*",
|
|
7157
|
+
stage: "compliance",
|
|
7158
|
+
message: `No CSS properties were audited from ${stylesPath}; refusing to report silent success.`,
|
|
7159
|
+
outputPath: stylesPath
|
|
7160
|
+
});
|
|
7161
|
+
} else if (threshold !== void 0 && Math.round(batch.aggregateCompliance * 100) < threshold) {
|
|
7162
|
+
failures.push({
|
|
7163
|
+
component: "*",
|
|
7164
|
+
stage: "compliance",
|
|
7165
|
+
message: `Compliance ${Math.round(batch.aggregateCompliance * 100)}% is below threshold ${threshold}%.`,
|
|
7166
|
+
outputPath: opts.out ?? ".reactscope/compliance-report.json"
|
|
7167
|
+
});
|
|
7168
|
+
}
|
|
7169
|
+
if (opts.out !== void 0) {
|
|
7170
|
+
const outPath = path.resolve(process.cwd(), opts.out);
|
|
7171
|
+
fs.writeFileSync(outPath, JSON.stringify(batch, null, 2), "utf-8");
|
|
7172
|
+
process.stderr.write(`Compliance report written to ${outPath}
|
|
5916
7173
|
`);
|
|
5917
|
-
|
|
5918
|
-
|
|
7174
|
+
}
|
|
7175
|
+
const useJson = opts.format === "json" || opts.format !== "text" && !isTTY();
|
|
7176
|
+
if (useJson) {
|
|
7177
|
+
process.stdout.write(`${JSON.stringify(batch, null, 2)}
|
|
7178
|
+
`);
|
|
7179
|
+
} else {
|
|
7180
|
+
process.stdout.write(`${formatComplianceReport(batch, threshold)}
|
|
7181
|
+
`);
|
|
7182
|
+
}
|
|
7183
|
+
const summaryPath = writeRunSummary({
|
|
7184
|
+
command: "scope tokens compliance",
|
|
7185
|
+
status: failures.length > 0 ? "failed" : "success",
|
|
7186
|
+
outputPaths: [opts.out ?? ".reactscope/compliance-report.json", stylesPath],
|
|
7187
|
+
compliance: {
|
|
7188
|
+
auditedProperties: batch.totalProperties,
|
|
7189
|
+
onSystemProperties: batch.totalOnSystem,
|
|
7190
|
+
offSystemProperties: batch.totalOffSystem,
|
|
7191
|
+
score: Math.round(batch.aggregateCompliance * 100),
|
|
7192
|
+
threshold
|
|
7193
|
+
},
|
|
7194
|
+
failures
|
|
7195
|
+
});
|
|
7196
|
+
process.stderr.write(`[scope/tokens] Run summary written to ${summaryPath}
|
|
7197
|
+
`);
|
|
7198
|
+
if (failures.length > 0) {
|
|
7199
|
+
process.exit(1);
|
|
7200
|
+
}
|
|
7201
|
+
} catch (err) {
|
|
7202
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
5919
7203
|
`);
|
|
5920
|
-
}
|
|
5921
|
-
if (threshold !== void 0 && Math.round(batch.aggregateCompliance * 100) < threshold) {
|
|
5922
7204
|
process.exit(1);
|
|
5923
7205
|
}
|
|
5924
|
-
} catch (err) {
|
|
5925
|
-
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
5926
|
-
`);
|
|
5927
|
-
process.exit(1);
|
|
5928
7206
|
}
|
|
5929
|
-
|
|
7207
|
+
);
|
|
5930
7208
|
}
|
|
5931
7209
|
var DEFAULT_TOKEN_FILE = "reactscope.tokens.json";
|
|
5932
7210
|
var CONFIG_FILE = "reactscope.config.json";
|
|
@@ -6131,18 +7409,242 @@ ${formatImpactSummary(report)}
|
|
|
6131
7409
|
}
|
|
6132
7410
|
);
|
|
6133
7411
|
}
|
|
7412
|
+
var DEFAULT_TOKEN_FILE2 = "reactscope.tokens.json";
|
|
7413
|
+
var CONFIG_FILE2 = "reactscope.config.json";
|
|
7414
|
+
function resolveOutputPath(fileFlag) {
|
|
7415
|
+
if (fileFlag !== void 0) {
|
|
7416
|
+
return path.resolve(process.cwd(), fileFlag);
|
|
7417
|
+
}
|
|
7418
|
+
const configPath = path.resolve(process.cwd(), CONFIG_FILE2);
|
|
7419
|
+
if (fs.existsSync(configPath)) {
|
|
7420
|
+
try {
|
|
7421
|
+
const raw = fs.readFileSync(configPath, "utf-8");
|
|
7422
|
+
const config = JSON.parse(raw);
|
|
7423
|
+
if (typeof config === "object" && config !== null && "tokens" in config && typeof config.tokens === "object" && config.tokens !== null && typeof config.tokens?.file === "string") {
|
|
7424
|
+
const file = config.tokens.file;
|
|
7425
|
+
return path.resolve(process.cwd(), file);
|
|
7426
|
+
}
|
|
7427
|
+
} catch {
|
|
7428
|
+
}
|
|
7429
|
+
}
|
|
7430
|
+
return path.resolve(process.cwd(), DEFAULT_TOKEN_FILE2);
|
|
7431
|
+
}
|
|
7432
|
+
var CSS_VAR_RE = /--([\w-]+)\s*:\s*([^;]+)/g;
|
|
7433
|
+
var HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
|
7434
|
+
var COLOR_FN_RE = /^(?:rgba?|hsla?|oklch|oklab|lch|lab|color|hwb)\(/;
|
|
7435
|
+
var DIMENSION_RE = /^-?\d+(?:\.\d+)?(?:px|rem|em|%|vw|vh|ch|ex|cap|lh|dvh|svh|lvh)$/;
|
|
7436
|
+
var DURATION_RE = /^-?\d+(?:\.\d+)?(?:ms|s)$/;
|
|
7437
|
+
var FONT_FAMILY_RE = /^["']|,\s*(?:sans-serif|serif|monospace|cursive|fantasy|system-ui)/;
|
|
7438
|
+
var NUMBER_RE = /^-?\d+(?:\.\d+)?$/;
|
|
7439
|
+
var CUBIC_BEZIER_RE = /^cubic-bezier\(/;
|
|
7440
|
+
var SHADOW_RE = /^\d.*(?:px|rem|em)\s+(?:#|rgba?|hsla?|oklch|oklab)/i;
|
|
7441
|
+
function inferTokenType(value) {
|
|
7442
|
+
const v = value.trim();
|
|
7443
|
+
if (HEX_COLOR_RE.test(v) || COLOR_FN_RE.test(v)) return "color";
|
|
7444
|
+
if (DURATION_RE.test(v)) return "duration";
|
|
7445
|
+
if (DIMENSION_RE.test(v)) return "dimension";
|
|
7446
|
+
if (FONT_FAMILY_RE.test(v)) return "fontFamily";
|
|
7447
|
+
if (CUBIC_BEZIER_RE.test(v)) return "cubicBezier";
|
|
7448
|
+
if (SHADOW_RE.test(v)) return "shadow";
|
|
7449
|
+
if (NUMBER_RE.test(v)) return "number";
|
|
7450
|
+
return "color";
|
|
7451
|
+
}
|
|
7452
|
+
function setNestedToken(root, segments, value, type) {
|
|
7453
|
+
let node = root;
|
|
7454
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
7455
|
+
const seg = segments[i];
|
|
7456
|
+
if (seg === void 0) continue;
|
|
7457
|
+
if (!(seg in node) || typeof node[seg] !== "object" || node[seg] === null) {
|
|
7458
|
+
node[seg] = {};
|
|
7459
|
+
}
|
|
7460
|
+
node = node[seg];
|
|
7461
|
+
}
|
|
7462
|
+
const leaf = segments[segments.length - 1];
|
|
7463
|
+
if (leaf === void 0) return;
|
|
7464
|
+
node[leaf] = { value, type };
|
|
7465
|
+
}
|
|
7466
|
+
function extractBlockBody(css, openBrace) {
|
|
7467
|
+
let depth = 0;
|
|
7468
|
+
let end = -1;
|
|
7469
|
+
for (let i = openBrace; i < css.length; i++) {
|
|
7470
|
+
if (css[i] === "{") depth++;
|
|
7471
|
+
else if (css[i] === "}") {
|
|
7472
|
+
depth--;
|
|
7473
|
+
if (depth === 0) {
|
|
7474
|
+
end = i;
|
|
7475
|
+
break;
|
|
7476
|
+
}
|
|
7477
|
+
}
|
|
7478
|
+
}
|
|
7479
|
+
if (end === -1) return "";
|
|
7480
|
+
return css.slice(openBrace + 1, end);
|
|
7481
|
+
}
|
|
7482
|
+
function parseScopedBlocks(css) {
|
|
7483
|
+
const blocks = [];
|
|
7484
|
+
const blockRe = /(?::root|@theme(?:\s+inline)?|\.dark\.high-contrast|\.dark)\s*\{/g;
|
|
7485
|
+
let match = blockRe.exec(css);
|
|
7486
|
+
while (match !== null) {
|
|
7487
|
+
const selector = match[0];
|
|
7488
|
+
const braceIdx = css.indexOf("{", match.index);
|
|
7489
|
+
if (braceIdx === -1) {
|
|
7490
|
+
match = blockRe.exec(css);
|
|
7491
|
+
continue;
|
|
7492
|
+
}
|
|
7493
|
+
const body = extractBlockBody(css, braceIdx);
|
|
7494
|
+
let scope;
|
|
7495
|
+
if (selector.includes(".dark.high-contrast")) scope = "dark-high-contrast";
|
|
7496
|
+
else if (selector.includes(".dark")) scope = "dark";
|
|
7497
|
+
else if (selector.includes("@theme")) scope = "theme";
|
|
7498
|
+
else scope = "root";
|
|
7499
|
+
blocks.push({ scope, body });
|
|
7500
|
+
match = blockRe.exec(css);
|
|
7501
|
+
}
|
|
7502
|
+
return blocks;
|
|
7503
|
+
}
|
|
7504
|
+
function extractVarsFromBody(body) {
|
|
7505
|
+
const results = [];
|
|
7506
|
+
for (const m of body.matchAll(CSS_VAR_RE)) {
|
|
7507
|
+
const name = m[1];
|
|
7508
|
+
const value = m[2]?.trim();
|
|
7509
|
+
if (name === void 0 || value === void 0 || value.length === 0) continue;
|
|
7510
|
+
if (value.startsWith("var(") || value.startsWith("calc(")) continue;
|
|
7511
|
+
results.push({ name, value });
|
|
7512
|
+
}
|
|
7513
|
+
return results;
|
|
7514
|
+
}
|
|
7515
|
+
function extractCSSCustomProperties(tokenSources) {
|
|
7516
|
+
const cssSources = tokenSources.filter(
|
|
7517
|
+
(s) => s.kind === "css-custom-properties" || s.kind === "tailwind-v4-theme"
|
|
7518
|
+
);
|
|
7519
|
+
if (cssSources.length === 0) return null;
|
|
7520
|
+
const tokens = {};
|
|
7521
|
+
const themes = {};
|
|
7522
|
+
let found = false;
|
|
7523
|
+
for (const source of cssSources) {
|
|
7524
|
+
try {
|
|
7525
|
+
if (source.path.includes("compiled") || source.path.includes(".min.")) continue;
|
|
7526
|
+
const raw = fs.readFileSync(source.path, "utf-8");
|
|
7527
|
+
const blocks = parseScopedBlocks(raw);
|
|
7528
|
+
for (const block of blocks) {
|
|
7529
|
+
const vars = extractVarsFromBody(block.body);
|
|
7530
|
+
for (const { name, value } of vars) {
|
|
7531
|
+
const segments = name.split("-").filter(Boolean);
|
|
7532
|
+
if (segments.length === 0) continue;
|
|
7533
|
+
if (block.scope === "root" || block.scope === "theme") {
|
|
7534
|
+
const type = inferTokenType(value);
|
|
7535
|
+
setNestedToken(tokens, segments, value, type);
|
|
7536
|
+
found = true;
|
|
7537
|
+
} else {
|
|
7538
|
+
const themeName = block.scope;
|
|
7539
|
+
if (!themes[themeName]) themes[themeName] = {};
|
|
7540
|
+
const path = segments.join(".");
|
|
7541
|
+
themes[themeName][path] = value;
|
|
7542
|
+
found = true;
|
|
7543
|
+
}
|
|
7544
|
+
}
|
|
7545
|
+
}
|
|
7546
|
+
} catch {
|
|
7547
|
+
}
|
|
7548
|
+
}
|
|
7549
|
+
return found ? { tokens, themes } : null;
|
|
7550
|
+
}
|
|
7551
|
+
function registerTokensInit(tokensCmd) {
|
|
7552
|
+
tokensCmd.command("init").description(
|
|
7553
|
+
"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"
|
|
7554
|
+
).option("--file <path>", "Output path for the token file (overrides config)").option("--force", "Overwrite existing token file", false).action((opts) => {
|
|
7555
|
+
try {
|
|
7556
|
+
const outPath = resolveOutputPath(opts.file);
|
|
7557
|
+
if (fs.existsSync(outPath) && !opts.force) {
|
|
7558
|
+
process.stderr.write(
|
|
7559
|
+
`Token file already exists at ${outPath}.
|
|
7560
|
+
Run with --force to overwrite.
|
|
7561
|
+
`
|
|
7562
|
+
);
|
|
7563
|
+
process.exit(1);
|
|
7564
|
+
}
|
|
7565
|
+
const rootDir = process.cwd();
|
|
7566
|
+
const detected = detectProject(rootDir);
|
|
7567
|
+
const tailwindTokens = extractTailwindTokens(detected.tokenSources);
|
|
7568
|
+
const cssResult = extractCSSCustomProperties(detected.tokenSources);
|
|
7569
|
+
const mergedTokens = {};
|
|
7570
|
+
const mergedThemes = {};
|
|
7571
|
+
if (tailwindTokens !== null) {
|
|
7572
|
+
Object.assign(mergedTokens, tailwindTokens);
|
|
7573
|
+
}
|
|
7574
|
+
if (cssResult !== null) {
|
|
7575
|
+
for (const [key, value] of Object.entries(cssResult.tokens)) {
|
|
7576
|
+
if (!(key in mergedTokens)) {
|
|
7577
|
+
mergedTokens[key] = value;
|
|
7578
|
+
}
|
|
7579
|
+
}
|
|
7580
|
+
for (const [themeName, overrides] of Object.entries(cssResult.themes)) {
|
|
7581
|
+
if (!mergedThemes[themeName]) mergedThemes[themeName] = {};
|
|
7582
|
+
Object.assign(mergedThemes[themeName], overrides);
|
|
7583
|
+
}
|
|
7584
|
+
}
|
|
7585
|
+
const tokenFile = {
|
|
7586
|
+
$schema: "https://raw.githubusercontent.com/FlatFilers/Scope/main/packages/tokens/schema.json",
|
|
7587
|
+
version: "1.0.0",
|
|
7588
|
+
meta: {
|
|
7589
|
+
name: "Design Tokens",
|
|
7590
|
+
lastUpdated: (/* @__PURE__ */ new Date()).toISOString().split("T")[0]
|
|
7591
|
+
},
|
|
7592
|
+
tokens: mergedTokens
|
|
7593
|
+
};
|
|
7594
|
+
if (Object.keys(mergedThemes).length > 0) {
|
|
7595
|
+
tokenFile.themes = mergedThemes;
|
|
7596
|
+
}
|
|
7597
|
+
fs.writeFileSync(outPath, `${JSON.stringify(tokenFile, null, 2)}
|
|
7598
|
+
`);
|
|
7599
|
+
const tokenGroupCount = Object.keys(mergedTokens).length;
|
|
7600
|
+
const themeNames = Object.keys(mergedThemes);
|
|
7601
|
+
if (detected.tokenSources.length > 0) {
|
|
7602
|
+
process.stdout.write("Detected token sources:\n");
|
|
7603
|
+
for (const source of detected.tokenSources) {
|
|
7604
|
+
process.stdout.write(` ${source.kind}: ${source.path}
|
|
7605
|
+
`);
|
|
7606
|
+
}
|
|
7607
|
+
process.stdout.write("\n");
|
|
7608
|
+
}
|
|
7609
|
+
if (tokenGroupCount > 0) {
|
|
7610
|
+
process.stdout.write(`Extracted ${tokenGroupCount} token group(s) \u2192 ${outPath}
|
|
7611
|
+
`);
|
|
7612
|
+
if (themeNames.length > 0) {
|
|
7613
|
+
for (const name of themeNames) {
|
|
7614
|
+
const count = Object.keys(mergedThemes[name] ?? {}).length;
|
|
7615
|
+
process.stdout.write(` theme "${name}": ${count} override(s)
|
|
7616
|
+
`);
|
|
7617
|
+
}
|
|
7618
|
+
}
|
|
7619
|
+
} else {
|
|
7620
|
+
process.stdout.write(
|
|
7621
|
+
`No token sources detected. Created empty token file \u2192 ${outPath}
|
|
7622
|
+
Add tokens manually or re-run after configuring a design system.
|
|
7623
|
+
`
|
|
7624
|
+
);
|
|
7625
|
+
}
|
|
7626
|
+
} catch (err) {
|
|
7627
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
7628
|
+
`);
|
|
7629
|
+
process.exit(1);
|
|
7630
|
+
}
|
|
7631
|
+
});
|
|
7632
|
+
}
|
|
6134
7633
|
var DEFAULT_STYLES_PATH3 = ".reactscope/compliance-styles.json";
|
|
6135
7634
|
var DEFAULT_MANIFEST_PATH = ".reactscope/manifest.json";
|
|
6136
7635
|
var DEFAULT_OUTPUT_DIR2 = ".reactscope/previews";
|
|
6137
7636
|
async function renderComponentWithCssOverride(filePath, componentName, cssOverride, vpWidth, vpHeight, timeoutMs) {
|
|
7637
|
+
const PAD = 16;
|
|
6138
7638
|
const htmlHarness = await buildComponentHarness(
|
|
6139
7639
|
filePath,
|
|
6140
7640
|
componentName,
|
|
6141
7641
|
{},
|
|
6142
7642
|
// no props
|
|
6143
7643
|
vpWidth,
|
|
6144
|
-
cssOverride
|
|
7644
|
+
cssOverride,
|
|
6145
7645
|
// injected as <style>
|
|
7646
|
+
void 0,
|
|
7647
|
+
PAD
|
|
6146
7648
|
);
|
|
6147
7649
|
const pool = new render.BrowserPool({
|
|
6148
7650
|
size: { browsers: 1, pagesPerBrowser: 1 },
|
|
@@ -6163,7 +7665,6 @@ async function renderComponentWithCssOverride(filePath, componentName, cssOverri
|
|
|
6163
7665
|
);
|
|
6164
7666
|
const rootLocator = page.locator("[data-reactscope-root]");
|
|
6165
7667
|
const bb = await rootLocator.boundingBox();
|
|
6166
|
-
const PAD = 16;
|
|
6167
7668
|
const MIN_W = 320;
|
|
6168
7669
|
const MIN_H = 120;
|
|
6169
7670
|
const clipX = Math.max(0, (bb?.x ?? 0) - PAD);
|
|
@@ -6352,8 +7853,8 @@ function registerPreview(tokensCmd) {
|
|
|
6352
7853
|
}
|
|
6353
7854
|
|
|
6354
7855
|
// src/tokens/commands.ts
|
|
6355
|
-
var
|
|
6356
|
-
var
|
|
7856
|
+
var DEFAULT_TOKEN_FILE3 = "reactscope.tokens.json";
|
|
7857
|
+
var CONFIG_FILE3 = "reactscope.config.json";
|
|
6357
7858
|
function isTTY2() {
|
|
6358
7859
|
return process.stdout.isTTY === true;
|
|
6359
7860
|
}
|
|
@@ -6375,7 +7876,7 @@ function resolveTokenFilePath(fileFlag) {
|
|
|
6375
7876
|
if (fileFlag !== void 0) {
|
|
6376
7877
|
return path.resolve(process.cwd(), fileFlag);
|
|
6377
7878
|
}
|
|
6378
|
-
const configPath = path.resolve(process.cwd(),
|
|
7879
|
+
const configPath = path.resolve(process.cwd(), CONFIG_FILE3);
|
|
6379
7880
|
if (fs.existsSync(configPath)) {
|
|
6380
7881
|
try {
|
|
6381
7882
|
const raw = fs.readFileSync(configPath, "utf-8");
|
|
@@ -6387,7 +7888,7 @@ function resolveTokenFilePath(fileFlag) {
|
|
|
6387
7888
|
} catch {
|
|
6388
7889
|
}
|
|
6389
7890
|
}
|
|
6390
|
-
return path.resolve(process.cwd(),
|
|
7891
|
+
return path.resolve(process.cwd(), DEFAULT_TOKEN_FILE3);
|
|
6391
7892
|
}
|
|
6392
7893
|
function loadTokens(absPath) {
|
|
6393
7894
|
if (!fs.existsSync(absPath)) {
|
|
@@ -6716,6 +8217,7 @@ function createTokensCommand() {
|
|
|
6716
8217
|
const tokensCmd = new commander.Command("tokens").description(
|
|
6717
8218
|
'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'
|
|
6718
8219
|
);
|
|
8220
|
+
registerTokensInit(tokensCmd);
|
|
6719
8221
|
registerGet2(tokensCmd);
|
|
6720
8222
|
registerList2(tokensCmd);
|
|
6721
8223
|
registerSearch(tokensCmd);
|
|
@@ -6731,7 +8233,7 @@ function createTokensCommand() {
|
|
|
6731
8233
|
// src/program.ts
|
|
6732
8234
|
function createProgram(options = {}) {
|
|
6733
8235
|
const program = new commander.Command("scope").version(options.version ?? "0.1.0").description(
|
|
6734
|
-
'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\
|
|
8236
|
+
'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.'
|
|
6735
8237
|
);
|
|
6736
8238
|
program.command("capture <url>").description(
|
|
6737
8239
|
"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"
|