chalk-talk 0.11.0 → 0.13.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/README.md +1 -1
- package/assets/player.html +3 -3
- package/embed-mermaid.js +419 -419
- package/embed.js +5 -5
- package/markdown.js +1 -1
- package/mount.global.js +446 -446
- package/mount.js +1 -1
- package/package.json +1 -1
- package/player.js +4 -4
- package/scripts/browser.mjs +167 -33
- package/scripts/build.mjs +76 -23
- package/scripts/contract.mjs +276 -17
- package/scripts/mermaid.mjs +1 -1
- package/scripts/syntax.mjs +69 -0
package/scripts/browser.mjs
CHANGED
|
@@ -1,47 +1,181 @@
|
|
|
1
|
-
// A Chrome, Chromium or Edge without a window: opens a built page in one of its modes (#review, #
|
|
2
|
-
// back what the page wrote in <pre id="chalk-<mode>">.
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
/**
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
const
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
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
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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 [
|
|
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) :
|
|
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 =
|
|
95
|
-
if (
|
|
96
|
-
|
|
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 =
|
|
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
|