@ahmedalbarghouti/farq 0.0.2 → 0.0.3
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 +6 -17
- package/dist/index.js +7 -3
- package/dist/open-pr.d.ts +6 -1
- package/dist/open-pr.js +42 -16
- package/dist/render/pr.d.ts +6 -0
- package/dist/render/pr.js +29 -7
- package/dist/template.d.ts +6 -0
- package/dist/template.js +40 -19
- package/dist/visual/chrome.js +5 -2
- package/dist/visual/cluster.d.ts +23 -0
- package/dist/visual/cluster.js +180 -0
- package/dist/visual/compose.d.ts +2 -0
- package/dist/visual/compose.js +6 -4
- package/dist/visual/diagram.d.ts +1 -0
- package/dist/visual/diagram.js +6 -2
- package/dist/visual/mockup.d.ts +2 -0
- package/dist/visual/mockup.js +9 -5
- package/dist/visual/pipeline.d.ts +5 -0
- package/dist/visual/pipeline.js +154 -68
- package/dist/visual/viewport.d.ts +16 -0
- package/dist/visual/viewport.js +19 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,13 +5,13 @@
|
|
|
5
5
|
One command. No servers. No API keys. Auth stays with your local `claude` or `opencode` CLI.
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npx @ahmedalbarghouti/farq@0.0.
|
|
8
|
+
npx @ahmedalbarghouti/farq@0.0.3 pr # title + markdown body (+ image when feasible)
|
|
9
9
|
npx farq slack # Slack mrkdwn daily update
|
|
10
10
|
npx farq json # structured JSON
|
|
11
11
|
npx farq pr --open # fill PR template + create with gh
|
|
12
12
|
```
|
|
13
13
|
|
|
14
|
-
> **0.0.
|
|
14
|
+
> **0.0.3** — PATH/`gh`/Chrome resolution, hosted-image PR body cleanup, orphaned asset prune. CLI surface (`pr` / `slack` / `json`) is the stable bit.
|
|
15
15
|
|
|
16
16
|
## Install
|
|
17
17
|
|
|
@@ -23,7 +23,7 @@ farq --help
|
|
|
23
23
|
Or run without installing:
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
npx @ahmedalbarghouti/farq@0.0.
|
|
26
|
+
npx @ahmedalbarghouti/farq@0.0.3 --help
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
Generated images land in a **user cache directory outside the repo** by default (no `.gitignore` change needed). Use `--out .farq` if you want them in-tree.
|
|
@@ -76,7 +76,7 @@ farq pr --open
|
|
|
76
76
|
|
|
77
77
|
1. Reads `.github` PR template if present and fills known sections
|
|
78
78
|
2. Infers title style from recent merged PR titles when `gh` works
|
|
79
|
-
3. Best-effort uploads
|
|
79
|
+
3. Best-effort uploads composed image(s) as prerelease assets and embeds the URL(s)
|
|
80
80
|
4. Creates the PR with `gh pr create`, or **updates** title/body with `gh pr edit` if one already exists for the branch, then opens it in the browser
|
|
81
81
|
|
|
82
82
|
On the default branch (`main` / `master` / repo default), `--open` skips create and still prints the artifact. Titles are capped at GitHub's **256** character limit (overflow goes into the body).
|
|
@@ -91,7 +91,7 @@ Validated change summary plus an `images` array of produced file paths.
|
|
|
91
91
|
|
|
92
92
|
## Images (honesty policy)
|
|
93
93
|
|
|
94
|
-
- Visuals come from the diff only. UI markup gets a mockup attempt; everything else gets a small concept flowchart/diagram.
|
|
94
|
+
- Visuals come from the diff only. A cheap model groups summary items into **up to 5** visual topics by intent (same feature → one image; truly unrelated domains → separate). File-overlap is the fallback if that grouping fails. UI markup gets a mockup attempt; everything else gets a small concept flowchart/diagram. Screenshots are capped at **1280×720** so content must fit the frame (no tall cut-off pages). If a topic is infeasible, it is skipped (still exit 0).
|
|
95
95
|
- Generated compositions include a small **generated preview** badge.
|
|
96
96
|
- Diagrams stay conceptual — no code dumps.
|
|
97
97
|
- Missing Chrome / visual failure **soft-degrades** to text-only with a stderr warning (exit 0), unless you passed `--before`/`--after` (then hard-fail).
|
|
@@ -141,22 +141,11 @@ If both `claude` and `opencode` are installed and nothing is configured, farq us
|
|
|
141
141
|
|
|
142
142
|
Progress → **stderr**. Artifact → **stdout**.
|
|
143
143
|
|
|
144
|
-
## Publishing notes (maintainers)
|
|
145
|
-
|
|
146
|
-
Tag-triggered publish via GitHub Actions (npm provenance):
|
|
147
|
-
|
|
148
|
-
```bash
|
|
149
|
-
git tag v0.0.2
|
|
150
|
-
git push origin v0.0.2
|
|
151
|
-
```
|
|
152
|
-
|
|
153
|
-
Requires repo secret `NPM_TOKEN`. Package name: `@ahmedalbarghouti/farq`.
|
|
154
|
-
|
|
155
144
|
## Repository ops
|
|
156
145
|
|
|
157
146
|
- **CI** runs on every PR and on pushes to `main` (test + build + `--help` smoke).
|
|
158
147
|
- **`main` is protected** — changes go through PRs; the `CI / test` check must pass.
|
|
159
|
-
- **Releases** are tag-triggered: create `v0.0.
|
|
148
|
+
- **Releases** are tag-triggered: create `v0.0.3` (or later) after merge; the publish workflow needs a repo secret named `NPM_TOKEN` (npm automation/granular token with publish rights).
|
|
160
149
|
- **`--open` image assets:** uploaded as `farq-assets-<branch>` prereleases; farq deletes orphaned tags whose branch no longer has an open PR.
|
|
161
150
|
|
|
162
151
|
## Roadmap
|
package/dist/index.js
CHANGED
|
@@ -87,6 +87,7 @@ async function run(type, opts) {
|
|
|
87
87
|
: undefined;
|
|
88
88
|
let imagePath = null;
|
|
89
89
|
let images = [];
|
|
90
|
+
let imageMeta = [];
|
|
90
91
|
if (imagesEnabled || (opts.before && opts.after)) {
|
|
91
92
|
const visSpin = ui.stage("visual", provider.name);
|
|
92
93
|
try {
|
|
@@ -105,6 +106,7 @@ async function run(type, opts) {
|
|
|
105
106
|
});
|
|
106
107
|
imagePath = visual.imagePath;
|
|
107
108
|
images = visual.images;
|
|
109
|
+
imageMeta = visual.imageMeta;
|
|
108
110
|
if (visual.warning) {
|
|
109
111
|
visSpin.succeed("visuals skipped");
|
|
110
112
|
ui.note(visual.warning);
|
|
@@ -126,10 +128,10 @@ async function run(type, opts) {
|
|
|
126
128
|
ui.note(`${msg} (continuing without image)`);
|
|
127
129
|
}
|
|
128
130
|
}
|
|
129
|
-
const
|
|
131
|
+
const prImages = (imageMeta.length > 0 ? imageMeta : images.map((p) => ({ path: p, title: "before / after" }))).map((img) => ({ path: displayImageRef(cwd, img.path), title: img.title }));
|
|
130
132
|
let artifact = "";
|
|
131
133
|
if (type === "pr") {
|
|
132
|
-
artifact = renderPr({ summary,
|
|
134
|
+
artifact = renderPr({ summary, images: prImages });
|
|
133
135
|
}
|
|
134
136
|
else if (type === "slack") {
|
|
135
137
|
artifact = renderSlack(summary);
|
|
@@ -147,6 +149,8 @@ async function run(type, opts) {
|
|
|
147
149
|
? artifact.split("\n\n").slice(1).join("\n\n")
|
|
148
150
|
: artifact,
|
|
149
151
|
imagePath,
|
|
152
|
+
imagePaths: images,
|
|
153
|
+
imageTitles: imageMeta.map((m) => m.title),
|
|
150
154
|
});
|
|
151
155
|
if (result.skipped) {
|
|
152
156
|
openSpin.succeed(result.reason);
|
|
@@ -186,7 +190,7 @@ async function main() {
|
|
|
186
190
|
program
|
|
187
191
|
.name("farq")
|
|
188
192
|
.description("Turn git branch changes into paste-ready PR/Slack updates with optional before/after visuals")
|
|
189
|
-
.version("0.0.
|
|
193
|
+
.version("0.0.3");
|
|
190
194
|
addShared(program
|
|
191
195
|
.command("pr", { isDefault: true })
|
|
192
196
|
.description("PR title + body markdown")
|
package/dist/open-pr.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export type OpenPrResult = {
|
|
|
10
10
|
title: string;
|
|
11
11
|
body: string;
|
|
12
12
|
imageUrl?: string | null;
|
|
13
|
+
imageUrls?: string[];
|
|
13
14
|
};
|
|
14
15
|
/** Prerelease tag used to host a branch's composed PNG. */
|
|
15
16
|
export declare function assetTagForBranch(branch: string): string;
|
|
@@ -26,11 +27,15 @@ export declare function openPullRequest(options: {
|
|
|
26
27
|
summary: ChangeSummary;
|
|
27
28
|
bodyMarkdown: string;
|
|
28
29
|
imagePath?: string | null;
|
|
30
|
+
imagePaths?: string[];
|
|
31
|
+
imageTitles?: string[];
|
|
29
32
|
}): Promise<OpenPrResult>;
|
|
30
33
|
/**
|
|
31
34
|
* Delete `farq-assets-*` prereleases whose branch no longer has an open PR.
|
|
32
35
|
* Always keeps `keepTag` (the just-uploaded asset).
|
|
33
36
|
*/
|
|
34
37
|
export declare function pruneOrphanedFarqAssets(cwd: string, keepTag: string): Promise<string[]>;
|
|
35
|
-
/** Best-effort: prerelease asset URL for
|
|
38
|
+
/** Best-effort: prerelease asset URL for one PNG. */
|
|
36
39
|
export declare function uploadPrImage(cwd: string, imagePath: string, branch: string): Promise<string>;
|
|
40
|
+
/** Best-effort: upload many PNGs to one prerelease; URLs in input order. */
|
|
41
|
+
export declare function uploadPrImages(cwd: string, imagePaths: string[], branch: string): Promise<string[]>;
|
package/dist/open-pr.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { execa } from "execa";
|
|
5
5
|
import { fillPrTemplate, findPrTemplate } from "./template.js";
|
|
@@ -104,14 +104,19 @@ export async function openPullRequest(options) {
|
|
|
104
104
|
reason: `Already on ${defaultBranch} — skipping PR create`,
|
|
105
105
|
};
|
|
106
106
|
}
|
|
107
|
-
|
|
107
|
+
const paths = options.imagePaths && options.imagePaths.length > 0
|
|
108
|
+
? options.imagePaths
|
|
109
|
+
: options.imagePath
|
|
110
|
+
? [options.imagePath]
|
|
111
|
+
: [];
|
|
112
|
+
let imageUrls = [];
|
|
108
113
|
let warning;
|
|
109
|
-
if (
|
|
114
|
+
if (paths.length > 0) {
|
|
110
115
|
try {
|
|
111
|
-
|
|
116
|
+
imageUrls = await uploadPrImages(cwd, paths, branch);
|
|
112
117
|
}
|
|
113
118
|
catch (err) {
|
|
114
|
-
warning = `Image upload failed — attach manually: ${
|
|
119
|
+
warning = `Image upload failed — attach manually: ${paths.join(", ")} (${err instanceof Error ? err.message : String(err)})`;
|
|
115
120
|
}
|
|
116
121
|
}
|
|
117
122
|
const template = findPrTemplate(cwd);
|
|
@@ -119,7 +124,11 @@ export async function openPullRequest(options) {
|
|
|
119
124
|
template,
|
|
120
125
|
summary: options.summary,
|
|
121
126
|
bodyMarkdown: options.bodyMarkdown,
|
|
122
|
-
imageUrl,
|
|
127
|
+
imageUrl: imageUrls[0] ?? null,
|
|
128
|
+
images: imageUrls.map((url, i) => ({
|
|
129
|
+
url,
|
|
130
|
+
title: options.imageTitles?.[i],
|
|
131
|
+
})),
|
|
123
132
|
});
|
|
124
133
|
const dir = mkdtempSync(join(tmpdir(), "farq-pr-"));
|
|
125
134
|
const bodyFile = join(dir, "body.md");
|
|
@@ -149,7 +158,8 @@ export async function openPullRequest(options) {
|
|
|
149
158
|
warning,
|
|
150
159
|
title,
|
|
151
160
|
body,
|
|
152
|
-
imageUrl,
|
|
161
|
+
imageUrl: imageUrls[0] ?? null,
|
|
162
|
+
imageUrls,
|
|
153
163
|
};
|
|
154
164
|
}
|
|
155
165
|
const created = await gh(cwd, [
|
|
@@ -173,7 +183,8 @@ export async function openPullRequest(options) {
|
|
|
173
183
|
warning,
|
|
174
184
|
title,
|
|
175
185
|
body,
|
|
176
|
-
imageUrl,
|
|
186
|
+
imageUrl: imageUrls[0] ?? null,
|
|
187
|
+
imageUrls,
|
|
177
188
|
};
|
|
178
189
|
}
|
|
179
190
|
catch (err) {
|
|
@@ -225,8 +236,18 @@ export async function pruneOrphanedFarqAssets(cwd, keepTag) {
|
|
|
225
236
|
}
|
|
226
237
|
return deleted;
|
|
227
238
|
}
|
|
228
|
-
/** Best-effort: prerelease asset URL for
|
|
239
|
+
/** Best-effort: prerelease asset URL for one PNG. */
|
|
229
240
|
export async function uploadPrImage(cwd, imagePath, branch) {
|
|
241
|
+
const urls = await uploadPrImages(cwd, [imagePath], branch);
|
|
242
|
+
const url = urls[0];
|
|
243
|
+
if (!url)
|
|
244
|
+
throw new Error("no asset URL returned");
|
|
245
|
+
return url;
|
|
246
|
+
}
|
|
247
|
+
/** Best-effort: upload many PNGs to one prerelease; URLs in input order. */
|
|
248
|
+
export async function uploadPrImages(cwd, imagePaths, branch) {
|
|
249
|
+
if (imagePaths.length === 0)
|
|
250
|
+
return [];
|
|
230
251
|
const tag = assetTagForBranch(branch);
|
|
231
252
|
try {
|
|
232
253
|
await gh(cwd, ["release", "delete", tag, "--yes", "--cleanup-tag"], {
|
|
@@ -240,7 +261,7 @@ export async function uploadPrImage(cwd, imagePath, branch) {
|
|
|
240
261
|
"release",
|
|
241
262
|
"create",
|
|
242
263
|
tag,
|
|
243
|
-
|
|
264
|
+
...imagePaths,
|
|
244
265
|
"--title",
|
|
245
266
|
`farq assets (${branch})`,
|
|
246
267
|
"--notes",
|
|
@@ -253,12 +274,17 @@ export async function uploadPrImage(cwd, imagePath, branch) {
|
|
|
253
274
|
tag,
|
|
254
275
|
"--json",
|
|
255
276
|
"assets",
|
|
256
|
-
"--jq",
|
|
257
|
-
".assets[0].url",
|
|
258
277
|
]);
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
278
|
+
const parsed = JSON.parse(stdout);
|
|
279
|
+
const assets = parsed.assets ?? [];
|
|
280
|
+
const urls = imagePaths.map((p) => {
|
|
281
|
+
const name = basename(p);
|
|
282
|
+
const hit = assets.find((a) => a.name === name);
|
|
283
|
+
if (!hit?.url) {
|
|
284
|
+
throw new Error(`no asset URL for ${name}`);
|
|
285
|
+
}
|
|
286
|
+
return hit.url;
|
|
287
|
+
});
|
|
262
288
|
await pruneOrphanedFarqAssets(cwd, tag);
|
|
263
|
-
return
|
|
289
|
+
return urls;
|
|
264
290
|
}
|
package/dist/render/pr.d.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import type { ChangeSummary } from "../schema.js";
|
|
2
|
+
export type PrImage = {
|
|
3
|
+
path: string;
|
|
4
|
+
title?: string;
|
|
5
|
+
};
|
|
2
6
|
export type RenderPrOptions = {
|
|
3
7
|
summary: ChangeSummary;
|
|
8
|
+
/** @deprecated prefer images */
|
|
4
9
|
imagePath?: string | null;
|
|
5
10
|
imageAlt?: string;
|
|
11
|
+
images?: PrImage[];
|
|
6
12
|
};
|
|
7
13
|
export declare function renderPr(options: RenderPrOptions): string;
|
package/dist/render/pr.js
CHANGED
|
@@ -18,13 +18,27 @@ export function renderPr(options) {
|
|
|
18
18
|
out.push("");
|
|
19
19
|
out.push(overflow);
|
|
20
20
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
21
|
+
const images = normalizeImages(options);
|
|
22
|
+
if (images.length > 0) {
|
|
23
|
+
const anyLocal = images.some((img) => !isHostedImageUrl(img.path));
|
|
24
|
+
if (images.length === 1) {
|
|
25
|
+
out.push("");
|
|
26
|
+
out.push("### Before / After");
|
|
27
|
+
out.push("");
|
|
28
|
+
const alt = images[0].title ?? options.imageAlt ?? "before / after";
|
|
29
|
+
out.push(``);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
out.push("");
|
|
33
|
+
out.push("### Visuals");
|
|
34
|
+
for (const img of images) {
|
|
35
|
+
out.push("");
|
|
36
|
+
out.push(`#### ${img.title ?? "Before / After"}`);
|
|
37
|
+
out.push("");
|
|
38
|
+
out.push(``);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (anyLocal) {
|
|
28
42
|
out.push("");
|
|
29
43
|
out.push(LOCAL_IMAGE_NOTE);
|
|
30
44
|
}
|
|
@@ -49,3 +63,11 @@ export function renderPr(options) {
|
|
|
49
63
|
}
|
|
50
64
|
return `${title}\n\n${out.join("\n").trim()}\n`;
|
|
51
65
|
}
|
|
66
|
+
function normalizeImages(options) {
|
|
67
|
+
if (options.images && options.images.length > 0)
|
|
68
|
+
return options.images;
|
|
69
|
+
if (options.imagePath) {
|
|
70
|
+
return [{ path: options.imagePath, title: options.imageAlt }];
|
|
71
|
+
}
|
|
72
|
+
return [];
|
|
73
|
+
}
|
package/dist/template.d.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import type { ChangeSummary } from "./schema.js";
|
|
2
2
|
export declare function findPrTemplate(cwd: string): string | null;
|
|
3
|
+
export type TemplateImage = {
|
|
4
|
+
url: string;
|
|
5
|
+
title?: string;
|
|
6
|
+
};
|
|
3
7
|
export declare function fillPrTemplate(options: {
|
|
4
8
|
template: string | null;
|
|
5
9
|
summary: ChangeSummary;
|
|
6
10
|
bodyMarkdown: string;
|
|
11
|
+
/** @deprecated prefer images */
|
|
7
12
|
imageUrl?: string | null;
|
|
13
|
+
images?: TemplateImage[];
|
|
8
14
|
}): {
|
|
9
15
|
title: string;
|
|
10
16
|
body: string;
|
package/dist/template.js
CHANGED
|
@@ -19,23 +19,16 @@ export function findPrTemplate(cwd) {
|
|
|
19
19
|
}
|
|
20
20
|
export function fillPrTemplate(options) {
|
|
21
21
|
const { title, overflow } = truncateTitle(options.summary.headline, GITHUB_TITLE_MAX);
|
|
22
|
+
const images = normalizeTemplateImages(options);
|
|
22
23
|
let body = options.template?.trim() || "";
|
|
23
24
|
if (!body) {
|
|
24
25
|
body = options.bodyMarkdown;
|
|
25
26
|
if (overflow)
|
|
26
27
|
body = overflow + "\n\n" + body;
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
body = body.replace(re, "");
|
|
32
|
-
}
|
|
33
|
-
else {
|
|
34
|
-
body =
|
|
35
|
-
"### Before / After\n\n\n\n" +
|
|
38
|
-
body;
|
|
28
|
+
if (images.length > 0) {
|
|
29
|
+
body = replaceLocalImages(body, images);
|
|
30
|
+
if (!hasMarkdownImage(body)) {
|
|
31
|
+
body = renderImageMarkdown(images) + "\n\n" + body;
|
|
39
32
|
}
|
|
40
33
|
body = stripLocalImageNote(body);
|
|
41
34
|
}
|
|
@@ -51,20 +44,48 @@ export function fillPrTemplate(options) {
|
|
|
51
44
|
body = fillSection(body, /test\s*plan|testing|how to test/i, testPlan);
|
|
52
45
|
if (overflow)
|
|
53
46
|
body = overflow + "\n\n" + body;
|
|
54
|
-
if (
|
|
55
|
-
|
|
56
|
-
|
|
47
|
+
if (images.length > 0) {
|
|
48
|
+
const md = renderImageMarkdown(images);
|
|
49
|
+
if (/before\s*\/\s*after|screenshots?|visuals?/i.test(body)) {
|
|
50
|
+
body = fillSection(body, /before\s*\/\s*after|screenshots?|visuals?/i, md.replace(/^###[^\n]+\n+/, "").trim());
|
|
57
51
|
}
|
|
58
52
|
else {
|
|
59
|
-
body +=
|
|
60
|
-
"\n\n### Before / After\n\n\n";
|
|
53
|
+
body += "\n\n" + md + "\n";
|
|
63
54
|
}
|
|
64
55
|
body = stripLocalImageNote(body);
|
|
65
56
|
}
|
|
66
57
|
return { title, body };
|
|
67
58
|
}
|
|
59
|
+
function normalizeTemplateImages(options) {
|
|
60
|
+
if (options.images && options.images.length > 0)
|
|
61
|
+
return options.images;
|
|
62
|
+
if (options.imageUrl)
|
|
63
|
+
return [{ url: options.imageUrl }];
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
function hasMarkdownImage(body) {
|
|
67
|
+
return /!\[[^\]]*\]\([^)]+\)/.test(body);
|
|
68
|
+
}
|
|
69
|
+
function replaceLocalImages(body, images) {
|
|
70
|
+
let i = 0;
|
|
71
|
+
return body.replace(/!\[([^\]]*)\]\((?!https?:\/\/)[^)]+\)/g, (_m, alt) => {
|
|
72
|
+
const img = images[Math.min(i, images.length - 1)];
|
|
73
|
+
i += 1;
|
|
74
|
+
return ``;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function renderImageMarkdown(images) {
|
|
78
|
+
if (images.length === 1) {
|
|
79
|
+
const alt = images[0].title ?? "before / after";
|
|
80
|
+
return `### Before / After\n\n`;
|
|
81
|
+
}
|
|
82
|
+
const parts = ["### Visuals"];
|
|
83
|
+
for (const img of images) {
|
|
84
|
+
const alt = img.title ?? "before / after";
|
|
85
|
+
parts.push("", `#### ${alt}`, "", ``);
|
|
86
|
+
}
|
|
87
|
+
return parts.join("\n");
|
|
88
|
+
}
|
|
68
89
|
function fillSection(template, heading, content) {
|
|
69
90
|
const lines = template.split("\n");
|
|
70
91
|
let inSection = false;
|
package/dist/visual/chrome.js
CHANGED
|
@@ -2,6 +2,7 @@ import { execa } from "execa";
|
|
|
2
2
|
import { platform } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { resolveExecutable, ToolNotFoundError, } from "../tools.js";
|
|
5
|
+
import { clampViewport } from "./viewport.js";
|
|
5
6
|
const TIMEOUT_MS = 30_000;
|
|
6
7
|
export class ChromeError extends Error {
|
|
7
8
|
constructor(message) {
|
|
@@ -55,8 +56,10 @@ export function resolveChrome(env = process.env) {
|
|
|
55
56
|
}
|
|
56
57
|
export async function screenshotHtml(options) {
|
|
57
58
|
const chrome = options.chromePath ?? resolveChrome();
|
|
58
|
-
const width
|
|
59
|
-
|
|
59
|
+
const { width, height } = clampViewport({
|
|
60
|
+
width: options.width,
|
|
61
|
+
height: options.height,
|
|
62
|
+
});
|
|
60
63
|
try {
|
|
61
64
|
await execa(chrome, [
|
|
62
65
|
"--headless=new",
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ChangeItem, ChangeSummary } from "../schema.js";
|
|
2
|
+
import type { Provider } from "../providers/index.js";
|
|
3
|
+
export declare const MAX_VISUAL_TOPICS = 5;
|
|
4
|
+
export type VisualTopic = {
|
|
5
|
+
id: number;
|
|
6
|
+
title: string;
|
|
7
|
+
items: ChangeItem[];
|
|
8
|
+
files: string[];
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Prefer intent clustering via the cheap model; fall back to file-overlap.
|
|
12
|
+
* Same-theme items (one feature across many files) → one topic.
|
|
13
|
+
* Truly unrelated domains → separate topics (cap 5).
|
|
14
|
+
*/
|
|
15
|
+
export declare function clusterVisualTopics(summary: ChangeSummary, options?: {
|
|
16
|
+
provider?: Provider;
|
|
17
|
+
model?: string;
|
|
18
|
+
log?: (msg: string) => void;
|
|
19
|
+
}): Promise<VisualTopic[]>;
|
|
20
|
+
/** File-overlap union-find (fallback). Exported for tests. */
|
|
21
|
+
export declare function clusterByFileOverlap(summary: ChangeSummary): VisualTopic[];
|
|
22
|
+
/** Build topics from model JSON; null if invalid. Exported for tests. */
|
|
23
|
+
export declare function topicsFromIntentJson(summary: ChangeSummary, raw: unknown): VisualTopic[] | null;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { extractJson } from "../extract-json.js";
|
|
2
|
+
export const MAX_VISUAL_TOPICS = 5;
|
|
3
|
+
/**
|
|
4
|
+
* Prefer intent clustering via the cheap model; fall back to file-overlap.
|
|
5
|
+
* Same-theme items (one feature across many files) → one topic.
|
|
6
|
+
* Truly unrelated domains → separate topics (cap 5).
|
|
7
|
+
*/
|
|
8
|
+
export async function clusterVisualTopics(summary, options) {
|
|
9
|
+
const items = summary.items;
|
|
10
|
+
if (items.length === 0)
|
|
11
|
+
return [];
|
|
12
|
+
if (items.length === 1) {
|
|
13
|
+
return [
|
|
14
|
+
{
|
|
15
|
+
id: 1,
|
|
16
|
+
title: items[0].title.slice(0, 80),
|
|
17
|
+
items,
|
|
18
|
+
files: uniqueFiles(items),
|
|
19
|
+
},
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
if (options?.provider) {
|
|
23
|
+
try {
|
|
24
|
+
const ai = await clusterByIntent(summary, options.provider, options.model);
|
|
25
|
+
if (ai) {
|
|
26
|
+
options.log?.(`visual topics (intent): ${ai.length} — ${ai.map((t) => t.title).join("; ")}`);
|
|
27
|
+
return ai;
|
|
28
|
+
}
|
|
29
|
+
options.log?.("visual topic intent clustering failed; using file overlap");
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
33
|
+
options.log?.(`visual topic intent clustering error: ${msg}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return clusterByFileOverlap(summary);
|
|
37
|
+
}
|
|
38
|
+
/** File-overlap union-find (fallback). Exported for tests. */
|
|
39
|
+
export function clusterByFileOverlap(summary) {
|
|
40
|
+
const items = summary.items;
|
|
41
|
+
if (items.length === 0)
|
|
42
|
+
return [];
|
|
43
|
+
const parent = items.map((_, i) => i);
|
|
44
|
+
const find = (i) => parent[i] === i ? i : (parent[i] = find(parent[i]));
|
|
45
|
+
const union = (a, b) => {
|
|
46
|
+
parent[find(a)] = find(b);
|
|
47
|
+
};
|
|
48
|
+
for (let i = 0; i < items.length; i++) {
|
|
49
|
+
for (let j = i + 1; j < items.length; j++) {
|
|
50
|
+
const a = items[i];
|
|
51
|
+
const b = items[j];
|
|
52
|
+
if (a.files.length > 0 &&
|
|
53
|
+
b.files.length > 0 &&
|
|
54
|
+
filesOverlap(a.files, b.files)) {
|
|
55
|
+
union(i, j);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const groups = new Map();
|
|
60
|
+
for (let i = 0; i < items.length; i++) {
|
|
61
|
+
const root = find(i);
|
|
62
|
+
const list = groups.get(root) ?? [];
|
|
63
|
+
list.push(items[i]);
|
|
64
|
+
groups.set(root, list);
|
|
65
|
+
}
|
|
66
|
+
let clusters = [...groups.values()].map((groupItems, idx) => ({
|
|
67
|
+
id: idx + 1,
|
|
68
|
+
title: topicTitle(groupItems),
|
|
69
|
+
items: groupItems,
|
|
70
|
+
files: uniqueFiles(groupItems),
|
|
71
|
+
}));
|
|
72
|
+
while (clusters.length > MAX_VISUAL_TOPICS) {
|
|
73
|
+
clusters.sort((a, b) => a.items.length - b.items.length || a.files.length - b.files.length);
|
|
74
|
+
const smallest = clusters[0];
|
|
75
|
+
const next = clusters[1];
|
|
76
|
+
const rest = clusters.slice(2);
|
|
77
|
+
clusters = [
|
|
78
|
+
{
|
|
79
|
+
id: 0,
|
|
80
|
+
title: topicTitle([...smallest.items, ...next.items]),
|
|
81
|
+
items: [...smallest.items, ...next.items],
|
|
82
|
+
files: uniqueFiles([...smallest.items, ...next.items]),
|
|
83
|
+
},
|
|
84
|
+
...rest,
|
|
85
|
+
];
|
|
86
|
+
}
|
|
87
|
+
return finalizeTopics(clusters);
|
|
88
|
+
}
|
|
89
|
+
/** Build topics from model JSON; null if invalid. Exported for tests. */
|
|
90
|
+
export function topicsFromIntentJson(summary, raw) {
|
|
91
|
+
const items = summary.items;
|
|
92
|
+
if (!raw || typeof raw !== "object")
|
|
93
|
+
return null;
|
|
94
|
+
const topics = raw.topics;
|
|
95
|
+
if (!Array.isArray(topics) || topics.length === 0)
|
|
96
|
+
return null;
|
|
97
|
+
if (topics.length > MAX_VISUAL_TOPICS)
|
|
98
|
+
return null;
|
|
99
|
+
const used = new Set();
|
|
100
|
+
const built = [];
|
|
101
|
+
for (const t of topics) {
|
|
102
|
+
if (!t || typeof t !== "object")
|
|
103
|
+
return null;
|
|
104
|
+
const title = String(t.title ?? "").trim();
|
|
105
|
+
const indices = t.item_indices;
|
|
106
|
+
if (!title || !Array.isArray(indices) || indices.length === 0)
|
|
107
|
+
return null;
|
|
108
|
+
const groupItems = [];
|
|
109
|
+
for (const idx of indices) {
|
|
110
|
+
if (typeof idx !== "number" || !Number.isInteger(idx))
|
|
111
|
+
return null;
|
|
112
|
+
if (idx < 0 || idx >= items.length || used.has(idx))
|
|
113
|
+
return null;
|
|
114
|
+
used.add(idx);
|
|
115
|
+
groupItems.push(items[idx]);
|
|
116
|
+
}
|
|
117
|
+
built.push({
|
|
118
|
+
id: built.length + 1,
|
|
119
|
+
title: title.slice(0, 80),
|
|
120
|
+
items: groupItems,
|
|
121
|
+
files: uniqueFiles(groupItems),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
// Every item must appear exactly once.
|
|
125
|
+
if (used.size !== items.length)
|
|
126
|
+
return null;
|
|
127
|
+
return finalizeTopics(built);
|
|
128
|
+
}
|
|
129
|
+
async function clusterByIntent(summary, provider, model) {
|
|
130
|
+
const listed = summary.items
|
|
131
|
+
.map((item, i) => `${i}. [${item.category}] ${item.title} — ${item.description} (files: ${item.files.join(", ") || "none"})`)
|
|
132
|
+
.join("\n");
|
|
133
|
+
const prompt = `You group change-summary items into visual topics for before/after diagrams.
|
|
134
|
+
|
|
135
|
+
Rules:
|
|
136
|
+
- If items all advance the SAME headline/feature/story, return EXACTLY ONE topic with every item index — even across many files, docs, prompts, tests, and upload plumbing.
|
|
137
|
+
- Example: clustering + pipeline + PR render + asset upload + README + prompt polish for "multi-image visuals" → one topic.
|
|
138
|
+
- Only create multiple topics when a reviewer would treat them as separate product changes (e.g. a UI redesign AND an unrelated billing API).
|
|
139
|
+
- Prompt/style tweaks, tests, and docs for a feature stay in that feature's topic.
|
|
140
|
+
- Maximum ${MAX_VISUAL_TOPICS} topics. Prefer fewer; one is best when unsure.
|
|
141
|
+
- Every item index must appear in exactly one topic. Use 0-based indices.
|
|
142
|
+
- Topic titles: short, human, <=80 chars.
|
|
143
|
+
|
|
144
|
+
Return JSON only:
|
|
145
|
+
{"topics":[{"title":"...","item_indices":[0,1,2]}]}
|
|
146
|
+
|
|
147
|
+
Headline: ${summary.headline}
|
|
148
|
+
Overview: ${summary.overview}
|
|
149
|
+
|
|
150
|
+
Items:
|
|
151
|
+
${listed}
|
|
152
|
+
`;
|
|
153
|
+
const raw = await provider.complete(prompt, { model });
|
|
154
|
+
const json = extractJson(raw);
|
|
155
|
+
return topicsFromIntentJson(summary, json);
|
|
156
|
+
}
|
|
157
|
+
function finalizeTopics(clusters) {
|
|
158
|
+
return clusters.map((c, i) => ({
|
|
159
|
+
...c,
|
|
160
|
+
id: i + 1,
|
|
161
|
+
title: c.title.slice(0, 80),
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
function topicTitle(items) {
|
|
165
|
+
if (items.length === 1)
|
|
166
|
+
return items[0].title;
|
|
167
|
+
if (items.length === 2)
|
|
168
|
+
return `${items[0].title}; ${items[1].title}`;
|
|
169
|
+
return `${items[0].title} (+${items.length - 1} more)`;
|
|
170
|
+
}
|
|
171
|
+
function uniqueFiles(items) {
|
|
172
|
+
return [...new Set(items.flatMap((i) => i.files))];
|
|
173
|
+
}
|
|
174
|
+
function filesOverlap(a, b) {
|
|
175
|
+
const set = new Set(a.map(normalizePath));
|
|
176
|
+
return b.some((f) => set.has(normalizePath(f)));
|
|
177
|
+
}
|
|
178
|
+
function normalizePath(p) {
|
|
179
|
+
return p.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
180
|
+
}
|
package/dist/visual/compose.d.ts
CHANGED
|
@@ -6,6 +6,8 @@ export type ComposeOptions = {
|
|
|
6
6
|
afterPath: string;
|
|
7
7
|
badge?: "generated preview" | "before / after";
|
|
8
8
|
chromePath?: string;
|
|
9
|
+
/** Output PNG basename (default before-after.png). */
|
|
10
|
+
outFileName?: string;
|
|
9
11
|
};
|
|
10
12
|
export declare function buildComposeHtml(options: {
|
|
11
13
|
beforeBase64: string;
|
package/dist/visual/compose.js
CHANGED
|
@@ -3,6 +3,7 @@ import { join, resolve } from "node:path";
|
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
4
|
import { defaultOutDir } from "../paths.js";
|
|
5
5
|
import { resolveChrome, screenshotHtml, ChromeError } from "./chrome.js";
|
|
6
|
+
import { DEFAULT_VIEWPORT } from "./viewport.js";
|
|
6
7
|
export function buildComposeHtml(options) {
|
|
7
8
|
const { beforeBase64, afterBase64, badge } = options;
|
|
8
9
|
return `<!doctype html>
|
|
@@ -43,16 +44,17 @@ export async function composeBeforeAfter(options) {
|
|
|
43
44
|
afterBase64: after.toString("base64"),
|
|
44
45
|
badge,
|
|
45
46
|
});
|
|
46
|
-
const
|
|
47
|
+
const stem = (options.outFileName ?? "before-after.png").replace(/\.png$/i, "");
|
|
48
|
+
const composePath = join(outDir, `${stem}-compose.html`);
|
|
47
49
|
writeFileSync(composePath, html, "utf8");
|
|
48
|
-
const outPng = join(outDir,
|
|
50
|
+
const outPng = join(outDir, `${stem}.png`);
|
|
49
51
|
const chromePath = options.chromePath ?? resolveChrome();
|
|
50
52
|
await screenshotHtml({
|
|
51
53
|
chromePath,
|
|
52
54
|
url: pathToFileURL(composePath).href,
|
|
53
55
|
outPath: outPng,
|
|
54
|
-
width:
|
|
55
|
-
height:
|
|
56
|
+
width: DEFAULT_VIEWPORT.width,
|
|
57
|
+
height: DEFAULT_VIEWPORT.height,
|
|
56
58
|
});
|
|
57
59
|
return outPng;
|
|
58
60
|
}
|
package/dist/visual/diagram.d.ts
CHANGED
package/dist/visual/diagram.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { extractJson } from "../extract-json.js";
|
|
4
|
+
import { VIEWPORT_MAX_HEIGHT, VIEWPORT_MAX_WIDTH } from "./viewport.js";
|
|
4
5
|
export async function generateDiagram(options) {
|
|
5
6
|
const prompt = `You create a conceptual before/after flowchart as one self-contained HTML document.
|
|
6
7
|
|
|
7
8
|
This is the fallback visual when a pixel UI mockup is not appropriate (API, logic, docs, config, etc.). Prefer a small flowchart or labeled before/after boxes that explain the change at a glance.
|
|
8
9
|
|
|
9
10
|
Rules:
|
|
10
|
-
- Pure HTML/CSS, no libraries, no external requests
|
|
11
|
+
- Pure HTML/CSS, no libraries, no external requests except optional @import fonts from fonts.googleapis.com or fonts.bunny.net.
|
|
11
12
|
- Two labeled columns (Before / After) with simple boxes/arrows (flowchart style).
|
|
12
13
|
- Concept-level only: field/endpoint/step names OK. NO code, NO syntax, NO JSON dumps, NO walls of text.
|
|
14
|
+
- FRAME LIMIT (hard): the screenshot is exactly ${VIEWPORT_MAX_WIDTH}x${VIEWPORT_MAX_HEIGHT}px. Set html,body { margin:0; width:100%; height:100%; overflow:hidden }. The entire flowchart must fit in that frame with no scrolling and no clipped boxes or footer text. Prefer fewer/shorter steps over a tall diagram.
|
|
15
|
+
- Visual craft: one clear composition, expressive typography, subtle atmospheric background (gradient or soft pattern), high contrast. Avoid purple-gradient clichés, neon glow, and cluttered sticker UI.
|
|
13
16
|
- Include a small corner badge text: generated preview
|
|
14
17
|
- If you truly cannot produce a faithful conceptual visual from the diff alone, return {"feasible": false, "reason": "..."}. Prefer a simple flowchart over declining.
|
|
15
18
|
|
|
@@ -30,7 +33,8 @@ ${options.diffText}
|
|
|
30
33
|
return { feasible: false, reason: json.reason ?? "not feasible" };
|
|
31
34
|
}
|
|
32
35
|
mkdirSync(options.outDir, { recursive: true });
|
|
33
|
-
const
|
|
36
|
+
const prefix = options.filePrefix ?? "";
|
|
37
|
+
const htmlPath = join(options.outDir, `${prefix}diagram.html`);
|
|
34
38
|
writeFileSync(htmlPath, json.html, "utf8");
|
|
35
39
|
return { feasible: true, htmlPath };
|
|
36
40
|
}
|
package/dist/visual/mockup.d.ts
CHANGED
package/dist/visual/mockup.js
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { extractJson } from "../extract-json.js";
|
|
4
|
+
import { DEFAULT_VIEWPORT, VIEWPORT_MAX_HEIGHT, VIEWPORT_MAX_WIDTH, clampViewport, } from "./viewport.js";
|
|
4
5
|
export async function generateMockup(options) {
|
|
5
6
|
const prompt = `You create faithful before/after HTML mockups from a git diff.
|
|
6
7
|
|
|
7
8
|
Rules:
|
|
8
|
-
- The diff contains exact before/after markup/styles. Emit two fully self-contained HTML documents (inline CSS, no external requests
|
|
9
|
+
- The diff contains exact before/after markup/styles. Emit two fully self-contained HTML documents (inline CSS, no external requests) that render this exact markup with these exact styles.
|
|
9
10
|
- Stub only the minimum: container width, placeholder text where dynamic props appear (realistic neutral placeholders).
|
|
10
11
|
- Do not invent UI that is not present in the code.
|
|
11
12
|
- Never show raw code listings.
|
|
13
|
+
- FRAME LIMIT (hard): design for exactly ${VIEWPORT_MAX_WIDTH}x${VIEWPORT_MAX_HEIGHT}px. html/body must be width/height 100% with overflow:hidden. All content must fit in that single screen — no scrolling, no content cut off at the bottom. Prefer denser layout over tall pages.
|
|
14
|
+
- Visual craft: one clear composition, strong typographic hierarchy, purposeful (non-default) web fonts via @import from fonts.googleapis.com or fonts.bunny.net if helpful, atmospheric background (subtle gradient or pattern — not flat single-color), high contrast for text. No purple-on-white clichés, no glow spam, no floating badge stickers on the mockup itself.
|
|
12
15
|
- If you cannot render faithfully, return {"feasible": false, "reason": "..."}.
|
|
13
16
|
|
|
14
17
|
Return JSON only:
|
|
15
|
-
{"feasible": true, "before_html": "...", "after_html": "...", "viewport": {"width"
|
|
18
|
+
{"feasible": true, "before_html": "...", "after_html": "...", "viewport": {"width":${DEFAULT_VIEWPORT.width},"height":${DEFAULT_VIEWPORT.height}}}
|
|
16
19
|
or {"feasible": false, "reason": "..."}
|
|
17
20
|
|
|
18
21
|
Change summary context:
|
|
@@ -33,14 +36,15 @@ ${options.files
|
|
|
33
36
|
return { feasible: false, reason: "missing before_html/after_html" };
|
|
34
37
|
}
|
|
35
38
|
mkdirSync(options.outDir, { recursive: true });
|
|
36
|
-
const
|
|
37
|
-
const
|
|
39
|
+
const prefix = options.filePrefix ?? "";
|
|
40
|
+
const beforePath = join(options.outDir, `${prefix}before.html`);
|
|
41
|
+
const afterPath = join(options.outDir, `${prefix}after.html`);
|
|
38
42
|
writeFileSync(beforePath, json.before_html, "utf8");
|
|
39
43
|
writeFileSync(afterPath, json.after_html, "utf8");
|
|
40
44
|
return {
|
|
41
45
|
feasible: true,
|
|
42
46
|
beforePath,
|
|
43
47
|
afterPath,
|
|
44
|
-
viewport: json.viewport,
|
|
48
|
+
viewport: clampViewport(json.viewport),
|
|
45
49
|
};
|
|
46
50
|
}
|
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import type { ChangeSummary } from "../schema.js";
|
|
2
2
|
import type { Provider } from "../providers/index.js";
|
|
3
3
|
import type { GatherDiffResult } from "../git.js";
|
|
4
|
+
export type VisualImage = {
|
|
5
|
+
path: string;
|
|
6
|
+
title: string;
|
|
7
|
+
};
|
|
4
8
|
export type VisualPipelineResult = {
|
|
5
9
|
imagePath: string | null;
|
|
6
10
|
images: string[];
|
|
11
|
+
imageMeta: VisualImage[];
|
|
7
12
|
softDegraded: boolean;
|
|
8
13
|
warning?: string;
|
|
9
14
|
};
|
package/dist/visual/pipeline.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { copyFileSync, mkdirSync } from "node:fs";
|
|
2
|
-
import { join, resolve } from "node:path";
|
|
2
|
+
import { basename, join, resolve } from "node:path";
|
|
3
3
|
import { pathToFileURL } from "node:url";
|
|
4
4
|
import { gatherVisualFileContents } from "../git.js";
|
|
5
5
|
import { decideGate } from "./gate.js";
|
|
6
6
|
import { generateMockup } from "./mockup.js";
|
|
7
7
|
import { generateDiagram } from "./diagram.js";
|
|
8
|
+
import { clusterVisualTopics } from "./cluster.js";
|
|
8
9
|
import { defaultOutDir } from "../paths.js";
|
|
9
10
|
import { composeBeforeAfter, ChromeError } from "./compose.js";
|
|
10
11
|
import { screenshotHtml, resolveChrome } from "./chrome.js";
|
|
12
|
+
import { DEFAULT_VIEWPORT, clampViewport } from "./viewport.js";
|
|
11
13
|
export async function runVisualPipeline(options) {
|
|
12
14
|
const cwd = options.cwd ?? process.cwd();
|
|
13
15
|
const outDir = resolve(cwd, options.outDir ?? defaultOutDir(cwd));
|
|
@@ -17,9 +19,8 @@ export async function runVisualPipeline(options) {
|
|
|
17
19
|
log(msg);
|
|
18
20
|
};
|
|
19
21
|
if (options.noImages) {
|
|
20
|
-
return
|
|
22
|
+
return emptyResult(false);
|
|
21
23
|
}
|
|
22
|
-
// Manual screenshots skip generation
|
|
23
24
|
if (options.before && options.after) {
|
|
24
25
|
try {
|
|
25
26
|
mkdirSync(outDir, { recursive: true });
|
|
@@ -33,95 +34,180 @@ export async function runVisualPipeline(options) {
|
|
|
33
34
|
beforePath: beforeCopy,
|
|
34
35
|
afterPath: afterCopy,
|
|
35
36
|
badge: "before / after",
|
|
37
|
+
outFileName: "visual-1.png",
|
|
36
38
|
});
|
|
37
|
-
return
|
|
39
|
+
return singleResult(composed, "before / after");
|
|
38
40
|
}
|
|
39
41
|
catch (err) {
|
|
40
42
|
const msg = err instanceof Error ? err.message : String(err);
|
|
41
43
|
throw new ChromeError(msg);
|
|
42
44
|
}
|
|
43
45
|
}
|
|
44
|
-
const gate = decideGate(options.diff.files);
|
|
45
|
-
vlog(`visual gate: ${gate}`);
|
|
46
|
-
if (gate === "none") {
|
|
47
|
-
return { imagePath: null, images: [], softDegraded: false };
|
|
48
|
-
}
|
|
49
46
|
try {
|
|
50
47
|
resolveChrome();
|
|
51
48
|
}
|
|
52
49
|
catch (err) {
|
|
53
50
|
const msg = err instanceof Error ? err.message : String(err);
|
|
54
51
|
return {
|
|
55
|
-
|
|
56
|
-
images: [],
|
|
57
|
-
softDegraded: true,
|
|
52
|
+
...emptyResult(true),
|
|
58
53
|
warning: msg,
|
|
59
54
|
};
|
|
60
55
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
56
|
+
const topics = await clusterVisualTopics(options.summary, {
|
|
57
|
+
provider: options.provider,
|
|
58
|
+
model: options.modelCheap,
|
|
59
|
+
log: vlog,
|
|
60
|
+
});
|
|
61
|
+
vlog(`visual topics: ${topics.length}`);
|
|
62
|
+
if (topics.length === 0) {
|
|
63
|
+
return emptyResult(false);
|
|
64
|
+
}
|
|
65
|
+
const imageMeta = [];
|
|
66
|
+
const warnings = [];
|
|
67
|
+
for (const topic of topics) {
|
|
68
|
+
try {
|
|
69
|
+
const path = await renderTopic({
|
|
70
|
+
topic,
|
|
71
|
+
cwd,
|
|
68
72
|
outDir,
|
|
69
|
-
|
|
70
|
-
|
|
73
|
+
summary: options.summary,
|
|
74
|
+
diff: options.diff,
|
|
75
|
+
provider: options.provider,
|
|
76
|
+
modelCheap: options.modelCheap,
|
|
77
|
+
vlog,
|
|
71
78
|
});
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
-
const afterPng = join(outDir, "after.png");
|
|
75
|
-
await screenshotHtml({
|
|
76
|
-
url: pathToFileURL(mockup.beforePath).href,
|
|
77
|
-
outPath: beforePng,
|
|
78
|
-
width: mockup.viewport?.width,
|
|
79
|
-
height: mockup.viewport?.height,
|
|
80
|
-
});
|
|
81
|
-
await screenshotHtml({
|
|
82
|
-
url: pathToFileURL(mockup.afterPath).href,
|
|
83
|
-
outPath: afterPng,
|
|
84
|
-
width: mockup.viewport?.width,
|
|
85
|
-
height: mockup.viewport?.height,
|
|
86
|
-
});
|
|
87
|
-
const composed = await composeBeforeAfter({
|
|
88
|
-
cwd,
|
|
89
|
-
outDir,
|
|
90
|
-
beforePath: beforePng,
|
|
91
|
-
afterPath: afterPng,
|
|
92
|
-
badge: "generated preview",
|
|
93
|
-
});
|
|
94
|
-
return { imagePath: composed, images: [composed], softDegraded: false };
|
|
79
|
+
if (path) {
|
|
80
|
+
imageMeta.push({ path, title: topic.title });
|
|
95
81
|
}
|
|
96
|
-
vlog(`mockup infeasible: ${mockup.reason}; trying diagram`);
|
|
97
82
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
83
|
+
catch (err) {
|
|
84
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
85
|
+
warnings.push(`topic "${topic.title}": ${msg}`);
|
|
86
|
+
vlog(`topic failed: ${msg}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (imageMeta.length === 0) {
|
|
90
|
+
return {
|
|
91
|
+
...emptyResult(warnings.length > 0),
|
|
92
|
+
warning: warnings[0],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
imagePath: imageMeta[0].path,
|
|
97
|
+
images: imageMeta.map((i) => i.path),
|
|
98
|
+
imageMeta,
|
|
99
|
+
softDegraded: warnings.length > 0,
|
|
100
|
+
warning: warnings.length > 0 ? warnings.join("; ") : undefined,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
async function renderTopic(options) {
|
|
104
|
+
const { topic, cwd, outDir, provider, modelCheap, vlog } = options;
|
|
105
|
+
const prefix = `visual-${topic.id}-`;
|
|
106
|
+
const stem = `visual-${topic.id}`;
|
|
107
|
+
const topicSummary = scopeSummary(options.summary, topic);
|
|
108
|
+
const topicFiles = filterDiffFiles(options.diff.files, topic.files);
|
|
109
|
+
const gate = topicFiles.length > 0
|
|
110
|
+
? decideGate(topicFiles)
|
|
111
|
+
: decideGate(options.diff.files);
|
|
112
|
+
vlog(`visual gate [${topic.id} ${topic.title}]: ${gate}`);
|
|
113
|
+
if (gate === "none")
|
|
114
|
+
return null;
|
|
115
|
+
if (gate === "mockup") {
|
|
116
|
+
const files = await gatherVisualFileContents(cwd, topicFiles.length > 0 ? topicFiles : options.diff.files, options.diff.baseRef);
|
|
117
|
+
const mockup = await generateMockup({
|
|
118
|
+
provider,
|
|
119
|
+
summary: topicSummary,
|
|
120
|
+
files,
|
|
103
121
|
outDir,
|
|
104
|
-
model:
|
|
122
|
+
model: modelCheap,
|
|
105
123
|
log: vlog,
|
|
124
|
+
filePrefix: prefix,
|
|
106
125
|
});
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
|
|
126
|
+
if (mockup.feasible) {
|
|
127
|
+
const beforePng = join(outDir, `${prefix}before.png`);
|
|
128
|
+
const afterPng = join(outDir, `${prefix}after.png`);
|
|
129
|
+
const vp = clampViewport(mockup.viewport);
|
|
130
|
+
await screenshotHtml({
|
|
131
|
+
url: pathToFileURL(mockup.beforePath).href,
|
|
132
|
+
outPath: beforePng,
|
|
133
|
+
width: vp.width,
|
|
134
|
+
height: vp.height,
|
|
135
|
+
});
|
|
136
|
+
await screenshotHtml({
|
|
137
|
+
url: pathToFileURL(mockup.afterPath).href,
|
|
138
|
+
outPath: afterPng,
|
|
139
|
+
width: vp.width,
|
|
140
|
+
height: vp.height,
|
|
141
|
+
});
|
|
142
|
+
return composeBeforeAfter({
|
|
143
|
+
cwd,
|
|
144
|
+
outDir,
|
|
145
|
+
beforePath: beforePng,
|
|
146
|
+
afterPath: afterPng,
|
|
147
|
+
badge: "generated preview",
|
|
148
|
+
outFileName: `${stem}.png`,
|
|
149
|
+
});
|
|
110
150
|
}
|
|
111
|
-
|
|
112
|
-
await screenshotHtml({
|
|
113
|
-
url: pathToFileURL(diagram.htmlPath).href,
|
|
114
|
-
outPath: outPng,
|
|
115
|
-
});
|
|
116
|
-
return { imagePath: outPng, images: [outPng], softDegraded: false };
|
|
151
|
+
vlog(`mockup infeasible [${topic.id}]: ${mockup.reason}; trying diagram`);
|
|
117
152
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
153
|
+
const diagram = await generateDiagram({
|
|
154
|
+
provider,
|
|
155
|
+
summary: topicSummary,
|
|
156
|
+
diffText: scopeDiffText(options.diff.diffText, topic.files),
|
|
157
|
+
outDir,
|
|
158
|
+
model: modelCheap,
|
|
159
|
+
log: vlog,
|
|
160
|
+
filePrefix: prefix,
|
|
161
|
+
});
|
|
162
|
+
if (!diagram.feasible) {
|
|
163
|
+
vlog(`diagram infeasible [${topic.id}]: ${diagram.reason}`);
|
|
164
|
+
return null;
|
|
126
165
|
}
|
|
166
|
+
const outPng = join(outDir, `${stem}.png`);
|
|
167
|
+
await screenshotHtml({
|
|
168
|
+
url: pathToFileURL(diagram.htmlPath).href,
|
|
169
|
+
outPath: outPng,
|
|
170
|
+
width: DEFAULT_VIEWPORT.width,
|
|
171
|
+
height: DEFAULT_VIEWPORT.height,
|
|
172
|
+
});
|
|
173
|
+
return outPng;
|
|
174
|
+
}
|
|
175
|
+
function scopeSummary(summary, topic) {
|
|
176
|
+
return {
|
|
177
|
+
...summary,
|
|
178
|
+
headline: topic.title.slice(0, 140),
|
|
179
|
+
overview: topic.items.map((i) => i.description).join(" "),
|
|
180
|
+
items: topic.items,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
function filterDiffFiles(files, topicFiles) {
|
|
184
|
+
if (topicFiles.length === 0)
|
|
185
|
+
return files;
|
|
186
|
+
const set = new Set(topicFiles.map((f) => f.replaceAll("\\", "/")));
|
|
187
|
+
return files.filter((f) => set.has(f.path.replaceAll("\\", "/")));
|
|
188
|
+
}
|
|
189
|
+
function scopeDiffText(diffText, topicFiles) {
|
|
190
|
+
if (topicFiles.length === 0 || !diffText)
|
|
191
|
+
return diffText;
|
|
192
|
+
// Keep hunks whose path header mentions a topic file; else full diff.
|
|
193
|
+
const norms = topicFiles.map((f) => f.replaceAll("\\", "/"));
|
|
194
|
+
const parts = diffText.split(/(?=^diff --git )/m);
|
|
195
|
+
const kept = parts.filter((p) => norms.some((f) => p.includes(f) || p.includes(basename(f))));
|
|
196
|
+
return kept.length > 0 ? kept.join("") : diffText;
|
|
197
|
+
}
|
|
198
|
+
function emptyResult(soft) {
|
|
199
|
+
return {
|
|
200
|
+
imagePath: null,
|
|
201
|
+
images: [],
|
|
202
|
+
imageMeta: [],
|
|
203
|
+
softDegraded: soft,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function singleResult(path, title) {
|
|
207
|
+
return {
|
|
208
|
+
imagePath: path,
|
|
209
|
+
images: [path],
|
|
210
|
+
imageMeta: [{ path, title }],
|
|
211
|
+
softDegraded: false,
|
|
212
|
+
};
|
|
127
213
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Hard screenshot frame — Chrome captures the window only, so HTML must fit. */
|
|
2
|
+
export declare const VIEWPORT_MAX_WIDTH = 1280;
|
|
3
|
+
export declare const VIEWPORT_MAX_HEIGHT = 720;
|
|
4
|
+
export declare const DEFAULT_VIEWPORT: {
|
|
5
|
+
readonly width: 1280;
|
|
6
|
+
readonly height: 720;
|
|
7
|
+
};
|
|
8
|
+
export type ViewportSize = {
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
};
|
|
12
|
+
/** Clamp to max frame; invalid/missing values fall back to defaults. */
|
|
13
|
+
export declare function clampViewport(input?: {
|
|
14
|
+
width?: number;
|
|
15
|
+
height?: number;
|
|
16
|
+
} | null): ViewportSize;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Hard screenshot frame — Chrome captures the window only, so HTML must fit. */
|
|
2
|
+
export const VIEWPORT_MAX_WIDTH = 1280;
|
|
3
|
+
export const VIEWPORT_MAX_HEIGHT = 720;
|
|
4
|
+
export const DEFAULT_VIEWPORT = {
|
|
5
|
+
width: VIEWPORT_MAX_WIDTH,
|
|
6
|
+
height: VIEWPORT_MAX_HEIGHT,
|
|
7
|
+
};
|
|
8
|
+
/** Clamp to max frame; invalid/missing values fall back to defaults. */
|
|
9
|
+
export function clampViewport(input) {
|
|
10
|
+
const width = clampDim(input?.width, VIEWPORT_MAX_WIDTH);
|
|
11
|
+
const height = clampDim(input?.height, VIEWPORT_MAX_HEIGHT);
|
|
12
|
+
return { width, height };
|
|
13
|
+
}
|
|
14
|
+
function clampDim(value, max) {
|
|
15
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
16
|
+
return max;
|
|
17
|
+
}
|
|
18
|
+
return Math.min(Math.round(value), max);
|
|
19
|
+
}
|