afterbefore 0.1.19 → 0.2.1
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/README.md +78 -62
- package/dist/chunk-DAWAW3ZL.js +226 -0
- package/dist/chunk-DAWAW3ZL.js.map +1 -0
- package/dist/next.d.ts +5 -0
- package/dist/next.js +28 -0
- package/dist/next.js.map +1 -0
- package/dist/overlay/index.d.ts +5 -0
- package/dist/overlay/index.js +1176 -0
- package/dist/overlay/index.js.map +1 -0
- package/dist/server/middleware.d.ts +8 -0
- package/dist/server/middleware.js +29 -0
- package/dist/server/middleware.js.map +1 -0
- package/dist/server/route.d.ts +10 -0
- package/dist/server/route.js +30 -0
- package/dist/server/route.js.map +1 -0
- package/package.json +42 -20
- package/dist/cli.js +0 -1233
- package/dist/cli.js.map +0 -1
- package/dist/index.d.ts +0 -59
- package/dist/index.js +0 -1180
- package/dist/index.js.map +0 -1
package/README.md
CHANGED
|
@@ -3,88 +3,104 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/afterbefore)
|
|
4
4
|
[](https://www.npmjs.com/package/afterbefore)
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
Before/after screenshot capture for Next.js development. A floating icon in your dev server — click to capture before, make your changes, click to capture after.
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
No CLI. No Playwright. No second server. The browser is the tool.
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
## Quick start
|
|
13
|
-
|
|
14
|
-
Run it from a feature branch in any Next.js app router project:
|
|
10
|
+
## Setup
|
|
15
11
|
|
|
16
12
|
```bash
|
|
17
|
-
|
|
13
|
+
npm install afterbefore
|
|
18
14
|
```
|
|
19
15
|
|
|
20
|
-
|
|
16
|
+
### 1. Add the overlay to your root layout
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
// app/layout.tsx
|
|
20
|
+
import { AfterBefore } from "afterbefore/overlay"
|
|
21
|
+
|
|
22
|
+
export default function RootLayout({ children }) {
|
|
23
|
+
return (
|
|
24
|
+
<html>
|
|
25
|
+
<body>
|
|
26
|
+
{children}
|
|
27
|
+
{process.env.NODE_ENV === "development" && <AfterBefore />}
|
|
28
|
+
</body>
|
|
29
|
+
</html>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
```
|
|
21
33
|
|
|
22
|
-
|
|
34
|
+
### 2. Create the API route
|
|
23
35
|
|
|
36
|
+
```ts
|
|
37
|
+
// app/api/__afterbefore/[...path]/route.ts
|
|
38
|
+
export { GET, POST } from "afterbefore/server"
|
|
24
39
|
```
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
40
|
+
|
|
41
|
+
### 3. Wrap your Next.js config
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
// next.config.ts
|
|
45
|
+
import { withAfterBefore } from "afterbefore/next"
|
|
46
|
+
|
|
47
|
+
export default withAfterBefore({
|
|
48
|
+
// your existing config
|
|
49
|
+
})
|
|
29
50
|
```
|
|
30
51
|
|
|
52
|
+
The config wrapper adds a dev-only rewrite from `/__afterbefore/*` to the API route. It does nothing in production.
|
|
53
|
+
|
|
31
54
|
## How it works
|
|
32
55
|
|
|
33
|
-
1.
|
|
34
|
-
2.
|
|
35
|
-
3.
|
|
36
|
-
4.
|
|
37
|
-
5.
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|------|---------|-------------|
|
|
48
|
-
| `--base <ref>` | `main` | Base branch to compare against |
|
|
49
|
-
| `--output <dir>` | `.afterbefore` | Output directory |
|
|
50
|
-
| `--max-routes <count>` | `6` | Cap the number of routes captured (0 = unlimited) |
|
|
51
|
-
| `--width <pixels>` | `1280` | Viewport width |
|
|
52
|
-
| `--height <pixels>` | `720` | Viewport height |
|
|
53
|
-
| `--delay <ms>` | `0` | Extra wait time after page load |
|
|
54
|
-
| `--max-depth <n>` | `10` | Max import graph traversal depth |
|
|
55
|
-
| `--dry-run` | `false` | Show affected routes without capturing |
|
|
56
|
-
| `--verbose` | `false` | Show detailed import graph traversal |
|
|
57
|
-
|
|
58
|
-
## Configuration file
|
|
59
|
-
|
|
60
|
-
For pages that need interaction before capture (opening a modal, clicking a dropdown), create an `afterbefore.config.json`:
|
|
61
|
-
|
|
62
|
-
```json
|
|
63
|
-
{
|
|
64
|
-
"scenarios": {
|
|
65
|
-
"/design-system": [
|
|
66
|
-
{
|
|
67
|
-
"name": "components-tab",
|
|
68
|
-
"actions": [
|
|
69
|
-
{ "click": ".tab-components" },
|
|
70
|
-
{ "wait": 300 }
|
|
71
|
-
]
|
|
72
|
-
}
|
|
73
|
-
]
|
|
74
|
-
}
|
|
75
|
-
}
|
|
56
|
+
1. A floating icon appears in the bottom-left corner of your dev server
|
|
57
|
+
2. Click it and choose a capture mode — the screenshot is saved as `before.png`
|
|
58
|
+
3. Make your changes
|
|
59
|
+
4. Click again — the screenshot is saved as `after.png`
|
|
60
|
+
5. Open the output folder, copy markdown for a PR comment, or push directly to your GitHub PR
|
|
61
|
+
|
|
62
|
+
Screenshots are captured using [html-to-image](https://github.com/nicorevin/html-to-image), which renders the DOM to a canvas. This captures exactly what you see — logged-in state, open modals, scroll position, form inputs — without needing a headless browser.
|
|
63
|
+
|
|
64
|
+
Screenshots land on your Desktop, named after your branch:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
~/Desktop/
|
|
68
|
+
my-feature-before.png
|
|
69
|
+
my-feature-after.png
|
|
76
70
|
```
|
|
77
71
|
|
|
78
|
-
|
|
72
|
+
## Capture modes
|
|
73
|
+
|
|
74
|
+
| Mode | Behavior |
|
|
75
|
+
|------|----------|
|
|
76
|
+
| **Viewport** | Captures the visible area |
|
|
77
|
+
| **Full Page** | Captures the entire document height |
|
|
78
|
+
| **Select Area** | Crosshair cursor — drag a rectangle to capture a specific region |
|
|
79
|
+
|
|
80
|
+
## PR integration
|
|
81
|
+
|
|
82
|
+
When both screenshots are captured, click the icon to:
|
|
83
|
+
|
|
84
|
+
- **Open Folder** — opens `.afterbefore/` in your file manager
|
|
85
|
+
- **Copy Markdown** — copies a before/after comparison table to your clipboard
|
|
86
|
+
- **Push to PR** — commits the screenshots, pushes, and posts a PR comment with the images (requires [GitHub CLI](https://cli.github.com/))
|
|
87
|
+
- **Reset** — start over
|
|
88
|
+
|
|
89
|
+
## Known limitations
|
|
90
|
+
|
|
91
|
+
- Web fonts may render slightly differently than in the browser
|
|
92
|
+
- Complex CSS (`backdrop-filter`, `mix-blend-mode`) may not reproduce perfectly
|
|
93
|
+
- iframes and tainted canvas/WebGL content are not captured
|
|
94
|
+
- Next.js only (React overlay, API routes)
|
|
95
|
+
|
|
96
|
+
For pixel-perfect CI screenshots, see the roadmap for Phase 1 (Playwright-based CLI).
|
|
79
97
|
|
|
80
98
|
## Requirements
|
|
81
99
|
|
|
82
|
-
- Next.js
|
|
83
|
-
-
|
|
100
|
+
- Next.js >= 14 (app router)
|
|
101
|
+
- React >= 18
|
|
84
102
|
- Node.js >= 18
|
|
85
103
|
|
|
86
|
-
Playwright's Chromium browser is installed automatically on first run if it's missing.
|
|
87
|
-
|
|
88
104
|
## License
|
|
89
105
|
|
|
90
106
|
Licensed under [PolyForm Shield 1.0.0](https://polyformproject.org/licenses/shield/1.0.0)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// src/server/save.ts
|
|
2
|
+
import { NextResponse } from "next/server";
|
|
3
|
+
import { writeFile } from "fs/promises";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { homedir } from "os";
|
|
6
|
+
import { execFile } from "child_process";
|
|
7
|
+
import { promisify } from "util";
|
|
8
|
+
var execFileAsync = promisify(execFile);
|
|
9
|
+
async function getBranch() {
|
|
10
|
+
const { stdout } = await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
11
|
+
cwd: process.cwd()
|
|
12
|
+
});
|
|
13
|
+
return stdout.trim();
|
|
14
|
+
}
|
|
15
|
+
var VALID_TYPES = ["before", "after"];
|
|
16
|
+
var VALID_MODES = ["fullpage", "viewport", "area"];
|
|
17
|
+
var DATA_URL_PREFIX = "data:image/png;base64,";
|
|
18
|
+
async function handleSave(req) {
|
|
19
|
+
let body;
|
|
20
|
+
try {
|
|
21
|
+
body = await req.json();
|
|
22
|
+
} catch {
|
|
23
|
+
return NextResponse.json(
|
|
24
|
+
{ error: "Invalid JSON body" },
|
|
25
|
+
{ status: 400 }
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
const { type, mode, image } = body;
|
|
29
|
+
if (!VALID_TYPES.includes(type)) {
|
|
30
|
+
return NextResponse.json(
|
|
31
|
+
{ error: `Invalid type: must be "before" or "after"` },
|
|
32
|
+
{ status: 400 }
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
if (!VALID_MODES.includes(mode)) {
|
|
36
|
+
return NextResponse.json(
|
|
37
|
+
{ error: `Invalid mode: must be "fullpage", "viewport", or "area"` },
|
|
38
|
+
{ status: 400 }
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
if (typeof image !== "string" || !image.startsWith(DATA_URL_PREFIX)) {
|
|
42
|
+
return NextResponse.json(
|
|
43
|
+
{ error: "Invalid image: must be a data:image/png;base64 data URL" },
|
|
44
|
+
{ status: 400 }
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
const base64 = image.slice(DATA_URL_PREFIX.length);
|
|
48
|
+
const buffer = Buffer.from(base64, "base64");
|
|
49
|
+
const branch = await getBranch();
|
|
50
|
+
const filename = `${branch}-${type}.png`;
|
|
51
|
+
const filepath = join(homedir(), "Desktop", filename);
|
|
52
|
+
try {
|
|
53
|
+
await writeFile(filepath, buffer);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
return NextResponse.json(
|
|
56
|
+
{ error: "Failed to write screenshot", detail: String(err) },
|
|
57
|
+
{ status: 500 }
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return NextResponse.json({ success: true, path: `~/Desktop/${filename}` });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// src/server/push.ts
|
|
64
|
+
import { NextResponse as NextResponse2 } from "next/server";
|
|
65
|
+
import { execFile as execFile2 } from "child_process";
|
|
66
|
+
import { access, mkdir, copyFile } from "fs/promises";
|
|
67
|
+
import { join as join2 } from "path";
|
|
68
|
+
import { homedir as homedir2 } from "os";
|
|
69
|
+
import { promisify as promisify2 } from "util";
|
|
70
|
+
var execFileAsync2 = promisify2(execFile2);
|
|
71
|
+
async function run(cmd, args) {
|
|
72
|
+
return execFileAsync2(cmd, args, { cwd: process.cwd() });
|
|
73
|
+
}
|
|
74
|
+
async function ghAvailable() {
|
|
75
|
+
try {
|
|
76
|
+
await run("gh", ["--version"]);
|
|
77
|
+
return true;
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
async function getPrInfo() {
|
|
83
|
+
try {
|
|
84
|
+
const { stdout } = await run("gh", [
|
|
85
|
+
"pr",
|
|
86
|
+
"view",
|
|
87
|
+
"--json",
|
|
88
|
+
"number,url,headRepository,headRefName"
|
|
89
|
+
]);
|
|
90
|
+
return JSON.parse(stdout);
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
async function fileExists(path) {
|
|
96
|
+
try {
|
|
97
|
+
await access(path);
|
|
98
|
+
return true;
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
async function handlePush(req) {
|
|
104
|
+
if (!await ghAvailable()) {
|
|
105
|
+
return NextResponse2.json(
|
|
106
|
+
{ success: false, error: "GitHub CLI (gh) is not installed or not in PATH" },
|
|
107
|
+
{ status: 500 }
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const pr = await getPrInfo();
|
|
111
|
+
if (!pr) {
|
|
112
|
+
return NextResponse2.json(
|
|
113
|
+
{ success: false, error: "No PR found for current branch" },
|
|
114
|
+
{ status: 404 }
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const desktop = join2(homedir2(), "Desktop");
|
|
118
|
+
const branch = pr.headRefName;
|
|
119
|
+
const beforePath = join2(desktop, `${branch}-before.png`);
|
|
120
|
+
const afterPath = join2(desktop, `${branch}-after.png`);
|
|
121
|
+
const [hasBefore, hasAfter] = await Promise.all([
|
|
122
|
+
fileExists(beforePath),
|
|
123
|
+
fileExists(afterPath)
|
|
124
|
+
]);
|
|
125
|
+
if (!hasBefore || !hasAfter) {
|
|
126
|
+
const missing = [
|
|
127
|
+
!hasBefore && `${branch}-before.png`,
|
|
128
|
+
!hasAfter && `${branch}-after.png`
|
|
129
|
+
].filter(Boolean);
|
|
130
|
+
return NextResponse2.json(
|
|
131
|
+
{ success: false, error: `Missing screenshots: ${missing.join(", ")}` },
|
|
132
|
+
{ status: 400 }
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
const repoDir = join2(process.cwd(), ".afterbefore");
|
|
136
|
+
const repoBefore = join2(repoDir, `${branch}-before.png`);
|
|
137
|
+
const repoAfter = join2(repoDir, `${branch}-after.png`);
|
|
138
|
+
try {
|
|
139
|
+
await mkdir(repoDir, { recursive: true });
|
|
140
|
+
await copyFile(beforePath, repoBefore);
|
|
141
|
+
await copyFile(afterPath, repoAfter);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
return NextResponse2.json(
|
|
144
|
+
{ success: false, error: "Failed to copy screenshots into repo", detail: String(err) },
|
|
145
|
+
{ status: 500 }
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
await run("git", ["add", repoBefore, repoAfter]);
|
|
150
|
+
await run("git", [
|
|
151
|
+
"commit",
|
|
152
|
+
"-m",
|
|
153
|
+
"chore: add before/after screenshots"
|
|
154
|
+
]);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
const msg = String(err);
|
|
157
|
+
if (!msg.includes("nothing to commit")) {
|
|
158
|
+
return NextResponse2.json(
|
|
159
|
+
{ success: false, error: "Failed to commit screenshots", detail: msg },
|
|
160
|
+
{ status: 500 }
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
await run("git", ["push"]);
|
|
166
|
+
} catch (err) {
|
|
167
|
+
return NextResponse2.json(
|
|
168
|
+
{ success: false, error: "Failed to push to remote", detail: String(err) },
|
|
169
|
+
{ status: 500 }
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
const owner = pr.headRepository.owner.login;
|
|
173
|
+
const repo = pr.headRepository.name;
|
|
174
|
+
const rawBase = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}`;
|
|
175
|
+
const beforeUrl = `${rawBase}/.afterbefore/${branch}-before.png`;
|
|
176
|
+
const afterUrl = `${rawBase}/.afterbefore/${branch}-after.png`;
|
|
177
|
+
const ts = Date.now();
|
|
178
|
+
const commentBody = [
|
|
179
|
+
"## Before / After",
|
|
180
|
+
"",
|
|
181
|
+
"| Before | After |",
|
|
182
|
+
"|--------|-------|",
|
|
183
|
+
`|  |  |`
|
|
184
|
+
].join("\n");
|
|
185
|
+
let commentUrl;
|
|
186
|
+
try {
|
|
187
|
+
const { stdout } = await run("gh", [
|
|
188
|
+
"pr",
|
|
189
|
+
"comment",
|
|
190
|
+
String(pr.number),
|
|
191
|
+
"--body",
|
|
192
|
+
commentBody
|
|
193
|
+
]);
|
|
194
|
+
commentUrl = stdout.trim() || void 0;
|
|
195
|
+
} catch (err) {
|
|
196
|
+
return NextResponse2.json(
|
|
197
|
+
{ success: false, error: "Failed to post PR comment", detail: String(err) },
|
|
198
|
+
{ status: 500 }
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
return NextResponse2.json({
|
|
202
|
+
success: true,
|
|
203
|
+
prNumber: pr.number,
|
|
204
|
+
prUrl: pr.url,
|
|
205
|
+
commentUrl
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// src/server/open.ts
|
|
210
|
+
import { NextResponse as NextResponse3 } from "next/server";
|
|
211
|
+
import { execFile as execFile3 } from "child_process";
|
|
212
|
+
import { join as join3 } from "path";
|
|
213
|
+
import { homedir as homedir3 } from "os";
|
|
214
|
+
async function handleOpen(_req) {
|
|
215
|
+
const desktop = join3(homedir3(), "Desktop");
|
|
216
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
|
|
217
|
+
execFile3(cmd, [desktop]);
|
|
218
|
+
return NextResponse3.json({ success: true });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export {
|
|
222
|
+
handleSave,
|
|
223
|
+
handlePush,
|
|
224
|
+
handleOpen
|
|
225
|
+
};
|
|
226
|
+
//# sourceMappingURL=chunk-DAWAW3ZL.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/server/save.ts","../src/server/push.ts","../src/server/open.ts"],"sourcesContent":["import { NextRequest, NextResponse } from \"next/server\";\nimport { writeFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { execFile } from \"node:child_process\";\nimport { promisify } from \"node:util\";\n\nconst execFileAsync = promisify(execFile);\n\nasync function getBranch(): Promise<string> {\n const { stdout } = await execFileAsync(\"git\", [\"rev-parse\", \"--abbrev-ref\", \"HEAD\"], {\n cwd: process.cwd(),\n });\n return stdout.trim();\n}\n\nconst VALID_TYPES = [\"before\", \"after\"] as const;\ntype ScreenshotType = (typeof VALID_TYPES)[number];\n\nconst VALID_MODES = [\"fullpage\", \"viewport\", \"area\"] as const;\n\nconst DATA_URL_PREFIX = \"data:image/png;base64,\";\n\ninterface SaveRequestBody {\n type: ScreenshotType;\n mode: string;\n image: string;\n}\n\nexport async function handleSave(req: NextRequest): Promise<NextResponse> {\n let body: SaveRequestBody;\n try {\n body = await req.json();\n } catch {\n return NextResponse.json(\n { error: \"Invalid JSON body\" },\n { status: 400 },\n );\n }\n\n const { type, mode, image } = body;\n\n if (!VALID_TYPES.includes(type as ScreenshotType)) {\n return NextResponse.json(\n { error: `Invalid type: must be \"before\" or \"after\"` },\n { status: 400 },\n );\n }\n\n if (!VALID_MODES.includes(mode as (typeof VALID_MODES)[number])) {\n return NextResponse.json(\n { error: `Invalid mode: must be \"fullpage\", \"viewport\", or \"area\"` },\n { status: 400 },\n );\n }\n\n if (typeof image !== \"string\" || !image.startsWith(DATA_URL_PREFIX)) {\n return NextResponse.json(\n { error: \"Invalid image: must be a data:image/png;base64 data URL\" },\n { status: 400 },\n );\n }\n\n const base64 = image.slice(DATA_URL_PREFIX.length);\n const buffer = Buffer.from(base64, \"base64\");\n\n const branch = await getBranch();\n const filename = `${branch}-${type}.png`;\n const filepath = join(homedir(), \"Desktop\", filename);\n\n try {\n await writeFile(filepath, buffer);\n } catch (err) {\n return NextResponse.json(\n { error: \"Failed to write screenshot\", detail: String(err) },\n { status: 500 },\n );\n }\n\n return NextResponse.json({ success: true, path: `~/Desktop/${filename}` });\n}\n","import { NextRequest, NextResponse } from \"next/server\";\nimport { execFile } from \"node:child_process\";\nimport { access, mkdir, copyFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport { homedir } from \"node:os\";\nimport { promisify } from \"node:util\";\n\nconst execFileAsync = promisify(execFile);\n\ninterface PrInfo {\n number: number;\n url: string;\n headRepository: { owner: { login: string }; name: string };\n headRefName: string;\n}\n\nasync function run(\n cmd: string,\n args: string[],\n): Promise<{ stdout: string; stderr: string }> {\n return execFileAsync(cmd, args, { cwd: process.cwd() });\n}\n\nasync function ghAvailable(): Promise<boolean> {\n try {\n await run(\"gh\", [\"--version\"]);\n return true;\n } catch {\n return false;\n }\n}\n\nasync function getPrInfo(): Promise<PrInfo | null> {\n try {\n const { stdout } = await run(\"gh\", [\n \"pr\",\n \"view\",\n \"--json\",\n \"number,url,headRepository,headRefName\",\n ]);\n return JSON.parse(stdout) as PrInfo;\n } catch {\n return null;\n }\n}\n\nasync function fileExists(path: string): Promise<boolean> {\n try {\n await access(path);\n return true;\n } catch {\n return false;\n }\n}\n\nexport async function handlePush(req: NextRequest): Promise<NextResponse> {\n // 1. Check gh CLI availability\n if (!(await ghAvailable())) {\n return NextResponse.json(\n { success: false, error: \"GitHub CLI (gh) is not installed or not in PATH\" },\n { status: 500 },\n );\n }\n\n // 2. Check for active PR\n const pr = await getPrInfo();\n if (!pr) {\n return NextResponse.json(\n { success: false, error: \"No PR found for current branch\" },\n { status: 404 },\n );\n }\n\n // 3. Check that screenshot files exist on Desktop\n const desktop = join(homedir(), \"Desktop\");\n const branch = pr.headRefName;\n const beforePath = join(desktop, `${branch}-before.png`);\n const afterPath = join(desktop, `${branch}-after.png`);\n\n const [hasBefore, hasAfter] = await Promise.all([\n fileExists(beforePath),\n fileExists(afterPath),\n ]);\n\n if (!hasBefore || !hasAfter) {\n const missing = [\n !hasBefore && `${branch}-before.png`,\n !hasAfter && `${branch}-after.png`,\n ].filter(Boolean);\n return NextResponse.json(\n { success: false, error: `Missing screenshots: ${missing.join(\", \")}` },\n { status: 400 },\n );\n }\n\n // 4. Copy from Desktop into repo, stage, commit, and push\n const repoDir = join(process.cwd(), \".afterbefore\");\n const repoBefore = join(repoDir, `${branch}-before.png`);\n const repoAfter = join(repoDir, `${branch}-after.png`);\n\n try {\n await mkdir(repoDir, { recursive: true });\n await copyFile(beforePath, repoBefore);\n await copyFile(afterPath, repoAfter);\n } catch (err) {\n return NextResponse.json(\n { success: false, error: \"Failed to copy screenshots into repo\", detail: String(err) },\n { status: 500 },\n );\n }\n\n try {\n await run(\"git\", [\"add\", repoBefore, repoAfter]);\n await run(\"git\", [\n \"commit\",\n \"-m\",\n \"chore: add before/after screenshots\",\n ]);\n } catch (err) {\n // Commit may fail if files are already committed with no changes.\n // That's fine -- we still want to push and comment.\n const msg = String(err);\n if (!msg.includes(\"nothing to commit\")) {\n return NextResponse.json(\n { success: false, error: \"Failed to commit screenshots\", detail: msg },\n { status: 500 },\n );\n }\n }\n\n try {\n await run(\"git\", [\"push\"]);\n } catch (err) {\n return NextResponse.json(\n { success: false, error: \"Failed to push to remote\", detail: String(err) },\n { status: 500 },\n );\n }\n\n // 5. Build raw GitHub URLs for the images\n const owner = pr.headRepository.owner.login;\n const repo = pr.headRepository.name;\n const rawBase = `https://raw.githubusercontent.com/${owner}/${repo}/${branch}`;\n\n const beforeUrl = `${rawBase}/.afterbefore/${branch}-before.png`;\n const afterUrl = `${rawBase}/.afterbefore/${branch}-after.png`;\n\n // Cache-bust with timestamp so GitHub doesn't serve stale images\n const ts = Date.now();\n const commentBody = [\n \"## Before / After\",\n \"\",\n \"| Before | After |\",\n \"|--------|-------|\",\n `|  |  |`,\n ].join(\"\\n\");\n\n // 6. Post PR comment\n let commentUrl: string | undefined;\n try {\n const { stdout } = await run(\"gh\", [\n \"pr\",\n \"comment\",\n String(pr.number),\n \"--body\",\n commentBody,\n ]);\n // gh pr comment prints the comment URL to stdout\n commentUrl = stdout.trim() || undefined;\n } catch (err) {\n return NextResponse.json(\n { success: false, error: \"Failed to post PR comment\", detail: String(err) },\n { status: 500 },\n );\n }\n\n return NextResponse.json({\n success: true,\n prNumber: pr.number,\n prUrl: pr.url,\n commentUrl,\n });\n}\n","import { NextRequest, NextResponse } from \"next/server\";\nimport { execFile } from \"node:child_process\";\nimport { join } from \"node:path\";\nimport { homedir } from \"node:os\";\n\nexport async function handleOpen(_req: NextRequest): Promise<NextResponse> {\n const desktop = join(homedir(), \"Desktop\");\n\n const cmd =\n process.platform === \"darwin\"\n ? \"open\"\n : process.platform === \"win32\"\n ? \"explorer\"\n : \"xdg-open\";\n\n execFile(cmd, [desktop]);\n\n return NextResponse.json({ success: true });\n}\n"],"mappings":"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","names":["NextResponse","execFile","join","homedir","promisify","execFileAsync","NextResponse","execFile","join","homedir"]}
|
package/dist/next.d.ts
ADDED
package/dist/next.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// src/next.ts
|
|
2
|
+
function withAfterBefore(config = {}) {
|
|
3
|
+
if (process.env.NODE_ENV !== "development") return config;
|
|
4
|
+
return {
|
|
5
|
+
...config,
|
|
6
|
+
async rewrites() {
|
|
7
|
+
const existing = await config.rewrites?.() ?? [];
|
|
8
|
+
const rules = [
|
|
9
|
+
{
|
|
10
|
+
source: "/__afterbefore/:path*",
|
|
11
|
+
destination: "/api/__afterbefore/:path*"
|
|
12
|
+
}
|
|
13
|
+
];
|
|
14
|
+
if (Array.isArray(existing)) {
|
|
15
|
+
return [...rules, ...existing];
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
beforeFiles: [...rules, ...existing.beforeFiles ?? []],
|
|
19
|
+
afterFiles: existing.afterFiles ?? [],
|
|
20
|
+
fallback: existing.fallback ?? []
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export {
|
|
26
|
+
withAfterBefore
|
|
27
|
+
};
|
|
28
|
+
//# sourceMappingURL=next.js.map
|
package/dist/next.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/next.ts"],"sourcesContent":["import type { NextConfig } from \"next\";\n\ntype Rewrite = { source: string; destination: string };\n\nexport function withAfterBefore(config: NextConfig = {}): NextConfig {\n if (process.env.NODE_ENV !== \"development\") return config;\n\n return {\n ...config,\n async rewrites() {\n const existing = (await config.rewrites?.()) ?? [];\n const rules: Rewrite[] = [\n {\n source: \"/__afterbefore/:path*\",\n destination: \"/api/__afterbefore/:path*\",\n },\n ];\n\n if (Array.isArray(existing)) {\n return [...rules, ...existing];\n }\n\n return {\n beforeFiles: [...rules, ...(existing.beforeFiles ?? [])],\n afterFiles: existing.afterFiles ?? [],\n fallback: existing.fallback ?? [],\n };\n },\n };\n}\n"],"mappings":";AAIO,SAAS,gBAAgB,SAAqB,CAAC,GAAe;AACnE,MAAI,QAAQ,IAAI,aAAa,cAAe,QAAO;AAEnD,SAAO;AAAA,IACL,GAAG;AAAA,IACH,MAAM,WAAW;AACf,YAAM,WAAY,MAAM,OAAO,WAAW,KAAM,CAAC;AACjD,YAAM,QAAmB;AAAA,QACvB;AAAA,UACE,QAAQ;AAAA,UACR,aAAa;AAAA,QACf;AAAA,MACF;AAEA,UAAI,MAAM,QAAQ,QAAQ,GAAG;AAC3B,eAAO,CAAC,GAAG,OAAO,GAAG,QAAQ;AAAA,MAC/B;AAEA,aAAO;AAAA,QACL,aAAa,CAAC,GAAG,OAAO,GAAI,SAAS,eAAe,CAAC,CAAE;AAAA,QACvD,YAAY,SAAS,cAAc,CAAC;AAAA,QACpC,UAAU,SAAS,YAAY,CAAC;AAAA,MAClC;AAAA,IACF;AAAA,EACF;AACF;","names":[]}
|