@lincyaw/compiler 1.0.0-rc.0 → 1.0.0-rc.2

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/src/compiler.ts CHANGED
@@ -1,50 +1,49 @@
1
- import { randomUUID } from 'node:crypto'
1
+ import { createHash, randomUUID } from 'node:crypto'
2
2
  import { existsSync, statSync } from 'node:fs'
3
3
  import { readFile } from 'node:fs/promises'
4
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, isRecord } from '@lincyaw/kernel'
6
- import type { AnchorResolverRegistry, CodeNode, DirectiveOptionType, EnvironmentContext, EnvironmentProvider, EnvironmentSpec, ExecuteConfig, Executor, Finding, IRDocument, IRPass, IRSection, NodeServiceContext, PassContext, RunContext, RunRequest, RunResult, ServiceToken, SourceReader } from '@lincyaw/kernel'
7
- import { defaultOptions } from 'myst-parser'
8
- import { audienceDocument, audienceMarks, AudienceParser, audienceView, emptyReferences, liftScopes, markBlocks, scanOutputs, withheld } from './audience.ts'
9
- import type { Withheld } from './audience.ts'
10
- import { BUILTIN_DIRECTIVES, sectionBlocks } from './blocks.ts'
11
- import type { PluginDirective, PluginRole } from './blocks.ts'
12
- import { Dependencies } from './dependencies.ts'
13
- import type { CommittedFiles, Revision } from './dependencies.ts'
14
- import { checkLanguages, checkRuns, checkSecrets, environmentExecutors, environmentProviders, readExecute } from './environments.ts'
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'
15
13
  import { executePass, FREEZE_DIR, FreezeCache, runEnvironment } from './execute.ts'
16
- import type { ExecutionOptions, RunStats } from './execute.ts'
14
+ import type { RunEnvironmentOptions, RunStats } from './execute.ts'
17
15
  import { numberFootnotes } from './footnotes.ts'
18
16
  import { checkForbidden } from './forbid.ts'
19
- import { pluginChecks } from './host.ts'
17
+ import { CompileInputs } from './inputs.ts'
18
+ import type { CommittedFiles, GitRead } from './inputs.ts'
20
19
  import { checkLinks } from './links.ts'
21
- import { DECK_PLUGIN, directoryComponents, loadOperatorPlugins, loadPlugins, pluginId } from './plugins.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'
22
25
  import type { LoadedPlugin, PluginProblem, PluginSources } from './plugins.ts'
23
26
  import { resolveReferences } from './references.ts'
24
- import { splitSections } from './sections.ts'
25
- import { NodeServices, operatorDir, operatorPlugins, pluginLogger, workspaceSettings } from './settings.ts'
26
- import type { OperatorSettings } from './settings.ts'
27
- import { readFrontmatter, sourceOf, withoutBom } from './source.ts'
28
- import type { Parser } from './source.ts'
29
- import { scheduleSteps } from './steps.ts'
30
27
  import { applyTranslations, translationFields } from './translations.ts'
31
28
  import { validateIR } from './validate.ts'
32
29
  import { createSourceReader, findWorkspaceRoot } from './workspace.ts'
30
+ import { operatorDir, operatorPlugins, workspaceSettings } from './workspace-settings.ts'
31
+ import type { OperatorSettings } from './workspace-settings.ts'
33
32
 
34
- export const DECK_ENTRY = 'lecture.md'
33
+ export { DECK_ENTRY }
35
34
 
36
35
  const BUILTIN_RENDERERS = ['web', 'slides', 'notebook']
37
36
 
38
- const MYST_ROLES = new Set((defaultOptions.roles as unknown as { name: string, alias?: string[] }[]).flatMap(role => [role.name, ...role.alias ?? []]))
39
37
 
