Skip to content

Commit e7c0ba3

Browse files
authored
Add headless screenshot capture and refresh gallery (#4)
scripts/capture-screenshots.mjs builds exampleSite, serves it on an ephemeral local port, and drives headless chromium through three viewport / colour-scheme combinations to write the gallery PNGs. Reproducible replacement for the manual capture flow that produced the previous shots; the previous PNGs predated the system-monospace chrome and the de-rounded kbd corners. Wired as `npm run screenshots`. Playwright sits under optionalDependencies so CI's `npm ci --ignore-scripts` skips the browser-binary postinstall. Quirk worth knowing: exampleSite/hugo.toml says `theme = "pager"` but the repo dir is `hugo-theme-pager`. CI sidesteps this with `path: pager` on checkout; the script does it with a temp themes/pager symlink to the repo root, so a normal local clone works too. Co-authored-by: Przemysław Szypowicz <2733699+pszypowicz@users.noreply.github.com>
1 parent 8b028c6 commit e7c0ba3

8 files changed

Lines changed: 302 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- `scripts/capture-screenshots.mjs` plus `npm run screenshots`. Builds
13+
`exampleSite/`, serves it on an ephemeral local port, drives a
14+
headless chromium through three viewport / colour-scheme
15+
combinations, and writes the gallery PNGs to `images/`. Reproducible
16+
replacement for the previous manual capture flow. Playwright is
17+
declared under `optionalDependencies` so CI's
18+
`npm ci --ignore-scripts` skips the browser-binary postinstall.
19+
- README "Maintainer notes" section pointing at the script.
20+
21+
### Changed
22+
23+
- Refreshed `images/screenshot.png`, `images/screenshot-dark.png`,
24+
and `images/tn.png` against the current build. Picks up the
25+
system-monospace chrome and headings, the de-rounded `kbd`
26+
corners, and the current grid layout.
27+
1028
## [0.2.2]
1129

1230
### Added

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,22 @@ See `exampleSite/hugo.toml` for the full set. Widgets live under `[params.widget
100100

101101
Search, comments, galleries, analytics, theme switcher UI, multilingual switcher UI, mermaid diagrams, and smooth-scroll. All deliberately absent. Add them yourself if you need them.
102102

103+
## Maintainer notes
104+
105+
Regenerate the gallery screenshots after any visible change:
106+
107+
```
108+
npm install
109+
npx playwright install chromium # one-time, per machine
110+
npm run screenshots
111+
```
112+
113+
The script (`scripts/capture-screenshots.mjs`) builds `exampleSite/`,
114+
serves it on a local ephemeral port, drives headless chromium through
115+
three viewport / colour-scheme combinations, and writes
116+
`images/screenshot.png`, `images/screenshot-dark.png`, and
117+
`images/tn.png`. Run with `--help` for options.
118+
103119
## Credits
104120

105121
- Icon paths derived from [Tabler Icons](https://tabler.io/icons) - MIT.

images/screenshot-dark.png

-664 Bytes
Loading

images/screenshot.png

192 Bytes
Loading

images/tn.png

975 Bytes
Loading

package-lock.json

Lines changed: 52 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@
44
"description": "Dev tooling for linting and typechecking the theme's JS assets.",
55
"scripts": {
66
"lint": "eslint assets/js",
7-
"typecheck": "tsc -p jsconfig.json"
7+
"typecheck": "tsc -p jsconfig.json",
8+
"screenshots": "node scripts/capture-screenshots.mjs"
89
},
910
"type": "module",
1011
"devDependencies": {
1112
"@eslint/js": "^9.39.4",
1213
"eslint": "^9.39.4",
1314
"typescript": "^5.9.3"
15+
},
16+
"optionalDependencies": {
17+
"playwright": "^1.59.1"
1418
}
1519
}

scripts/capture-screenshots.mjs

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
#!/usr/bin/env node
2+
// Render the bundled exampleSite home page and write the gallery
3+
// screenshots referenced from README.md and theme.toml. Builds the
4+
// site into a temporary directory, serves it over an ephemeral local
5+
// HTTP port so subresources resolve normally, then drives a headless
6+
// chromium through three viewport / colour-scheme combinations.
7+
//
8+
// Run with `npm run screenshots` or `node scripts/capture-screenshots.mjs`.
9+
// `--help` prints usage.
10+
11+
import { parseArgs } from "node:util";
12+
import { spawnSync } from "node:child_process";
13+
import { createServer } from "node:http";
14+
import { mkdtemp, rm, stat, readFile, symlink, mkdir } from "node:fs/promises";
15+
import { tmpdir } from "node:os";
16+
import { join, extname, resolve, dirname } from "node:path";
17+
import { fileURLToPath } from "node:url";
18+
19+
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
20+
const REPO_ROOT = resolve(SCRIPT_DIR, "..");
21+
22+
const CAPTURES = [
23+
{ name: "screenshot.png", width: 1500, height: 1000, colorScheme: "light" },
24+
{ name: "screenshot-dark.png", width: 1500, height: 1000, colorScheme: "dark" },
25+
{ name: "tn.png", width: 900, height: 600, colorScheme: "light" },
26+
];
27+
28+
const MIME = {
29+
".html": "text/html; charset=utf-8",
30+
".css": "text/css; charset=utf-8",
31+
".js": "application/javascript; charset=utf-8",
32+
".mjs": "application/javascript; charset=utf-8",
33+
".json": "application/json; charset=utf-8",
34+
".svg": "image/svg+xml",
35+
".png": "image/png",
36+
".jpg": "image/jpeg",
37+
".jpeg": "image/jpeg",
38+
".gif": "image/gif",
39+
".ico": "image/x-icon",
40+
".woff": "font/woff",
41+
".woff2":"font/woff2",
42+
".txt": "text/plain; charset=utf-8",
43+
".xml": "application/xml; charset=utf-8",
44+
};
45+
46+
const HELP = `Usage: node scripts/capture-screenshots.mjs [options]
47+
48+
Capture the gallery screenshots from the bundled exampleSite home page.
49+
Builds exampleSite with hugo, serves it locally, drives a headless
50+
chromium to render three viewports, writes PNGs to --out-dir.
51+
52+
Options:
53+
--out-dir <path> Directory to write the PNGs into (default: images)
54+
--hugo <path> hugo binary to use (default: hugo on PATH)
55+
--keep-temp Leave the built site on disk after exit (default: remove)
56+
-h, --help Show this help and exit
57+
58+
Outputs (relative to --out-dir):
59+
screenshot.png 1500x1000, light colour scheme
60+
screenshot-dark.png 1500x1000, dark colour scheme
61+
tn.png 900x600, light colour scheme
62+
63+
Requires: hugo on PATH (or --hugo), playwright with chromium installed
64+
(\`npm install\` followed by \`npx playwright install chromium\`).
65+
`;
66+
67+
function parseCli() {
68+
let args;
69+
try {
70+
args = parseArgs({
71+
options: {
72+
"out-dir": { type: "string", default: "images" },
73+
"hugo": { type: "string", default: "hugo" },
74+
"keep-temp": { type: "boolean", default: false },
75+
"help": { type: "boolean", short: "h", default: false },
76+
},
77+
strict: true,
78+
});
79+
} catch (err) {
80+
process.stderr.write(`error: ${err.message}\n\n${HELP}`);
81+
process.exit(2);
82+
}
83+
if (args.values.help) {
84+
process.stdout.write(HELP);
85+
process.exit(0);
86+
}
87+
return {
88+
outDir: resolve(REPO_ROOT, args.values["out-dir"]),
89+
hugoBin: args.values.hugo,
90+
keepTemp: args.values["keep-temp"],
91+
};
92+
}
93+
94+
async function buildSite({ hugoBin, destDir, themesDir }) {
95+
// exampleSite/hugo.toml declares `theme = "pager"`, so hugo expects
96+
// a directory named `pager` under --themesDir. The repo itself is
97+
// `hugo-theme-pager`, which is fine in CI (checkout uses path: pager)
98+
// but not when running from a normal clone. Stage a symlink so the
99+
// lookup succeeds regardless of the on-disk directory name.
100+
await mkdir(themesDir, { recursive: true });
101+
await symlink(REPO_ROOT, join(themesDir, "pager"), "dir");
102+
103+
const result = spawnSync(
104+
hugoBin,
105+
[
106+
"--source", join(REPO_ROOT, "exampleSite"),
107+
"--themesDir", themesDir,
108+
"--destination", destDir,
109+
"--minify",
110+
"--gc",
111+
"--cleanDestinationDir",
112+
],
113+
{ stdio: "inherit" },
114+
);
115+
if (result.error) {
116+
throw new Error(`failed to spawn ${hugoBin}: ${result.error.message}`);
117+
}
118+
if (result.status !== 0) {
119+
throw new Error(`${hugoBin} exited with status ${result.status}`);
120+
}
121+
}
122+
123+
function startStaticServer(rootDir) {
124+
const server = createServer(async (req, res) => {
125+
try {
126+
const url = new URL(req.url, "http://localhost/");
127+
let pathname = decodeURIComponent(url.pathname);
128+
if (pathname.endsWith("/")) pathname += "index.html";
129+
const target = join(rootDir, pathname);
130+
if (!target.startsWith(rootDir)) {
131+
res.writeHead(403).end("forbidden");
132+
return;
133+
}
134+
let body;
135+
let resolvedPath = target;
136+
try {
137+
const s = await stat(target);
138+
if (s.isDirectory()) resolvedPath = join(target, "index.html");
139+
} catch {
140+
res.writeHead(404).end("not found");
141+
return;
142+
}
143+
try {
144+
body = await readFile(resolvedPath);
145+
} catch {
146+
res.writeHead(404).end("not found");
147+
return;
148+
}
149+
const type = MIME[extname(resolvedPath).toLowerCase()] || "application/octet-stream";
150+
res.writeHead(200, { "content-type": type, "content-length": body.length });
151+
res.end(body);
152+
} catch (err) {
153+
res.writeHead(500).end(`server error: ${err.message}`);
154+
}
155+
});
156+
return new Promise((resolveListen, rejectListen) => {
157+
server.once("error", rejectListen);
158+
server.listen(0, "127.0.0.1", () => {
159+
const addr = server.address();
160+
resolveListen({ server, port: addr.port });
161+
});
162+
});
163+
}
164+
165+
async function capture({ port, outDir }) {
166+
const { chromium } = await import("playwright");
167+
const browser = await chromium.launch();
168+
try {
169+
for (const shot of CAPTURES) {
170+
const context = await browser.newContext({
171+
viewport: { width: shot.width, height: shot.height },
172+
colorScheme: shot.colorScheme,
173+
deviceScaleFactor: 1,
174+
});
175+
const page = await context.newPage();
176+
await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: "networkidle" });
177+
const out = join(outDir, shot.name);
178+
await page.screenshot({ path: out, fullPage: false, type: "png" });
179+
await context.close();
180+
process.stdout.write(`wrote ${out} (${shot.width}x${shot.height}, ${shot.colorScheme})\n`);
181+
}
182+
} finally {
183+
await browser.close();
184+
}
185+
}
186+
187+
async function main() {
188+
const opts = parseCli();
189+
const workDir = await mkdtemp(join(tmpdir(), "pager-screenshots-"));
190+
const siteDir = join(workDir, "site");
191+
const themesDir = join(workDir, "themes");
192+
let server;
193+
try {
194+
await buildSite({ hugoBin: opts.hugoBin, destDir: siteDir, themesDir });
195+
const started = await startStaticServer(siteDir);
196+
server = started.server;
197+
await capture({ port: started.port, outDir: opts.outDir });
198+
} finally {
199+
if (server) await new Promise((r) => server.close(r));
200+
if (!opts.keepTemp) {
201+
await rm(workDir, { recursive: true, force: true });
202+
} else {
203+
process.stdout.write(`kept build dir: ${workDir}\n`);
204+
}
205+
}
206+
}
207+
208+
main().catch((err) => {
209+
process.stderr.write(`error: ${err.message}\n`);
210+
process.exit(1);
211+
});

0 commit comments

Comments
 (0)