kaloko 0.9.0 → 0.11.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 +99 -33
- package/src/lib/assertions.mjs +3 -1
- package/src/lib/browser-daemon.mjs +3 -1
- package/src/lib/browser.mjs +79 -10
- 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/doctor.mjs +4 -2
- 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 +142 -35
package/src/lib/design-cli.mjs
CHANGED
|
@@ -11,10 +11,12 @@ import { loadTokens } from './drift.mjs'
|
|
|
11
11
|
import { layout } from './scenario.mjs'
|
|
12
12
|
import { launchEphemeral } from './browser.mjs'
|
|
13
13
|
import { observeConsole } from './console.mjs'
|
|
14
|
-
import { contextOptionsOf } from './dimensions.mjs'
|
|
15
|
-
import { slimCapture } from './manifest-core.mjs'
|
|
14
|
+
import { contextOptionsOf, stateOf } from './dimensions.mjs'
|
|
15
|
+
import { slimCapture, keyAppliesTo } from './manifest-core.mjs'
|
|
16
|
+
import { modeCss } from './tokens.mjs'
|
|
17
|
+
import { fieldsOf } from './stack.mjs'
|
|
16
18
|
import { captureKey, sha256Hex, extOf, TEXT_TYPES, RENDERER } from './design-core.mjs'
|
|
17
|
-
import { designDir, revisionOf, serveDesign, copyLive, fromCache, toCache, restoreCached, readBase, writeBase, headKey, tokensRef } from './design.mjs'
|
|
19
|
+
import { designDir, revisionOf, serveDesign, copyLive, fromCache, toCache, restoreCached, readBase, writeBase, headKey, tokensRef, statePage } from './design.mjs'
|
|
18
20
|
|
|
19
21
|
const enc = encodeURIComponent
|
|
20
22
|
const screensOf = (scenario) => scenario.nodes.filter((n) => n.kind !== 'decision' && n.kind !== 'external')
|
|
@@ -26,29 +28,50 @@ export async function localRevisions ({ dir, scenario, locales, allLocales, proj
|
|
|
26
28
|
for (const locale of locales) {
|
|
27
29
|
for (const node of screensOf(scenario)) {
|
|
28
30
|
if (pick && !pick.includes(node.id)) continue
|
|
29
|
-
const rev = await revisionOf({ dir, scenario: scenario.id, node: node.id, locale, locales: allLocales, tokens })
|
|
31
|
+
const rev = await revisionOf({ dir, scenario: scenario.id, node: node.id, locale, locales: allLocales, tokens, states: node.states, stack: node.kind === 'stack' ? node : null })
|
|
30
32
|
if (rev) out[headKey(node.id, locale)] = rev
|
|
31
33
|
}
|
|
32
34
|
}
|
|
33
35
|
return out
|
|
34
36
|
}
|
|
35
37
|
|
|
38
|
+
/**
|
|
39
|
+
* In the page: a token mode's custom properties after the page's own styles, and data-kaloko-mode on <html> for
|
|
40
|
+
* pages that switch themes themselves (#46). `null` removes both.
|
|
41
|
+
*/
|
|
42
|
+
function setTokenMode (arg) {
|
|
43
|
+
const old = document.getElementById('kaloko-token-mode')
|
|
44
|
+
if (old) old.remove()
|
|
45
|
+
if (!arg) { document.documentElement.removeAttribute('data-kaloko-mode'); return }
|
|
46
|
+
const style = document.createElement('style')
|
|
47
|
+
style.id = 'kaloko-token-mode'
|
|
48
|
+
style.textContent = arg.css
|
|
49
|
+
;(document.head || document.documentElement).appendChild(style)
|
|
50
|
+
document.documentElement.setAttribute('data-kaloko-mode', arg.mode)
|
|
51
|
+
}
|
|
52
|
+
|
|
36
53
|
/**
|
|
37
54
|
* Walk of a design environment: serves the folder, renders each step and locale in every viewport, and reuses
|
|
38
55
|
* captures of revisions that were already rendered (content cache). Writes revisions.json into the run.
|
|
56
|
+
* Steps with states (#46) render each state's page (statePage, else the entry with ?state=<name>); runs with token
|
|
57
|
+
* modes render each mode with its tokens as custom properties.
|
|
39
58
|
*/
|
|
40
59
|
export async function walkDesign ({ config, root, run, scenario, env, pick, locales, viewports, say, noCache = false }) {
|
|
41
60
|
const dir = designDir(env, root)
|
|
42
61
|
const revisions = await readJson(join(run.dir, 'revisions.json'), {})
|
|
43
62
|
const fresh = await localRevisions({ dir, scenario, locales, allLocales: run.meta.scope.locales, project: run.meta.project?.slug, pick })
|
|
44
63
|
const missing = []
|
|
45
|
-
for (const locale of locales) for (const node of screensOf(scenario)) if ((!pick || pick.includes(node.id)) && !fresh[headKey(node.id, locale)]) missing.push(`${node.id} (${locale}): add ${node.id}/index.html to ${relative(process.cwd(), dir)}/`)
|
|
64
|
+
for (const locale of locales) for (const node of screensOf(scenario)) if ((!pick || pick.includes(node.id)) && !fresh[headKey(node.id, locale)]) missing.push(`${node.id} (${locale}): add ${node.kind === 'stack' ? `a folder per variant, ${node.id}/<variant>/index.html,` : `${node.id}/index.html`} to ${relative(process.cwd(), dir)}/`)
|
|
46
65
|
const server = await serveDesign(dir)
|
|
47
66
|
const browser = await launchEphemeral(config)
|
|
48
67
|
const paths = runPaths(run.dir)
|
|
49
68
|
const mode = env.tokens_mode || ''
|
|
50
69
|
// the design folder's own set names the values in the step spec (the one its live pages reference)
|
|
51
70
|
const tokens = await loadTokens({ client: null, config, root, designDir: dir })
|
|
71
|
+
const scope0 = run.meta.scope
|
|
72
|
+
// token modes (#46): the set's values of each mode as CSS, set on the page before it is captured
|
|
73
|
+
const modeSheets = new Map((scope0?.dimensions?.token_modes || []).map((m) => [m, tokens ? modeCss(tokens, m) : '']))
|
|
74
|
+
const stateNotes = new Set()
|
|
52
75
|
let rendered = 0, reused = 0
|
|
53
76
|
try {
|
|
54
77
|
// one page per device profile (batch E): a phone preset renders with touch, its pixel ratio and user agent, so
|
|
@@ -61,42 +84,69 @@ export async function walkDesign ({ config, root, run, scenario, env, pick, loca
|
|
|
61
84
|
if (!pages.has(k)) { const p = await (await browser.newContext(contextOptionsOf({ engine: 'chromium', emulation }))).newPage(); observeConsole(p); pages.set(k, p) }
|
|
62
85
|
return pages.get(k)
|
|
63
86
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const live = { revision: rev.id, entry: rev.entry }
|
|
87
|
+
// renders one page (a step, or one item of a stack step) in the viewports whose captures are not cached
|
|
88
|
+
const renderPage = async ({ node, rev, entry: base, vpsIn, item = null }) => {
|
|
89
|
+
const stepId = item ? `${node.id}/items/${item.id}` : node.id
|
|
90
|
+
const live = { revision: rev.id, entry: base }
|
|
69
91
|
const todo = {}
|
|
70
|
-
for (const [vp, size] of Object.entries(
|
|
71
|
-
|
|
92
|
+
for (const [vp, size] of Object.entries(vpsIn)) {
|
|
93
|
+
if (!keyAppliesTo(vp, node)) continue // a state this step does not have
|
|
94
|
+
const ck = captureKey(rev.id, vp, mode, item?.id)
|
|
72
95
|
const cached = noCache ? null : await fromCache({ root, output: config.output, key: ck })
|
|
73
|
-
// a capture from before the step spec existed renders again, so Dev mode and `kaloko spec` have it
|
|
74
|
-
if (cached && cached.record.criteria_hash === criteriaHash(node) && cached.record.spec) {
|
|
75
|
-
const record = await restoreCached({ cached, runDir: run.dir, paths, node:
|
|
76
|
-
const file = paths.step(
|
|
96
|
+
// a capture from before the step spec existed renders again, so Dev mode and `kaloko spec` have it (items have none)
|
|
97
|
+
if (cached && cached.record.criteria_hash === criteriaHash(node) && (item || cached.record.spec)) {
|
|
98
|
+
const record = await restoreCached({ cached, runDir: run.dir, paths, node: stepId, locale: rev.locale, viewport: vp })
|
|
99
|
+
const file = paths.step(stepId, rev.locale, vp, 'capture.json')
|
|
77
100
|
await writeJson(file, record)
|
|
78
|
-
await addCapture(run.dir, { type: 'capture', node: node.id, locale: rev.locale, viewport: vp, file: relative(run.dir, file), at: new Date().toISOString() })
|
|
101
|
+
await addCapture(run.dir, { type: 'capture', node: node.id, ...(item ? { item: item.id } : {}), locale: rev.locale, viewport: vp, file: relative(run.dir, file), at: new Date().toISOString() })
|
|
79
102
|
reused++
|
|
80
103
|
} else todo[vp] = size
|
|
81
104
|
}
|
|
82
|
-
// dark captures render <
|
|
105
|
+
// dark captures render <page folder>/dark/index.html when there is one; otherwise the page itself in the dark
|
|
83
106
|
// scheme (prefers-color-scheme emulated): one page load per entry
|
|
84
|
-
const darkEntry =
|
|
107
|
+
const darkEntry = base.replace(/index\.html$/, 'dark/index.html')
|
|
85
108
|
const groups = new Map()
|
|
86
109
|
for (const [vp, size] of Object.entries(todo)) {
|
|
87
|
-
const
|
|
110
|
+
const variant = run.meta.scope?.variants?.[vp] || {}
|
|
111
|
+
const state = stateOf(vp)
|
|
112
|
+
// a state renders its own page (<page folder>/<state>.html), else the page with ?state=<name>
|
|
113
|
+
let entry = state === 'default' ? base : statePage(base, state, rev.files)
|
|
114
|
+
let query = ''
|
|
115
|
+
if (!entry) {
|
|
116
|
+
entry = base; query = `?state=${encodeURIComponent(state)}`
|
|
117
|
+
const note = `${node.id}${item ? `/${item.id}` : ''} ${state}: no ${base.replace(/index\.html$/, `${state}.html`)}; rendered ${base}${query}`
|
|
118
|
+
if (!stateNotes.has(note)) { stateNotes.add(note); say(` ${note}`) }
|
|
119
|
+
}
|
|
120
|
+
if (state === 'default' && variant.color_scheme === 'dark' && rev.files[darkEntry]) entry = darkEntry
|
|
121
|
+
const tokenMode = modeSheets.size ? (variant.token_mode || scope0.dimensions.token_modes[0]) : null
|
|
88
122
|
const page = await pageFor(vp)
|
|
89
|
-
const g = `${entry}|${[...pages.values()].indexOf(page)}`
|
|
90
|
-
if (!groups.has(g)) groups.set(g, { entry, page, vps: {} })
|
|
123
|
+
const g = `${entry}${query}|${tokenMode || ''}|${[...pages.values()].indexOf(page)}`
|
|
124
|
+
if (!groups.has(g)) groups.set(g, { entry, query, tokenMode, page, vps: {} })
|
|
91
125
|
groups.get(g).vps[vp] = size
|
|
92
126
|
}
|
|
93
|
-
for (const { entry, page, vps } of groups.values()) {
|
|
127
|
+
for (const { entry, query, tokenMode, page, vps } of groups.values()) {
|
|
94
128
|
const since = new Date().toISOString()
|
|
95
|
-
await page.goto(`${server.url}/${entry}`, { waitUntil: 'load' })
|
|
96
|
-
|
|
97
|
-
|
|
129
|
+
await page.goto(`${server.url}/${entry}${query}`, { waitUntil: 'load' })
|
|
130
|
+
if (tokenMode) await page.evaluate(setTokenMode, { mode: tokenMode, css: modeSheets.get(tokenMode) || '' })
|
|
131
|
+
const results = await captureStep({ page, node, locale: rev.locale, viewports: vps, run, maxChars: config.evaluators?.jev?.max_state_chars, since, extra: { live: entry !== base ? { ...live, entry } : query ? { ...live, query: query.slice(1) } : live }, designSummary: true, tokens, ...(item ? { item, fields: fieldsOf(node) } : {}) })
|
|
132
|
+
for (const r of results) { await toCache({ root, output: config.output, key: captureKey(rev.id, r.viewport, mode, item?.id), runDir: run.dir, record: r }); rendered++ }
|
|
133
|
+
}
|
|
134
|
+
return Object.keys(todo).length
|
|
135
|
+
}
|
|
136
|
+
for (const [key, rev] of Object.entries(fresh)) {
|
|
137
|
+
const node = scenario.nodes.find((n) => n.id === rev.node)
|
|
138
|
+
revisions[key] = rev
|
|
139
|
+
await copyLive(run.dir, dir, rev)
|
|
140
|
+
if (rev.items) {
|
|
141
|
+
// a stack step (#61): every variant is a page of its own, captured like a page of a stack in an app run
|
|
142
|
+
const vps = stackViewports(node, viewports)
|
|
143
|
+
let todo = 0
|
|
144
|
+
for (const it of rev.items) todo += await renderPage({ node, rev, entry: it.entry, vpsIn: vps, item: { id: it.id, path: it.entry.replace(/index\.html$/, '') } })
|
|
145
|
+
say(`✓ ${node.id} ${rev.locale} revision ${rev.id.slice(0, 8)} ${rev.items.length} variant(s)${todo ? '' : ' (unchanged, reused)'}`)
|
|
146
|
+
continue
|
|
98
147
|
}
|
|
99
|
-
|
|
148
|
+
const todo = await renderPage({ node, rev, entry: rev.entry, vpsIn: viewports })
|
|
149
|
+
say(`✓ ${node.id} ${rev.locale} revision ${rev.id.slice(0, 8)}${todo ? '' : ' (unchanged, reused)'}`)
|
|
100
150
|
}
|
|
101
151
|
} finally {
|
|
102
152
|
await browser.close()
|
|
@@ -108,6 +158,17 @@ export async function walkDesign ({ config, root, run, scenario, env, pick, loca
|
|
|
108
158
|
return { rendered, reused, revisions }
|
|
109
159
|
}
|
|
110
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Viewports a stack step's variants are captured in on a design environment (#61): `items.viewports` when the step
|
|
163
|
+
* names some, else every viewport of the walk (a design has a few variants, not hundreds of pages).
|
|
164
|
+
*/
|
|
165
|
+
export function stackViewports (node, viewports) {
|
|
166
|
+
const want = node.items?.viewports
|
|
167
|
+
if (!Array.isArray(want) || !want.length) return viewports
|
|
168
|
+
const out = Object.fromEntries(Object.entries(viewports).filter(([k]) => want.includes(k) || want.includes(k.split('.')[0])))
|
|
169
|
+
return Object.keys(out).length ? out : viewports
|
|
170
|
+
}
|
|
171
|
+
|
|
111
172
|
/**
|
|
112
173
|
* The scope the draft keeps: locales and capture keys, and (batch E) what the keys mean and the device presets, so
|
|
113
174
|
* versions on the service get the scheme switcher and device frames. User agents stay local.
|
|
@@ -163,6 +224,8 @@ export async function syncDesign ({ config, root, run, scenario, env, client, ar
|
|
|
163
224
|
const remote = await client.request('GET', `/orgs/${org}/scenarios/${sid}/draft?branch=${enc(branch)}`)
|
|
164
225
|
const remoteHeads = Object.fromEntries((remote.heads || []).map((h) => [headKey(h.node, h.locale, h.state), h.revision]))
|
|
165
226
|
const remoteCaps = Object.fromEntries((remote.heads || []).map((h) => [headKey(h.node, h.locale, h.state), h.captures || {}]))
|
|
227
|
+
// a stack step's captures are per variant (#61): item → viewport → { renderer, criteria_hash }
|
|
228
|
+
const remoteItemCaps = Object.fromEntries((remote.heads || []).map((h) => [headKey(h.node, h.locale, h.state), h.item_captures || {}]))
|
|
166
229
|
const snapshot = snapshotOf(scenario, run)
|
|
167
230
|
const snapshotChanged = !remote.exists || remote.snapshot_hash !== await sha256Hex(JSON.stringify(snapshot))
|
|
168
231
|
let local = await localRevisions({ dir, scenario, locales, allLocales: locales, project: run.meta.project?.slug })
|
|
@@ -214,32 +277,38 @@ export async function syncDesign ({ config, root, run, scenario, env, client, ar
|
|
|
214
277
|
}
|
|
215
278
|
let result = null
|
|
216
279
|
// same revision, but its draft captures were rendered by another renderer or for other criteria: send them again
|
|
217
|
-
const
|
|
218
|
-
|
|
280
|
+
const nodeOf = (key) => scenario.nodes.find((n) => n.id === local[key].node)
|
|
281
|
+
// what a step's draft captures are: [item, viewport] pairs (item '' for a plain step; every variant of a stack step),
|
|
282
|
+
// without the keys of states (#46) the step does not have
|
|
283
|
+
const slots = (key) => {
|
|
284
|
+
const rev = local[key]
|
|
285
|
+
const vps = Object.keys(rev.items ? stackViewports(nodeOf(key), run.meta.scope.viewports) : run.meta.scope.viewports).filter((k) => keyAppliesTo(k, nodeOf(key)))
|
|
286
|
+
return rev.items ? rev.items.flatMap((it) => vps.map((vp) => [it.id, vp])) : vps.map((vp) => ['', vp])
|
|
287
|
+
}
|
|
288
|
+
const toRefresh = push ? Object.keys(local).filter((key) => local[key].id === remoteHeads[key] && !toPush.includes(key) && slots(key).some(([item, vp]) => { const rc = item ? remoteItemCaps[key]?.[item]?.[vp] : remoteCaps[key]?.[vp]; return !rc || rc.renderer !== RENDERER || rc.criteria_hash !== criteriaHash(nodeOf(key)) })) : []
|
|
219
289
|
if (push && (toPush.length || toRefresh.length || snapshotChanged)) {
|
|
220
|
-
// steps to push need captures of their current revision in every viewport of the run
|
|
221
|
-
const
|
|
222
|
-
const capturesByKey = async () => new Map(latestCaptures(await readCaptures(run.dir)).map((c) => [`${c.node}|${c.locale}|${c.viewport}`, c]))
|
|
290
|
+
// steps to push need captures of their current revision in every viewport of the run (and state of the step)
|
|
291
|
+
const capturesByKey = async () => new Map(latestCaptures(await readCaptures(run.dir)).map((c) => [`${c.node}|${c.item || ''}|${c.locale}|${c.viewport}`, c]))
|
|
223
292
|
let byKey = await capturesByKey()
|
|
224
293
|
const stale = []
|
|
225
294
|
const sending = [...toPush, ...toRefresh]
|
|
226
295
|
for (const key of sending) {
|
|
227
296
|
const [node, locale] = key.split('|')
|
|
228
297
|
const want = criteriaHash(scenario.nodes.find((n) => n.id === node))
|
|
229
|
-
for (const vp of
|
|
230
|
-
const c = byKey.get(`${node}|${locale}|${vp}`)
|
|
298
|
+
for (const [item, vp] of slots(key)) {
|
|
299
|
+
const c = byKey.get(`${node}|${item}|${locale}|${vp}`)
|
|
231
300
|
const rec = c ? await readJson(join(run.dir, c.file), null) : null
|
|
232
|
-
if (!rec || rec.live?.revision !== local[key].id || rec.criteria_hash !== want || !rec.design) stale.push(node)
|
|
301
|
+
if (!rec || rec.live?.revision !== local[key].id || rec.criteria_hash !== want || (!item && !rec.design)) stale.push(node)
|
|
233
302
|
}
|
|
234
303
|
}
|
|
235
304
|
if (stale.length) { await render([...new Set(stale)]); byKey = await capturesByKey() }
|
|
236
305
|
// every pushed capture carries a valid evaluation of the scenario's criteria (deterministic checks are free)
|
|
237
306
|
let unevaluated = false
|
|
238
|
-
for (const key of sending) for (const vp of
|
|
307
|
+
for (const key of sending) for (const [item, vp] of slots(key)) {
|
|
239
308
|
const [node, locale] = key.split('|')
|
|
240
|
-
const c = byKey.get(`${node}|${locale}|${vp}`)
|
|
309
|
+
const c = byKey.get(`${node}|${item}|${locale}|${vp}`)
|
|
241
310
|
const rec = c && await readJson(join(run.dir, c.file), null)
|
|
242
|
-
const ev = await readJson(runPaths(run.dir).step(node, locale, vp, 'eval.json'), null)
|
|
311
|
+
const ev = await readJson(runPaths(run.dir).step(item ? `${node}/items/${item}` : node, locale, vp, 'eval.json'), null)
|
|
243
312
|
if (rec && ev?.source !== evaluationSource(scenario.nodes.find((n) => n.id === node), rec)) unevaluated = true
|
|
244
313
|
}
|
|
245
314
|
if (unevaluated) { const { evaluateRun } = await import('./evaluate.mjs'); await evaluateRun({ run, scenario, config, root }); say('Evaluated the new captures against the scenario criteria.') }
|
|
@@ -250,11 +319,13 @@ export async function syncDesign ({ config, root, run, scenario, env, client, ar
|
|
|
250
319
|
const node = scenario.nodes.find((n) => n.id === rev.node)
|
|
251
320
|
for (const [p, h] of Object.entries(rev.files)) files.set(h, { abs: join(dir, p), ext: extOf(p) })
|
|
252
321
|
const captures = {}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
322
|
+
const itemCaptures = {}
|
|
323
|
+
for (const [item, vp] of slots(key)) {
|
|
324
|
+
const what = `${rev.node}${item ? `/${item}` : ''} ${rev.locale} ${vp}`
|
|
325
|
+
const c = byKey.get(`${rev.node}|${item}|${rev.locale}|${vp}`)
|
|
326
|
+
if (!c) throw new UserError(`${what}: no capture; run kaloko walk first.`)
|
|
256
327
|
const rec = await readJson(join(run.dir, c.file))
|
|
257
|
-
if (rec.live?.revision !== rev.id) throw new UserError(`${
|
|
328
|
+
if (rec.live?.revision !== rev.id) throw new UserError(`${what}: capture does not match the folder; run kaloko walk again.`)
|
|
258
329
|
const out = {}
|
|
259
330
|
for (const [k, rel] of [['image', rec.files.image], ['thumb', rec.files.thumb], ['html', rec.files.html]]) {
|
|
260
331
|
if (!rel) continue
|
|
@@ -263,14 +334,17 @@ export async function syncDesign ({ config, root, run, scenario, env, client, ar
|
|
|
263
334
|
files.set(h, { abs, ext: extOf(rel) })
|
|
264
335
|
out[k] = h
|
|
265
336
|
}
|
|
266
|
-
const stored = await readJson(runPaths(run.dir).step(rev.node, rev.locale, vp, 'eval.json'), null)
|
|
267
|
-
|
|
337
|
+
const stored = await readJson(runPaths(run.dir).step(item ? `${rev.node}/items/${item}` : rev.node, rev.locale, vp, 'eval.json'), null)
|
|
338
|
+
// a variant keeps what the stack index shows: its id, page and index fields; a step the page the capture
|
|
339
|
+
// rendered (a state's or the dark page, #46), so the canvas opens that page live
|
|
340
|
+
out.record = { ...slimCapture(rec), ...(rec.spec ? { spec: rec.spec } : {}), ...(item ? { item: rec.item, fields: rec.fields || {}, live: rec.live } : rec.live?.entry && rec.live.entry !== rev.entry ? { live: { entry: rec.live.entry } } : rec.live?.query ? { live: { query: rec.live.query } } : {}) }
|
|
268
341
|
out.evaluation = stored?.source === evaluationSource(node, rec) ? stored : null
|
|
269
|
-
out.mode = env.tokens_mode || ''
|
|
342
|
+
out.mode = rec.token_mode || env.tokens_mode || ''
|
|
270
343
|
out.renderer = RENDERER
|
|
271
|
-
|
|
344
|
+
if (item) (itemCaptures[item] ||= {})[vp] = out
|
|
345
|
+
else captures[vp] = out
|
|
272
346
|
}
|
|
273
|
-
revisions.push({ id: rev.id, node: rev.node, locale: rev.locale, state: rev.state, entry: rev.entry, files: rev.files, tokens: rev.tokens, based_on: base[key] || remoteHeads[key] || null, captures })
|
|
347
|
+
revisions.push({ id: rev.id, node: rev.node, locale: rev.locale, state: rev.state, entry: rev.entry, ...(rev.items ? { items: rev.items, item_captures: itemCaptures } : {}), files: rev.files, tokens: rev.tokens, based_on: base[key] || remoteHeads[key] || null, captures })
|
|
274
348
|
}
|
|
275
349
|
await uploadBlobs({ client, org, files, say })
|
|
276
350
|
const heads = toPush.map((key) => ({ node: local[key].node, locale: local[key].locale, state: local[key].state, revision: local[key].id, base: remoteHeads[key] || null }))
|
package/src/lib/design-core.mjs
CHANGED
|
@@ -87,13 +87,35 @@ export const REVISION_ID = /^[0-9a-f]{32}$/
|
|
|
87
87
|
* Identity of a revision: same content, same id, on every machine. `files` maps bundle paths to
|
|
88
88
|
* blob hashes; `tokens` is the token set reference ({ project, version, hash }) or null.
|
|
89
89
|
*/
|
|
90
|
-
export async function revisionId ({ scenario, node, locale, state = 'default', kind = 'design', entry, files, tokens = null }) {
|
|
91
|
-
|
|
90
|
+
export async function revisionId ({ scenario, node, locale, state = 'default', kind = 'design', entry, files, tokens = null, items = null }) {
|
|
91
|
+
// a stack step's revision covers its whole folder and names its items (variants) with their pages: [{ id, entry }];
|
|
92
|
+
// steps without items keep the identity they always had
|
|
93
|
+
const canonical = JSON.stringify({ v: 1, scenario, node, locale, state, kind, entry, files: Object.keys(files).sort().map((k) => [k, files[k]]), tokens: tokens ? tokens.hash || null : null, ...(Array.isArray(items) ? { items: items.map((i) => [i.id, i.entry]) } : {}) })
|
|
92
94
|
return (await sha256Hex(canonical)).slice(0, 32)
|
|
93
95
|
}
|
|
94
96
|
|
|
95
|
-
/** Key of a rendered capture: a revision rendered in one viewport and token mode by one renderer. */
|
|
96
|
-
export const captureKey = (revision, viewport, mode = '') => `${revision}|${viewport}|${mode}|${RENDERER}`
|
|
97
|
+
/** Key of a rendered capture: a revision (or one item of a stack revision) rendered in one viewport and token mode by one renderer. */
|
|
98
|
+
export const captureKey = (revision, viewport, mode = '', item = '') => `${revision}${item ? `+${item}` : ''}|${viewport}|${mode}|${RENDERER}`
|
|
99
|
+
|
|
100
|
+
/** Item ids of a stack step: folder names (a path segment of the bundle), at most this many per step. */
|
|
101
|
+
export const ITEM_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/
|
|
102
|
+
export const MAX_ITEMS = 200
|
|
103
|
+
|
|
104
|
+
/** Problems with the items of a stack revision ([{ id, entry }]) against its file list (empty when valid). */
|
|
105
|
+
export function itemProblems (items, files, node) {
|
|
106
|
+
if (!Array.isArray(items) || !items.length) return ['a stack revision needs at least one item']
|
|
107
|
+
const problems = []
|
|
108
|
+
if (items.length > MAX_ITEMS) problems.push(`more than ${MAX_ITEMS} items`)
|
|
109
|
+
const seen = new Set()
|
|
110
|
+
for (const it of items) {
|
|
111
|
+
if (!it || !ITEM_ID.test(it.id || '')) { problems.push(`item ${String(it?.id).slice(0, 40)}: invalid id`); continue }
|
|
112
|
+
if (seen.has(it.id)) problems.push(`item ${it.id}: listed twice`)
|
|
113
|
+
seen.add(it.id)
|
|
114
|
+
if (typeof it.entry !== 'string' || !it.entry.startsWith(`${node}/${it.id}/`) || extOf(it.entry) !== 'html') problems.push(`item ${it.id}: entry must be an .html page in ${node}/${it.id}/`)
|
|
115
|
+
else if (!(it.entry in files)) problems.push(`item ${it.id}: entry ${it.entry} is not among the files`)
|
|
116
|
+
}
|
|
117
|
+
return problems
|
|
118
|
+
}
|
|
97
119
|
|
|
98
120
|
// ---- reference scanner: which bundle files an entry needs ----
|
|
99
121
|
|
|
@@ -19,10 +19,12 @@ const titleOf = (t, locale) => (t && typeof t === 'object' ? t[locale] ?? t.defa
|
|
|
19
19
|
const flowOrder = (nodes) => nodes.filter((n) => n.kind !== 'decision').slice().sort((a, b) => (a.col ?? 0) - (b.col ?? 0) || (a.lane ?? 0) - (b.lane ?? 0))
|
|
20
20
|
|
|
21
21
|
/** A qawalk.scenario.v1 document from a run manifest: a design version carries the whole flow (steps, criteria, edges). */
|
|
22
|
-
export function scenarioFromManifest (m) {
|
|
22
|
+
export function scenarioFromManifest (m, { variants = {} } = {}) {
|
|
23
23
|
const lanes = m.lanes || []
|
|
24
24
|
const nodes = m.nodes.map((n) => {
|
|
25
25
|
const own = (n.criteria || []).filter((c) => !c.pack)
|
|
26
|
+
// a stack step lists its variant folders (#61), so the exported scenario validates and walks the exported folder
|
|
27
|
+
const items = n.kind === 'stack' ? { items: { urls: variants[n.id]?.length ? variants[n.id] : (n.stack?.sample || []).map((x) => x.id) } } : {}
|
|
26
28
|
const packs = [...new Set((n.criteria || []).filter((c) => c.pack).map((c) => c.pack))]
|
|
27
29
|
return {
|
|
28
30
|
id: n.id, kind: n.kind, title: n.title,
|
|
@@ -30,7 +32,7 @@ export function scenarioFromManifest (m) {
|
|
|
30
32
|
...(lanes.length && Number.isInteger(n.lane) ? { lane: n.lane } : {}), ...(n.session ? { session: n.session } : {}),
|
|
31
33
|
// run manifests do not carry how a step is reached; the scenario needs it, so the export names the manual way
|
|
32
34
|
...(['decision', 'stack'].includes(n.kind) ? {} : { execution: n.execution?.strategy && n.execution?.instructions ? { strategy: n.execution.strategy, instructions: n.execution.instructions } : { strategy: 'manual', instructions: `Open ${typeof n.path === 'string' ? n.path : `the ${n.id} step`}.` } }),
|
|
33
|
-
...(own.length ? { criteria: own.map((c) => ({ id: c.id, text: c.text, check: c.check })) } : {}), ...(packs.length ? { packs } : {})
|
|
35
|
+
...items, ...(own.length ? { criteria: own.map((c) => ({ id: c.id, text: c.text, check: c.check })) } : {}), ...(packs.length ? { packs } : {})
|
|
34
36
|
}
|
|
35
37
|
})
|
|
36
38
|
return {
|
|
@@ -44,7 +46,10 @@ export function scenarioFromManifest (m) {
|
|
|
44
46
|
/** Start page of an exported design: the steps in flow order with each locale's page and screenshot; plain HTML, no script. */
|
|
45
47
|
export function exportIndexHtml ({ index }) {
|
|
46
48
|
const loc0 = index.locales[0]
|
|
47
|
-
const
|
|
49
|
+
const href = (p) => esc(p.split('/').map(enc).join('/'))
|
|
50
|
+
// a stack step lists its variants: each one's page per locale (#61)
|
|
51
|
+
const variants = (s) => s.variants?.length ? `<ul>${s.variants.map((v) => `<li>${esc(v.id)}: ${index.locales.filter((l) => v.pages[l]).map((l) => `<a href="${href(v.pages[l])}">${esc(l)}</a>`).join(' · ')}</li>`).join('')}</ul>` : ''
|
|
52
|
+
const rows = index.steps.map((s) => `<li><h2>${esc(titleOf(s.title, loc0))} <small>${esc(s.id)}</small></h2>${s.purpose ? `<p>${esc(s.purpose)}</p>` : ''}<p>${index.locales.filter((l) => s.pages[l]).map((l) => `<a href="${href(s.pages[l])}">${esc(l)}: ${esc(titleOf(s.title, l))}</a>`).join(' · ') || '<em>no page</em>'}</p>${variants(s)}${s.screenshots.length ? `<p class="shots">${s.screenshots.map((x) => `<a href="${esc(x.file)}"><img src="${esc(x.file)}" alt="${esc(`${s.id} ${x.locale} ${x.viewport}`)}" loading="lazy" width="160"></a>`).join('')}</p>` : ''}</li>`).join('\n')
|
|
48
53
|
return `<!doctype html>
|
|
49
54
|
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${esc(index.scenario.title)} · ${esc(index.ref)}</title>
|
|
50
55
|
<style>:root{color-scheme:light dark;--ink:#151514;--paper:#fff;--line:#ddd}@media (prefers-color-scheme:dark){:root{--ink:#eee;--paper:#151514;--line:#333}}body{margin:0;font:16px/1.5 system-ui,sans-serif;color:var(--ink);background:var(--paper)}main{max-width:960px;margin:0 auto;padding:32px 16px}ol{padding-left:20px}li{border-top:1px solid var(--line);padding:12px 0}h2{font-size:18px;margin:0}small{font-weight:400;opacity:.6}.shots img{border:1px solid var(--line);border-radius:4px;margin:0 8px 8px 0;height:auto}code{font-size:14px}</style></head>
|
|
@@ -117,8 +122,11 @@ export async function exportDesign ({ config, scenarioId, client, ref = 'design'
|
|
|
117
122
|
// the live files keep the design folder layout: links between steps work from disk exactly as in the folder
|
|
118
123
|
const paths = new Map(), clashes = []
|
|
119
124
|
const pages = {}
|
|
125
|
+
const variantPages = {}
|
|
120
126
|
for (const s of revs.steps.filter((x) => x.state === 'default')) {
|
|
121
127
|
;(pages[s.node] ||= {})[s.locale] = s.entry
|
|
128
|
+
// the variants of a stack step (#61) are folders of the step: <step>/<variant>/index.html
|
|
129
|
+
for (const it of s.items || []) { safeExportPath(it.entry); ((variantPages[s.node] ||= {})[it.id] ||= {})[s.locale] = it.entry }
|
|
122
130
|
for (const [p, hash] of Object.entries(s.files)) {
|
|
123
131
|
safeExportPath(p) // checked before anything is written
|
|
124
132
|
if (paths.has(p)) { if (paths.get(p).hash !== hash) clashes.push(p); continue }
|
|
@@ -143,16 +151,28 @@ export async function exportDesign ({ config, scenarioId, client, ref = 'design'
|
|
|
143
151
|
screenshots.push({ locale, viewport, file, size: c.size || null })
|
|
144
152
|
shots++
|
|
145
153
|
}
|
|
146
|
-
|
|
154
|
+
// a stack step's screenshots are its variants': screenshots/<step>/<variant>/<locale>/<viewport>.webp
|
|
155
|
+
if (n.stack?.items) {
|
|
156
|
+
const idx = JSON.parse((await fileOf(n.stack.items)).toString('utf8'))
|
|
157
|
+
for (const it of idx.items || []) for (const [locale, vps] of Object.entries(it.captures || {})) for (const [viewport, c] of Object.entries(vps || {})) {
|
|
158
|
+
if (!c?.png) continue
|
|
159
|
+
const file = `screenshots/${n.id}/${safeExportPath(it.id)}/${locale}/${viewport}.${c.png.split('.').pop()}`
|
|
160
|
+
await sink.put(file, await fileOf(c.png))
|
|
161
|
+
screenshots.push({ item: it.id, locale, viewport, file, size: c.size || null })
|
|
162
|
+
shots++
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const variants = Object.entries(variantPages[n.id] || {}).map(([id, byLocale]) => ({ id, pages: byLocale }))
|
|
166
|
+
steps.push({ id: n.id, title: n.title, purpose: n.purpose || '', kind: n.kind, pages: pages[n.id] || {}, ...(variants.length ? { variants } : {}), screenshots })
|
|
147
167
|
}
|
|
148
168
|
const index = {
|
|
149
169
|
schema: DESIGN_EXPORT_SCHEMA, scenario: { id: scenarioId, title: manifest.scenario?.title || scenarioId }, ref, run_id: runId,
|
|
150
170
|
version: manifest.version ?? null, version_name: manifest.version_name ?? null, branch, exported_at: new Date().toISOString(),
|
|
151
171
|
locales: manifest.scope.locales, viewports: manifest.scope.viewports, tokens: paths.has(TOKENS_FILE) ? TOKENS_FILE : null,
|
|
152
|
-
formats: { pages: 'HTML, CSS, JS and assets in the design folder layout (<step>/index.html, <step>/<locale>/index.html, _shared/)', tokens: 'W3C Design Tokens (DTCG) JSON', scenario: 'qawalk.scenario.v1 YAML', screenshots: 'WebP or PNG per step, locale and viewport' },
|
|
172
|
+
formats: { pages: 'HTML, CSS, JS and assets in the design folder layout (<step>/index.html, <step>/<locale>/index.html, <stack step>/<variant>/index.html, _shared/)', tokens: 'W3C Design Tokens (DTCG) JSON', scenario: 'qawalk.scenario.v1 YAML', screenshots: 'WebP or PNG per step, locale and viewport' },
|
|
153
173
|
steps
|
|
154
174
|
}
|
|
155
|
-
await sink.put('scenario.yml', `# Exported from Kaloko (${ref}); validate with: kaloko validate scenario.yml\n` + yaml.dump(scenarioFromManifest(manifest), { lineWidth: 140, noRefs: true }))
|
|
175
|
+
await sink.put('scenario.yml', `# Exported from Kaloko (${ref}); validate with: kaloko validate scenario.yml\n` + yaml.dump(scenarioFromManifest(manifest, { variants: Object.fromEntries(Object.entries(variantPages).map(([k, v]) => [k, Object.keys(v)])) }), { lineWidth: 140, noRefs: true }))
|
|
156
176
|
await sink.put('kaloko.json', JSON.stringify(index, null, 2) + '\n')
|
|
157
177
|
await sink.put('index.html', exportIndexHtml({ index }))
|
|
158
178
|
await sink.close()
|
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
|
|