frameshot-mcp 0.3.0 → 0.7.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.
@@ -0,0 +1,984 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __export = (target, all) => {
3
+ for (var name in all)
4
+ __defProp(target, name, { get: all[name], enumerable: true });
5
+ };
6
+
7
+ // src/infrastructure/browser-pool.ts
8
+ import { chromium, firefox, webkit } from "playwright";
9
+ var BrowserPool = class {
10
+ pool = /* @__PURE__ */ new Map();
11
+ async warmup(engines) {
12
+ await Promise.all(engines.map((e) => this.getSlot(e)));
13
+ }
14
+ async getPage(engine) {
15
+ const slot = await this.getSlot(engine);
16
+ return slot.page;
17
+ }
18
+ async setViewport(engine, viewport) {
19
+ const slot = await this.getSlot(engine);
20
+ const current = slot.page.viewportSize();
21
+ if (current?.width !== viewport.width || current?.height !== viewport.height) {
22
+ await slot.page.setViewportSize(viewport);
23
+ }
24
+ }
25
+ async shutdown() {
26
+ for (const slot of this.pool.values()) {
27
+ await slot.browser.close().catch(() => {
28
+ });
29
+ }
30
+ this.pool.clear();
31
+ }
32
+ async getSlot(engine) {
33
+ const existing = this.pool.get(engine);
34
+ if (existing?.browser.isConnected() && existing.ready) {
35
+ return existing;
36
+ }
37
+ const launcher = { chromium, firefox, webkit }[engine];
38
+ let browser;
39
+ try {
40
+ browser = await launcher.launch({
41
+ headless: true,
42
+ ...engine === "chromium" ? { channel: "chrome" } : {}
43
+ });
44
+ } catch (_e) {
45
+ throw new Error(
46
+ `${engine} is not installed. Run: npx playwright install ${engine}`
47
+ );
48
+ }
49
+ const context = await browser.newContext({
50
+ viewport: { width: 1280, height: 800 },
51
+ deviceScaleFactor: 2
52
+ });
53
+ const page = await context.newPage();
54
+ await page.setContent(
55
+ '<html><head><script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio"></script></head><body></body></html>',
56
+ { waitUntil: "networkidle" }
57
+ );
58
+ const slot = { browser, page, ready: true };
59
+ this.pool.set(engine, slot);
60
+ return slot;
61
+ }
62
+ };
63
+
64
+ // src/infrastructure/html-builder.ts
65
+ var HtmlBuilder = class {
66
+ build(code, framework, options) {
67
+ const { darkMode, css, tailwindVersion } = options;
68
+ const tailwind = tailwindVersion === "4" ? '<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>' : '<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio"></script>';
69
+ const tailwindConfig = tailwindVersion === "4" ? "" : "<script>tailwind.config={darkMode:'class'}</script>";
70
+ const customCss = css ? `<style>${css}</style>` : "";
71
+ const baseStyle = `<style>*{margin:0;box-sizing:border-box}body{padding:16px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}</style>`;
72
+ const htmlClass = darkMode ? ' class="dark"' : "";
73
+ if (framework === "html") {
74
+ if (code.includes("<html")) return code;
75
+ return `<!DOCTYPE html><html${htmlClass}><head><meta charset="utf-8">${tailwind}${tailwindConfig}${baseStyle}${customCss}</head><body>${code}</body></html>`;
76
+ }
77
+ if (framework === "react") {
78
+ const cleanedCode = code.replace(/['"]use client['"];?\n?/g, "").replace(/['"]use server['"];?\n?/g, "").replace(/import\s+.*?\s+from\s+['"]next\/image['"];?\n?/g, "").replace(/import\s+.*?\s+from\s+['"]next\/link['"];?\n?/g, "");
79
+ return `<!DOCTYPE html><html${htmlClass}><head><meta charset="utf-8">${tailwind}${tailwindConfig}
80
+ <script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
81
+ <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
82
+ <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
83
+ ${baseStyle}${customCss}</head><body><div id="root"></div>
84
+ <script type="text/babel">
85
+ const Image = (props) => React.createElement('img', {...props, src: props.src?.src || props.src});
86
+ const Link = ({href, children, ...props}) => React.createElement('a', {href, ...props}, children);
87
+ ${cleanedCode}
88
+ const _C = typeof App!=='undefined'?App:typeof Component!=='undefined'?Component:typeof Default!=='undefined'?Default:null;
89
+ if(_C)ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(_C));
90
+ </script></body></html>`;
91
+ }
92
+ if (framework === "vue") {
93
+ return `<!DOCTYPE html><html${htmlClass}><head><meta charset="utf-8">${tailwind}${tailwindConfig}
94
+ <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
95
+ ${baseStyle}${customCss}</head><body><div id="app"></div>
96
+ <script>
97
+ const{createApp,ref,reactive,computed,onMounted,watch,watchEffect}=Vue;
98
+ ${code}
99
+ const _C=typeof App!=='undefined'?App:typeof Component!=='undefined'?Component:null;
100
+ if(_C)createApp(_C).mount('#app');
101
+ </script></body></html>`;
102
+ }
103
+ if (framework === "svelte") {
104
+ return `<!DOCTYPE html><html${htmlClass}><head><meta charset="utf-8">${tailwind}${tailwindConfig}
105
+ <script src="https://unpkg.com/svelte@4/compiler.cjs"></script>
106
+ ${baseStyle}${customCss}</head><body><div id="app"></div>
107
+ <script type="module">
108
+ import "https://unpkg.com/svelte@4/internal/index.mjs";
109
+ ${code}
110
+ const _C=typeof App!=='undefined'?App:typeof Component!=='undefined'?Component:null;
111
+ if(_C)new _C({target:document.getElementById('app')});
112
+ </script></body></html>`;
113
+ }
114
+ return code;
115
+ }
116
+ };
117
+
118
+ // src/infrastructure/image-comparator.ts
119
+ import pixelmatch from "pixelmatch";
120
+ import { PNG } from "pngjs";
121
+ var ImageComparator = class {
122
+ diff(imageA, imageB, threshold = 0.1) {
123
+ const bufA = Buffer.from(imageA, "base64");
124
+ const bufB = Buffer.from(imageB, "base64");
125
+ const pngA = PNG.sync.read(bufA);
126
+ const pngB = PNG.sync.read(bufB);
127
+ const width = Math.max(pngA.width, pngB.width);
128
+ const height = Math.max(pngA.height, pngB.height);
129
+ const normalizedA = new PNG({ width, height });
130
+ const normalizedB = new PNG({ width, height });
131
+ PNG.bitblt(pngA, normalizedA, 0, 0, pngA.width, pngA.height, 0, 0);
132
+ PNG.bitblt(pngB, normalizedB, 0, 0, pngB.width, pngB.height, 0, 0);
133
+ const diffPng = new PNG({ width, height });
134
+ const diffPixels = pixelmatch(
135
+ normalizedA.data,
136
+ normalizedB.data,
137
+ diffPng.data,
138
+ width,
139
+ height,
140
+ { threshold }
141
+ );
142
+ const totalPixels = width * height;
143
+ return {
144
+ diff: PNG.sync.write(diffPng).toString("base64"),
145
+ diffPixels,
146
+ totalPixels,
147
+ diffPercentage: Math.round(diffPixels / totalPixels * 1e4) / 100
148
+ };
149
+ }
150
+ composite(images, columns, labelHeight = 30) {
151
+ const pngs = images.map((buf) => PNG.sync.read(buf));
152
+ const cellWidth = Math.max(...pngs.map((p) => p.width));
153
+ const cellHeight = Math.max(...pngs.map((p) => p.height));
154
+ const rows = Math.ceil(pngs.length / columns);
155
+ const gridWidth = cellWidth * columns;
156
+ const gridHeight = (cellHeight + labelHeight) * rows;
157
+ const grid = new PNG({ width: gridWidth, height: gridHeight });
158
+ grid.data.fill(255);
159
+ for (let i = 0; i < pngs.length; i++) {
160
+ const col = i % columns;
161
+ const row = Math.floor(i / columns);
162
+ const x = col * cellWidth;
163
+ const y = row * (cellHeight + labelHeight) + labelHeight;
164
+ PNG.bitblt(pngs[i], grid, 0, 0, pngs[i].width, pngs[i].height, x, y);
165
+ }
166
+ return {
167
+ image: PNG.sync.write(grid).toString("base64"),
168
+ width: gridWidth,
169
+ height: gridHeight
170
+ };
171
+ }
172
+ };
173
+
174
+ // src/infrastructure/snapshot-store.ts
175
+ var SnapshotStore = class {
176
+ store = /* @__PURE__ */ new Map();
177
+ save(key, image) {
178
+ this.store.set(key, { image, timestamp: Date.now() });
179
+ }
180
+ get(key) {
181
+ return this.store.get(key);
182
+ }
183
+ list() {
184
+ return Array.from(this.store.entries()).map(([key, val]) => ({
185
+ key,
186
+ timestamp: val.timestamp
187
+ }));
188
+ }
189
+ };
190
+
191
+ // src/infrastructure/project-detector.ts
192
+ import { existsSync, readFileSync } from "fs";
193
+ import { dirname, join, resolve } from "path";
194
+ var VITE_CONFIG_NAMES = [
195
+ "vite.config.ts",
196
+ "vite.config.js",
197
+ "vite.config.mjs",
198
+ "vite.config.mts"
199
+ ];
200
+ var ProjectDetector = class {
201
+ detect(filePath) {
202
+ const root = this.findProjectRoot(filePath);
203
+ const viteConfigPath = this.findViteConfig(root);
204
+ const framework = this.detectFramework(root);
205
+ const hasVite = this.checkViteAvailable(root);
206
+ return { root, viteConfigPath, framework, hasVite };
207
+ }
208
+ findProjectRoot(startPath) {
209
+ let dir = dirname(resolve(startPath));
210
+ while (dir !== dirname(dir)) {
211
+ if (existsSync(join(dir, "package.json"))) {
212
+ return dir;
213
+ }
214
+ dir = dirname(dir);
215
+ }
216
+ return dirname(resolve(startPath));
217
+ }
218
+ findViteConfig(root) {
219
+ for (const name of VITE_CONFIG_NAMES) {
220
+ const configPath = join(root, name);
221
+ if (existsSync(configPath)) {
222
+ return configPath;
223
+ }
224
+ }
225
+ return void 0;
226
+ }
227
+ detectFramework(root) {
228
+ const pkgPath = join(root, "package.json");
229
+ if (!existsSync(pkgPath)) return "html";
230
+ try {
231
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
232
+ const allDeps = {
233
+ ...pkg.dependencies,
234
+ ...pkg.devDependencies
235
+ };
236
+ if (allDeps.react || allDeps["react-dom"]) return "react";
237
+ if (allDeps.vue) return "vue";
238
+ if (allDeps.svelte) return "svelte";
239
+ } catch {
240
+ }
241
+ return "html";
242
+ }
243
+ checkViteAvailable(root) {
244
+ const vitePkgPath = join(root, "node_modules", "vite", "package.json");
245
+ return existsSync(vitePkgPath);
246
+ }
247
+ };
248
+
249
+ // src/infrastructure/vite-bundler.ts
250
+ import { createRequire } from "module";
251
+ import { join as join2, resolve as resolve2 } from "path";
252
+ var ViteBundler = class {
253
+ servers = /* @__PURE__ */ new Map();
254
+ detector = new ProjectDetector();
255
+ async getUrl(filePath, options = {}) {
256
+ const absPath = resolve2(filePath);
257
+ const project = options.projectRoot ? this.detector.detect(join2(options.projectRoot, "package.json")) : this.detector.detect(absPath);
258
+ if (!project.hasVite) {
259
+ throw new Error(
260
+ `Vite not found in ${project.root}. Install vite: npm install -D vite`
261
+ );
262
+ }
263
+ const framework = options.framework ?? project.framework;
264
+ const entryCode = this.generateEntry(absPath, framework, options.props);
265
+ const cached = await this.ensureServer(project);
266
+ cached.currentEntry = entryCode;
267
+ cached.lastUsed = Date.now();
268
+ const mod = cached.server.moduleGraph.getModuleById(
269
+ "\0virtual:frameshot-entry"
270
+ );
271
+ if (mod) {
272
+ cached.server.moduleGraph.invalidateModule(mod);
273
+ }
274
+ return {
275
+ url: `http://127.0.0.1:${cached.port}/@frameshot/`,
276
+ project
277
+ };
278
+ }
279
+ async shutdown() {
280
+ for (const cached of this.servers.values()) {
281
+ await cached.server.close().catch(() => {
282
+ });
283
+ }
284
+ this.servers.clear();
285
+ }
286
+ async ensureServer(project) {
287
+ const existing = this.servers.get(project.root);
288
+ if (existing) {
289
+ return existing;
290
+ }
291
+ const vite = await this.importVite(project.root);
292
+ if (!vite) {
293
+ throw new Error(`Failed to import vite from ${project.root}`);
294
+ }
295
+ const self = this;
296
+ const server = await vite.createServer({
297
+ root: project.root,
298
+ configFile: project.viteConfigPath ?? false,
299
+ server: {
300
+ port: 0,
301
+ strictPort: false,
302
+ host: "127.0.0.1",
303
+ hmr: false
304
+ },
305
+ logLevel: "error",
306
+ plugins: [
307
+ {
308
+ name: "frameshot-virtual",
309
+ resolveId(id) {
310
+ if (id === "virtual:frameshot-entry") return "\0virtual:frameshot-entry";
311
+ return null;
312
+ },
313
+ load(id) {
314
+ if (id === "\0virtual:frameshot-entry") {
315
+ const cached2 = self.servers.get(project.root);
316
+ return cached2?.currentEntry ?? "";
317
+ }
318
+ return null;
319
+ }
320
+ },
321
+ {
322
+ name: "frameshot-html",
323
+ configureServer(server2) {
324
+ server2.middlewares.use((req, res, next) => {
325
+ if (req.url === "/@frameshot/" || req.url === "/@frameshot") {
326
+ const html = `<!DOCTYPE html>
327
+ <html>
328
+ <head><meta charset="utf-8"></head>
329
+ <body>
330
+ <div id="app"></div>
331
+ <script type="module">import "virtual:frameshot-entry";</script>
332
+ </body>
333
+ </html>`;
334
+ server2.transformIndexHtml(req.url, html).then((transformed) => {
335
+ res.statusCode = 200;
336
+ res.setHeader("Content-Type", "text/html");
337
+ res.end(transformed);
338
+ }).catch(next);
339
+ } else {
340
+ next();
341
+ }
342
+ });
343
+ }
344
+ }
345
+ ],
346
+ optimizeDeps: {
347
+ include: this.getOptimizeDepsInclude(project.framework)
348
+ }
349
+ });
350
+ await server.listen();
351
+ const address = server.httpServer?.address();
352
+ const port = typeof address === "object" && address ? address.port : 5173;
353
+ const cached = {
354
+ server,
355
+ port,
356
+ lastUsed: Date.now(),
357
+ currentEntry: ""
358
+ };
359
+ this.servers.set(project.root, cached);
360
+ return cached;
361
+ }
362
+ async importVite(projectRoot) {
363
+ try {
364
+ const require2 = createRequire(join2(projectRoot, "package.json"));
365
+ const vitePath = require2.resolve("vite");
366
+ return await import(vitePath);
367
+ } catch {
368
+ try {
369
+ return await import("vite");
370
+ } catch {
371
+ return null;
372
+ }
373
+ }
374
+ }
375
+ generateEntry(componentPath, framework, props) {
376
+ const propsJson = props ? JSON.stringify(props) : "{}";
377
+ switch (framework) {
378
+ case "react":
379
+ return `
380
+ import { createElement } from "react";
381
+ import { createRoot } from "react-dom/client";
382
+ import Component from "${componentPath}";
383
+ const props = ${propsJson};
384
+ const root = createRoot(document.getElementById("app"));
385
+ root.render(createElement(Component, props));
386
+ `;
387
+ case "vue":
388
+ return `
389
+ import { createApp } from "vue";
390
+ import Component from "${componentPath}";
391
+ const props = ${propsJson};
392
+ createApp(Component, props).mount("#app");
393
+ `;
394
+ case "svelte":
395
+ return `
396
+ import Component from "${componentPath}";
397
+ const props = ${propsJson};
398
+ new Component({ target: document.getElementById("app"), props });
399
+ `;
400
+ default:
401
+ return `
402
+ const res = await fetch("${componentPath}");
403
+ const html = await res.text();
404
+ document.getElementById("app").innerHTML = html;
405
+ `;
406
+ }
407
+ }
408
+ getOptimizeDepsInclude(framework) {
409
+ switch (framework) {
410
+ case "react":
411
+ return ["react", "react-dom/client"];
412
+ case "vue":
413
+ return ["vue"];
414
+ case "svelte":
415
+ return [];
416
+ default:
417
+ return [];
418
+ }
419
+ }
420
+ };
421
+
422
+ // src/domain/types.ts
423
+ var DEVICE_PRESETS = {
424
+ mobile: { width: 375, height: 667 },
425
+ tablet: { width: 768, height: 1024 },
426
+ desktop: { width: 1280, height: 800 }
427
+ };
428
+ var EXT_TO_FRAMEWORK = {
429
+ ".jsx": "react",
430
+ ".tsx": "react",
431
+ ".vue": "vue",
432
+ ".svelte": "svelte",
433
+ ".html": "html",
434
+ ".htm": "html"
435
+ };
436
+
437
+ // src/use-cases/audit.ts
438
+ var AuditUseCase = class {
439
+ constructor(pool, htmlBuilder) {
440
+ this.pool = pool;
441
+ this.htmlBuilder = htmlBuilder;
442
+ }
443
+ pool;
444
+ htmlBuilder;
445
+ async auditA11y(code, framework, options = {}) {
446
+ const html = this.htmlBuilder.build(code, framework, {
447
+ darkMode: options.darkMode ?? false,
448
+ css: options.css ?? "",
449
+ tailwindVersion: options.tailwindVersion ?? "3"
450
+ });
451
+ const page = await this.pool.getPage("chromium");
452
+ const viewport = options.viewport ?? { width: 1280, height: 800 };
453
+ await this.pool.setViewport("chromium", viewport);
454
+ await page.setContent(html, { waitUntil: "load", timeout: 1e4 });
455
+ const axeSource = await import("axe-core").then((m) => m.source);
456
+ await page.addScriptTag({ content: axeSource });
457
+ const results = await page.evaluate(() => {
458
+ return window.axe.run();
459
+ });
460
+ return {
461
+ violations: results.violations.map(
462
+ (v) => ({
463
+ id: v.id,
464
+ impact: v.impact,
465
+ description: v.description,
466
+ helpUrl: v.helpUrl,
467
+ nodes: v.nodes.map((n) => ({
468
+ html: n.html,
469
+ target: n.target
470
+ }))
471
+ })
472
+ ),
473
+ passes: results.passes.length,
474
+ incomplete: results.incomplete.length
475
+ };
476
+ }
477
+ async perfAudit(code, framework, options = {}) {
478
+ const html = this.htmlBuilder.build(code, framework, {
479
+ darkMode: options.darkMode ?? false,
480
+ css: options.css ?? "",
481
+ tailwindVersion: options.tailwindVersion ?? "3"
482
+ });
483
+ const page = await this.pool.getPage("chromium");
484
+ const viewport = options.viewport ?? { width: 1280, height: 800 };
485
+ await this.pool.setViewport("chromium", viewport);
486
+ const start = performance.now();
487
+ await page.setContent(html, { waitUntil: "load", timeout: 1e4 });
488
+ const renderTimeMs = Math.round(performance.now() - start);
489
+ const metrics = await page.evaluate(() => {
490
+ const all = document.querySelectorAll("*");
491
+ let maxDepth = 0;
492
+ for (const el of all) {
493
+ let depth = 0;
494
+ let node = el;
495
+ while (node) {
496
+ depth++;
497
+ node = node.parentElement;
498
+ }
499
+ if (depth > maxDepth) maxDepth = depth;
500
+ }
501
+ return {
502
+ domElements: all.length,
503
+ domDepth: maxDepth,
504
+ scriptCount: document.querySelectorAll("script").length,
505
+ styleSheetCount: document.styleSheets.length,
506
+ imageCount: document.querySelectorAll("img").length,
507
+ totalDomSize: document.documentElement.outerHTML.length
508
+ };
509
+ });
510
+ return { renderTimeMs, ...metrics };
511
+ }
512
+ };
513
+
514
+ // src/use-cases/catalog.ts
515
+ import { readdirSync, statSync } from "fs";
516
+ import { extname, join as join3, relative } from "path";
517
+ var CatalogUseCase = class {
518
+ constructor(renderUseCase) {
519
+ this.renderUseCase = renderUseCase;
520
+ }
521
+ renderUseCase;
522
+ async renderCatalog(directory, options = {}) {
523
+ const { recursive = false, ...renderOptions } = options;
524
+ const files = this.scanDirectory(directory, recursive);
525
+ const results = [];
526
+ for (const filePath of files) {
527
+ const relativePath = relative(directory, filePath);
528
+ const framework = this.detectFramework(filePath);
529
+ try {
530
+ const { results: renderResults } = await this.renderUseCase.renderFile(
531
+ filePath,
532
+ {
533
+ ...renderOptions,
534
+ engines: ["chromium"]
535
+ }
536
+ );
537
+ const result = renderResults[0];
538
+ results.push({
539
+ path: relativePath,
540
+ framework,
541
+ image: result.image,
542
+ width: result.width,
543
+ height: result.height,
544
+ consoleErrors: result.consoleErrors
545
+ });
546
+ } catch {
547
+ results.push({
548
+ path: relativePath,
549
+ framework,
550
+ image: "",
551
+ width: 0,
552
+ height: 0,
553
+ consoleErrors: [`Failed to render ${relativePath}`]
554
+ });
555
+ }
556
+ }
557
+ return results;
558
+ }
559
+ scanDirectory(dir, recursive) {
560
+ const componentExtensions = /* @__PURE__ */ new Set([
561
+ ".jsx",
562
+ ".tsx",
563
+ ".vue",
564
+ ".svelte",
565
+ ".html",
566
+ ".htm"
567
+ ]);
568
+ const files = [];
569
+ const entries = readdirSync(dir);
570
+ for (const entry of entries) {
571
+ if (entry.startsWith(".") || entry === "node_modules") continue;
572
+ const fullPath = join3(dir, entry);
573
+ const stat = statSync(fullPath);
574
+ if (stat.isDirectory() && recursive) {
575
+ files.push(...this.scanDirectory(fullPath, recursive));
576
+ } else if (stat.isFile() && componentExtensions.has(extname(entry).toLowerCase())) {
577
+ files.push(fullPath);
578
+ }
579
+ }
580
+ return files;
581
+ }
582
+ detectFramework(filePath) {
583
+ return EXT_TO_FRAMEWORK[extname(filePath).toLowerCase()] ?? "react";
584
+ }
585
+ };
586
+
587
+ // src/use-cases/diff.ts
588
+ var DiffUseCase = class {
589
+ constructor(renderUseCase, imageComparator) {
590
+ this.renderUseCase = renderUseCase;
591
+ this.imageComparator = imageComparator;
592
+ }
593
+ renderUseCase;
594
+ imageComparator;
595
+ async diffComponent(before, after, framework, options = {}) {
596
+ const [beforeResults, afterResults] = await Promise.all([
597
+ this.renderUseCase.render(before, framework, {
598
+ ...options,
599
+ engines: ["chromium"]
600
+ }),
601
+ this.renderUseCase.render(after, framework, {
602
+ ...options,
603
+ engines: ["chromium"]
604
+ })
605
+ ]);
606
+ const comparison = this.imageComparator.diff(
607
+ beforeResults[0].image,
608
+ afterResults[0].image
609
+ );
610
+ return {
611
+ before: beforeResults[0].image,
612
+ after: afterResults[0].image,
613
+ ...comparison
614
+ };
615
+ }
616
+ async diffFromReference(code, framework, referenceImage, options = {}) {
617
+ const { threshold = 0.1, ...renderOptions } = options;
618
+ const [renderResult] = await this.renderUseCase.render(code, framework, {
619
+ ...renderOptions,
620
+ engines: ["chromium"]
621
+ });
622
+ const comparison = this.imageComparator.diff(
623
+ referenceImage,
624
+ renderResult.image,
625
+ threshold
626
+ );
627
+ return {
628
+ rendered: renderResult.image,
629
+ ...comparison,
630
+ passed: comparison.diffPercentage === 0
631
+ };
632
+ }
633
+ };
634
+
635
+ // src/use-cases/render.ts
636
+ import { readFileSync as readFileSync2 } from "fs";
637
+ import { extname as extname2 } from "path";
638
+ var RenderUseCase = class {
639
+ constructor(pool, htmlBuilder, imageComparator, viteBundler) {
640
+ this.pool = pool;
641
+ this.htmlBuilder = htmlBuilder;
642
+ this.imageComparator = imageComparator;
643
+ this.viteBundler = viteBundler;
644
+ }
645
+ pool;
646
+ htmlBuilder;
647
+ imageComparator;
648
+ viteBundler;
649
+ async render(code, framework, options = {}) {
650
+ const opts = this.resolveOptions(options);
651
+ const html = this.htmlBuilder.build(code, framework, {
652
+ darkMode: opts.darkMode,
653
+ css: opts.css,
654
+ tailwindVersion: opts.tailwindVersion
655
+ });
656
+ return Promise.all(
657
+ opts.engines.map((engine) => this.renderHtml(engine, html, opts))
658
+ );
659
+ }
660
+ async renderFile(filePath, options = {}) {
661
+ const opts = this.resolveOptions(options);
662
+ const ext = extname2(filePath).toLowerCase();
663
+ const framework = EXT_TO_FRAMEWORK[ext] ?? "react";
664
+ if (this.viteBundler) {
665
+ try {
666
+ const { url } = await this.viteBundler.getUrl(filePath, {
667
+ props: options.props,
668
+ framework,
669
+ projectRoot: options.projectRoot
670
+ });
671
+ const results2 = await Promise.all(
672
+ opts.engines.map((engine) => this.renderUrl(engine, url, opts))
673
+ );
674
+ return { results: results2, mode: "vite" };
675
+ } catch {
676
+ }
677
+ }
678
+ const code = readFileSync2(filePath, "utf-8");
679
+ const results = await this.render(code, framework, options);
680
+ return { results, mode: "cdn" };
681
+ }
682
+ async renderInteraction(code, framework, interactions, options = {}) {
683
+ const opts = this.resolveOptions(options);
684
+ const html = this.htmlBuilder.build(code, framework, {
685
+ darkMode: opts.darkMode,
686
+ css: opts.css,
687
+ tailwindVersion: opts.tailwindVersion
688
+ });
689
+ const page = await this.pool.getPage("chromium");
690
+ await this.pool.setViewport("chromium", opts.viewport);
691
+ await page.setContent(html, { waitUntil: "load", timeout: 1e4 });
692
+ for (const interaction of interactions) {
693
+ switch (interaction.action) {
694
+ case "click":
695
+ if (interaction.selector) await page.click(interaction.selector);
696
+ break;
697
+ case "hover":
698
+ if (interaction.selector) await page.hover(interaction.selector);
699
+ break;
700
+ case "focus":
701
+ if (interaction.selector) await page.focus(interaction.selector);
702
+ break;
703
+ case "type":
704
+ if (interaction.selector && interaction.value)
705
+ await page.fill(interaction.selector, interaction.value);
706
+ break;
707
+ case "wait":
708
+ await page.waitForTimeout(interaction.ms ?? 300);
709
+ break;
710
+ }
711
+ }
712
+ const screenshot = await page.screenshot({ type: "png", fullPage: true });
713
+ const metrics = await page.evaluate(() => ({
714
+ w: document.documentElement.scrollWidth,
715
+ h: document.documentElement.scrollHeight
716
+ }));
717
+ return {
718
+ image: screenshot.toString("base64"),
719
+ width: metrics.w,
720
+ height: metrics.h
721
+ };
722
+ }
723
+ async captureAnimation(code, framework, options = {}) {
724
+ const { frames = 5, duration = 1e3 } = options;
725
+ const opts = this.resolveOptions(options);
726
+ const html = this.htmlBuilder.build(code, framework, {
727
+ darkMode: opts.darkMode,
728
+ css: opts.css,
729
+ tailwindVersion: opts.tailwindVersion
730
+ });
731
+ const page = await this.pool.getPage("chromium");
732
+ await this.pool.setViewport("chromium", opts.viewport);
733
+ await page.setContent(html, { waitUntil: "load", timeout: 1e4 });
734
+ const interval = duration / (frames - 1);
735
+ const results = [];
736
+ for (let i = 0; i < frames; i++) {
737
+ if (i > 0) await page.waitForTimeout(interval);
738
+ const screenshot = await page.screenshot({
739
+ type: "png",
740
+ fullPage: false
741
+ });
742
+ results.push({
743
+ timestamp: Math.round(i * interval),
744
+ image: screenshot.toString("base64")
745
+ });
746
+ }
747
+ return results;
748
+ }
749
+ async renderGrid(cells, framework, options = {}) {
750
+ const { columns = Math.min(cells.length, 3) } = options;
751
+ const screenshots = await Promise.all(
752
+ cells.map(async (cell) => {
753
+ const [result] = await this.render(cell.code, framework, {
754
+ ...options,
755
+ engines: ["chromium"]
756
+ });
757
+ return Buffer.from(result.image, "base64");
758
+ })
759
+ );
760
+ const composite = this.imageComparator.composite(screenshots, columns);
761
+ return { ...composite, cells: cells.length };
762
+ }
763
+ async renderMatrix(code, framework, viewports, themes, options = {}) {
764
+ const combinations = [];
765
+ for (const viewport of viewports) {
766
+ for (const theme of themes) {
767
+ combinations.push({ viewport, theme });
768
+ }
769
+ }
770
+ return Promise.all(
771
+ combinations.map(async ({ viewport, theme }) => {
772
+ const [result] = await this.render(code, framework, {
773
+ ...options,
774
+ viewport: { width: viewport.width, height: viewport.height },
775
+ darkMode: theme === "dark",
776
+ engines: ["chromium"],
777
+ fullPage: true
778
+ });
779
+ return {
780
+ viewport: viewport.label,
781
+ theme,
782
+ image: result.image,
783
+ width: result.width,
784
+ height: result.height,
785
+ consoleErrors: result.consoleErrors
786
+ };
787
+ })
788
+ );
789
+ }
790
+ async renderHtml(engine, html, options) {
791
+ const page = await this.pool.getPage(engine);
792
+ await this.pool.setViewport(engine, options.viewport);
793
+ const consoleErrors = [];
794
+ const onError = (msg) => {
795
+ if (msg.type() === "error") consoleErrors.push(msg.text());
796
+ };
797
+ page.on("console", onError);
798
+ page.on("pageerror", (err) => consoleErrors.push(err.message));
799
+ await page.setContent(html, { waitUntil: "load", timeout: 1e4 });
800
+ if (options.waitFor > 0) {
801
+ await page.waitForTimeout(options.waitFor);
802
+ }
803
+ const screenshot = await page.screenshot({
804
+ type: "png",
805
+ fullPage: options.fullPage
806
+ });
807
+ const metrics = await page.evaluate(() => ({
808
+ w: document.documentElement.scrollWidth,
809
+ h: document.documentElement.scrollHeight
810
+ }));
811
+ page.removeListener("console", onError);
812
+ return {
813
+ engine,
814
+ image: screenshot.toString("base64"),
815
+ width: metrics.w,
816
+ height: metrics.h,
817
+ consoleErrors
818
+ };
819
+ }
820
+ async renderUrl(engine, url, options) {
821
+ const page = await this.pool.getPage(engine);
822
+ await this.pool.setViewport(engine, options.viewport);
823
+ const consoleErrors = [];
824
+ const onError = (msg) => {
825
+ if (msg.type() === "error") consoleErrors.push(msg.text());
826
+ };
827
+ page.on("console", onError);
828
+ page.on("pageerror", (err) => consoleErrors.push(err.message));
829
+ await page.goto(url, { waitUntil: "load", timeout: 15e3 });
830
+ await page.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => {
831
+ });
832
+ if (options.waitFor > 0) {
833
+ await page.waitForTimeout(options.waitFor);
834
+ }
835
+ const screenshot = await page.screenshot({
836
+ type: "png",
837
+ fullPage: options.fullPage
838
+ });
839
+ const metrics = await page.evaluate(() => ({
840
+ w: document.documentElement.scrollWidth,
841
+ h: document.documentElement.scrollHeight
842
+ }));
843
+ page.removeListener("console", onError);
844
+ return {
845
+ engine,
846
+ image: screenshot.toString("base64"),
847
+ width: metrics.w,
848
+ height: metrics.h,
849
+ consoleErrors
850
+ };
851
+ }
852
+ resolveOptions(partial) {
853
+ return {
854
+ viewport: partial.viewport ?? { width: 1280, height: 800 },
855
+ engines: partial.engines ?? ["chromium"],
856
+ fullPage: partial.fullPage ?? true,
857
+ darkMode: partial.darkMode ?? false,
858
+ css: partial.css ?? "",
859
+ tailwindVersion: partial.tailwindVersion ?? "3",
860
+ waitFor: partial.waitFor ?? 0
861
+ };
862
+ }
863
+ };
864
+
865
+ // src/use-cases/screenshot.ts
866
+ var ScreenshotUseCase = class {
867
+ constructor(pool) {
868
+ this.pool = pool;
869
+ }
870
+ pool;
871
+ async screenshotUrl(url, options = {}) {
872
+ const engines = options.engines ?? ["chromium"];
873
+ const { waitForNetworkIdle = true } = options;
874
+ return Promise.all(
875
+ engines.map(async (engine) => {
876
+ const { width = 1280, height = 800 } = options.viewport ?? {};
877
+ const fullPage = options.fullPage ?? true;
878
+ const waitFor = options.waitFor ?? 0;
879
+ const page = await this.pool.getPage(engine);
880
+ await this.pool.setViewport(engine, { width, height });
881
+ const consoleErrors = [];
882
+ const onError = (msg) => {
883
+ if (msg.type() === "error") consoleErrors.push(msg.text());
884
+ };
885
+ page.on("console", onError);
886
+ page.on("pageerror", (err) => consoleErrors.push(err.message));
887
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15e3 });
888
+ if (waitForNetworkIdle) {
889
+ await page.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => {
890
+ });
891
+ }
892
+ if (options.waitForSelector) {
893
+ await page.waitForSelector(options.waitForSelector, {
894
+ timeout: 1e4
895
+ });
896
+ }
897
+ if (waitFor > 0) {
898
+ await page.waitForTimeout(waitFor);
899
+ }
900
+ const screenshot = await page.screenshot({ type: "png", fullPage });
901
+ const metrics = await page.evaluate(() => ({
902
+ w: document.documentElement.scrollWidth,
903
+ h: document.documentElement.scrollHeight
904
+ }));
905
+ page.removeListener("console", onError);
906
+ return {
907
+ engine,
908
+ image: screenshot.toString("base64"),
909
+ width: metrics.w,
910
+ height: metrics.h,
911
+ consoleErrors
912
+ };
913
+ })
914
+ );
915
+ }
916
+ async screenshotUrlWithRetry(url, options = {}) {
917
+ const { retryCount = 0, ...screenshotOpts } = options;
918
+ const maxAttempts = retryCount + 1;
919
+ let lastError;
920
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
921
+ try {
922
+ if (attempt > 0) {
923
+ const delay = 300 * 2 ** (attempt - 1);
924
+ await new Promise((resolve3) => setTimeout(resolve3, delay));
925
+ }
926
+ return await this.screenshotUrl(url, screenshotOpts);
927
+ } catch (error) {
928
+ lastError = error;
929
+ }
930
+ }
931
+ throw lastError;
932
+ }
933
+ };
934
+
935
+ // src/use-cases/snapshot.ts
936
+ var SnapshotUseCase = class {
937
+ constructor(store, renderUseCase, diffUseCase) {
938
+ this.store = store;
939
+ this.renderUseCase = renderUseCase;
940
+ this.diffUseCase = diffUseCase;
941
+ }
942
+ store;
943
+ renderUseCase;
944
+ diffUseCase;
945
+ async save(key, code, framework, options = {}) {
946
+ const [result] = await this.renderUseCase.render(code, framework, {
947
+ ...options,
948
+ engines: ["chromium"]
949
+ });
950
+ this.store.save(key, result.image);
951
+ return { image: result.image, width: result.width, height: result.height };
952
+ }
953
+ async check(key, code, framework, options = {}) {
954
+ const snapshot = this.store.get(key);
955
+ if (!snapshot) return null;
956
+ return this.diffUseCase.diffFromReference(
957
+ code,
958
+ framework,
959
+ snapshot.image,
960
+ options
961
+ );
962
+ }
963
+ list() {
964
+ return this.store.list();
965
+ }
966
+ };
967
+
968
+ export {
969
+ __export,
970
+ BrowserPool,
971
+ HtmlBuilder,
972
+ ImageComparator,
973
+ SnapshotStore,
974
+ ProjectDetector,
975
+ ViteBundler,
976
+ DEVICE_PRESETS,
977
+ EXT_TO_FRAMEWORK,
978
+ AuditUseCase,
979
+ CatalogUseCase,
980
+ DiffUseCase,
981
+ RenderUseCase,
982
+ ScreenshotUseCase,
983
+ SnapshotUseCase
984
+ };