40
38
  export interface CompilerOptions {
39
+ priority?: 'interactive' | 'background'
41
40
  settings?: OperatorSettings
42
41
  services?: [ServiceToken<unknown>, unknown][]
43
42
  workspaceRoot?: string
44
43
  bundled?: string
45
44
  operatorDir?: string
46
45
  secret?: (name: string) => string | undefined
47
- execution?: ExecutionOptions
46
+ execution?: RunEnvironmentOptions
48
47
  audience?: string
49
48
  headmatter?: Record<string, unknown>
50
49
  deckDir?: string
@@ -65,13 +64,19 @@ export interface DeclaredEnvironment {
65
64
 
66
65
  export interface Compilation {
67
66
  doc: IRDocument
67
+ sourceCapabilities?: SourceCapabilities
68
68
  options: CompilerOptions
69
- files: string[]
70
- dependencies: { files: string[], revisions: Revision[] }
71
- runs: RunStats
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
72
76
  deckDir: string
73
77
  entry: string
74
- frontmatter: Record<string, unknown>
78
+ /** The entry's headmatter, merged with `CompilerOptions.headmatter`. */
79
+ headmatter: Record<string, unknown>
75
80
  workspaceRoot: string
76
81
  src: SourceReader
77
82
  resolvers: AnchorResolverRegistry
@@ -88,19 +93,30 @@ export interface Compilation {
88
93
  runContext: RunContext
89
94
  run(node: CodeNode, signal?: AbortSignal): Promise<RunResult>
90
95
  audience?: AudienceCompilation
91
- forAudience(name: string): IRDocument
92
- audienceOf(name: string): 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
93
99
  publishChecks(options?: { externalLinks?: boolean }): Promise<Finding[]>
94
100
  check(options?: { externalLinks?: boolean }): Promise<Finding[]>
95
101
  }
96
102
 
97
103
  export interface CompileOptions {
98
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
99
113
  }
100
114
 
101
115
  export function resolveEntry(deck: string): string {
102
116
  const path = resolve(deck)
103
- return existsSync(path) && statSync(path).isDirectory() ? join(path, DECK_ENTRY) : path
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
104
120
  }
105
121
 
106
122
  interface Workspace {
@@ -117,7 +133,46 @@ interface Plugins {
117
133
  key: string
118
134
  plugins: LoadedPlugin[]
119
135
  problems: PluginProblem[]
120
- services: NodeServices
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[]
121
176
  }
122
177
 
123
178
  export class Compiler {
@@ -127,8 +182,7 @@ export class Compiler {
127
182
  #workspace?: Workspace
128
183
  readonly #committed: CommittedFiles = new Map()
129
184
  #plugins?: Plugins
130
- #parser?: { key: string, parser: Parser }
131
- readonly #environments = new Map<string, Executor>()
185
+ readonly #environments = new WeakMap<EnvironmentProvider, Map<string, Executor>>()
132
186
 
133
187
  constructor(deck: string, options: CompilerOptions = {}) {
134
188
  this.entry = resolveEntry(deck)
@@ -136,67 +190,148 @@ export class Compiler {
136
190
  this.options = options
137
191
  }
138
192
 
139
- async compile(options: CompileOptions = {}): Promise<Compilation> {
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()
140
265
  const workspace = this.#workspace ??= await this.#openWorkspace()
141
- const { root: workspaceRoot, src, settings } = workspace
266
+ const { root: workspaceRoot, settings } = workspace
142
267
  const findings: Finding[] = []
143
268
  const report = (finding: Finding) => findings.push(finding)
144
269
  const read = await readFile(this.entry, 'utf8').catch(() => undefined)
145
270
  if (read === undefined) throw new Error(`${this.entry} not found`)
146
- const text = withoutBom(read)
271
+ const text = read.replace(/^\uFEFF/, '')
147
272
  if (workspace.problem) report({ checkId: 'presmium.workspace', severity: 'error', message: workspace.problem, source: { file: this.entry, line: 1 } })
148
273
  if (workspace.operatorProblem) report({ checkId: 'presmium.workspace', severity: 'error', message: workspace.operatorProblem, source: { file: this.entry, line: 1 } })
149
- const headmatter = withHeadmatter(readFrontmatter(text), this.options.headmatter)
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 } : {} } }
150
284
  const lang = typeof headmatter.lang === 'string' && headmatter.lang.trim() ? headmatter.lang.trim() : DEFAULT_LANG
151
- const dependencies = new Dependencies(workspaceRoot, this.#committed)
152
- const watch = (...files: string[]) => dependencies.watch(...files)
285
+ const inputs = new CompileInputs(workspaceRoot, this.#committed)
153
286
  const presmium = isRecord(headmatter.presmium) ? headmatter.presmium : {}
154
287
  const configOf = (plugin: LoadedPlugin) => {
155
288
  const config = presmium[pluginId(plugin)]
156
289
  return isRecord(config) ? config : {}
157
290
  }
158
291
 
159
- const names = stringList(headmatter.plugins)
160
- const { plugins, problems, services } = await this.#loadPlugins(names, workspace, (loaded): NodeServiceContext => ({
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, {
161
298
  deckDir: this.deckDir,
162
299
  workspaceRoot,
163
- config: configOf(loaded),
164
- settings: settings[pluginId(loaded)] ?? {},
300
+ config: configOf(plugin),
301
+ settings: settings[pluginId(plugin)] ?? {},
165
302
  lang,
166
- secret: this.options.secret ?? (name => process.env[name]),
303
+ secret: this.options.secret ?? operatorSpeechSecrets(workspace.operatorDir, name => process.env[name]),
167
304
  ...workspace.operatorDir ? { operatorDir: workspace.operatorDir } : {},
168
- log: pluginLogger(pluginId(loaded)),
169
- }))
170
- const service = <T>(token: ServiceToken<T>) => services.get(token)
171
- const at = (name: string) => ({ file: this.entry, line: frontmatterLine(text, name) })
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' })
172
310
  for (const p of problems) report({ checkId: 'presmium.plugins', severity: p.severity, message: p.message, source: at(p.name) })
173
- const execute = readExecute(headmatter.execute, report, at)
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)
174
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
+ }
175
319
 
176
- const directives = new Map<string, PluginDirective>()
177
- const roles = new Map<string, PluginRole>()
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
178
323
  const resolvers = createAnchorResolverRegistry()
179
324
  const executors: Executor[] = []
180
325
  const providers = new Map<string, EnvironmentProvider>()
181
326
  const components = new Map<string, string>()
182
327
  for (const loaded of plugins) {
183
- for (const name of await directoryComponents(loaded.dir)) if (!components.has(name)) components.set(name, pluginId(loaded))
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
+ }
184
333
  const { plugin } = loaded
185
334
  if (!plugin) continue
186
- for (const spec of plugin.directives ?? []) {
187
- if (BUILTIN_DIRECTIVES.has(spec.name) || directives.has(spec.name)) {
188
- report({ checkId: 'presmium.plugins', severity: 'error', message: `plugin ${plugin.id}: directive {${spec.name}} is already defined`, source: at(loaded.name) })
189
- } else {
190
- directives.set(spec.name, { plugin: plugin.id, config: configOf(loaded), spec })
191
- }
192
- }
193
- for (const spec of plugin.roles ?? []) {
194
- if (MYST_ROLES.has(spec.name) || roles.has(spec.name)) {
195
- report({ checkId: 'presmium.plugins', severity: 'error', message: `plugin ${plugin.id}: role {${spec.name}} is already defined`, source: at(loaded.name) })
196
- } else {
197
- roles.set(spec.name, { plugin: plugin.id, config: configOf(loaded), spec })
198
- }
199
- }
200
335
  try {
201
336
  for (const resolver of plugin.resolvers ?? []) resolvers.register(resolver)
202
337
  } catch (error) {
@@ -207,84 +342,27 @@ export class Compiler {
207
342
  }
208
343
  for (const name of await directoryComponents(this.deckDir)) components.set(name, DECK_PLUGIN)
209
344
  const secret = this.options.secret ?? (name => process.env[name])
210
- const environmentContext: EnvironmentContext = { deckDir: this.deckDir, workspaceRoot, secret, log: pluginLogger('presmium.execute') }
211
- const declared = environmentProviders(execute, providers, report, at)
212
- executors.push(...await environmentExecutors(execute, declared, environmentContext, this.#environments, report, at))
213
-
214
- const parser = this.#parserFor(directives, roles)
215
- const source = await parser.expand(this.entry)
216
- findings.push(...source.findings)
217
- dependencies.watch(...source.missing)
218
- const { title: _title, lang: _lang, plugins: _plugins, presmium: _presmium, renderers, sectionDepth: depth, execute: _execute, translations: _translations, translationOf: _translationOf, ...rest } = headmatter
219
- const title = typeof headmatter.title === 'string' ? headmatter.title : basename(this.entry, extname(this.entry))
220
- const sectionDepth = Number.isInteger(depth) && (depth as number) >= 1 ? depth as number : 2
221
-
222
- const lifted = liftScopes(source.children, sectionDepth, (node, audience) => report({
223
- checkId: 'presmium.audience',
224
- severity: 'warning',
225
- message: `this content follows an {include} with :audience: whose file opens a section, so it is part of that section, which is only for ${audience.join(', ')}`,
226
- hint: 'start a section of its own with a heading after the {include}, or move it before the {include}',
227
- source: sourceOf(node, this.entry),
228
- }))
229
- const marks = audienceMarks(lifted, this.entry)
230
- const raw = splitSections(lifted, { entry: this.entry, title, sectionDepth }, report)
231
- const rendererIds = new Set([...BUILTIN_RENDERERS, ...isRecord(renderers) ? Object.keys(renderers) : []])
232
- const ctx = { entry: this.entry, deckDir: this.deckDir, workspaceRoot, lang, renderers: rendererIds, parser, directives, roles, components, execute, report, watch }
233
- const sections: IRSection[] = []
234
- for (const [i, section] of raw.entries()) {
235
- const { blocks: drafts, hints, config, audience } = await sectionBlocks(section.nodes, ctx)
236
- const { blocks, steps } = scheduleSteps(drafts, report)
237
- drafts.forEach((draft, j) => {
238
- if (draft.audience) blocks[j]!.audience = draft.audience
239
- })
240
- sections.push({
241
- id: section.id,
242
- index: i + 1,
243
- title: section.title,
244
- depth: section.depth,
245
- source: section.source,
246
- steps,
247
- hints,
248
- ...Object.keys(config).length > 0 ? { config } : {},
249
- blocks,
250
- ...audience ? { audience } : {},
251
- })
252
- }
253
-
254
- markBlocks(sections, marks)
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
+ }
255
350
 
256
- const doc: IRDocument = {
257
- ir: IR_VERSION,
258
- meta: { ...rest, title, lang, entry: this.entry },
259
- config: {
260
- plugins: names,
261
- presmium,
262
- renderers: isRecord(renderers) ? recordOfRecords(renderers) : {},
263
- ...Object.keys(execute).length > 0 ? { execute } : {},
264
- },
265
- sections,
266
- diagnostics: [],
267
- }
268
- applyTranslations(doc, translationFields(headmatter), report)
269
- let audience: AudienceCompilation | undefined
270
- if (this.options.audience) {
271
- const name = this.options.audience
272
- const view = audienceView(doc, name, marks)
273
- doc.sections = view.doc.sections
274
- doc.meta.audience = name
275
- audience = audienceCompilation(name, withheld(view.removed, doc))
276
- const left = findings.filter(finding => !view.keeps(finding))
277
- findings.splice(0, findings.length, ...findings.filter(view.keeps))
278
- if (left.length > 0) {
279
- const severity = left.some(f => f.severity === 'error') ? 'error' : left.some(f => f.severity === 'warning') ? 'warning' : 'info'
280
- 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 } })
281
- }
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)
282
357
  }
283
- checkRuns(doc, execute, report)
284
- const unsupported = checkLanguages(doc, execute, executors, report)
285
-
286
- numberFootnotes(doc, source.files)
358
+ run.inputs.watch(this.entry)
359
+ return { sections, marks: [], sourceFiles: [this.entry], sourceFingerprints: { [this.entry]: fnv1a64(run.rawSource) } }
360
+ }
287
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
288
366
  const lockFile = join(this.deckDir, ANCHOR_LOCK_PATH)
