@siyahi/tools 0.1.0-next.5 → 0.1.1-next.2

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.
Files changed (3) hide show
  1. package/README.md +11 -2
  2. package/package.json +2 -1
  3. 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/`. It fails when visible text or a
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.0-next.5",
3
+ "version": "0.1.1-next.2",
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
- import { mkdirSync } from "node:fs";
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
- const MAX_HEIGHT = 16000;
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("usage: siyahi-shots <url>... [--out shots] [--gutter 16] [--font Geist] [--chrome <selector>]");
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, file: string }>}
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
- await page.setViewportSize({
126
- width: mode.width,
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 file = path.join(outDir, `${slugOf(url)}-${mode.name}.png`);
132
- await page.screenshot({ path: file });
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, file };
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, file } = await shoot(browser, url, mode);
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)} ${file}`,
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
  }