@fragments-sdk/cli 0.7.2 → 0.7.4
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 +2 -0
- package/dist/bin.js +25 -17
- package/dist/bin.js.map +1 -1
- package/dist/chunk-AWYCDRPG.js +272 -0
- package/dist/chunk-AWYCDRPG.js.map +1 -0
- package/dist/chunk-EKLMXTWU.js +80 -0
- package/dist/chunk-EKLMXTWU.js.map +1 -0
- package/dist/{chunk-DH4ETVSM.js → chunk-NEJ2FBTN.js} +9 -7
- package/dist/{chunk-DH4ETVSM.js.map → chunk-NEJ2FBTN.js.map} +1 -1
- package/dist/{chunk-GHYYFAQN.js → chunk-P33AKQJW.js} +1 -76
- package/dist/chunk-P33AKQJW.js.map +1 -0
- package/dist/{chunk-3T6QL7IY.js → chunk-R6IZZSE7.js} +23 -275
- package/dist/chunk-R6IZZSE7.js.map +1 -0
- package/dist/{chunk-7KUSBMI4.js → chunk-S56I5FST.js} +174 -45
- package/dist/chunk-S56I5FST.js.map +1 -0
- package/dist/{chunk-DQHWLAUV.js → chunk-TOIE7VXF.js} +2 -2
- package/dist/{chunk-OOGTG5FM.js → chunk-UXLGIGSX.js} +56 -2
- package/dist/chunk-UXLGIGSX.js.map +1 -0
- package/dist/{chunk-GKX2HPZ6.js → chunk-YMPGYEWK.js} +9 -3
- package/dist/chunk-YMPGYEWK.js.map +1 -0
- package/dist/chunk-Z7EY4VHE.js +50 -0
- package/dist/{core-UQXZTBFZ.js → core-3NMNCLFW.js} +8 -5
- package/dist/discovery-Z4RDDFVR.js +28 -0
- package/dist/{generate-GP6ZLAQB.js → generate-23VLX7QN.js} +7 -4
- package/dist/{generate-GP6ZLAQB.js.map → generate-23VLX7QN.js.map} +1 -1
- package/dist/index.js +15 -11
- package/dist/index.js.map +1 -1
- package/dist/{init-W72WBSU2.js → init-VYVYMVHH.js} +10 -6
- package/dist/{init-W72WBSU2.js.map → init-VYVYMVHH.js.map} +1 -1
- package/dist/mcp-bin.js +5 -3
- package/dist/mcp-bin.js.map +1 -1
- package/dist/sass.node-4XJK6YBF.js +130708 -0
- package/dist/sass.node-4XJK6YBF.js.map +1 -0
- package/dist/scan-FZR6YVI5.js +15 -0
- package/dist/{service-PVGTYUKX.js → service-CFFBHW4X.js} +6 -4
- package/dist/service-CFFBHW4X.js.map +1 -0
- package/dist/{static-viewer-KILKIVN7.js → static-viewer-VA2JXSCX.js} +6 -4
- package/dist/static-viewer-VA2JXSCX.js.map +1 -0
- package/dist/{test-3YRYQRGV.js → test-VTD7R6G2.js} +8 -4
- package/dist/{test-3YRYQRGV.js.map → test-VTD7R6G2.js.map} +1 -1
- package/dist/{tokens-IXSQHPQK.js → tokens-7JA5CPDL.js} +10 -7
- package/dist/{tokens-IXSQHPQK.js.map → tokens-7JA5CPDL.js.map} +1 -1
- package/dist/{viewer-K42REJU2.js → viewer-WXTDDQGK.js} +403 -26
- package/dist/viewer-WXTDDQGK.js.map +1 -0
- package/package.json +5 -1
- package/src/build.ts +57 -5
- package/src/commands/init.ts +6 -2
- package/src/core/__tests__/token-resolver.test.ts +82 -0
- package/src/core/discovery.ts +7 -1
- package/src/core/token-parser.ts +102 -0
- package/src/core/token-resolver.ts +155 -0
- package/src/migrate/detect.ts +4 -0
- package/src/service/__tests__/patch-generator.test.ts +2 -2
- package/src/service/patch-generator.ts +8 -1
- package/src/viewer/components/App.tsx +63 -2
- package/src/viewer/components/Layout.tsx +1 -1
- package/src/viewer/components/LeftSidebar.tsx +35 -77
- package/src/viewer/preview-frame.html +1 -1
- package/src/viewer/render-utils.ts +141 -0
- package/src/viewer/styles/globals.css +2 -1
- package/src/viewer/vite-plugin.ts +399 -24
- package/dist/chunk-3T6QL7IY.js.map +0 -1
- package/dist/chunk-7KUSBMI4.js.map +0 -1
- package/dist/chunk-GHYYFAQN.js.map +0 -1
- package/dist/chunk-GKX2HPZ6.js.map +0 -1
- package/dist/chunk-OOGTG5FM.js.map +0 -1
- package/dist/scan-V54HWRDY.js +0 -12
- package/dist/viewer-K42REJU2.js.map +0 -1
- /package/dist/{chunk-DQHWLAUV.js.map → chunk-TOIE7VXF.js.map} +0 -0
- /package/dist/{core-UQXZTBFZ.js.map → chunk-Z7EY4VHE.js.map} +0 -0
- /package/dist/{scan-V54HWRDY.js.map → core-3NMNCLFW.js.map} +0 -0
- /package/dist/{service-PVGTYUKX.js.map → discovery-Z4RDDFVR.js.map} +0 -0
- /package/dist/{static-viewer-KILKIVN7.js.map → scan-FZR6YVI5.js.map} +0 -0
|
@@ -8,6 +8,8 @@ export interface RenderRequest {
|
|
|
8
8
|
component: string;
|
|
9
9
|
/** Props to pass to the component */
|
|
10
10
|
props?: Record<string, unknown>;
|
|
11
|
+
/** Variant name to render (uses variant's render function) */
|
|
12
|
+
variant?: string;
|
|
11
13
|
/** Viewport dimensions */
|
|
12
14
|
viewport?: {
|
|
13
15
|
width: number;
|
|
@@ -161,6 +163,145 @@ render();
|
|
|
161
163
|
`;
|
|
162
164
|
}
|
|
163
165
|
|
|
166
|
+
/**
|
|
167
|
+
* Generate a render script that renders a specific variant by name.
|
|
168
|
+
* The variant lookup happens in the browser using the fragment's variants array.
|
|
169
|
+
*/
|
|
170
|
+
export function generateVariantRenderScript(
|
|
171
|
+
fragmentPath: string,
|
|
172
|
+
componentName: string,
|
|
173
|
+
variantName: string
|
|
174
|
+
): string {
|
|
175
|
+
const variantNameLower = JSON.stringify(variantName.toLowerCase());
|
|
176
|
+
|
|
177
|
+
return `
|
|
178
|
+
import React from "react";
|
|
179
|
+
import { createRoot } from "react-dom/client";
|
|
180
|
+
|
|
181
|
+
async function render() {
|
|
182
|
+
const root = document.getElementById("render-root");
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
const fragmentModule = await import("${fragmentPath}");
|
|
186
|
+
const fragment = fragmentModule.default;
|
|
187
|
+
|
|
188
|
+
if (!fragment || !fragment.variants || fragment.variants.length === 0) {
|
|
189
|
+
throw new Error("Fragment has no variants");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const variant = fragment.variants.find(
|
|
193
|
+
v => v.name.toLowerCase() === ${variantNameLower}
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
if (!variant) {
|
|
197
|
+
const available = fragment.variants.map(v => v.name).join(", ");
|
|
198
|
+
throw new Error("Variant '" + ${JSON.stringify(variantName)} + "' not found. Available: " + available);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const element = variant.render();
|
|
202
|
+
|
|
203
|
+
const reactRoot = createRoot(root);
|
|
204
|
+
reactRoot.render(element);
|
|
205
|
+
|
|
206
|
+
requestAnimationFrame(() => {
|
|
207
|
+
requestAnimationFrame(() => {
|
|
208
|
+
root.classList.add("ready");
|
|
209
|
+
window.__RENDER_READY__ = true;
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
} catch (error) {
|
|
213
|
+
console.error("Render error:", error);
|
|
214
|
+
root.innerHTML = \`
|
|
215
|
+
<div class="render-error">
|
|
216
|
+
<strong>Render Error</strong>
|
|
217
|
+
<pre>\${error.message}</pre>
|
|
218
|
+
</div>
|
|
219
|
+
\`;
|
|
220
|
+
root.classList.add("ready");
|
|
221
|
+
window.__RENDER_READY__ = true;
|
|
222
|
+
window.__RENDER_ERROR__ = error.message;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
render();
|
|
227
|
+
`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Generate a render script that also runs axe-core for accessibility auditing.
|
|
232
|
+
* When variantName is provided, renders that specific variant; otherwise renders
|
|
233
|
+
* the component with empty props.
|
|
234
|
+
*/
|
|
235
|
+
export function generateA11yRenderScript(
|
|
236
|
+
fragmentPath: string,
|
|
237
|
+
componentName: string,
|
|
238
|
+
variantName?: string
|
|
239
|
+
): string {
|
|
240
|
+
const variantLookup = variantName
|
|
241
|
+
? `
|
|
242
|
+
const variant = fragment.variants?.find(
|
|
243
|
+
v => v.name.toLowerCase() === ${JSON.stringify(variantName.toLowerCase())}
|
|
244
|
+
);
|
|
245
|
+
if (!variant) {
|
|
246
|
+
throw new Error("Variant '${variantName}' not found");
|
|
247
|
+
}
|
|
248
|
+
element = variant.render();`
|
|
249
|
+
: `
|
|
250
|
+
element = React.createElement(fragment.component, {});`;
|
|
251
|
+
|
|
252
|
+
return `
|
|
253
|
+
import React from "react";
|
|
254
|
+
import { createRoot } from "react-dom/client";
|
|
255
|
+
import axe from "axe-core";
|
|
256
|
+
|
|
257
|
+
async function render() {
|
|
258
|
+
const root = document.getElementById("render-root");
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const fragmentModule = await import("${fragmentPath}");
|
|
262
|
+
const fragment = fragmentModule.default;
|
|
263
|
+
|
|
264
|
+
if (!fragment || !fragment.component) {
|
|
265
|
+
throw new Error("Fragment does not export a component");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
let element;
|
|
269
|
+
${variantLookup}
|
|
270
|
+
|
|
271
|
+
const reactRoot = createRoot(root);
|
|
272
|
+
reactRoot.render(element);
|
|
273
|
+
|
|
274
|
+
// Wait for React to flush rendering
|
|
275
|
+
await new Promise(resolve => {
|
|
276
|
+
requestAnimationFrame(() => {
|
|
277
|
+
requestAnimationFrame(resolve);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// Additional settle time for CSS/animations
|
|
282
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
283
|
+
|
|
284
|
+
// Run axe-core accessibility audit
|
|
285
|
+
const results = await axe.run('#render-root', {
|
|
286
|
+
runOnly: {
|
|
287
|
+
type: 'tag',
|
|
288
|
+
values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'],
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
window.__AXE_RESULTS__ = results;
|
|
293
|
+
window.__RENDER_READY__ = true;
|
|
294
|
+
} catch (error) {
|
|
295
|
+
console.error("A11y audit error:", error);
|
|
296
|
+
window.__AXE_ERROR__ = error.message;
|
|
297
|
+
window.__RENDER_READY__ = true;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
render();
|
|
302
|
+
`;
|
|
303
|
+
}
|
|
304
|
+
|
|
164
305
|
/**
|
|
165
306
|
* Generate a virtual module ID for a render request.
|
|
166
307
|
* This creates a unique ID that Vite can resolve.
|
|
@@ -65,7 +65,8 @@ html {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
body {
|
|
68
|
-
|
|
68
|
+
margin: 0;
|
|
69
|
+
font-family: var(--fui-font-sans, Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
|
|
69
70
|
background-color: var(--bg-primary);
|
|
70
71
|
color: var(--text-primary);
|
|
71
72
|
-webkit-font-smoothing: antialiased;
|
|
@@ -28,6 +28,8 @@ import {
|
|
|
28
28
|
import svgr from "vite-plugin-svgr";
|
|
29
29
|
import {
|
|
30
30
|
generateRenderScript,
|
|
31
|
+
generateVariantRenderScript,
|
|
32
|
+
generateA11yRenderScript,
|
|
31
33
|
findFragmentByName,
|
|
32
34
|
getAvailableComponents,
|
|
33
35
|
type RenderRequest,
|
|
@@ -180,6 +182,21 @@ export function fragmentsPlugin(options: FragmentsPluginOptions): Plugin[] {
|
|
|
180
182
|
// Add process.env shim and esbuild config for Storybook compatibility
|
|
181
183
|
config() {
|
|
182
184
|
return {
|
|
185
|
+
build: {
|
|
186
|
+
rollupOptions: {
|
|
187
|
+
onwarn(warning: any, defaultHandler: any) {
|
|
188
|
+
// Suppress Node.js module externalization warnings from html2canvas/sanitize-html
|
|
189
|
+
if (
|
|
190
|
+
warning.code === "MODULE_LEVEL_DIRECTIVE" ||
|
|
191
|
+
(warning.message &&
|
|
192
|
+
warning.message.includes("has been externalized"))
|
|
193
|
+
) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
defaultHandler(warning);
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
},
|
|
183
200
|
define: {
|
|
184
201
|
// Shim process.env for story files that use it (e.g., process.env.STORYBOOK_*)
|
|
185
202
|
"process.env": "{}",
|
|
@@ -216,7 +233,7 @@ export function fragmentsPlugin(options: FragmentsPluginOptions): Plugin[] {
|
|
|
216
233
|
try {
|
|
217
234
|
// Parse JSON body
|
|
218
235
|
const body = await parseJsonBody(req);
|
|
219
|
-
const { component, props = {}, viewport } = body as RenderRequest;
|
|
236
|
+
const { component, props = {}, viewport, variant } = body as RenderRequest;
|
|
220
237
|
|
|
221
238
|
if (!component) {
|
|
222
239
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
@@ -258,12 +275,18 @@ export function fragmentsPlugin(options: FragmentsPluginOptions): Plugin[] {
|
|
|
258
275
|
return;
|
|
259
276
|
}
|
|
260
277
|
|
|
261
|
-
// Generate render script
|
|
262
|
-
const renderScript =
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
278
|
+
// Generate render script — use variant render if specified, otherwise props
|
|
279
|
+
const renderScript = variant
|
|
280
|
+
? generateVariantRenderScript(
|
|
281
|
+
fragmentFile.absolutePath,
|
|
282
|
+
fragmentInfo.name,
|
|
283
|
+
variant
|
|
284
|
+
)
|
|
285
|
+
: generateRenderScript(
|
|
286
|
+
fragmentFile.absolutePath,
|
|
287
|
+
fragmentInfo.name,
|
|
288
|
+
props
|
|
289
|
+
);
|
|
267
290
|
|
|
268
291
|
// Store the render request for the render page to pick up
|
|
269
292
|
const requestId =
|
|
@@ -1022,14 +1045,32 @@ export function fragmentsPlugin(options: FragmentsPluginOptions): Plugin[] {
|
|
|
1022
1045
|
return;
|
|
1023
1046
|
}
|
|
1024
1047
|
|
|
1025
|
-
//
|
|
1048
|
+
// Auto-discover tokens when config.tokens is missing
|
|
1026
1049
|
if (!config.tokens || !config.tokens.include || config.tokens.include.length === 0) {
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1050
|
+
try {
|
|
1051
|
+
const { discoverTokenFiles } = await import("../core/discovery.js");
|
|
1052
|
+
const discovered = await discoverTokenFiles(projectRoot);
|
|
1053
|
+
if (discovered.length > 0) {
|
|
1054
|
+
config.tokens = {
|
|
1055
|
+
...config.tokens,
|
|
1056
|
+
include: discovered.map((f) => f.relativePath),
|
|
1057
|
+
};
|
|
1058
|
+
} else {
|
|
1059
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1060
|
+
res.end(JSON.stringify({
|
|
1061
|
+
error: "No token files found",
|
|
1062
|
+
suggestion: "Add 'tokens' config to fragments.config.ts or add token files matching default patterns (_variables.scss, tokens.scss, etc.)",
|
|
1063
|
+
}));
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
} catch {
|
|
1067
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1068
|
+
res.end(JSON.stringify({
|
|
1069
|
+
error: "No token configuration found and auto-discovery failed",
|
|
1070
|
+
suggestion: "Add 'tokens' config to fragments.config.ts to enable fix generation",
|
|
1071
|
+
}));
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1033
1074
|
}
|
|
1034
1075
|
|
|
1035
1076
|
// Load fragment data
|
|
@@ -1057,23 +1098,81 @@ export function fragmentsPlugin(options: FragmentsPluginOptions): Plugin[] {
|
|
|
1057
1098
|
await registry.initialize(config.tokens, projectRoot);
|
|
1058
1099
|
}
|
|
1059
1100
|
|
|
1060
|
-
// For now, we generate patches based on style diff data
|
|
1061
|
-
// In a full implementation, we would:
|
|
1062
|
-
// 1. Render the component and get computed styles
|
|
1063
|
-
// 2. Compare with Figma styles to find hardcoded values
|
|
1064
|
-
// 3. Generate patches for each hardcoded value
|
|
1065
|
-
|
|
1066
1101
|
// Get source file path from fragment
|
|
1067
1102
|
const fragmentFile = fragmentFiles.find(
|
|
1068
1103
|
(f) => f.relativePath === fragmentInfo.path
|
|
1069
1104
|
);
|
|
1070
1105
|
const sourceFile = fragmentFile?.relativePath || `${component}.tsx`;
|
|
1071
1106
|
|
|
1072
|
-
//
|
|
1073
|
-
|
|
1107
|
+
// Render the component and extract computed styles
|
|
1108
|
+
let styleDiffs: Array<{
|
|
1109
|
+
property: string;
|
|
1110
|
+
figma: string;
|
|
1111
|
+
rendered: string;
|
|
1112
|
+
match: boolean;
|
|
1113
|
+
}> = [];
|
|
1114
|
+
|
|
1115
|
+
if (fragmentFile) {
|
|
1116
|
+
try {
|
|
1117
|
+
const renderScript = generateRenderScript(
|
|
1118
|
+
fragmentFile.absolutePath,
|
|
1119
|
+
fragmentInfo.name,
|
|
1120
|
+
{}
|
|
1121
|
+
);
|
|
1122
|
+
|
|
1123
|
+
const requestId =
|
|
1124
|
+
Date.now().toString(36) + Math.random().toString(36).slice(2);
|
|
1125
|
+
pendingRenders.set(requestId, {
|
|
1126
|
+
script: renderScript,
|
|
1127
|
+
viewport: { width: 800, height: 600 },
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
const address = _server.httpServer?.address();
|
|
1131
|
+
const port =
|
|
1132
|
+
typeof address === "object" && address ? address.port : 6006;
|
|
1133
|
+
|
|
1134
|
+
const { computedStyles } = await captureRenderWithStyles(
|
|
1135
|
+
`http://localhost:${port}/fragments/__render__/${requestId}`,
|
|
1136
|
+
{ width: 800, height: 600 },
|
|
1137
|
+
true
|
|
1138
|
+
);
|
|
1139
|
+
pendingRenders.delete(requestId);
|
|
1140
|
+
|
|
1141
|
+
if (computedStyles) {
|
|
1142
|
+
// Build token value lookup from registry
|
|
1143
|
+
const tokenValues = new Map<string, string>();
|
|
1144
|
+
const allTokens = registry.getAllTokens();
|
|
1145
|
+
for (const t of allTokens) {
|
|
1146
|
+
if (t.resolvedValue) {
|
|
1147
|
+
tokenValues.set(t.resolvedValue, t.name);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// For each computed style property, check if it uses a token
|
|
1152
|
+
for (const [prop, value] of Object.entries(computedStyles)) {
|
|
1153
|
+
if (!value || value === "transparent" || value === "rgba(0, 0, 0, 0)") continue;
|
|
1154
|
+
|
|
1155
|
+
// Check if the value matches any token
|
|
1156
|
+
const matchesToken = tokenValues.has(value);
|
|
1157
|
+
if (!matchesToken) {
|
|
1158
|
+
styleDiffs.push({
|
|
1159
|
+
property: prop,
|
|
1160
|
+
figma: value, // Using rendered as "expected" since we have no Figma
|
|
1161
|
+
rendered: value,
|
|
1162
|
+
match: false,
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
} catch (renderErr) {
|
|
1168
|
+
// If rendering fails, continue with empty diffs
|
|
1169
|
+
console.warn("[Fragments] Could not render for style extraction:", renderErr);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1074
1173
|
const result = generateTokenPatches(
|
|
1075
1174
|
component,
|
|
1076
|
-
|
|
1175
|
+
styleDiffs,
|
|
1077
1176
|
registry,
|
|
1078
1177
|
{ sourceFile }
|
|
1079
1178
|
);
|
|
@@ -1095,6 +1194,205 @@ export function fragmentsPlugin(options: FragmentsPluginOptions): Plugin[] {
|
|
|
1095
1194
|
return;
|
|
1096
1195
|
}
|
|
1097
1196
|
|
|
1197
|
+
// Handle /fragments/a11y endpoint for accessibility auditing
|
|
1198
|
+
if (req.url === "/fragments/a11y" && req.method === "POST") {
|
|
1199
|
+
try {
|
|
1200
|
+
const body = (await parseJsonBody(req)) as {
|
|
1201
|
+
component: string;
|
|
1202
|
+
variant?: string;
|
|
1203
|
+
standard?: "AA" | "AAA";
|
|
1204
|
+
};
|
|
1205
|
+
|
|
1206
|
+
const { component, variant: variantName, standard = "AA" } = body;
|
|
1207
|
+
|
|
1208
|
+
if (!component) {
|
|
1209
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1210
|
+
res.end(
|
|
1211
|
+
JSON.stringify({ error: "Missing required field: component" })
|
|
1212
|
+
);
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// Load fragments to find the component
|
|
1217
|
+
const loadedFragments = await loadFragmentsForRender(
|
|
1218
|
+
fragmentFiles,
|
|
1219
|
+
projectRoot
|
|
1220
|
+
);
|
|
1221
|
+
const fragmentInfo = findFragmentByName(component, loadedFragments);
|
|
1222
|
+
|
|
1223
|
+
if (!fragmentInfo) {
|
|
1224
|
+
const available = getAvailableComponents(loadedFragments);
|
|
1225
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
1226
|
+
res.end(
|
|
1227
|
+
JSON.stringify({
|
|
1228
|
+
error: `Component '${component}' not found. Available: ${available.join(", ")}`,
|
|
1229
|
+
})
|
|
1230
|
+
);
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
const fragmentFile = fragmentFiles.find(
|
|
1235
|
+
(f) => f.relativePath === fragmentInfo.path
|
|
1236
|
+
);
|
|
1237
|
+
if (!fragmentFile) {
|
|
1238
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1239
|
+
res.end(
|
|
1240
|
+
JSON.stringify({ error: "Could not resolve fragment file path" })
|
|
1241
|
+
);
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
// Determine which variants to audit
|
|
1246
|
+
const variantNames: string[] = [];
|
|
1247
|
+
if (variantName) {
|
|
1248
|
+
variantNames.push(variantName);
|
|
1249
|
+
} else {
|
|
1250
|
+
// Load full fragment data to get variant names
|
|
1251
|
+
const fullData = await loadFullFragmentData(projectRoot);
|
|
1252
|
+
const fragmentData = fullData
|
|
1253
|
+
? Object.values(fullData.fragments).find(
|
|
1254
|
+
(f) => f.meta.name.toLowerCase() === component.toLowerCase()
|
|
1255
|
+
)
|
|
1256
|
+
: null;
|
|
1257
|
+
|
|
1258
|
+
if (fragmentData && fragmentData.variants?.length > 0) {
|
|
1259
|
+
for (const v of fragmentData.variants) {
|
|
1260
|
+
variantNames.push(v.name);
|
|
1261
|
+
}
|
|
1262
|
+
} else {
|
|
1263
|
+
// Fallback: audit default render (no variant)
|
|
1264
|
+
variantNames.push("Default");
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// Get server address
|
|
1269
|
+
const address = _server.httpServer?.address();
|
|
1270
|
+
const port =
|
|
1271
|
+
typeof address === "object" && address ? address.port : 6006;
|
|
1272
|
+
|
|
1273
|
+
// Audit each variant
|
|
1274
|
+
const results: Array<{
|
|
1275
|
+
variant: string;
|
|
1276
|
+
violations: number;
|
|
1277
|
+
passes: number;
|
|
1278
|
+
incomplete: number;
|
|
1279
|
+
summary: {
|
|
1280
|
+
total: number;
|
|
1281
|
+
critical: number;
|
|
1282
|
+
serious: number;
|
|
1283
|
+
moderate: number;
|
|
1284
|
+
minor: number;
|
|
1285
|
+
};
|
|
1286
|
+
violationDetails?: Array<{
|
|
1287
|
+
id: string;
|
|
1288
|
+
impact: string | undefined;
|
|
1289
|
+
description: string;
|
|
1290
|
+
helpUrl: string;
|
|
1291
|
+
nodes: number;
|
|
1292
|
+
}>;
|
|
1293
|
+
}> = [];
|
|
1294
|
+
|
|
1295
|
+
for (const vName of variantNames) {
|
|
1296
|
+
// Generate a11y render script for this variant
|
|
1297
|
+
const a11yScript = generateA11yRenderScript(
|
|
1298
|
+
fragmentFile.absolutePath,
|
|
1299
|
+
fragmentInfo.name,
|
|
1300
|
+
vName === "Default" && !variantName ? undefined : vName
|
|
1301
|
+
);
|
|
1302
|
+
|
|
1303
|
+
const requestId =
|
|
1304
|
+
Date.now().toString(36) + Math.random().toString(36).slice(2);
|
|
1305
|
+
pendingRenders.set(requestId, {
|
|
1306
|
+
script: a11yScript,
|
|
1307
|
+
viewport: { width: 800, height: 600 },
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
try {
|
|
1311
|
+
const auditResult = await captureA11yAudit(
|
|
1312
|
+
`http://localhost:${port}/fragments/__render__/${requestId}`,
|
|
1313
|
+
{ width: 800, height: 600 }
|
|
1314
|
+
);
|
|
1315
|
+
|
|
1316
|
+
// Transform axe results into the expected shape
|
|
1317
|
+
let critical = 0;
|
|
1318
|
+
let serious = 0;
|
|
1319
|
+
let moderate = 0;
|
|
1320
|
+
let minor = 0;
|
|
1321
|
+
|
|
1322
|
+
for (const violation of auditResult.violations ?? []) {
|
|
1323
|
+
switch (violation.impact) {
|
|
1324
|
+
case "critical":
|
|
1325
|
+
critical++;
|
|
1326
|
+
break;
|
|
1327
|
+
case "serious":
|
|
1328
|
+
serious++;
|
|
1329
|
+
break;
|
|
1330
|
+
case "moderate":
|
|
1331
|
+
moderate++;
|
|
1332
|
+
break;
|
|
1333
|
+
case "minor":
|
|
1334
|
+
minor++;
|
|
1335
|
+
break;
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
results.push({
|
|
1340
|
+
variant: vName,
|
|
1341
|
+
violations: auditResult.violations?.length ?? 0,
|
|
1342
|
+
passes: auditResult.passes?.length ?? 0,
|
|
1343
|
+
incomplete: auditResult.incomplete?.length ?? 0,
|
|
1344
|
+
summary: {
|
|
1345
|
+
total: critical + serious + moderate + minor,
|
|
1346
|
+
critical,
|
|
1347
|
+
serious,
|
|
1348
|
+
moderate,
|
|
1349
|
+
minor,
|
|
1350
|
+
},
|
|
1351
|
+
violationDetails: (auditResult.violations ?? []).map(v => ({
|
|
1352
|
+
id: v.id,
|
|
1353
|
+
impact: v.impact,
|
|
1354
|
+
description: v.description,
|
|
1355
|
+
helpUrl: v.helpUrl,
|
|
1356
|
+
nodes: v.nodes.length,
|
|
1357
|
+
})),
|
|
1358
|
+
});
|
|
1359
|
+
} catch (err) {
|
|
1360
|
+
// If a single variant fails, report it as a result with error info
|
|
1361
|
+
results.push({
|
|
1362
|
+
variant: vName,
|
|
1363
|
+
violations: 0,
|
|
1364
|
+
passes: 0,
|
|
1365
|
+
incomplete: 0,
|
|
1366
|
+
summary: {
|
|
1367
|
+
total: 0,
|
|
1368
|
+
critical: 0,
|
|
1369
|
+
serious: 0,
|
|
1370
|
+
moderate: 0,
|
|
1371
|
+
minor: 0,
|
|
1372
|
+
},
|
|
1373
|
+
});
|
|
1374
|
+
} finally {
|
|
1375
|
+
pendingRenders.delete(requestId);
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
res.setHeader("Content-Type", "application/json");
|
|
1380
|
+
res.end(JSON.stringify({ results }));
|
|
1381
|
+
} catch (error) {
|
|
1382
|
+
console.error("[Fragments] Error running a11y audit:", error);
|
|
1383
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
1384
|
+
res.end(
|
|
1385
|
+
JSON.stringify({
|
|
1386
|
+
error:
|
|
1387
|
+
error instanceof Error
|
|
1388
|
+
? error.message
|
|
1389
|
+
: "A11y audit failed",
|
|
1390
|
+
})
|
|
1391
|
+
);
|
|
1392
|
+
}
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1098
1396
|
// Handle /fragments/preview/ - isolated iframe for component previews
|
|
1099
1397
|
if (req.url?.startsWith("/fragments/preview")) {
|
|
1100
1398
|
// Redirect to trailing slash
|
|
@@ -1470,7 +1768,9 @@ export async function loadFragment(path) {
|
|
|
1470
1768
|
}
|
|
1471
1769
|
}
|
|
1472
1770
|
|
|
1473
|
-
|
|
1771
|
+
if (fragment) {
|
|
1772
|
+
loadedFragments.set(path, fragment);
|
|
1773
|
+
}
|
|
1474
1774
|
return fragment;
|
|
1475
1775
|
}
|
|
1476
1776
|
|
|
@@ -1673,6 +1973,81 @@ async function loadFragmentsForRender(
|
|
|
1673
1973
|
});
|
|
1674
1974
|
}
|
|
1675
1975
|
|
|
1976
|
+
/**
|
|
1977
|
+
* Load full fragment data from fragments.json (includes variants).
|
|
1978
|
+
* Used by the a11y endpoint to enumerate all variants.
|
|
1979
|
+
*/
|
|
1980
|
+
async function loadFullFragmentData(
|
|
1981
|
+
configDir: string
|
|
1982
|
+
): Promise<{
|
|
1983
|
+
fragments: Record<
|
|
1984
|
+
string,
|
|
1985
|
+
{
|
|
1986
|
+
filePath: string;
|
|
1987
|
+
meta: { name: string };
|
|
1988
|
+
variants: Array<{ name: string; description?: string; code?: string }>;
|
|
1989
|
+
}
|
|
1990
|
+
>;
|
|
1991
|
+
} | null> {
|
|
1992
|
+
const { join } = await import("node:path");
|
|
1993
|
+
const fragmentsJsonPath = join(configDir, BRAND.outFile);
|
|
1994
|
+
|
|
1995
|
+
try {
|
|
1996
|
+
const content = await readFile(fragmentsJsonPath, "utf-8");
|
|
1997
|
+
return JSON.parse(content);
|
|
1998
|
+
} catch {
|
|
1999
|
+
return null;
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
/**
|
|
2004
|
+
* Capture an accessibility audit using the shared browser pool.
|
|
2005
|
+
* Navigates to the render page (which imports axe-core and runs the audit),
|
|
2006
|
+
* then extracts the axe results from the page context.
|
|
2007
|
+
*/
|
|
2008
|
+
async function captureA11yAudit(
|
|
2009
|
+
url: string,
|
|
2010
|
+
viewport: { width: number; height: number }
|
|
2011
|
+
): Promise<{
|
|
2012
|
+
violations: Array<{ impact?: string; id: string; description: string; helpUrl: string; nodes: Array<unknown> }>;
|
|
2013
|
+
passes: Array<{ id: string }>;
|
|
2014
|
+
incomplete: Array<{ id: string }>;
|
|
2015
|
+
}> {
|
|
2016
|
+
const { pool } = await getSharedRenderPool();
|
|
2017
|
+
|
|
2018
|
+
const ctx = await pool.acquire();
|
|
2019
|
+
const page = await ctx.newPage();
|
|
2020
|
+
|
|
2021
|
+
try {
|
|
2022
|
+
await page.setViewportSize(viewport);
|
|
2023
|
+
await page.goto(url, { waitUntil: "networkidle" });
|
|
2024
|
+
|
|
2025
|
+
// Wait for the render + axe audit to complete
|
|
2026
|
+
await page.waitForFunction(
|
|
2027
|
+
() => (window as any).__RENDER_READY__ === true,
|
|
2028
|
+
{ timeout: 15000 }
|
|
2029
|
+
);
|
|
2030
|
+
|
|
2031
|
+
// Check for error
|
|
2032
|
+
const error = await page.evaluate(() => (window as any).__AXE_ERROR__);
|
|
2033
|
+
if (error) {
|
|
2034
|
+
throw new Error(`A11y audit error: ${error}`);
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
// Extract axe results
|
|
2038
|
+
const results = await page.evaluate(() => (window as any).__AXE_RESULTS__);
|
|
2039
|
+
|
|
2040
|
+
if (!results) {
|
|
2041
|
+
throw new Error("Axe results not available — axe-core may not be installed");
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
return results;
|
|
2045
|
+
} finally {
|
|
2046
|
+
await page.close();
|
|
2047
|
+
pool.release(ctx);
|
|
2048
|
+
}
|
|
2049
|
+
}
|
|
2050
|
+
|
|
1676
2051
|
/**
|
|
1677
2052
|
* Serve the render HTML page for AI preview.
|
|
1678
2053
|
*/
|