289
367
  let lock = AnchorLock.empty()
290
368
  try {
@@ -293,31 +371,33 @@ export class Compiler {
293
371
  report({ checkId: 'presmium.lock', severity: 'error', message: errorMessage(error), source: { file: lockFile, line: 1 } })
294
372
  }
295
373
  const cache = new FreezeCache(join(this.deckDir, FREEZE_DIR), relative(this.deckDir, this.entry))
296
- const runContext = this.#runContext(workspaceRoot, settings, service, lang)
374
+ const runContext = this.#runContext(workspace.root, workspace.settings, run.service, run.lang)
297
375
  const passes = plugins.flatMap(loaded => (loaded.plugin?.passes ?? []).map(pass => ({ pass, loaded })))
298
376
  .sort((a, b) => (a.pass.order ?? 0) - (b.pass.order ?? 0))
299
377
  const runPass = async ({ pass, loaded }: { pass: IRPass, loaded: LoadedPlugin }) => {
378
+ signal?.throwIfAborted()
300
379
  const config = configOf(loaded)
301
- const pctx: PassContext = {
380
+ const passContext: PassContext = {
302
381
  deckDir: this.deckDir,
303
- workspaceRoot,
304
- lang,
305
- src: dependencies.reader(src),
306
- resolvers: dependencies.resolvers(resolvers),
382
+ workspaceRoot: workspace.root,
383
+ lang: run.lang,
384
+ src: inputs.reader(workspace.src),
385
+ resolvers: inputs.resolvers(resolvers),
307
386
  lock: lock.toJSON(),
308
387
  executors,
309
388
  cache,
310
389
  config,
311
390
  configFor: section => ({ ...config, ...section.config?.[pluginId(loaded)] }),
312
391
  report: finding => report({ ...finding, source: finding.source ?? at(loaded.name) }),
313
- watch,
314
- service,
392
+ watch: (...files) => inputs.watch(...files),
393
+ service: run.service,
315
394
  }
316
395
  try {
317
- await pass.run(doc, pctx)
396
+ await pass.run(doc, passContext)
318
397
  } catch (error) {
319
398
  report({ checkId: 'presmium.plugins', severity: 'error', message: `pass ${pass.name} of plugin ${pluginId(loaded)} failed: ${errorMessage(error)}`, source: at(loaded.name) })
320
399
  }
400
+ signal?.throwIfAborted()
321
401
  }
322
402
  const blame = (findings: Finding[], culprit: string): Finding[] => findings.map(finding => ({ ...finding, hint: culprit }))
323
403
  let invalid = blame(validateIR(doc, this.entry), 'the IR is invalid before the plugin passes: a directive wrote it')
@@ -330,52 +410,58 @@ export class Compiler {
330
410
  }
331
411
  }
332
412
 
333
- let runs: RunStats = { executed: 0, cached: 0 }
413
+ let runStats: RunStats = { executed: 0, cached: 0 }
334
414
  let filled = new WeakSet<object>()
335
415
  if (invalid.length === 0) {
336
416
  filled = emptyReferences(doc)
337
417
  resolveReferences(doc, report)
338
- runs = await executePass(doc, executors, cache, report, { ...this.options.execution, rerun: execute.rerun === 'environment', context: runContext, unsupported })
418
+ runStats = await executePass(doc, executors, cache, report, { ...this.options.execution, signal, rerun: run.execute.rerun === 'environment', context: runContext, unsupported })
339
419
  for (const step of passes.filter(({ pass }) => pass.after === 'execute')) await runPass(step)
340
420
  invalid = validateIR(doc, this.entry)
341
421
  }
342
- findings.push(...invalid)
343
- doc.diagnostics = sortFindings(findings)
344
- if (options.pruneCache && !audience && !findings.some(f => f.severity === 'error')) cache.prune()
345
- const publish = { doc, entry: this.entry, deckDir: this.deckDir, workspaceRoot, renderers: publishedRenderers(doc, plugins) }
422
+ return { lock, runStats, runContext, cache, filled, invalid }
423
+ }
346
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] : [])])] }
347
431
  const compilation: Compilation = {
348
432
  doc,
433
+ sourceCapabilities: {},
349
434
  options: this.options,
350
- files: source.files,
351
- dependencies: { files: dependencies.files, revisions: dependencies.revisions },
352
- runs,
435
+ sourceFiles: results.sourceFiles,
436
+ sourceFingerprints: results.sourceFingerprints,
437
+ inputs: { files: inputs.files, gitReads: inputs.gitReads },
438
+ runStats: passes.runStats,
353
439
  deckDir: this.deckDir,
354
440
  entry: this.entry,
355
- frontmatter: headmatter,
356
- workspaceRoot,
357
- src,
358
- resolvers,
359
- lock,
441
+ headmatter,
442
+ workspaceRoot: workspace.root,
443
+ src: workspace.src,
444
+ resolvers: contributions.resolvers,
445
+ lock: passes.lock,
360
446
  plugins,
361
- components,
362
- service,
363
- settings,
447
+ components: contributions.components,
448
+ service: run.service,
449
+ settings: workspace.settings,
364
450
  operatorDir: workspace.operatorDir,
365
451
  pluginSources: workspace.sources,
366
452
  execute,
367
- environments: [...declared].map(([name, provider]) => ({ name, spec: execute.environments![name]!, provider, context: environmentContext })),
453
+ environments: [...contributions.environments].map(([name, provider]) => ({ name, spec: execute.environments![name]!, provider, context: contributions.environmentContext })),
368
454
  executors,
369
455
  runContext,
370
456
  run: (node, signal) => this.#run(doc, node, executors, runContext, signal),
371
457
  ...audience ? { audience } : {},
372
- forAudience: name => audienceDocument(doc, name, filled, marks),
373
- audienceOf: (name) => {
374
- const view = audienceView(doc, name, marks)
375
- return audienceCompilation(name, withheld(view.removed, view.doc))
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))
376
462
  },
