kaloko 0.9.0 → 0.10.0
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/package.json +1 -1
- package/schemas/config.v1.json +2 -0
- package/schemas/revision.v1.json +11 -0
- package/schemas/run.v1.json +12 -3
- package/schemas/scenario.v1.json +14 -0
- package/skills/kaloko/reference/design.md +93 -1
- package/skills/kaloko/reference/execution.md +6 -0
- package/skills/kaloko/reference/portable-html.md +12 -3
- package/skills/kaloko/reference/stacks.md +6 -0
- package/src/cli.mjs +95 -30
- package/src/lib/build.mjs +9 -46
- package/src/lib/capture.mjs +1 -1
- package/src/lib/design-cli.mjs +121 -47
- package/src/lib/design-core.mjs +26 -4
- package/src/lib/design-export.mjs +26 -6
- package/src/lib/design-lint.mjs +45 -11
- package/src/lib/design-origins.mjs +86 -0
- package/src/lib/design.mjs +112 -8
- package/src/lib/dimensions.mjs +66 -12
- package/src/lib/drift.mjs +1 -1
- package/src/lib/evaluate.mjs +6 -2
- package/src/lib/manifest-core.mjs +90 -5
- package/src/lib/spec-cli.mjs +7 -4
- package/src/lib/stack.mjs +5 -8
- package/src/lib/tokens.mjs +16 -0
- package/src/lib/walk-dimensions.mjs +31 -6
- package/viewer/index.html +138 -35
package/src/lib/design-lint.mjs
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import { readFile, readdir, stat } from 'node:fs/promises'
|
|
6
6
|
import { join, relative, sep } from 'node:path'
|
|
7
7
|
import { BUNDLE_LIMITS, SHARED_DIR, TOKENS_FILE, TEXT_TYPES, contentTypeOf, extOf, resolveRef } from './design-core.mjs'
|
|
8
|
+
import { listedItems } from './design.mjs'
|
|
9
|
+
import { originAllowed } from './design-origins.mjs'
|
|
8
10
|
|
|
9
11
|
/** Rule id → level and a short reason (the fix is in each finding). */
|
|
10
12
|
export const LINT_RULES = {
|
|
@@ -19,7 +21,9 @@ export const LINT_RULES = {
|
|
|
19
21
|
'websocket': ['error', 'network connections are blocked (connect-src self)'],
|
|
20
22
|
'too-large': ['error', 'over the bundle limits'],
|
|
21
23
|
'tokens-json': ['error', 'tokens.json is not valid JSON'],
|
|
24
|
+
'variant-page': ['error', 'a variant folder of a stack step without its index.html'],
|
|
22
25
|
'storage': ['warn', 'storage and cookies throw in the sandboxed frame and are not shared between viewers'],
|
|
26
|
+
'origin-not-allowed': ['warn', 'a font or asset origin the organization has not allowed yet; it does not load on the live step'],
|
|
23
27
|
'nondeterministic': ['warn', 'random values and the current time change every capture'],
|
|
24
28
|
'directory-link': ['warn', 'a link to a folder does not open its index.html from disk or a zip'],
|
|
25
29
|
'broken-link': ['warn', 'a link to a page that is not in the folder'],
|
|
@@ -39,7 +43,8 @@ export const LINT_RULES = {
|
|
|
39
43
|
'missing-step': ['warn', 'a scenario step without a design'],
|
|
40
44
|
'external-link': ['info', 'a link that leaves the prototype'],
|
|
41
45
|
'stray-folder': ['info', 'a folder that is not a step of the scenario'],
|
|
42
|
-
'no-tokens': ['info', 'no token set: the inspector cannot name values']
|
|
46
|
+
'no-tokens': ['info', 'no token set: the inspector cannot name values'],
|
|
47
|
+
'origins-unchecked': ['info', 'the organization\'s list of font and asset origins could not be read']
|
|
43
48
|
}
|
|
44
49
|
export const BUDGET = { file_bytes: 1024 * 1024, step_bytes: 5 * 1024 * 1024 }
|
|
45
50
|
|
|
@@ -50,21 +55,37 @@ const kb = (b) => `${Math.round(b / 1024)} kB`
|
|
|
50
55
|
|
|
51
56
|
/**
|
|
52
57
|
* Lints the files of a design folder. `files`: Map path → { bytes, text? } (text for text types), `steps`: step ids of
|
|
53
|
-
* the scenario (null = every top-level folder but _shared)
|
|
58
|
+
* the scenario (null = every top-level folder but _shared), `stacks`: stack step id → the variant folders its
|
|
59
|
+
* `items.urls` lists (null: discovered from subfolders), `locales` of the scenario. `origins`: { wanted, allowed,
|
|
60
|
+
* unchecked } font and asset origins (#46): `wanted` the design environment declares (`origins` in
|
|
61
|
+
* kaloko.config.yml), `allowed` the organization's list on the service (null when unknown; `unchecked` says it could
|
|
62
|
+
* not be read). A stylesheet, font or image from a wanted or allowed origin is no error; one the organization has not
|
|
63
|
+
* allowed yet is a warning. Scripts, fetches and media never load from other sites. Returns findings
|
|
64
|
+
* { rule, level, file, line, message, fix }.
|
|
54
65
|
*/
|
|
55
|
-
export function lintFiles (files, { steps = null } = {}) {
|
|
66
|
+
export function lintFiles (files, { steps = null, stacks = {}, locales = [], origins = null } = {}) {
|
|
67
|
+
const wanted = origins?.wanted || [], allowed = Array.isArray(origins?.allowed) ? origins.allowed : null
|
|
56
68
|
const out = []
|
|
57
69
|
const add = (rule, file, line, message, fix = '') => out.push({ rule, level: LINT_RULES[rule][0], file, line: line || null, message, fix })
|
|
58
70
|
const has = (p) => files.has(p)
|
|
59
71
|
const isDir = (p) => [...files.keys()].some((f) => f.startsWith(p.replace(/\/?$/, '/')))
|
|
60
72
|
let anyMotion = null, reducedMotion = false, usesVars = false
|
|
61
|
-
const resource = (file, text, i, raw, { script = false, data = false } = {}) => {
|
|
73
|
+
const resource = (file, text, i, raw, { script = false, data = false, media = false } = {}) => {
|
|
62
74
|
const u = raw.trim()
|
|
63
75
|
if (!u || u.startsWith('#') || /^(about:blank|blob:)/i.test(u)) return
|
|
64
76
|
const line = lineAt(text, i)
|
|
65
77
|
if (/^data:/i.test(u)) { if (script) add('external-url', file, line, 'a script from a data: URL is blocked (script-src self)', 'Put the script into a .js file of the bundle.'); return }
|
|
66
78
|
if (/^javascript:/i.test(u)) return
|
|
67
|
-
if (/^(https?:)?\/\//i.test(u) || SCHEME.test(u))
|
|
79
|
+
if (/^(https?:)?\/\//i.test(u) || SCHEME.test(u)) {
|
|
80
|
+
// fonts, stylesheets and images may come from an origin the organization allows (style-src, font-src, img-src)
|
|
81
|
+
const asset = /^(https:)?\/\//i.test(u) && !script && !data && !media
|
|
82
|
+
if (asset && (originAllowed(wanted, u) || (allowed && originAllowed(allowed, u)))) {
|
|
83
|
+
if (allowed && !originAllowed(allowed, u)) add('origin-not-allowed', file, line, `${new URL(u.startsWith('//') ? `https:${u}` : u).origin} is in origins of the design environment, but the organization does not allow it yet; it will not load on the live step`, 'Ask an organization admin to add it in Settings → Security → Fonts and assets of designs, or download the file into _shared/.')
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
const why = script ? 'scripts load only from the bundle, also from origins the organization allows' : data ? 'live steps have no network (connect-src self)' : media ? 'video and audio load only from the bundle' : 'it will not load on the live step unless the organization allows that origin'
|
|
87
|
+
return add('external-url', file, line, `loads ${u.slice(0, 120)} from another site; ${why}`, data ? 'Live steps have no network: put the data into a JSON fixture of the bundle and fetch(\'./fixtures/….json\').' : asset ? 'Download the file into _shared/ (fonts as woff2) and reference it with a relative path, or add its origin to origins of the design environment and ask an organization admin to allow it (Settings → Security).' : 'Download the file into _shared/ and reference it with a relative path.')
|
|
88
|
+
}
|
|
68
89
|
if (u.startsWith('/')) return add('root-relative', file, line, `${u.slice(0, 120)} starts with /, which is the origin root on the live step and the disk root from file://`, `Use a path relative to ${file} (for example ../${SHARED_DIR}/…).`)
|
|
69
90
|
const p = resolveRef(file, stripQ(u))
|
|
70
91
|
if (!p) return add('outside-folder', file, line, `${u.slice(0, 120)} points outside the design folder`, 'Keep every file the page needs inside the design folder.')
|
|
@@ -99,7 +120,7 @@ export function lintFiles (files, { steps = null } = {}) {
|
|
|
99
120
|
at(/\bnew\s+(?:XMLHttpRequest|EventSource|WebSocket)\b/g, (m, l) => add('websocket', file, l, `${m[0].replace(/^new\s+/, '')} needs a network the live step does not have (connect-src self)`, 'Read fixtures from JSON files in the bundle with fetch(\'./fixtures/…json\').'))
|
|
100
121
|
at(/(?:^|[^.\w$])(?:eval\s*\(|new\s+Function\s*\(|set(?:Timeout|Interval)\s*\(\s*['"`])/g, (m, l) => add('eval', file, l, 'code from a string is blocked on the live step (no \'unsafe-eval\')', 'Pass a function instead of a string; do not use eval or new Function.'))
|
|
101
122
|
at(/navigator\.serviceWorker/g, (m, l) => add('service-worker', file, l, 'service workers are refused on live steps and outlive the page', 'Remove the registration; prototypes need no offline cache.'))
|
|
102
|
-
at(/\b(localStorage|sessionStorage|indexedDB|document\.cookie)\b/g, (m, l) => add('storage', file, l, `${m[1]} throws in the sandboxed live step (opaque origin) and is empty for every other viewer`, 'Keep
|
|
123
|
+
at(/\b(localStorage|sessionStorage|indexedDB|document\.cookie)\b/g, (m, l) => add('storage', file, l, `${m[1]} throws in the sandboxed live step (opaque origin) and is empty for every other viewer`, 'Keep state between steps in window.kaloko.state (the prototype state of the canvas) or in the URL (?query, #hash); wrap any storage access in try/catch.'))
|
|
103
124
|
at(/\b(Date\.now\(\)|new Date\(\s*\)|Math\.random\(\)|crypto\.(?:randomUUID|getRandomValues)\()/g, (m, l) => add('nondeterministic', file, l, `${m[1].replace(/\($/, '()')} makes every capture different, so unchanged steps look changed`, 'Use fixed dates (new Date(\'2026-01-15T10:00\')) and a seeded generator for fake data.'))
|
|
104
125
|
at(/(?:^|[^.\w$]|window\.)(alert|confirm|prompt)\s*\(/g, (m, l) => add('modal', file, l, `${m[1]}() is blocked in the sandbox (no allow-modals)`, 'Show an in-page <dialog> instead.'))
|
|
105
126
|
at(/(?:^|[^.\w$])window\.open\s*\(/g, (m, l) => add('new-window', file, l, 'window.open is blocked in the sandbox (no allow-popups)', 'Navigate in the same frame (location.href = \'../next/index.html\').'))
|
|
@@ -113,8 +134,8 @@ export function lintFiles (files, { steps = null } = {}) {
|
|
|
113
134
|
if (ext === 'css') css(file, t)
|
|
114
135
|
else if (ext === 'js' || ext === 'mjs') js(file, t)
|
|
115
136
|
else if (ext === 'html' || ext === 'svg') {
|
|
116
|
-
for (const m of t.matchAll(/\s(src|poster|data-src)\s*=\s*(['"])([^'"]*)\2/gi)) resource(file, t, m.index, m[3], { script: /<script\b[^>]*$/i.test(
|
|
117
|
-
for (const m of t.matchAll(/<link\b([^>]*)>/gi)) { const h = m[1].match(/\shref\s*=\s*(['"])([^'"]*)\1/i); const rel = (m[1].match(/\srel\s*=\s*(['"]?)([^'"\s>]+)\1/i)?.[2] || '').toLowerCase(); if (h && !/^(canonical|alternate|author|help|license|next|prev|search|dns-prefetch|preconnect)$/.test(rel)) resource(file, t, m.index, h[2]) }
|
|
137
|
+
for (const m of t.matchAll(/\s(src|poster|data-src)\s*=\s*(['"])([^'"]*)\2/gi)) { const before = t.slice(Math.max(0, m.index - 200), m.index); resource(file, t, m.index, m[3], { script: /<script\b[^>]*$/i.test(before), media: m[1].toLowerCase() === 'src' && /<(video|audio|source|track)\b[^>]*$/i.test(before) }) }
|
|
138
|
+
for (const m of t.matchAll(/<link\b([^>]*)>/gi)) { const h = m[1].match(/\shref\s*=\s*(['"])([^'"]*)\1/i); const rel = (m[1].match(/\srel\s*=\s*(['"]?)([^'"\s>]+)\1/i)?.[2] || '').toLowerCase(); if (h && !/^(canonical|alternate|author|help|license|next|prev|search|dns-prefetch|preconnect)$/.test(rel)) resource(file, t, m.index, h[2], { script: rel === 'modulepreload' || /\sas\s*=\s*["']?script/i.test(m[1]) }) }
|
|
118
139
|
for (const m of t.matchAll(/<(?:use|image|feImage)\b[^>]*\s(?:xlink:)?href\s*=\s*(['"])([^'"]*)\1/gi)) resource(file, t, m.index, m[2])
|
|
119
140
|
for (const m of t.matchAll(/\ssrcset\s*=\s*(['"])([^'"]*)\1/gi)) for (const part of m[2].split(',')) resource(file, t, m.index, part.trim().split(/\s+/)[0])
|
|
120
141
|
for (const m of t.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)) css(file, m[1], m.index + m[0].indexOf(m[1]), t)
|
|
@@ -137,6 +158,7 @@ export function lintFiles (files, { steps = null } = {}) {
|
|
|
137
158
|
try { JSON.parse(files.get(TOKENS_FILE).text) } catch (e) { add('tokens-json', TOKENS_FILE, null, e.message, 'Fix the JSON (kaloko tokens validate).') }
|
|
138
159
|
if (!usesVars) add('tokens-unused', TOKENS_FILE, null, 'no var(--…) in any stylesheet', 'Write token values as CSS custom properties (--color-accent) and use var(--color-accent), so the inspector and kaloko tokens check see them.')
|
|
139
160
|
} else add('no-tokens', '.', null, 'no tokens.json in the folder', 'Add a W3C DTCG token set (kaloko tokens pull, or write tokens.json).')
|
|
161
|
+
if (wanted.length && !allowed && origins?.unchecked) add('origins-unchecked', '.', null, `origins of the design environment (${wanted.join(', ')}) were not compared with the organization's list`, 'Sign in (kaloko login) so kaloko design lint can read which origins the organization allows.')
|
|
140
162
|
// per step: everything under its folder plus the shared files (an upper bound of what a revision carries)
|
|
141
163
|
const tops = [...new Set([...files.keys()].filter((p) => p.includes('/')).map((p) => p.split('/')[0]))].filter((d) => d !== SHARED_DIR)
|
|
142
164
|
const shared = [...files].filter(([p]) => p.startsWith(`${SHARED_DIR}/`)).reduce((s, [, f]) => s + f.bytes, 0)
|
|
@@ -146,6 +168,17 @@ export function lintFiles (files, { steps = null } = {}) {
|
|
|
146
168
|
else if (own + shared > BUDGET.step_bytes) add('large-file', `${d}/`, null, `${kb(own + shared)} with the shared files: over the ${kb(BUDGET.step_bytes)} budget of a step`, 'Compress images and fonts; drop unused files from _shared/.')
|
|
147
169
|
if (steps && !steps.includes(d)) add('stray-folder', `${d}/`, null, `${d}/ is not a step of the scenario`, 'Rename it to a step id, or move shared files into _shared/.')
|
|
148
170
|
}
|
|
171
|
+
// stack steps (#61): every variant is a folder with its own page, <step>/<variant>/index.html
|
|
172
|
+
for (const [step, listed] of Object.entries(stacks || {})) {
|
|
173
|
+
const under = [...files.keys()].filter((p) => p.startsWith(`${step}/`) && p.split('/').length > 2)
|
|
174
|
+
const subs = [...new Set(under.map((p) => p.split('/')[1]))].filter((n) => !locales.includes(n) && n !== 'dark' && !n.startsWith('_'))
|
|
175
|
+
for (const id of listed || subs) {
|
|
176
|
+
if (has(`${step}/${id}/index.html`) || locales.some((l) => has(`${step}/${id}/${l}/index.html`))) continue
|
|
177
|
+
// a discovered folder without any page holds assets, not a variant
|
|
178
|
+
if (!listed && !under.some((p) => p.startsWith(`${step}/${id}/`) && extOf(p) === 'html')) continue
|
|
179
|
+
add('variant-page', `${step}/${id}/`, null, `variant ${id} of the stack step ${step} has no ${step}/${id}/index.html`, `Add ${step}/${id}/index.html${listed ? `, or drop ${id} from items.urls` : ''}.`)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
149
182
|
if (steps) for (const s of steps) if (!has(`${s}/index.html`) && ![...files.keys()].some((p) => p.startsWith(`${s}/`) && p.endsWith('/index.html'))) add('missing-step', `${s}/`, null, `step ${s} has no ${s}/index.html`, `Add ${s}/index.html.`)
|
|
150
183
|
const order = { error: 0, warn: 1, info: 2 }
|
|
151
184
|
return out.sort((a, b) => order[a.level] - order[b.level] || a.file.localeCompare(b.file) || (a.line || 0) - (b.line || 0))
|
|
@@ -170,11 +203,12 @@ export async function readDesignFolder (dir) {
|
|
|
170
203
|
return files
|
|
171
204
|
}
|
|
172
205
|
|
|
173
|
-
/** Lints a design folder; `scenario` (optional) names the steps. */
|
|
174
|
-
export async function lintDesign ({ dir, scenario = null }) {
|
|
206
|
+
/** Lints a design folder; `scenario` (optional) names the steps, `origins` ({ wanted, allowed, unchecked }) see lintFiles. */
|
|
207
|
+
export async function lintDesign ({ dir, scenario = null, origins = null }) {
|
|
175
208
|
const files = await readDesignFolder(dir)
|
|
176
209
|
const steps = scenario ? scenario.nodes.filter((n) => n.kind !== 'decision' && n.kind !== 'external').map((n) => n.id) : null
|
|
177
|
-
const
|
|
210
|
+
const stacks = scenario ? Object.fromEntries(scenario.nodes.filter((n) => n.kind === 'stack').map((n) => [n.id, listedItems(n)])) : {}
|
|
211
|
+
const findings = lintFiles(files, { steps, stacks, locales: scenario?.locales || [], origins })
|
|
178
212
|
return { dir, files: files.size, findings, errors: findings.filter((f) => f.level === 'error').length, warnings: findings.filter((f) => f.level === 'warn').length }
|
|
179
213
|
}
|
|
180
214
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Font and asset origins on live design steps (#46, item 3). An organization admin lists the origins its designs
|
|
2
|
+
// load fonts, stylesheets and images from (Google Fonts, Adobe Fonts, a company CDN); the live origin's CSP adds them
|
|
3
|
+
// to style-src, font-src and img-src, never to script-src, connect-src or anything else. Shared by the service
|
|
4
|
+
// (worker/src/live.js, worker/src/design-origins.js) and the CLI (`kaloko design lint`), so both read an entry alike.
|
|
5
|
+
//
|
|
6
|
+
// cleanAssetOrigin(s, { reserved }) → 'https://fonts.gstatic.com' or 'https://*.cdn.example.com'; throws on anything else
|
|
7
|
+
// cleanAssetOrigins(list, opts) → the cleaned, de-duplicated list (at most MAX_ASSET_ORIGINS); throws on a bad entry
|
|
8
|
+
// originAllowed(list, url) → is the URL's origin covered by an entry (wildcards cover subdomains, not the apex)
|
|
9
|
+
// designCsp(appOrigin, origins) → the Content-Security-Policy of a live design step
|
|
10
|
+
|
|
11
|
+
export const MAX_ASSET_ORIGINS = 20
|
|
12
|
+
/** Hosts of Kaloko itself: never a font or asset origin of a design (the app, the files host, the live origin). */
|
|
13
|
+
export const RESERVED_HOSTS = ['kaloko.app', 'kaloko-usercontent.com']
|
|
14
|
+
|
|
15
|
+
const LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?'
|
|
16
|
+
const DOMAIN = new RegExp(`^(?=.{3,253}$)(?:${LABEL}\\.)+(?:[a-z][a-z0-9-]{0,61}[a-z0-9]|xn--[a-z0-9-]{1,59})$`)
|
|
17
|
+
const under = (host, base) => host === base || host.endsWith(`.${base}`)
|
|
18
|
+
|
|
19
|
+
/** One origin as typed (" HTTPS://Fonts.GoogleAPIs.com/ ") in its stored form; throws an Error that names the entry. */
|
|
20
|
+
export function cleanAssetOrigin (input, { reserved = [] } = {}) {
|
|
21
|
+
const raw = String(input ?? '').trim()
|
|
22
|
+
const shown = raw.slice(0, 80)
|
|
23
|
+
const s = raw.toLowerCase().replace(/\/$/, '')
|
|
24
|
+
const m = s.match(/^https:\/\/(\*\.)?([^/:?#@\s*]+)(?::(\d{1,5}))?$/)
|
|
25
|
+
if (!m) {
|
|
26
|
+
if (/^http:\/\//.test(s)) throw new Error(`Only https origins can be added: ${shown}`)
|
|
27
|
+
if (s.includes('*')) throw new Error(`A wildcard is allowed only as the first label (https://*.example.com): ${shown}`)
|
|
28
|
+
throw new Error(`Not an origin (https://host, without a path): ${shown}`)
|
|
29
|
+
}
|
|
30
|
+
const [, wild, host, port] = m
|
|
31
|
+
if (!DOMAIN.test(host)) throw new Error(`Not an origin (https://host, without a path): ${shown}`)
|
|
32
|
+
// a wildcard needs a registrable base at least: *.example.com, never *.com
|
|
33
|
+
if (wild && host.split('.').length < 2) throw new Error(`A wildcard needs a domain under it (https://*.example.com): ${shown}`)
|
|
34
|
+
if (port !== undefined && (Number(port) < 1 || Number(port) > 65535)) throw new Error(`Not an origin (https://host, without a path): ${shown}`)
|
|
35
|
+
for (const r of [...RESERVED_HOSTS, ...reserved].map((x) => String(x || '').toLowerCase().replace(/:\d+$/, '')).filter(Boolean)) {
|
|
36
|
+
if (under(host, r) || (wild && under(r, host))) throw new Error(`Kaloko's own hosts cannot be added: ${shown}`)
|
|
37
|
+
}
|
|
38
|
+
return `https://${wild || ''}${host}${port && port !== '443' ? `:${port}` : ''}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A list (array, or text with one origin per line, commas or spaces) cleaned; throws on the first bad entry. */
|
|
42
|
+
export function cleanAssetOrigins (list, opts = {}) {
|
|
43
|
+
const items = (Array.isArray(list) ? list : String(list ?? '').split(/[\s,;]+/)).filter((x) => String(x ?? '').trim())
|
|
44
|
+
const out = []
|
|
45
|
+
for (const x of items) { const c = cleanAssetOrigin(x, opts); if (!out.includes(c)) out.push(c) }
|
|
46
|
+
if (out.length > MAX_ASSET_ORIGINS) throw new Error(`At most ${MAX_ASSET_ORIGINS} font and asset origins.`)
|
|
47
|
+
return out
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Does an entry of `list` cover the origin of `url` (a URL or an origin)? Wildcards cover subdomains at any depth. */
|
|
51
|
+
export function originAllowed (list, url) {
|
|
52
|
+
let u
|
|
53
|
+
try { u = new URL(String(url).startsWith('//') ? `https:${url}` : String(url)) } catch { return false }
|
|
54
|
+
if (u.protocol !== 'https:') return false
|
|
55
|
+
return (list || []).some((entry) => {
|
|
56
|
+
const m = String(entry).match(/^https:\/\/(\*\.)?([^/:]+)(?::(\d+))?$/)
|
|
57
|
+
if (!m || (m[3] || '') !== u.port) return false
|
|
58
|
+
return m[1] ? u.hostname.endsWith(`.${m[2]}`) : u.hostname === m[2]
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The CSP of a live design step. `origins` must already be cleaned (cleanAssetOrigins) and checked against the hosts
|
|
64
|
+
* of the instance: they are added to style-src, font-src and img-src only. Scripts, connections, frames, workers and
|
|
65
|
+
* forms stay on the step's own origin whatever the list says.
|
|
66
|
+
*/
|
|
67
|
+
export function designCsp (appOrigin, origins = []) {
|
|
68
|
+
const extra = origins.length ? ` ${origins.join(' ')}` : ''
|
|
69
|
+
return [
|
|
70
|
+
"default-src 'self'",
|
|
71
|
+
"script-src 'self' 'unsafe-inline'",
|
|
72
|
+
`style-src 'self' 'unsafe-inline'${extra}`,
|
|
73
|
+
`img-src 'self' data: blob:${extra}`,
|
|
74
|
+
`font-src 'self' data:${extra}`,
|
|
75
|
+
"media-src 'self' blob:",
|
|
76
|
+
"connect-src 'self'",
|
|
77
|
+
"frame-src 'none'",
|
|
78
|
+
"worker-src 'none'",
|
|
79
|
+
"object-src 'none'",
|
|
80
|
+
"base-uri 'self'",
|
|
81
|
+
"form-action 'none'",
|
|
82
|
+
`frame-ancestors ${appOrigin}`,
|
|
83
|
+
// opaque origin even when opened directly: no cookies, storage or service workers
|
|
84
|
+
'sandbox allow-scripts allow-forms'
|
|
85
|
+
].join('; ')
|
|
86
|
+
}
|
package/src/lib/design.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { createServer } from 'node:http'
|
|
|
4
4
|
import { readFile, readdir, stat, mkdir, writeFile, copyFile, rm } from 'node:fs/promises'
|
|
5
5
|
import { join, resolve, dirname, relative, sep } from 'node:path'
|
|
6
6
|
import { UserError } from './util.mjs'
|
|
7
|
-
import { bundleProblems, closure, contentTypeOf, revisionId, sha256Hex, TOKENS_FILE, SHARED_DIR, extOf } from './design-core.mjs'
|
|
7
|
+
import { bundleProblems, closure, contentTypeOf, revisionId, sha256Hex, TOKENS_FILE, SHARED_DIR, extOf, ITEM_ID, MAX_ITEMS, itemProblems } from './design-core.mjs'
|
|
8
8
|
|
|
9
9
|
export const isDesign = (env) => env?.kind === 'design'
|
|
10
10
|
|
|
@@ -31,12 +31,25 @@ async function listFiles (dir, base = dir) {
|
|
|
31
31
|
return out
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The page of a step state (#46): `<step>/<locale>/<state>.html` or `<step>/<state>.html` next to the entry, or null
|
|
36
|
+
* (the entry then renders with `?state=<name>`). `files` are the revision's paths (or a list of them).
|
|
37
|
+
*/
|
|
38
|
+
export function statePage (entry, state, files) {
|
|
39
|
+
if (!state || state === 'default') return entry
|
|
40
|
+
const has = (p) => (Array.isArray(files) ? files.includes(p) : !!files?.[p])
|
|
41
|
+
const dir = entry.slice(0, entry.lastIndexOf('/') + 1)
|
|
42
|
+
const node = entry.split('/')[0]
|
|
43
|
+
for (const p of [`${dir}${state}.html`, `${node}/${state}.html`]) if (has(p)) return p
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
|
|
34
47
|
/**
|
|
35
48
|
* The files of one step in one locale: `<step>/<locale>/…` overrides `<step>/…` (other locales' folders
|
|
36
49
|
* are left out), plus every bundle file those reference. Returns { entry, files: [{ path, bytes, hash }] }
|
|
37
|
-
* or null when the step has no folder.
|
|
50
|
+
* or null when the step has no folder. `states` (#46): the pages of the step's states travel with it.
|
|
38
51
|
*/
|
|
39
|
-
export async function scanStep ({ dir, node, locale, locales }) {
|
|
52
|
+
export async function scanStep ({ dir, node, locale, locales, states = [] }) {
|
|
40
53
|
const stepDir = join(dir, node)
|
|
41
54
|
if (!await exists(stepDir)) return null
|
|
42
55
|
const own = await listFiles(stepDir)
|
|
@@ -51,7 +64,9 @@ export async function scanStep ({ dir, node, locale, locales }) {
|
|
|
51
64
|
const beside = (entryDir === `${node}/` ? common : mine.map((p) => p.slice(locale.length + 1))).filter((p) => !p.includes('/') && extOf(p) !== 'html')
|
|
52
65
|
// a page of its own for the dark colour scheme (<step>/dark/index.html, or per locale): part of the revision
|
|
53
66
|
const dark = own.includes(`${entryDir.slice(node.length + 1)}dark/index.html`) ? [`${entryDir}dark/index.html`] : []
|
|
54
|
-
|
|
67
|
+
// pages of the step's states (<step>/<state>.html, or per locale): part of the revision like the dark page
|
|
68
|
+
const stateRoots = (states || []).filter((s) => s !== 'default').map((s) => statePage(entry, s, own.map((p) => `${node}/${p}`))).filter(Boolean)
|
|
69
|
+
const roots = [entry, ...dark, ...stateRoots, ...beside.map((p) => entryDir + p)]
|
|
55
70
|
const paths = await closure({ roots, read: (p) => readFile(join(dir, p), 'utf8'), exists: (p) => isFile(join(dir, p)) })
|
|
56
71
|
const files = []
|
|
57
72
|
for (const path of paths) {
|
|
@@ -63,6 +78,94 @@ export async function scanStep ({ dir, node, locale, locales }) {
|
|
|
63
78
|
return { entry, files }
|
|
64
79
|
}
|
|
65
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Items of a stack step as `items.urls` names them, when they name folders of the step (`refund`, `./refund/`,
|
|
83
|
+
* `refund/index.html`): a list of folder names. Null when the step lists app URLs (`/credit?reason=x`), uses another
|
|
84
|
+
* source (sitemap, file, crawl, script) or lists nothing: then the item folders are discovered.
|
|
85
|
+
*/
|
|
86
|
+
export function listedItems (node) {
|
|
87
|
+
const urls = node?.items?.urls
|
|
88
|
+
if (!Array.isArray(urls) || !urls.length) return null
|
|
89
|
+
const ids = urls.map((u) => String(u).trim().replace(/^\.\//, '').replace(/\/index\.html$/, '').replace(/\/+$/, ''))
|
|
90
|
+
return ids.every((id) => ITEM_ID.test(id) && id !== '.' && id !== '..') ? [...new Set(ids)] : null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Folder names inside a step folder that are not items: locale overrides, the dark page and _private folders. */
|
|
94
|
+
const notItem = (name, locales) => locales.includes(name) || name === 'dark' || name.startsWith('_') || name.startsWith('.')
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The item folders of a stack step (#61): `<step>/<item>/index.html` (or `<step>/<item>/<locale>/index.html`), in the
|
|
98
|
+
* order `items.urls` lists them, else every subfolder with a page, sorted by name. Returns [{ id, listed }]; a listed
|
|
99
|
+
* folder without a page is still returned, so the walk and the lint can name it.
|
|
100
|
+
*/
|
|
101
|
+
export async function stackItems ({ dir, node, locales }) {
|
|
102
|
+
const stepDir = join(dir, node.id)
|
|
103
|
+
const listed = listedItems(node)
|
|
104
|
+
if (listed) return listed.map((id) => ({ id, listed: true }))
|
|
105
|
+
let entries = []
|
|
106
|
+
try { entries = await readdir(stepDir, { withFileTypes: true }) } catch { return [] }
|
|
107
|
+
const out = []
|
|
108
|
+
for (const e of entries.filter((x) => x.isDirectory() && !notItem(x.name, locales) && ITEM_ID.test(x.name)).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
109
|
+
const has = await isFile(join(stepDir, e.name, 'index.html')) || (await Promise.all(locales.map((l) => isFile(join(stepDir, e.name, l, 'index.html'))))).some(Boolean)
|
|
110
|
+
if (has) out.push({ id: e.name, listed: false })
|
|
111
|
+
}
|
|
112
|
+
return out
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Entry of a folder (`<base>/<locale>/index.html` over `<base>/index.html`) and the roots of its revision. */
|
|
116
|
+
async function folderRoots ({ dir, base, locale, locales, states = [] }) {
|
|
117
|
+
const own = await listFiles(join(dir, base))
|
|
118
|
+
const localeDirs = new Set(locales)
|
|
119
|
+
const common = own.filter((p) => !localeDirs.has(p.split('/')[0]))
|
|
120
|
+
const mine = own.filter((p) => p.split('/')[0] === locale)
|
|
121
|
+
const entry = mine.includes(`${locale}/index.html`) ? `${base}/${locale}/index.html` : common.includes('index.html') ? `${base}/index.html` : null
|
|
122
|
+
if (!entry) return null
|
|
123
|
+
const entryDir = entry.slice(0, entry.lastIndexOf('/') + 1)
|
|
124
|
+
const beside = (entryDir === `${base}/` ? common : mine.map((p) => p.slice(locale.length + 1))).filter((p) => !p.includes('/') && extOf(p) !== 'html')
|
|
125
|
+
const dark = own.includes(`${entryDir.slice(base.length + 1)}dark/index.html`) ? [`${entryDir}dark/index.html`] : []
|
|
126
|
+
// step states (#46) of a variant: <step>/<variant>/<state>.html (or per locale) next to its page
|
|
127
|
+
const ownPaths = own.map((p) => `${base}/${p}`)
|
|
128
|
+
const stateRoots = (states || []).filter((s) => s !== 'default').map((s) => [`${entryDir}${s}.html`, `${base}/${s}.html`].find((p) => ownPaths.includes(p))).filter(Boolean)
|
|
129
|
+
return { entry, roots: [entry, ...dark, ...stateRoots, ...beside.map((p) => entryDir + p)] }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The files of a stack step in one locale (#61): every item's page with what it references, and the optional overview
|
|
134
|
+
* (`<step>/index.html`). The revision covers the whole step folder; its entry is the overview, else the first item.
|
|
135
|
+
* Returns { entry, items: [{ id, entry }], files } or null when the step has no folder.
|
|
136
|
+
*/
|
|
137
|
+
export async function scanStack ({ dir, node, locale, locales }) {
|
|
138
|
+
if (!await exists(join(dir, node.id))) return null
|
|
139
|
+
const found = await stackItems({ dir, node, locales })
|
|
140
|
+
if (!found.length) throw new UserError(`Design step ${node.id} is a stack: add a folder per variant with a page, ${node.id}/<variant>/index.html.`)
|
|
141
|
+
if (found.length > MAX_ITEMS) throw new UserError(`Design step ${node.id} has ${found.length} variants; a stack step holds at most ${MAX_ITEMS}.`)
|
|
142
|
+
const items = []
|
|
143
|
+
const roots = []
|
|
144
|
+
for (const it of found) {
|
|
145
|
+
const r = await folderRoots({ dir, base: `${node.id}/${it.id}`, locale, locales, states: node.states })
|
|
146
|
+
if (!r) throw new UserError(`Design step ${node.id}: variant ${it.id} has no page; add ${node.id}/${it.id}/index.html.`)
|
|
147
|
+
items.push({ id: it.id, entry: r.entry })
|
|
148
|
+
roots.push(...r.roots)
|
|
149
|
+
}
|
|
150
|
+
// the overview: only the step folder's own page and the files beside it, never the item folders (they come above)
|
|
151
|
+
const own = await listFiles(join(dir, node.id))
|
|
152
|
+
const overviewEntry = own.includes(`${locale}/index.html`) ? `${node.id}/${locale}/index.html` : own.includes('index.html') ? `${node.id}/index.html` : null
|
|
153
|
+
if (overviewEntry) {
|
|
154
|
+
const at = overviewEntry.slice(0, overviewEntry.lastIndexOf('/') + 1)
|
|
155
|
+
roots.unshift(overviewEntry, ...own.map((p) => `${node.id}/${p}`).filter((p) => p.startsWith(at) && !p.slice(at.length).includes('/') && extOf(p) !== 'html'))
|
|
156
|
+
}
|
|
157
|
+
const paths = await closure({ roots, read: (p) => readFile(join(dir, p), 'utf8'), exists: (p) => isFile(join(dir, p)) })
|
|
158
|
+
const files = []
|
|
159
|
+
for (const path of paths) {
|
|
160
|
+
const buf = await readFile(join(dir, path))
|
|
161
|
+
files.push({ path, bytes: buf.length, hash: await sha256Hex(buf) })
|
|
162
|
+
}
|
|
163
|
+
const entry = overviewEntry || items[0].entry
|
|
164
|
+
const problems = [...bundleProblems(files, entry), ...itemProblems(items, Object.fromEntries(files.map((f) => [f.path, f.hash])), node.id)]
|
|
165
|
+
if (problems.length) throw new UserError(`Design step ${node.id} (${locale}) breaks the bundle rules:\n - ${problems.join('\n - ')}`)
|
|
166
|
+
return { entry, items, files }
|
|
167
|
+
}
|
|
168
|
+
|
|
66
169
|
/** Token set reference of the design folder (tokens.json), or null. */
|
|
67
170
|
export async function tokensRef (dir, project) {
|
|
68
171
|
const file = join(dir, TOKENS_FILE)
|
|
@@ -73,8 +176,9 @@ export async function tokensRef (dir, project) {
|
|
|
73
176
|
}
|
|
74
177
|
|
|
75
178
|
/** A revision of one step and locale, with its id. */
|
|
76
|
-
export async function revisionOf ({ dir, scenario, node, locale, locales, tokens }) {
|
|
77
|
-
|
|
179
|
+
export async function revisionOf ({ dir, scenario, node, locale, locales, tokens, states = [], stack = null }) {
|
|
180
|
+
// `stack`: the scenario node of a stack step (#61); its revision covers every item folder; `states` (#46): their pages
|
|
181
|
+
const scan = stack ? await scanStack({ dir, node: stack, locale, locales }) : await scanStep({ dir, node, locale, locales, states })
|
|
78
182
|
if (!scan) return null
|
|
79
183
|
// the token set travels with every revision: the canvas inspector names values from it on the live origin
|
|
80
184
|
if (tokens && !scan.files.some((f) => f.path === TOKENS_FILE)) {
|
|
@@ -82,8 +186,8 @@ export async function revisionOf ({ dir, scenario, node, locale, locales, tokens
|
|
|
82
186
|
scan.files.push({ path: TOKENS_FILE, bytes: buf.length, hash: await sha256Hex(buf) })
|
|
83
187
|
}
|
|
84
188
|
const files = Object.fromEntries(scan.files.map((f) => [f.path, f.hash]))
|
|
85
|
-
const id = await revisionId({ scenario, node, locale, entry: scan.entry, files, tokens })
|
|
86
|
-
return { id, scenario, node, locale, state: 'default', kind: 'design', entry: scan.entry, files, sizes: Object.fromEntries(scan.files.map((f) => [f.path, f.bytes])), tokens }
|
|
189
|
+
const id = await revisionId({ scenario, node, locale, entry: scan.entry, files, tokens, items: scan.items || null })
|
|
190
|
+
return { id, scenario, node, locale, state: 'default', kind: 'design', entry: scan.entry, ...(scan.items ? { items: scan.items } : {}), files, sizes: Object.fromEntries(scan.files.map((f) => [f.path, f.bytes])), tokens }
|
|
87
191
|
}
|
|
88
192
|
|
|
89
193
|
/**
|
package/src/lib/dimensions.mjs
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { UserError } from './util.mjs'
|
|
10
10
|
import { devicePreset, emulationOf, presetSummary, presetNames } from './devices.mjs'
|
|
11
11
|
import { launchWithRetry } from './browser.mjs'
|
|
12
|
+
import { STATE_PREFIX, stateOfKey, statesOf } from './manifest-core.mjs'
|
|
12
13
|
|
|
13
14
|
export const BROWSERS = ['chromium', 'webkit', 'firefox']
|
|
14
15
|
export const COLOR_SCHEMES = ['light', 'dark']
|
|
@@ -22,8 +23,15 @@ export const DIMENSIONS = [
|
|
|
22
23
|
{ name: 'color_scheme', list: 'color_schemes', values: COLOR_SCHEMES, default: 'light', suffix: (v) => v },
|
|
23
24
|
{ name: 'reduced_motion', list: 'reduced_motion', values: REDUCED_MOTION, default: 'no-preference', suffix: () => 'reduce' },
|
|
24
25
|
{ name: 'forced_colors', list: 'forced_colors', values: FORCED_COLORS, default: 'none', suffix: () => 'forced' },
|
|
25
|
-
{ name: 'display_mode', list: 'display_modes', values: DISPLAY_MODES, default: 'browser', suffix: (v) => v }
|
|
26
|
+
{ name: 'display_mode', list: 'display_modes', values: DISPLAY_MODES, default: 'browser', suffix: (v) => v },
|
|
27
|
+
// token modes of a design's token set (#46): brand-a, high-contrast…; the default mode (the environment's
|
|
28
|
+
// tokens_mode, else the first) is the first of the list and is left out of keys
|
|
29
|
+
{ name: 'token_mode', list: 'token_modes', values: null, default: null, suffix: (v) => `mode-${v}` },
|
|
30
|
+
// step states (#46): default, empty, error, loading or any name a step lists in `states:`; always the last part
|
|
31
|
+
{ name: 'step_state', list: 'states', values: null, default: 'default', suffix: (v) => `${STATE_PREFIX}${v}` }
|
|
26
32
|
]
|
|
33
|
+
/** Names of token modes and states: they are parts of capture keys and file names. */
|
|
34
|
+
export const FREE_VALUE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,39}$/
|
|
27
35
|
const ALIASES = { chrome: 'chromium', safari: 'webkit', gecko: 'firefox', 'no-motion': 'reduce', reduced: 'reduce', 'no_preference': 'no-preference', high_contrast: 'active', 'high-contrast': 'active', forced: 'active', pwa: 'standalone', installed: 'standalone' }
|
|
28
36
|
|
|
29
37
|
const asList = (v) => v == null ? null : Array.isArray(v) ? v.map(String) : String(v).split(',').map((s) => s.trim()).filter(Boolean)
|
|
@@ -35,6 +43,11 @@ export function dimensionValues (dim, raw) {
|
|
|
35
43
|
if (!list?.length) return null
|
|
36
44
|
const out = []
|
|
37
45
|
for (const v0 of list) {
|
|
46
|
+
if (!d.values) {
|
|
47
|
+
if (!FREE_VALUE.test(v0)) throw new UserError(`${d.list.replace(/_/g, ' ')}: "${v0}" is not a valid name (letters, digits, - and _; at most 40).`)
|
|
48
|
+
if (!out.includes(v0)) out.push(v0)
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
38
51
|
const v = ALIASES[v0.toLowerCase()] || v0.toLowerCase()
|
|
39
52
|
if (!d.values.includes(v)) throw new UserError(`Unknown ${d.list.replace(/_/g, ' ')} value "${v0}". Use: ${d.values.join(', ')}.`)
|
|
40
53
|
if (!out.includes(v)) out.push(v)
|
|
@@ -52,12 +65,28 @@ export function dimensionsFrom ({ args = {}, scenario = {}, config = {} } = {})
|
|
|
52
65
|
const out = {}
|
|
53
66
|
for (const d of DIMENSIONS) {
|
|
54
67
|
const flag = d.list.replace(/_/g, '-')
|
|
68
|
+
if (d.name === 'step_state') { out.states = scenarioStates(scenario, args.states); continue }
|
|
69
|
+
// token modes come from the design's token set (startScope); here only an explicit choice
|
|
55
70
|
const raw = args[flag] ?? args[d.list] ?? scenario?.[d.list] ?? walk[d.list]
|
|
56
|
-
out[d.list] = dimensionValues(d.name, raw)
|
|
71
|
+
out[d.list] = raw === false || raw === 'false' || raw === 'none' ? (d.name === 'token_mode' ? false : null) : dimensionValues(d.name, raw)
|
|
57
72
|
}
|
|
58
73
|
return out
|
|
59
74
|
}
|
|
60
75
|
|
|
76
|
+
/**
|
|
77
|
+
* The states of a run (#46): `default` and every state a step lists in `states:`, in scenario order; null when no
|
|
78
|
+
* step has states. `pick` (kaloko start --states) narrows them; default always stays.
|
|
79
|
+
*/
|
|
80
|
+
export function scenarioStates (scenario, pick = null) {
|
|
81
|
+
const all = []
|
|
82
|
+
for (const n of scenario?.nodes || []) for (const s of statesOf(n)) if (!all.includes(s)) all.push(s)
|
|
83
|
+
if (all.length < 2) return null
|
|
84
|
+
const want = pick ? dimensionValues('step_state', pick) : null
|
|
85
|
+
if (want) for (const s of want) if (!all.includes(s)) throw new UserError(`No step of this scenario has the state "${s}" (states: ${all.slice(1).join(', ')}).`)
|
|
86
|
+
const out = want ? all.filter((s) => s === 'default' || want.includes(s)) : all
|
|
87
|
+
return out.length > 1 ? out : null
|
|
88
|
+
}
|
|
89
|
+
|
|
61
90
|
/**
|
|
62
91
|
* Viewports of a scenario: { name: [w, h] } as before, or names of device presets — as an array
|
|
63
92
|
* (`[iphone-15, ipad-landscape, desktop]`) or as values (`phone: iphone-15`, `tablet: { device: ipad-portrait }`,
|
|
@@ -100,12 +129,14 @@ export function appDevices (ids, platform) {
|
|
|
100
129
|
return { viewports, devices }
|
|
101
130
|
}
|
|
102
131
|
|
|
103
|
-
/** The capture key of a viewport and dimension values: defaults are left out. */
|
|
104
|
-
export function variantKey (viewport, dims = {}) {
|
|
132
|
+
/** The capture key of a viewport and dimension values: defaults are left out (`defaults`: the run's own, token mode). */
|
|
133
|
+
export function variantKey (viewport, dims = {}, defaults = {}) {
|
|
105
134
|
const parts = [viewport]
|
|
106
|
-
for (const d of DIMENSIONS) { const v = dims[d.name]; if (v && v !== d.default) parts.push(d.suffix(v)) }
|
|
135
|
+
for (const d of DIMENSIONS) { const v = dims[d.name]; if (v && v !== (defaults[d.name] ?? d.default)) parts.push(d.suffix(v)) }
|
|
107
136
|
return parts.join('.')
|
|
108
137
|
}
|
|
138
|
+
/** Defaults a scope sets itself: the token mode left out of keys is the first of its modes. */
|
|
139
|
+
export const scopeDefaults = (scope) => (scope?.dimensions?.token_modes?.length ? { token_mode: scope.dimensions.token_modes[0] } : {})
|
|
109
140
|
|
|
110
141
|
/**
|
|
111
142
|
* Scope of a run: every combination of viewport and dimension values as a capture key with its size, and the
|
|
@@ -119,10 +150,11 @@ export function expandScope ({ locales, viewports, devices = {}, dims = {} }) {
|
|
|
119
150
|
const out = {}; const variants = {}
|
|
120
151
|
let combos = [{}]
|
|
121
152
|
for (const [d, l] of used) combos = combos.flatMap((c) => l.map((v) => ({ ...c, [d.name]: v })))
|
|
153
|
+
const defaults = dims.token_modes?.length ? { token_mode: dims.token_modes[0] } : {}
|
|
122
154
|
for (const [name, size] of Object.entries(viewports)) {
|
|
123
155
|
const device = devices[name] || null
|
|
124
156
|
for (const c of combos) {
|
|
125
|
-
const key = variantKey(name, c)
|
|
157
|
+
const key = variantKey(name, c, defaults)
|
|
126
158
|
let s = size
|
|
127
159
|
// installed PWA: the whole screen of a preset without the browser's bars (the status bar stays)
|
|
128
160
|
if (c.display_mode === 'standalone' && device?.screen) s = [size[0], Math.max(size[1], device.screen[1] - (device.safe?.top || 0))]
|
|
@@ -130,7 +162,7 @@ export function expandScope ({ locales, viewports, devices = {}, dims = {} }) {
|
|
|
130
162
|
variants[key] = { viewport: name, browser: c.browser || 'chromium', ...c, ...(device ? { device: name } : {}) }
|
|
131
163
|
}
|
|
132
164
|
}
|
|
133
|
-
if (Object.keys(out).length > 400) throw new UserError(`This scope has ${Object.keys(out).length} combinations of viewport, browser, colour scheme and
|
|
165
|
+
if (Object.keys(out).length > 400) throw new UserError(`This scope has ${Object.keys(out).length} combinations of viewport, browser, colour scheme, motion, token mode and state; keep it under 400 (pick fewer viewports or values).`)
|
|
134
166
|
const dimensions = Object.fromEntries(used.map(([d, l]) => [d.list, l]))
|
|
135
167
|
return { ...loc, viewports: out, variants, ...(used.length ? { dimensions } : {}), ...(Object.keys(devices).length ? { devices } : {}) }
|
|
136
168
|
}
|
|
@@ -147,10 +179,27 @@ export function variantOf (scope, key) {
|
|
|
147
179
|
else if (part === 'reduce') out.reduced_motion = 'reduce'
|
|
148
180
|
else if (part === 'forced') out.forced_colors = 'active'
|
|
149
181
|
else if (part === 'standalone') out.display_mode = 'standalone'
|
|
182
|
+
else if (part.startsWith('mode-')) out.token_mode = part.slice(5)
|
|
183
|
+
else if (part.startsWith(STATE_PREFIX)) out.step_state = part.slice(STATE_PREFIX.length)
|
|
150
184
|
}
|
|
151
185
|
return out
|
|
152
186
|
}
|
|
153
187
|
|
|
188
|
+
/** The step state of a capture key ('default' when the key has none). */
|
|
189
|
+
export const stateOf = (key) => stateOfKey(key)
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Keys of `state` for keys of the default state (a walk pass, a device): `mobile` → `mobile.state-empty`, only those
|
|
193
|
+
* the scope has. The default state gives the keys back.
|
|
194
|
+
*/
|
|
195
|
+
export function stateKeys (scope, keys, state = 'default') {
|
|
196
|
+
if (!state || state === 'default') return keys.filter((k) => stateOfKey(k) === 'default')
|
|
197
|
+
return keys.filter((k) => stateOfKey(k) === 'default').map((k) => `${k}.${STATE_PREFIX}${state}`).filter((k) => scope?.viewports?.[k])
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The states a walk captures a step in: its `states:` that the run has, default first. */
|
|
201
|
+
export const nodeStates = (scope, node) => statesOf(node).filter((s) => s === 'default' || scope?.dimensions?.states?.includes(s))
|
|
202
|
+
|
|
154
203
|
/** The fields a capture record and a manifest capture carry for its dimensions (only those that were set). */
|
|
155
204
|
export function variantFields (scope, key) {
|
|
156
205
|
const v = scope?.variants?.[key]
|
|
@@ -167,7 +216,8 @@ export function variantFields (scope, key) {
|
|
|
167
216
|
* `only` limits the engines (walk --browsers).
|
|
168
217
|
*/
|
|
169
218
|
export function passesOf (scope, { only = null } = {}) {
|
|
170
|
-
|
|
219
|
+
// keys of other step states are captured by the same passes, one step script invocation per state
|
|
220
|
+
const keys = Object.keys(scope?.viewports || {}).filter((k) => stateOfKey(k) === 'default')
|
|
171
221
|
const passes = new Map()
|
|
172
222
|
for (const key of keys) {
|
|
173
223
|
const v = variantOf(scope, key)
|
|
@@ -300,15 +350,19 @@ export function referenceKey (key, available, scope = null) {
|
|
|
300
350
|
const have = new Set(available || [])
|
|
301
351
|
if (have.has(key)) return key
|
|
302
352
|
const v = variantOf(scope, key)
|
|
303
|
-
const
|
|
353
|
+
const defaults = scopeDefaults(scope)
|
|
354
|
+
const chromium = variantKey(v.viewport, { ...v, browser: 'chromium' }, defaults)
|
|
304
355
|
if (have.has(chromium)) return chromium
|
|
305
356
|
// a reference without a colour scheme dimension (light by default) answers for light captures
|
|
306
|
-
if (!v.color_scheme || v.color_scheme === 'light') { const plain = variantKey(v.viewport, { ...v, browser: 'chromium', color_scheme: null }); if (have.has(plain)) return plain }
|
|
357
|
+
if (!v.color_scheme || v.color_scheme === 'light') { const plain = variantKey(v.viewport, { ...v, browser: 'chromium', color_scheme: null }, defaults); if (have.has(plain)) return plain }
|
|
307
358
|
return null
|
|
308
359
|
}
|
|
309
360
|
|
|
310
|
-
/**
|
|
311
|
-
|
|
361
|
+
/**
|
|
362
|
+
* Token mode of a capture: the token mode it was rendered in (#46), else its colour scheme when the token set has
|
|
363
|
+
* that mode, else the environment's mode.
|
|
364
|
+
*/
|
|
365
|
+
export const tokenModeOf = (variant, modes = [], fallback = null) => (variant?.token_mode && modes.includes(variant.token_mode) ? variant.token_mode : variant?.color_scheme && modes.includes(variant.color_scheme) ? variant.color_scheme : fallback)
|
|
312
366
|
|
|
313
367
|
/** Engines a scope needs (for `kaloko browser install` hints and doctor). */
|
|
314
368
|
export const enginesOf = (scope) => [...new Set(Object.keys(scope?.viewports || {}).map((k) => variantOf(scope, k).browser))]
|
package/src/lib/drift.mjs
CHANGED
|
@@ -85,7 +85,7 @@ export async function driftForCapture ({ node, entry, capture, runDir, ctx }) {
|
|
|
85
85
|
const refCaps = !ctx.isDesign ? (await referenceStep(ctx, node.id, entry.locale))?.captures : null
|
|
86
86
|
const refStyles = refCaps ? refCaps[referenceKey(entry.viewport, Object.keys(refCaps))]?.design?.styles : null
|
|
87
87
|
// a dark capture is checked against the dark mode of the token set (DTCG modes light/dark)
|
|
88
|
-
const off = tokenOffenders(capture.design?.styles, ctx.tokens, tokenModeOf({ color_scheme: capture.color_scheme }, modesOf(ctx.tokens), ctx.mode), refStyles)
|
|
88
|
+
const off = tokenOffenders(capture.design?.styles, ctx.tokens, tokenModeOf({ color_scheme: capture.color_scheme, token_mode: capture.token_mode }, modesOf(ctx.tokens), ctx.mode), refStyles)
|
|
89
89
|
if (off == null) { results[c.id] = { ok: null, evidence: 'Capture has no style summary; capture this step again.' }; continue }
|
|
90
90
|
results[c.id] = off.length
|
|
91
91
|
? { ok: false, evidence: `${off.length} value(s) outside the token set: ${off.slice(0, 5).map((o) => `${o.property} ${o.value}${o.suggest ? ` → ${o.suggest}` : ''}`).join('; ')}` }
|
package/src/lib/evaluate.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import { KEYBOARD_ASSERTS } from './keyboard.mjs'
|
|
|
8
8
|
import { needsDrift, driftForCapture, loadTokens, driftCacheDir } from './drift.mjs'
|
|
9
9
|
import { packHelp } from './pack-help.mjs'
|
|
10
10
|
import { tokenCriteria, storedValueChecks, STYLE_ASSERTS } from './assertions.mjs'
|
|
11
|
+
import { appliesToState } from './manifest-core.mjs'
|
|
11
12
|
|
|
12
13
|
/** Latest capture per node/locale/viewport. */
|
|
13
14
|
export function latestCaptures (entries) {
|
|
@@ -19,6 +20,8 @@ export function latestCaptures (entries) {
|
|
|
19
20
|
export const criteriaHash = (node) => sha256(node.criteria || [])
|
|
20
21
|
/** A criterion with `environments` (e.g. [production]) applies only there; without a known environment it applies. */
|
|
21
22
|
export const appliesOn = (c, environment) => !environment || !Array.isArray(c.environments) || !c.environments.length || c.environments.includes(environment)
|
|
23
|
+
/** Environment and step state (#46): a criterion with `states:` applies only to captures of those states. */
|
|
24
|
+
export const appliesTo = (c, environment, capture) => appliesOn(c, environment) && appliesToState(c, capture?.step_state)
|
|
22
25
|
/** Folder of a capture under steps/: the node, or steps/<node>/items/<item> for a page of a stack step. */
|
|
23
26
|
export const stepIdOf = (e) => (e.item ? `${e.node}/items/${e.item}` : e.node)
|
|
24
27
|
export const evaluationSource = (node, capture) => sha256({ capture, criteria: node.criteria || [], purpose: node.purpose || '' })
|
|
@@ -31,6 +34,7 @@ export const evaluationSource = (node, capture) => sha256({ capture, criteria: n
|
|
|
31
34
|
export function combine ({ node, capture, jev, vision = null, thresholds, environment = null }) {
|
|
32
35
|
return (node.criteria || []).map((c) => {
|
|
33
36
|
if (!appliesOn(c, environment)) return { id: c.id, type: c.check.type, verdict: 'not_evaluated', evidence: `Applies on ${c.environments.join(', ')} only; this walk ran on ${environment}.`, na: true, not_applicable: 'environment' }
|
|
37
|
+
if (!appliesToState(c, capture?.step_state)) return { id: c.id, type: c.check.type, verdict: 'not_evaluated', evidence: `Applies to the state${c.states.length > 1 ? 's' : ''} ${c.states.join(', ')} only; this capture shows ${capture?.step_state || 'default'}.`, na: true, not_applicable: 'state' }
|
|
34
38
|
if (c.check.type === 'deterministic') {
|
|
35
39
|
const r = capture.checks?.[c.id]
|
|
36
40
|
const verdict = r == null || r.ok == null ? 'not_evaluated' : r.ok ? 'pass' : 'fail'
|
|
@@ -106,7 +110,7 @@ export async function evaluateRun ({ run, scenario, config, root, fetchImpl }) {
|
|
|
106
110
|
const current = capture.criteria_hash === criteriaHash(node)
|
|
107
111
|
// console triage needs the evaluator only when the capture has console errors
|
|
108
112
|
const outOfSample = entry.item && !sampled.get(entry.node)?.has(entry.item)
|
|
109
|
-
const semantic = outOfSample ? [] : (node.criteria || []).filter((c) => c.check.type === 'semantic' &&
|
|
113
|
+
const semantic = outOfSample ? [] : (node.criteria || []).filter((c) => c.check.type === 'semantic' && appliesTo(c, environment, capture) && !(c.check.about === 'console' && !(capture.console_errors || []).length))
|
|
110
114
|
// quick signals ride along on every captured page state (also steps without semantic criteria)
|
|
111
115
|
const signals = settings.signals !== false && !outOfSample && !!capture.state
|
|
112
116
|
// an imported screen without page text (#11) has nothing to ask about: a person decides on the canvas
|
|
@@ -206,7 +210,7 @@ export function semanticPrefetch ({ scenario, config, root, environment = null }
|
|
|
206
210
|
if (!semantic.length && settings.signals === false) continue
|
|
207
211
|
queue.push(async () => {
|
|
208
212
|
const capture = await readJson(join(runDir, e.file))
|
|
209
|
-
const crit = semantic.filter((c) => !(c.check.about === 'console' && !(capture.console_errors || []).length))
|
|
213
|
+
const crit = semantic.filter((c) => appliesToState(c, capture.step_state) && !(c.check.about === 'console' && !(capture.console_errors || []).length))
|
|
210
214
|
const signals = settings.signals !== false && !!capture.state
|
|
211
215
|
if ((!crit.length && !signals) || capture.criteria_hash !== criteriaHash(node)) return
|
|
212
216
|
asked++
|