@cychien/ezdesign 0.0.0-stage → 0.9.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/LICENSE +21 -0
- package/README.md +615 -2
- package/dist/announcements/template-ERNKAKI5.svg +33 -0
- package/dist/cli.mjs +10095 -0
- package/dist/constants.d.ts +73 -0
- package/dist/events-worker.js +77 -0
- package/dist/extension/g/0.9.1-16d05e759b/dialog.css +1 -0
- package/dist/extension/g/0.9.1-16d05e759b/dialog.html +13 -0
- package/dist/extension/g/0.9.1-16d05e759b/dialog.js +1 -0
- package/dist/extension/g/0.9.1-16d05e759b/icon-128.png +0 -0
- package/dist/extension/g/0.9.1-16d05e759b/icon-16.png +0 -0
- package/dist/extension/g/0.9.1-16d05e759b/icon-32.png +0 -0
- package/dist/extension/g/0.9.1-16d05e759b/icon-48.png +0 -0
- package/dist/extension/g/0.9.1-16d05e759b/media.html +9 -0
- package/dist/extension/g/0.9.1-16d05e759b/media.js +1 -0
- package/dist/extension/g/0.9.1-16d05e759b/page.js +39 -0
- package/dist/extension/g/0.9.1-16d05e759b/setup.css +1 -0
- package/dist/extension/g/0.9.1-16d05e759b/setup.html +17 -0
- package/dist/extension/g/0.9.1-16d05e759b/setup.js +1 -0
- package/dist/extension/g/0.9.1-16d05e759b/worker.js +57 -0
- package/dist/extension/manifest.json +57 -0
- package/dist/native-host.mjs +20594 -0
- package/dist/overlay.css +422 -0
- package/dist/overlay.js +2642 -0
- package/dist/project-root.d.ts +10 -0
- package/dist/shell.css +4060 -0
- package/dist/shell.js +8284 -0
- package/dist/vite.d.ts +26 -0
- package/dist/vite.mjs +105 -0
- package/package.json +82 -4
- package/skills/ezdesign/SKILL.md +35 -0
- package/skills/ezdesign/reference/baseline.md +131 -0
- package/skills/ezdesign/reference/components.md +102 -0
- package/skills/ezdesign/reference/critique.md +129 -0
- package/skills/ezdesign/reference/design.md +247 -0
- package/skills/ezdesign/reference/inspiration.md +125 -0
- package/skills/ezdesign/reference/make.md +331 -0
- package/skills/ezdesign/reference/principles.md +130 -0
- package/skills/ezdesign/reference/product.md +127 -0
- package/skills/ezdesign/reference/stack.md +123 -0
- package/skills/ezdesign/reference/start.md +78 -0
- package/skills/ezdesign/scripts/page.d.mts +61 -0
- package/skills/ezdesign/scripts/page.mjs +908 -0
|
@@ -0,0 +1,908 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Measures a rendered page, screenshots it, and compares two sets of screenshots, so the design,
|
|
3
|
+
// components and making steps run the same commands every time instead of improvising them.
|
|
4
|
+
//
|
|
5
|
+
// node page.mjs measure <url> what the page renders, tallied, as JSON
|
|
6
|
+
// node page.mjs shot <url> <file> [--viewport WxH[,WxH...]] [--around <css>] [--page]
|
|
7
|
+
// [--hover [css]] [--focus [css]] [--active [css]] [--states]
|
|
8
|
+
// the element at 2x, its states, and the page
|
|
9
|
+
// node page.mjs shots <url> <dir> [path ...] full-page PNGs at desktop and mobile per path
|
|
10
|
+
// node page.mjs compare <before-dir> <after-dir> which screenshots changed; exit 1 when any did
|
|
11
|
+
//
|
|
12
|
+
// It drives a headless Chrome of its own over the DevTools protocol, one browser per command. In an
|
|
13
|
+
// ezdesign session that browser is signed in the way the user's is: the daemon hands over the page's
|
|
14
|
+
// cookies and storage, in Playwright's storageState shape, through EZDESIGN_PAGE_STATE.
|
|
15
|
+
|
|
16
|
+
import { spawn } from 'node:child_process'
|
|
17
|
+
import {
|
|
18
|
+
existsSync,
|
|
19
|
+
mkdirSync,
|
|
20
|
+
mkdtempSync,
|
|
21
|
+
readFileSync,
|
|
22
|
+
readdirSync,
|
|
23
|
+
realpathSync,
|
|
24
|
+
rmSync,
|
|
25
|
+
writeFileSync,
|
|
26
|
+
} from 'node:fs'
|
|
27
|
+
import { tmpdir } from 'node:os'
|
|
28
|
+
import { basename, dirname, extname, join, resolve } from 'node:path'
|
|
29
|
+
import { fileURLToPath } from 'node:url'
|
|
30
|
+
import { inflateSync } from 'node:zlib'
|
|
31
|
+
|
|
32
|
+
export const DESKTOP = { width: 1440, height: 900 }
|
|
33
|
+
export const MOBILE = { width: 390, height: 844 }
|
|
34
|
+
const SETTLE_MS = 400
|
|
35
|
+
const LOAD_TIMEOUT_MS = 30_000
|
|
36
|
+
const STATE_TIMEOUT_MS = 2000
|
|
37
|
+
const MARGIN = 48
|
|
38
|
+
// Twice the pixels, so a detail is judged on what it is rather than guessed at.
|
|
39
|
+
const PIECE_SCALE = 2
|
|
40
|
+
export const STATES = ['hover', 'focus', 'active']
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------- the browser
|
|
43
|
+
|
|
44
|
+
const CHROMES = {
|
|
45
|
+
darwin: [
|
|
46
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
47
|
+
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
|
48
|
+
'/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
|
|
49
|
+
],
|
|
50
|
+
linux: [
|
|
51
|
+
'/usr/bin/google-chrome',
|
|
52
|
+
'/usr/bin/google-chrome-stable',
|
|
53
|
+
'/usr/bin/chromium',
|
|
54
|
+
'/usr/bin/chromium-browser',
|
|
55
|
+
'/snap/bin/chromium',
|
|
56
|
+
],
|
|
57
|
+
win32: [
|
|
58
|
+
`${process.env.PROGRAMFILES}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
59
|
+
`${process.env['PROGRAMFILES(X86)']}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
60
|
+
`${process.env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
61
|
+
],
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function findChrome(env = process.env, platform = process.platform) {
|
|
65
|
+
if (env.CHROME_PATH) return env.CHROME_PATH
|
|
66
|
+
const found = (CHROMES[platform] ?? []).find((path) => existsSync(path))
|
|
67
|
+
if (!found) throw new Error('no Chrome found; set CHROME_PATH to its executable')
|
|
68
|
+
return found
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The DevTools protocol over the pipe Chrome opens on fds 3 and 4: JSON messages ended by NUL. */
|
|
72
|
+
async function launch() {
|
|
73
|
+
const profile = mkdtempSync(join(tmpdir(), 'ezdesign-page-'))
|
|
74
|
+
const chrome = spawn(
|
|
75
|
+
findChrome(),
|
|
76
|
+
[
|
|
77
|
+
'--headless=new',
|
|
78
|
+
'--remote-debugging-pipe',
|
|
79
|
+
`--user-data-dir=${profile}`,
|
|
80
|
+
'--no-first-run',
|
|
81
|
+
'--no-default-browser-check',
|
|
82
|
+
'--hide-scrollbars',
|
|
83
|
+
'--mute-audio',
|
|
84
|
+
'--disable-extensions',
|
|
85
|
+
'--use-mock-keychain',
|
|
86
|
+
'--password-store=basic',
|
|
87
|
+
'about:blank',
|
|
88
|
+
],
|
|
89
|
+
{ stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] },
|
|
90
|
+
)
|
|
91
|
+
const toChrome = chrome.stdio[3]
|
|
92
|
+
const fromChrome = chrome.stdio[4]
|
|
93
|
+
let id = 0
|
|
94
|
+
const pending = new Map()
|
|
95
|
+
const listeners = new Set()
|
|
96
|
+
let buffered = ''
|
|
97
|
+
fromChrome.setEncoding('utf8')
|
|
98
|
+
fromChrome.on('data', (chunk) => {
|
|
99
|
+
buffered += chunk
|
|
100
|
+
let end
|
|
101
|
+
while ((end = buffered.indexOf('\0')) >= 0) {
|
|
102
|
+
const message = JSON.parse(buffered.slice(0, end))
|
|
103
|
+
buffered = buffered.slice(end + 1)
|
|
104
|
+
if (message.id !== undefined && pending.has(message.id)) {
|
|
105
|
+
const { ok, fail } = pending.get(message.id)
|
|
106
|
+
pending.delete(message.id)
|
|
107
|
+
if (message.error)
|
|
108
|
+
fail(
|
|
109
|
+
new Error(
|
|
110
|
+
`${message.error.message}${message.error.data ? `: ${message.error.data}` : ''}`,
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
else ok(message.result)
|
|
114
|
+
} else for (const listen of listeners) listen(message)
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
const died = new Promise((_, fail) => {
|
|
118
|
+
chrome.on('exit', (code) => fail(new Error(`Chrome exited (${code}) before the page was done`)))
|
|
119
|
+
chrome.on('error', fail)
|
|
120
|
+
})
|
|
121
|
+
died.catch(() => {})
|
|
122
|
+
const send = (method, params = {}, sessionId) =>
|
|
123
|
+
Promise.race([
|
|
124
|
+
new Promise((ok, fail) => {
|
|
125
|
+
const message = { id: ++id, method, params, ...(sessionId ? { sessionId } : {}) }
|
|
126
|
+
pending.set(message.id, { ok, fail })
|
|
127
|
+
toChrome.write(`${JSON.stringify(message)}\0`)
|
|
128
|
+
}),
|
|
129
|
+
died,
|
|
130
|
+
])
|
|
131
|
+
const next = (sessionId, method, timeout) =>
|
|
132
|
+
new Promise((ok) => {
|
|
133
|
+
const timer = setTimeout(() => {
|
|
134
|
+
listeners.delete(listen)
|
|
135
|
+
ok(null)
|
|
136
|
+
}, timeout)
|
|
137
|
+
const listen = (m) => {
|
|
138
|
+
if (m.sessionId !== sessionId || m.method !== method) return
|
|
139
|
+
clearTimeout(timer)
|
|
140
|
+
listeners.delete(listen)
|
|
141
|
+
ok(m.params)
|
|
142
|
+
}
|
|
143
|
+
listeners.add(listen)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const { targetId } = await send('Target.createTarget', { url: 'about:blank' })
|
|
147
|
+
const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true })
|
|
148
|
+
const page = (method, params) => send(method, params, sessionId)
|
|
149
|
+
await page('Page.enable')
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
async viewport({ width, height }, scale = 1) {
|
|
153
|
+
const mobile = isPhone({ width })
|
|
154
|
+
await page('Emulation.setDeviceMetricsOverride', {
|
|
155
|
+
width,
|
|
156
|
+
height,
|
|
157
|
+
deviceScaleFactor: scale,
|
|
158
|
+
mobile,
|
|
159
|
+
})
|
|
160
|
+
await page('Emulation.setTouchEmulationEnabled', { enabled: mobile })
|
|
161
|
+
},
|
|
162
|
+
async navigate(url) {
|
|
163
|
+
const loaded = next(sessionId, 'Page.loadEventFired', LOAD_TIMEOUT_MS)
|
|
164
|
+
const { errorText } = await page('Page.navigate', { url })
|
|
165
|
+
if (errorText) throw new Error(`could not open ${url}: ${errorText}`)
|
|
166
|
+
await loaded
|
|
167
|
+
},
|
|
168
|
+
/** Where the page ended up: a sign-in it bounced to is somewhere else. */
|
|
169
|
+
async open(url) {
|
|
170
|
+
await this.navigate(url)
|
|
171
|
+
return this.evaluate(
|
|
172
|
+
`async () => { await document.fonts.ready; await new Promise((r) => setTimeout(r, ${SETTLE_MS})); return location.href }`,
|
|
173
|
+
)
|
|
174
|
+
},
|
|
175
|
+
/** Cookies go in directly; storage is written from a blank page of the origin, served here so
|
|
176
|
+
* none of the app runs before it is in place. */
|
|
177
|
+
async signIn(origin, { cookies, storage }) {
|
|
178
|
+
if (cookies.length) {
|
|
179
|
+
await page('Network.setCookies', {
|
|
180
|
+
cookies: cookies.map(({ name, value, path }) => ({ name, value, path, url: origin })),
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
if (!storage) return
|
|
184
|
+
const serve = (m) => {
|
|
185
|
+
if (m.sessionId !== sessionId || m.method !== 'Fetch.requestPaused') return
|
|
186
|
+
page('Fetch.fulfillRequest', {
|
|
187
|
+
requestId: m.params.requestId,
|
|
188
|
+
responseCode: 200,
|
|
189
|
+
responseHeaders: [{ name: 'content-type', value: 'text/html' }],
|
|
190
|
+
body: Buffer.from('<!doctype html><title></title>').toString('base64'),
|
|
191
|
+
}).catch(() => {})
|
|
192
|
+
}
|
|
193
|
+
listeners.add(serve)
|
|
194
|
+
try {
|
|
195
|
+
await page('Fetch.enable', { patterns: [{ urlPattern: `${origin}/*` }] })
|
|
196
|
+
await this.navigate(`${origin}/`)
|
|
197
|
+
await this.evaluate(`() => (${seedStorage})(${JSON.stringify(storage)})`)
|
|
198
|
+
} finally {
|
|
199
|
+
listeners.delete(serve)
|
|
200
|
+
await page('Fetch.disable').catch(() => {})
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
async evaluate(fn) {
|
|
204
|
+
const { result, exceptionDetails } = await page('Runtime.evaluate', {
|
|
205
|
+
expression: `(${fn})()`,
|
|
206
|
+
awaitPromise: true,
|
|
207
|
+
returnByValue: true,
|
|
208
|
+
})
|
|
209
|
+
if (exceptionDetails) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
exceptionDetails.exception?.description?.split('\n')[0] ?? exceptionDetails.text,
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
return result.value
|
|
215
|
+
},
|
|
216
|
+
/** The whole page when `clip` is left out, or that rect of it, past the viewport either way.
|
|
217
|
+
* `zoom` scales the image against the viewport's own pixel ratio. */
|
|
218
|
+
async screenshot(clip, zoom = 1) {
|
|
219
|
+
const { cssContentSize } = await page('Page.getLayoutMetrics')
|
|
220
|
+
const area = clip ?? {
|
|
221
|
+
x: 0,
|
|
222
|
+
y: 0,
|
|
223
|
+
width: cssContentSize.width,
|
|
224
|
+
height: cssContentSize.height,
|
|
225
|
+
}
|
|
226
|
+
const { data } = await page('Page.captureScreenshot', {
|
|
227
|
+
format: 'png',
|
|
228
|
+
captureBeyondViewport: true,
|
|
229
|
+
clip: { ...area, scale: zoom },
|
|
230
|
+
})
|
|
231
|
+
return Buffer.from(data, 'base64')
|
|
232
|
+
},
|
|
233
|
+
async mouse(type, x, y, buttons = 0) {
|
|
234
|
+
await page('Input.dispatchMouseEvent', {
|
|
235
|
+
type,
|
|
236
|
+
x,
|
|
237
|
+
y,
|
|
238
|
+
button: type === 'mouseMoved' ? 'none' : 'left',
|
|
239
|
+
buttons,
|
|
240
|
+
clickCount: type === 'mouseMoved' ? 0 : 1,
|
|
241
|
+
})
|
|
242
|
+
},
|
|
243
|
+
async key(key) {
|
|
244
|
+
for (const type of ['keyDown', 'keyUp']) await page('Input.dispatchKeyEvent', { type, key })
|
|
245
|
+
},
|
|
246
|
+
// Chrome writes to its profile until it has exited, so the folder goes only after that.
|
|
247
|
+
async close() {
|
|
248
|
+
const exited = new Promise((ok) => chrome.once('exit', ok))
|
|
249
|
+
await send('Browser.close').catch(() => {})
|
|
250
|
+
let timer
|
|
251
|
+
await Promise.race([exited, new Promise((ok) => (timer = setTimeout(ok, 2000)))])
|
|
252
|
+
clearTimeout(timer)
|
|
253
|
+
if (chrome.exitCode === null && chrome.signalCode === null) chrome.kill()
|
|
254
|
+
rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
|
255
|
+
},
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function withBrowser(url, run) {
|
|
260
|
+
const browser = await launch()
|
|
261
|
+
try {
|
|
262
|
+
const origin = new URL(url).origin
|
|
263
|
+
const state = signInFor(origin, await fetchPageState())
|
|
264
|
+
if (state) await browser.signIn(origin, state)
|
|
265
|
+
return await run(browser)
|
|
266
|
+
} finally {
|
|
267
|
+
await browser.close()
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ---------------------------------------------------------------------------- signing in
|
|
272
|
+
|
|
273
|
+
async function fetchPageState(env = process.env) {
|
|
274
|
+
if (!env.EZDESIGN_PAGE_STATE || !env.EZDESIGN_PAGE_STATE_TOKEN) return null
|
|
275
|
+
try {
|
|
276
|
+
const res = await fetch(env.EZDESIGN_PAGE_STATE, {
|
|
277
|
+
headers: { authorization: `Bearer ${env.EZDESIGN_PAGE_STATE_TOKEN}` },
|
|
278
|
+
signal: AbortSignal.timeout(STATE_TIMEOUT_MS),
|
|
279
|
+
})
|
|
280
|
+
return res.ok ? await res.json() : null
|
|
281
|
+
} catch {
|
|
282
|
+
return null
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]'])
|
|
287
|
+
|
|
288
|
+
/** A dev server answers on every loopback name, and the user's browser may know it by another. */
|
|
289
|
+
export function sameOrigin(a, b) {
|
|
290
|
+
const x = new URL(a)
|
|
291
|
+
const y = new URL(b)
|
|
292
|
+
if (x.protocol !== y.protocol || x.port !== y.port) return false
|
|
293
|
+
return x.hostname === y.hostname || (LOOPBACK.has(x.hostname) && LOOPBACK.has(y.hostname))
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Whether a cookie for `domain` is sent to `host`, loopback names counting as one. */
|
|
297
|
+
function cookieReaches(domain, host) {
|
|
298
|
+
const bare = domain.replace(/^\./, '')
|
|
299
|
+
return bare === host || host.endsWith(`.${bare}`) || (LOOPBACK.has(bare) && LOOPBACK.has(host))
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** What of a storageState applies to the page at `origin`, or null when none of it does. */
|
|
303
|
+
export function signInFor(origin, state) {
|
|
304
|
+
if (!state) return null
|
|
305
|
+
const host = new URL(origin).hostname
|
|
306
|
+
const cookies = (state.cookies ?? []).filter((c) => cookieReaches(c.domain, host))
|
|
307
|
+
const entry = state.origins?.find((o) => sameOrigin(o.origin, origin)) ?? null
|
|
308
|
+
const storage = entry && {
|
|
309
|
+
localStorage: entry.localStorage ?? [],
|
|
310
|
+
sessionStorage: entry.sessionStorage ?? [],
|
|
311
|
+
indexedDB: entry.indexedDB ?? [],
|
|
312
|
+
}
|
|
313
|
+
return cookies.length || storage ? { cookies, storage } : null
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Runs in the page. Writes the user's storage into this browser's copy of the origin. */
|
|
317
|
+
async function seedStorage({ localStorage: local, sessionStorage: session, indexedDB: dbs }) {
|
|
318
|
+
for (const { name, value } of local) localStorage.setItem(name, value)
|
|
319
|
+
for (const { name, value } of session) sessionStorage.setItem(name, value)
|
|
320
|
+
const path = (o) => o.keyPathArray ?? o.keyPath
|
|
321
|
+
for (const db of dbs) {
|
|
322
|
+
await new Promise((ok) => {
|
|
323
|
+
const request = indexedDB.open(db.name, db.version)
|
|
324
|
+
request.onupgradeneeded = () => {
|
|
325
|
+
for (const s of db.stores) {
|
|
326
|
+
const store = request.result.createObjectStore(s.name, {
|
|
327
|
+
...(path(s) !== undefined ? { keyPath: path(s) } : {}),
|
|
328
|
+
autoIncrement: s.autoIncrement,
|
|
329
|
+
})
|
|
330
|
+
for (const i of s.indexes)
|
|
331
|
+
store.createIndex(i.name, path(i), { multiEntry: i.multiEntry, unique: i.unique })
|
|
332
|
+
for (const r of s.records) {
|
|
333
|
+
if ('key' in r) store.put(r.value, r.key)
|
|
334
|
+
else store.put(r.value)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
request.onsuccess = () => {
|
|
339
|
+
request.result.close()
|
|
340
|
+
ok()
|
|
341
|
+
}
|
|
342
|
+
request.onerror = () => ok()
|
|
343
|
+
})
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** The page went somewhere other than where it was sent: a sign-in, most of the time. */
|
|
348
|
+
export function landedElsewhere(requested, landed) {
|
|
349
|
+
const trim = (p) => p.replace(/\/+$/, '') || '/'
|
|
350
|
+
const a = new URL(requested)
|
|
351
|
+
const b = new URL(landed)
|
|
352
|
+
return !sameOrigin(a.href, b.href) || trim(a.pathname) !== trim(b.pathname)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const redirectLine = (requested, landed) => `redirected: ${requested} ended at ${landed}`
|
|
356
|
+
|
|
357
|
+
// Phones get touch and a mobile viewport, so a layout that keys on either renders as it would there.
|
|
358
|
+
const isPhone = ({ width }) => width < 600
|
|
359
|
+
|
|
360
|
+
// ---------------------------------------------------------------------------- measure
|
|
361
|
+
|
|
362
|
+
/** Runs in the page. Counts what the visible elements render, so frequency says what dominates. */
|
|
363
|
+
function tally() {
|
|
364
|
+
const TOP = 12
|
|
365
|
+
const rows = (map) =>
|
|
366
|
+
[...map.entries()]
|
|
367
|
+
.sort((a, b) => b[1] - a[1])
|
|
368
|
+
.slice(0, TOP)
|
|
369
|
+
.map(([value, count]) => ({ value, count }))
|
|
370
|
+
const add = (map, key, by = 1) => {
|
|
371
|
+
if (key) map.set(key, (map.get(key) ?? 0) + by)
|
|
372
|
+
}
|
|
373
|
+
const transparent = (c) => !c || c === 'transparent' || /rgba?\([^)]*,\s*0\)$/.test(c)
|
|
374
|
+
const px = (v) => (v && v !== '0px' && v !== 'normal' && v !== 'none' ? v : null)
|
|
375
|
+
|
|
376
|
+
const m = {
|
|
377
|
+
text: new Map(),
|
|
378
|
+
background: new Map(),
|
|
379
|
+
border: new Map(),
|
|
380
|
+
family: new Map(),
|
|
381
|
+
size: new Map(),
|
|
382
|
+
weight: new Map(),
|
|
383
|
+
lineHeight: new Map(),
|
|
384
|
+
letterSpacing: new Map(),
|
|
385
|
+
radius: new Map(),
|
|
386
|
+
shadow: new Map(),
|
|
387
|
+
padding: new Map(),
|
|
388
|
+
gap: new Map(),
|
|
389
|
+
}
|
|
390
|
+
const area = new Map()
|
|
391
|
+
const screen = innerWidth * Math.max(innerHeight, document.documentElement.scrollHeight)
|
|
392
|
+
let elements = 0
|
|
393
|
+
for (const el of document.body.querySelectorAll('*')) {
|
|
394
|
+
const r = el.getBoundingClientRect()
|
|
395
|
+
if (r.width < 1 || r.height < 1) continue
|
|
396
|
+
const s = getComputedStyle(el)
|
|
397
|
+
if (s.visibility === 'hidden' || s.display === 'none' || Number(s.opacity) === 0) continue
|
|
398
|
+
elements += 1
|
|
399
|
+
const ownText = [...el.childNodes].some((n) => n.nodeType === 3 && n.textContent.trim())
|
|
400
|
+
if (ownText) {
|
|
401
|
+
add(m.text, s.color)
|
|
402
|
+
add(m.family, s.fontFamily.split(',')[0].replace(/["']/g, '').trim())
|
|
403
|
+
add(m.size, s.fontSize)
|
|
404
|
+
add(m.weight, s.fontWeight)
|
|
405
|
+
add(m.lineHeight, s.lineHeight)
|
|
406
|
+
add(m.letterSpacing, px(s.letterSpacing))
|
|
407
|
+
}
|
|
408
|
+
if (!transparent(s.backgroundColor)) {
|
|
409
|
+
add(m.background, s.backgroundColor)
|
|
410
|
+
add(area, s.backgroundColor, r.width * r.height)
|
|
411
|
+
}
|
|
412
|
+
for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
|
|
413
|
+
if (parseFloat(s[`border${side}Width`]) > 0 && s[`border${side}Style`] !== 'none') {
|
|
414
|
+
add(m.border, `${s[`border${side}Width`]} ${s[`border${side}Color`]}`)
|
|
415
|
+
break
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
add(m.radius, px(s.borderRadius))
|
|
419
|
+
add(m.shadow, px(s.boxShadow))
|
|
420
|
+
for (const v of [s.paddingTop, s.paddingRight, s.paddingBottom, s.paddingLeft])
|
|
421
|
+
add(m.padding, px(v))
|
|
422
|
+
add(m.gap, px(s.rowGap))
|
|
423
|
+
add(m.gap, s.columnGap !== s.rowGap ? px(s.columnGap) : null)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// A stock palette defines hundreds of tokens no page uses; the project's own are what matter.
|
|
427
|
+
const STOCK =
|
|
428
|
+
/^--(tw-|color-(red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|zinc|neutral|stone)-\d)/
|
|
429
|
+
const tokens = {}
|
|
430
|
+
const root = getComputedStyle(document.documentElement)
|
|
431
|
+
let tokenCount = 0
|
|
432
|
+
for (const sheet of document.styleSheets) {
|
|
433
|
+
let rules
|
|
434
|
+
try {
|
|
435
|
+
rules = sheet.cssRules
|
|
436
|
+
} catch {
|
|
437
|
+
continue
|
|
438
|
+
}
|
|
439
|
+
const walk = (list) => {
|
|
440
|
+
for (const rule of list) {
|
|
441
|
+
if (rule.cssRules && !rule.selectorText) walk(rule.cssRules)
|
|
442
|
+
if (!rule.selectorText || !/(^|,)\s*(:root|html)\s*(,|$)/.test(rule.selectorText)) continue
|
|
443
|
+
for (const name of rule.style) {
|
|
444
|
+
if (!name.startsWith('--') || name in tokens) continue
|
|
445
|
+
tokenCount += 1
|
|
446
|
+
if (!STOCK.test(name)) tokens[name] = root.getPropertyValue(name).trim()
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
walk(rules)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const widths = new Map()
|
|
454
|
+
for (const el of document.querySelectorAll(
|
|
455
|
+
'main, section, header, footer, [class*="container"], [class*="shell"]',
|
|
456
|
+
)) {
|
|
457
|
+
const w = Math.round(el.getBoundingClientRect().width)
|
|
458
|
+
if (w > 0 && w < innerWidth) add(widths, `${w}px`)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return {
|
|
462
|
+
viewport: {
|
|
463
|
+
width: innerWidth,
|
|
464
|
+
height: innerHeight,
|
|
465
|
+
scrollHeight: document.documentElement.scrollHeight,
|
|
466
|
+
},
|
|
467
|
+
overflowsSideways: document.documentElement.scrollWidth > innerWidth,
|
|
468
|
+
elements,
|
|
469
|
+
colors: { text: rows(m.text), background: rows(m.background), border: rows(m.border) },
|
|
470
|
+
backgroundShare: rows(area).map(({ value, count }) => ({
|
|
471
|
+
value,
|
|
472
|
+
percent: Math.round((count / screen) * 1000) / 10,
|
|
473
|
+
})),
|
|
474
|
+
type: {
|
|
475
|
+
family: rows(m.family),
|
|
476
|
+
size: rows(m.size),
|
|
477
|
+
weight: rows(m.weight),
|
|
478
|
+
lineHeight: rows(m.lineHeight),
|
|
479
|
+
letterSpacing: rows(m.letterSpacing),
|
|
480
|
+
},
|
|
481
|
+
radius: rows(m.radius),
|
|
482
|
+
shadow: rows(m.shadow),
|
|
483
|
+
spacing: { padding: rows(m.padding), gap: rows(m.gap) },
|
|
484
|
+
containerWidths: rows(widths),
|
|
485
|
+
tokens: { declared: tokenCount, project: tokens },
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function measure(url) {
|
|
490
|
+
return withBrowser(url, async (browser) => {
|
|
491
|
+
await browser.viewport(DESKTOP)
|
|
492
|
+
const landed = await browser.open(url)
|
|
493
|
+
const desktop = await browser.evaluate(tally)
|
|
494
|
+
await browser.viewport(MOBILE)
|
|
495
|
+
await browser.open(url)
|
|
496
|
+
const { tokens, ...mobile } = await browser.evaluate(tally)
|
|
497
|
+
return {
|
|
498
|
+
url,
|
|
499
|
+
...(landedElsewhere(url, landed) ? { redirected: landed } : {}),
|
|
500
|
+
desktop,
|
|
501
|
+
mobile,
|
|
502
|
+
}
|
|
503
|
+
})
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ---------------------------------------------------------------------------- shot
|
|
507
|
+
|
|
508
|
+
/** Runs in the page. Where the element sits on the whole page, and what of it no screenshot shows. */
|
|
509
|
+
function locate(selector) {
|
|
510
|
+
const all = document.querySelectorAll(selector)
|
|
511
|
+
if (!all.length) return { found: 0 }
|
|
512
|
+
const el = all[0]
|
|
513
|
+
const r = el.getBoundingClientRect()
|
|
514
|
+
const name = (e) => {
|
|
515
|
+
const cls = typeof e.className === 'string' ? e.className.trim().split(/\s+/).slice(0, 2) : []
|
|
516
|
+
return (
|
|
517
|
+
e.tagName.toLowerCase() +
|
|
518
|
+
(e.id ? `#${e.id}` : '') +
|
|
519
|
+
cls.map((c) => (c ? `.${c}` : '')).join('')
|
|
520
|
+
)
|
|
521
|
+
}
|
|
522
|
+
const clipped = []
|
|
523
|
+
for (const e of [el, ...el.querySelectorAll('*')]) {
|
|
524
|
+
const st = getComputedStyle(e)
|
|
525
|
+
const below = st.overflowY !== 'visible' ? e.scrollHeight - e.clientHeight : 0
|
|
526
|
+
const side = st.overflowX !== 'visible' ? e.scrollWidth - e.clientWidth : 0
|
|
527
|
+
if (below > 1) clipped.push(`${name(e)} scrolls ${below}px more below`)
|
|
528
|
+
if (side > 1) clipped.push(`${name(e)} scrolls ${side}px more to the side`)
|
|
529
|
+
if (clipped.length >= 5) break
|
|
530
|
+
}
|
|
531
|
+
for (let a = el.parentElement; a && a !== document.body; a = a.parentElement) {
|
|
532
|
+
const st = getComputedStyle(a)
|
|
533
|
+
if (st.overflowX === 'visible' && st.overflowY === 'visible') continue
|
|
534
|
+
const ar = a.getBoundingClientRect()
|
|
535
|
+
if (
|
|
536
|
+
r.top < ar.top - 1 ||
|
|
537
|
+
r.bottom > ar.bottom + 1 ||
|
|
538
|
+
r.left < ar.left - 1 ||
|
|
539
|
+
r.right > ar.right + 1
|
|
540
|
+
) {
|
|
541
|
+
clipped.push(`${name(a)} cuts part of it off`)
|
|
542
|
+
break
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return {
|
|
546
|
+
found: all.length,
|
|
547
|
+
rect: { x: r.left + scrollX, y: r.top + scrollY, width: r.width, height: r.height },
|
|
548
|
+
clipped,
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
export function parseViewport(raw) {
|
|
553
|
+
const match = /^(\d+)x(\d+)$/.exec(raw ?? '')
|
|
554
|
+
if (!match) throw new Error(`--viewport takes WIDTHxHEIGHT, like 1168x861, not ${raw}`)
|
|
555
|
+
return { width: Number(match[1]), height: Number(match[2]) }
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** `card.png` at one viewport, `card-1168x861.png` and the rest when there are several; a state
|
|
559
|
+
* or the page adds its name, as in `card-hover.png`. */
|
|
560
|
+
export function shotFiles(file, sizes, page, states = []) {
|
|
561
|
+
const stem = join(dirname(file), basename(file, extname(file)))
|
|
562
|
+
const name = (tag, size) =>
|
|
563
|
+
sizes.length === 1 && !tag
|
|
564
|
+
? file
|
|
565
|
+
: `${stem}${tag}${sizes.length > 1 ? `-${size.width}x${size.height}` : ''}.png`
|
|
566
|
+
return sizes.flatMap((size) => [
|
|
567
|
+
{ size, file: name('', size), kind: 'piece' },
|
|
568
|
+
...(page ? [{ size, file: name('-page', size), kind: 'page' }] : []),
|
|
569
|
+
...states.map(({ state, target }) => ({
|
|
570
|
+
size,
|
|
571
|
+
file: name(`-${state}`, size),
|
|
572
|
+
kind: state,
|
|
573
|
+
target,
|
|
574
|
+
})),
|
|
575
|
+
])
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** The states asked for, each on its own element or on the piece. */
|
|
579
|
+
export function statesFrom(named, around) {
|
|
580
|
+
const wanted = named.states === true ? STATES : STATES.filter((s) => named[s] !== undefined)
|
|
581
|
+
return wanted.map((state) => {
|
|
582
|
+
const target = typeof named[state] === 'string' ? named[state] : around
|
|
583
|
+
if (!target) throw new Error(`--${state} needs a selector, or --around to take it from`)
|
|
584
|
+
return { state, target }
|
|
585
|
+
})
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Runs in the page. Brings the element into view and says where its middle is on screen. */
|
|
589
|
+
function reach(selector) {
|
|
590
|
+
const el = document.querySelector(selector)
|
|
591
|
+
if (!el) return null
|
|
592
|
+
el.scrollIntoView({ block: 'center', inline: 'center' })
|
|
593
|
+
const r = el.getBoundingClientRect()
|
|
594
|
+
return { x: r.left + r.width / 2, y: r.top + r.height / 2 }
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** Runs in the page. Waits out the transitions a state started, never one that loops. */
|
|
598
|
+
async function settle() {
|
|
599
|
+
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))
|
|
600
|
+
const finite = document
|
|
601
|
+
.getAnimations()
|
|
602
|
+
.filter((a) => a.effect?.getComputedTiming().endTime !== Infinity)
|
|
603
|
+
await Promise.race([
|
|
604
|
+
Promise.all(finite.map((a) => a.finished.catch(() => {}))),
|
|
605
|
+
new Promise((r) => setTimeout(r, 1000)),
|
|
606
|
+
])
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/** Puts the element in a state the way a person would: the pointer on it, pressed on it, or
|
|
610
|
+
* focus reached from the keyboard, so `:focus-visible` holds too. */
|
|
611
|
+
async function enterState(browser, state, target) {
|
|
612
|
+
const at = await browser.evaluate(`() => (${reach})(${JSON.stringify(target)})`)
|
|
613
|
+
if (!at) throw new Error(`nothing matches ${target} to ${state}`)
|
|
614
|
+
if (state === 'focus') {
|
|
615
|
+
await browser.key('Shift')
|
|
616
|
+
const focused = await browser.evaluate(
|
|
617
|
+
`() => { const el = document.querySelector(${JSON.stringify(target)}); el.focus(); return document.activeElement === el }`,
|
|
618
|
+
)
|
|
619
|
+
if (!focused) throw new Error(`${target} cannot take focus`)
|
|
620
|
+
} else {
|
|
621
|
+
await browser.mouse('mouseMoved', at.x, at.y)
|
|
622
|
+
if (state === 'active') await browser.mouse('mousePressed', at.x, at.y, 1)
|
|
623
|
+
}
|
|
624
|
+
await browser.evaluate(settle)
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/** Lets go without a click: the press is released away from the element. */
|
|
628
|
+
async function leaveState(browser, state) {
|
|
629
|
+
if (state === 'active') {
|
|
630
|
+
await browser.mouse('mouseMoved', 0, 0, 1)
|
|
631
|
+
await browser.mouse('mouseReleased', 0, 0)
|
|
632
|
+
}
|
|
633
|
+
await browser.mouse('mouseMoved', 0, 0)
|
|
634
|
+
await browser.evaluate('() => { document.activeElement?.blur(); window.scrollTo(0, 0) }')
|
|
635
|
+
await browser.evaluate(settle)
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
async function shot(
|
|
639
|
+
url,
|
|
640
|
+
file,
|
|
641
|
+
{
|
|
642
|
+
sizes = [DESKTOP],
|
|
643
|
+
around = null,
|
|
644
|
+
page = false,
|
|
645
|
+
margin = MARGIN,
|
|
646
|
+
scale = PIECE_SCALE,
|
|
647
|
+
states = [],
|
|
648
|
+
} = {},
|
|
649
|
+
) {
|
|
650
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
651
|
+
return withBrowser(url, async (browser) => {
|
|
652
|
+
const lines = []
|
|
653
|
+
let loaded = null
|
|
654
|
+
let landed = url
|
|
655
|
+
for (const { size, file: out, kind, target } of shotFiles(file, sizes, page, states)) {
|
|
656
|
+
// The piece, the page and the states at one viewport come out of one load.
|
|
657
|
+
if (loaded !== size) {
|
|
658
|
+
await browser.viewport(size, scale)
|
|
659
|
+
landed = await browser.open(url)
|
|
660
|
+
if (loaded === null && landedElsewhere(url, landed)) lines.push(redirectLine(url, landed))
|
|
661
|
+
loaded = size
|
|
662
|
+
}
|
|
663
|
+
const crop = kind === 'piece' || kind === 'page' ? around : (around ?? target)
|
|
664
|
+
// The page is for rhythm and proportion, which read at 1x; the detail is in the crop.
|
|
665
|
+
if (!crop || kind === 'page') {
|
|
666
|
+
writeFileSync(out, await browser.screenshot(undefined, 1 / scale))
|
|
667
|
+
lines.push(out)
|
|
668
|
+
continue
|
|
669
|
+
}
|
|
670
|
+
if (kind === 'hover' && isPhone(size)) {
|
|
671
|
+
lines.push(`${kind}: none at ${size.width}x${size.height}, a phone has no hover`)
|
|
672
|
+
continue
|
|
673
|
+
}
|
|
674
|
+
if (kind !== 'piece') await enterState(browser, kind, target)
|
|
675
|
+
const found = await browser.evaluate(`() => (${locate})(${JSON.stringify(crop)})`)
|
|
676
|
+
if (!found.found) {
|
|
677
|
+
const where = landedElsewhere(url, landed) ? `; it was ${redirectLine(url, landed)}` : ''
|
|
678
|
+
throw new Error(`nothing on ${url} matches ${crop} at ${size.width}x${size.height}${where}`)
|
|
679
|
+
}
|
|
680
|
+
const doc = await browser.evaluate(
|
|
681
|
+
'() => ({ width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight })',
|
|
682
|
+
)
|
|
683
|
+
const left = Math.max(0, Math.floor(found.rect.x - margin))
|
|
684
|
+
const top = Math.max(0, Math.floor(found.rect.y - margin))
|
|
685
|
+
const right = Math.min(doc.width, Math.ceil(found.rect.x + found.rect.width + margin))
|
|
686
|
+
const bottom = Math.min(doc.height, Math.ceil(found.rect.y + found.rect.height + margin))
|
|
687
|
+
writeFileSync(
|
|
688
|
+
out,
|
|
689
|
+
await browser.screenshot({ x: left, y: top, width: right - left, height: bottom - top }),
|
|
690
|
+
)
|
|
691
|
+
lines.push(out)
|
|
692
|
+
if (kind !== 'piece') {
|
|
693
|
+
await leaveState(browser, kind)
|
|
694
|
+
continue
|
|
695
|
+
}
|
|
696
|
+
if (found.found > 1) lines.push(`${found.found} elements match ${crop}; this is the first`)
|
|
697
|
+
for (const line of found.clipped) lines.push(`clipped: ${line}`)
|
|
698
|
+
}
|
|
699
|
+
return lines
|
|
700
|
+
})
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// ---------------------------------------------------------------------------- shots
|
|
704
|
+
|
|
705
|
+
export function shotName(path) {
|
|
706
|
+
const slug = path
|
|
707
|
+
.replace(/^\/+|\/+$/g, '')
|
|
708
|
+
.replace(/\.html?$/i, '')
|
|
709
|
+
.replace(/[^a-z0-9]+/gi, '-')
|
|
710
|
+
.toLowerCase()
|
|
711
|
+
return slug || 'index'
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function shots(url, dir, paths) {
|
|
715
|
+
mkdirSync(dir, { recursive: true })
|
|
716
|
+
return withBrowser(url, async (browser) => {
|
|
717
|
+
const written = []
|
|
718
|
+
for (const [name, size] of [
|
|
719
|
+
['desktop', DESKTOP],
|
|
720
|
+
['mobile', MOBILE],
|
|
721
|
+
]) {
|
|
722
|
+
await browser.viewport(size)
|
|
723
|
+
for (const path of paths) {
|
|
724
|
+
const target = new URL(path, url).href
|
|
725
|
+
const landed = await browser.open(target)
|
|
726
|
+
const file = join(dir, `${shotName(path)}-${name}.png`)
|
|
727
|
+
writeFileSync(file, await browser.screenshot())
|
|
728
|
+
written.push(file)
|
|
729
|
+
if (name === 'desktop' && landedElsewhere(target, landed))
|
|
730
|
+
written.push(redirectLine(target, landed))
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
return written
|
|
734
|
+
})
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// ---------------------------------------------------------------------------- compare
|
|
738
|
+
|
|
739
|
+
/** 8-bit truecolour PNGs, with or without alpha: what a browser screenshot is. */
|
|
740
|
+
export function decodePng(buffer) {
|
|
741
|
+
if (buffer.readUInt32BE(0) !== 0x89504e47) throw new Error('not a PNG')
|
|
742
|
+
let at = 8
|
|
743
|
+
let width = 0
|
|
744
|
+
let height = 0
|
|
745
|
+
let channels = 0
|
|
746
|
+
const data = []
|
|
747
|
+
while (at < buffer.length) {
|
|
748
|
+
const length = buffer.readUInt32BE(at)
|
|
749
|
+
const type = buffer.toString('ascii', at + 4, at + 8)
|
|
750
|
+
const body = buffer.subarray(at + 8, at + 8 + length)
|
|
751
|
+
if (type === 'IHDR') {
|
|
752
|
+
width = body.readUInt32BE(0)
|
|
753
|
+
height = body.readUInt32BE(4)
|
|
754
|
+
const depth = body[8]
|
|
755
|
+
const colour = body[9]
|
|
756
|
+
if (depth !== 8 || (colour !== 2 && colour !== 6) || body[12] !== 0) {
|
|
757
|
+
throw new Error(
|
|
758
|
+
`unsupported PNG: bit depth ${depth}, colour type ${colour}, interlace ${body[12]}`,
|
|
759
|
+
)
|
|
760
|
+
}
|
|
761
|
+
channels = colour === 6 ? 4 : 3
|
|
762
|
+
} else if (type === 'IDAT') data.push(body)
|
|
763
|
+
else if (type === 'IEND') break
|
|
764
|
+
at += 12 + length
|
|
765
|
+
}
|
|
766
|
+
const raw = inflateSync(Buffer.concat(data))
|
|
767
|
+
const stride = width * channels
|
|
768
|
+
const pixels = Buffer.alloc(stride * height)
|
|
769
|
+
for (let y = 0; y < height; y++) {
|
|
770
|
+
const filter = raw[y * (stride + 1)]
|
|
771
|
+
const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1))
|
|
772
|
+
const out = pixels.subarray(y * stride, (y + 1) * stride)
|
|
773
|
+
const up = y > 0 ? pixels.subarray((y - 1) * stride, y * stride) : null
|
|
774
|
+
for (let x = 0; x < stride; x++) {
|
|
775
|
+
const a = x >= channels ? out[x - channels] : 0
|
|
776
|
+
const b = up ? up[x] : 0
|
|
777
|
+
const c = up && x >= channels ? up[x - channels] : 0
|
|
778
|
+
let v = line[x]
|
|
779
|
+
if (filter === 1) v += a
|
|
780
|
+
else if (filter === 2) v += b
|
|
781
|
+
else if (filter === 3) v += (a + b) >> 1
|
|
782
|
+
else if (filter === 4) {
|
|
783
|
+
const p = a + b - c
|
|
784
|
+
const pa = Math.abs(p - a)
|
|
785
|
+
const pb = Math.abs(p - b)
|
|
786
|
+
const pc = Math.abs(p - c)
|
|
787
|
+
v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c
|
|
788
|
+
}
|
|
789
|
+
out[x] = v & 0xff
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
return { width, height, channels, pixels }
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
export function diffPngs(before, after) {
|
|
796
|
+
if (before.equals(after)) return { same: true }
|
|
797
|
+
const a = decodePng(before)
|
|
798
|
+
const b = decodePng(after)
|
|
799
|
+
if (a.width !== b.width || a.height !== b.height) {
|
|
800
|
+
return {
|
|
801
|
+
same: false,
|
|
802
|
+
size: { before: `${a.width}x${a.height}`, after: `${b.width}x${b.height}` },
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
let changed = 0
|
|
806
|
+
let box = null
|
|
807
|
+
for (let y = 0; y < a.height; y++) {
|
|
808
|
+
for (let x = 0; x < a.width; x++) {
|
|
809
|
+
const i = (y * a.width + x) * a.channels
|
|
810
|
+
const j = (y * b.width + x) * b.channels
|
|
811
|
+
if (
|
|
812
|
+
a.pixels[i] === b.pixels[j] &&
|
|
813
|
+
a.pixels[i + 1] === b.pixels[j + 1] &&
|
|
814
|
+
a.pixels[i + 2] === b.pixels[j + 2]
|
|
815
|
+
)
|
|
816
|
+
continue
|
|
817
|
+
changed += 1
|
|
818
|
+
box = box
|
|
819
|
+
? {
|
|
820
|
+
left: Math.min(box.left, x),
|
|
821
|
+
top: Math.min(box.top, y),
|
|
822
|
+
right: Math.max(box.right, x),
|
|
823
|
+
bottom: Math.max(box.bottom, y),
|
|
824
|
+
}
|
|
825
|
+
: { left: x, top: y, right: x, bottom: y }
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
return changed ? { same: false, changed, box } : { same: true }
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
export function compare(beforeDir, afterDir) {
|
|
832
|
+
const names = new Set(
|
|
833
|
+
[...readdirSync(beforeDir), ...readdirSync(afterDir)].filter((n) => n.endsWith('.png')),
|
|
834
|
+
)
|
|
835
|
+
return [...names].sort().map((name) => {
|
|
836
|
+
const a = join(beforeDir, name)
|
|
837
|
+
const b = join(afterDir, name)
|
|
838
|
+
if (!existsSync(a) || !existsSync(b))
|
|
839
|
+
return { name, same: false, missing: existsSync(a) ? 'after' : 'before' }
|
|
840
|
+
return { name, ...diffPngs(readFileSync(a), readFileSync(b)) }
|
|
841
|
+
})
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
export function describe(result) {
|
|
845
|
+
if (result.same) return `same ${result.name}`
|
|
846
|
+
if (result.missing) return `missing ${result.name} (no ${result.missing})`
|
|
847
|
+
if (result.size)
|
|
848
|
+
return `differs ${result.name}: size ${result.size.before} -> ${result.size.after}`
|
|
849
|
+
const { left, top, right, bottom } = result.box
|
|
850
|
+
return `differs ${result.name}: ${result.changed} pixels, x ${left}-${right}, y ${top}-${bottom}`
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// ---------------------------------------------------------------------------- the CLI
|
|
854
|
+
|
|
855
|
+
const USAGE = `usage:
|
|
856
|
+
node page.mjs measure <url>
|
|
857
|
+
node page.mjs shot <url> <file> [--viewport WxH[,WxH...]] [--around <css>] [--page] [--margin px] [--scale n]
|
|
858
|
+
[--hover [css]] [--focus [css]] [--active [css]] [--states]
|
|
859
|
+
node page.mjs shots <url> <dir> [path ...]
|
|
860
|
+
node page.mjs compare <before-dir> <after-dir>`
|
|
861
|
+
|
|
862
|
+
/** `--name value` pairs out of the arguments, the rest kept in order; a flag with no value is true. */
|
|
863
|
+
export function flags(args) {
|
|
864
|
+
const named = {}
|
|
865
|
+
const rest = []
|
|
866
|
+
for (let at = 0; at < args.length; at++) {
|
|
867
|
+
if (!args[at].startsWith('--')) rest.push(args[at])
|
|
868
|
+
else if (at + 1 < args.length && !args[at + 1].startsWith('--'))
|
|
869
|
+
named[args[at].slice(2)] = args[++at]
|
|
870
|
+
else named[args[at].slice(2)] = true
|
|
871
|
+
}
|
|
872
|
+
return { named, rest }
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
async function main([command, ...args]) {
|
|
876
|
+
if (command === 'measure' && args.length === 1) {
|
|
877
|
+
console.log(JSON.stringify(await measure(args[0]), null, 1))
|
|
878
|
+
} else if (command === 'shot' && flags(args).rest.length === 2) {
|
|
879
|
+
const { named, rest } = flags(args)
|
|
880
|
+
const lines = await shot(rest[0], resolve(rest[1]), {
|
|
881
|
+
sizes: named.viewport ? named.viewport.split(',').map(parseViewport) : [DESKTOP],
|
|
882
|
+
around: named.around ?? null,
|
|
883
|
+
page: named.page === true,
|
|
884
|
+
margin: named.margin !== undefined ? Number(named.margin) : MARGIN,
|
|
885
|
+
scale: named.scale !== undefined ? Number(named.scale) : PIECE_SCALE,
|
|
886
|
+
states: statesFrom(named, named.around ?? null),
|
|
887
|
+
})
|
|
888
|
+
for (const line of lines) console.log(line)
|
|
889
|
+
} else if (command === 'shots' && args.length >= 2) {
|
|
890
|
+
const [url, dir, ...paths] = args
|
|
891
|
+
for (const file of await shots(url, resolve(dir), paths.length ? paths : ['/']))
|
|
892
|
+
console.log(file)
|
|
893
|
+
} else if (command === 'compare' && args.length === 2) {
|
|
894
|
+
const results = compare(resolve(args[0]), resolve(args[1]))
|
|
895
|
+
for (const result of results) console.log(describe(result))
|
|
896
|
+
if (results.some((r) => !r.same)) process.exitCode = 1
|
|
897
|
+
} else {
|
|
898
|
+
console.error(USAGE)
|
|
899
|
+
process.exitCode = 2
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === realpathSync(process.argv[1])) {
|
|
904
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
905
|
+
console.error(error instanceof Error ? error.message.split('\n')[0] : String(error))
|
|
906
|
+
process.exitCode = 1
|
|
907
|
+
})
|
|
908
|
+
}
|