chalk-talk 0.11.0 → 0.12.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.
@@ -1,47 +1,181 @@
1
- // A Chrome, Chromium or Edge without a window: opens a built page in one of its modes (#review, #snapshot) and reads
2
- // back what the page wrote in <pre id="chalk-<mode>">. Used by build.mjs and by the repository's tools.
3
- import { execFileSync } from 'node:child_process'
4
- import { existsSync, mkdtempSync, rmSync } from 'node:fs'
5
- import { tmpdir } from 'node:os'
1
+ // A Chrome, Chromium or Edge without a window: opens a built page in one of its modes (#review, #snapshots, #svg…) and
2
+ // reads back what the page wrote in <pre id="chalk-<mode>">. It talks to the browser over the DevTools protocol on a
3
+ // pipe (--remote-debugging-pipe) and waits for that <pre> itself: --dump-dom hangs on some machines, the pipe does not.
4
+ // The browsers are tried in order; one that does not start or does not answer in time gives way to the next.
5
+ // Used by build.mjs and by the repository's tools. Node only, no dependency.
6
+ import { execFileSync, spawn } from 'node:child_process'
7
+ import { existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'
8
+ import { createRequire } from 'node:module'
9
+ import { homedir, tmpdir } from 'node:os'
6
10
  import { join } from 'node:path'
7
11
  import { pathToFileURL } from 'node:url'
8
12
 
9
- /** The browser to use: CHROME_PATH, then the usual places on macOS and Windows, then `which` on Linux. */
10
- export function browser() {
11
- const env = process.env.CHROME_PATH
12
- if (env && existsSync(env)) return env
13
- const mac = [
13
+ /** Playwright's own Chromium: the package's answer when it is installed here, else what it downloaded. */
14
+ function playwright() {
15
+ const found = []
16
+ const req = createRequire(join(process.cwd(), 'noop.js'))
17
+ for (const name of ['playwright', 'playwright-core']) {
18
+ try {
19
+ const p = req(name).chromium.executablePath()
20
+ if (p) found.push(p)
21
+ } catch {}
22
+ }
23
+ const caches = [process.env.PLAYWRIGHT_BROWSERS_PATH, join(homedir(), '.cache/ms-playwright'), join(homedir(), 'Library/Caches/ms-playwright'), '/opt/pw-browsers'].filter(Boolean)
24
+ const inside = ['chrome-linux64/chrome', 'chrome-linux/chrome', 'chrome-linux-arm64/chrome', 'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing', 'chrome-mac/Chromium.app/Contents/MacOS/Chromium']
25
+ for (const cache of caches) {
26
+ let dirs = []
27
+ try {
28
+ dirs = readdirSync(cache).filter((d) => /^chromium-\d+$/.test(d)).sort().reverse()
29
+ } catch {}
30
+ for (const d of dirs) for (const p of inside) found.push(join(cache, d, p))
31
+ }
32
+ return found
33
+ }
34
+
35
+ /** Every browser worth trying, in order: CHROME_PATH, PUPPETEER_EXECUTABLE_PATH, Playwright's Chromium, the usual places
36
+ * on macOS and Windows, then `which` on Linux. Only paths that exist, each once. */
37
+ export function candidates() {
38
+ const paths = [process.env.CHROME_PATH, process.env.PUPPETEER_EXECUTABLE_PATH, ...playwright(),
14
39
  '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
15
40
  '/Applications/Chromium.app/Contents/MacOS/Chromium',
16
41
  '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
17
- ]
18
- const win = ['C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe']
19
- for (const p of [...mac, ...win]) if (existsSync(p)) return p
20
- for (const name of ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'microsoft-edge']) {
21
- try {
22
- const p = execFileSync('which', [name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
23
- if (p) return p
24
- } catch {}
42
+ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
43
+ 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe']
44
+ if (process.platform !== 'win32') {
45
+ for (const name of ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'microsoft-edge']) {
46
+ try {
47
+ paths.push(execFileSync('which', [name], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim())
48
+ } catch {}
49
+ }
25
50
  }
26
- return null
51
+ return [...new Set(paths.filter((p) => p && existsSync(p)))]
52
+ }
53
+
54
+ /** The first browser there is, or null. */
55
+ export function browser() {
56
+ return candidates()[0] ?? null
57
+ }
58
+
59
+ /** What the browser needs besides the usual: on Linux no sandbox as root (Chromium refuses to start otherwise) and no
60
+ * /dev/shm (small in containers). */
61
+ export function launchFlags(platform = process.platform, uid = process.getuid?.()) {
62
+ const flags = ['--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', '--allow-file-access-from-files',
63
+ '--disable-background-networking', '--disable-component-update', '--disable-sync', '--remote-debugging-pipe']
64
+ if (platform === 'linux') flags.push('--disable-dev-shm-usage')
65
+ if (platform === 'linux' && uid === 0) flags.push('--no-sandbox')
66
+ return flags
27
67
  }
28
68
 
29
- /** Opens `file` (with the `query`, e.g. lesson=2) at `#mode` and returns what the page wrote, parsed; null without a
30
- * browser, { error } when it failed. */
31
- export function runPage(file, mode, query = '') {
32
- const exe = browser()
33
- if (!exe) return null
69
+ class BrowserFailed extends Error {}
70
+
71
+ // A wait that never keeps Node running by itself.
72
+ const pause = (ms) => new Promise((ok) => setTimeout(ok, ms).unref())
73
+
74
+ /** One browser, started with a profile of its own, spoken to over the pipe. */
75
+ async function start(exe) {
34
76
  const profile = mkdtempSync(join(tmpdir(), 'chalk-talk-'))
77
+ const child = spawn(exe, [...launchFlags(), `--user-data-dir=${profile}`, 'about:blank'], { stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] })
78
+ const waiting = new Map()
79
+ let id = 0
80
+ let dead
81
+ // a failure of the whole browser rejects every call still waiting
82
+ const fail = (why) => {
83
+ dead ??= why
84
+ for (const { reject } of waiting.values()) reject(new BrowserFailed(dead))
85
+ waiting.clear()
86
+ }
87
+ child.on('error', (e) => fail(e.message))
88
+ const exited = new Promise((ok) => child.once('exit', (code) => (fail(`it stopped (exit ${code})`), ok())))
89
+ let buf = Buffer.alloc(0)
90
+ child.stdio[4].on('data', (d) => {
91
+ buf = Buffer.concat([buf, d])
92
+ let at
93
+ while ((at = buf.indexOf(0)) >= 0) {
94
+ const msg = JSON.parse(buf.subarray(0, at).toString('utf8'))
95
+ buf = buf.subarray(at + 1)
96
+ const call = msg.id && waiting.get(msg.id)
97
+ if (!call) continue
98
+ waiting.delete(msg.id)
99
+ if (msg.error) call.reject(new Error(msg.error.message))
100
+ else call.ok(msg.result)
101
+ }
102
+ })
103
+ child.stdio[3].on('error', (e) => fail(e.message))
104
+ const send = (method, params = {}, sessionId) => new Promise((ok, reject) => {
105
+ if (dead) return reject(new BrowserFailed(dead))
106
+ const n = ++id
107
+ waiting.set(n, { ok, reject })
108
+ child.stdio[3].write(JSON.stringify({ id: n, method, params, ...(sessionId ? { sessionId } : {}) }) + '\0')
109
+ })
110
+ const close = async () => {
111
+ if (!dead) {
112
+ send('Browser.close').catch(() => {})
113
+ await Promise.race([exited, pause(3000)])
114
+ }
115
+ child.kill()
116
+ await Promise.race([exited, pause(2000)])
117
+ rmSync(profile, { recursive: true, force: true })
118
+ }
119
+ return { send, close }
120
+ }
121
+
122
+ /** Opens `file` at `#mode` (with `query`) in a tab of its own and returns what the page wrote, parsed — { error } when
123
+ * the page could not be opened or wrote nothing in `ms`. */
124
+ async function open(b, file, mode, query, ms) {
125
+ const until = Date.now() + ms
126
+ let sessionId
127
+ // Every call waits at most until the page's time is up: a page busy forever does not hold the build.
128
+ const ask = (method, params, wait = until - Date.now()) => {
129
+ let timer
130
+ const late = new Promise((_, reject) => (timer = setTimeout(() => reject(new BrowserFailed(`no answer in ${Math.round(ms / 1000)} s`)), Math.max(0, wait))))
131
+ return Promise.race([b.send(method, params, sessionId), late]).finally(() => clearTimeout(timer))
132
+ }
133
+ const { targetId } = await ask('Target.createTarget', { url: 'about:blank' })
35
134
  try {
36
- const dom = execFileSync(exe, [
37
- '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', '--allow-file-access-from-files',
38
- `--user-data-dir=${profile}`, '--virtual-time-budget=30000', '--dump-dom', `${pathToFileURL(file).href}${query ? `?${query}` : ''}#${mode}`,
39
- ], { encoding: 'utf8', timeout: 90000, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024 })
40
- const found = dom.match(new RegExp(`<pre id="chalk-${mode}">([^<]*)</pre>`))
41
- return found ? JSON.parse(decodeURIComponent(found[1])) : { error: `the page did not finish the ${mode}` }
42
- } catch (e) {
43
- return { error: e.message.split('\n')[0] }
135
+ ;({ sessionId } = await ask('Target.attachToTarget', { targetId, flatten: true }))
136
+ const nav = await ask('Page.navigate', { url: `${pathToFileURL(file).href}${query ? `?${query}` : ''}#${mode}` })
137
+ if (nav.errorText) return { error: `the page did not open (${nav.errorText})` }
138
+ while (Date.now() < until) {
139
+ const r = await ask('Runtime.evaluate', { expression: `document.getElementById('chalk-${mode}')?.textContent ?? null`, returnByValue: true })
140
+ const text = r.result?.value
141
+ if (typeof text === 'string' && text) return JSON.parse(decodeURIComponent(text))
142
+ await pause(50)
143
+ }
144
+ const at = await ask('Runtime.evaluate', { expression: 'location.href', returnByValue: true }, 1000).catch(() => undefined)
145
+ throw new BrowserFailed(`no answer in ${Math.round(ms / 1000)} s${at?.result?.value ? ` at ${at.result.value}` : ''}`)
44
146
  } finally {
45
- rmSync(profile, { recursive: true, force: true })
147
+ sessionId = undefined
148
+ b.send('Target.closeTarget', { targetId }).catch(() => {})
46
149
  }
47
150
  }
151
+
152
+ /** Runs `jobs` ({ file, mode, query }) in one browser and returns their results in order, with the browser used —
153
+ * `results` null when there is no browser at all. A browser that fails (does not start, stops, or a page gets no answer
154
+ * in `timeout` seconds) gives way to the next one, which runs every job again; when none works, each result is
155
+ * { error } naming what was tried. */
156
+ export async function runPages(jobs, { timeout = 20 } = {}) {
157
+ const list = candidates()
158
+ if (!list.length) return { results: null, tried: [] }
159
+ const tried = []
160
+ for (const exe of list) {
161
+ let b
162
+ try {
163
+ b = await start(exe)
164
+ const results = []
165
+ for (const { file, mode, query = '' } of jobs) results.push(await open(b, file, mode, query, timeout * 1000))
166
+ return { results, used: exe, tried }
167
+ } catch (e) {
168
+ tried.push(`${exe}: ${e instanceof BrowserFailed ? e.message : e.message.split('\n')[0]}`)
169
+ } finally {
170
+ await b?.close()
171
+ }
172
+ }
173
+ const error = `no browser answered — ${tried.join('; ')}. Point CHROME_PATH at another one, or give it more time with --review-timeout`
174
+ return { results: jobs.map(() => ({ error })), tried }
175
+ }
176
+
177
+ /** One page: what it wrote, parsed; null without a browser, { error } when it failed. */
178
+ export async function runPage(file, mode, query = '', opts) {
179
+ const { results } = await runPages([{ file, mode, query }], opts)
180
+ return results ? results[0] : null
181
+ }
package/scripts/build.mjs CHANGED
@@ -4,21 +4,25 @@
4
4
  // flowchart (.mmd, with `%% # chapter` and `%% > narration`), or Markdown (.md): each ```chalk-talk or ```mermaid block
5
5
  // in it is a lesson.
6
6
  //
7
- // node build.mjs <recipe.json|.chalk|.mmd|.md> [out.html] [--no-review]
7
+ // node build.mjs <recipe.json|.chalk|.mmd|.md> [out.html] [--no-review] [--snapshots [folder]] [--review-timeout <s>]
8
8
  // node build.mjs <one> <two> … --out course.html [--title "The course"] [--no-review]
9
9
  // node build.mjs <recipe> [out.svg] --svg an SVG that draws itself, with no script: it plays inside an <img>,
10
10
  // in a README on GitHub too (one SVG per lesson, named by its id)
11
11
  //
12
- // Only paths ending in .html or .svg are ever written. Exit codes: 0 drawn and checked (or no browser to check with); 1 a
12
+ // --snapshots writes, in <id>.snapshots/ beside the page (or the folder given), a PNG of each chapter as it ends at each
13
+ // setting the reviewer tries, and <id>.contact-sheet.png with them all: a picture to look at before delivering.
14
+ //
15
+ // Only .html, .svg and .png files are ever written. Exit codes: 0 drawn and checked (or no browser to check with); 1 a
13
16
  // recipe breaks the contract (nothing written); 2 bad arguments; 3 drawn, but the reviewer found something to fix (off
14
- // the paper, text over text, a line across a text); 4 --svg with no browser to draw it in. Needs Node 18 or later and
15
- // nothing else; the review and --svg use Chrome, Chromium or Edge.
16
- import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
17
+ // the paper, text over text, a line across a text); 4 --svg or --snapshots with no browser to draw them in. Needs Node 18
18
+ // or later and nothing else; the review, --snapshots and --svg use Chrome, Chromium or Edge (Playwright's Chromium too).
19
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
17
20
  import { tmpdir } from 'node:os'
18
21
  import { basename, dirname, join, resolve } from 'node:path'
19
22
  import { fileURLToPath } from 'node:url'
20
- import { runPage } from './browser.mjs'
23
+ import { runPages } from './browser.mjs'
21
24
  import { ChalkTextError, chaptersFor, parseChalk, PROP_ALIASES, PROP_NAMES, validateScene } from './contract.mjs'
25
+ import { syntaxForLine, syntaxForStep } from './syntax.mjs'
22
26
 
23
27
  const here = dirname(fileURLToPath(import.meta.url))
24
28
  const argv = process.argv.slice(2)
@@ -26,16 +30,20 @@ const option = (name) => {
26
30
  const at = argv.indexOf(name)
27
31
  return at >= 0 ? argv[at + 1] : undefined
28
32
  }
29
- const valued = new Set(['--out', '--title'])
30
- const args = argv.filter((a, i) => !a.startsWith('--') && !valued.has(argv[i - 1]))
31
- const noReview = argv.includes('--no-review')
33
+ const KINDS = ['.json', '.chalk', '.mmd', '.md']
32
34
  const asSvg = argv.includes('--svg')
33
35
  const ext = asSvg ? '.svg' : '.html'
34
- const KINDS = ['.json', '.chalk', '.mmd', '.md']
36
+ // --snapshots takes a folder only when one follows it (not another option, a recipe or the page).
37
+ const snapAt = argv.indexOf('--snapshots')
38
+ const snapFolder = snapAt >= 0 && argv[snapAt + 1] && !argv[snapAt + 1].startsWith('--') && ![...KINDS, ext].some((k) => argv[snapAt + 1].endsWith(k)) ? argv[snapAt + 1] : undefined
39
+ const valued = new Set(['--out', '--title', '--review-timeout'])
40
+ const args = argv.filter((a, i) => !a.startsWith('--') && !valued.has(argv[i - 1]) && !(snapFolder && i === snapAt + 1))
41
+ const noReview = argv.includes('--no-review')
42
+ const timeout = Number(option('--review-timeout') ?? 20)
35
43
  const sources = args.filter((a) => KINDS.some((k) => a.endsWith(k)))
36
44
  const outArg = option('--out') ?? args.find((a) => a.endsWith(ext))
37
- const usage = 'usage: node build.mjs <recipe.json> [out.html] [--no-review]\n node build.mjs <one.json> <two.json> … --out course.html [--title "…"] [--no-review]\n node build.mjs <recipe.json> [out.svg] --svg'
38
- if (!sources.length || args.some((a) => !KINDS.some((k) => a.endsWith(k)) && !a.endsWith(ext)) || (outArg && !outArg.endsWith(ext))) {
45
+ const usage = 'usage: node build.mjs <recipe.json> [out.html] [--no-review] [--snapshots [folder]] [--review-timeout <s>]\n node build.mjs <one.json> <two.json> … --out course.html [--title "…"] [--no-review]\n node build.mjs <recipe.json> [out.svg] --svg'
46
+ if (!sources.length || args.some((a) => !KINDS.some((k) => a.endsWith(k)) && !a.endsWith(ext)) || (outArg && !outArg.endsWith(ext)) || !(timeout > 0) || (asSvg && snapAt >= 0)) {
39
47
  console.error(usage)
40
48
  process.exit(2)
41
49
  }
@@ -44,26 +52,41 @@ const PROPS = [...PROP_NAMES, ...Object.keys(PROP_ALIASES)]
44
52
  // The Mermaid converter is large: loaded only for a Mermaid block.
45
53
  const mermaid = async (text) => (await import('./mermaid.mjs')).fromMermaid(text)
46
54
 
55
+ // The text being read and the recipe being checked, so a mistake can show how its line is written.
56
+ let reading = ''
57
+ let checking
58
+ const chalk = (text) => parseChalk((reading = text), PROPS)
59
+
47
60
  /** The recipes a file holds: one for .json, .chalk and .mmd; one per ```chalk-talk / ```mermaid block in a .md. */
48
61
  async function recipesOf(file) {
49
62
  const text = readFileSync(resolve(file), 'utf8')
50
63
  if (file.endsWith('.json')) return [JSON.parse(text)]
51
- if (file.endsWith('.chalk')) return [parseChalk(text, PROPS)]
64
+ if (file.endsWith('.chalk')) return [chalk(text)]
52
65
  if (file.endsWith('.mmd')) return [await mermaid(text)]
53
66
  const blocks = [...text.matchAll(/^```(chalk-talk|mermaid)[^\n]*\n([\s\S]*?)^```/gm)]
54
67
  if (!blocks.length) throw new Error('no ```chalk-talk or ```mermaid block in this file.')
55
68
  const out = []
56
- for (const [, kind, body] of blocks) out.push(kind === 'mermaid' ? await mermaid(body) : parseChalk(body, PROPS))
69
+ for (const [, kind, body] of blocks) out.push(kind === 'mermaid' ? await mermaid(body) : chalk(body))
57
70
  return out
58
71
  }
59
72
 
73
+ /** How the line or step a mistake names is written, as lines to print under it. */
74
+ function example(e) {
75
+ const line = e instanceof ChalkTextError
76
+ ? syntaxForLine(reading.split('\n')[e.line - 1] ?? '', PROPS)
77
+ : syntaxForStep(checking?.steps?.[Number(e.message.match(/^Step (\d+)/)?.[1]) - 1]?.type)
78
+ return line ? ` write it like:\n${line.split('\n').map((l) => ` ${l}`).join('\n')}` : ''
79
+ }
80
+
60
81
  const scenes = []
61
82
  for (const file of sources) {
62
83
  try {
63
- for (const recipe of await recipesOf(file)) scenes.push(validateScene(recipe))
84
+ for (const recipe of await recipesOf(file)) scenes.push(validateScene((checking = recipe)))
64
85
  } catch (e) {
65
86
  const where = sources.length > 1 || file.endsWith('.md') ? `${basename(file)}: ` : ''
66
87
  console.error(`✗ ${e instanceof ChalkTextError ? 'text' : 'contract'}: ${where}${e.message}`)
88
+ const hint = example(e)
89
+ if (hint) console.error(hint)
67
90
  process.exit(1)
68
91
  }
69
92
  }
@@ -86,24 +109,54 @@ scenes.forEach((scene, k) => {
86
109
  console.log(`✓ contract${who}${scene.steps.length} steps, ${chaptersFor(scene).length} chapters, ${values} values`)
87
110
  })
88
111
 
112
+ // One browser for everything the page has to do: the review, the snapshots and the SVGs, lesson by lesson.
113
+ const query = (k) => (course ? `lesson=${k + 1}` : '')
114
+ const jobs = []
115
+ scenes.forEach((_, k) => {
116
+ if (!noReview) jobs.push({ what: 'review', k, file: out, mode: 'review', query: query(k) })
117
+ if (snapAt >= 0) jobs.push({ what: 'snapshots', k, file: out, mode: 'snapshots', query: query(k) })
118
+ if (asSvg) jobs.push({ what: 'svg', k, file: out, mode: 'svg', query: query(k) })
119
+ })
120
+ const ran = jobs.length ? await runPages(jobs, { timeout }) : { results: [] }
121
+ const name = (exe) => basename(exe).replace(/\.exe$/i, '')
122
+ const result = (what, k) => ran.results?.[jobs.findIndex((j) => j.what === what && j.k === k)]
123
+
89
124
  let code = 0
90
125
  if (noReview) console.log('• review skipped (--no-review); the page checks itself when opened')
126
+ else if (!ran.results) console.log('• review no Chrome, Chromium or Edge here: the page checks itself when opened')
91
127
  else {
92
128
  for (let k = 0; k < scenes.length; k++) {
93
129
  const who = course ? ` (${k + 1})` : ''
94
- const r = runPage(out, 'review', course ? `lesson=${k + 1}` : '')
95
- if (!r) {
96
- console.log('• review no Chrome, Chromium or Edge here: the page checks itself when opened')
97
- break
98
- }
99
- if (r.error) console.log(`• review${who} could not run (${r.error}); the page checks itself when opened`)
100
- else if (!r.problems.length) console.log(`✓ review${who} ${r.combos} ${r.combos === 1 ? 'setting' : 'settings'}: nothing off the paper, no text over text`)
130
+ const r = result('review', k)
131
+ if (r.error) console.log(`✗ review${who} not checked: ${r.error}`)
132
+ else if (!r.problems.length) console.log(`✓ review${who} ${r.combos} ${r.combos === 1 ? 'setting' : 'settings'}: nothing off the paper, no text over text (${name(ran.used)})`)
101
133
  else {
102
134
  for (const p of r.problems) console.log(`! review${who} ${p}`)
103
135
  code = 3
104
136
  }
105
137
  }
106
138
  }
139
+
140
+ if (snapAt >= 0) {
141
+ const folder = resolve(snapFolder ?? join(dirname(out), `${slug}.snapshots`))
142
+ for (let k = 0; k < scenes.length; k++) {
143
+ const r = result('snapshots', k)
144
+ if (!r || r.error) {
145
+ console.error(`✗ snapshots ${r ? r.error : 'no Chrome, Chromium or Edge here to draw them (set CHROME_PATH)'}`)
146
+ code = 4
147
+ break
148
+ }
149
+ const lesson = slugOf(scenes[k], sources[0])
150
+ const into = course ? join(folder, lesson) : folder
151
+ mkdirSync(into, { recursive: true })
152
+ const png = (file, data) => writeFileSync(join(into, file), Buffer.from(data.replace(/^data:image\/png;base64,/, ''), 'base64'))
153
+ for (const s of r.shots) png(`${lesson}.ch${s.chapter}.${s.label}.png`, s.png)
154
+ const sheet = join(into, `${lesson}.contact-sheet.png`)
155
+ png(`${lesson}.contact-sheet.png`, r.sheet)
156
+ console.log(`→ ${sheet} (${r.chapters} ${r.chapters === 1 ? 'chapter' : 'chapters'} × ${r.settings} ${r.settings === 1 ? 'setting' : 'settings'})`)
157
+ }
158
+ }
159
+
107
160
  if (!asSvg) {
108
161
  console.log(`→ ${out} (${Math.round(statSync(out).size / 1024)} KB, opens offline${course ? `, ${scenes.length} lessons` : ''})`)
109
162
  process.exit(code)
@@ -112,7 +165,7 @@ if (!asSvg) {
112
165
  // --svg: each lesson drawn by the browser with its timing as CSS; one file, or one per lesson beside the first source.
113
166
  const where = outArg && !course ? undefined : dirname(resolve(outArg ?? sources[0]))
114
167
  for (let k = 0; k < scenes.length; k++) {
115
- const r = runPage(out, 'svg', course ? `lesson=${k + 1}` : '')
168
+ const r = result('svg', k)
116
169
  if (!r) {
117
170
  console.error('✗ svg no Chrome, Chromium or Edge here to draw it (set CHROME_PATH)')
118
171
  code = 4
@@ -1,4 +1,4 @@
1
- // GENERATED by tools/assets.mjs from the drawing engine v0.250.0. Do not edit.
1
+ // GENERATED by tools/assets.mjs from the drawing engine v0.252.0. Do not edit.
2
2
  var WORDS = ["and", "or", "not"];
3
3
 
4
4
  var PROP_NAMES = [