377
463
  publishChecks: async (checks = {}) => [...await checkLinks(publish, checks), ...checkForbidden(publish, headmatter)],
378
- check: checks => pluginChecks(compilation, checks),
464
+ check: checks => runAllChecks(compilation, checks),
379
465
  }
380
466
  return compilation
381
467
  }
@@ -409,10 +495,10 @@ export class Compiler {
409
495
  }
410
496
  nodes = [...before, node]
411
497
  }
412
- const session = run.session ? `run:${randomUUID()}` : undefined
413
- const requests = nodes.map(code => ({ ...request(code), ...session ? { session } : {} }))
498
+ const runSession = run.session ? `run:${randomUUID()}` : undefined
499
+ const requests = nodes.map(code => ({ ...request(code), ...runSession ? { session: runSession } : {} }))
414
500
  const results = await Promise.all(requests.map(r => executor.run(r, signal, context)))
415
- if (session) await executor.endSession?.(session)
501
+ if (runSession) await executor.endSession?.(runSession)
416
502
  return results.at(-1)!
417
503
  }
418
504
 
@@ -429,7 +515,7 @@ export class Compiler {
429
515
  return workspace
430
516
  }
431
517
 
432
- async #loadPlugins(names: string[], workspace: Workspace, context: (loaded: LoadedPlugin) => NodeServiceContext): Promise<Plugins> {
518
+ async #loadPlugins(names: string[], workspace: Workspace): Promise<Plugins> {
433
519
  const key = JSON.stringify(names)
