@siyahi/tools 0.1.0-next.4 → 0.1.1-next.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 +11 -2
- package/package.json +2 -1
- package/shots.mjs +63 -20
package/README.md
CHANGED
|
@@ -31,11 +31,20 @@ A Vite+ or ESLint config can spread every rule at once:
|
|
|
31
31
|
## siyahi-shots
|
|
32
32
|
|
|
33
33
|
```sh
|
|
34
|
-
npx siyahi-shots http://localhost:5173/#/page [more URLs] [--out shots] [--gutter 16] [--font Geist] [--chrome '[data-slot="sidebar"]']
|
|
34
|
+
npx siyahi-shots http://localhost:5173/#/page [more URLs] [--out shots] [--gutter 16] [--font Geist] [--chrome '[data-slot="sidebar"]'] [--wait-for h1]
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
Renders each URL in Chromium at 1920 and 390 pixels wide, light and dark, and
|
|
38
|
-
writes one full-page PNG per mode to `shots/`.
|
|
38
|
+
writes one full-page PNG per mode to `shots/`. A page taller than 16,000 pixels
|
|
39
|
+
is written as equal segments, `<name>-1.png`, `<name>-2.png` and so on, which
|
|
40
|
+
together cover the whole page; the run lists every file. A rerun replaces the
|
|
41
|
+
page's earlier shots.
|
|
42
|
+
|
|
43
|
+
It measures the page once the network is quiet, an element matching
|
|
44
|
+
`--wait-for` (by default `h1`) is visible, two animation frames have passed
|
|
45
|
+
and the fonts are ready, so content a page renders after loading its data is
|
|
46
|
+
in the shot. A mode fails when nothing matches within 15 seconds; pass a
|
|
47
|
+
selector that only the finished page has, such as `--wait-for '[data-ready]'`. It fails when visible text or a
|
|
39
48
|
card comes closer than `--gutter` pixels to either edge, when the page scrolls
|
|
40
49
|
sideways, or when the body font is not a loaded `--font` family. Content that
|
|
41
50
|
scrolls sideways inside its own box is exempt from the gutter, and so is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@siyahi/tools",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1-next.1",
|
|
4
4
|
"description": "Checks for Siyahi pages: a lint plugin, a theme contrast check and a screenshot script.",
|
|
5
5
|
"homepage": "https://siyahi.jatinbansal.com",
|
|
6
6
|
"license": "MIT",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"playwright": "^1.64.1"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
+
"@fontsource-variable/geist": "^5.3.0",
|
|
45
46
|
"@types/node": "24.19.2",
|
|
46
47
|
"oxlint": "^1.88.0",
|
|
47
48
|
"typescript": "7.0.2",
|
package/shots.mjs
CHANGED
|
@@ -2,10 +2,16 @@
|
|
|
2
2
|
// @ts-check
|
|
3
3
|
// Renders pages in Playwright's Chromium at 1920 and 390 wide, light and dark,
|
|
4
4
|
// checks the layout rules every Siyahi page keeps, and writes one full-page
|
|
5
|
-
// PNG per page and mode.
|
|
5
|
+
// PNG per page and mode. A page taller than 16,000px is written as segments
|
|
6
|
+
// <name>-1.png, <name>-2.png, … of equal height, which together cover it.
|
|
6
7
|
//
|
|
7
8
|
// siyahi-shots <url>... [--out shots] [--gutter 16] [--font Geist]
|
|
8
|
-
// [--chrome '[data-slot="sidebar"]']
|
|
9
|
+
// [--chrome '[data-slot="sidebar"]'] [--wait-for h1]
|
|
10
|
+
//
|
|
11
|
+
// Measures only once --wait-for (by default the page's h1) is visible, two
|
|
12
|
+
// animation frames have passed and the fonts are ready: a page that renders
|
|
13
|
+
// after the network goes quiet would otherwise be measured, and shot, at one
|
|
14
|
+
// screen tall.
|
|
9
15
|
//
|
|
10
16
|
// Checks: a gutter of at least --gutter px on both sides of visible text and
|
|
11
17
|
// cards, no sideways document scroll (also with a classic scrollbar taking its
|
|
@@ -16,12 +22,14 @@
|
|
|
16
22
|
//
|
|
17
23
|
// The capture first grows the viewport to the page's height and waits, so
|
|
18
24
|
// charts that redraw on resize settle before the picture is taken; a
|
|
19
|
-
// stitched full-page capture catches them mid-animation.
|
|
20
|
-
|
|
25
|
+
// stitched full-page capture catches them mid-animation. Segments are cut
|
|
26
|
+
// from that one full-height render, so sticky and fixed parts sit where they
|
|
27
|
+
// would in a single shot.
|
|
28
|
+
import { mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
21
29
|
import path from "node:path";
|
|
22
30
|
import process from "node:process";
|
|
23
31
|
import { parseArgs } from "node:util";
|
|
24
|
-
import { chromium } from "playwright";
|
|
32
|
+
import { chromium, errors } from "playwright";
|
|
25
33
|
|
|
26
34
|
/** @typedef {{ name: string, width: number, height: number, dark: boolean }} Mode */
|
|
27
35
|
/** @typedef {{ gutterLeft: number | null, gutterRight: number | null, overflow: number, overflowWithScrollbar: number, font: string, fontLoaded: boolean, docHeight: number, h1: string | null }} Probe */
|
|
@@ -33,7 +41,11 @@ const MODES = [
|
|
|
33
41
|
{ name: "390-light", width: 390, height: 844, dark: false },
|
|
34
42
|
{ name: "390-dark", width: 390, height: 844, dark: true },
|
|
35
43
|
];
|
|
36
|
-
|
|
44
|
+
// How long --wait-for may take to appear before the mode fails.
|
|
45
|
+
const WAIT_FOR_TIMEOUT = 15_000;
|
|
46
|
+
// Tallest PNG written. Headless Chromium renders taller pages, but many image
|
|
47
|
+
// viewers draw through a GPU texture capped at 16,384px.
|
|
48
|
+
const SEGMENT_MAX = 16000;
|
|
37
49
|
|
|
38
50
|
const { values, positionals } = parseArgs({
|
|
39
51
|
allowPositionals: true,
|
|
@@ -42,14 +54,18 @@ const { values, positionals } = parseArgs({
|
|
|
42
54
|
gutter: { type: "string", default: "16" },
|
|
43
55
|
font: { type: "string", default: "Geist" },
|
|
44
56
|
chrome: { type: "string", default: '[data-slot="sidebar"]' },
|
|
57
|
+
"wait-for": { type: "string", default: "h1" },
|
|
45
58
|
},
|
|
46
59
|
});
|
|
47
60
|
if (positionals.length === 0) {
|
|
48
|
-
console.error(
|
|
61
|
+
console.error(
|
|
62
|
+
"usage: siyahi-shots <url>... [--out shots] [--gutter 16] [--font Geist] [--chrome <selector>] [--wait-for <selector>]",
|
|
63
|
+
);
|
|
49
64
|
process.exit(2);
|
|
50
65
|
}
|
|
51
66
|
const outDir = path.resolve(values.out);
|
|
52
67
|
const minGutter = Number(values.gutter);
|
|
68
|
+
const waitFor = values["wait-for"];
|
|
53
69
|
mkdirSync(outDir, { recursive: true });
|
|
54
70
|
|
|
55
71
|
/** @param {string} url */
|
|
@@ -106,11 +122,22 @@ const SETTLE = `(async () => {
|
|
|
106
122
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
107
123
|
})()`;
|
|
108
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Deletes what an earlier run wrote for this page and mode, so a page that
|
|
127
|
+
* shrank leaves no stale segment behind.
|
|
128
|
+
* @param {string} base
|
|
129
|
+
*/
|
|
130
|
+
function clearShots(base) {
|
|
131
|
+
// base is letters, digits and dashes (slugOf and the mode name), so it is safe in a pattern.
|
|
132
|
+
const earlier = new RegExp(`^${base}(-\\d+)?\\.png$`);
|
|
133
|
+
for (const name of readdirSync(outDir)) if (earlier.test(name)) rmSync(path.join(outDir, name));
|
|
134
|
+
}
|
|
135
|
+
|
|
109
136
|
/**
|
|
110
137
|
* @param {import("playwright").Browser} browser
|
|
111
138
|
* @param {string} url
|
|
112
139
|
* @param {Mode} mode
|
|
113
|
-
* @returns {Promise<{ probe: Probe,
|
|
140
|
+
* @returns {Promise<{ probe: Probe, ready: boolean, height: number, files: string[] }>}
|
|
114
141
|
*/
|
|
115
142
|
async function shoot(browser, url, mode) {
|
|
116
143
|
const context = await browser.newContext({
|
|
@@ -119,27 +146,43 @@ async function shoot(browser, url, mode) {
|
|
|
119
146
|
});
|
|
120
147
|
const page = await context.newPage();
|
|
121
148
|
await page.goto(url, { waitUntil: "networkidle" });
|
|
149
|
+
const ready = await page.waitForSelector(waitFor, { timeout: WAIT_FOR_TIMEOUT }).then(
|
|
150
|
+
() => true,
|
|
151
|
+
(error) => {
|
|
152
|
+
if (error instanceof errors.TimeoutError) return false;
|
|
153
|
+
throw error;
|
|
154
|
+
},
|
|
155
|
+
);
|
|
122
156
|
await page.evaluate(SETTLE);
|
|
123
157
|
/** @type {Probe} */
|
|
124
158
|
const probe = await page.evaluate(PROBE);
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
height: Math.min(Math.max(probe.docHeight, mode.height), MAX_HEIGHT),
|
|
128
|
-
});
|
|
159
|
+
const height = Math.max(probe.docHeight, mode.height);
|
|
160
|
+
await page.setViewportSize({ width: mode.width, height });
|
|
129
161
|
await page.waitForTimeout(1500);
|
|
130
162
|
await page.evaluate(SETTLE);
|
|
131
|
-
const
|
|
132
|
-
|
|
163
|
+
const base = `${slugOf(url)}-${mode.name}`;
|
|
164
|
+
clearShots(base);
|
|
165
|
+
const count = Math.ceil(height / SEGMENT_MAX);
|
|
166
|
+
const segment = Math.ceil(height / count);
|
|
167
|
+
const files = [];
|
|
168
|
+
for (let i = 0; i < count; i++) {
|
|
169
|
+
const file = path.join(outDir, count === 1 ? `${base}.png` : `${base}-${i + 1}.png`);
|
|
170
|
+
const y = i * segment;
|
|
171
|
+
await page.screenshot({ path: file, clip: { x: 0, y, width: mode.width, height: Math.min(segment, height - y) } });
|
|
172
|
+
files.push(file);
|
|
173
|
+
}
|
|
133
174
|
await context.close();
|
|
134
|
-
return { probe,
|
|
175
|
+
return { probe, ready, height, files };
|
|
135
176
|
}
|
|
136
177
|
|
|
137
178
|
/**
|
|
138
179
|
* @param {Probe} p
|
|
180
|
+
* @param {boolean} ready whether --wait-for appeared
|
|
139
181
|
* @returns {string[]}
|
|
140
182
|
*/
|
|
141
|
-
function problemsOf(p) {
|
|
183
|
+
function problemsOf(p, ready) {
|
|
142
184
|
const problems = [];
|
|
185
|
+
if (!ready) problems.push(`nothing matching ${waitFor} appeared within ${WAIT_FOR_TIMEOUT / 1000}s (--wait-for)`);
|
|
143
186
|
if (p.gutterLeft !== null && p.gutterLeft < minGutter)
|
|
144
187
|
problems.push(`left gutter is ${p.gutterLeft}px, under ${minGutter}px`);
|
|
145
188
|
if (p.gutterRight !== null && p.gutterRight < minGutter)
|
|
@@ -149,7 +192,6 @@ function problemsOf(p) {
|
|
|
149
192
|
problems.push(`scrolls sideways by ${p.overflowWithScrollbar}px once a scrollbar takes its width`);
|
|
150
193
|
if (!p.font.startsWith(values.font)) problems.push(`body font is ${p.font}, not ${values.font}`);
|
|
151
194
|
else if (!p.fontLoaded) problems.push(`${p.font} is named but never loaded`);
|
|
152
|
-
if (p.docHeight > MAX_HEIGHT) problems.push(`page is ${p.docHeight}px tall; the shot stops at ${MAX_HEIGHT}px`);
|
|
153
195
|
return problems;
|
|
154
196
|
}
|
|
155
197
|
|
|
@@ -157,12 +199,13 @@ const browser = await chromium.launch();
|
|
|
157
199
|
let failed = false;
|
|
158
200
|
for (const url of positionals) {
|
|
159
201
|
for (const mode of MODES) {
|
|
160
|
-
const { probe,
|
|
161
|
-
const problems = problemsOf(probe);
|
|
202
|
+
const { probe, ready, height, files } = await shoot(browser, url, mode);
|
|
203
|
+
const problems = problemsOf(probe, ready);
|
|
162
204
|
failed ||= problems.length > 0;
|
|
163
205
|
console.log(
|
|
164
206
|
`${problems.length ? "FAIL" : "ok "} ${mode.name.padEnd(10)} gutter=${probe.gutterLeft}/${probe.gutterRight} ` +
|
|
165
|
-
`overflow=${probe.overflow}/${probe.overflowWithScrollbar} font=${probe.font} h1=${JSON.stringify(probe.h1)}
|
|
207
|
+
`overflow=${probe.overflow}/${probe.overflowWithScrollbar} font=${probe.font} h1=${JSON.stringify(probe.h1)} ` +
|
|
208
|
+
`height=${height} ${files.join(" ")}`,
|
|
166
209
|
);
|
|
167
210
|
for (const problem of problems) console.log(` ${problem}`);
|
|
168
211
|
}
|