frameshot-mcp 0.9.7 → 0.11.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.
@@ -10,7 +10,7 @@ import {
10
10
  SnapshotStore,
11
11
  SnapshotUseCase,
12
12
  ViteBundler
13
- } from "./chunk-MEBQ7ZWA.js";
13
+ } from "./chunk-FQ3BVCX7.js";
14
14
 
15
15
  // src/use-cases/watch.ts
16
16
  import { watch } from "chokidar";
@@ -264,6 +264,7 @@ var SnapshotStore = class {
264
264
  // src/infrastructure/project-detector.ts
265
265
  import { existsSync, readFileSync } from "fs";
266
266
  import { dirname, join, resolve } from "path";
267
+ import { parseTsconfig } from "get-tsconfig";
267
268
  var VITE_CONFIG_NAMES = [
268
269
  "vite.config.ts",
269
270
  "vite.config.js",
@@ -274,9 +275,10 @@ var ProjectDetector = class {
274
275
  detect(filePath) {
275
276
  const root = this.findProjectRoot(filePath);
276
277
  const viteConfigPath = this.findViteConfig(root);
277
- const framework = this.detectFramework(root);
278
+ const pkg = this.readPackageJson(root);
279
+ const framework = this.detectFramework(pkg);
278
280
  const hasVite = this.checkViteAvailable(root);
279
- const isNextJs = this.checkIsNextJs(root);
281
+ const isNextJs = this.checkIsNextJs(pkg);
280
282
  const pathAliases = this.readTsconfigAliases(root);
281
283
  return { root, viteConfigPath, framework, hasVite, isNextJs, pathAliases };
282
284
  }
@@ -299,48 +301,41 @@ var ProjectDetector = class {
299
301
  }
300
302
  return void 0;
301
303
  }
302
- detectFramework(root) {
304
+ readPackageJson(root) {
303
305
  const pkgPath = join(root, "package.json");
304
- if (!existsSync(pkgPath)) return "html";
306
+ if (!existsSync(pkgPath)) return null;
305
307
  try {
306
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
307
- const allDeps = {
308
- ...pkg.dependencies,
309
- ...pkg.devDependencies
310
- };
311
- if (allDeps["solid-js"]) return "solid";
312
- if (allDeps.preact) return "preact";
313
- if (allDeps.react || allDeps["react-dom"]) return "react";
314
- if (allDeps.vue) return "vue";
315
- if (allDeps.svelte) return "svelte";
308
+ return JSON.parse(readFileSync(pkgPath, "utf-8"));
316
309
  } catch {
310
+ return null;
317
311
  }
312
+ }
313
+ detectFramework(pkg) {
314
+ if (!pkg) return "html";
315
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
316
+ if (allDeps["solid-js"]) return "solid";
317
+ if (allDeps.preact) return "preact";
318
+ if (allDeps.react || allDeps["react-dom"]) return "react";
319
+ if (allDeps.vue) return "vue";
320
+ if (allDeps.svelte) return "svelte";
318
321
  return "html";
319
322
  }
320
323
  checkViteAvailable(root) {
321
324
  const vitePkgPath = join(root, "node_modules", "vite", "package.json");
322
325
  return existsSync(vitePkgPath);
323
326
  }
324
- checkIsNextJs(root) {
325
- const pkgPath = join(root, "package.json");
326
- if (!existsSync(pkgPath)) return false;
327
- try {
328
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
329
- const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
330
- return !!allDeps.next;
331
- } catch {
332
- return false;
333
- }
327
+ checkIsNextJs(pkg) {
328
+ if (!pkg) return false;
329
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
330
+ return !!allDeps.next;
334
331
  }
335
332
  readTsconfigAliases(root) {
336
333
  const tscPath = join(root, "tsconfig.json");
337
334
  if (!existsSync(tscPath)) return {};
338
335
  try {
339
- const content = readFileSync(tscPath, "utf-8");
340
- const stripped = content.replace(/^\s*\/\/[^\n]*/gm, "");
341
- const tsc = JSON.parse(stripped);
342
- const paths = tsc.compilerOptions?.paths ?? {};
343
- const baseUrl = tsc.compilerOptions?.baseUrl ?? ".";
336
+ const tsconfig = parseTsconfig(tscPath);
337
+ const paths = tsconfig.compilerOptions?.paths ?? {};
338
+ const baseUrl = tsconfig.compilerOptions?.baseUrl ?? ".";
344
339
  const result = {};
345
340
  for (const [alias, targets] of Object.entries(paths)) {
346
341
  const key = alias.replace(/\/\*$/, "");
@@ -1378,6 +1373,7 @@ import { readFileSync as readFileSync3 } from "fs";
1378
1373
  import { extname as extname2 } from "path";
1379
1374
 
1380
1375
  // src/infrastructure/page-utils.ts
1376
+ import picomatch from "picomatch";
1381
1377
  function attachConsoleCapture(page) {
1382
1378
  const errors = [];
1383
1379
  const onMessage = (msg) => {
@@ -1394,6 +1390,46 @@ function attachConsoleCapture(page) {
1394
1390
  }
1395
1391
  };
1396
1392
  }
1393
+ function normalizeResponse(value) {
1394
+ if (value && typeof value === "object" && "body" in value) {
1395
+ return value;
1396
+ }
1397
+ return { body: value };
1398
+ }
1399
+ async function attachMocks(page, mocks) {
1400
+ const matchers = Object.entries(mocks).map(([pattern, value]) => ({
1401
+ pattern,
1402
+ isMatch: pattern.startsWith("http") ? (url) => url === pattern : picomatch(pattern, { dot: true }),
1403
+ response: normalizeResponse(value)
1404
+ }));
1405
+ if (matchers.length === 0) return;
1406
+ await page.route("**/*", async (route) => {
1407
+ const url = route.request().url();
1408
+ const pathname = (() => {
1409
+ try {
1410
+ return new URL(url).pathname;
1411
+ } catch {
1412
+ return url;
1413
+ }
1414
+ })();
1415
+ const hit = matchers.find((m) => m.isMatch(url)) ?? matchers.find((m) => m.isMatch(pathname));
1416
+ if (!hit) {
1417
+ await route.continue();
1418
+ return;
1419
+ }
1420
+ const spec = hit.response;
1421
+ const isString = typeof spec.body === "string";
1422
+ const isBinary = spec.body instanceof Uint8Array || spec.body instanceof ArrayBuffer;
1423
+ const body = isString ? spec.body : isBinary ? Buffer.from(spec.body) : JSON.stringify(spec.body ?? null);
1424
+ const contentType = spec.contentType ?? (isString ? "text/plain" : "application/json");
1425
+ await route.fulfill({
1426
+ status: spec.status ?? 200,
1427
+ contentType,
1428
+ body,
1429
+ headers: spec.headers
1430
+ });
1431
+ });
1432
+ }
1397
1433
  async function takeScreenshot(page, engine, fullPage, errors, cleanup) {
1398
1434
  const screenshot = await page.screenshot({ type: "png", fullPage });
1399
1435
  const metrics = await page.evaluate(() => ({
@@ -1440,6 +1476,7 @@ var RenderUseCase = class {
1440
1476
  const opts = this.resolveOptions(options);
1441
1477
  const ext = extname2(filePath).toLowerCase();
1442
1478
  const framework = EXT_TO_FRAMEWORK[ext] ?? "react";
1479
+ let fallbackReason;
1443
1480
  if (this.viteBundler) {
1444
1481
  try {
1445
1482
  const { url } = await this.viteBundler.getUrl(filePath, {
@@ -1452,16 +1489,18 @@ var RenderUseCase = class {
1452
1489
  );
1453
1490
  return { results: results2, mode: "vite" };
1454
1491
  } catch (err) {
1455
- const msg = err instanceof Error ? err.message : String(err);
1492
+ fallbackReason = err instanceof Error ? err.message : String(err);
1456
1493
  process.stderr.write(
1457
- `[frameshot] Vite pipeline failed (${msg}), falling back to CDN
1494
+ `[frameshot] Vite pipeline failed (${fallbackReason}), falling back to CDN
1458
1495
  `
1459
1496
  );
1460
1497
  }
1498
+ } else {
1499
+ fallbackReason = "Vite bundler not available";
1461
1500
  }
1462
1501
  const code = readFileSync3(filePath, "utf-8");
1463
1502
  const results = await this.render(code, framework, options);
1464
- return { results, mode: "cdn" };
1503
+ return { results, mode: "cdn", fallbackReason };
1465
1504
  }
1466
1505
  async renderInteraction(code, framework, interactions, options = {}) {
1467
1506
  const opts = this.resolveOptions(options);
@@ -1578,6 +1617,7 @@ var RenderUseCase = class {
1578
1617
  const page = await this.pool.getPage(engine);
1579
1618
  await page.setViewportSize(options.viewport);
1580
1619
  const { errors, cleanup } = attachConsoleCapture(page);
1620
+ if (options.mock) await attachMocks(page, options.mock);
1581
1621
  await page.setContent(html, { waitUntil: "load", timeout: 1e4 });
1582
1622
  if (options.waitFor > 0) await page.waitForTimeout(options.waitFor);
1583
1623
  const result = await takeScreenshot(
@@ -1593,6 +1633,7 @@ var RenderUseCase = class {
1593
1633
  async renderUrl(engine, url, options) {
1594
1634
  const page = await this.pool.getPage(engine);
1595
1635
  const { errors, cleanup } = attachConsoleCapture(page);
1636
+ if (options.mock) await attachMocks(page, options.mock);
1596
1637
  const viewport = options.autoFit ? await this.resolveAutoFitViewport(page, url, options) : options.viewport;
1597
1638
  await page.setViewportSize(viewport);
1598
1639
  await this.navigateAndWait(page, url, options.waitFor);
package/dist/cli.js CHANGED
@@ -1,14 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  createContainer
4
- } from "./chunk-VUYZHZBH.js";
4
+ } from "./chunk-AF64XFKX.js";
5
5
  import {
6
6
  EXT_TO_FRAMEWORK
7
- } from "./chunk-MEBQ7ZWA.js";
7
+ } from "./chunk-FQ3BVCX7.js";
8
8
 
9
9
  // src/cli.ts
10
10
  import { mkdirSync } from "fs";
11
11
  import { resolve as resolve2 } from "path";
12
+ import mri from "mri";
12
13
 
13
14
  // src/cli/commands.ts
14
15
  import { execSync } from "child_process";
@@ -16,6 +17,7 @@ import { readFileSync, writeFileSync } from "fs";
16
17
  import { basename, extname, relative, resolve } from "path";
17
18
 
18
19
  // src/cli/display.ts
20
+ import { createSpinner } from "nanospinner";
19
21
  var c = {
20
22
  reset: "\x1B[0m",
21
23
  bold: "\x1B[1m",
@@ -50,18 +52,13 @@ function info(label, value) {
50
52
  log(` ${c.gray}${label.padEnd(6)}${c.reset} ${value}`);
51
53
  }
52
54
  function spinner(text) {
53
- let i = 0;
54
- const interval = setInterval(() => {
55
- process.stderr.write(
56
- `\r ${c.purple}${FRAMES[i % FRAMES.length]}${c.reset} ${c.dim}${text}${c.reset}`
57
- );
58
- i++;
59
- }, 80);
55
+ const s = createSpinner(`${c.dim}${text}${c.reset}`, {
56
+ color: "magenta",
57
+ frames: FRAMES
58
+ }).start();
60
59
  return {
61
60
  stop(finalText) {
62
- clearInterval(interval);
63
- process.stderr.write(`\r ${c.green}\u2713${c.reset} ${finalText}\x1B[K
64
- `);
61
+ s.success({ text: finalText });
65
62
  }
66
63
  };
67
64
  }
@@ -300,39 +297,27 @@ async function cmdDiff(useCase, filePath, flags, outDir) {
300
297
 
301
298
  // src/cli.ts
302
299
  function parseArgs(args) {
303
- const command = args[0] ?? "help";
304
- const positional = args[1] ?? "";
300
+ const parsed = mri(args, {
301
+ alias: { o: "out", r: "recursive", h: "help" },
302
+ boolean: ["recursive", "help"],
303
+ string: ["out", "props"],
304
+ default: { out: ".frameshot", recursive: false }
305
+ });
306
+ const [command = "help", positional = ""] = parsed._;
305
307
  const flags = {
306
- out: ".frameshot",
307
- recursive: false
308
+ out: parsed.out,
309
+ recursive: parsed.recursive,
310
+ width: parsed.width ? Number(parsed.width) : void 0,
311
+ height: parsed.height ? Number(parsed.height) : void 0
308
312
  };
309
- for (let i = 2; i < args.length; i++) {
310
- const arg = args[i];
311
- switch (arg) {
312
- case "--props":
313
- try {
314
- flags.props = JSON.parse(args[++i]);
315
- } catch {
316
- error("Invalid --props JSON");
317
- }
318
- break;
319
- case "--out":
320
- case "-o":
321
- flags.out = args[++i];
322
- break;
323
- case "--recursive":
324
- case "-r":
325
- flags.recursive = true;
326
- break;
327
- case "--width":
328
- flags.width = Number.parseInt(args[++i], 10);
329
- break;
330
- case "--height":
331
- flags.height = Number.parseInt(args[++i], 10);
332
- break;
313
+ if (parsed.props) {
314
+ try {
315
+ flags.props = JSON.parse(parsed.props);
316
+ } catch {
317
+ error("Invalid --props JSON");
333
318
  }
334
319
  }
335
- return { command, positional, flags };
320
+ return { command: String(command), positional: String(positional), flags };
336
321
  }
337
322
  var HELP = `
338
323
  ${brand()} ${c.dim}v0.7.0${c.reset}
@@ -362,7 +347,7 @@ var HELP = `
362
347
  `;
363
348
  async function main() {
364
349
  const { command, positional, flags } = parseArgs(process.argv.slice(2));
365
- if (command === "help" || command === "--help" || command === "-h") {
350
+ if (command === "help") {
366
351
  process.stdout.write(HELP);
367
352
  return;
368
353
  }
package/dist/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  createContainer
4
- } from "./chunk-VUYZHZBH.js";
4
+ } from "./chunk-AF64XFKX.js";
5
5
  import {
6
6
  DEVICE_PRESETS,
7
7
  EXT_TO_FRAMEWORK,
8
8
  __export
9
- } from "./chunk-MEBQ7ZWA.js";
9
+ } from "./chunk-FQ3BVCX7.js";
10
10
 
11
11
  // src/index.ts
12
12
  import { execSync } from "child_process";
@@ -14528,6 +14528,9 @@ function date4(params) {
14528
14528
  config(en_default());
14529
14529
 
14530
14530
  // src/tools/tool-utils.ts
14531
+ var mockSchema = external_exports.record(external_exports.string(), external_exports.unknown()).optional().describe(
14532
+ "Mock network responses. Keys: URL pattern (path '/api/users', glob '**/api/*', or full URL). Values: response body (auto-serialized as JSON) or { status, contentType, body, headers }."
14533
+ );
14531
14534
  function wrapHandler(handler) {
14532
14535
  return async (args) => {
14533
14536
  try {
@@ -14889,7 +14892,8 @@ function registerRenderFileTools(server2, useCase) {
14889
14892
  tailwindVersion: external_exports.enum(["3", "4"]).optional().default("3").describe("Tailwind CSS version (3 or 4)"),
14890
14893
  projectRoot: external_exports.string().optional().describe(
14891
14894
  "Project root directory override (defaults to auto-detect from file path)"
14892
- )
14895
+ ),
14896
+ mock: mockSchema
14893
14897
  },
14894
14898
  wrapHandler(
14895
14899
  async ({
@@ -14899,21 +14903,29 @@ function registerRenderFileTools(server2, useCase) {
14899
14903
  height,
14900
14904
  darkMode,
14901
14905
  tailwindVersion,
14902
- projectRoot
14906
+ projectRoot,
14907
+ mock
14903
14908
  }) => {
14904
14909
  const start = performance.now();
14905
- const { results, mode } = await useCase.renderFile(path, {
14906
- props,
14907
- viewport: { width, height },
14908
- fullPage: true,
14909
- engines: ["chromium"],
14910
- darkMode,
14911
- tailwindVersion,
14912
- projectRoot
14913
- });
14910
+ const { results, mode, fallbackReason } = await useCase.renderFile(
14911
+ path,
14912
+ {
14913
+ props,
14914
+ viewport: { width, height },
14915
+ fullPage: true,
14916
+ engines: ["chromium"],
14917
+ darkMode,
14918
+ tailwindVersion,
14919
+ projectRoot,
14920
+ mock
14921
+ }
14922
+ );
14914
14923
  const elapsed = Math.round(performance.now() - start);
14915
14924
  const ext = extname(path).toLowerCase();
14916
14925
  const framework = EXT_TO_FRAMEWORK[ext] ?? "react";
14926
+ const fallbackNote = mode === "cdn" && fallbackReason ? `
14927
+ \u2139\uFE0F Fell back to CDN pipeline: ${fallbackReason}
14928
+ Imports from node_modules may not resolve. To fix: install vite as a devDep and add a minimal vite.config.` : "";
14917
14929
  const content = results.flatMap((r) => [
14918
14930
  {
14919
14931
  type: "image",
@@ -14924,7 +14936,7 @@ function registerRenderFileTools(server2, useCase) {
14924
14936
  type: "text",
14925
14937
  text: `[${mode}/${framework}] ${r.width}x${r.height} (${elapsed}ms)${r.consoleErrors.length ? `
14926
14938
  \u26A0\uFE0F Console errors:
14927
- ${r.consoleErrors.join("\n")}` : ""}`
14939
+ ${r.consoleErrors.join("\n")}` : ""}${fallbackNote}`
14928
14940
  }
14929
14941
  ]);
14930
14942
  return { content };
@@ -14948,7 +14960,8 @@ ${r.consoleErrors.join("\n")}` : ""}`
14948
14960
  'Render both: ["light","dark"] returns 2 screenshots for comparison'
14949
14961
  ),
14950
14962
  css: external_exports.string().optional().describe("Custom CSS to inject (design tokens, variables, etc)"),
14951
- tailwindVersion: external_exports.enum(["3", "4"]).optional().default("3").describe("Tailwind CSS version (3 or 4)")
14963
+ tailwindVersion: external_exports.enum(["3", "4"]).optional().default("3").describe("Tailwind CSS version (3 or 4)"),
14964
+ mock: mockSchema
14952
14965
  },
14953
14966
  wrapHandler(
14954
14967
  async ({
@@ -14961,7 +14974,8 @@ ${r.consoleErrors.join("\n")}` : ""}`
14961
14974
  darkMode,
14962
14975
  colorSchemes,
14963
14976
  css,
14964
- tailwindVersion
14977
+ tailwindVersion,
14978
+ mock
14965
14979
  }) => {
14966
14980
  const start = performance.now();
14967
14981
  const schemes = colorSchemes ?? (darkMode ? ["dark"] : ["light"]);
@@ -14973,7 +14987,8 @@ ${r.consoleErrors.join("\n")}` : ""}`
14973
14987
  engines,
14974
14988
  darkMode: scheme === "dark",
14975
14989
  css,
14976
- tailwindVersion
14990
+ tailwindVersion,
14991
+ mock
14977
14992
  })
14978
14993
  )
14979
14994
  );
@@ -15560,21 +15575,9 @@ function registerAllTools(server2, useCases) {
15560
15575
 
15561
15576
  // src/index.ts
15562
15577
  var container = createContainer();
15563
- async function ensureBrowser() {
15564
- try {
15565
- await container.pool.warmup(["chromium"]);
15566
- } catch {
15567
- try {
15568
- execSync("npx playwright install chromium", { stdio: "pipe" });
15569
- await container.pool.warmup(["chromium"]);
15570
- } catch {
15571
- }
15572
- }
15573
- }
15574
- await ensureBrowser();
15575
15578
  var server = new McpServer({
15576
15579
  name: "frameshot",
15577
- version: "0.8.0"
15580
+ version: "0.10.0"
15578
15581
  });
15579
15582
  registerAllTools(server, {
15580
15583
  render: container.renderUseCase,
@@ -15587,6 +15590,18 @@ registerAllTools(server, {
15587
15590
  });
15588
15591
  var transport = new StdioServerTransport();
15589
15592
  await server.connect(transport);
15593
+ void (async () => {
15594
+ try {
15595
+ await container.pool.warmup(["chromium"]);
15596
+ } catch {
15597
+ try {
15598
+ execSync("npx playwright install chromium", { stdio: "pipe" });
15599
+ await container.pool.warmup(["chromium"]).catch(() => {
15600
+ });
15601
+ } catch {
15602
+ }
15603
+ }
15604
+ })();
15590
15605
  process.on("SIGINT", async () => {
15591
15606
  await container.shutdown();
15592
15607
  process.exit(0);
@@ -17,6 +17,19 @@ interface RenderOptions {
17
17
  tailwindVersion: "3" | "4";
18
18
  waitFor: number;
19
19
  autoFit: boolean;
20
+ /**
21
+ * Mock network requests. Keys are URL patterns (path like `/api/users`,
22
+ * glob like `**\/api/*`, or full URLs); values are the response body
23
+ * (auto-serialized as JSON) or a {@link MockResponse} for full control.
24
+ *
25
+ * @example
26
+ * mock: {
27
+ * '/api/users': [{ id: 1, name: 'Alice' }],
28
+ * '/api/posts/*': { status: 200, body: { data: [] } },
29
+ * 'https://api.example.com/x': 'plain text body',
30
+ * }
31
+ */
32
+ mock?: Record<string, unknown>;
20
33
  }
21
34
  interface ScreenshotResult {
22
35
  engine: Engine;
@@ -150,6 +163,7 @@ declare class ProjectDetector {
150
163
  detect(filePath: string): ProjectConfig;
151
164
  private findProjectRoot;
152
165
  private findViteConfig;
166
+ private readPackageJson;
153
167
  private detectFramework;
154
168
  private checkViteAvailable;
155
169
  private checkIsNextJs;
@@ -204,6 +218,8 @@ declare class RenderUseCase {
204
218
  renderFile(filePath: string, options?: Partial<FileRenderOptions>): Promise<{
205
219
  results: ScreenshotResult[];
206
220
  mode: "vite" | "cdn";
221
+ /** If we fell back to CDN, the reason the Vite pipeline was skipped. */
222
+ fallbackReason?: string;
207
223
  }>;
208
224
  renderInteraction(code: string, framework: Framework, interactions: Interaction[], options?: Partial<RenderOptions>): Promise<{
209
225
  image: string;
package/dist/renderer.js CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  SnapshotStore,
14
14
  SnapshotUseCase,
15
15
  ViteBundler
16
- } from "./chunk-MEBQ7ZWA.js";
16
+ } from "./chunk-FQ3BVCX7.js";
17
17
  export {
18
18
  AuditUseCase,
19
19
  BrowserPool,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frameshot-mcp",
3
- "version": "0.9.7",
3
+ "version": "0.11.0",
4
4
  "description": "Zero-config visual testing for AI agents. Render project components with full Vite dependency resolution — no stories, no config.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -72,6 +72,11 @@
72
72
  "@vitejs/plugin-vue": "^6.0.7",
73
73
  "axe-core": "^4.12.1",
74
74
  "chokidar": "^5.0.0",
75
+ "get-tsconfig": "^4.14.0",
76
+ "mri": "^1.2.0",
77
+ "nanospinner": "^1.2.2",
78
+ "picocolors": "^1.1.1",
79
+ "picomatch": "^4.0.4",
75
80
  "pixelmatch": "^7.2.0",
76
81
  "playwright": "^1.52.0",
77
82
  "pngjs": "^7.0.0",
@@ -88,6 +93,7 @@
88
93
  "devDependencies": {
89
94
  "@biomejs/biome": "^2.5.0",
90
95
  "@types/node": "^26.0.0",
96
+ "@types/picomatch": "^4.0.3",
91
97
  "@types/pngjs": "^6.0.5",
92
98
  "tsup": "^8.0.0",
93
99
  "typescript": "^5.7.0",
@@ -3,6 +3,7 @@
3
3
  import { execSync } from "node:child_process";
4
4
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
5
5
  import { basename, extname, join, relative } from "node:path";
6
+ import picomatch from "picomatch";
6
7
 
7
8
  const {
8
9
  INPUT_PATHS = "",
@@ -26,14 +27,13 @@ const COMPONENT_EXTS = new Set(
26
27
  INPUT_EXTENSIONS.split(",").map((e) => e.trim().toLowerCase()).filter(Boolean),
27
28
  );
28
29
 
29
- const EXCLUDE_PATTERNS = INPUT_EXCLUDE.split(",")
30
+ // Compile exclude globs once via picomatch — handles `*`, `**`, `?`, `[abc]`,
31
+ // brace expansion, and negation properly. Match against the file basename.
32
+ const excludeGlobs = INPUT_EXCLUDE.split(",")
30
33
  .map((p) => p.trim())
31
- .filter(Boolean)
32
- .map((p) => {
33
- // Convert glob pattern to regex: *.test.* /[^/]*\.test\.[^/]*/
34
- const escaped = p.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*");
35
- return new RegExp(escaped);
36
- });
34
+ .filter(Boolean);
35
+ const isExcludedGlob =
36
+ excludeGlobs.length > 0 ? picomatch(excludeGlobs) : () => false;
37
37
 
38
38
  /**
39
39
  * Derive a human-friendly display label from a file path.
@@ -80,8 +80,7 @@ function safeFileId(label) {
80
80
  }
81
81
 
82
82
  function isExcluded(filePath) {
83
- const base = filePath.split("/").pop() ?? "";
84
- return EXCLUDE_PATTERNS.some((re) => re.test(base));
83
+ return isExcludedGlob(basename(filePath));
85
84
  }
86
85
 
87
86
  function findFiles(workspace, patterns) {
@@ -260,12 +259,17 @@ async function main() {
260
259
  try {
261
260
  // Render current (after) version
262
261
  console.log(` Rendering (after): ${filePath}`);
263
- const { results: afterResults, mode } = await renderUseCase.renderFile(
264
- filePath,
265
- renderOpts,
266
- );
262
+ const {
263
+ results: afterResults,
264
+ mode,
265
+ fallbackReason,
266
+ } = await renderUseCase.renderFile(filePath, renderOpts);
267
267
  const afterResult = afterResults[0];
268
268
 
269
+ if (mode === "cdn" && fallbackReason) {
270
+ console.log(` ℹ️ CDN fallback: ${fallbackReason}`);
271
+ }
272
+
269
273
  const afterPath = join(outputDir, `${safeName}_after.png`);
270
274
  writeFileSync(afterPath, Buffer.from(afterResult.image, "base64"));
271
275