434
520
  if (this.#plugins?.key === key) return this.#plugins
435
521
  const operators = await loadOperatorPlugins(workspace.sources)
@@ -437,19 +523,10 @@ export class Compiler {
437
523
  const dirs = new Set(operators.plugins.map(loaded => loaded.dir))
438
524
  const plugins = [...operators.plugins, ...deck.plugins.filter(loaded => !dirs.has(loaded.dir))]
439
525
  const problems = [...operators.problems, ...deck.problems]
440
- const services = new NodeServices(this.options.services)
441
- for (const loaded of plugins) addServices(services, loaded, { ...context(loaded), service: token => services.get(token) }, problems)
442
- this.#plugins = { key, plugins, problems, services }
526
+ this.#plugins = { key, plugins, problems }
443
527
  return this.#plugins
444
528
  }
445
529
 
446
- #parserFor(directives: Map<string, PluginDirective>, roles: Map<string, PluginRole>): Parser {
447
- const syntax = new Map([...BUILTIN_DIRECTIVES, ...[...directives.keys()].map(name => [name, false] as const)])
448
- const options = new Map<string, Record<string, DirectiveOptionType> | undefined>([...roles].map(([name, { spec }]) => [name, spec.options]))
449
- const key = JSON.stringify([[...syntax].sort(), [...options].sort()])
450
- if (this.#parser?.key !== key) this.#parser = { key, parser: new AudienceParser(syntax, options) }
451
- return this.#parser.parser
452
- }
453
530
  }
