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/build.mjs
CHANGED
|
@@ -4,10 +4,9 @@ import { existsSync } from 'node:fs'
|
|
|
4
4
|
import { join, resolve } from 'node:path'
|
|
5
5
|
import { readJson, writeJson } from './util.mjs'
|
|
6
6
|
import { readCaptures, runPaths, listRuns } from './run.mjs'
|
|
7
|
-
import { latestCaptures, evaluationSource, stepIdOf
|
|
8
|
-
import { fieldsOf, DEFAULT_SEMANTIC_SAMPLE } from './stack.mjs'
|
|
7
|
+
import { latestCaptures, evaluationSource, stepIdOf } from './evaluate.mjs'
|
|
9
8
|
import { layout } from './scenario.mjs'
|
|
10
|
-
import { assembleNode,
|
|
9
|
+
import { assembleNode, assembleStack, summarize } from './manifest-core.mjs'
|
|
11
10
|
import { packageRoot } from './schema.mjs'
|
|
12
11
|
import { variantOf } from './dimensions.mjs'
|
|
13
12
|
|
|
@@ -148,53 +147,17 @@ async function runMedia ({ run, entries }) {
|
|
|
148
147
|
*/
|
|
149
148
|
async function buildStack ({ run, node, base, captures, attachments }) {
|
|
150
149
|
const paths = runPaths(run.dir)
|
|
151
|
-
const
|
|
152
|
-
const environment = run.meta?.environment?.name || null
|
|
153
|
-
const crit = (node.criteria || []).map((c) => ({ id: c.id, text: c.text, type: c.check.type, pack: c.pack || null, ...(appliesOn(c, environment) ? {} : { not_applicable: 'environment' }) }))
|
|
154
|
-
// criteria limited to other environments do not decide the status of a page
|
|
155
|
-
const live = new Set(crit.filter((c) => !c.not_applicable).map((c) => c.id))
|
|
156
|
-
const byItem = new Map()
|
|
150
|
+
const list = []
|
|
157
151
|
for (const c of captures.filter((x) => x.node === node.id && x.item)) {
|
|
158
152
|
const cap = await readJson(join(run.dir, c.file))
|
|
159
|
-
const
|
|
160
|
-
const stored = await readJson(evPath, null)
|
|
153
|
+
const stored = await readJson(paths.step(stepIdOf(c), c.locale, c.viewport, 'eval.json'), null)
|
|
161
154
|
const ev = stored?.source === evaluationSource(node, cap) ? stored : null
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
;(it.captures[c.locale] ||= {})[c.viewport] = { png: cap.files.png, thumb: cap.files.thumb || null, html: cap.files.html, url: cap.url, size: cap.size ? [cap.size.width, cap.size.height] : null, boxes: cap.boxes, captured_at: cap.captured_at, console_errors: (cap.console_errors || []).length, console: (cap.console_errors || []).slice(0, 10), overflow: (cap.overflow || []).length, meta: cap.meta, ...(cap.http_status ? { http_status: cap.http_status } : {}), ...(cap.capture?.full_page === false ? { first_screen: true } : {}), eval: ev ? `steps/${stepIdOf(c)}/${c.locale}/${c.viewport}.eval.json` : null }
|
|
165
|
-
it.results[`${c.locale}|${c.viewport}`] = ev ? Object.fromEntries(ev.criteria.map((r) => [r.id, [r.verdict, r.evidence ? String(r.evidence).slice(0, 200) : null, r.score ?? null]])) : Object.fromEntries(crit.filter((x) => x.type !== 'semantic').map((x) => [x.id, ['not_evaluated', null, null]]))
|
|
166
|
-
if (!ev) it.stale = true
|
|
167
|
-
}
|
|
168
|
-
const items = [...byItem.values()].sort((a, b) => a.order - b.order).map(({ order, ...it }) => {
|
|
169
|
-
const per = Object.values(it.results)
|
|
170
|
-
const verdictOf = (id) => worst(per.map((r) => r[id]?.[0]).filter(Boolean))
|
|
171
|
-
const verdicts = crit.filter((c) => live.has(c.id)).map((c) => verdictOf(c.id)).filter((v) => v !== 'not_reached')
|
|
172
|
-
const status = statusOf(verdicts, per.length > 0)
|
|
173
|
-
const by_locale = Object.fromEntries(Object.keys(it.captures).map((l) => [l, statusOf(Object.entries(it.results).filter(([k]) => k.startsWith(`${l}|`)).flatMap(([, r]) => Object.entries(r).filter(([id]) => live.has(id)).map(([, x]) => x[0])), true)]))
|
|
174
|
-
return { ...it, status, by_locale, fails: crit.filter((c) => live.has(c.id) && verdictOf(c.id) === 'fail').map((c) => c.id), review: crit.filter((c) => live.has(c.id) && ['needs_review', 'not_evaluated'].includes(verdictOf(c.id))).map((c) => c.id) }
|
|
175
|
-
})
|
|
176
|
-
const summary = { pass: 0, partial: 0, fail: 0, skip: 0 }
|
|
177
|
-
for (const it of items) summary[it.status]++
|
|
178
|
-
const criteria = crit.map((c) => {
|
|
179
|
-
const counts = {}
|
|
180
|
-
for (const it of items) { const v = worst(Object.values(it.results).map((r) => r[c.id]?.[0]).filter(Boolean)); if (v !== 'not_reached') counts[v] = (counts[v] || 0) + 1 }
|
|
181
|
-
return { ...c, counts, verdict: counts.fail ? 'fail' : counts.needs_review ? 'needs_review' : counts.not_evaluated ? 'not_evaluated' : counts.pass ? 'pass' : 'not_reached', by_locale: {} }
|
|
182
|
-
})
|
|
183
|
-
const semanticSample = node.items?.semantic_sample ?? DEFAULT_SEMANTIC_SAMPLE
|
|
184
|
-
const file = `steps/${node.id}/items.json`
|
|
185
|
-
await writeJson(join(run.dir, file), { schema: 'qawalk.stack.v1', node: node.id, fields, criteria: crit, semantic_sample: semanticSample, items })
|
|
186
|
-
const thumbOf = (it) => { const c = Object.values(it.captures)[0]; const v = c && Object.values(c)[0]; return v ? v.thumb || v.png : null }
|
|
187
|
-
const sample = [...items.filter((it) => it.status === 'fail'), ...items.filter((it) => it.status !== 'fail')].slice(0, 4).map((it) => ({ id: it.id, title: it.title, path: it.path, status: it.status, thumb: thumbOf(it) }))
|
|
188
|
-
const status = !items.length ? 'skip' : summary.fail ? 'fail' : summary.partial || summary.skip ? 'partial' : 'pass'
|
|
189
|
-
const locales = [...new Set(items.flatMap((it) => Object.keys(it.by_locale)))]
|
|
190
|
-
const by_locale = Object.fromEntries(locales.map((l) => { const s = items.map((it) => it.by_locale[l]).filter(Boolean); return [l, s.includes('fail') ? 'fail' : s.some((x) => x !== 'pass') ? 'partial' : 'pass'] }))
|
|
191
|
-
const src = node.items || {}
|
|
192
|
-
return {
|
|
193
|
-
...base, status, by_locale, captures: {}, criteria, semantic: {}, metadata: {}, session: node.session || null,
|
|
194
|
-
execution: node.execution ? { strategy: node.execution.strategy, instructions: node.execution.instructions } : { strategy: 'playwright', instructions: 'Open every page of the stack and capture it.' },
|
|
195
|
-
attachments: attachments.filter((a) => a.node === node.id && a.kind !== 'focus').map((a) => ({ type: a.kind, title: a.title, file: a.file, locale: a.locale })),
|
|
196
|
-
stack: { count: items.length, items: file, summary, fields, sample, semantic_sample: semanticSample, source: ['urls', 'file', 'sitemap', 'crawl', 'script'].find((k) => src[k]) || null, stale: items.filter((it) => it.stale).length }
|
|
155
|
+
// a variant of a design stack (#61) opens live in the lightbox: its page in the step's revision
|
|
156
|
+
list.push({ item: c.item, locale: c.locale, viewport: c.viewport, cap, ev, files: { png: cap.files.png, thumb: cap.files.thumb || null, html: cap.files.html, eval: `steps/${stepIdOf(c)}/${c.locale}/${c.viewport}.eval.json` }, ...(cap.live ? { live: cap.live } : {}) })
|
|
197
157
|
}
|
|
158
|
+
const { node: out, index, file } = assembleStack({ node, base, environment: run.meta?.environment?.name || null, captures: list, attachments })
|
|
159
|
+
await writeJson(join(run.dir, file), index)
|
|
160
|
+
return out
|
|
198
161
|
}
|
|
199
162
|
|
|
200
163
|
/**
|
package/src/lib/capture.mjs
CHANGED
|
@@ -165,7 +165,7 @@ export async function captureStep ({ page, node, locale, viewports, run, console
|
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
167
|
// the step spec (Dev mode, `kaloko spec`, drift): elements with roles, names and styles mapped to the tokens
|
|
168
|
-
if (probeArgs(node, maxChars, designSummary).design && !item) result.spec = await stepSpec(page, tokens, scope?.variants?.[viewport]?.color_scheme || null)
|
|
168
|
+
if (probeArgs(node, maxChars, designSummary).design && !item) result.spec = await stepSpec(page, tokens, scope?.variants?.[viewport]?.token_mode || scope?.variants?.[viewport]?.color_scheme || null)
|
|
169
169
|
// regions the scenario ignores in pixel comparisons (#20), as page rectangles of this capture
|
|
170
170
|
const ignore = await resolveIgnore(page, node, locale, viewport)
|
|
171
171
|
// what moves while the page sits still (walk --stability N): repeated screenshots, compared here
|
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()
|