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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,526 @@
1
+ import { 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, 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'
15
+ import { executePass, FREEZE_DIR, FreezeCache, runEnvironment } from './execute.ts'
16
+ import type { ExecutionOptions, RunStats } from './execute.ts'
17
+ import { numberFootnotes } from './footnotes.ts'
18
+ import { checkForbidden } from './forbid.ts'
19
+ import { pluginChecks } from './host.ts'
20
+ import { checkLinks } from './links.ts'
21
+ import { DECK_PLUGIN, directoryComponents, loadOperatorPlugins, loadPlugins, pluginId } from './plugins.ts'
22
+ import type { LoadedPlugin, PluginProblem, PluginSources } from './plugins.ts'
23
+ 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
+ import { applyTranslations, translationFields } from './translations.ts'
31
+ import { validateIR } from './validate.ts'
32
+ import { createSourceReader, findWorkspaceRoot } from './workspace.ts'
33
+
34
+ export const DECK_ENTRY = 'lecture.md'
35
+
36
+ const BUILTIN_RENDERERS = ['web', 'slides', 'notebook']
37
+
38
+ const MYST_ROLES = new Set((defaultOptions.roles as unknown as { name: string, alias?: string[] }[]).flatMap(role => [role.name, ...role.alias ?? []]))
39
+
40
+ export interface CompilerOptions {
41
+ settings?: OperatorSettings
42
+ services?: [ServiceToken<unknown>, unknown][]
43
+ workspaceRoot?: string
44
+ bundled?: string
45
+ operatorDir?: string
46
+ secret?: (name: string) => string | undefined
47
+ execution?: ExecutionOptions
48
+ audience?: string
49
+ headmatter?: Record<string, unknown>
50
+ deckDir?: string
51
+ }
52
+
53
+ export interface AudienceCompilation {
54
+ name: string
55
+ withheld: Withheld[]
56
+ scan(dir: string, paths?: string[], options?: { quote?: boolean }): Promise<Finding[]>
57
+ }
58
+
59
+ export interface DeclaredEnvironment {
60
+ name: string
61
+ spec: EnvironmentSpec
62
+ provider: EnvironmentProvider
63
+ context: EnvironmentContext
64
+ }
65
+
66
+ export interface Compilation {
67
+ doc: IRDocument
68
+ options: CompilerOptions
69
+ files: string[]
70
+ dependencies: { files: string[], revisions: Revision[] }
71
+ runs: RunStats
72
+ deckDir: string
73
+ entry: string
74
+ frontmatter: Record<string, unknown>
75
+ workspaceRoot: string
76
+ src: SourceReader
77
+ resolvers: AnchorResolverRegistry
78
+ lock: AnchorLock
79
+ plugins: LoadedPlugin[]
80
+ components: Map<string, string>
81
+ service<T>(token: ServiceToken<T>): T | undefined
82
+ settings: OperatorSettings
83
+ operatorDir?: string
84
+ pluginSources: PluginSources
85
+ execute: ExecuteConfig
86
+ environments: DeclaredEnvironment[]
87
+ executors: Executor[]
88
+ runContext: RunContext
89
+ run(node: CodeNode, signal?: AbortSignal): Promise<RunResult>
90
+ audience?: AudienceCompilation
91
+ forAudience(name: string): IRDocument
92
+ audienceOf(name: string): AudienceCompilation
93
+ publishChecks(options?: { externalLinks?: boolean }): Promise<Finding[]>
94
+ check(options?: { externalLinks?: boolean }): Promise<Finding[]>
95
+ }
96
+
97
+ export interface CompileOptions {
98
+ pruneCache?: boolean
99
+ }
100
+
101
+ export function resolveEntry(deck: string): string {
102
+ const path = resolve(deck)
103
+ return existsSync(path) && statSync(path).isDirectory() ? join(path, DECK_ENTRY) : path
104
+ }
105
+
106
+ interface Workspace {
107
+ root: string
108
+ src: SourceReader
109
+ settings: OperatorSettings
110
+ problem?: string
111
+ sources: PluginSources
112
+ operatorDir?: string
113
+ operatorProblem?: string
114
+ }
115
+
116
+ interface Plugins {
117
+ key: string
118
+ plugins: LoadedPlugin[]
119
+ problems: PluginProblem[]
120
+ services: NodeServices
121
+ }
122
+
123
+ export class Compiler {
124
+ readonly entry: string
125
+ readonly deckDir: string
126
+ readonly options: CompilerOptions
127
+ #workspace?: Workspace
128
+ readonly #committed: CommittedFiles = new Map()
129
+ #plugins?: Plugins
130
+ #parser?: { key: string, parser: Parser }
131
+ readonly #environments = new Map<string, Executor>()
132
+
133
+ constructor(deck: string, options: CompilerOptions = {}) {
134
+ this.entry = resolveEntry(deck)
135
+ this.deckDir = options.deckDir ? resolve(options.deckDir) : dirname(this.entry)
136
+ this.options = options
137
+ }
138
+
139
+ async compile(options: CompileOptions = {}): Promise<Compilation> {
140
+ const workspace = this.#workspace ??= await this.#openWorkspace()
141
+ const { root: workspaceRoot, src, settings } = workspace
142
+ const findings: Finding[] = []
143
+ const report = (finding: Finding) => findings.push(finding)
144
+ const read = await readFile(this.entry, 'utf8').catch(() => undefined)
145
+ if (read === undefined) throw new Error(`${this.entry} not found`)
146
+ const text = withoutBom(read)
147
+ if (workspace.problem) report({ checkId: 'presmium.workspace', severity: 'error', message: workspace.problem, source: { file: this.entry, line: 1 } })
148
+ 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)
150
+ 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)
153
+ const presmium = isRecord(headmatter.presmium) ? headmatter.presmium : {}
154
+ const configOf = (plugin: LoadedPlugin) => {
155
+ const config = presmium[pluginId(plugin)]
156
+ return isRecord(config) ? config : {}
157
+ }
158
+
159
+ const names = stringList(headmatter.plugins)
160
+ const { plugins, problems, services } = await this.#loadPlugins(names, workspace, (loaded): NodeServiceContext => ({
161
+ deckDir: this.deckDir,
162
+ workspaceRoot,
163
+ config: configOf(loaded),
164
+ settings: settings[pluginId(loaded)] ?? {},
165
+ lang,
166
+ secret: this.options.secret ?? (name => process.env[name]),
167
+ ...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) })
172
+ 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)
174
+ checkSecrets(headmatter, report, at)
175
+
176
+ const directives = new Map<string, PluginDirective>()
177
+ const roles = new Map<string, PluginRole>()
178
+ const resolvers = createAnchorResolverRegistry()
179
+ const executors: Executor[] = []
180
+ const providers = new Map<string, EnvironmentProvider>()
181
+ const components = new Map<string, string>()
182
+ for (const loaded of plugins) {
183
+ for (const name of await directoryComponents(loaded.dir)) if (!components.has(name)) components.set(name, pluginId(loaded))
184
+ const { plugin } = loaded
185
+ 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
+ try {
201
+ for (const resolver of plugin.resolvers ?? []) resolvers.register(resolver)
202
+ } catch (error) {
203
+ report({ checkId: 'presmium.plugins', severity: 'error', message: `plugin ${plugin.id}: ${errorMessage(error)}`, source: at(loaded.name) })
204
+ }
205
+ executors.push(...plugin.executors ?? [])
206
+ for (const [kind, provider] of Object.entries(plugin.environments ?? {})) if (!providers.has(kind)) providers.set(kind, provider)
207
+ }
208
+ for (const name of await directoryComponents(this.deckDir)) components.set(name, DECK_PLUGIN)
209
+ 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)
255
+
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
+ }
282
+ }
283
+ checkRuns(doc, execute, report)
284
+ const unsupported = checkLanguages(doc, execute, executors, report)
285
+
286
+ numberFootnotes(doc, source.files)
287
+
288
+ const lockFile = join(this.deckDir, ANCHOR_LOCK_PATH)
289
+ let lock = AnchorLock.empty()
290
+ try {
291
+ lock = AnchorLock.parse(await readFile(lockFile, 'utf8').catch(() => undefined))
292
+ } catch (error) {
293
+ report({ checkId: 'presmium.lock', severity: 'error', message: errorMessage(error), source: { file: lockFile, line: 1 } })
294
+ }
295
+ const cache = new FreezeCache(join(this.deckDir, FREEZE_DIR), relative(this.deckDir, this.entry))
296
+ const runContext = this.#runContext(workspaceRoot, settings, service, lang)
297
+ const passes = plugins.flatMap(loaded => (loaded.plugin?.passes ?? []).map(pass => ({ pass, loaded })))
298
+ .sort((a, b) => (a.pass.order ?? 0) - (b.pass.order ?? 0))
299
+ const runPass = async ({ pass, loaded }: { pass: IRPass, loaded: LoadedPlugin }) => {
300
+ const config = configOf(loaded)
301
+ const pctx: PassContext = {
302
+ deckDir: this.deckDir,
303
+ workspaceRoot,
304
+ lang,
305
+ src: dependencies.reader(src),
306
+ resolvers: dependencies.resolvers(resolvers),
307
+ lock: lock.toJSON(),
308
+ executors,
309
+ cache,
310
+ config,
311
+ configFor: section => ({ ...config, ...section.config?.[pluginId(loaded)] }),
312
+ report: finding => report({ ...finding, source: finding.source ?? at(loaded.name) }),
313
+ watch,
314
+ service,
315
+ }
316
+ try {
317
+ await pass.run(doc, pctx)
318
+ } catch (error) {
319
+ report({ checkId: 'presmium.plugins', severity: 'error', message: `pass ${pass.name} of plugin ${pluginId(loaded)} failed: ${errorMessage(error)}`, source: at(loaded.name) })
320
+ }
321
+ }
322
+ const blame = (findings: Finding[], culprit: string): Finding[] => findings.map(finding => ({ ...finding, hint: culprit }))
323
+ let invalid = blame(validateIR(doc, this.entry), 'the IR is invalid before the plugin passes: a directive wrote it')
324
+ for (const step of invalid.length > 0 ? [] : passes.filter(({ pass }) => pass.after !== 'execute')) {
325
+ await runPass(step)
326
+ invalid = validateIR(doc, this.entry)
327
+ if (invalid.length > 0) {
328
+ invalid = blame(invalid, `the IR is invalid after pass ${step.pass.name} of plugin ${pluginId(step.loaded)}`)
329
+ break
330
+ }
331
+ }
332
+
333
+ let runs: RunStats = { executed: 0, cached: 0 }
334
+ let filled = new WeakSet<object>()
335
+ if (invalid.length === 0) {
336
+ filled = emptyReferences(doc)
337
+ resolveReferences(doc, report)
338
+ runs = await executePass(doc, executors, cache, report, { ...this.options.execution, rerun: execute.rerun === 'environment', context: runContext, unsupported })
339
+ for (const step of passes.filter(({ pass }) => pass.after === 'execute')) await runPass(step)
340
+ invalid = validateIR(doc, this.entry)
341
+ }
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) }
346
+
347
+ const compilation: Compilation = {
348
+ doc,
349
+ options: this.options,
350
+ files: source.files,
351
+ dependencies: { files: dependencies.files, revisions: dependencies.revisions },
352
+ runs,
353
+ deckDir: this.deckDir,
354
+ entry: this.entry,
355
+ frontmatter: headmatter,
356
+ workspaceRoot,
357
+ src,
358
+ resolvers,
359
+ lock,
360
+ plugins,
361
+ components,
362
+ service,
363
+ settings,
364
+ operatorDir: workspace.operatorDir,
365
+ pluginSources: workspace.sources,
366
+ execute,
367
+ environments: [...declared].map(([name, provider]) => ({ name, spec: execute.environments![name]!, provider, context: environmentContext })),
368
+ executors,
369
+ runContext,
370
+ run: (node, signal) => this.#run(doc, node, executors, runContext, signal),
371
+ ...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))
376
+ },
377
+ publishChecks: async (checks = {}) => [...await checkLinks(publish, checks), ...checkForbidden(publish, headmatter)],
378
+ check: checks => pluginChecks(compilation, checks),
379
+ }
380
+ return compilation
381
+ }
382
+
383
+ #runContext(workspaceRoot: string, settings: OperatorSettings, service: <T>(token: ServiceToken<T>) => T | undefined, lang: string): RunContext {
384
+ const passEnv = stringList(settings['presmium.exec']?.passEnv)
385
+ const sandbox = service(PROCESS_SANDBOX)
386
+ return {
387
+ env: runEnvironment(this.options.execution?.env ?? 'local', passEnv, workspaceRoot),
388
+ ...sandbox ? { sandbox } : {},
389
+ lang,
390
+ }
391
+ }
392
+
393
+ async #run(doc: IRDocument, node: CodeNode, executors: Executor[], context: RunContext, signal?: AbortSignal): Promise<RunResult> {
394
+ const run = node.run ?? { lang: node.lang, when: 'live' as const }
395
+ const executor = run.executor
396
+ ? executors.find(e => e.id === run.executor && e.supports(run.lang))
397
+ : executors.find(e => !e.id.startsWith(environmentExecutor('')) && e.supports(run.lang))
398
+ if (!executor) return failedRun(run.executor ?? '', `no executor for ${run.executor ?? run.lang}`)
399
+ const request = (code: CodeNode): RunRequest => {
400
+ const spec = code.run ?? run
401
+ return { lang: spec.lang, code: code.value, ...spec.args ? { args: spec.args } : {}, ...spec.stdin !== undefined ? { stdin: spec.stdin } : {}, ...spec.timeoutMs !== undefined ? { timeoutMs: spec.timeoutMs } : {} }
402
+ }
403
+ let nodes = [node]
404
+ if (run.session) {
405
+ const before: CodeNode[] = []
406
+ for (const { block } of docBlocks(doc)) {
407
+ if (block.node === node) break
408
+ if (block.node.type === 'code' && block.node.run?.session === run.session && block.node.run.when !== 'live') before.push(block.node)
409
+ }
410
+ nodes = [...before, node]
411
+ }
412
+ const session = run.session ? `run:${randomUUID()}` : undefined
413
+ const requests = nodes.map(code => ({ ...request(code), ...session ? { session } : {} }))
414
+ const results = await Promise.all(requests.map(r => executor.run(r, signal, context)))
415
+ if (session) await executor.endSession?.(session)
416
+ return results.at(-1)!
417
+ }
418
+
419
+ async #openWorkspace(): Promise<Workspace> {
420
+ const root = this.options.workspaceRoot ? resolve(this.options.workspaceRoot) : await findWorkspaceRoot(this.deckDir)
421
+ const read = this.options.settings ? { settings: this.options.settings } : workspaceSettings(root)
422
+ const sources: PluginSources = { workspaceRoot: root, listed: operatorPlugins(read.settings), bundled: this.options.bundled }
423
+ const workspace: Workspace = { root, src: createSourceReader(root), settings: read.settings, problem: read.problem, sources }
424
+ try {
425
+ workspace.operatorDir = operatorDir(root, this.options.operatorDir ?? process.env.PRESMIUM_OPERATOR_DIR)
426
+ } catch (error) {
427
+ workspace.operatorProblem = errorMessage(error)
428
+ }
429
+ return workspace
430
+ }
431
+
432
+ async #loadPlugins(names: string[], workspace: Workspace, context: (loaded: LoadedPlugin) => NodeServiceContext): Promise<Plugins> {
433
+ const key = JSON.stringify(names)
434
+ if (this.#plugins?.key === key) return this.#plugins
435
+ const operators = await loadOperatorPlugins(workspace.sources)
436
+ const deck = await loadPlugins(this.deckDir, names, workspace.sources)
437
+ const dirs = new Set(operators.plugins.map(loaded => loaded.dir))
438
+ const plugins = [...operators.plugins, ...deck.plugins.filter(loaded => !dirs.has(loaded.dir))]
439
+ 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 }
443
+ return this.#plugins
444
+ }
445
+
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
+ }
454
+
455
+ function addServices(services: NodeServices, loaded: LoadedPlugin, context: NodeServiceContext, problems: PluginProblem[]): void {
456
+ const contribute = loaded.plugin?.services
457
+ if (!contribute) return
458
+ try {
459
+ for (const message of services.add(pluginId(loaded), contribute.call(loaded.plugin, context))) problems.push({ name: loaded.name, severity: 'warning', message })
460
+ } catch (error) {
461
+ problems.push({ name: loaded.name, severity: 'error', message: `plugin ${pluginId(loaded)}: services() failed: ${errorMessage(error)}` })
462
+ }
463
+ }
464
+
465
+ export async function workspacePlugins(root: string, options: CompilerOptions = {}): Promise<{ plugins: LoadedPlugin[], problems: PluginProblem[], service<T>(token: ServiceToken<T>): T | undefined }> {
466
+ const settings = options.settings ?? workspaceSettings(root).settings
467
+ const { plugins, problems } = await loadOperatorPlugins({ workspaceRoot: root, listed: operatorPlugins(settings), bundled: options.bundled })
468
+ let operator: string | undefined
469
+ try {
470
+ operator = operatorDir(root, options.operatorDir ?? process.env.PRESMIUM_OPERATOR_DIR)
471
+ } catch {
472
+ }
473
+ const services = new NodeServices(options.services)
474
+ for (const loaded of plugins) {
475
+ addServices(services, loaded, {
476
+ deckDir: root,
477
+ workspaceRoot: root,
478
+ config: {},
479
+ settings: settings[pluginId(loaded)] ?? {},
480
+ lang: DEFAULT_LANG,
481
+ secret: options.secret ?? (name => process.env[name]),
482
+ ...operator ? { operatorDir: operator } : {},
483
+ service: token => services.get(token),
484
+ log: pluginLogger(pluginId(loaded)),
485
+ }, problems)
486
+ }
487
+ return { plugins, problems, service: token => services.get(token) }
488
+ }
489
+
490
+ export function audienceCompilation(name: string, left: Withheld[]): AudienceCompilation {
491
+ return { name, withheld: left, scan: (dir, paths, options = {}) => scanOutputs(left, paths ?? [dir], name, dir, options.quote) }
492
+ }
493
+
494
+ function withHeadmatter(own: Record<string, unknown>, replaced?: Record<string, unknown>): Record<string, unknown> {
495
+ if (!replaced) return own
496
+ const merged = { ...own, ...replaced }
497
+ for (const [key, value] of Object.entries(replaced)) if (value === undefined) delete merged[key]
498
+ return merged
499
+ }
500
+
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
506
+ }
507
+
508
+ export function sortFindings(findings: Finding[]): Finding[] {
509
+ const file = (f: Finding) => f.source?.file ?? ''
510
+ const line = (f: Finding) => f.source?.line ?? 0
511
+ return findings.toSorted((a, b) => file(a) < file(b) ? -1 : file(a) > file(b) ? 1 : line(a) - line(b))
512
+ }
513
+
514
+ function publishedRenderers(doc: IRDocument, plugins: LoadedPlugin[]): string[] {
515
+ const configured = Object.keys(doc.config.renderers)
516
+ if (configured.length > 0) return configured
517
+ return [...new Set([...BUILTIN_RENDERERS, ...plugins.flatMap(loaded => (loaded.plugin?.renderer ? [loaded.plugin.renderer.id] : []))])]
518
+ }
519
+
520
+ function stringList(value: unknown): string[] {
521
+ return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : []
522
+ }
523
+
524
+ function recordOfRecords(value: Record<string, unknown>): Record<string, Record<string, unknown>> {
525
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, isRecord(v) ? v : {}]))
526
+ }
@@ -0,0 +1,76 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { existsSync } from 'node:fs'
3
+ import { dirname, resolve } from 'node:path'
4
+ import { promisify } from 'node:util'
5
+ import type { AnchorResolverRegistry, SourceReader } from '@lincyaw/kernel'
6
+ import { COMMIT } from './workspace.ts'
7
+
8
+ export type CommittedFiles = Map<string, string>
9
+
10
+ const run = promisify(execFile)
11
+
12
+ export interface Revision {
13
+ file: string
14
+ rev: string
15
+ }
16
+
17
+ export class Dependencies {
18
+ readonly #root: string
19
+ readonly #committed: CommittedFiles
20
+ readonly #files = new Set<string>()
21
+ readonly #revisions = new Map<string, Revision>()
22
+
23
+ constructor(root: string, committed: CommittedFiles = new Map()) {
24
+ this.#root = root
25
+ this.#committed = committed
26
+ }
27
+
28
+ get files(): string[] {
29
+ return [...this.#files]
30
+ }
31
+
32
+ get revisions(): Revision[] {
33
+ return [...this.#revisions.values()]
34
+ }
35
+
36
+ watch(...files: string[]): void {
37
+ for (const file of files) this.#files.add(resolve(this.#root, file))
38
+ }
39
+
40
+ reader(src: SourceReader): SourceReader {
41
+ return {
42
+ read: async (file, ref) => {
43
+ const path = resolve(this.#root, file)
44
+ if (ref === undefined) {
45
+ this.#files.add(path)
46
+ return src.read(file, ref)
47
+ }
48
+ const key = `${path}\0${ref}`
49
+ this.#revisions.set(key, { file: path, rev: ref })
50
+ const known = this.#committed.get(key)
51
+ if (known !== undefined) return known
52
+ const text = await src.read(file, ref)
53
+ if (text !== undefined && COMMIT.test(ref)) this.#committed.set(key, text)
54
+ return text
55
+ },
56
+ }
57
+ }
58
+
59
+ resolvers(registry: AnchorResolverRegistry): AnchorResolverRegistry {
60
+ return {
61
+ register: resolver => registry.register(resolver),
62
+ schemes: () => registry.schemes(),
63
+ resolve: (anchor, src, recorded, ref) => registry.resolve(anchor, this.reader(src), recorded, ref),
64
+ }
65
+ }
66
+ }
67
+
68
+ export async function revisionFiles(dir: string, revs: Iterable<string>): Promise<string[]> {
69
+ let cwd = dir
70
+ while (!existsSync(cwd) && dirname(cwd) !== cwd) cwd = dirname(cwd)
71
+ const git = (args: string[]) => run('git', args, { cwd }).then(({ stdout }) => stdout.split('\n').filter(Boolean), () => [])
72
+ const names = new Set(['HEAD', ...[...revs].filter(rev => !rev.startsWith('-') && !COMMIT.test(rev))])
73
+ const refs = await Promise.all([...names].map(name => git(['rev-parse', '--symbolic-full-name', name])))
74
+ const files = new Set(['HEAD', 'packed-refs', ...refs.flat()])
75
+ return (await git(['rev-parse', ...[...files].flatMap(name => ['--git-path', name])])).map(path => resolve(cwd, path))
76
+ }