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,603 @@
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/renderer.ts
8
+ import pixelmatch from "pixelmatch";
9
+ import { chromium, firefox, webkit } from "playwright";
10
+ import { PNG } from "pngjs";
11
+ var pool = /* @__PURE__ */ new Map();
12
+ var ENGINES = ["chromium", "firefox", "webkit"];
13
+ async function warmup(engines = ENGINES) {
14
+ await Promise.all(engines.map((e) => getSlot(e)));
15
+ }
16
+ async function getSlot(engine) {
17
+ const existing = pool.get(engine);
18
+ if (existing?.browser.isConnected() && existing.ready) {
19
+ return existing;
20
+ }
21
+ const launcher = { chromium, firefox, webkit }[engine];
22
+ let browser;
23
+ try {
24
+ browser = await launcher.launch({
25
+ headless: true,
26
+ ...engine === "chromium" ? { channel: "chrome" } : {}
27
+ });
28
+ } catch (_e) {
29
+ throw new Error(
30
+ `${engine} is not installed. Run: npx playwright install ${engine}`
31
+ );
32
+ }
33
+ const context = await browser.newContext({
34
+ viewport: { width: 1280, height: 800 },
35
+ deviceScaleFactor: 2
36
+ });
37
+ const page = await context.newPage();
38
+ await page.setContent(
39
+ '<html><head><script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio"></script></head><body></body></html>',
40
+ { waitUntil: "networkidle" }
41
+ );
42
+ const slot = { browser, page, ready: true };
43
+ pool.set(engine, slot);
44
+ return slot;
45
+ }
46
+ function wrapComponent(code, framework, darkMode = false, css = "", tailwindVersion = "3") {
47
+ 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>';
48
+ const tailwindConfig = tailwindVersion === "4" ? "" : "<script>tailwind.config={darkMode:'class'}</script>";
49
+ const customCss = css ? `<style>${css}</style>` : "";
50
+ const baseStyle = `<style>*{margin:0;box-sizing:border-box}body{padding:16px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}</style>`;
51
+ const htmlClass = darkMode ? ' class="dark"' : "";
52
+ if (framework === "html") {
53
+ if (code.includes("<html")) return code;
54
+ return `<!DOCTYPE html><html${htmlClass}><head><meta charset="utf-8">${tailwind}${tailwindConfig}${baseStyle}${customCss}</head><body>${code}</body></html>`;
55
+ }
56
+ if (framework === "react") {
57
+ 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, "");
58
+ return `<!DOCTYPE html><html${htmlClass}><head><meta charset="utf-8">${tailwind}${tailwindConfig}
59
+ <script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
60
+ <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
61
+ <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
62
+ ${baseStyle}${customCss}</head><body><div id="root"></div>
63
+ <script type="text/babel">
64
+ const Image = (props) => React.createElement('img', {...props, src: props.src?.src || props.src});
65
+ const Link = ({href, children, ...props}) => React.createElement('a', {href, ...props}, children);
66
+ ${cleanedCode}
67
+ const _C = typeof App!=='undefined'?App:typeof Component!=='undefined'?Component:typeof Default!=='undefined'?Default:null;
68
+ if(_C)ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(_C));
69
+ </script></body></html>`;
70
+ }
71
+ if (framework === "vue") {
72
+ return `<!DOCTYPE html><html${htmlClass}><head><meta charset="utf-8">${tailwind}${tailwindConfig}
73
+ <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
74
+ ${baseStyle}${customCss}</head><body><div id="app"></div>
75
+ <script>
76
+ const{createApp,ref,reactive,computed,onMounted,watch,watchEffect}=Vue;
77
+ ${code}
78
+ const _C=typeof App!=='undefined'?App:typeof Component!=='undefined'?Component:null;
79
+ if(_C)createApp(_C).mount('#app');
80
+ </script></body></html>`;
81
+ }
82
+ if (framework === "svelte") {
83
+ return `<!DOCTYPE html><html${htmlClass}><head><meta charset="utf-8">${tailwind}${tailwindConfig}
84
+ <script src="https://unpkg.com/svelte@4/compiler.cjs"></script>
85
+ ${baseStyle}${customCss}</head><body><div id="app"></div>
86
+ <script type="module">
87
+ import "https://unpkg.com/svelte@4/internal/index.mjs";
88
+ ${code}
89
+ const _C=typeof App!=='undefined'?App:typeof Component!=='undefined'?Component:null;
90
+ if(_C)new _C({target:document.getElementById('app')});
91
+ </script></body></html>`;
92
+ }
93
+ return code;
94
+ }
95
+ async function renderSingle(engine, html, options) {
96
+ const { width = 1280, height = 800, fullPage = true, waitFor = 0 } = options;
97
+ const slot = await getSlot(engine);
98
+ const { page } = slot;
99
+ const consoleErrors = [];
100
+ const onError = (msg) => {
101
+ if (msg.type() === "error") {
102
+ consoleErrors.push(msg.text());
103
+ }
104
+ };
105
+ page.on("console", onError);
106
+ page.on("pageerror", (err) => consoleErrors.push(err.message));
107
+ const currentViewport = page.viewportSize();
108
+ if (currentViewport?.width !== width || currentViewport?.height !== height) {
109
+ await page.setViewportSize({ width, height });
110
+ }
111
+ await page.setContent(html, { waitUntil: "load", timeout: 1e4 });
112
+ if (waitFor > 0) {
113
+ await page.waitForTimeout(waitFor);
114
+ }
115
+ const screenshot = await page.screenshot({ type: "png", fullPage });
116
+ const metrics = await page.evaluate(() => ({
117
+ w: document.documentElement.scrollWidth,
118
+ h: document.documentElement.scrollHeight
119
+ }));
120
+ page.removeListener("console", onError);
121
+ return {
122
+ engine,
123
+ image: screenshot.toString("base64"),
124
+ width: metrics.w,
125
+ height: metrics.h,
126
+ consoleErrors
127
+ };
128
+ }
129
+ async function render(code, framework, options = {}) {
130
+ const engines = options.engines ?? ["chromium"];
131
+ const html = wrapComponent(
132
+ code,
133
+ framework,
134
+ options.darkMode ?? false,
135
+ options.css ?? "",
136
+ options.tailwindVersion ?? "3"
137
+ );
138
+ const results = await Promise.all(
139
+ engines.map((e) => renderSingle(e, html, options))
140
+ );
141
+ return results;
142
+ }
143
+ async function screenshotUrl(url, options = {}) {
144
+ const engines = options.engines ?? ["chromium"];
145
+ const { waitForNetworkIdle = true } = options;
146
+ const results = await Promise.all(
147
+ engines.map(async (engine) => {
148
+ const {
149
+ width = 1280,
150
+ height = 800,
151
+ fullPage = true,
152
+ waitFor = 0
153
+ } = options;
154
+ const slot = await getSlot(engine);
155
+ const { page } = slot;
156
+ const consoleErrors = [];
157
+ const onError = (msg) => {
158
+ if (msg.type() === "error") {
159
+ consoleErrors.push(msg.text());
160
+ }
161
+ };
162
+ page.on("console", onError);
163
+ page.on("pageerror", (err) => consoleErrors.push(err.message));
164
+ const currentViewport = page.viewportSize();
165
+ if (currentViewport?.width !== width || currentViewport?.height !== height) {
166
+ await page.setViewportSize({ width, height });
167
+ }
168
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 15e3 });
169
+ if (waitForNetworkIdle) {
170
+ await page.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => {
171
+ });
172
+ }
173
+ if (options.waitForSelector) {
174
+ await page.waitForSelector(options.waitForSelector, {
175
+ timeout: 1e4
176
+ });
177
+ }
178
+ if (waitFor > 0) {
179
+ await page.waitForTimeout(waitFor);
180
+ }
181
+ const screenshot = await page.screenshot({ type: "png", fullPage });
182
+ const metrics = await page.evaluate(() => ({
183
+ w: document.documentElement.scrollWidth,
184
+ h: document.documentElement.scrollHeight
185
+ }));
186
+ page.removeListener("console", onError);
187
+ return {
188
+ engine,
189
+ image: screenshot.toString("base64"),
190
+ width: metrics.w,
191
+ height: metrics.h,
192
+ consoleErrors
193
+ };
194
+ })
195
+ );
196
+ return results;
197
+ }
198
+ async function auditA11y(code, framework, options = {}) {
199
+ const html = wrapComponent(
200
+ code,
201
+ framework,
202
+ options.darkMode ?? false,
203
+ options.css ?? "",
204
+ options.tailwindVersion ?? "3"
205
+ );
206
+ const slot = await getSlot("chromium");
207
+ const { page } = slot;
208
+ const { width = 1280, height = 800 } = options;
209
+ const currentViewport = page.viewportSize();
210
+ if (currentViewport?.width !== width || currentViewport?.height !== height) {
211
+ await page.setViewportSize({ width, height });
212
+ }
213
+ await page.setContent(html, { waitUntil: "load", timeout: 1e4 });
214
+ const axeSource = await import("axe-core").then((m) => m.source);
215
+ await page.addScriptTag({ content: axeSource });
216
+ const results = await page.evaluate(() => {
217
+ return window.axe.run();
218
+ });
219
+ return {
220
+ violations: results.violations.map(
221
+ (v) => ({
222
+ id: v.id,
223
+ impact: v.impact,
224
+ description: v.description,
225
+ helpUrl: v.helpUrl,
226
+ nodes: v.nodes.map((n) => ({
227
+ html: n.html,
228
+ target: n.target
229
+ }))
230
+ })
231
+ ),
232
+ passes: results.passes.length,
233
+ incomplete: results.incomplete.length
234
+ };
235
+ }
236
+ async function diffComponent(before, after, framework, options = {}) {
237
+ const [beforeResults, afterResults] = await Promise.all([
238
+ render(before, framework, { ...options, engines: ["chromium"] }),
239
+ render(after, framework, { ...options, engines: ["chromium"] })
240
+ ]);
241
+ const beforeBuf = Buffer.from(beforeResults[0].image, "base64");
242
+ const afterBuf = Buffer.from(afterResults[0].image, "base64");
243
+ const beforePng = PNG.sync.read(beforeBuf);
244
+ const afterPng = PNG.sync.read(afterBuf);
245
+ const width = Math.max(beforePng.width, afterPng.width);
246
+ const height = Math.max(beforePng.height, afterPng.height);
247
+ const normalizedBefore = new PNG({ width, height });
248
+ const normalizedAfter = new PNG({ width, height });
249
+ PNG.bitblt(
250
+ beforePng,
251
+ normalizedBefore,
252
+ 0,
253
+ 0,
254
+ beforePng.width,
255
+ beforePng.height,
256
+ 0,
257
+ 0
258
+ );
259
+ PNG.bitblt(
260
+ afterPng,
261
+ normalizedAfter,
262
+ 0,
263
+ 0,
264
+ afterPng.width,
265
+ afterPng.height,
266
+ 0,
267
+ 0
268
+ );
269
+ const diffPng = new PNG({ width, height });
270
+ const diffPixels = pixelmatch(
271
+ normalizedBefore.data,
272
+ normalizedAfter.data,
273
+ diffPng.data,
274
+ width,
275
+ height,
276
+ { threshold: 0.1 }
277
+ );
278
+ const totalPixels = width * height;
279
+ return {
280
+ before: beforeResults[0].image,
281
+ after: afterResults[0].image,
282
+ diff: PNG.sync.write(diffPng).toString("base64"),
283
+ diffPixels,
284
+ totalPixels,
285
+ diffPercentage: Math.round(diffPixels / totalPixels * 1e4) / 100
286
+ };
287
+ }
288
+ async function captureAnimation(code, framework, options = {}) {
289
+ const { frames = 5, duration = 1e3 } = options;
290
+ const html = wrapComponent(
291
+ code,
292
+ framework,
293
+ options.darkMode ?? false,
294
+ options.css ?? "",
295
+ options.tailwindVersion ?? "3"
296
+ );
297
+ const slot = await getSlot("chromium");
298
+ const { page } = slot;
299
+ const { width = 1280, height = 800 } = options;
300
+ const currentViewport = page.viewportSize();
301
+ if (currentViewport?.width !== width || currentViewport?.height !== height) {
302
+ await page.setViewportSize({ width, height });
303
+ }
304
+ await page.setContent(html, { waitUntil: "load", timeout: 1e4 });
305
+ const interval = duration / (frames - 1);
306
+ const results = [];
307
+ for (let i = 0; i < frames; i++) {
308
+ if (i > 0) {
309
+ await page.waitForTimeout(interval);
310
+ }
311
+ const screenshot = await page.screenshot({ type: "png", fullPage: false });
312
+ results.push({
313
+ timestamp: Math.round(i * interval),
314
+ image: screenshot.toString("base64")
315
+ });
316
+ }
317
+ return results;
318
+ }
319
+ async function renderWithInteractions(code, framework, interactions, options = {}) {
320
+ const { width = 1280, height = 800 } = options;
321
+ const html = wrapComponent(
322
+ code,
323
+ framework,
324
+ options.darkMode ?? false,
325
+ options.css ?? "",
326
+ options.tailwindVersion ?? "3"
327
+ );
328
+ const slot = await getSlot("chromium");
329
+ const { page } = slot;
330
+ const currentViewport = page.viewportSize();
331
+ if (currentViewport?.width !== width || currentViewport?.height !== height) {
332
+ await page.setViewportSize({ width, height });
333
+ }
334
+ await page.setContent(html, { waitUntil: "load", timeout: 1e4 });
335
+ for (const interaction of interactions) {
336
+ switch (interaction.action) {
337
+ case "click":
338
+ if (interaction.selector) {
339
+ await page.click(interaction.selector);
340
+ }
341
+ break;
342
+ case "hover":
343
+ if (interaction.selector) {
344
+ await page.hover(interaction.selector);
345
+ }
346
+ break;
347
+ case "focus":
348
+ if (interaction.selector) {
349
+ await page.focus(interaction.selector);
350
+ }
351
+ break;
352
+ case "type":
353
+ if (interaction.selector && interaction.value) {
354
+ await page.fill(interaction.selector, interaction.value);
355
+ }
356
+ break;
357
+ case "wait":
358
+ await page.waitForTimeout(interaction.ms ?? 300);
359
+ break;
360
+ }
361
+ }
362
+ const screenshot = await page.screenshot({ type: "png", fullPage: true });
363
+ const metrics = await page.evaluate(() => ({
364
+ w: document.documentElement.scrollWidth,
365
+ h: document.documentElement.scrollHeight
366
+ }));
367
+ return {
368
+ image: screenshot.toString("base64"),
369
+ width: metrics.w,
370
+ height: metrics.h
371
+ };
372
+ }
373
+ async function renderGrid(cells, framework, options = {}) {
374
+ const { columns = Math.min(cells.length, 3) } = options;
375
+ const cellWidth = options.width ?? 400;
376
+ const cellHeight = options.height ?? 300;
377
+ const screenshots = await Promise.all(
378
+ cells.map(async (cell) => {
379
+ const [result] = await render(cell.code, framework, {
380
+ ...options,
381
+ width: cellWidth,
382
+ height: cellHeight,
383
+ engines: ["chromium"]
384
+ });
385
+ return Buffer.from(result.image, "base64");
386
+ })
387
+ );
388
+ const rows = Math.ceil(cells.length / columns);
389
+ const pngs = screenshots.map((buf) => PNG.sync.read(buf));
390
+ const actualCellWidth = Math.max(...pngs.map((p) => p.width));
391
+ const actualCellHeight = Math.max(...pngs.map((p) => p.height));
392
+ const labelHeight = 30;
393
+ const gridWidth = actualCellWidth * columns;
394
+ const gridHeight = (actualCellHeight + labelHeight) * rows;
395
+ const grid = new PNG({ width: gridWidth, height: gridHeight });
396
+ grid.data.fill(255);
397
+ for (let i = 0; i < pngs.length; i++) {
398
+ const col = i % columns;
399
+ const row = Math.floor(i / columns);
400
+ const x = col * actualCellWidth;
401
+ const y = row * (actualCellHeight + labelHeight) + labelHeight;
402
+ PNG.bitblt(pngs[i], grid, 0, 0, pngs[i].width, pngs[i].height, x, y);
403
+ }
404
+ return {
405
+ image: PNG.sync.write(grid).toString("base64"),
406
+ width: gridWidth,
407
+ height: gridHeight,
408
+ cells: cells.length
409
+ };
410
+ }
411
+ async function perfAudit(code, framework, options = {}) {
412
+ const { width = 1280, height = 800 } = options;
413
+ const html = wrapComponent(
414
+ code,
415
+ framework,
416
+ options.darkMode ?? false,
417
+ options.css ?? "",
418
+ options.tailwindVersion ?? "3"
419
+ );
420
+ const slot = await getSlot("chromium");
421
+ const { page } = slot;
422
+ const currentViewport = page.viewportSize();
423
+ if (currentViewport?.width !== width || currentViewport?.height !== height) {
424
+ await page.setViewportSize({ width, height });
425
+ }
426
+ const start = performance.now();
427
+ await page.setContent(html, { waitUntil: "load", timeout: 1e4 });
428
+ const renderTimeMs = Math.round(performance.now() - start);
429
+ const metrics = await page.evaluate(() => {
430
+ const all = document.querySelectorAll("*");
431
+ let maxDepth = 0;
432
+ for (const el of all) {
433
+ let depth = 0;
434
+ let node = el;
435
+ while (node) {
436
+ depth++;
437
+ node = node.parentElement;
438
+ }
439
+ if (depth > maxDepth) maxDepth = depth;
440
+ }
441
+ return {
442
+ domElements: all.length,
443
+ domDepth: maxDepth,
444
+ scriptCount: document.querySelectorAll("script").length,
445
+ styleSheetCount: document.styleSheets.length,
446
+ imageCount: document.querySelectorAll("img").length,
447
+ totalDomSize: document.documentElement.outerHTML.length
448
+ };
449
+ });
450
+ return { renderTimeMs, ...metrics };
451
+ }
452
+ async function renderMatrix(code, framework, viewports, themes, options = {}) {
453
+ const combinations = [];
454
+ for (const viewport of viewports) {
455
+ for (const theme of themes) {
456
+ combinations.push({ viewport, theme });
457
+ }
458
+ }
459
+ const results = await Promise.all(
460
+ combinations.map(async ({ viewport, theme }) => {
461
+ const [result] = await render(code, framework, {
462
+ ...options,
463
+ width: viewport.width,
464
+ height: viewport.height,
465
+ darkMode: theme === "dark",
466
+ engines: ["chromium"],
467
+ fullPage: true
468
+ });
469
+ return {
470
+ viewport: viewport.label,
471
+ theme,
472
+ image: result.image,
473
+ width: result.width,
474
+ height: result.height,
475
+ consoleErrors: result.consoleErrors
476
+ };
477
+ })
478
+ );
479
+ return results;
480
+ }
481
+ async function diffFromReference(code, framework, referenceImage, options = {}) {
482
+ const { threshold = 0.1 } = options;
483
+ const [renderResult] = await render(code, framework, {
484
+ ...options,
485
+ engines: ["chromium"]
486
+ });
487
+ const renderedBuf = Buffer.from(renderResult.image, "base64");
488
+ const referenceBuf = Buffer.from(referenceImage, "base64");
489
+ const renderedPng = PNG.sync.read(renderedBuf);
490
+ const referencePng = PNG.sync.read(referenceBuf);
491
+ const width = Math.max(renderedPng.width, referencePng.width);
492
+ const height = Math.max(renderedPng.height, referencePng.height);
493
+ const normalizedRendered = new PNG({ width, height });
494
+ const normalizedReference = new PNG({ width, height });
495
+ PNG.bitblt(
496
+ renderedPng,
497
+ normalizedRendered,
498
+ 0,
499
+ 0,
500
+ renderedPng.width,
501
+ renderedPng.height,
502
+ 0,
503
+ 0
504
+ );
505
+ PNG.bitblt(
506
+ referencePng,
507
+ normalizedReference,
508
+ 0,
509
+ 0,
510
+ referencePng.width,
511
+ referencePng.height,
512
+ 0,
513
+ 0
514
+ );
515
+ const diffPng = new PNG({ width, height });
516
+ const diffPixels = pixelmatch(
517
+ normalizedReference.data,
518
+ normalizedRendered.data,
519
+ diffPng.data,
520
+ width,
521
+ height,
522
+ { threshold }
523
+ );
524
+ const totalPixels = width * height;
525
+ const diffPercentage = Math.round(diffPixels / totalPixels * 1e4) / 100;
526
+ return {
527
+ rendered: renderResult.image,
528
+ diff: PNG.sync.write(diffPng).toString("base64"),
529
+ diffPixels,
530
+ totalPixels,
531
+ diffPercentage,
532
+ passed: diffPercentage === 0
533
+ };
534
+ }
535
+ async function renderCatalog(files, options = {}) {
536
+ const results = [];
537
+ for (const file of files) {
538
+ try {
539
+ const [result] = await render(file.code, file.framework, {
540
+ ...options,
541
+ engines: ["chromium"]
542
+ });
543
+ results.push({
544
+ path: file.path,
545
+ framework: file.framework,
546
+ image: result.image,
547
+ width: result.width,
548
+ height: result.height,
549
+ consoleErrors: result.consoleErrors
550
+ });
551
+ } catch {
552
+ results.push({
553
+ path: file.path,
554
+ framework: file.framework,
555
+ image: "",
556
+ width: 0,
557
+ height: 0,
558
+ consoleErrors: [`Failed to render ${file.path}`]
559
+ });
560
+ }
561
+ }
562
+ return results;
563
+ }
564
+ var snapshotStore = /* @__PURE__ */ new Map();
565
+ function saveSnapshot(key, image) {
566
+ snapshotStore.set(key, { image, timestamp: Date.now() });
567
+ }
568
+ function getSnapshot(key) {
569
+ return snapshotStore.get(key);
570
+ }
571
+ function listSnapshots() {
572
+ return Array.from(snapshotStore.entries()).map(([key, val]) => ({
573
+ key,
574
+ timestamp: val.timestamp
575
+ }));
576
+ }
577
+ async function shutdown() {
578
+ for (const slot of pool.values()) {
579
+ await slot.browser.close().catch(() => {
580
+ });
581
+ }
582
+ pool.clear();
583
+ }
584
+
585
+ export {
586
+ __export,
587
+ warmup,
588
+ render,
589
+ screenshotUrl,
590
+ auditA11y,
591
+ diffComponent,
592
+ captureAnimation,
593
+ renderWithInteractions,
594
+ renderGrid,
595
+ perfAudit,
596
+ renderMatrix,
597
+ diffFromReference,
598
+ renderCatalog,
599
+ saveSnapshot,
600
+ getSnapshot,
601
+ listSnapshots,
602
+ shutdown
603
+ };