@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.
- package/LICENSE +202 -0
- package/README.md +29 -2
- package/package.json +47 -3
- package/src/audience.ts +486 -0
- package/src/blocks.ts +557 -0
- package/src/compiler.ts +526 -0
- package/src/dependencies.ts +76 -0
- package/src/directives.ts +116 -0
- package/src/environments.ts +148 -0
- package/src/execute.ts +367 -0
- package/src/footnotes.ts +39 -0
- package/src/forbid.ts +82 -0
- package/src/host.ts +56 -0
- package/src/index.ts +20 -0
- package/src/links.ts +203 -0
- package/src/paths.ts +56 -0
- package/src/plugins.ts +178 -0
- package/src/published.ts +105 -0
- package/src/references.ts +42 -0
- package/src/renderers.ts +535 -0
- package/src/sections.ts +98 -0
- package/src/serve.ts +93 -0
- package/src/settings.ts +105 -0
- package/src/source.ts +300 -0
- package/src/steps.ts +66 -0
- package/src/translations.ts +139 -0
- package/src/validate.ts +105 -0
- package/src/workspace.ts +27 -0
package/src/compiler.ts
ADDED
|
@@ -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
|
+
}
|