454
531
 
455
532
  function addServices(services: NodeServices, loaded: LoadedPlugin, context: NodeServiceContext, problems: PluginProblem[]): void {
@@ -478,13 +555,13 @@ export async function workspacePlugins(root: string, options: CompilerOptions =
478
555
  config: {},
479
556
  settings: settings[pluginId(loaded)] ?? {},
480
557
  lang: DEFAULT_LANG,
481
- secret: options.secret ?? (name => process.env[name]),
558
+ secret: options.secret ?? operatorSpeechSecrets(operator, name => process.env[name]),
482
559
  ...operator ? { operatorDir: operator } : {},
483
- service: token => services.get(token),
560
+ service: token => services.tryGet(token),
484
561
  log: pluginLogger(pluginId(loaded)),
485
562
  }, problems)
486
563
  }
487
- return { plugins, problems, service: token => services.get(token) }
564
+ return { plugins, problems, service: token => services.tryGet(token) }
488
565
  }
489
566
 
490
567
  export function audienceCompilation(name: string, left: Withheld[]): AudienceCompilation {
@@ -498,11 +575,8 @@ function withHeadmatter(own: Record<string, unknown>, replaced?: Record<string,
498
575
  return merged
499
576
  }
500
577
 
501
- export function frontmatterLine(text: string, needle: string): number {
502
- const lines = withoutBom(text).split(/\r?\n/)
503
- const end = lines.indexOf('---', 1)
504
- const index = lines.slice(0, end < 0 ? undefined : end).findIndex(line => line.includes(needle))
505
- return index < 0 ? 1 : index + 1
578
+ function titleOf(headmatter: Record<string, unknown>, entry: string): string {
579
+ return typeof headmatter.title === 'string' ? headmatter.title : basename(entry, extname(entry))
506
580
  }
507
581
 
508
582
  export function sortFindings(findings: Finding[]): Finding[] {