@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,600 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { existsSync, statSync } from 'node:fs'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { basename, dirname, extname, join, relative, resolve } from 'node:path'
5
+ import { ANCHOR_LOCK_PATH, AnchorLock, DEFAULT_LANG, IR_VERSION, PROCESS_SANDBOX, createAnchorResolverRegistry, docBlocks, environmentExecutor, errorMessage, failedRun, fnv1a64, isRecord, productionAssetIds, validateDocument } from '@lincyaw/kernel'
6
+ import { operatorSpeechSecrets, productionAssetFile } from '@lincyaw/kernel/node'
7
+ import type { AnchorResolverRegistry, CanonicalSource, CodeNode, EnvironmentContext, EnvironmentProvider, EnvironmentSpec, ExecuteConfig, Executor, Finding, IRDocument, IRPass, IRSection, NodeServiceContext, PassContext, RunContext, RunRequest, RunResult, ServiceToken, SourceCapabilities, SourceReader, TeachingDocument } from '@lincyaw/kernel'
8
+ import { audienceDocument, emptyReferences, filterForAudience, scanOutputs, withheld } from './audience.ts'
9
+ import type { AudienceMark, Withheld } from './audience.ts'
10
+ import { runAllChecks } from './compilation-actions.ts'
11
+ import { enqueueCompile } from './compile-queue.ts'
12
+ import { checkLanguages, checkRuns, checkSecrets, environmentExecutors, environmentProviders, toExecuteConfig } from './environments.ts'
13
+ import { executePass, FREEZE_DIR, FreezeCache, runEnvironment } from './execute.ts'
14
+ import type { RunEnvironmentOptions, RunStats } from './execute.ts'
15
+ import { numberFootnotes } from './footnotes.ts'
16
+ import { checkForbidden } from './forbid.ts'
17
+ import { CompileInputs } from './inputs.ts'
18
+ import type { CommittedFiles, GitRead } from './inputs.ts'
19
+ import { checkLinks } from './links.ts'
20
+ import { pluginLogger } from './logging.ts'
21
+ import { NodeServices } from './node-services.ts'
22
+ import { lowerDocument } from './document-lowering.ts'
23
+ import { DECK_ENTRY } from './paths.ts'
24
+ import { currentPluginName, DECK_PLUGIN, directoryComponents, isRetiredPlugin, listedPlugins, loadOperatorPlugins, loadPlugins, pluginId } from './plugins.ts'
25
+ import type { LoadedPlugin, PluginProblem, PluginSources } from './plugins.ts'
26
+ import { resolveReferences } from './references.ts'
27
+ import { applyTranslations, translationFields } from './translations.ts'
28
+ import { validateIR } from './validate.ts'
29
+ import { createSourceReader, findWorkspaceRoot } from './workspace.ts'
30
+ import { operatorDir, operatorPlugins, workspaceSettings } from './workspace-settings.ts'
31
+ import type { OperatorSettings } from './workspace-settings.ts'
32
+
33
+ export { DECK_ENTRY }
34
+
35
+ const BUILTIN_RENDERERS = ['web', 'slides', 'notebook']
36
+
37
+
38
+ export interface CompilerOptions {
39
+ priority?: 'interactive' | 'background'
40
+ settings?: OperatorSettings
41
+ services?: [ServiceToken<unknown>, unknown][]
42
+ workspaceRoot?: string
43
+ bundled?: string
44
+ operatorDir?: string
45
+ secret?: (name: string) => string | undefined
46
+ execution?: RunEnvironmentOptions
47
+ audience?: string
48
+ headmatter?: Record<string, unknown>
49
+ deckDir?: string
50
+ }
51
+
52
+ export interface AudienceCompilation {
53
+ name: string
54
+ withheld: Withheld[]
55
+ scan(dir: string, paths?: string[], options?: { quote?: boolean }): Promise<Finding[]>
56
+ }
57
+
58
+ export interface DeclaredEnvironment {
59
+ name: string
60
+ spec: EnvironmentSpec
61
+ provider: EnvironmentProvider
62
+ context: EnvironmentContext
63
+ }
64
+
65
+ export interface Compilation {
66
+ doc: IRDocument
67
+ sourceCapabilities?: SourceCapabilities
68
+ options: CompilerOptions
69
+ /** The canonical JSON authoring entry read by this compilation. */
70
+ sourceFiles: string[]
71
+ /** Fingerprints of exact authoring text, including BOM. Keys are absolute source paths. */
72
+ sourceFingerprints?: Record<string, string>
73
+ /** The other files the compilation read, and the files it read at a git ref. */
74
+ inputs: { files: string[], gitReads: GitRead[] }
75
+ runStats: RunStats
76
+ deckDir: string
77
+ entry: string
78
+ /** The entry's headmatter, merged with `CompilerOptions.headmatter`. */
79
+ headmatter: Record<string, unknown>
80
+ workspaceRoot: string
81
+ src: SourceReader
82
+ resolvers: AnchorResolverRegistry
83
+ lock: AnchorLock
84
+ plugins: LoadedPlugin[]
85
+ components: Map<string, string>
86
+ service<T>(token: ServiceToken<T>): T | undefined
87
+ settings: OperatorSettings
88
+ operatorDir?: string
89
+ pluginSources: PluginSources
90
+ execute: ExecuteConfig
91
+ environments: DeclaredEnvironment[]
92
+ executors: Executor[]
93
+ runContext: RunContext
94
+ run(node: CodeNode, signal?: AbortSignal): Promise<RunResult>
95
+ audience?: AudienceCompilation
96
+ /** The document for one audience, with the blocks that leak withheld content removed. */
97
+ audienceDocument(name: string): IRDocument
98
+ audienceCompilation(name: string): AudienceCompilation
99
+ publishChecks(options?: { externalLinks?: boolean }): Promise<Finding[]>
100
+ check(options?: { externalLinks?: boolean }): Promise<Finding[]>
101
+ }
102
+
103
+ export interface CompileOptions {
104
+ pruneCache?: boolean
105
+ signal?: AbortSignal
106
+ }
107
+
108
+ /** Plugin services assembled from the exact authoring source, without running compilation passes. */
109
+ export interface DocumentNodeServices {
110
+ source: CanonicalSource
111
+ findings: Finding[]
112
+ service<T>(token: ServiceToken<T>): T | undefined
113
+ }
114
+
115
+ export function resolveEntry(deck: string): string {
116
+ const path = resolve(deck)
117
+ const entry = existsSync(path) && statSync(path).isDirectory() ? join(path, DECK_ENTRY) : path
118
+ if (basename(entry) !== DECK_ENTRY) throw new Error(`The document entry is ${DECK_ENTRY}; migrate the old source before compiling.`)
119
+ return entry
120
+ }
121
+
122
+ interface Workspace {
123
+ root: string
124
+ src: SourceReader
125
+ settings: OperatorSettings
126
+ problem?: string
127
+ sources: PluginSources
128
+ operatorDir?: string
129
+ operatorProblem?: string
130
+ }
131
+
132
+ interface Plugins {
133
+ key: string
134
+ plugins: LoadedPlugin[]
135
+ problems: PluginProblem[]
136
+ }
137
+
138
+ type Report = (finding: Finding) => void
139
+
140
+ type HeadmatterLine = (needle: string) => { file: string, line: number }
141
+
142
+ /** What the plugins of one compilation contribute to it. */
143
+ interface Contributions {
144
+ resolvers: AnchorResolverRegistry
145
+ executors: Executor[]
146
+ components: Map<string, string>
147
+ environments: Map<string, EnvironmentProvider>
148
+ environmentContext: EnvironmentContext
149
+ }
150
+
151
+ /** The state of one compilation that its steps share. */
152
+ interface CompileRun {
153
+ workspace: Workspace
154
+ headmatter: Record<string, unknown>
155
+ lang: string
156
+ plugins: LoadedPlugin[]
157
+ configOf(plugin: LoadedPlugin): Record<string, unknown>
158
+ service<T>(token: ServiceToken<T>): T | undefined
159
+ execute: ExecuteConfig
160
+ inputs: CompileInputs
161
+ findings: Finding[]
162
+ report: Report
163
+ at: HeadmatterLine
164
+ signal?: AbortSignal
165
+ document: TeachingDocument
166
+ rawSource: string
167
+ }
168
+
169
+ interface PassResults {
170
+ lock: AnchorLock
171
+ runStats: RunStats
172
+ runContext: RunContext
173
+ cache: FreezeCache
174
+ filled: WeakSet<object>
175
+ invalid: Finding[]
176
+ }
177
+
178
+ export class Compiler {
179
+ readonly entry: string
180
+ readonly deckDir: string
181
+ readonly options: CompilerOptions
182
+ #workspace?: Workspace
183
+ readonly #committed: CommittedFiles = new Map()
184
+ #plugins?: Plugins
185
+ readonly #environments = new WeakMap<EnvironmentProvider, Map<string, Executor>>()
186
+
187
+ constructor(deck: string, options: CompilerOptions = {}) {
188
+ this.entry = resolveEntry(deck)
189
+ this.deckDir = options.deckDir ? resolve(options.deckDir) : dirname(this.entry)
190
+ this.options = options
191
+ }
192
+
193
+ compile(options: CompileOptions = {}): Promise<Compilation> {
194
+ return enqueueCompile(() => this.#compile(options), this.options.priority === 'interactive')
195
+ }
196
+
197
+ /** Authoring services must also work when a scene or a compilation pass cannot be built. */
198
+ async nodeServices(options: Pick<CompileOptions, 'signal'> = {}): Promise<DocumentNodeServices> {
199
+ const run = await this.#start(options)
200
+ options.signal?.throwIfAborted()
201
+ return {
202
+ source: { documentId: run.document.id, version: createHash('sha256').update(run.rawSource).digest('hex').slice(0, 20), fingerprint: fnv1a64(run.rawSource) },
203
+ findings: [...run.findings],
204
+ service: run.service,
205
+ }
206
+ }
207
+
208
+ async #compile(options: CompileOptions): Promise<Compilation> {
209
+ const run = await this.#start(options)
210
+ const { headmatter, execute, findings, report } = run
211
+ const contributions = await this.#collectContributions(run)
212
+ const { sections, marks, sourceFiles, sourceFingerprints } = await this.#compileSections(run, contributions)
213
+
214
+ const { title: _title, lang: _lang, plugins: _plugins, presmium, renderers, sectionDepth: _sectionDepth, execute: _execute, translations: _translations, translationOf: _translationOf, ...rest } = headmatter
215
+ const doc: IRDocument = {
216
+ ir: IR_VERSION,
217
+ source: { documentId: run.document.id, version: createHash('sha256').update(run.rawSource).digest('hex').slice(0, 20), fingerprint: fnv1a64(run.rawSource) },
218
+ meta: { ...rest, title: titleOf(headmatter, this.entry), lang: run.lang, entry: this.entry, documentId: run.document.id },
219
+ config: {
220
+ plugins: listedPlugins(stringList(headmatter.plugins)),
221
+ presmium: isRecord(presmium) ? presmium : {},
222
+ renderers: isRecord(renderers) ? recordOfRecords(renderers) : {},
223
+ ...Object.keys(execute).length > 0 ? { execute } : {},
224
+ },
225
+ sections,
226
+ diagnostics: [],
227
+ production: structuredClone(run.document.production),
228
+ productionAssets: Object.fromEntries(productionAssetIds(run.document.production, run.document.sectionOrder.map(id => ({ id, stepIds: run.document.sections[id]!.stepOrder }))).map(id => [id, structuredClone(run.document.assets[id]!)])),
229
+ }
230
+ applyTranslations(doc, translationFields(headmatter), report)
231
+ let audience: AudienceCompilation | undefined
232
+ if (this.options.audience) {
233
+ const name = this.options.audience
234
+ const filtered = filterForAudience(doc, name, marks)
235
+ doc.sections = filtered.doc.sections
236
+ doc.meta.audience = name
237
+ doc.production = filtered.doc.production
238
+ doc.productionAssets = filtered.doc.productionAssets
239
+ audience = audienceCompilation(name, withheld(filtered.removed, doc))
240
+ const left = findings.filter(finding => !filtered.keeps(finding))
241
+ findings.splice(0, findings.length, ...findings.filter(filtered.keeps))
242
+ if (left.length > 0) {
243
+ const severity = left.some(f => f.severity === 'error') ? 'error' : left.some(f => f.severity === 'warning') ? 'warning' : 'info'
244
+ report({ checkId: 'presmium.audience', severity, message: `${left.length} finding${left.length === 1 ? ' is' : 's are'} in the content left out for ${name}, not shown here: presmium check without --audience shows ${left.length === 1 ? 'it' : 'them'}`, source: { file: this.entry, line: 1 } })
245
+ }
246
+ }
247
+ checkRuns(doc, execute, report)
248
+ const unsupported = checkLanguages(doc, execute, contributions.executors, report)
249
+
250
+ numberFootnotes(doc, sourceFiles)
251
+
252
+ const passes = await this.#runPasses(doc, run, contributions, unsupported)
253
+ findings.push(...passes.invalid)
254
+ options.signal?.throwIfAborted()
255
+ doc.diagnostics = sortFindings(findings)
256
+ // Reassert provenance after extension passes; authored metadata cannot replace the source identity.
257
+ doc.source = { documentId: run.document.id, version: createHash('sha256').update(run.rawSource).digest('hex').slice(0, 20), fingerprint: fnv1a64(run.rawSource) }
258
+ if (options.pruneCache && !audience && !findings.some(f => f.severity === 'error')) passes.cache.prune()
259
+ return this.#assemble(doc, run, contributions, { sourceFiles, sourceFingerprints, marks, audience, passes })
260
+ }
261
+
262
+ /** Reads the entry's headmatter, loads the plugins with their Node services and reads the execution settings. */
263
+ async #start(options: CompileOptions): Promise<CompileRun> {
264
+ options.signal?.throwIfAborted()
265
+ const workspace = this.#workspace ??= await this.#openWorkspace()
266
+ const { root: workspaceRoot, settings } = workspace
267
+ const findings: Finding[] = []
268
+ const report = (finding: Finding) => findings.push(finding)
269
+ const read = await readFile(this.entry, 'utf8').catch(() => undefined)
270
+ if (read === undefined) throw new Error(`${this.entry} not found`)
271
+ const text = read.replace(/^\uFEFF/, '')
272
+ if (workspace.problem) report({ checkId: 'presmium.workspace', severity: 'error', message: workspace.problem, source: { file: this.entry, line: 1 } })
273
+ if (workspace.operatorProblem) report({ checkId: 'presmium.workspace', severity: 'error', message: workspace.operatorProblem, source: { file: this.entry, line: 1 } })
274
+ let parsed: unknown
275
+ try { parsed = JSON.parse(text) } catch (error) { throw new Error(`Invalid ${DECK_ENTRY}: ${errorMessage(error)}`) }
276
+ const validated = validateDocument(parsed)
277
+ if (!validated.document) throw new Error(validated.issues.map(issue => `${issue.path || '/'}: ${issue.message}`).join('\n'))
278
+ const document = validated.document
279
+ const headmatter = withHeadmatter({ ...document.metadata }, this.options.headmatter)
280
+ const defaultVoice = document.production.defaults.voiceSource
281
+ const lecture = isRecord((headmatter.renderers as Record<string, unknown> | undefined)?.lecture) ? (headmatter.renderers as Record<string, Record<string, unknown>>).lecture : {}
282
+ if (lecture && Object.hasOwn(lecture, 'script')) throw new Error('/metadata/renderers/lecture/script: External lecture scripts are not supported. Author narration in lecture.json narrations and bind it through each step narrationIds.')
283
+ if (defaultVoice?.type === 'tts' || document.production.defaults.trailingPauseMs !== undefined || Object.keys(lecture ?? {}).length) headmatter.renderers = { ...(isRecord(headmatter.renderers) ? headmatter.renderers : {}), lecture: { ...lecture, ...defaultVoice?.type === 'tts' ? { voice: defaultVoice.voice } : {}, ...document.production.defaults.trailingPauseMs !== undefined ? { gap: document.production.defaults.trailingPauseMs } : {} } }
284
+ const lang = typeof headmatter.lang === 'string' && headmatter.lang.trim() ? headmatter.lang.trim() : DEFAULT_LANG
285
+ const inputs = new CompileInputs(workspaceRoot, this.#committed)
286
+ const presmium = isRecord(headmatter.presmium) ? headmatter.presmium : {}
287
+ const configOf = (plugin: LoadedPlugin) => {
288
+ const config = presmium[pluginId(plugin)]
289
+ return isRecord(config) ? config : {}
290
+ }
291
+
292
+ const listed = stringList(headmatter.plugins)
293
+ const loaded = await this.#loadPlugins(listedPlugins(listed), workspace)
294
+ const { plugins } = loaded
295
+ const problems = [...loaded.problems]
296
+ const services = new NodeServices(this.options.services)
297
+ for (const plugin of plugins) addServices(services, plugin, {
298
+ deckDir: this.deckDir,
299
+ workspaceRoot,
300
+ config: configOf(plugin),
301
+ settings: settings[pluginId(plugin)] ?? {},
302
+ lang,
303
+ secret: this.options.secret ?? operatorSpeechSecrets(workspace.operatorDir, name => process.env[name]),
304
+ ...workspace.operatorDir ? { operatorDir: workspace.operatorDir } : {},
305
+ log: pluginLogger(pluginId(plugin)),
306
+ service: token => services.tryGet(token),
307
+ }, problems)
308
+ const service = <T>(token: ServiceToken<T>) => services.tryGet(token)
309
+ const at = (_name: string) => ({ file: this.entry, line: 1, format: 'json' as const, pointer: '/metadata' })
310
+ for (const p of problems) report({ checkId: 'presmium.plugins', severity: p.severity, message: p.message, source: at(p.name) })
311
+ for (const name of listed) {
312
+ if (isRetiredPlugin(name)) report({ checkId: 'presmium.plugins', severity: 'info', message: `plugin "${name}" is no longer needed: the viewer shows the slides itself; remove it from plugins`, source: at(name) })
313
+ else if (currentPluginName(name) !== name) report({ checkId: 'presmium.plugins', severity: 'info', message: `plugin "${name}" is now named "${currentPluginName(name)}"; the old name still works`, source: at(name) })
314
+ }
315
+ const execute = toExecuteConfig(headmatter.execute, report, at)
316
+ checkSecrets(headmatter, report, at)
317
+ return { workspace, headmatter, lang, plugins, configOf, service, execute, inputs, findings, report, at, signal: options.signal, document, rawSource: read }
318
+ }
319
+
320
+ /** Registers each plugin's directives, roles, anchor resolvers, executors, environment providers and components. */
321
+ async #collectContributions(run: CompileRun): Promise<Contributions> {
322
+ const { plugins, configOf, execute, report, at } = run
323
+ const resolvers = createAnchorResolverRegistry()
324
+ const executors: Executor[] = []
325
+ const providers = new Map<string, EnvironmentProvider>()
326
+ const components = new Map<string, string>()
327
+ for (const loaded of plugins) {
328
+ for (const name of await directoryComponents(loaded.dir)) {
329
+ const owner = components.get(name)
330
+ if (owner === undefined) components.set(name, pluginId(loaded))
331
+ else report({ checkId: 'presmium.plugins', severity: 'warning', message: `plugin ${pluginId(loaded)}: component ${name} is already registered by ${owner}; its component is ignored`, source: at(loaded.name) })
332
+ }
333
+ const { plugin } = loaded
334
+ if (!plugin) continue
335
+ try {
336
+ for (const resolver of plugin.resolvers ?? []) resolvers.register(resolver)
337
+ } catch (error) {
338
+ report({ checkId: 'presmium.plugins', severity: 'error', message: `plugin ${plugin.id}: ${errorMessage(error)}`, source: at(loaded.name) })
339
+ }
340
+ executors.push(...plugin.executors ?? [])
341
+ for (const [kind, provider] of Object.entries(plugin.environments ?? {})) if (!providers.has(kind)) providers.set(kind, provider)
342
+ }
343
+ for (const name of await directoryComponents(this.deckDir)) components.set(name, DECK_PLUGIN)
344
+ const secret = this.options.secret ?? (name => process.env[name])
345
+ const environmentContext: EnvironmentContext = { deckDir: this.deckDir, workspaceRoot: run.workspace.root, secret, log: pluginLogger('presmium.execute') }
346
+ const environments = environmentProviders(execute, providers, report, at)
347
+ executors.push(...await environmentExecutors(execute, environments, environmentContext, this.#environments, report, at))
348
+ return { resolvers, executors, components, environments, environmentContext }
349
+ }
350
+
351
+ /** Validated JSON is the sole runtime source; compiler output remains a derived snapshot. */
352
+ async #compileSections(run: CompileRun, _contributions: Contributions): Promise<{ sections: IRSection[], marks: AudienceMark[], sourceFiles: string[], sourceFingerprints: Record<string, string> }> {
353
+ const sections = lowerDocument(run.document, this.entry, this.deckDir, run.report, (...files) => run.inputs.watch(...files))
354
+ for (const id of productionAssetIds(run.document.production, run.document.sectionOrder.map(id => ({ id, stepIds: run.document.sections[id]!.stepOrder })))) {
355
+ const asset = run.document.assets[id], file = asset && productionAssetFile(this.deckDir, asset.location)
356
+ if (file) run.inputs.watch(file)
357
+ }
358
+ run.inputs.watch(this.entry)
359
+ return { sections, marks: [], sourceFiles: [this.entry], sourceFingerprints: { [this.entry]: fnv1a64(run.rawSource) } }
360
+ }
361
+
362
+ /** Runs the plugins' passes, resolves references and runs the code at compile time, validating the IR in between. */
363
+ async #runPasses(doc: IRDocument, run: CompileRun, contributions: Contributions, unsupported: ReadonlySet<CodeNode>): Promise<PassResults> {
364
+ const { plugins, configOf, workspace, inputs, report, at, signal } = run
365
+ const { executors, resolvers } = contributions
366
+ const lockFile = join(this.deckDir, ANCHOR_LOCK_PATH)
367
+ let lock = AnchorLock.empty()
368
+ try {
369
+ lock = AnchorLock.parse(await readFile(lockFile, 'utf8').catch(() => undefined))
370
+ } catch (error) {
371
+ report({ checkId: 'presmium.lock', severity: 'error', message: errorMessage(error), source: { file: lockFile, line: 1 } })
372
+ }
373
+ const cache = new FreezeCache(join(this.deckDir, FREEZE_DIR), relative(this.deckDir, this.entry))
374
+ const runContext = this.#runContext(workspace.root, workspace.settings, run.service, run.lang)
375
+ const passes = plugins.flatMap(loaded => (loaded.plugin?.passes ?? []).map(pass => ({ pass, loaded })))
376
+ .sort((a, b) => (a.pass.order ?? 0) - (b.pass.order ?? 0))
377
+ const runPass = async ({ pass, loaded }: { pass: IRPass, loaded: LoadedPlugin }) => {
378
+ signal?.throwIfAborted()
379
+ const config = configOf(loaded)
380
+ const passContext: PassContext = {
381
+ deckDir: this.deckDir,
382
+ workspaceRoot: workspace.root,
383
+ lang: run.lang,
384
+ src: inputs.reader(workspace.src),
385
+ resolvers: inputs.resolvers(resolvers),
386
+ lock: lock.toJSON(),
387
+ executors,
388
+ cache,
389
+ config,
390
+ configFor: section => ({ ...config, ...section.config?.[pluginId(loaded)] }),
391
+ report: finding => report({ ...finding, source: finding.source ?? at(loaded.name) }),
392
+ watch: (...files) => inputs.watch(...files),
393
+ service: run.service,
394
+ }
395
+ try {
396
+ await pass.run(doc, passContext)
397
+ } catch (error) {
398
+ report({ checkId: 'presmium.plugins', severity: 'error', message: `pass ${pass.name} of plugin ${pluginId(loaded)} failed: ${errorMessage(error)}`, source: at(loaded.name) })
399
+ }
400
+ signal?.throwIfAborted()
401
+ }
402
+ const blame = (findings: Finding[], culprit: string): Finding[] => findings.map(finding => ({ ...finding, hint: culprit }))
403
+ let invalid = blame(validateIR(doc, this.entry), 'the IR is invalid before the plugin passes: a directive wrote it')
404
+ for (const step of invalid.length > 0 ? [] : passes.filter(({ pass }) => pass.after !== 'execute')) {
405
+ await runPass(step)
406
+ invalid = validateIR(doc, this.entry)
407
+ if (invalid.length > 0) {
408
+ invalid = blame(invalid, `the IR is invalid after pass ${step.pass.name} of plugin ${pluginId(step.loaded)}`)
409
+ break
410
+ }
411
+ }
412
+
413
+ let runStats: RunStats = { executed: 0, cached: 0 }
414
+ let filled = new WeakSet<object>()
415
+ if (invalid.length === 0) {
416
+ filled = emptyReferences(doc)
417
+ resolveReferences(doc, report)
418
+ runStats = await executePass(doc, executors, cache, report, { ...this.options.execution, signal, rerun: run.execute.rerun === 'environment', context: runContext, unsupported })
419
+ for (const step of passes.filter(({ pass }) => pass.after === 'execute')) await runPass(step)
420
+ invalid = validateIR(doc, this.entry)
421
+ }
422
+ return { lock, runStats, runContext, cache, filled, invalid }
423
+ }
424
+
425
+ #assemble(doc: IRDocument, run: CompileRun, contributions: Contributions, results: { sourceFiles: string[], sourceFingerprints: Record<string, string>, marks: AudienceMark[], audience?: AudienceCompilation, passes: PassResults }): Compilation {
426
+ const { workspace, headmatter, plugins, execute, inputs } = run
427
+ const { executors } = contributions
428
+ const { marks, audience, passes } = results
429
+ const { runContext } = passes
430
+ const publish = { doc, entry: this.entry, deckDir: this.deckDir, workspaceRoot: workspace.root, renderers: Object.keys(run.document.metadata.renderers ?? {}).length ? Object.keys(run.document.metadata.renderers!) : [...new Set([...BUILTIN_RENDERERS, ...plugins.flatMap(loaded => loaded.plugin?.renderer ? [loaded.plugin.renderer.id] : [])])] }
431
+ const compilation: Compilation = {
432
+ doc,
433
+ sourceCapabilities: {},
434
+ options: this.options,
435
+ sourceFiles: results.sourceFiles,
436
+ sourceFingerprints: results.sourceFingerprints,
437
+ inputs: { files: inputs.files, gitReads: inputs.gitReads },
438
+ runStats: passes.runStats,
439
+ deckDir: this.deckDir,
440
+ entry: this.entry,
441
+ headmatter,
442
+ workspaceRoot: workspace.root,
443
+ src: workspace.src,
444
+ resolvers: contributions.resolvers,
445
+ lock: passes.lock,
446
+ plugins,
447
+ components: contributions.components,
448
+ service: run.service,
449
+ settings: workspace.settings,
450
+ operatorDir: workspace.operatorDir,
451
+ pluginSources: workspace.sources,
452
+ execute,
453
+ environments: [...contributions.environments].map(([name, provider]) => ({ name, spec: execute.environments![name]!, provider, context: contributions.environmentContext })),
454
+ executors,
455
+ runContext,
456
+ run: (node, signal) => this.#run(doc, node, executors, runContext, signal),
457
+ ...audience ? { audience } : {},
458
+ audienceDocument: name => audienceDocument(doc, name, passes.filled, marks),
459
+ audienceCompilation: (name) => {
460
+ const filtered = filterForAudience(doc, name, marks)
461
+ return audienceCompilation(name, withheld(filtered.removed, filtered.doc))
462
+ },
463
+ publishChecks: async (checks = {}) => [...await checkLinks(publish, checks), ...checkForbidden(publish, headmatter)],
464
+ check: checks => runAllChecks(compilation, checks),
465
+ }
466
+ return compilation
467
+ }
468
+
469
+ #runContext(workspaceRoot: string, settings: OperatorSettings, service: <T>(token: ServiceToken<T>) => T | undefined, lang: string): RunContext {
470
+ const passEnv = stringList(settings['presmium.exec']?.passEnv)
471
+ const sandbox = service(PROCESS_SANDBOX)
472
+ return {
473
+ env: runEnvironment(this.options.execution?.env ?? 'local', passEnv, workspaceRoot),
474
+ ...sandbox ? { sandbox } : {},
475
+ lang,
476
+ }
477
+ }
478
+
479
+ async #run(doc: IRDocument, node: CodeNode, executors: Executor[], context: RunContext, signal?: AbortSignal): Promise<RunResult> {
480
+ const run = node.run ?? { lang: node.lang, when: 'live' as const }
481
+ const executor = run.executor
482
+ ? executors.find(e => e.id === run.executor && e.supports(run.lang))
483
+ : executors.find(e => !e.id.startsWith(environmentExecutor('')) && e.supports(run.lang))
484
+ if (!executor) return failedRun(run.executor ?? '', `no executor for ${run.executor ?? run.lang}`)
485
+ const request = (code: CodeNode): RunRequest => {
486
+ const spec = code.run ?? run
487
+ return { lang: spec.lang, code: code.value, ...spec.args ? { args: spec.args } : {}, ...spec.stdin !== undefined ? { stdin: spec.stdin } : {}, ...spec.timeoutMs !== undefined ? { timeoutMs: spec.timeoutMs } : {} }
488
+ }
489
+ let nodes = [node]
490
+ if (run.session) {
491
+ const before: CodeNode[] = []
492
+ for (const { block } of docBlocks(doc)) {
493
+ if (block.node === node) break
494
+ if (block.node.type === 'code' && block.node.run?.session === run.session && block.node.run.when !== 'live') before.push(block.node)
495
+ }
496
+ nodes = [...before, node]
497
+ }
498
+ const runSession = run.session ? `run:${randomUUID()}` : undefined
499
+ const requests = nodes.map(code => ({ ...request(code), ...runSession ? { session: runSession } : {} }))
500
+ const results = await Promise.all(requests.map(r => executor.run(r, signal, context)))
501
+ if (runSession) await executor.endSession?.(runSession)
502
+ return results.at(-1)!
503
+ }
504
+
505
+ async #openWorkspace(): Promise<Workspace> {
506
+ const root = this.options.workspaceRoot ? resolve(this.options.workspaceRoot) : await findWorkspaceRoot(this.deckDir)
507
+ const read = this.options.settings ? { settings: this.options.settings } : workspaceSettings(root)
508
+ const sources: PluginSources = { workspaceRoot: root, listed: operatorPlugins(read.settings), bundled: this.options.bundled }
509
+ const workspace: Workspace = { root, src: createSourceReader(root), settings: read.settings, problem: read.problem, sources }
510
+ try {
511
+ workspace.operatorDir = operatorDir(root, this.options.operatorDir ?? process.env.PRESMIUM_OPERATOR_DIR)
512
+ } catch (error) {
513
+ workspace.operatorProblem = errorMessage(error)
514
+ }
515
+ return workspace
516
+ }
517
+
518
+ async #loadPlugins(names: string[], workspace: Workspace): Promise<Plugins> {
519
+ const key = JSON.stringify(names)
520
+ if (this.#plugins?.key === key) return this.#plugins
521
+ const operators = await loadOperatorPlugins(workspace.sources)
522
+ const deck = await loadPlugins(this.deckDir, names, workspace.sources)
523
+ const dirs = new Set(operators.plugins.map(loaded => loaded.dir))
524
+ const plugins = [...operators.plugins, ...deck.plugins.filter(loaded => !dirs.has(loaded.dir))]
525
+ const problems = [...operators.problems, ...deck.problems]
526
+ this.#plugins = { key, plugins, problems }
527
+ return this.#plugins
528
+ }
529
+
530
+ }
531
+
532
+ function addServices(services: NodeServices, loaded: LoadedPlugin, context: NodeServiceContext, problems: PluginProblem[]): void {
533
+ const contribute = loaded.plugin?.services
534
+ if (!contribute) return
535
+ try {
536
+ for (const message of services.add(pluginId(loaded), contribute.call(loaded.plugin, context))) problems.push({ name: loaded.name, severity: 'warning', message })
537
+ } catch (error) {
538
+ problems.push({ name: loaded.name, severity: 'error', message: `plugin ${pluginId(loaded)}: services() failed: ${errorMessage(error)}` })
539
+ }
540
+ }
541
+
542
+ export async function workspacePlugins(root: string, options: CompilerOptions = {}): Promise<{ plugins: LoadedPlugin[], problems: PluginProblem[], service<T>(token: ServiceToken<T>): T | undefined }> {
543
+ const settings = options.settings ?? workspaceSettings(root).settings
544
+ const { plugins, problems } = await loadOperatorPlugins({ workspaceRoot: root, listed: operatorPlugins(settings), bundled: options.bundled })
545
+ let operator: string | undefined
546
+ try {
547
+ operator = operatorDir(root, options.operatorDir ?? process.env.PRESMIUM_OPERATOR_DIR)
548
+ } catch {
549
+ }
550
+ const services = new NodeServices(options.services)
551
+ for (const loaded of plugins) {
552
+ addServices(services, loaded, {
553
+ deckDir: root,
554
+ workspaceRoot: root,
555
+ config: {},
556
+ settings: settings[pluginId(loaded)] ?? {},
557
+ lang: DEFAULT_LANG,
558
+ secret: options.secret ?? operatorSpeechSecrets(operator, name => process.env[name]),
559
+ ...operator ? { operatorDir: operator } : {},
560
+ service: token => services.tryGet(token),
561
+ log: pluginLogger(pluginId(loaded)),
562
+ }, problems)
563
+ }
564
+ return { plugins, problems, service: token => services.tryGet(token) }
565
+ }
566
+
567
+ export function audienceCompilation(name: string, left: Withheld[]): AudienceCompilation {
568
+ return { name, withheld: left, scan: (dir, paths, options = {}) => scanOutputs(left, paths ?? [dir], name, dir, options.quote) }
569
+ }
570
+
571
+ function withHeadmatter(own: Record<string, unknown>, replaced?: Record<string, unknown>): Record<string, unknown> {
572
+ if (!replaced) return own
573
+ const merged = { ...own, ...replaced }
574
+ for (const [key, value] of Object.entries(replaced)) if (value === undefined) delete merged[key]
575
+ return merged
576
+ }
577
+
578
+ function titleOf(headmatter: Record<string, unknown>, entry: string): string {
579
+ return typeof headmatter.title === 'string' ? headmatter.title : basename(entry, extname(entry))
580
+ }
581
+
582
+ export function sortFindings(findings: Finding[]): Finding[] {
583
+ const file = (f: Finding) => f.source?.file ?? ''
584
+ const line = (f: Finding) => f.source?.line ?? 0
585
+ return findings.toSorted((a, b) => file(a) < file(b) ? -1 : file(a) > file(b) ? 1 : line(a) - line(b))
586
+ }
587
+
588
+ function publishedRenderers(doc: IRDocument, plugins: LoadedPlugin[]): string[] {
589
+ const configured = Object.keys(doc.config.renderers)
590
+ if (configured.length > 0) return configured
591
+ return [...new Set([...BUILTIN_RENDERERS, ...plugins.flatMap(loaded => (loaded.plugin?.renderer ? [loaded.plugin.renderer.id] : []))])]
592
+ }
593
+
594
+ function stringList(value: unknown): string[] {
595
+ return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []
596
+ }
597
+
598
+ function recordOfRecords(value: Record<string, unknown>): Record<string, Record<string, unknown>> {
599
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, isRecord(v) ? v : {}]))
600
+ }