@jxsuite/import 0.34.0
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 +58 -0
- package/package.json +47 -0
- package/src/ai-componentize.ts +307 -0
- package/src/apply-styles.ts +103 -0
- package/src/asset-collect.ts +254 -0
- package/src/asset-download.ts +201 -0
- package/src/asset-rewrite.ts +143 -0
- package/src/capture.ts +159 -0
- package/src/cli.ts +153 -0
- package/src/componentize.ts +348 -0
- package/src/crawl.ts +391 -0
- package/src/css-tokens.ts +156 -0
- package/src/emit.ts +231 -0
- package/src/index.ts +53 -0
- package/src/layout-detect.ts +151 -0
- package/src/media-extract.ts +121 -0
- package/src/run.ts +528 -0
- package/src/screenshot-diff.ts +92 -0
- package/src/style-capture.ts +386 -0
- package/src/style-diff.ts +183 -0
- package/src/to-jx.ts +89 -0
- package/src/verify.ts +311 -0
package/src/verify.ts
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verify — render each emitted page in the Jx runtime, screenshot-diff vs the captured original,
|
|
3
|
+
* and report a per-page fidelity score.
|
|
4
|
+
*
|
|
5
|
+
* Uses the compiler's `buildSite` to produce static HTML, serves it locally via Bun, then
|
|
6
|
+
* screenshots the rendered pages with puppeteer and diffs against reference screenshots captured
|
|
7
|
+
* during import.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
12
|
+
import type { Browser, Page } from "puppeteer-core";
|
|
13
|
+
import { launchBrowser, closeBrowser } from "./capture.ts";
|
|
14
|
+
import { diffScreenshots } from "./screenshot-diff.ts";
|
|
15
|
+
|
|
16
|
+
export interface PageRef {
|
|
17
|
+
sourceUrl: string;
|
|
18
|
+
/** Pre-captured reference screenshot (PNG buffer or path to a .png file on disk). */
|
|
19
|
+
screenshot: Buffer | string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface VerifyOptions {
|
|
23
|
+
/** The emitted Jx project directory (contains project.json). */
|
|
24
|
+
projectDir: string;
|
|
25
|
+
/** Map of route path (e.g. "pages/index.json") → page reference data. */
|
|
26
|
+
pages: Map<string, PageRef>;
|
|
27
|
+
/** Viewport width (default: 1440). */
|
|
28
|
+
viewportWidth?: number;
|
|
29
|
+
/** Viewport height (default: 900). */
|
|
30
|
+
viewportHeight?: number;
|
|
31
|
+
/** Where to write diff images and the report (default: <projectDir>/verify/). */
|
|
32
|
+
reportDir?: string;
|
|
33
|
+
/** Pixelmatch threshold (default: 0.15). */
|
|
34
|
+
threshold?: number;
|
|
35
|
+
/** Progress callback. */
|
|
36
|
+
onProgress?: (msg: string) => void;
|
|
37
|
+
/** Existing browser instance to reuse. */
|
|
38
|
+
browser?: Browser;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PageVerifyResult {
|
|
42
|
+
route: string;
|
|
43
|
+
sourceUrl: string;
|
|
44
|
+
fidelity: number;
|
|
45
|
+
mismatchedPixels: number;
|
|
46
|
+
totalPixels: number;
|
|
47
|
+
diffImagePath: string;
|
|
48
|
+
originalScreenshotPath: string;
|
|
49
|
+
renderedScreenshotPath: string;
|
|
50
|
+
error?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface VerifyResult {
|
|
54
|
+
pages: PageVerifyResult[];
|
|
55
|
+
/** Average fidelity across all pages. */
|
|
56
|
+
averageFidelity: number;
|
|
57
|
+
reportDir: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Map a source page route (e.g. "pages/about.json") to the URL path the built site serves.
|
|
62
|
+
* "pages/index.json" → "/", "pages/about.json" → "/about", "pages/blog/post-1.json" →
|
|
63
|
+
* "/blog/post-1"
|
|
64
|
+
*/
|
|
65
|
+
export function routeToUrlPath(route: string): string {
|
|
66
|
+
let path = route
|
|
67
|
+
.replace(/^pages\//, "/")
|
|
68
|
+
.replace(/\.json$/, "")
|
|
69
|
+
.replace(/\/index$/, "/");
|
|
70
|
+
if (path !== "/" && path.endsWith("/")) {
|
|
71
|
+
path = path.slice(0, -1);
|
|
72
|
+
}
|
|
73
|
+
if (!path.startsWith("/")) {
|
|
74
|
+
path = `/${path}`;
|
|
75
|
+
}
|
|
76
|
+
return path;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function screenshotPage(
|
|
80
|
+
page: Page,
|
|
81
|
+
url: string,
|
|
82
|
+
width: number,
|
|
83
|
+
height: number,
|
|
84
|
+
): Promise<Buffer> {
|
|
85
|
+
await page.setViewport({ width, height });
|
|
86
|
+
await page.goto(url, { waitUntil: "networkidle0", timeout: 30_000 });
|
|
87
|
+
await new Promise<void>((r) => {
|
|
88
|
+
setTimeout(r, 500);
|
|
89
|
+
});
|
|
90
|
+
const screenshot = await page.screenshot({ type: "png", fullPage: false });
|
|
91
|
+
return Buffer.from(screenshot);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Take a reference screenshot of a live page via puppeteer. Called during import so the screenshot
|
|
96
|
+
* is captured at the same time as the DOM.
|
|
97
|
+
*/
|
|
98
|
+
export async function captureReferenceScreenshot(
|
|
99
|
+
page: Page,
|
|
100
|
+
width = 1440,
|
|
101
|
+
height = 900,
|
|
102
|
+
): Promise<Buffer> {
|
|
103
|
+
await page.setViewport({ width, height });
|
|
104
|
+
const screenshot = await page.screenshot({ type: "png", fullPage: false });
|
|
105
|
+
return Buffer.from(screenshot);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Build the Jx project to static HTML using the compiler. */
|
|
109
|
+
async function buildProject(projectDir: string): Promise<{ distDir: string; errors: string[] }> {
|
|
110
|
+
const { buildSite } = await import("@jxsuite/compiler/site");
|
|
111
|
+
const distDir = join(projectDir, "dist");
|
|
112
|
+
const result = await buildSite(projectDir, { clean: true, verbose: false });
|
|
113
|
+
return { distDir, errors: result.errors };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Serve a directory over HTTP using Bun's built-in server. Returns the server instance and the base
|
|
118
|
+
* URL.
|
|
119
|
+
*/
|
|
120
|
+
export function serveDirectory(dir: string): {
|
|
121
|
+
server: ReturnType<typeof Bun.serve>;
|
|
122
|
+
baseUrl: string;
|
|
123
|
+
} {
|
|
124
|
+
const server = Bun.serve({
|
|
125
|
+
port: 0,
|
|
126
|
+
async fetch(req) {
|
|
127
|
+
let { pathname } = new URL(req.url);
|
|
128
|
+
if (pathname === "/") {
|
|
129
|
+
pathname = "/index.html";
|
|
130
|
+
} else if (!pathname.includes(".")) {
|
|
131
|
+
pathname = `${pathname}/index.html`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const filePath = join(dir, pathname);
|
|
135
|
+
const file = Bun.file(filePath);
|
|
136
|
+
if (await file.exists()) {
|
|
137
|
+
const contentType = filePath.endsWith(".html")
|
|
138
|
+
? "text/html"
|
|
139
|
+
: filePath.endsWith(".css")
|
|
140
|
+
? "text/css"
|
|
141
|
+
: filePath.endsWith(".js")
|
|
142
|
+
? "application/javascript"
|
|
143
|
+
: "application/octet-stream";
|
|
144
|
+
return new Response(file, { headers: { "Content-Type": contentType } });
|
|
145
|
+
}
|
|
146
|
+
return new Response("Not Found", { status: 404 });
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
return { server, baseUrl: `http://localhost:${server.port}` };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function resolveScreenshot(ref: Buffer | string): Buffer {
|
|
154
|
+
if (Buffer.isBuffer(ref)) {
|
|
155
|
+
return ref;
|
|
156
|
+
}
|
|
157
|
+
return readFileSync(ref);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Verify a cloned Jx project against reference screenshots captured during import.
|
|
162
|
+
*
|
|
163
|
+
* 1. Build the project to static HTML via the Jx compiler
|
|
164
|
+
* 2. Serve the built output locally
|
|
165
|
+
* 3. For each page: screenshot the rendered page, diff against the reference screenshot
|
|
166
|
+
* 4. Report per-page fidelity
|
|
167
|
+
*/
|
|
168
|
+
export async function verifyProject(opts: VerifyOptions): Promise<VerifyResult> {
|
|
169
|
+
const {
|
|
170
|
+
projectDir,
|
|
171
|
+
pages,
|
|
172
|
+
viewportWidth = 1440,
|
|
173
|
+
viewportHeight = 900,
|
|
174
|
+
threshold = 0.15,
|
|
175
|
+
onProgress = () => {},
|
|
176
|
+
} = opts;
|
|
177
|
+
|
|
178
|
+
const reportDir = opts.reportDir ?? join(projectDir, "verify");
|
|
179
|
+
mkdirSync(reportDir, { recursive: true });
|
|
180
|
+
|
|
181
|
+
// Step 1: Build the project
|
|
182
|
+
onProgress("Building project to static HTML...");
|
|
183
|
+
let distDir: string;
|
|
184
|
+
let buildErrors: string[];
|
|
185
|
+
try {
|
|
186
|
+
const buildResult = await buildProject(projectDir);
|
|
187
|
+
({ distDir } = buildResult);
|
|
188
|
+
buildErrors = buildResult.errors;
|
|
189
|
+
if (buildErrors.length > 0) {
|
|
190
|
+
onProgress(` Build completed with ${buildErrors.length} error(s)`);
|
|
191
|
+
}
|
|
192
|
+
} catch (error) {
|
|
193
|
+
const err = error as Error;
|
|
194
|
+
return {
|
|
195
|
+
pages: [...pages.entries()].map(([route, ref]) => ({
|
|
196
|
+
route,
|
|
197
|
+
sourceUrl: ref.sourceUrl,
|
|
198
|
+
fidelity: 0,
|
|
199
|
+
mismatchedPixels: 0,
|
|
200
|
+
totalPixels: 0,
|
|
201
|
+
diffImagePath: "",
|
|
202
|
+
originalScreenshotPath: "",
|
|
203
|
+
renderedScreenshotPath: "",
|
|
204
|
+
error: `Build failed: ${err.message}`,
|
|
205
|
+
})),
|
|
206
|
+
averageFidelity: 0,
|
|
207
|
+
reportDir,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Step 2: Serve the built output
|
|
212
|
+
onProgress("Starting local server for rendered pages...");
|
|
213
|
+
const { server, baseUrl } = serveDirectory(distDir);
|
|
214
|
+
|
|
215
|
+
const browser = opts.browser ?? (await launchBrowser());
|
|
216
|
+
const results: PageVerifyResult[] = [];
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
// Step 3: Screenshot rendered pages and diff against references
|
|
220
|
+
for (const [route, ref] of pages) {
|
|
221
|
+
const urlPath = routeToUrlPath(route);
|
|
222
|
+
const safeName = urlPath === "/" ? "index" : urlPath.slice(1).replaceAll("/", "-");
|
|
223
|
+
|
|
224
|
+
onProgress(` Verifying ${route}...`);
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const originalPng = resolveScreenshot(ref.screenshot);
|
|
228
|
+
|
|
229
|
+
// Screenshot rendered
|
|
230
|
+
const renderedUrl = `${baseUrl}${urlPath}`;
|
|
231
|
+
onProgress(` Screenshotting rendered: ${renderedUrl}`);
|
|
232
|
+
const renderedPage = await browser.newPage();
|
|
233
|
+
const renderedPng = await screenshotPage(
|
|
234
|
+
renderedPage,
|
|
235
|
+
renderedUrl,
|
|
236
|
+
viewportWidth,
|
|
237
|
+
viewportHeight,
|
|
238
|
+
);
|
|
239
|
+
await renderedPage.close();
|
|
240
|
+
|
|
241
|
+
// Diff
|
|
242
|
+
onProgress(" Computing pixel diff...");
|
|
243
|
+
const diff = diffScreenshots(originalPng, renderedPng, { threshold });
|
|
244
|
+
|
|
245
|
+
// Write artifacts
|
|
246
|
+
const origPath = join(reportDir, `${safeName}-original.png`);
|
|
247
|
+
const renderedPath = join(reportDir, `${safeName}-rendered.png`);
|
|
248
|
+
const diffPath = join(reportDir, `${safeName}-diff.png`);
|
|
249
|
+
|
|
250
|
+
writeFileSync(origPath, originalPng);
|
|
251
|
+
writeFileSync(renderedPath, renderedPng);
|
|
252
|
+
writeFileSync(diffPath, diff.diffPng);
|
|
253
|
+
|
|
254
|
+
onProgress(` Fidelity: ${diff.fidelity}% (${diff.mismatchedPixels} mismatched pixels)`);
|
|
255
|
+
|
|
256
|
+
results.push({
|
|
257
|
+
route,
|
|
258
|
+
sourceUrl: ref.sourceUrl,
|
|
259
|
+
fidelity: diff.fidelity,
|
|
260
|
+
mismatchedPixels: diff.mismatchedPixels,
|
|
261
|
+
totalPixels: diff.totalPixels,
|
|
262
|
+
diffImagePath: diffPath,
|
|
263
|
+
originalScreenshotPath: origPath,
|
|
264
|
+
renderedScreenshotPath: renderedPath,
|
|
265
|
+
});
|
|
266
|
+
} catch (error) {
|
|
267
|
+
const err = error as Error;
|
|
268
|
+
onProgress(` Error: ${err.message}`);
|
|
269
|
+
results.push({
|
|
270
|
+
route,
|
|
271
|
+
sourceUrl: ref.sourceUrl,
|
|
272
|
+
fidelity: 0,
|
|
273
|
+
mismatchedPixels: 0,
|
|
274
|
+
totalPixels: 0,
|
|
275
|
+
diffImagePath: "",
|
|
276
|
+
originalScreenshotPath: "",
|
|
277
|
+
renderedScreenshotPath: "",
|
|
278
|
+
error: err.message,
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Step 4: Write summary report
|
|
284
|
+
const successfulPages = results.filter((r) => !r.error);
|
|
285
|
+
const averageFidelity =
|
|
286
|
+
successfulPages.length > 0
|
|
287
|
+
? Math.round(
|
|
288
|
+
(successfulPages.reduce((sum, r) => sum + r.fidelity, 0) / successfulPages.length) *
|
|
289
|
+
100,
|
|
290
|
+
) / 100
|
|
291
|
+
: 0;
|
|
292
|
+
|
|
293
|
+
const report = {
|
|
294
|
+
timestamp: new Date().toISOString(),
|
|
295
|
+
projectDir,
|
|
296
|
+
viewport: { width: viewportWidth, height: viewportHeight },
|
|
297
|
+
threshold,
|
|
298
|
+
averageFidelity,
|
|
299
|
+
pages: results,
|
|
300
|
+
buildErrors: buildErrors ?? [],
|
|
301
|
+
};
|
|
302
|
+
writeFileSync(join(reportDir, "report.json"), JSON.stringify(report, null, 2));
|
|
303
|
+
|
|
304
|
+
return { pages: results, averageFidelity, reportDir };
|
|
305
|
+
} finally {
|
|
306
|
+
void server.stop();
|
|
307
|
+
if (!opts.browser) {
|
|
308
|
+
await closeBrowser();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|