@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.
- package/LICENSE +202 -0
- package/README.md +33 -2
- package/package.json +46 -3
- package/src/assets.ts +163 -0
- package/src/audience.ts +291 -0
- package/src/build.ts +101 -0
- package/src/compilation-actions.ts +63 -0
- package/src/compile-queue.ts +28 -0
- package/src/compiler.ts +600 -0
- package/src/dev.ts +222 -0
- package/src/directives.ts +117 -0
- package/src/document-lowering.ts +176 -0
- package/src/environments.ts +150 -0
- package/src/execute.ts +381 -0
- package/src/export.ts +49 -0
- package/src/footnotes.ts +39 -0
- package/src/forbid.ts +76 -0
- package/src/frontmatter.ts +16 -0
- package/src/index.ts +30 -0
- package/src/inputs.ts +85 -0
- package/src/links.ts +179 -0
- package/src/logging.ts +16 -0
- package/src/node-services.ts +33 -0
- package/src/paths.ts +42 -0
- package/src/plugins.ts +221 -0
- package/src/references.ts +53 -0
- package/src/renderers.ts +195 -0
- package/src/sections.ts +24 -0
- package/src/serve.ts +95 -0
- package/src/source-lines.ts +47 -0
- package/src/translations.ts +133 -0
- package/src/validate.ts +107 -0
- package/src/views.ts +2 -0
- package/src/workspace-settings.ts +59 -0
- package/src/workspace.ts +37 -0
package/src/compiler.ts
ADDED
|
@@ -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
|
+
}
|