Setting the file. One moment.
Post Build Gap · Wix Headless Replatform · wix/skills · Skills Docs
ContentsBack to the top of the page 28.10
Workflow
This file
Number 28.57
Position 57 of 89
Type JavaScript
Size 16 KB
Lines 358 scripts/ post-build-gap.mjs
JavaScript · 358 lines · 16 KB
14 writeText,
15 } from "./lib/common.mjs" ;
16 import { ensureBrowserExtractionReady } from "./lib/browser-tooling.mjs" ;
17 import { extractPage } from "./extract-page.mjs" ;
18 import { extractAssets } from "./extract-assets.mjs" ;
19 import { extractInteractions } from "./extract-interactions.mjs" ;
20 import { extractSeoForUrl } from "./extract-seo.mjs" ;
21 import { extractDesignSystem } from "./extract-design-system.mjs" ;
22 import { generateSceneContract } from "./generate-scene-contract.mjs" ;
23 import { generateControlStateContract } from "./generate-control-state-contract.mjs" ;
24 import { generateVisualAssets } from "./generate-visual-assets.mjs" ;
25 import { generateLayoutBlueprint } from "./generate-layout-blueprint.mjs" ;
26 import { generateUiNormalization } from "./generate-ui-normalization.mjs" ;
27 import { analyzeGapEvidence, renderGapAnalysisMarkdown, renderGapFixPlan } from "./lib/gap-analysis.mjs" ;
28
29 export const MAX_GAP_CYCLES = 5 ;
30
31 async function main () {
32 const args = parseArgs ();
33 const sourceUrl = normalizeUrl (args._[ 0 ] || args.url). toString ();
34 const resultUrlValue = args[ "result-url" ] || args[ "clone-url" ];
35 if ( ! resultUrlValue) throw new Error ( "Missing --result-url for the running clone, for example http://localhost:4321/" );
36 const paths = String (args.paths || "" ). split ( "," ). map (( value ) => value. trim ()). filter (Boolean);
37 const outputDir = resolveOutputDir (sourceUrl, args.out);
38 const report = await runPostBuildGap ({
39 sourceUrl,
40 resultUrl: normalizeUrl (resultUrlValue). toString (),
41 outputDir,
42 screenshots: args.screenshots !== "false" ,
43 designExtractor: args[ "design-extractor" ],
44 paths,
45 });
46 if (args.json) process.stdout. write ( `${ JSON . stringify ( report , null , 2 ) } \n ` );
47 else console. log ( `[gap-analysis] iteration ${ String ( report . iteration ). padStart ( 3 , "0" ) }: score ${ report . summary . score }/100, ${ report . summary . blockingCount } blocking gap(s), visual review ${ report . visualReview . status }` );
48 }
49
50 export async function runPostBuildGap ({ sourceUrl , resultUrl , outputDir , screenshots = true , designExtractor , browserTooling , paths = [] } = {}) {
51 if ( ! sourceUrl || ! resultUrl || ! outputDir) throw new Error ( "sourceUrl, resultUrl, and outputDir are required" );
52 const canonicalDocs = docsDir (outputDir);
53 const frozenSource = await loadFrozenSourceEvidence (canonicalDocs);
54 const canonicalSourceMap = sanitizeSourceMap (frozenSource.evidence.sourceMap);
55 const sourceMap = structuredClone (canonicalSourceMap);
56 const selectedPaths = [ ...new Set (paths. map (( value ) => new URL (value, sourceUrl).pathname. replace ( / \/ $ / , "" ) || "/" ))];
57 if (selectedPaths. length ) {
58 sourceMap.pages = (sourceMap.pages || []). filter (( page ) => selectedPaths. includes ( new URL (page.url).pathname. replace ( / \/ $ / , "" ) || "/" ));
59 if ( ! sourceMap.pages. length ) throw new Error ( `No source-map pages matched --paths ${ selectedPaths . join ( ", " ) }` );
60 }
61 const gapRoot = path. join (canonicalDocs, "gap-analysis" );
62 const previousReport = await readPreviousReport (gapRoot);
63 const iteration = await nextIteration (gapRoot);
64 const iterationDir = path. join (gapRoot, "iterations" , String (iteration). padStart ( 3 , "0" ));
65 const resultOutputDir = path. join (iterationDir, "result-extraction" );
66 const sourceEvidence = frozenSource.evidence;
67 sourceEvidence.sourceMap = sourceMap;
68 const sourceEvidenceReference = {
69 root: frozenSource.extractionDir,
70 fingerprint: frozenSource.manifest.manifestHash,
71 mode: "frozen-manifest-reference" ,
72 };
73 await ensureDir (iterationDir);
74 await writeJson (path. join (iterationDir, "iteration-manifest.json" ), {
75 iteration,
76 sourceUrl,
77 resultUrl,
78 paths: selectedPaths,
79 sourceEvidence: sourceEvidenceReference,
80 status: "extracting" ,
81 startedAt: new Date (). toISOString (),
82 });
83
84 const tooling = browserTooling || await ensureBrowserExtractionReady ({ startDir: process. cwd () });
85 const resultEvidence = await extractResultEvidence ({
86 sourceMap,
87 sourceUrl,
88 resultUrl,
89 resultOutputDir,
90 screenshots,
91 designExtractor: designExtractor || sourceMap.designExtraction?.selectedExtractor || "auto" ,
92 browserTooling: tooling,
93 });
94 await attachScreenshotMetadata (sourceEvidence);
95 await attachScreenshotMetadata (resultEvidence);
96 const report = analyzeGapEvidence ({ source: sourceEvidence, result: resultEvidence, iteration });
97 report.sourceEvidence = sourceEvidenceReference;
98 report.visualProgress = buildVisualProgressRequirement ({ previousReport, report });
99 report.paths = {
100 iterationDir,
101 sourceEvidence: canonicalDocs,
102 resultExtraction: docsDir (resultOutputDir),
103 reportJson: path. join (iterationDir, "gap-analysis.json" ),
104 reportMarkdown: path. join (iterationDir, "gap-analysis.md" ),
105 fixPlan: path. join (iterationDir, "gap-fix-plan.md" ),
106 visualProgress: path. join (iterationDir, "visual-progress.json" ),
107 };
108 if (report.visualProgress.required) await writeJson (report.paths.visualProgress, visualProgressTemplate ({ report, previousReport }));
109 await writeJson (report.paths.reportJson, report);
110 await writeText (report.paths.reportMarkdown, renderGapAnalysisMarkdown (report));
111 await writeText (report.paths.fixPlan, renderGapFixPlan (report));
112 await writeJson (path. join (gapRoot, "latest.json" ), report);
113 await writeText (path. join (gapRoot, "latest.md" ), renderGapAnalysisMarkdown (report));
114 await writeJson (path. join (iterationDir, "iteration-manifest.json" ), {
115 iteration,
116 sourceUrl,
117 resultUrl,
118 status: "comparison-complete-visual-review-pending" ,
119 startedAt: report.generatedAt,
120 completedAt: new Date (). toISOString (),
121 score: report.summary.score,
122 blockingCount: report.summary.blockingCount,
123 sourceEvidence: sourceEvidenceReference,
124 visualProgress: report.visualProgress,
125 });
126 return report;
127 }
128
129 async function extractResultEvidence ({ sourceMap , sourceUrl , resultUrl , resultOutputDir , screenshots , designExtractor , browserTooling }) {
130 await rm (resultOutputDir, { recursive: true , force: true });
131 const resultDocs = docsDir (resultOutputDir);
132 const pagesDir = path. join (resultDocs, "pages" );
133 const screenshotsDir = path. join (resultDocs, "screenshots" );
134 await ensureDir (pagesDir);
135 const pages = [];
136 for ( const [ index , sourcePage ] of (sourceMap.pages || []). entries ()) {
137 const comparisonPath = pagePath (sourcePage.url, sourceUrl, index);
138 const targetUrl = resultPageUrl (resultUrl, comparisonPath);
139 console. log ( `[gap-analysis] extracting result page ${ targetUrl }` );
140 const page = await extractPage (targetUrl, {
141 outputDir: resultOutputDir,
142 screenshots,
143 browserTooling,
144 screenshotDir: screenshotsDir,
145 screenshotPrefix: "result" ,
146 });
147 page.comparisonPath = comparisonPath;
148 page.sourceComparisonUrl = sourcePage.url;
149 pages. push (page);
150 await writeJson (path. join (pagesDir, `${ String ( index + 1 ). padStart ( 3 , "0" ) }-${ slugForUrl ( targetUrl ) }.json` ), page);
151 }
152
153 const [ seo , assets ] = await Promise . all ([
154 extractSeoForUrl (resultUrl),
155 extractAssets (resultUrl, { outputDir: resultOutputDir, download: false }),
156 ]);
157 await writeJson (path. join (resultDocs, "seo.json" ), seo);
158 await writeJson (path. join (resultDocs, "assets.json" ), assets);
159 await writeJson (path. join (resultDocs, "fonts.json" ), assets.fonts);
160
161 const interactionMap = await extractInteractions (resultUrl, { outputDir: resultOutputDir, pages, browserTooling });
162 const enrichedPages = interactionMap.pages?. length ? interactionMap.pages : pages;
163 for ( const page of enrichedPages) page.comparisonPath ||= pagePath (page.sourceComparisonUrl || page.url, sourceUrl);
164 await writeJson (path. join (resultDocs, "interaction-map.json" ), interactionMap);
165 const resultMap = {
166 sourceUrl: resultUrl,
167 comparisonSourceUrl: sourceUrl,
168 normalizedAt: new Date (). toISOString (),
169 scope: sourceMap.scope,
170 pages: enrichedPages,
171 assets: assets.assets,
172 fonts: { families: assets.fonts?.families || [], faceCount: assets.fonts?.faces?. length || 0 },
173 interactions: interactionMap.summary || {},
174 seo,
175 tokens: {},
176 extractionRole: "post-build-result" ,
177 };
178 await writeJson (path. join (resultDocs, "source-map.json" ), resultMap);
179
180 console. log ( "[gap-analysis] extracting result design system and derived contracts" );
181 const designSystem = await extractDesignSystem ({ sourceUrl: resultUrl, outputDir: resultOutputDir, extractor: designExtractor, browserTooling });
182 resultMap.tokens = designSystem.tokens;
183 resultMap.designExtraction = {
184 selectedExtractor: designSystem.selectedExtractor,
185 actualExtractor: designSystem.actualExtractor || designSystem.extractor,
186 fallbackReason: designSystem.fallbackReason,
187 };
188 await writeJson (path. join (resultDocs, "tokens.json" ), designSystem.tokens);
189 await writeJson (path. join (resultDocs, "source-map.json" ), resultMap);
190 const sceneContract = await generateSceneContract ({ outputDir: resultOutputDir, pages: enrichedPages, interactionMap });
191 await generateControlStateContract ({ outputDir: resultOutputDir, interactionMap });
192 await generateVisualAssets ({ outputDir: resultOutputDir, pages: enrichedPages, assets });
193 const layoutBlueprint = await generateLayoutBlueprint ({ outputDir: resultOutputDir, pages: enrichedPages, interactionMap, sceneContract });
194 await generateUiNormalization ({ outputDir: resultOutputDir, pages: enrichedPages, interactionMap, sceneContract, layoutBlueprint });
195 return loadEvidence (resultDocs);
196 }
197
198 async function readPreviousReport ( gapRoot ) {
199 try { return await readJson (path. join (gapRoot, "latest.json" )); } catch { return null ; }
200 }
201
202 function buildVisualProgressRequirement ({ previousReport , report }) {
203 const previousBlocking = (previousReport?.findings || []). filter (( finding ) => [ "critical" , "high" ]. includes (finding.severity) && finding.status !== "resolved" && finding.status !== "accepted" );
204 return {
205 required: previousBlocking. length > 0 ,
206 status: previousBlocking. length ? "pending" : "not-applicable" ,
207 previousIteration: previousBlocking. length ? previousReport.iteration : null ,
208 previousReportPath: previousBlocking. length ? previousReport.paths?.reportJson || null : null ,
209 previousBlockingFindingIds: previousBlocking. map (( finding ) => finding.id),
210 instructions: "For every prior blocking finding, record a named target section and exact source, before-result, and after-result screenshots. A claimed resolution requires verdict improved." ,
211 };
212 }
213
214 function visualProgressTemplate ({ report , previousReport }) {
215 const prior = report.visualProgress.previousBlockingFindingIds;
216 const pairs = new Map ((report.screenshotPairs || []). map (( pair ) => [pair.id, pair]));
217 const priorPairs = new Map ((previousReport?.screenshotPairs || []). map (( pair ) => [pair.id, pair]));
218 return {
219 entries: prior. map (( findingId ) => ({
220 priorFindingId: findingId,
221 target: "Name the affected section or interaction state." ,
222 pairId: "Choose the matching screenshot pair ID." ,
223 sourceScreenshotPath: "Copy the source path for that pair." ,
224 beforeResultScreenshotPath: "Copy the result path from the prior iteration for that pair." ,
225 afterResultScreenshotPath: "Copy the result path from this iteration for that pair." ,
226 beforeObservation: "Describe the visible pre-fix mismatch." ,
227 afterObservation: "Describe what is visibly closer, unchanged, or worse." ,
228 verdict: "improved" ,
229 evidence: "State the visible relationship being judged; do not cite only a score or code change." ,
230 })),
231 _templateNote: `Use the source and before/after paths from the matching pair. Current pair IDs: ${ [ ... pairs . keys ()]. join ( ", " ) || "none"}. Prior pair IDs: ${ [ ... priorPairs . keys ()]. join ( ", " ) || "available in the prior report"}.` ,
232 };
233 }
234
235 function sanitizeSourceMap ( sourceMap ) {
236 const sanitized = structuredClone (sourceMap || {});
237 const pages = Array. isArray (sanitized.pages) ? sanitized.pages : [];
238 sanitized.pages = pages. filter (( page ) => page?.url && ! isAssetLikeUrl (page.url));
239 return sanitized;
240 }
241
242 async function loadEvidence ( dir ) {
243 const read = async ( name , fallback ) => {
244 try { return await readJson (path. join (dir, name)); } catch { return fallback; }
245 };
246 return {
247 root: dir,
248 sourceMap: await read ( "source-map.json" , { pages: [] }),
249 assets: await read ( "assets.json" , { assets: [] }),
250 seo: await read ( "seo.json" , {}),
251 interactionMap: await read ( "interaction-map.json" , { interactions: [] }),
252 sceneContract: await read ( "scene-contract.json" , { scenes: [] }),
253 controlStateContract: await read ( "control-state-contract.json" , { controls: [] }),
254 visualAssets: await read ( "visual-assets.json" , { logos: [], icons: [] }),
255 layoutBlueprint: await read ( "layout-blueprint.json" , { pages: [] }),
256 uiNormalization: await read ( "ui-normalization.json" , { sections: [] }),
257 tokens: await read ( "tokens.json" , {}),
258 };
259 }
260
261 async function loadFrozenSourceEvidence ( canonicalDocs ) {
262 const latest = await readJson (path. join (canonicalDocs, "extraction" , "latest.json" ));
263 const extractionDir = path. join (canonicalDocs, "extraction" , latest.captureId);
264 const manifest = await readJson (path. join (extractionDir, "extraction-manifest.json" ));
265 if (manifest.manifestHash !== latest.manifestHash) throw new Error ( "Frozen source manifest does not match extraction/latest.json" );
266 const observations = path. join (extractionDir, "observations" );
267 const page = await readJson (path. join (observations, "page.json" ));
268 page.screenshots = Object. fromEntries (Object. entries (page.screenshots || {}). map (([ viewport , filePath ]) => [
269 viewport,
270 path. isAbsolute (filePath) ? filePath : path. join (observations, filePath),
271 ]));
272 const read = async ( name , fallback ) => {
273 try { return await readJson (path. join (observations, name)); } catch { return fallback; }
274 };
275 const seo = await read ( "seo.json" , {});
276 const tokens = await read ( "tokens.json" , {});
277 return {
278 extractionDir,
279 manifest,
280 evidence: {
281 root: extractionDir,
282 sourceMap: {
283 sourceUrl: page.url,
284 scope: "home" ,
285 pages: [page],
286 seo,
287 tokens,
288 designExtraction: tokens.extraction || {},
289 },
290 assets: await read ( "assets.json" , { assets: [] }),
291 seo,
292 interactionMap: await read ( "interaction-map.json" , { interactions: [] }),
293 sceneContract: await read ( "scene-contract.json" , { scenes: [] }),
294 controlStateContract: await read ( "control-state-contract.json" , { controls: [] }),
295 visualAssets: await read ( "visual-assets.json" , { logos: [], icons: [] }),
296 layoutBlueprint: await read ( "layout-blueprint.json" , { pages: [] }),
297 uiNormalization: await read ( "ui-normalization.json" , { sections: [] }),
298 tokens,
299 },
300 };
301 }
302
303 async function attachScreenshotMetadata ( evidence ) {
304 for ( const page of evidence.sourceMap.pages || []) {
305 page.screenshotMetadata = {};
306 for ( const [ viewport , filePath ] of Object. entries (page.screenshots || {})) {
307 const metadata = await pngMetadata (filePath);
308 if (metadata) page.screenshotMetadata[viewport] = metadata;
309 }
310 }
311 }
312
313 async function pngMetadata ( filePath ) {
314 try {
315 const buffer = await readFile (filePath);
316 if (buffer. length < 24 || buffer. toString ( "hex" , 0 , 8 ) !== "89504e470d0a1a0a" ) return null ;
317 return { width: buffer. readUInt32BE ( 16 ), height: buffer. readUInt32BE ( 20 ), bytes: buffer. length };
318 } catch {
319 return null ;
320 }
321 }
322
323 async function nextIteration ( gapRoot ) {
324 try {
325 const names = await readdir (path. join (gapRoot, "iterations" ));
326 const numbers = names. map (( name ) => Number (name)). filter (Number.isInteger);
327 const next = Math. max ( 0 , ... numbers) + 1 ;
328 if (next > MAX_GAP_CYCLES ) throw new Error ( `Maximum of ${ MAX_GAP_CYCLES } visual gap cycles reached; finalize the reviewed final iteration as done_with_gaps when blockers remain.` );
329 return next;
330 } catch (error) {
331 if (error?.code !== "ENOENT" ) throw error;
332 return 1 ;
333 }
334 }
335
336 function pagePath ( pageUrl , sourceUrl , fallbackIndex = 0 ) {
337 try {
338 const page = new URL (pageUrl, sourceUrl);
339 return page.pathname || "/" ;
340 } catch {
341 return fallbackIndex ? `/page-${ fallbackIndex + 1 }` : "/" ;
342 }
343 }
344
345 function resultPageUrl ( resultUrl , pathname ) {
346 const target = new URL (resultUrl);
347 target.pathname = pathname || "/" ;
348 target.search = "" ;
349 target.hash = "" ;
350 return target. toString ();
351 }
352
353 if ( import . meta .url === `file://${ process . argv [ 1 ] }` ) {
354 main (). catch (( error ) => {
355 console. error (error.stack || error.message);
356 process. exit ( 1 );
357 });
358 }