@a2f0/agent-tool 0.1.31 → 0.1.32
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 +4 -2
- package/package.json +10 -2
- package/src/cli.ts +17 -0
- package/src/config.ts +4 -0
- package/src/images/build.ts +2 -45
- package/src/images/png.ts +10 -0
- package/src/permissions/catalog.ts +1 -1
- package/src/screenshots/build.ts +271 -0
- package/src/screenshots/config.ts +94 -0
- package/src/screenshots/gallery.css +528 -0
- package/src/screenshots/gallery.ts +713 -0
- package/src/screenshots/manifest.ts +176 -0
- package/src/system/files.ts +46 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The manifest `agent-tool screenshots build` writes, and pure helpers that
|
|
3
|
+
* read it. This module has no Node or DOM dependencies, so an Astro page's
|
|
4
|
+
* frontmatter, a browser, and the CLI share it
|
|
5
|
+
* (`@a2f0/agent-tool/screenshots/manifest`).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type ScreenshotFrame = "phone" | "tablet" | "none";
|
|
9
|
+
|
|
10
|
+
export interface ScreenshotManifestProject {
|
|
11
|
+
name: string;
|
|
12
|
+
label: string;
|
|
13
|
+
/** Present only when agent-tool.json sets it; otherwise the frame follows the images' shape. */
|
|
14
|
+
frame?: ScreenshotFrame;
|
|
15
|
+
}
|
|
16
|
+
export interface ScreenshotManifestTheme { name: string; label: string }
|
|
17
|
+
export interface ScreenshotManifestScreen { name: string; label: string; alt?: string }
|
|
18
|
+
export interface ScreenshotEntry {
|
|
19
|
+
project: string;
|
|
20
|
+
theme: string;
|
|
21
|
+
name: string;
|
|
22
|
+
/** URL with a `?v=<content hash>` suffix, so a recaptured screen gets a new URL. */
|
|
23
|
+
src: string;
|
|
24
|
+
/** Pixel dimensions from the PNG header. */
|
|
25
|
+
width: number;
|
|
26
|
+
height: number;
|
|
27
|
+
}
|
|
28
|
+
export interface ScreenshotCapture { commit: string; finishedAt: string | null }
|
|
29
|
+
export interface ScreenshotManifest {
|
|
30
|
+
schemaVersion: 1;
|
|
31
|
+
projects: ScreenshotManifestProject[];
|
|
32
|
+
themes: ScreenshotManifestTheme[];
|
|
33
|
+
screens: ScreenshotManifestScreen[];
|
|
34
|
+
entries: ScreenshotEntry[];
|
|
35
|
+
capture: ScreenshotCapture | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const EMPTY_MANIFEST: ScreenshotManifest = { schemaVersion: 1, projects: [], themes: [], screens: [], entries: [], capture: null };
|
|
39
|
+
|
|
40
|
+
/** Height over width at or above which an unconfigured project is drawn as a phone. */
|
|
41
|
+
export const PHONE_RATIO = 1.6;
|
|
42
|
+
|
|
43
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
44
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
45
|
+
}
|
|
46
|
+
function text(value: unknown): value is string { return typeof value === "string"; }
|
|
47
|
+
function optional(value: unknown): boolean { return value === undefined || text(value); }
|
|
48
|
+
function named(value: unknown, extra: (item: Record<string, unknown>) => boolean = () => true): boolean {
|
|
49
|
+
return isRecord(value) && text(value["name"]) && text(value["label"]) && extra(value);
|
|
50
|
+
}
|
|
51
|
+
const FRAMES: readonly unknown[] = ["phone", "tablet", "none"];
|
|
52
|
+
|
|
53
|
+
/** Checks a fetched or read manifest's shape; throws on anything this version cannot render. */
|
|
54
|
+
export function parseScreenshotManifest(value: unknown): ScreenshotManifest {
|
|
55
|
+
if (!isRecord(value) || value["schemaVersion"] !== 1) throw new Error("Unsupported screenshot manifest; expected schemaVersion 1.");
|
|
56
|
+
const { projects, themes, screens, entries, capture } = value as Record<"projects" | "themes" | "screens" | "entries" | "capture", unknown>;
|
|
57
|
+
if (!Array.isArray(projects) || !projects.every(item => named(item, project => project["frame"] === undefined || FRAMES.includes(project["frame"])))) throw new Error("Screenshot manifest projects are invalid.");
|
|
58
|
+
if (!Array.isArray(themes) || !themes.every(item => named(item))) throw new Error("Screenshot manifest themes are invalid.");
|
|
59
|
+
if (!Array.isArray(screens) || !screens.every(item => named(item, screen => optional(screen["alt"])))) throw new Error("Screenshot manifest screens are invalid.");
|
|
60
|
+
const entry = (item: unknown) => isRecord(item) && ["project", "theme", "name", "src"].every(key => text(item[key]))
|
|
61
|
+
&& [item["width"], item["height"]].every(size => typeof size === "number" && Number.isFinite(size) && size > 0);
|
|
62
|
+
if (!Array.isArray(entries) || !entries.every(entry)) throw new Error("Screenshot manifest entries are invalid.");
|
|
63
|
+
if (capture !== null && !(isRecord(capture) && text(capture["commit"]) && (capture["finishedAt"] === null || text(capture["finishedAt"])))) throw new Error("Screenshot manifest capture is invalid.");
|
|
64
|
+
return value as unknown as ScreenshotManifest;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function findScreenshot(manifest: ScreenshotManifest, project: string, theme: string, name: string): ScreenshotEntry | undefined {
|
|
68
|
+
return manifest.entries.find(entry => entry.project === project && entry.theme === theme && entry.name === name);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function projectInfo(manifest: ScreenshotManifest, project: string): ScreenshotManifestProject {
|
|
72
|
+
return manifest.projects.find(item => item.name === project) ?? { name: project, label: project };
|
|
73
|
+
}
|
|
74
|
+
export function screenInfo(manifest: ScreenshotManifest, name: string): ScreenshotManifestScreen {
|
|
75
|
+
return manifest.screens.find(item => item.name === name) ?? { name, label: name };
|
|
76
|
+
}
|
|
77
|
+
export function themeInfo(manifest: ScreenshotManifest, theme: string): ScreenshotManifestTheme {
|
|
78
|
+
return manifest.themes.find(item => item.name === theme) ?? { name: theme, label: theme };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The project's screens, in manifest order, that have a capture in any theme. */
|
|
82
|
+
export function projectScreens(manifest: ScreenshotManifest, project: string): string[] {
|
|
83
|
+
const captured = new Set(manifest.entries.filter(entry => entry.project === project).map(entry => entry.name));
|
|
84
|
+
return manifest.screens.map(screen => screen.name).filter(name => captured.has(name));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The configured frame, else a phone bezel for tall portrait captures and none otherwise. */
|
|
88
|
+
export function projectFrame(manifest: ScreenshotManifest, project: string): ScreenshotFrame {
|
|
89
|
+
const configured = projectInfo(manifest, project).frame;
|
|
90
|
+
if (configured) return configured;
|
|
91
|
+
const sample = manifest.entries.find(entry => entry.project === project);
|
|
92
|
+
return sample && sample.height / sample.width >= PHONE_RATIO ? "phone" : "none";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** `index` wrapped into `[0, length)`, for stepping past either end. */
|
|
96
|
+
export function wrapIndex(index: number, length: number): number {
|
|
97
|
+
return length > 0 ? ((index % length) + length) % length : 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A deep link: `<basePath>/<project>/<screen>/`, the form static hosts serve without a redirect. */
|
|
101
|
+
export function screenshotPath(basePath: string, project: string, screen: string): string {
|
|
102
|
+
return `${basePath.replace(/\/+$/, "")}/${encodeURIComponent(project)}/${encodeURIComponent(screen)}/`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The project and screen a deep link names, when `pathname` is under `basePath`. */
|
|
106
|
+
export function parseScreenshotPath(basePath: string, pathname: string): { project: string; screen: string } | undefined {
|
|
107
|
+
const base = basePath.replace(/\/+$/, "");
|
|
108
|
+
if (!pathname.startsWith(`${base}/`)) return undefined;
|
|
109
|
+
const parts = pathname.slice(base.length + 1).split("/").filter(Boolean);
|
|
110
|
+
if (parts.length !== 2) return undefined;
|
|
111
|
+
try { return { project: decodeURIComponent(parts[0]!), screen: decodeURIComponent(parts[1]!) }; } catch { return undefined; }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface ScreenshotRoute {
|
|
115
|
+
params: { slug: string | undefined };
|
|
116
|
+
props: { project?: string; screen?: string };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Astro `getStaticPaths` for a `[...slug]` route: the index plus one route per
|
|
121
|
+
* captured project and screen. Themes share a route, and pairs with no capture
|
|
122
|
+
* never become URLs.
|
|
123
|
+
*/
|
|
124
|
+
export function screenshotRoutes(manifest: ScreenshotManifest): ScreenshotRoute[] {
|
|
125
|
+
const pairs = new Map(manifest.entries.map(entry => [`${entry.project}/${entry.name}`, entry]));
|
|
126
|
+
return [
|
|
127
|
+
{ params: { slug: undefined }, props: {} },
|
|
128
|
+
...[...pairs].map(([slug, entry]) => ({ params: { slug }, props: { project: entry.project, screen: entry.name } })),
|
|
129
|
+
];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The starting project: a deep link's when captured, else `mobile` on a narrow screen when captured, else the first. */
|
|
133
|
+
export function startingProject(manifest: ScreenshotManifest, requested: string | undefined | null, narrow: boolean): string {
|
|
134
|
+
const names = manifest.projects.map(project => project.name);
|
|
135
|
+
if (requested && names.includes(requested)) return requested;
|
|
136
|
+
if (narrow && names.includes("mobile")) return "mobile";
|
|
137
|
+
return names[0] ?? "";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The requested screen when the project captured it, else the project's first screen. */
|
|
141
|
+
export function startingScreen(manifest: ScreenshotManifest, project: string, requested: string | undefined | null): string | undefined {
|
|
142
|
+
const screens = projectScreens(manifest, project);
|
|
143
|
+
return requested && screens.includes(requested) ? requested : screens[0];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The visitor's color scheme when the opening screen was captured in it,
|
|
148
|
+
* otherwise the first theme that has that capture.
|
|
149
|
+
*/
|
|
150
|
+
export function startingTheme(manifest: ScreenshotManifest, project: string, screen: string | undefined, prefersDark: boolean): string {
|
|
151
|
+
const themes = manifest.themes.map(theme => theme.name);
|
|
152
|
+
const order = prefersDark ? ["dark", ...themes] : ["light", ...themes];
|
|
153
|
+
const theme = order.find(candidate => themes.includes(candidate) && (screen === undefined || findScreenshot(manifest, project, candidate, screen) !== undefined));
|
|
154
|
+
return theme ?? themes[0] ?? "light";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Fills `{screen}`, `{project}`, and `{projectLower}` from the manifest's labels,
|
|
159
|
+
* and `{heading}` with the heading template's result.
|
|
160
|
+
*/
|
|
161
|
+
export function formatScreenshotTemplate(template: string, manifest: ScreenshotManifest, project: string, screen: string, heading = ""): string {
|
|
162
|
+
const values: Record<string, string> = {
|
|
163
|
+
screen: screenInfo(manifest, screen).label,
|
|
164
|
+
project: projectInfo(manifest, project).label,
|
|
165
|
+
projectLower: projectInfo(manifest, project).label.toLowerCase(),
|
|
166
|
+
heading,
|
|
167
|
+
};
|
|
168
|
+
return template.replace(/\{(screen|project|projectLower|heading)\}/g, (_, key: string) => values[key] ?? "");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export const DEFAULT_HEADING_TEMPLATE = "{screen}, {projectLower} screenshot";
|
|
172
|
+
|
|
173
|
+
/** The heading the gallery shows for a deep link, for a page to render the same text server-side. */
|
|
174
|
+
export function screenshotHeading(manifest: ScreenshotManifest, project: string, screen: string, template = DEFAULT_HEADING_TEMPLATE): string {
|
|
175
|
+
return formatScreenshotTemplate(template, manifest, project, screen);
|
|
176
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { closeSync, constants, fstatSync, ftruncateSync, lstatSync, openSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
/** Refuses symbolic links below the base, so a committed link cannot redirect a write outside it. */
|
|
6
|
+
export function assertNoLinks(base: string, destination: string): void {
|
|
7
|
+
let current = base;
|
|
8
|
+
for (const part of path.relative(base, destination).split(path.sep)) {
|
|
9
|
+
current = path.join(current, part);
|
|
10
|
+
let link: boolean;
|
|
11
|
+
try { link = lstatSync(current).isSymbolicLink(); } catch { return; }
|
|
12
|
+
if (link) throw new Error(`Refusing to write through symbolic link ${path.relative(base, current)}.`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Replaces a file atomically through an unpredictable, exclusively created
|
|
18
|
+
* temporary name, so no committed link can stand in for it. Sandboxed build
|
|
19
|
+
* phases, such as Xcode's, may write only their declared outputs; there the
|
|
20
|
+
* file is rewritten in place, refusing to follow a link.
|
|
21
|
+
*/
|
|
22
|
+
export function replaceFile(destination: string, bytes: Uint8Array): void {
|
|
23
|
+
const temporary = `${destination}.${randomUUID()}.tmp`;
|
|
24
|
+
let created = false;
|
|
25
|
+
try {
|
|
26
|
+
writeFileSync(temporary, bytes, { flag: "wx" });
|
|
27
|
+
created = true;
|
|
28
|
+
renameSync(temporary, destination);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (created || !["EACCES", "EPERM"].includes((error as NodeJS.ErrnoException).code ?? "")) {
|
|
31
|
+
// Best effort: the original error matters more than a temporary file left behind.
|
|
32
|
+
try { rmSync(temporary, { force: true }); } catch {}
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
// Nothing was created, and a sandbox may refuse even an attempt to remove an undeclared path.
|
|
36
|
+
const descriptor = openSync(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW);
|
|
37
|
+
try {
|
|
38
|
+
// A hard link would share the file with a path outside the output, so only a sole link is rewritten.
|
|
39
|
+
const stats = fstatSync(descriptor);
|
|
40
|
+
if (!stats.isFile() || stats.nlink !== 1) throw new Error(`Refusing to rewrite ${destination}, which is not a file with a single link.`);
|
|
41
|
+
ftruncateSync(descriptor, 0);
|
|
42
|
+
// writeFileSync on a descriptor repeats short writes until every byte is written.
|
|
43
|
+
writeFileSync(descriptor, bytes);
|
|
44
|
+
} finally { closeSync(descriptor); }
|
|
45
|
+
}
|
|
46
|
+
}
|