@lincyaw/compiler 0.0.0-stage → 1.0.0-rc.1

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.
@@ -0,0 +1,150 @@
1
+ import { environmentExecutor, errorMessage, irBlocks, isRecord } from '@lincyaw/kernel'
2
+ import type { CodeNode, EnvironmentContext, EnvironmentProvider, EnvironmentSpec, ExecuteConfig, Executor, Finding, IRDocument } from '@lincyaw/kernel'
3
+
4
+ const PREFIX = environmentExecutor('')
5
+
6
+ type At = (needle: string) => { file: string, line: number }
7
+
8
+ export function toExecuteConfig(value: unknown, report: (finding: Finding) => void, at: At): ExecuteConfig {
9
+ if (value === undefined) return {}
10
+ const fail = (message: string, needle = 'execute') => report({ checkId: 'presmium.execute', severity: 'error', message, source: at(needle) })
11
+ if (!isRecord(value)) {
12
+ fail('`execute` must be a mapping, e.g. `execute: { environments: { box: { kind: devcontainer } } }`')
13
+ return {}
14
+ }
15
+ const execute: ExecuteConfig = {}
16
+ if (value.env !== undefined) {
17
+ if (typeof value.env === 'string' && value.env.trim()) execute.env = value.env.trim()
18
+ else fail('`execute.env` must be the name of an environment')
19
+ }
20
+ if (value.rerun === 'environment') execute.rerun = 'environment'
21
+ if (value.environments !== undefined) {
22
+ if (!isRecord(value.environments)) {
23
+ fail('`execute.environments` must map names to environments, e.g. `box: { kind: devcontainer }`')
24
+ } else {
25
+ const environments: Record<string, EnvironmentSpec> = {}
26
+ for (const [name, spec] of Object.entries(value.environments)) {
27
+ if (name === 'browser' || !/^[\w-]+$/.test(name)) fail(`environment "${name}": a name is letters, digits, \`_\` and \`-\`, and not \`browser\`, which is reserved`, `${name}:`)
28
+ else if (!isRecord(spec) || typeof spec.kind !== 'string' || !spec.kind) fail(`environment "${name}" needs a \`kind\`, e.g. jupyter, devcontainer, wasi, webgpu`, `${name}:`)
29
+ else environments[name] = spec as EnvironmentSpec
30
+ }
31
+ execute.environments = environments
32
+ }
33
+ }
34
+ if (execute.env && execute.env !== 'browser' && !execute.environments?.[execute.env]) fail(`\`execute.env\`: no environment "${execute.env}" in \`execute.environments\``)
35
+ return execute
36
+ }
37
+
38
+ export function environmentProviders(execute: ExecuteConfig, providers: Map<string, EnvironmentProvider>, report: (finding: Finding) => void, at: At): Map<string, EnvironmentProvider> {
39
+ const found = new Map<string, EnvironmentProvider>()
40
+ for (const [name, spec] of Object.entries(execute.environments ?? {})) {
41
+ const provider = providers.get(spec.kind)
42
+ if (provider) found.set(name, provider)
43
+ else report({ checkId: 'presmium.execute', severity: 'error', message: `environment "${name}": no plugin in \`plugins\` provides environments of kind ${spec.kind}`, source: at(`${name}:`) })
44
+ }
45
+ return found
46
+ }
47
+
48
+ export async function environmentExecutors(
49
+ execute: ExecuteConfig,
50
+ providers: Map<string, EnvironmentProvider>,
51
+ context: EnvironmentContext,
52
+ made: WeakMap<EnvironmentProvider, Map<string, Executor>>,
53
+ report: (finding: Finding) => void,
54
+ at: At,
55
+ ): Promise<Executor[]> {
56
+ const executors: Executor[] = []
57
+ for (const [name, provider] of providers) {
58
+ const spec = execute.environments![name]!
59
+ const key = JSON.stringify([name, spec])
60
+ let instances = made.get(provider)
61
+ if (!instances) made.set(provider, instances = new Map())
62
+ let executor = instances.get(key)
63
+ if (!executor) {
64
+ try {
65
+ executor = await provider.create(name, spec, context)
66
+ } catch (error) {
67
+ report({ checkId: 'presmium.execute', severity: 'error', message: `environment "${name}": the ${spec.kind} provider failed: ${errorMessage(error)}`, source: at(`${name}:`) })
68
+ continue
69
+ }
70
+ if (executor.id !== environmentExecutor(name)) {
71
+ report({ checkId: 'presmium.execute', severity: 'error', message: `environment "${name}": the ${spec.kind} provider made the executor ${executor.id}, not ${environmentExecutor(name)}`, source: at(`${name}:`) })
72
+ continue
73
+ }
74
+ instances.set(key, executor)
75
+ }
76
+ executors.push(executor)
77
+ }
78
+ return executors
79
+ }
80
+
81
+ export function checkRuns(doc: IRDocument, execute: ExecuteConfig, report: (finding: Finding) => void): void {
82
+ const sessions = new Map<string, string>()
83
+ for (const section of doc.sections) {
84
+ for (const block of irBlocks(section.blocks)) {
85
+ const run = block.node.type === 'code' ? block.node.run : undefined
86
+ if (!run) continue
87
+ const fail = (message: string) => report({ checkId: 'presmium.execute', severity: 'error', message, source: { file: block.source.file, line: block.source.line } })
88
+ const name = run.executor?.startsWith(PREFIX) ? run.executor.slice(PREFIX.length) : undefined
89
+ if (name !== undefined && !execute.environments?.[name]) fail(`:env: ${name}: no such environment in \`execute.environments\` of the headmatter`)
90
+ if (!run.session) continue
91
+ const where = run.executor ?? 'browser'
92
+ const first = sessions.get(run.session)
93
+ if (first === undefined) sessions.set(run.session, where)
94
+ else if (first !== where) fail(`session "${run.session}" runs in ${describe(first)} and here in ${describe(where)}; the blocks of a session share one environment`)
95
+ }
96
+ }
97
+ }
98
+
99
+ export function checkLanguages(doc: IRDocument, execute: ExecuteConfig, executors: readonly Executor[], report: (finding: Finding) => void): Set<CodeNode> {
100
+ const reported = new Set<CodeNode>()
101
+ for (const section of doc.sections) {
102
+ for (const block of irBlocks(section.blocks)) {
103
+ const run = block.node.type === 'code' ? block.node.run : undefined
104
+ const name = run?.executor?.startsWith(PREFIX) ? run.executor.slice(PREFIX.length) : undefined
105
+ const spec = name === undefined ? undefined : execute.environments?.[name]
106
+ const executor = spec?.languages?.length ? executors.find(e => e.id === run!.executor) : undefined
107
+ if (!run || !spec?.languages || !executor || executor.supports(run.lang)) continue
108
+ reported.add(block.node as CodeNode)
109
+ const runs = `runs ${spec.languages.join(', ')}, not ${run.lang}`
110
+ const fallback = execute.env === name ? ` (blocks that name none run in "${name}", the headmatter's execute.env)` : ''
111
+ report({
112
+ checkId: 'presmium.execute',
113
+ severity: 'error',
114
+ message: `environment "${name}" (${spec.kind}) ${runs}`,
115
+ hint: `name another environment with :env:${fallback}, or add ${run.lang} to the languages of "${name}"`,
116
+ source: { file: block.source.file, line: block.source.line },
117
+ })
118
+ }
119
+ }
120
+ return reported
121
+ }
122
+
123
+ function describe(executor: string): string {
124
+ return executor.startsWith(PREFIX) ? `environment ${executor.slice(PREFIX.length)}` : executor === 'browser' ? 'the default environment' : `executor ${executor}`
125
+ }
126
+
127
+ const CREDENTIALS = [
128
+ /^sk-[\w-]{16,}$/,
129
+ /^(?:gh[pousr]|github_pat)_\w{20,}$/,
130
+ /^xox[abposr]-[\w-]{10,}$/,
131
+ /^AKIA[0-9A-Z]{16}$/,
132
+ /^AIza[\w-]{30,}$/,
133
+ /^eyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]{10,}$/,
134
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
135
+ ]
136
+ const CREDENTIAL_KEY = /(?:^|[_-])(?:token|secret|password|passwd|api[_-]?key|access[_-]?key|private[_-]?key)$/i
137
+
138
+ export function checkSecrets(headmatter: Record<string, unknown>, report: (finding: Finding) => void, at: At): void {
139
+ const visit = (value: unknown, key: string, path: string) => {
140
+ if (typeof value === 'string') {
141
+ const looks = CREDENTIALS.some(pattern => pattern.test(value.trim())) || (CREDENTIAL_KEY.test(key) && value.trim().length >= 12 && !/\s/.test(value.trim()))
142
+ if (looks) report({ checkId: 'presmium.secrets', severity: 'warning', message: `${path} looks like a credential; credentials come from environment variables or a login (e.g. PRESMIUM_ENV_<NAME>_TOKEN), never from the deck, which is published`, source: at(`${key}:`) })
143
+ } else if (Array.isArray(value)) {
144
+ value.forEach((item, i) => visit(item, key, `${path}[${i}]`))
145
+ } else if (isRecord(value)) {
146
+ for (const [k, v] of Object.entries(value)) visit(v, k, path ? `${path}.${k}` : k)
147
+ }
148
+ }
149
+ visit(headmatter, '', '')
150
+ }
package/src/execute.ts ADDED
@@ -0,0 +1,381 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { CREDENTIAL_VARIABLE, environmentExecutor, errorMessage, irBlocks, isRecord } from '@lincyaw/kernel'
6
+ import type { CodeNode, Executor, Finding, IRBlock, IRDocument, RecordedRun, RunCache, RunContext, RunOutput, RunRequest, RunResult } from '@lincyaw/kernel'
7
+ import { resourceEnvironment } from '@lincyaw/kernel/node'
8
+
9
+ export const FREEZE_DIR = '.presmium/freeze'
10
+ const BLOBS = 'blobs'
11
+ const BLOB_LENGTH = 4096
12
+ const BLOB_REF = 'presmium/blob'
13
+
14
+ interface FreezeRecord {
15
+ input?: string
16
+ entry?: string
17
+ lang?: string
18
+ code?: string
19
+ environment?: string
20
+ revision?: string
21
+ run: RecordedRun
22
+ }
23
+
24
+ export class FreezeCache implements RunCache {
25
+ readonly dir: string
26
+ readonly entry: string
27
+ readonly used = new Set<string>()
28
+ #records?: Map<string, FreezeRecord>
29
+
30
+ constructor(dir: string, entry: string) {
31
+ this.dir = dir
32
+ this.entry = entry
33
+ }
34
+
35
+ get(key: string): RecordedRun | undefined {
36
+ const record = this.#read(`${key}.json`)
37
+ return record && this.#restore(record.run)
38
+ }
39
+
40
+ set(key: string, run: RecordedRun): void {
41
+ this.#write(key, { run })
42
+ }
43
+
44
+ /** Records a run under its cache key and drops the older recordings of the same input. */
45
+ recordRun(key: string, input: string, request: RunRequest, run: RecordedRun, environment: string, revision = ''): void {
46
+ for (const [file, record] of this.#all()) {
47
+ if (record.input !== input || file === `${key}.json`) continue
48
+ rmSync(join(this.dir, file), { force: true })
49
+ this.#records?.delete(file)
50
+ }
51
+ this.#write(key, { input, entry: this.entry, lang: request.lang, code: request.code, environment, ...revision ? { revision } : {}, run })
52
+ }
53
+
54
+ /** The recording of the input, made in whichever environment, whatever its cache key. */
55
+ findByInput(input: string): { run: RecordedRun, environment?: string, revision: string } | undefined {
56
+ for (const record of this.#all().values()) {
57
+ if (record.input !== input) continue
58
+ const run = this.#restore(record.run)
59
+ if (run) return { run, environment: record.environment, revision: record.revision ?? '' }
60
+ }
61
+ return undefined
62
+ }
63
+
64
+ prune(): number {
65
+ let removed = 0
66
+ for (const [file, record] of this.#all()) {
67
+ if (record.entry !== this.entry || !record.input || this.used.has(record.input)) continue
68
+ rmSync(join(this.dir, file), { force: true })
69
+ this.#records?.delete(file)
70
+ removed++
71
+ }
72
+ const referred = new Set<string>()
73
+ for (const record of this.#all().values()) blobRefs(record.run, referred)
74
+ let blobs: string[] = []
75
+ try {
76
+ blobs = readdirSync(join(this.dir, BLOBS))
77
+ } catch {}
78
+ for (const blob of blobs.filter(name => !referred.has(name))) rmSync(join(this.dir, BLOBS, blob), { force: true })
79
+ return removed
80
+ }
81
+
82
+ #all(): Map<string, FreezeRecord> {
83
+ if (!this.#records) {
84
+ this.#records = new Map()
85
+ let files: string[] = []
86
+ try {
87
+ files = readdirSync(this.dir).filter(f => f.endsWith('.json')).sort()
88
+ } catch {
89
+ }
90
+ for (const file of files) {
91
+ const record = this.#read(file)
92
+ if (record) this.#records.set(file, record)
93
+ }
94
+ }
95
+ return this.#records
96
+ }
97
+
98
+ #text(file: string): string | undefined {
99
+ try {
100
+ return readFileSync(file, 'utf8')
101
+ } catch {
102
+ return undefined
103
+ }
104
+ }
105
+
106
+ #read(file: string): FreezeRecord | undefined {
107
+ const text = this.#text(join(this.dir, file))
108
+ if (text === undefined) return undefined
109
+ try {
110
+ const data: unknown = JSON.parse(text)
111
+ return isRecord(data) && isRecord(data.run) ? data as unknown as FreezeRecord : undefined
112
+ } catch {
113
+ return undefined
114
+ }
115
+ }
116
+
117
+ #write(key: string, record: FreezeRecord): void {
118
+ mkdirSync(this.dir, { recursive: true })
119
+ const stored = { ...record, run: this.#store(record.run) }
120
+ writeFileSync(join(this.dir, `${key}.json`), `${JSON.stringify(stored, null, 2)}\n`)
121
+ this.#records?.set(`${key}.json`, stored)
122
+ }
123
+
124
+ #store(run: RecordedRun): RecordedRun {
125
+ if (!run.outputs) return run
126
+ return { ...run, outputs: run.outputs.map(output => mapData(output, (value) => {
127
+ const text = Array.isArray(value) && value.every(v => typeof v === 'string') ? value.join('') : value
128
+ if (typeof text !== 'string' || text.length <= BLOB_LENGTH) return value
129
+ const name = createHash('sha256').update(text).digest('hex')
130
+ const file = join(this.dir, BLOBS, name)
131
+ if (!existsSync(file)) {
132
+ mkdirSync(join(this.dir, BLOBS), { recursive: true })
133
+ writeFileSync(file, text)
134
+ }
135
+ return { [BLOB_REF]: name }
136
+ })) }
137
+ }
138
+
139
+ #restore(run: RecordedRun): RecordedRun | undefined {
140
+ if (!run.outputs) return run
141
+ let complete = true
142
+ const outputs = run.outputs.map(output => mapData(output, (value) => {
143
+ if (!isRecord(value) || typeof value[BLOB_REF] !== 'string') return value
144
+ const text = this.#text(join(this.dir, BLOBS, value[BLOB_REF].replace(/[^0-9a-f]/g, '')))
145
+ if (text === undefined) complete = false
146
+ return text
147
+ }))
148
+ return complete ? { ...run, outputs } : undefined
149
+ }
150
+ }
151
+
152
+ function mapData(output: RunOutput, map: (value: unknown) => unknown): RunOutput {
153
+ if (output.output_type !== 'display_data' && output.output_type !== 'execute_result') return output
154
+ const data: Record<string, unknown> = {}
155
+ for (const [mime, value] of Object.entries(output.data)) {
156
+ const mapped = map(value)
157
+ if (mapped !== undefined) data[mime] = mapped
158
+ }
159
+ return { ...output, data }
160
+ }
161
+
162
+ function blobRefs(run: RecordedRun, into: Set<string>): void {
163
+ for (const output of run.outputs ?? []) {
164
+ mapData(output, (value) => {
165
+ if (isRecord(value) && typeof value[BLOB_REF] === 'string') into.add(value[BLOB_REF])
166
+ return value
167
+ })
168
+ }
169
+ }
170
+
171
+ export interface RunEnvironmentOptions {
172
+ env?: 'local' | 'serve'
173
+ }
174
+
175
+ export interface RunStats {
176
+ executed: number
177
+ cached: number
178
+ sandbox?: string
179
+ }
180
+
181
+ export interface ExecutePassOptions extends RunEnvironmentOptions {
182
+ signal?: AbortSignal
183
+ rerun?: boolean
184
+ context: RunContext
185
+ unsupported?: ReadonlySet<CodeNode>
186
+ }
187
+
188
+ const SERVE_ALLOWED = /^(?:PATH|LANG|LC_\w+|TZ|TMPDIR)$/
189
+
190
+ export function runEnvironment(rules: 'local' | 'serve', passEnv: readonly string[], workspaceRoot: string, source: NodeJS.ProcessEnv = process.env): Record<string, string> {
191
+ const env: Record<string, string> = {}
192
+ for (const [name, value] of Object.entries(source)) {
193
+ if (value === undefined) continue
194
+ const kept = rules === 'serve' ? SERVE_ALLOWED.test(name) : !/^PRESMIUM_/i.test(name) && !CREDENTIAL_VARIABLE.test(name)
195
+ if (kept) env[name] = value
196
+ }
197
+ if (rules === 'serve') {
198
+ env.HOME = runHome(workspaceRoot, source)
199
+ env.PYTHONNOUSERSITE = '1'
200
+ }
201
+ for (const name of passEnv) {
202
+ const value = source[name]
203
+ if (value !== undefined) env[name] = value
204
+ }
205
+ if (source.PRESMIUM_MAX_THREADS !== undefined) env.PRESMIUM_MAX_THREADS = source.PRESMIUM_MAX_THREADS
206
+ return resourceEnvironment(env)
207
+ }
208
+
209
+ function runHome(workspaceRoot: string, source: NodeJS.ProcessEnv): string {
210
+ const state = source.XDG_STATE_HOME || join(homedir(), '.local', 'state')
211
+ const home = join(state, 'presmium', 'run-home', createHash('sha256').update(workspaceRoot).digest('hex').slice(0, 16))
212
+ mkdirSync(home, { recursive: true, mode: 0o700 })
213
+ const stat = lstatSync(home)
214
+ const uid = process.getuid?.()
215
+ if (!stat.isDirectory() || (uid !== undefined && stat.uid !== uid) || (stat.mode & 0o077) !== 0) {
216
+ throw new Error(`the run HOME ${home} is not a private directory of this user`)
217
+ }
218
+ return home
219
+ }
220
+
221
+ interface Item {
222
+ node: CodeNode
223
+ block: IRBlock
224
+ request: RunRequest
225
+ input: string
226
+ key?: string
227
+ }
228
+
229
+ interface Unit {
230
+ session?: string
231
+ items: Item[]
232
+ executor?: Executor
233
+ environment: string
234
+ revision?: string
235
+ }
236
+
237
+ type Report = (severity: Finding['severity'], message: string, block: IRBlock) => void
238
+
239
+ export async function executePass(doc: IRDocument, executors: Executor[], cache: FreezeCache, report: (finding: Finding) => void, options: ExecutePassOptions): Promise<RunStats> {
240
+ const stats: RunStats = { executed: 0, cached: 0, ...options.context.sandbox ? { sandbox: options.context.sandbox.kind } : {} }
241
+ const say: Report = (severity, message, block) =>
242
+ report({ checkId: 'presmium.execute', severity, message, source: { file: block.source.file, line: block.source.line } })
243
+ const units = collect(doc, cache)
244
+
245
+ const pending: Unit[] = []
246
+ for (const unit of units) {
247
+ const run = unit.items[0]!.node.run!
248
+ unit.executor = run.executor
249
+ ? executors.find(e => e.id === run.executor && e.supports(run.lang))
250
+ : executors.find(e => !e.id.startsWith(environmentExecutor('')) && e.supports(run.lang))
251
+ if (!unit.executor) {
252
+ for (const item of unit.items) fromRecording(item, cache, stats, say, run.executor, undefined, options.unsupported?.has(item.node))
253
+ continue
254
+ }
255
+ try {
256
+ unit.environment = await unit.executor.fingerprint?.(run.lang) ?? ''
257
+ unit.revision = await unit.executor.revision?.(run.lang) ?? ''
258
+ } catch (error) {
259
+ for (const item of unit.items) fromRecording(item, cache, stats, say, run.executor, errorMessage(error))
260
+ continue
261
+ }
262
+ let previous = unit.session
263
+ for (const item of unit.items) {
264
+ const made = { executor: unit.executor.id, environment: unit.environment, ...unit.revision ? { revision: unit.revision } : {} }
265
+ item.key = hash(unit.session ? { previous, input: item.input, ...made } : { input: item.input, ...made })
266
+ previous = item.key
267
+ }
268
+ const cached = unit.items.map(item => cache.get(item.key!))
269
+ if (cached.every(Boolean)) {
270
+ unit.items.forEach((item, i) => item.node.outputs = [cached[i]!])
271
+ stats.cached += unit.items.length
272
+ continue
273
+ }
274
+ const others = options.rerun ? [] : unit.items.map(item => cache.findByInput(item.input)).map(other => (other?.revision === unit.revision ? other : undefined))
275
+ if (others.length > 0 && others.every(Boolean)) {
276
+ unit.items.forEach((item, i) => {
277
+ const other = others[i]!
278
+ item.node.outputs = [other.run]
279
+ const made = other.environment === undefined ? 'another environment' : `"${other.environment || 'no fingerprint'}"`
280
+ say('warning', `block ${item.block.id}: its recorded output was made with ${made}, this build has "${unit.environment || 'no fingerprint'}"; the recording is reused (\`execute: { rerun: environment }\` in the headmatter runs it again)`, item.block)
281
+ })
282
+ stats.cached += unit.items.length
283
+ continue
284
+ }
285
+ pending.push(unit)
286
+ }
287
+
288
+ const sessions: [Executor, string][] = []
289
+ const compile = `compile:${randomUUID()}`
290
+ try {
291
+ for (const unit of pending) {
292
+ options.signal?.throwIfAborted()
293
+ const executor = unit.executor!
294
+ const session = unit.session && `${compile}:${unit.session}`
295
+ const run = (request: RunRequest) => executor.run(session ? { ...request, session } : request, options.signal, options.context)
296
+ try {
297
+ if (session) {
298
+ await executor.endSession?.(session)
299
+ sessions.push([executor, session])
300
+ // Submit a logical session together: executors batch these into one runtime.
301
+ const results = await Promise.all(unit.items.map(item => run(item.request)))
302
+ options.signal?.throwIfAborted()
303
+ unit.items.forEach((item, i) => record(item, results[i]!, unit, cache, stats, say))
304
+ } else {
305
+ const item = unit.items[0]!
306
+ const result = await run(item.request)
307
+ options.signal?.throwIfAborted()
308
+ record(item, result, unit, cache, stats, say)
309
+ }
310
+ } catch (error) {
311
+ options.signal?.throwIfAborted()
312
+ for (const item of unit.items) fromRecording(item, cache, stats, say, executor.id, errorMessage(error))
313
+ }
314
+ }
315
+ } finally {
316
+ for (const [executor, session] of sessions) await executor.endSession?.(session)?.catch(() => {})
317
+ }
318
+ return stats
319
+ }
320
+
321
+ function collect(doc: IRDocument, cache: FreezeCache): Unit[] {
322
+ const units: Unit[] = []
323
+ const sessions = new Map<string, Unit>()
324
+ for (const section of doc.sections) {
325
+ for (const block of irBlocks(section.blocks)) {
326
+ const node = block.node
327
+ if (node.type !== 'code' || !node.run || node.run.when === 'live') continue
328
+ const run = node.run
329
+ const request: RunRequest = { lang: run.lang, code: node.value }
330
+ if (run.args) request.args = run.args
331
+ if (run.stdin !== undefined) request.stdin = run.stdin
332
+ if (run.timeoutMs !== undefined) request.timeoutMs = run.timeoutMs
333
+ const fields = { lang: request.lang, code: request.code, args: request.args, stdin: request.stdin, executor: run.executor }
334
+ if (!run.session) {
335
+ const input = hash(fields)
336
+ cache.used.add(input)
337
+ units.push({ items: [{ node, block, request, input }], environment: '' })
338
+ continue
339
+ }
340
+ const session = `${cache.entry}:${run.session}`
341
+ request.session = session
342
+ let unit = sessions.get(session)
343
+ if (!unit) {
344
+ unit = { session, items: [], environment: '' }
345
+ sessions.set(session, unit)
346
+ units.push(unit)
347
+ }
348
+ const input = hash({ previous: unit.items.at(-1)?.input ?? session, ...fields })
349
+ cache.used.add(input)
350
+ unit.items.push({ node, block, request, input })
351
+ }
352
+ }
353
+ return units
354
+ }
355
+
356
+ function record(item: Item, result: RunResult, unit: Unit, cache: FreezeCache, stats: RunStats, say: Report): void {
357
+ const recorded: RecordedRun = { ...result, cacheKey: item.key! }
358
+ item.node.outputs = [recorded]
359
+ stats.executed++
360
+ if (!result.timedOut) cache.recordRun(item.key!, item.input, item.request, recorded, unit.environment, unit.revision)
361
+ if (!result.ok) say('warning', `block ${item.block.id} exited with ${result.timedOut ? 'a timeout' : result.exitCode === null ? `no exit code: ${result.stderr.trim().split('\n')[0]}` : `code ${result.exitCode}`}`, item.block)
362
+ }
363
+
364
+ function fromRecording(item: Item, cache: FreezeCache, stats: RunStats, say: Report, executor: string | undefined, failure?: string, reported = false): void {
365
+ const recorded = cache.findByInput(item.input)?.run
366
+ const which = executor ?? item.request.lang
367
+ if (recorded) {
368
+ item.node.outputs = [recorded]
369
+ stats.cached++
370
+ if (failure) say('warning', `executor ${which} is unavailable (${failure}); block ${item.block.id} shows its recorded output`, item.block)
371
+ return
372
+ }
373
+ if (reported) return
374
+ if (failure) return say('error', `executor ${which} failed on block ${item.block.id}: ${failure}`, item.block)
375
+ const environment = executor?.startsWith(environmentExecutor(''))
376
+ say(environment ? 'error' : 'warning', `no executor for ${which} and no recorded output in ${FREEZE_DIR}; block ${item.block.id} has no output`, item.block)
377
+ }
378
+
379
+ function hash(value: unknown): string {
380
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex')
381
+ }
package/src/export.ts ADDED
@@ -0,0 +1,49 @@
1
+ import { rm } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import type { ExportOutput } from '@lincyaw/kernel'
4
+ import { build } from './build.ts'
5
+ import type { Compilation } from './compiler.ts'
6
+ import { documentExporters } from './renderers.ts'
7
+
8
+ // Below the export's directory: the builds its exporter asks for, removed once it is done.
9
+ const BUILDS = '.build'
10
+
11
+ export interface ExportOptions {
12
+ /** An empty directory for the files. */
13
+ outDir: string
14
+ /** What the one who asked chose (`ExportContext.options`). */
15
+ options?: Record<string, unknown>
16
+ signal?: AbortSignal
17
+ progress?(phase: string, done?: number, total?: number): void
18
+ log?(line: string): void
19
+ }
20
+
21
+ /** Makes the document's export `id` (a plugin's exporter: the slides as a PDF, the lecture as a video) into `outDir`. */
22
+ export async function exportDocument(compilation: Compilation, id: string, options: ExportOptions): Promise<ExportOutput> {
23
+ const found = (await documentExporters(compilation)).find(({ exporter }) => exporter.id === id)
24
+ if (!found) throw new Error(`no plugin of the document exports ${id}`)
25
+ const builds = join(options.outDir, BUILDS)
26
+ try {
27
+ return await found.exporter.run({
28
+ deckDir: compilation.deckDir,
29
+ workspaceRoot: compilation.workspaceRoot,
30
+ source: compilation.doc.source,
31
+ config: found.config,
32
+ outDir: options.outDir,
33
+ signal: options.signal ?? new AbortController().signal,
34
+ progress: options.progress ?? (() => {}),
35
+ log: options.log ?? (() => {}),
36
+ options: options.options ?? {},
37
+ async build(renderer, config = {}) {
38
+ const { renderers } = compilation.doc.config
39
+ const configured = { ...compilation, doc: { ...compilation.doc, config: { ...compilation.doc.config, renderers: { ...renderers, [renderer]: { ...renderers[renderer], ...config } } } } }
40
+ const report = await build(configured, { outDir: join(builds, renderer), to: [renderer], base: '/', ...options.log ? { log: options.log } : {} })
41
+ const own = report.outputs.find(output => output.renderer === renderer)
42
+ if (!own) throw new Error(report.findings.filter(f => f.severity === 'error').map(f => f.message).join('; ') || `the ${renderer} renderer made nothing`)
43
+ return own.outDir
44
+ },
45
+ })
46
+ } finally {
47
+ await rm(builds, { recursive: true, force: true })
48
+ }
49
+ }
@@ -0,0 +1,39 @@
1
+ import { docBlocks, nodeMdast } from '@lincyaw/kernel'
2
+ import type { IRDocument, MdastNode } from '@lincyaw/kernel'
3
+ import { unique } from './sections.ts'
4
+
5
+ const FOOTNOTE = new Set(['footnoteReference', 'footnoteDefinition'])
6
+
7
+ export function numberFootnotes(doc: IRDocument, files: readonly string[]): void {
8
+ const byFile = new Map<string, MdastNode[]>(files.map(file => [file, []]))
9
+ const collect = (node: MdastNode, into: MdastNode[]) => {
10
+ if (FOOTNOTE.has(node.type) && typeof node.identifier === 'string') into.push(node)
11
+ node.children?.forEach(child => collect(child, into))
12
+ }
13
+ for (const { block } of docBlocks(doc)) {
14
+ const into = byFile.get(block.source.file) ?? byFile.set(block.source.file, []).get(block.source.file)!
15
+ for (const tree of nodeMdast(block.node)) collect(tree, into)
16
+ }
17
+
18
+ const used = new Set<string>()
19
+ for (const nodes of byFile.values()) {
20
+ const rename = new Map<string, string>()
21
+ for (const node of nodes) {
22
+ const id = node.identifier as string
23
+ if (node.type !== 'footnoteDefinition' || rename.has(id)) continue
24
+ rename.set(id, unique(id, used))
25
+ }
26
+ const numbers = new Map<string, number>()
27
+ for (const type of ['footnoteReference', 'footnoteDefinition']) {
28
+ for (const node of nodes) {
29
+ const id = node.identifier as string
30
+ if (node.type === type && !numbers.has(id)) numbers.set(id, numbers.size + 1)
31
+ }
32
+ }
33
+ for (const node of nodes) {
34
+ const id = node.identifier as string
35
+ node.enumerator = String(numbers.get(id))
36
+ node.identifier = rename.get(id) ?? id
37
+ }
38
+ }
39
+ }