#!/usr/bin/env node
/**
* Format existing local screenshots or recordings for a GitHub PR body.
* Browser operation belongs to agent-browser; this script only owns the
* deterministic attachment markup and marker replacement contract.
*/
import { existsSync, readFileSync } from "node:fs";
import { extname, relative, resolve } from "node:path";
import { parseArgs } from "node:util";
export const MARKER_START = "";
export const MARKER_END = "";
const IMAGE_EXTENSIONS = new Set([".gif", ".jpeg", ".jpg", ".png", ".webp"]);
const VIDEO_EXTENSIONS = new Set([".mp4", ".mov", ".webm"]);
export function mediaKind(file) {
const extension = extname(file).toLowerCase();
if (IMAGE_EXTENSIONS.has(extension)) return "image";
if (VIDEO_EXTENSIONS.has(extension)) return "video";
throw new Error(`Unsupported media file "${file}"`);
}
export function localRef(file, cwd = process.cwd()) {
const path = relative(cwd, resolve(cwd, file));
if (path === ".." || path.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`)) {
throw new Error(`Media files must be inside the working directory: "${file}"`);
}
const ref = path.startsWith(".") ? path : `./${path}`;
if (/\s/.test(ref)) throw new Error(`Media paths cannot contain whitespace: "${ref}"`);
return ref;
}
export function buildPairs({ before = [], after = [], labels = [] }) {
if (after.length === 0) throw new Error("At least one --after file is required");
if (before.length !== 0 && before.length !== after.length) {
throw new Error("Provide either no --before files or one --before file for every --after file");
}
if (labels.length > after.length) throw new Error("Provide at most one --label for every --after file");
return after.map((afterFile, index) => ({
before: before.length ? before[index] : null,
after: afterFile,
label: labels[index] ?? null,
}));
}
function heading(label) {
return label ? ` (${label})` : "";
}
function imageRef(file, alt, cwd) {
return `})`;
}
function githubAttachmentUrl(value) {
let url;
try {
url = new URL(value);
} catch {
throw new Error(`Invalid GitHub attachment URL: "${value}"`);
}
if (url.protocol !== "https:" || url.hostname !== "github.com" || !url.pathname.startsWith("/user-attachments/assets/")) {
throw new Error(`Expected a https://github.com/user-attachments/assets/... URL: "${value}"`);
}
return url.href;
}
function renderImagePair(pair, cwd, lines) {
const suffix = heading(pair.label);
if (pair.before) {
lines.push(
`| Before${suffix} | After${suffix} |`,
"|:---:|:---:|",
`| ${imageRef(pair.before, "Before", cwd)} | ${imageRef(pair.after, "After", cwd)} |`,
"",
);
} else {
lines.push(`| Preview${suffix} |`, "|:---:|", `| ${imageRef(pair.after, "Preview", cwd)} |`, "");
}
}
function renderVideoPair(pair, cwd, lines) {
const suffix = heading(pair.label);
if (pair.before) {
lines.push(`**Before${suffix}**`, "", imageRef(pair.before, "Before", cwd), "");
}
lines.push(`**${pair.before ? "After" : "Preview"}${suffix}**`, "", imageRef(pair.after, pair.before ? "After" : "Preview", cwd), "");
}
function renderVideoTablePair(pair, lines) {
const suffix = heading(pair.label);
const beforeUrl = pair.before ? githubAttachmentUrl(pair.before) : null;
const afterUrl = githubAttachmentUrl(pair.after);
lines.push("
", " ");
if (beforeUrl) lines.push(` | Before${suffix} | `);
lines.push(` ${beforeUrl ? "After" : "Preview"}${suffix} | `, "
", " ");
if (beforeUrl) lines.push(` | `);
lines.push(` | `, "
", "
", "");
}
export function formatMarkdown(pairs, { attribution, cwd = process.cwd() } = {}) {
const lines = [MARKER_START];
if (attribution) lines.push(`> Before/after by ${attribution}`, "");
for (const pair of pairs) {
const beforeKind = pair.before ? mediaKind(pair.before) : null;
const afterKind = mediaKind(pair.after);
if (beforeKind && beforeKind !== afterKind) {
throw new Error(`Before and after must use the same media type: "${pair.before}" and "${pair.after}"`);
}
if (afterKind === "image") renderImagePair(pair, cwd, lines);
else renderVideoPair(pair, cwd, lines);
}
lines.push(MARKER_END);
return `${lines.join("\n").trim()}\n`;
}
export function formatVideoTables(pairs, { attribution } = {}) {
const lines = [MARKER_START];
if (attribution) lines.push(`> Before/after by ${attribution}`, "");
for (const pair of pairs) renderVideoTablePair(pair, lines);
lines.push(MARKER_END);
return `${lines.join("\n").trim()}\n`;
}
export function attachList(pairs, { cwd = process.cwd() } = {}) {
return [...new Set(pairs.flatMap((pair) => [pair.before, pair.after]).filter(Boolean).map((file) => localRef(file, cwd)))];
}
export function replaceMarkedBlock(body, block) {
const start = body.indexOf(MARKER_START);
const end = body.indexOf(MARKER_END);
if (start === -1 && end === -1) {
const prefix = body.trimEnd();
return prefix ? `${prefix}\n\n${block}` : block;
}
if (start === -1 || end === -1 || end < start) {
throw new Error("PR body contains an incomplete before-and-after marker block");
}
if (
body.indexOf(MARKER_START, start + MARKER_START.length) !== -1 ||
body.indexOf(MARKER_END, end + MARKER_END.length) !== -1
) {
throw new Error("PR body contains multiple before-and-after marker blocks");
}
const afterEnd = end + MARKER_END.length;
const prefix = body.slice(0, start).trimEnd();
const suffix = body.slice(afterEnd).trim();
return [prefix, block.trim(), suffix].filter(Boolean).join("\n\n") + "\n";
}
function validateFiles(pairs, cwd) {
for (const file of pairs.flatMap((pair) => [pair.before, pair.after]).filter(Boolean)) {
const absolute = resolve(cwd, file);
if (!existsSync(absolute)) throw new Error(`Media file does not exist: "${file}"`);
mediaKind(file);
localRef(file, cwd);
}
}
function main(argv) {
const { values } = parseArgs({
args: argv,
options: {
before: { type: "string", multiple: true },
after: { type: "string", multiple: true },
label: { type: "string", multiple: true },
attribution: { type: "string" },
"attach-list": { type: "boolean" },
"body-file": { type: "string" },
"before-video-url": { type: "string", multiple: true },
"after-video-url": { type: "string", multiple: true },
},
});
const localMode = (values.after?.length ?? 0) > 0 || (values.before?.length ?? 0) > 0;
const videoTableMode = (values["after-video-url"]?.length ?? 0) > 0 || (values["before-video-url"]?.length ?? 0) > 0;
if (localMode === videoTableMode) {
throw new Error("Provide local --after files or final --after-video-url attachments, but not both");
}
const pairs = videoTableMode
? buildPairs({ before: values["before-video-url"], after: values["after-video-url"], labels: values.label })
: buildPairs({ before: values.before, after: values.after, labels: values.label });
if (!videoTableMode) validateFiles(pairs, process.cwd());
if (values["attach-list"]) {
if (videoTableMode) throw new Error("--attach-list requires local media files");
process.stdout.write(`${attachList(pairs).join("\n")}\n`);
return;
}
const block = videoTableMode
? formatVideoTables(pairs, { attribution: values.attribution })
: formatMarkdown(pairs, { attribution: values.attribution });
if (values["body-file"]) {
process.stdout.write(replaceMarkedBlock(readFileSync(values["body-file"], "utf-8"), block));
} else {
process.stdout.write(block);
}
}
if (process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").at(-1))) {
try {
main(process.argv.slice(2));
} catch (error) {
console.error(String(error?.message ?? error));
process.exit(1);
}
}