@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.
Files changed (73) hide show
  1. package/README.md +2 -0
  2. package/dist/bin.js +25 -17
  3. package/dist/bin.js.map +1 -1
  4. package/dist/chunk-AWYCDRPG.js +272 -0
  5. package/dist/chunk-AWYCDRPG.js.map +1 -0
  6. package/dist/chunk-EKLMXTWU.js +80 -0
  7. package/dist/chunk-EKLMXTWU.js.map +1 -0
  8. package/dist/{chunk-DH4ETVSM.js → chunk-NEJ2FBTN.js} +9 -7
  9. package/dist/{chunk-DH4ETVSM.js.map → chunk-NEJ2FBTN.js.map} +1 -1
  10. package/dist/{chunk-GHYYFAQN.js → chunk-P33AKQJW.js} +1 -76
  11. package/dist/chunk-P33AKQJW.js.map +1 -0
  12. package/dist/{chunk-3T6QL7IY.js → chunk-R6IZZSE7.js} +23 -275
  13. package/dist/chunk-R6IZZSE7.js.map +1 -0
  14. package/dist/{chunk-7KUSBMI4.js → chunk-S56I5FST.js} +174 -45
  15. package/dist/chunk-S56I5FST.js.map +1 -0
  16. package/dist/{chunk-DQHWLAUV.js → chunk-TOIE7VXF.js} +2 -2
  17. package/dist/{chunk-OOGTG5FM.js → chunk-UXLGIGSX.js} +56 -2
  18. package/dist/chunk-UXLGIGSX.js.map +1 -0
  19. package/dist/{chunk-GKX2HPZ6.js → chunk-YMPGYEWK.js} +9 -3
  20. package/dist/chunk-YMPGYEWK.js.map +1 -0
  21. package/dist/chunk-Z7EY4VHE.js +50 -0
  22. package/dist/{core-UQXZTBFZ.js → core-3NMNCLFW.js} +8 -5
  23. package/dist/discovery-Z4RDDFVR.js +28 -0
  24. package/dist/{generate-GP6ZLAQB.js → generate-23VLX7QN.js} +7 -4
  25. package/dist/{generate-GP6ZLAQB.js.map → generate-23VLX7QN.js.map} +1 -1
  26. package/dist/index.js +15 -11
  27. package/dist/index.js.map +1 -1
  28. package/dist/{init-W72WBSU2.js → init-VYVYMVHH.js} +10 -6
  29. package/dist/{init-W72WBSU2.js.map → init-VYVYMVHH.js.map} +1 -1
  30. package/dist/mcp-bin.js +5 -3
  31. package/dist/mcp-bin.js.map +1 -1
  32. package/dist/sass.node-4XJK6YBF.js +130708 -0
  33. package/dist/sass.node-4XJK6YBF.js.map +1 -0
  34. package/dist/scan-FZR6YVI5.js +15 -0
  35. package/dist/{service-PVGTYUKX.js → service-CFFBHW4X.js} +6 -4
  36. package/dist/service-CFFBHW4X.js.map +1 -0
  37. package/dist/{static-viewer-KILKIVN7.js → static-viewer-VA2JXSCX.js} +6 -4
  38. package/dist/static-viewer-VA2JXSCX.js.map +1 -0
  39. package/dist/{test-3YRYQRGV.js → test-VTD7R6G2.js} +8 -4
  40. package/dist/{test-3YRYQRGV.js.map → test-VTD7R6G2.js.map} +1 -1
  41. package/dist/{tokens-IXSQHPQK.js → tokens-7JA5CPDL.js} +10 -7
  42. package/dist/{tokens-IXSQHPQK.js.map → tokens-7JA5CPDL.js.map} +1 -1
  43. package/dist/{viewer-K42REJU2.js → viewer-WXTDDQGK.js} +403 -26
  44. package/dist/viewer-WXTDDQGK.js.map +1 -0
  45. package/package.json +5 -1
  46. package/src/build.ts +57 -5
  47. package/src/commands/init.ts +6 -2
  48. package/src/core/__tests__/token-resolver.test.ts +82 -0
  49. package/src/core/discovery.ts +7 -1
  50. package/src/core/token-parser.ts +102 -0
  51. package/src/core/token-resolver.ts +155 -0
  52. package/src/migrate/detect.ts +4 -0
  53. package/src/service/__tests__/patch-generator.test.ts +2 -2
  54. package/src/service/patch-generator.ts +8 -1
  55. package/src/viewer/components/App.tsx +63 -2
  56. package/src/viewer/components/Layout.tsx +1 -1
  57. package/src/viewer/components/LeftSidebar.tsx +35 -77
  58. package/src/viewer/preview-frame.html +1 -1
  59. package/src/viewer/render-utils.ts +141 -0
  60. package/src/viewer/styles/globals.css +2 -1
  61. package/src/viewer/vite-plugin.ts +399 -24
  62. package/dist/chunk-3T6QL7IY.js.map +0 -1
  63. package/dist/chunk-7KUSBMI4.js.map +0 -1
  64. package/dist/chunk-GHYYFAQN.js.map +0 -1
  65. package/dist/chunk-GKX2HPZ6.js.map +0 -1
  66. package/dist/chunk-OOGTG5FM.js.map +0 -1
  67. package/dist/scan-V54HWRDY.js +0 -12
  68. package/dist/viewer-K42REJU2.js.map +0 -1
  69. /package/dist/{chunk-DQHWLAUV.js.map → chunk-TOIE7VXF.js.map} +0 -0
  70. /package/dist/{core-UQXZTBFZ.js.map → chunk-Z7EY4VHE.js.map} +0 -0
  71. /package/dist/{scan-V54HWRDY.js.map → core-3NMNCLFW.js.map} +0 -0
  72. /package/dist/{service-PVGTYUKX.js.map → discovery-Z4RDDFVR.js.map} +0 -0
  73. /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
- font-family: var(--fui-font-sans, 'Geist Sans', Inter, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif);
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 = generateRenderScript(
263
- fragmentFile.absolutePath,
264
- fragmentInfo.name,
265
- props
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
- // Check if tokens are configured
1048
+ // Auto-discover tokens when config.tokens is missing
1026
1049
  if (!config.tokens || !config.tokens.include || config.tokens.include.length === 0) {
1027
- res.writeHead(400, { "Content-Type": "application/json" });
1028
- res.end(JSON.stringify({
1029
- error: "No token configuration found",
1030
- suggestion: "Add 'tokens' config to fragments.config.ts to enable fix generation",
1031
- }));
1032
- return;
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
- // For demonstration, we'll create a placeholder response
1073
- // In production, this would use style comparison + AST patching
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
- [], // Would be populated by actual style diffs
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
- loadedFragments.set(path, fragment);
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
  */