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.
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/cli.js ADDED
@@ -0,0 +1,407 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ BrowserPool,
4
+ CatalogUseCase,
5
+ DiffUseCase,
6
+ EXT_TO_FRAMEWORK,
7
+ HtmlBuilder,
8
+ ImageComparator,
9
+ RenderUseCase,
10
+ ViteBundler
11
+ } from "./chunk-Q7A3DLED.js";
12
+
13
+ // src/cli.ts
14
+ import { execFileSync, execSync } from "child_process";
15
+ import { mkdirSync, readFileSync, writeFileSync } from "fs";
16
+ import { basename, extname, relative, resolve } from "path";
17
+ var c = {
18
+ reset: "\x1B[0m",
19
+ bold: "\x1B[1m",
20
+ dim: "\x1B[2m",
21
+ italic: "\x1B[3m",
22
+ purple: "\x1B[38;2;139;92;246m",
23
+ violet: "\x1B[38;2;167;139;250m",
24
+ blue: "\x1B[38;2;96;165;250m",
25
+ cyan: "\x1B[38;2;103;232;249m",
26
+ green: "\x1B[38;2;74;222;128m",
27
+ red: "\x1B[38;2;248;113;113m",
28
+ yellow: "\x1B[38;2;250;204;21m",
29
+ gray: "\x1B[38;2;113;113;122m",
30
+ white: "\x1B[38;2;250;250;250m",
31
+ bgPurple: "\x1B[48;2;139;92;246m"
32
+ };
33
+ var FRAMES = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
34
+ function brand() {
35
+ return `${c.purple}${c.bold}\u25C6${c.reset} ${c.purple}${c.bold}frameshot${c.reset}`;
36
+ }
37
+ function log(msg) {
38
+ process.stderr.write(`${msg}
39
+ `);
40
+ }
41
+ function error(msg) {
42
+ log(`
43
+ ${c.red}${c.bold}\u2716${c.reset} ${c.red}${msg}${c.reset}
44
+ `);
45
+ process.exit(1);
46
+ }
47
+ function info(label, value) {
48
+ log(` ${c.gray}${label.padEnd(6)}${c.reset} ${value}`);
49
+ }
50
+ function spinner(text) {
51
+ let i = 0;
52
+ const interval = setInterval(() => {
53
+ process.stderr.write(
54
+ `\r ${c.purple}${FRAMES[i % FRAMES.length]}${c.reset} ${c.dim}${text}${c.reset}`
55
+ );
56
+ i++;
57
+ }, 80);
58
+ return {
59
+ stop(finalText) {
60
+ clearInterval(interval);
61
+ process.stderr.write(`\r ${c.green}\u2713${c.reset} ${finalText}\x1B[K
62
+ `);
63
+ }
64
+ };
65
+ }
66
+ function separator() {
67
+ log(` ${c.gray}${"\u2500".repeat(48)}${c.reset}`);
68
+ }
69
+ function supportsItermImage() {
70
+ const term = process.env.TERM_PROGRAM ?? "";
71
+ return term === "iTerm.app" || term === "WezTerm" || !!process.env.TERM_PROGRAM_VERSION?.includes("iTerm") || term === "vscode";
72
+ }
73
+ function supportsKitty() {
74
+ const term = process.env.TERM ?? "";
75
+ return term.includes("kitty") || !!process.env.KITTY_PID;
76
+ }
77
+ function centerPad(imageWidthCols) {
78
+ const termWidth = process.stdout.columns ?? 80;
79
+ const leftPad = Math.max(0, Math.floor((termWidth - imageWidthCols) / 2));
80
+ return " ".repeat(leftPad);
81
+ }
82
+ function displayImage(base64, filePath) {
83
+ if (process.env.FRAMESHOT_NO_IMAGE) return;
84
+ const IMAGE_COLS = 40;
85
+ const pad = centerPad(IMAGE_COLS);
86
+ if (supportsItermImage()) {
87
+ const osc = `\x1B]1337;File=inline=1;width=${IMAGE_COLS};preserveAspectRatio=1:${base64}\x07`;
88
+ process.stdout.write(`${pad}${osc}
89
+ `);
90
+ return;
91
+ }
92
+ if (supportsKitty()) {
93
+ try {
94
+ const output = execFileSync(
95
+ "chafa",
96
+ ["--format=kitty", "--size=40x", filePath],
97
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
98
+ );
99
+ const indented = output.split("\n").map((l) => l ? pad + l : l).join("\n");
100
+ process.stdout.write(`${indented}
101
+ `);
102
+ return;
103
+ } catch {
104
+ }
105
+ }
106
+ try {
107
+ const output = execFileSync(
108
+ "chafa",
109
+ ["--format=sixels", "--size=40x", filePath],
110
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
111
+ );
112
+ const indented = output.split("\n").map((l) => l ? pad + l : l).join("\n");
113
+ process.stdout.write(`${indented}
114
+ `);
115
+ return;
116
+ } catch {
117
+ }
118
+ try {
119
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
120
+ execFileSync(cmd, [filePath], { stdio: "pipe" });
121
+ } catch {
122
+ }
123
+ }
124
+ function parseArgs(args) {
125
+ const command = args[0] ?? "help";
126
+ const positional = args[1] ?? "";
127
+ const flags = {
128
+ out: ".frameshot",
129
+ recursive: false
130
+ };
131
+ for (let i = 2; i < args.length; i++) {
132
+ const arg = args[i];
133
+ switch (arg) {
134
+ case "--props":
135
+ try {
136
+ flags.props = JSON.parse(args[++i]);
137
+ } catch {
138
+ error("Invalid --props JSON");
139
+ }
140
+ break;
141
+ case "--out":
142
+ case "-o":
143
+ flags.out = args[++i];
144
+ break;
145
+ case "--recursive":
146
+ case "-r":
147
+ flags.recursive = true;
148
+ break;
149
+ case "--width":
150
+ flags.width = Number.parseInt(args[++i], 10);
151
+ break;
152
+ case "--height":
153
+ flags.height = Number.parseInt(args[++i], 10);
154
+ break;
155
+ }
156
+ }
157
+ return { command, positional, flags };
158
+ }
159
+ var HELP = `
160
+ ${brand()} ${c.dim}v0.7.0${c.reset}
161
+ ${c.dim}Zero-config component screenshots \u2014 Vite + Playwright${c.reset}
162
+
163
+ ${c.white}${c.bold}Commands${c.reset}
164
+ ${c.blue}render${c.reset} ${c.dim}<file>${c.reset} Render a component to PNG
165
+ ${c.blue}catalog${c.reset} ${c.dim}<dir>${c.reset} Render all components in a directory
166
+ ${c.blue}diff${c.reset} ${c.dim}<file>${c.reset} Visual diff against git HEAD
167
+
168
+ ${c.white}${c.bold}Options${c.reset}
169
+ ${c.purple}--props${c.reset} ${c.dim}'{...}'${c.reset} Component props (JSON)
170
+ ${c.purple}--out${c.reset}, ${c.purple}-o${c.reset} ${c.dim}<dir>${c.reset} Output directory ${c.gray}[.frameshot]${c.reset}
171
+ ${c.purple}--recursive${c.reset}, ${c.purple}-r${c.reset} Scan subdirectories
172
+ ${c.purple}--width${c.reset} ${c.dim}<px>${c.reset} Viewport width ${c.gray}[auto]${c.reset}
173
+ ${c.purple}--height${c.reset} ${c.dim}<px>${c.reset} Viewport height ${c.gray}[auto]${c.reset}
174
+
175
+ ${c.white}${c.bold}Examples${c.reset}
176
+ ${c.gray}$${c.reset} frameshot render src/Button.tsx
177
+ ${c.gray}$${c.reset} frameshot render src/Card.tsx ${c.purple}--props${c.reset} '{"title":"Hi"}'
178
+ ${c.gray}$${c.reset} frameshot catalog src/components/ ${c.purple}-r${c.reset}
179
+ ${c.gray}$${c.reset} frameshot diff src/Header.tsx
180
+
181
+ ${c.dim}Supports: React \xB7 Vue \xB7 Svelte \xB7 Tailwind \xB7 CSS Modules${c.reset}
182
+ ${c.dim}Display: iTerm2 \xB7 Kitty \xB7 Sixel \xB7 fallback to open${c.reset}
183
+
184
+ `;
185
+ async function main() {
186
+ const { command, positional, flags } = parseArgs(process.argv.slice(2));
187
+ if (command === "help" || command === "--help" || command === "-h") {
188
+ process.stdout.write(HELP);
189
+ return;
190
+ }
191
+ if (!positional) {
192
+ error(
193
+ `Missing argument. Run ${c.dim}frameshot --help${c.reset} for usage.`
194
+ );
195
+ }
196
+ log(`
197
+ ${brand()} ${c.dim}v0.7.0${c.reset}
198
+ `);
199
+ const pool = new BrowserPool();
200
+ const htmlBuilder = new HtmlBuilder();
201
+ const imageComparator = new ImageComparator();
202
+ const viteBundler = new ViteBundler();
203
+ const renderUseCase = new RenderUseCase(
204
+ pool,
205
+ htmlBuilder,
206
+ imageComparator,
207
+ viteBundler
208
+ );
209
+ const catalogUseCase = new CatalogUseCase(renderUseCase);
210
+ const diffUseCase = new DiffUseCase(renderUseCase, imageComparator);
211
+ const outDir = resolve(flags.out);
212
+ mkdirSync(outDir, { recursive: true });
213
+ try {
214
+ await pool.warmup(["chromium"]);
215
+ switch (command) {
216
+ case "render":
217
+ await cmdRender(renderUseCase, positional, flags, outDir);
218
+ break;
219
+ case "catalog":
220
+ await cmdCatalog(catalogUseCase, positional, flags, outDir);
221
+ break;
222
+ case "diff":
223
+ await cmdDiff(diffUseCase, positional, flags, outDir);
224
+ break;
225
+ default:
226
+ error(
227
+ `Unknown command '${command}'. Run ${c.dim}frameshot --help${c.reset} for usage.`
228
+ );
229
+ }
230
+ } finally {
231
+ await viteBundler.shutdown();
232
+ await pool.shutdown();
233
+ }
234
+ log("");
235
+ }
236
+ async function cmdRender(useCase, filePath, flags, outDir) {
237
+ const absPath = resolve(filePath);
238
+ const relPath = relative(process.cwd(), absPath);
239
+ info("file", `${c.white}${relPath}${c.reset}`);
240
+ if (flags.width !== void 0 && flags.height !== void 0) {
241
+ info("size", `${c.cyan}${flags.width}\xD7${flags.height}${c.reset}`);
242
+ } else {
243
+ info("size", `${c.dim}auto${c.reset}`);
244
+ }
245
+ if (flags.props) {
246
+ info("props", `${c.dim}${JSON.stringify(flags.props)}${c.reset}`);
247
+ }
248
+ log("");
249
+ separator();
250
+ log("");
251
+ const autoFit = flags.width === void 0 || flags.height === void 0;
252
+ const s = spinner("Bundling with Vite & rendering...");
253
+ const start = performance.now();
254
+ const { results, mode } = await useCase.renderFile(absPath, {
255
+ props: flags.props,
256
+ viewport: {
257
+ width: flags.width ?? 1280,
258
+ height: flags.height ?? 800
259
+ },
260
+ autoFit,
261
+ fullPage: true,
262
+ engines: ["chromium"]
263
+ });
264
+ const result = results[0];
265
+ const elapsed = Math.round(performance.now() - start);
266
+ const outFile = resolve(
267
+ outDir,
268
+ `${basename(filePath, extname(filePath))}.png`
269
+ );
270
+ writeFileSync(outFile, Buffer.from(result.image, "base64"));
271
+ s.stop(`Rendered in ${c.white}${c.bold}${elapsed}ms${c.reset}`);
272
+ log("");
273
+ displayImage(result.image, outFile);
274
+ separator();
275
+ log("");
276
+ log(
277
+ ` ${c.green}${c.bold}\u25CF${c.reset} ${c.white}${relative(process.cwd(), outFile)}${c.reset}`
278
+ );
279
+ log(
280
+ ` ${c.dim}${result.width}\xD7${result.height}px \xB7 ${mode} pipeline \xB7 chromium${c.reset}`
281
+ );
282
+ const relevantErrors = result.consoleErrors.filter(
283
+ (e) => !e.includes("Failed to load resource")
284
+ );
285
+ if (relevantErrors.length) {
286
+ log(`
287
+ ${c.yellow}\u26A0${c.reset} ${relevantErrors.join(`
288
+ `)}`);
289
+ }
290
+ }
291
+ async function cmdCatalog(useCase, dirPath, flags, outDir) {
292
+ const absDir = resolve(dirPath);
293
+ const relDir = relative(process.cwd(), absDir);
294
+ const start = performance.now();
295
+ info("dir", `${c.white}${relDir}/${c.reset}`);
296
+ info("mode", `${c.purple}vite${c.reset}`);
297
+ if (flags.recursive) info("scan", `${c.dim}recursive${c.reset}`);
298
+ log("");
299
+ separator();
300
+ log("");
301
+ const s = spinner("Scanning & rendering components...");
302
+ const results = await useCase.renderCatalog(absDir, {
303
+ recursive: flags.recursive,
304
+ viewport: { width: flags.width ?? 1280, height: flags.height ?? 800 },
305
+ autoFit: flags.width === void 0 || flags.height === void 0
306
+ });
307
+ if (results.length === 0) {
308
+ s.stop(`${c.dim}No component files found.${c.reset}`);
309
+ return;
310
+ }
311
+ s.stop(`Found ${c.white}${c.bold}${results.length}${c.reset} components`);
312
+ log("");
313
+ let rendered = 0;
314
+ for (const entry of results) {
315
+ const name = basename(entry.path, extname(entry.path));
316
+ if (!entry.image) {
317
+ log(` ${c.red}\u2716${c.reset} ${c.dim}${name}${c.reset}`);
318
+ continue;
319
+ }
320
+ const outFile = resolve(outDir, `${name}.png`);
321
+ writeFileSync(outFile, Buffer.from(entry.image, "base64"));
322
+ log(` ${c.green}\u25CF${c.reset} ${c.white}${name}${c.reset}`);
323
+ rendered++;
324
+ }
325
+ const elapsed = Math.round(performance.now() - start);
326
+ log("");
327
+ separator();
328
+ log("");
329
+ log(
330
+ ` ${c.green}${c.bold}${rendered}${c.reset}${c.dim}/${results.length} rendered in ${c.white}${elapsed}ms${c.dim} \u2192 ${relative(process.cwd(), outDir)}/${c.reset}`
331
+ );
332
+ }
333
+ async function cmdDiff(useCase, filePath, flags, outDir) {
334
+ const absPath = resolve(filePath);
335
+ const relPath = relative(process.cwd(), absPath);
336
+ info("file", `${c.white}${relPath}${c.reset}`);
337
+ info("base", `${c.violet}HEAD${c.reset}`);
338
+ log("");
339
+ separator();
340
+ log("");
341
+ const s = spinner("Comparing against HEAD...");
342
+ const start = performance.now();
343
+ let baseCode;
344
+ try {
345
+ const gitRelPath = relative(
346
+ execSync("git rev-parse --show-toplevel", { encoding: "utf-8" }).trim(),
347
+ absPath
348
+ );
349
+ baseCode = execSync(`git show HEAD:${gitRelPath}`, { encoding: "utf-8" });
350
+ } catch {
351
+ s.stop(`${c.red}Failed${c.reset}`);
352
+ error(
353
+ `Could not get HEAD version of ${relPath}. Is this file tracked by git?`
354
+ );
355
+ }
356
+ const currentCode = readFileSync(absPath, "utf-8");
357
+ const ext = extname(filePath).toLowerCase();
358
+ const framework = EXT_TO_FRAMEWORK[ext] ?? "react";
359
+ const result = await useCase.diffComponent(baseCode, currentCode, framework, {
360
+ viewport: { width: flags.width ?? 1280, height: flags.height ?? 800 },
361
+ autoFit: flags.width === void 0 || flags.height === void 0
362
+ });
363
+ const elapsed = Math.round(performance.now() - start);
364
+ const name = basename(filePath, extname(filePath));
365
+ writeFileSync(
366
+ resolve(outDir, `${name}_before.png`),
367
+ Buffer.from(result.before, "base64")
368
+ );
369
+ writeFileSync(
370
+ resolve(outDir, `${name}_after.png`),
371
+ Buffer.from(result.after, "base64")
372
+ );
373
+ writeFileSync(
374
+ resolve(outDir, `${name}_diff.png`),
375
+ Buffer.from(result.diff, "base64")
376
+ );
377
+ s.stop(`Compared in ${c.white}${c.bold}${elapsed}ms${c.reset}`);
378
+ log("");
379
+ displayImage(result.after, resolve(outDir, `${name}_after.png`));
380
+ separator();
381
+ log("");
382
+ if (result.diffPercentage === 0) {
383
+ log(
384
+ ` ${c.green}${c.bold}\u25CF${c.reset} ${c.green}Identical${c.reset} ${c.dim}\u2014 no visual changes detected${c.reset}`
385
+ );
386
+ } else {
387
+ const pct = result.diffPercentage.toFixed(1);
388
+ const bar = renderBar(result.diffPercentage);
389
+ log(
390
+ ` ${c.yellow}${c.bold}\u25CF${c.reset} ${c.yellow}${pct}% changed${c.reset}`
391
+ );
392
+ log(` ${bar}`);
393
+ log("");
394
+ log(
395
+ ` ${c.dim}${name}_before.png \xB7 ${name}_after.png \xB7 ${name}_diff.png${c.reset}`
396
+ );
397
+ }
398
+ }
399
+ function renderBar(pct) {
400
+ const width = 30;
401
+ const filled = Math.round(pct / 100 * width);
402
+ const empty = width - filled;
403
+ return `${c.yellow}${"\u2588".repeat(filled)}${c.gray}${"\u2591".repeat(empty)}${c.reset} ${c.dim}${pct.toFixed(1)}%${c.reset}`;
404
+ }
405
+ main().catch((e) => {
406
+ error(e.message ?? String(e));
407
+ });