afterbefore 0.1.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.js ADDED
@@ -0,0 +1,1150 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/logger.ts
7
+ import chalk from "chalk";
8
+ import ora from "ora";
9
+ var Logger = class {
10
+ spinner = null;
11
+ info(message) {
12
+ this.clearSpinner();
13
+ console.log(chalk.blue("\u2139"), message);
14
+ }
15
+ success(message) {
16
+ this.clearSpinner();
17
+ console.log(chalk.green("\u2714"), message);
18
+ }
19
+ warn(message) {
20
+ this.clearSpinner();
21
+ console.log(chalk.yellow("\u26A0"), message);
22
+ }
23
+ error(message) {
24
+ this.clearSpinner();
25
+ console.error(chalk.red("\u2716"), message);
26
+ }
27
+ dim(message) {
28
+ this.clearSpinner();
29
+ console.log(chalk.dim(message));
30
+ }
31
+ spin(message) {
32
+ this.clearSpinner();
33
+ this.spinner = ora(message).start();
34
+ return this.spinner;
35
+ }
36
+ stopSpinner() {
37
+ this.clearSpinner();
38
+ }
39
+ clearSpinner() {
40
+ if (this.spinner) {
41
+ this.spinner.stop();
42
+ this.spinner = null;
43
+ }
44
+ }
45
+ };
46
+ var logger = new Logger();
47
+
48
+ // src/utils/git.ts
49
+ import { execSync } from "child_process";
50
+ function git(args, cwd) {
51
+ return execSync(`git ${args}`, {
52
+ cwd,
53
+ encoding: "utf-8",
54
+ stdio: ["pipe", "pipe", "pipe"]
55
+ }).trim();
56
+ }
57
+ function isGitRepo(cwd) {
58
+ try {
59
+ git("rev-parse --is-inside-work-tree", cwd);
60
+ return true;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+ function getMergeBase(base, cwd) {
66
+ return git(`merge-base ${base} HEAD`, cwd);
67
+ }
68
+ function getDiffNameStatus(base, cwd) {
69
+ const mergeBase = getMergeBase(base, cwd);
70
+ return git(`diff --name-status ${mergeBase}`, cwd);
71
+ }
72
+
73
+ // src/pipeline.ts
74
+ import { resolve as resolve2 } from "path";
75
+
76
+ // src/cleanup.ts
77
+ var CleanupRegistry = class {
78
+ cleanups = [];
79
+ registered = false;
80
+ register(fn) {
81
+ this.cleanups.push(fn);
82
+ this.ensureSignalHandlers();
83
+ }
84
+ async runAll() {
85
+ const fns = [...this.cleanups].reverse();
86
+ this.cleanups = [];
87
+ for (const fn of fns) {
88
+ try {
89
+ await fn();
90
+ } catch (err) {
91
+ logger.warn(
92
+ `Cleanup failed: ${err instanceof Error ? err.message : String(err)}`
93
+ );
94
+ }
95
+ }
96
+ }
97
+ ensureSignalHandlers() {
98
+ if (this.registered) return;
99
+ this.registered = true;
100
+ const handler = async (signal) => {
101
+ logger.dim(`
102
+ Received ${signal}, cleaning up...`);
103
+ await this.runAll();
104
+ process.exit(signal === "SIGINT" ? 130 : 143);
105
+ };
106
+ process.on("SIGINT", () => handler("SIGINT"));
107
+ process.on("SIGTERM", () => handler("SIGTERM"));
108
+ process.on("exit", () => {
109
+ if (this.cleanups.length > 0) {
110
+ for (const fn of [...this.cleanups].reverse()) {
111
+ try {
112
+ fn();
113
+ } catch {
114
+ }
115
+ }
116
+ this.cleanups = [];
117
+ }
118
+ });
119
+ }
120
+ };
121
+ var cleanupRegistry = new CleanupRegistry();
122
+
123
+ // src/utils/fs.ts
124
+ import { mkdir } from "fs/promises";
125
+ async function ensureDir(dir) {
126
+ await mkdir(dir, { recursive: true });
127
+ }
128
+
129
+ // src/utils/port.ts
130
+ import { createServer } from "net";
131
+ function findPort() {
132
+ return new Promise((resolve3, reject) => {
133
+ const server = createServer();
134
+ server.listen(0, () => {
135
+ const addr = server.address();
136
+ if (!addr || typeof addr === "string") {
137
+ server.close();
138
+ reject(new Error("Failed to get port"));
139
+ return;
140
+ }
141
+ const port = addr.port;
142
+ server.close(() => resolve3(port));
143
+ });
144
+ server.on("error", reject);
145
+ });
146
+ }
147
+ async function findAvailablePort(exclude) {
148
+ for (let i = 0; i < 5; i++) {
149
+ const port = await findPort();
150
+ if (!exclude || !exclude.has(port)) return port;
151
+ }
152
+ throw new Error("Failed to find available port after 5 attempts");
153
+ }
154
+
155
+ // src/stages/diff.ts
156
+ var VALID_STATUSES = /* @__PURE__ */ new Set(["A", "M", "D", "R", "C"]);
157
+ function parseDiffOutput(raw) {
158
+ if (!raw.trim()) return [];
159
+ const results = [];
160
+ for (const line of raw.trim().split("\n")) {
161
+ const parts = line.split(" ");
162
+ const statusRaw = parts[0].charAt(0);
163
+ if (!VALID_STATUSES.has(statusRaw)) continue;
164
+ const status = statusRaw;
165
+ if (status === "R" || status === "C") {
166
+ results.push({ status, oldPath: parts[1], path: parts[2] });
167
+ } else {
168
+ results.push({ status, path: parts[1] });
169
+ }
170
+ }
171
+ return results;
172
+ }
173
+ function getChangedFiles(base, cwd) {
174
+ const raw = getDiffNameStatus(base, cwd);
175
+ return parseDiffOutput(raw);
176
+ }
177
+
178
+ // src/stages/classify.ts
179
+ var VISUAL_CATEGORIES = /* @__PURE__ */ new Set([
180
+ "page",
181
+ "component",
182
+ "style",
183
+ "layout"
184
+ ]);
185
+ function classifyFile(filePath) {
186
+ const p = filePath.replace(/^src\//, "");
187
+ if (/\.(test|spec)\.[tj]sx?$/.test(p) || /^tests?\//.test(p) || /\/__tests__\//.test(p) || p.includes(".test.") || p.includes(".spec.")) {
188
+ return "test";
189
+ }
190
+ if (/^(tsconfig|next\.config|tailwind\.config|postcss\.config|\.eslint|\.prettier|vitest\.config|jest\.config)/.test(
191
+ p
192
+ ) || p === "package.json" || p === "package-lock.json" || p.endsWith(".config.ts") || p.endsWith(".config.js") || p.endsWith(".config.mjs")) {
193
+ return "config";
194
+ }
195
+ if (/\.(css|scss|sass|less)$/.test(p) || p === "tailwind.config.ts") {
196
+ return "style";
197
+ }
198
+ if (/\/layout\.[tj]sx?$/.test(p) || /^app\/layout\.[tj]sx?$/.test(p)) {
199
+ return "layout";
200
+ }
201
+ if (/\/page\.[tj]sx?$/.test(p) || /^app\/page\.[tj]sx?$/.test(p)) {
202
+ return "page";
203
+ }
204
+ if (/^(components|app)\//.test(p) && /\.[tj]sx?$/.test(p)) {
205
+ return "component";
206
+ }
207
+ if (/^(lib|utils|hooks|helpers|services|api)\//.test(p) && /\.[tj]sx?$/.test(p)) {
208
+ return "utility";
209
+ }
210
+ if (/\.[tj]sx?$/.test(p)) {
211
+ return "component";
212
+ }
213
+ return "other";
214
+ }
215
+ function classifyFiles(files) {
216
+ return files.map((f) => ({
217
+ ...f,
218
+ category: classifyFile(f.path)
219
+ }));
220
+ }
221
+ function filterVisuallyRelevant(files) {
222
+ return files.filter((f) => VISUAL_CATEGORIES.has(f.category));
223
+ }
224
+
225
+ // src/stages/graph.ts
226
+ import { readdirSync, readFileSync as readFileSync2 } from "fs";
227
+ import { join as join2, relative } from "path";
228
+ import { init, parse } from "es-module-lexer";
229
+
230
+ // src/stages/resolve.ts
231
+ import { existsSync, readFileSync, statSync } from "fs";
232
+ import { resolve, dirname, join } from "path";
233
+ var EXTENSIONS = [".ts", ".tsx", ".js", ".jsx"];
234
+ function createResolver(projectRoot) {
235
+ const mappings = loadPathMappings(projectRoot);
236
+ const existsCache = /* @__PURE__ */ new Map();
237
+ function cachedExists(p) {
238
+ const cached = existsCache.get(p);
239
+ if (cached !== void 0) return cached;
240
+ const result = existsSync(p);
241
+ existsCache.set(p, result);
242
+ return result;
243
+ }
244
+ function tryResolve(candidate) {
245
+ if (cachedExists(candidate) && !isDirectory(candidate)) return candidate;
246
+ for (const ext of EXTENSIONS) {
247
+ const withExt = candidate + ext;
248
+ if (cachedExists(withExt)) return withExt;
249
+ }
250
+ for (const ext of EXTENSIONS) {
251
+ const indexFile = join(candidate, `index${ext}`);
252
+ if (cachedExists(indexFile)) return indexFile;
253
+ }
254
+ return null;
255
+ }
256
+ function isDirectory(p) {
257
+ try {
258
+ return statSync(p).isDirectory();
259
+ } catch {
260
+ return false;
261
+ }
262
+ }
263
+ return (specifier, fromFile) => {
264
+ if (specifier.startsWith(".")) {
265
+ const dir = dirname(fromFile);
266
+ const candidate = resolve(dir, specifier);
267
+ return tryResolve(candidate);
268
+ }
269
+ for (const mapping of mappings) {
270
+ if (!specifier.startsWith(mapping.prefix)) continue;
271
+ const rest = specifier.slice(mapping.prefix.length);
272
+ for (const target of mapping.targets) {
273
+ const candidate = resolve(projectRoot, target + rest);
274
+ const result = tryResolve(candidate);
275
+ if (result) return result;
276
+ }
277
+ }
278
+ return null;
279
+ };
280
+ }
281
+ function loadPathMappings(projectRoot) {
282
+ const tsconfigPath = join(projectRoot, "tsconfig.json");
283
+ if (!existsSync(tsconfigPath)) {
284
+ logger.dim("No tsconfig.json found, skipping path alias resolution");
285
+ return [];
286
+ }
287
+ try {
288
+ const raw = readFileSync(tsconfigPath, "utf-8");
289
+ const cleaned = raw.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
290
+ const config = JSON.parse(cleaned);
291
+ const paths = config?.compilerOptions?.paths;
292
+ if (!paths) return [];
293
+ const baseUrl = config?.compilerOptions?.baseUrl || ".";
294
+ const mappings = [];
295
+ for (const [pattern, targets] of Object.entries(paths)) {
296
+ const prefix = pattern.replace(/\*$/, "");
297
+ const resolvedTargets = targets.map(
298
+ (t) => t.replace(/\*$/, "")
299
+ );
300
+ const absoluteTargets = resolvedTargets.map(
301
+ (t) => join(baseUrl, t)
302
+ );
303
+ mappings.push({ prefix, targets: absoluteTargets });
304
+ }
305
+ return mappings;
306
+ } catch (e) {
307
+ logger.warn(`Failed to parse tsconfig.json: ${e}`);
308
+ return [];
309
+ }
310
+ }
311
+
312
+ // src/stages/graph.ts
313
+ var SOURCE_EXTENSIONS = /* @__PURE__ */ new Set([".ts", ".tsx", ".js", ".jsx"]);
314
+ var SOURCE_DIRS = ["app", "src", "components", "lib"];
315
+ function collectFiles(dir) {
316
+ const results = [];
317
+ let entries;
318
+ try {
319
+ entries = readdirSync(dir, { withFileTypes: true });
320
+ } catch {
321
+ return results;
322
+ }
323
+ for (const entry of entries) {
324
+ const fullPath = join2(dir, entry.name);
325
+ if (entry.isDirectory()) {
326
+ if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "dist") {
327
+ continue;
328
+ }
329
+ results.push(...collectFiles(fullPath));
330
+ } else if (entry.isFile()) {
331
+ const ext = entry.name.slice(entry.name.lastIndexOf("."));
332
+ if (SOURCE_EXTENSIONS.has(ext)) {
333
+ results.push(fullPath);
334
+ }
335
+ }
336
+ }
337
+ return results;
338
+ }
339
+ function parseImports(filePath) {
340
+ let source;
341
+ try {
342
+ source = readFileSync2(filePath, "utf-8");
343
+ } catch {
344
+ return [];
345
+ }
346
+ try {
347
+ const cleaned = source.replace(/import\s+type\s+/g, "import ").replace(/export\s+type\s+/g, "export ");
348
+ const [imports] = parse(cleaned);
349
+ return imports.map((imp) => imp.n).filter((n) => n !== void 0 && n !== "");
350
+ } catch {
351
+ const specifiers = [];
352
+ const importRegex = /(?:import|from)\s+["']([^"']+)["']/g;
353
+ let match;
354
+ while ((match = importRegex.exec(source)) !== null) {
355
+ specifiers.push(match[1]);
356
+ }
357
+ return specifiers;
358
+ }
359
+ }
360
+ async function buildImportGraph(projectRoot) {
361
+ await init;
362
+ const resolve3 = createResolver(projectRoot);
363
+ const allFiles = [];
364
+ for (const dir of SOURCE_DIRS) {
365
+ const fullDir = join2(projectRoot, dir);
366
+ allFiles.push(...collectFiles(fullDir));
367
+ }
368
+ const fileSet = new Set(allFiles);
369
+ const forward = /* @__PURE__ */ new Map();
370
+ const reverse = /* @__PURE__ */ new Map();
371
+ for (const filePath of fileSet) {
372
+ const relPath = relative(projectRoot, filePath);
373
+ const specifiers = parseImports(filePath);
374
+ const deps = /* @__PURE__ */ new Set();
375
+ for (const spec of specifiers) {
376
+ const resolved = resolve3(spec, filePath);
377
+ if (!resolved) continue;
378
+ const relResolved = relative(projectRoot, resolved);
379
+ deps.add(relResolved);
380
+ if (!reverse.has(relResolved)) {
381
+ reverse.set(relResolved, /* @__PURE__ */ new Set());
382
+ }
383
+ reverse.get(relResolved).add(relPath);
384
+ }
385
+ forward.set(relPath, deps);
386
+ }
387
+ logger.dim(`Import graph: ${fileSet.size} files, ${countEdges(forward)} edges`);
388
+ return { forward, reverse };
389
+ }
390
+ function countEdges(forward) {
391
+ let count = 0;
392
+ for (const deps of forward.values()) {
393
+ count += deps.size;
394
+ }
395
+ return count;
396
+ }
397
+
398
+ // src/utils/nextjs.ts
399
+ function pagePathToRoute(pagePath) {
400
+ let p = pagePath.replace(/^src\//, "");
401
+ const match = p.match(/^app\/(.*)\/page\.[tj]sx?$/);
402
+ if (!match) {
403
+ if (/^app\/page\.[tj]sx?$/.test(p)) return "/";
404
+ return null;
405
+ }
406
+ let route = match[1];
407
+ if (/\[.*\]/.test(route)) {
408
+ logger.warn(`Skipping dynamic route: /${route} (dynamic segments not supported)`);
409
+ return null;
410
+ }
411
+ route = route.split("/").filter((seg) => !seg.startsWith("(")).join("/");
412
+ return `/${route}` || "/";
413
+ }
414
+ function isPageFile(filePath) {
415
+ const p = filePath.replace(/^src\//, "");
416
+ return /^app\/(.+\/)?page\.[tj]sx?$/.test(p);
417
+ }
418
+ function isLayoutFile(filePath) {
419
+ const p = filePath.replace(/^src\//, "");
420
+ return /^app\/(.+\/)?layout\.[tj]sx?$/.test(p);
421
+ }
422
+ function getLayoutDir(filePath) {
423
+ const p = filePath.replace(/^src\//, "");
424
+ const match = p.match(/^(app\/.*\/)layout\.[tj]sx?$/);
425
+ if (!match) return "app/";
426
+ return match[1];
427
+ }
428
+
429
+ // src/stages/impact.ts
430
+ var MAX_DEPTH = 3;
431
+ function findAffectedRoutes(changedFiles, graph, projectRoot) {
432
+ const routeMap = /* @__PURE__ */ new Map();
433
+ for (const file of changedFiles) {
434
+ const visited = /* @__PURE__ */ new Set();
435
+ const queue = [
436
+ { path: file, depth: 0 }
437
+ ];
438
+ visited.add(file);
439
+ while (queue.length > 0) {
440
+ const { path, depth } = queue.shift();
441
+ if (isPageFile(path)) {
442
+ const route = pagePathToRoute(path);
443
+ if (route !== null && !routeMap.has(route)) {
444
+ routeMap.set(route, {
445
+ pagePath: path,
446
+ route,
447
+ reason: depth === 0 ? "direct" : "transitive",
448
+ depth
449
+ });
450
+ }
451
+ }
452
+ if (depth >= MAX_DEPTH) continue;
453
+ const importers = graph.reverse.get(path);
454
+ if (!importers) continue;
455
+ for (const importer of importers) {
456
+ if (visited.has(importer)) continue;
457
+ visited.add(importer);
458
+ queue.push({ path: importer, depth: depth + 1 });
459
+ }
460
+ }
461
+ }
462
+ for (const file of changedFiles) {
463
+ if (!isLayoutFile(file)) continue;
464
+ const layoutDir = getLayoutDir(file);
465
+ for (const knownFile of graph.forward.keys()) {
466
+ if (knownFile.startsWith(layoutDir) && isPageFile(knownFile)) {
467
+ const route = pagePathToRoute(knownFile);
468
+ if (route !== null && !routeMap.has(route)) {
469
+ routeMap.set(route, {
470
+ pagePath: knownFile,
471
+ route,
472
+ reason: "transitive",
473
+ depth: 1
474
+ });
475
+ }
476
+ }
477
+ }
478
+ }
479
+ const routes = Array.from(routeMap.values());
480
+ logger.dim(`Found ${routes.length} affected route(s)`);
481
+ return routes;
482
+ }
483
+
484
+ // src/stages/worktree.ts
485
+ import { execSync as execSync2 } from "child_process";
486
+ import { rm, mkdtemp } from "fs/promises";
487
+ import { join as join4 } from "path";
488
+ import { tmpdir } from "os";
489
+
490
+ // src/utils/pm.ts
491
+ import { existsSync as existsSync2 } from "fs";
492
+ import { join as join3 } from "path";
493
+ function detectPackageManager(dir) {
494
+ if (existsSync2(join3(dir, "bun.lockb")) || existsSync2(join3(dir, "bun.lock")))
495
+ return "bun";
496
+ if (existsSync2(join3(dir, "pnpm-lock.yaml"))) return "pnpm";
497
+ if (existsSync2(join3(dir, "yarn.lock"))) return "yarn";
498
+ return "npm";
499
+ }
500
+ function pmExec(pm) {
501
+ switch (pm) {
502
+ case "bun":
503
+ return "bunx";
504
+ case "pnpm":
505
+ return "pnpm exec";
506
+ case "yarn":
507
+ return "yarn";
508
+ default:
509
+ return "npx";
510
+ }
511
+ }
512
+
513
+ // src/stages/worktree.ts
514
+ async function createWorktree(base, cwd) {
515
+ const worktreeDir = await mkdtemp(join4(tmpdir(), "afterbefore-wt-"));
516
+ logger.info(`Creating worktree for ${base} at ${worktreeDir}`);
517
+ execSync2(`git worktree add "${worktreeDir}" "${base}"`, {
518
+ cwd,
519
+ stdio: ["pipe", "pipe", "pipe"]
520
+ });
521
+ const pm = detectPackageManager(cwd);
522
+ logger.info(`Installing dependencies with ${pm}`);
523
+ execSync2(`${pm} install`, {
524
+ cwd: worktreeDir,
525
+ stdio: ["pipe", "pipe", "pipe"]
526
+ });
527
+ const cleanup = async () => {
528
+ logger.dim(`Cleaning up worktree at ${worktreeDir}`);
529
+ try {
530
+ execSync2(`git worktree remove --force "${worktreeDir}"`, {
531
+ cwd,
532
+ stdio: ["pipe", "pipe", "pipe"]
533
+ });
534
+ } catch {
535
+ try {
536
+ await rm(worktreeDir, { recursive: true, force: true });
537
+ execSync2("git worktree prune", {
538
+ cwd,
539
+ stdio: ["pipe", "pipe", "pipe"]
540
+ });
541
+ } catch {
542
+ logger.warn(`Failed to clean up worktree at ${worktreeDir}`);
543
+ }
544
+ }
545
+ };
546
+ cleanupRegistry.register(cleanup);
547
+ return { path: worktreeDir, ref: base, cleanup };
548
+ }
549
+
550
+ // src/stages/server.ts
551
+ import { spawn } from "child_process";
552
+ function waitForServer(url, timeoutMs) {
553
+ const start = Date.now();
554
+ return new Promise((resolve3, reject) => {
555
+ const poll = async () => {
556
+ if (Date.now() - start > timeoutMs) {
557
+ reject(
558
+ new Error(
559
+ `Server at ${url} did not respond within ${timeoutMs / 1e3}s. Make sure 'next dev' starts correctly in your project.`
560
+ )
561
+ );
562
+ return;
563
+ }
564
+ try {
565
+ await fetch(url);
566
+ resolve3();
567
+ } catch {
568
+ setTimeout(poll, 500);
569
+ }
570
+ };
571
+ poll();
572
+ });
573
+ }
574
+ async function startServer(projectDir, port) {
575
+ const url = `http://localhost:${port}`;
576
+ const pm = detectPackageManager(projectDir);
577
+ const exec = pmExec(pm);
578
+ const [cmd, ...baseArgs] = exec.split(" ");
579
+ logger.info(`Starting Next.js dev server on ${url} (using ${pm})`);
580
+ const child = spawn(cmd, [...baseArgs, "next", "dev", "-p", String(port)], {
581
+ cwd: projectDir,
582
+ stdio: ["pipe", "pipe", "pipe"],
583
+ detached: false
584
+ });
585
+ child.stderr?.on("data", (data) => {
586
+ const msg = data.toString().trim();
587
+ if (msg) logger.dim(`[next:${port}] ${msg}`);
588
+ });
589
+ await waitForServer(url, 6e4);
590
+ logger.success(`Server ready at ${url}`);
591
+ return { port, process: child, url };
592
+ }
593
+ async function stopServer(server) {
594
+ logger.dim(`Stopping server on port ${server.port}`);
595
+ server.process.kill("SIGTERM");
596
+ await new Promise((resolve3) => {
597
+ const timeout = setTimeout(() => {
598
+ server.process.kill("SIGKILL");
599
+ resolve3();
600
+ }, 5e3);
601
+ server.process.on("exit", () => {
602
+ clearTimeout(timeout);
603
+ resolve3();
604
+ });
605
+ });
606
+ }
607
+
608
+ // src/stages/capture.ts
609
+ import { join as join5 } from "path";
610
+ import { chromium } from "playwright";
611
+ function routeToDir(route) {
612
+ if (route === "/") return "_root";
613
+ return route.replace(/^\//, "").replace(/\//g, "_");
614
+ }
615
+ async function captureRoutes(routes, beforeUrl, afterUrl, outputDir) {
616
+ logger.info(`Capturing ${routes.length} route(s)`);
617
+ const browser = await chromium.launch();
618
+ const results = [];
619
+ try {
620
+ const context = await browser.newContext({
621
+ viewport: { width: 1280, height: 720 }
622
+ });
623
+ const page = await context.newPage();
624
+ for (const route of routes) {
625
+ const dirName = routeToDir(route);
626
+ const routeDir = join5(outputDir, dirName);
627
+ await ensureDir(routeDir);
628
+ const beforePath = join5(routeDir, "before.png");
629
+ const afterPath = join5(routeDir, "after.png");
630
+ logger.dim(`Capturing before: ${route}`);
631
+ await page.goto(`${beforeUrl}${route}`, { waitUntil: "networkidle" });
632
+ await page.waitForTimeout(500);
633
+ await page.screenshot({ path: beforePath, fullPage: true });
634
+ logger.dim(`Capturing after: ${route}`);
635
+ await page.goto(`${afterUrl}${route}`, { waitUntil: "networkidle" });
636
+ await page.waitForTimeout(500);
637
+ await page.screenshot({ path: afterPath, fullPage: true });
638
+ results.push({ route, beforePath, afterPath });
639
+ logger.success(`Captured ${route}`);
640
+ }
641
+ await context.close();
642
+ } finally {
643
+ await browser.close();
644
+ }
645
+ return results;
646
+ }
647
+
648
+ // src/stages/compare.ts
649
+ import { readFileSync as readFileSync3 } from "fs";
650
+ import { writeFileSync } from "fs";
651
+ import { join as join6, dirname as dirname2 } from "path";
652
+ import { PNG } from "pngjs";
653
+ import pixelmatch from "pixelmatch";
654
+ import sharp from "sharp";
655
+ function readPng(filePath) {
656
+ const buffer = readFileSync3(filePath);
657
+ return PNG.sync.read(buffer);
658
+ }
659
+ function normalizeDimensions(img1, img2) {
660
+ const width = Math.max(img1.width, img2.width);
661
+ const height = Math.max(img1.height, img2.height);
662
+ const pad = (src) => {
663
+ if (src.width === width && src.height === height) return src;
664
+ const padded = new PNG({ width, height });
665
+ for (let i = 0; i < padded.data.length; i += 4) {
666
+ padded.data[i] = 255;
667
+ padded.data[i + 1] = 255;
668
+ padded.data[i + 2] = 255;
669
+ padded.data[i + 3] = 255;
670
+ }
671
+ PNG.bitblt(src, padded, 0, 0, src.width, src.height, 0, 0);
672
+ return padded;
673
+ };
674
+ return [pad(img1), pad(img2)];
675
+ }
676
+ async function generateSideBySide(beforePath, afterPath, outputPath) {
677
+ const labelHeight = 32;
678
+ const gap = 4;
679
+ const beforeImg = sharp(beforePath);
680
+ const afterImg = sharp(afterPath);
681
+ const [beforeMeta, afterMeta] = await Promise.all([
682
+ beforeImg.metadata(),
683
+ afterImg.metadata()
684
+ ]);
685
+ const bw = beforeMeta.width;
686
+ const bh = beforeMeta.height;
687
+ const aw = afterMeta.width;
688
+ const ah = afterMeta.height;
689
+ const totalWidth = bw + gap + aw;
690
+ const totalHeight = labelHeight + Math.max(bh, ah);
691
+ const labelSvg = (text, w) => Buffer.from(
692
+ `<svg width="${w}" height="${labelHeight}">
693
+ <rect width="${w}" height="${labelHeight}" fill="#f3f4f6"/>
694
+ <text x="${w / 2}" y="${labelHeight / 2 + 5}" text-anchor="middle"
695
+ font-family="sans-serif" font-size="14" font-weight="bold" fill="#374151">${text}</text>
696
+ </svg>`
697
+ );
698
+ const beforeLabel = await sharp(labelSvg("Before", bw)).png().toBuffer();
699
+ const afterLabel = await sharp(labelSvg("After", aw)).png().toBuffer();
700
+ const beforeWithLabel = await sharp({
701
+ create: {
702
+ width: bw,
703
+ height: labelHeight + bh,
704
+ channels: 4,
705
+ background: { r: 255, g: 255, b: 255, alpha: 1 }
706
+ }
707
+ }).composite([
708
+ { input: beforeLabel, top: 0, left: 0 },
709
+ { input: await sharp(beforePath).png().toBuffer(), top: labelHeight, left: 0 }
710
+ ]).png().toBuffer();
711
+ const afterWithLabel = await sharp({
712
+ create: {
713
+ width: aw,
714
+ height: labelHeight + ah,
715
+ channels: 4,
716
+ background: { r: 255, g: 255, b: 255, alpha: 1 }
717
+ }
718
+ }).composite([
719
+ { input: afterLabel, top: 0, left: 0 },
720
+ { input: await sharp(afterPath).png().toBuffer(), top: labelHeight, left: 0 }
721
+ ]).png().toBuffer();
722
+ await sharp({
723
+ create: {
724
+ width: totalWidth,
725
+ height: totalHeight,
726
+ channels: 4,
727
+ background: { r: 229, g: 231, b: 235, alpha: 1 }
728
+ }
729
+ }).composite([
730
+ { input: beforeWithLabel, top: 0, left: 0 },
731
+ { input: afterWithLabel, top: 0, left: bw + gap }
732
+ ]).png().toFile(outputPath);
733
+ }
734
+ async function compareScreenshots(captures, outputDir, threshold = 0.1) {
735
+ logger.info(`Comparing ${captures.length} route(s)`);
736
+ const results = [];
737
+ for (const capture of captures) {
738
+ const routeDir = dirname2(capture.beforePath);
739
+ const diffPath = join6(routeDir, "diff.png");
740
+ const sideBySidePath = join6(routeDir, "side-by-side.png");
741
+ const sliderPath = join6(routeDir, "slider.html");
742
+ await ensureDir(routeDir);
743
+ const beforeImg = readPng(capture.beforePath);
744
+ const afterImg = readPng(capture.afterPath);
745
+ const [normBefore, normAfter] = normalizeDimensions(beforeImg, afterImg);
746
+ const { width, height } = normBefore;
747
+ const totalPixels = width * height;
748
+ const diffImg = new PNG({ width, height });
749
+ const diffPixels = pixelmatch(
750
+ normBefore.data,
751
+ normAfter.data,
752
+ diffImg.data,
753
+ width,
754
+ height,
755
+ { threshold: 0.1 }
756
+ );
757
+ writeFileSync(diffPath, PNG.sync.write(diffImg));
758
+ await generateSideBySide(
759
+ capture.beforePath,
760
+ capture.afterPath,
761
+ sideBySidePath
762
+ );
763
+ const diffPercentage = diffPixels / totalPixels * 100;
764
+ const changed = diffPercentage > threshold;
765
+ if (changed) {
766
+ logger.warn(
767
+ `${capture.route}: ${diffPercentage.toFixed(2)}% changed (${diffPixels} pixels)`
768
+ );
769
+ } else {
770
+ logger.success(`${capture.route}: no visual changes`);
771
+ }
772
+ results.push({
773
+ route: capture.route,
774
+ beforePath: capture.beforePath,
775
+ afterPath: capture.afterPath,
776
+ diffPath,
777
+ sideBySidePath,
778
+ sliderPath,
779
+ diffPixels,
780
+ totalPixels,
781
+ diffPercentage,
782
+ changed
783
+ });
784
+ }
785
+ return results;
786
+ }
787
+
788
+ // src/stages/report.ts
789
+ import { writeFileSync as writeFileSync2 } from "fs";
790
+ import { join as join7 } from "path";
791
+ import { execSync as execSync3 } from "child_process";
792
+
793
+ // src/templates/report.html.ts
794
+ import { readFileSync as readFileSync4 } from "fs";
795
+ function toBase64(filePath) {
796
+ return readFileSync4(filePath).toString("base64");
797
+ }
798
+ function imgSrc(filePath) {
799
+ return `data:image/png;base64,${toBase64(filePath)}`;
800
+ }
801
+ function generateReportHtml(results) {
802
+ const changed = results.filter((r) => r.changed);
803
+ const unchanged = results.filter((r) => !r.changed);
804
+ const card = (r) => `
805
+ <div class="card ${r.changed ? "changed" : "unchanged"}">
806
+ <div class="card-header">
807
+ <span class="route">${r.route}</span>
808
+ <span class="badge ${r.changed ? "badge-changed" : "badge-unchanged"}">
809
+ ${r.changed ? `${r.diffPercentage.toFixed(2)}% changed` : "No change"}
810
+ </span>
811
+ </div>
812
+ <div class="images">
813
+ <div class="img-col">
814
+ <div class="label">Before</div>
815
+ <img src="${imgSrc(r.beforePath)}" alt="Before" />
816
+ </div>
817
+ <div class="img-col">
818
+ <div class="label">After</div>
819
+ <img src="${imgSrc(r.afterPath)}" alt="After" />
820
+ </div>
821
+ <div class="img-col">
822
+ <div class="label">Diff</div>
823
+ <img src="${imgSrc(r.diffPath)}" alt="Diff" />
824
+ </div>
825
+ </div>
826
+ ${r.changed ? `<a class="slider-link" href="${r.sliderPath}">Open slider view</a>` : ""}
827
+ </div>`;
828
+ return `<!DOCTYPE html>
829
+ <html lang="en">
830
+ <head>
831
+ <meta charset="utf-8" />
832
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
833
+ <title>afterbefore Report</title>
834
+ <style>
835
+ * { box-sizing: border-box; margin: 0; padding: 0; }
836
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f9fafb; color: #111827; padding: 24px; }
837
+ h1 { font-size: 24px; margin-bottom: 8px; }
838
+ .summary { color: #6b7280; margin-bottom: 24px; font-size: 14px; }
839
+ .grid { display: grid; grid-template-columns: 1fr; gap: 24px; }
840
+ .card { background: #fff; border-radius: 8px; border: 1px solid #e5e7eb; overflow: hidden; }
841
+ .card.changed { border-color: #fbbf24; }
842
+ .card-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-bottom: 1px solid #e5e7eb; }
843
+ .route { font-weight: 600; font-size: 16px; font-family: monospace; }
844
+ .badge { font-size: 12px; padding: 2px 8px; border-radius: 9999px; font-weight: 500; }
845
+ .badge-changed { background: #fef3c7; color: #92400e; }
846
+ .badge-unchanged { background: #d1fae5; color: #065f46; }
847
+ .images { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1px; background: #e5e7eb; }
848
+ .img-col { background: #fff; padding: 8px; }
849
+ .img-col img { width: 100%; height: auto; display: block; border-radius: 4px; }
850
+ .label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: #6b7280; margin-bottom: 4px; font-weight: 600; }
851
+ .slider-link { display: block; padding: 8px 16px; text-align: center; font-size: 13px; color: #2563eb; text-decoration: none; border-top: 1px solid #e5e7eb; }
852
+ .slider-link:hover { background: #eff6ff; }
853
+ .section-title { font-size: 18px; font-weight: 600; margin: 24px 0 12px; }
854
+ </style>
855
+ </head>
856
+ <body>
857
+ <h1>afterbefore Report</h1>
858
+ <p class="summary">${results.length} route(s) captured, ${changed.length} with visual changes.</p>
859
+
860
+ ${changed.length > 0 ? `<h2 class="section-title">Changed (${changed.length})</h2><div class="grid">${changed.map(card).join("")}</div>` : ""}
861
+ ${unchanged.length > 0 ? `<h2 class="section-title">Unchanged (${unchanged.length})</h2><div class="grid">${unchanged.map(card).join("")}</div>` : ""}
862
+
863
+ </body>
864
+ </html>`;
865
+ }
866
+
867
+ // src/templates/slider.html.ts
868
+ import { readFileSync as readFileSync5 } from "fs";
869
+ function toBase642(filePath) {
870
+ return readFileSync5(filePath).toString("base64");
871
+ }
872
+ function imgSrc2(filePath) {
873
+ return `data:image/png;base64,${toBase642(filePath)}`;
874
+ }
875
+ function generateSliderHtml(result) {
876
+ return `<!DOCTYPE html>
877
+ <html lang="en">
878
+ <head>
879
+ <meta charset="utf-8" />
880
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
881
+ <title>afterbefore Slider - ${result.route}</title>
882
+ <style>
883
+ * { box-sizing: border-box; margin: 0; padding: 0; }
884
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f9fafb; color: #111827; padding: 24px; }
885
+ h1 { font-size: 20px; margin-bottom: 4px; }
886
+ .meta { color: #6b7280; font-size: 13px; margin-bottom: 16px; }
887
+ .container { position: relative; overflow: hidden; border-radius: 8px; border: 1px solid #e5e7eb; user-select: none; cursor: ew-resize; }
888
+ .container img { display: block; width: 100%; height: auto; }
889
+ .before-wrap { position: absolute; top: 0; left: 0; height: 100%; overflow: hidden; }
890
+ .before-wrap img { display: block; height: 100%; width: auto; min-width: 100%; object-fit: cover; }
891
+ .slider-line { position: absolute; top: 0; width: 3px; height: 100%; background: #2563eb; cursor: ew-resize; z-index: 10; }
892
+ .slider-handle { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 36px; height: 36px; background: #2563eb; border-radius: 50%; border: 3px solid #fff; box-shadow: 0 2px 8px rgba(0,0,0,0.3); display: flex; align-items: center; justify-content: center; }
893
+ .slider-handle::before { content: '\\2194'; color: #fff; font-size: 18px; }
894
+ .labels { display: flex; justify-content: space-between; margin-top: 8px; font-size: 12px; color: #6b7280; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
895
+ </style>
896
+ </head>
897
+ <body>
898
+ <h1>${result.route}</h1>
899
+ <p class="meta">${result.diffPercentage.toFixed(2)}% changed (${result.diffPixels.toLocaleString()} pixels)</p>
900
+
901
+ <div class="container" id="slider">
902
+ <img src="${imgSrc2(result.afterPath)}" alt="After" draggable="false" />
903
+ <div class="before-wrap" id="beforeWrap">
904
+ <img src="${imgSrc2(result.beforePath)}" alt="Before" draggable="false" />
905
+ </div>
906
+ <div class="slider-line" id="sliderLine">
907
+ <div class="slider-handle"></div>
908
+ </div>
909
+ </div>
910
+ <div class="labels"><span>Before</span><span>After</span></div>
911
+
912
+ <script>
913
+ (function() {
914
+ const container = document.getElementById('slider');
915
+ const beforeWrap = document.getElementById('beforeWrap');
916
+ const sliderLine = document.getElementById('sliderLine');
917
+ let dragging = false;
918
+
919
+ function setPosition(x) {
920
+ const rect = container.getBoundingClientRect();
921
+ let pct = ((x - rect.left) / rect.width) * 100;
922
+ pct = Math.max(0, Math.min(100, pct));
923
+ beforeWrap.style.width = pct + '%';
924
+ sliderLine.style.left = pct + '%';
925
+ }
926
+
927
+ // Start at 50%
928
+ beforeWrap.style.width = '50%';
929
+ sliderLine.style.left = '50%';
930
+
931
+ container.addEventListener('mousedown', function(e) { dragging = true; setPosition(e.clientX); });
932
+ window.addEventListener('mousemove', function(e) { if (dragging) setPosition(e.clientX); });
933
+ window.addEventListener('mouseup', function() { dragging = false; });
934
+
935
+ container.addEventListener('touchstart', function(e) { dragging = true; setPosition(e.touches[0].clientX); }, { passive: true });
936
+ window.addEventListener('touchmove', function(e) { if (dragging) setPosition(e.touches[0].clientX); }, { passive: true });
937
+ window.addEventListener('touchend', function() { dragging = false; });
938
+ })();
939
+ </script>
940
+ </body>
941
+ </html>`;
942
+ }
943
+
944
+ // src/templates/summary.md.ts
945
+ function generateSummaryMd(results) {
946
+ const changed = results.filter((r) => r.changed);
947
+ const lines = [];
948
+ lines.push("<!-- afterbefore -->");
949
+ lines.push("");
950
+ lines.push("## afterbefore Report");
951
+ lines.push("");
952
+ if (changed.length === 0) {
953
+ lines.push("No visual changes detected.");
954
+ lines.push("");
955
+ lines.push(`${results.length} route(s) captured, all unchanged.`);
956
+ return lines.join("\n");
957
+ }
958
+ lines.push(
959
+ `${results.length} route(s) captured, **${changed.length}** with visual changes.`
960
+ );
961
+ lines.push("");
962
+ lines.push("| Route | Diff % | Status |");
963
+ lines.push("|-------|--------|--------|");
964
+ for (const r of results) {
965
+ const status = r.changed ? "Changed" : "Unchanged";
966
+ const pct = r.changed ? `${r.diffPercentage.toFixed(2)}%` : "0%";
967
+ lines.push(`| \`${r.route}\` | ${pct} | ${status} |`);
968
+ }
969
+ return lines.join("\n");
970
+ }
971
+
972
+ // src/stages/report.ts
973
+ var COMMENT_MARKER = "<!-- afterbefore -->";
974
+ function findPrNumber() {
975
+ try {
976
+ const output = execSync3("gh pr view --json number -q .number", {
977
+ encoding: "utf-8",
978
+ stdio: ["pipe", "pipe", "pipe"]
979
+ }).trim();
980
+ return output || null;
981
+ } catch {
982
+ return null;
983
+ }
984
+ }
985
+ function findExistingCommentId(prNumber) {
986
+ try {
987
+ const output = execSync3(
988
+ `gh api repos/{owner}/{repo}/issues/${prNumber}/comments --jq '.[] | select(.body | contains("${COMMENT_MARKER}")) | .id'`,
989
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
990
+ ).trim();
991
+ const ids = output.split("\n").filter(Boolean);
992
+ return ids[0] || null;
993
+ } catch {
994
+ return null;
995
+ }
996
+ }
997
+ function postOrUpdateComment(prNumber, body) {
998
+ const existingId = findExistingCommentId(prNumber);
999
+ if (existingId) {
1000
+ logger.info(`Updating existing PR comment (id: ${existingId})`);
1001
+ execSync3(
1002
+ `gh api repos/{owner}/{repo}/issues/comments/${existingId} -X PATCH -f body=@-`,
1003
+ {
1004
+ input: body,
1005
+ encoding: "utf-8",
1006
+ stdio: ["pipe", "pipe", "pipe"]
1007
+ }
1008
+ );
1009
+ } else {
1010
+ logger.info(`Creating new PR comment`);
1011
+ execSync3(
1012
+ `gh api repos/{owner}/{repo}/issues/${prNumber}/comments -f body=@-`,
1013
+ {
1014
+ input: body,
1015
+ encoding: "utf-8",
1016
+ stdio: ["pipe", "pipe", "pipe"]
1017
+ }
1018
+ );
1019
+ }
1020
+ }
1021
+ async function generateReport(results, outputDir, options) {
1022
+ await ensureDir(outputDir);
1023
+ const summaryMd = generateSummaryMd(results);
1024
+ const summaryPath = join7(outputDir, "summary.md");
1025
+ writeFileSync2(summaryPath, summaryMd, "utf-8");
1026
+ logger.success(`Written summary to ${summaryPath}`);
1027
+ const reportHtml = generateReportHtml(results);
1028
+ const indexPath = join7(outputDir, "index.html");
1029
+ writeFileSync2(indexPath, reportHtml, "utf-8");
1030
+ logger.success(`Written report to ${indexPath}`);
1031
+ for (const result of results) {
1032
+ if (!result.changed) continue;
1033
+ const sliderHtml = generateSliderHtml(result);
1034
+ writeFileSync2(result.sliderPath, sliderHtml, "utf-8");
1035
+ logger.dim(`Written slider for ${result.route}`);
1036
+ }
1037
+ if (options.post) {
1038
+ const prNumber = findPrNumber();
1039
+ if (!prNumber) {
1040
+ logger.warn(
1041
+ "Could not find PR number. Make sure you are on a branch with an open PR and `gh` is authenticated."
1042
+ );
1043
+ return;
1044
+ }
1045
+ postOrUpdateComment(prNumber, summaryMd);
1046
+ logger.success(`Posted results to PR #${prNumber}`);
1047
+ }
1048
+ }
1049
+
1050
+ // src/pipeline.ts
1051
+ async function runPipeline(options) {
1052
+ const { base, output, post, cwd } = options;
1053
+ const outputDir = resolve2(cwd, output);
1054
+ try {
1055
+ const spinner = logger.spin("Analyzing git diff...");
1056
+ const diffFiles = getChangedFiles(base, cwd);
1057
+ spinner.stop();
1058
+ if (diffFiles.length === 0) {
1059
+ logger.success("No changed files detected. Nothing to do.");
1060
+ return;
1061
+ }
1062
+ logger.info(`Found ${diffFiles.length} changed file(s)`);
1063
+ const classified = classifyFiles(diffFiles);
1064
+ const visualFiles = filterVisuallyRelevant(classified);
1065
+ const impactfulFiles = classified.filter(
1066
+ (f) => f.category !== "test" && f.category !== "other"
1067
+ );
1068
+ if (impactfulFiles.length === 0) {
1069
+ logger.success(
1070
+ "No visually relevant changes detected (only test/other files changed)."
1071
+ );
1072
+ return;
1073
+ }
1074
+ logger.info(
1075
+ `${visualFiles.length} visually relevant file(s), ${impactfulFiles.length} potentially impactful`
1076
+ );
1077
+ const graphSpinner = logger.spin("Building import graph...");
1078
+ const graph = await buildImportGraph(cwd);
1079
+ graphSpinner.stop();
1080
+ const changedPaths = impactfulFiles.map((f) => f.path);
1081
+ const affectedRoutes = findAffectedRoutes(changedPaths, graph, cwd);
1082
+ if (affectedRoutes.length === 0) {
1083
+ logger.success(
1084
+ "No affected routes found. Changed files don't impact any pages."
1085
+ );
1086
+ return;
1087
+ }
1088
+ logger.info(
1089
+ `Affected routes: ${affectedRoutes.map((r) => r.route).join(", ")}`
1090
+ );
1091
+ await ensureDir(outputDir);
1092
+ const worktree = await createWorktree(base, cwd);
1093
+ const beforePort = await findAvailablePort();
1094
+ const afterPort = await findAvailablePort(/* @__PURE__ */ new Set([beforePort]));
1095
+ const beforeServer = await startServer(worktree.path, beforePort);
1096
+ cleanupRegistry.register(() => stopServer(beforeServer));
1097
+ const afterServer = await startServer(cwd, afterPort);
1098
+ cleanupRegistry.register(() => stopServer(afterServer));
1099
+ const routes = affectedRoutes.map((r) => r.route);
1100
+ const captures = await captureRoutes(
1101
+ routes,
1102
+ beforeServer.url,
1103
+ afterServer.url,
1104
+ outputDir
1105
+ );
1106
+ const results = await compareScreenshots(captures, outputDir, options.threshold);
1107
+ await generateReport(results, outputDir, { post });
1108
+ const changedCount = results.filter((r) => r.changed).length;
1109
+ logger.success(
1110
+ `Done! ${results.length} route(s) captured, ${changedCount} with visual changes.`
1111
+ );
1112
+ logger.info(`Output: ${outputDir}`);
1113
+ logger.dim(`Open ${resolve2(outputDir, "index.html")} in your browser to view the report.`);
1114
+ } finally {
1115
+ await cleanupRegistry.runAll();
1116
+ }
1117
+ }
1118
+
1119
+ // src/cli.ts
1120
+ var program = new Command();
1121
+ program.name("afterbefore").description(
1122
+ "Automatic before/after screenshot capture for PRs. Git diff is the config."
1123
+ ).version("0.1.0").option("--base <ref>", "Base branch or ref to compare against", "main").option("--output <dir>", "Output directory for screenshots", ".afterbefore").option("--post", "Post results as a PR comment via gh CLI", false).option(
1124
+ "--threshold <percent>",
1125
+ "Diff threshold percentage (changes below this are ignored)",
1126
+ "0.1"
1127
+ ).action(async (opts) => {
1128
+ const cwd = process.cwd();
1129
+ if (!isGitRepo(cwd)) {
1130
+ logger.error("Not a git repository. Run this from inside a git repo.");
1131
+ process.exit(1);
1132
+ }
1133
+ const options = {
1134
+ base: opts.base,
1135
+ output: opts.output,
1136
+ post: opts.post,
1137
+ threshold: parseFloat(opts.threshold),
1138
+ cwd
1139
+ };
1140
+ try {
1141
+ await runPipeline(options);
1142
+ } catch (err) {
1143
+ logger.error(
1144
+ err instanceof Error ? err.message : `Unexpected error: ${String(err)}`
1145
+ );
1146
+ process.exit(1);
1147
+ }
1148
+ });
1149
+ program.parse();
1150
+ //# sourceMappingURL=cli.js.map