@lincyaw/compiler 1.0.0-rc.0 → 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/src/dev.ts ADDED
@@ -0,0 +1,222 @@
1
+ import { existsSync, realpathSync, statSync, watch } from 'node:fs'
2
+ import type { FSWatcher } from 'node:fs'
3
+ import { mkdir } from 'node:fs/promises'
4
+ import { basename, dirname, join } from 'node:path'
5
+ import { errorMessage } from '@lincyaw/kernel'
6
+ import type { Finding, RenderContext, RenderDevServer } from '@lincyaw/kernel'
7
+ import type { Compilation, Compiler } from './compiler.ts'
8
+ import { gitReadFiles } from './inputs.ts'
9
+ import { pluginLogger } from './logging.ts'
10
+ import { nodeVitePlugins, planRenderRun, renderContext, renderFinding, selectRenderers, translationsFor, WEB, writeIR } from './renderers.ts'
11
+ import type { InstalledRenderer, RenderRun } from './renderers.ts'
12
+ import { viewFor } from './views.ts'
13
+
14
+ /** The renderers' dev servers for one deck, recompiling it when its files change. */
15
+ export interface DevServers {
16
+ servers: { renderer: string, url: string }[]
17
+ ir: string
18
+ update(compilation: Compilation): Promise<void>
19
+ close(): Promise<void>
20
+ }
21
+
22
+ export interface DevOptions {
23
+ outDir: string
24
+ to?: string[]
25
+ server?: { url: string }
26
+ onCompile?(compilation: Compilation, findings: Finding[]): void
27
+ compilation?: Compilation
28
+ base?: string
29
+ port?: number
30
+ preview?: { origins: string[] }
31
+ }
32
+
33
+ interface RunningServer {
34
+ renderer: string
35
+ url: string
36
+ server: RenderDevServer
37
+ ctx: RenderContext
38
+ }
39
+
40
+ /** Watches the directories of a set of files, since a file that does not exist yet cannot be watched itself. */
41
+ class FileWatcher {
42
+ readonly #watchers = new Map<string, FSWatcher>()
43
+ readonly #changed: () => void
44
+ readonly #failed: (error: Error) => void
45
+ #names = new Map<string, Set<string>>()
46
+
47
+ constructor(changed: () => void, failed: (error: Error) => void) {
48
+ this.#changed = changed
49
+ this.#failed = failed
50
+ }
51
+
52
+ watch(files: Iterable<string>): void {
53
+ const names = new Map<string, Set<string>>()
54
+ for (const file of files) {
55
+ let dir = dirname(file)
56
+ let name = basename(file)
57
+ while (!existsSync(dir) && dirname(dir) !== dir) {
58
+ name = basename(dir)
59
+ dir = dirname(dir)
60
+ }
61
+ names.set(dir, (names.get(dir) ?? new Set<string>()).add(name))
62
+ }
63
+ this.#names = names
64
+ for (const [dir, watcher] of this.#watchers) {
65
+ if (names.has(dir)) continue
66
+ watcher.close()
67
+ this.#watchers.delete(dir)
68
+ }
69
+ for (const dir of names.keys()) {
70
+ if (this.#watchers.has(dir)) continue
71
+ const failed = (error: unknown) => this.#failed(new Error(`cannot watch ${dir}: ${errorMessage(error)}`))
72
+ try {
73
+ const watcher = watch(dir, (_event, name) => {
74
+ if (name && this.#names.get(dir)?.has(basename(name))) this.#changed()
75
+ })
76
+ this.#watchers.set(dir, watcher)
77
+ watcher.on('error', (error) => {
78
+ watcher.close()
79
+ if (this.#watchers.get(dir) === watcher) this.#watchers.delete(dir)
80
+ failed(error)
81
+ })
82
+ } catch (error) {
83
+ failed(error)
84
+ }
85
+ }
86
+ }
87
+
88
+ close(): void {
89
+ for (const watcher of this.#watchers.values()) watcher.close()
90
+ this.#watchers.clear()
91
+ }
92
+ }
93
+
94
+ async function startDevServers(compilation: Compilation, selected: InstalledRenderer[], run: RenderRun, outDir: string, options: DevOptions, translations: NonNullable<RenderContext['translations']>, findings: Finding[]): Promise<RunningServer[]> {
95
+ const servers: RunningServer[] = []
96
+ for (const { renderer } of selected) {
97
+ if (!renderer.dev) {
98
+ findings.push(renderFinding(compilation, 'warning', `renderer ${renderer.id} has no dev server`))
99
+ continue
100
+ }
101
+ const dir = join(outDir, renderer.id)
102
+ const ctx = renderContext(compilation, renderer.id, dir, dir, run, findings, options.server)
103
+ if (options.port !== undefined) ctx.config = { ...ctx.config, port: options.port }
104
+ if (options.preview) ctx.preview = { origins: [...options.preview.origins] }
105
+ if (renderer.id === WEB) {
106
+ ctx.translations = translations
107
+ }
108
+ try {
109
+ await mkdir(ctx.outDir, { recursive: true })
110
+ const server = await renderer.dev(viewFor(compilation.doc, renderer.id), ctx)
111
+ servers.push({ renderer: renderer.id, url: server.url, server, ctx })
112
+ } catch (error) {
113
+ findings.push(renderFinding(compilation, 'error', `renderer ${renderer.id} failed to start: ${errorMessage(error)}`))
114
+ }
115
+ }
116
+ return servers
117
+ }
118
+
119
+ export async function dev(compiler: Compiler, options: DevOptions): Promise<DevServers> {
120
+ await mkdir(options.outDir, { recursive: true })
121
+ const outDir = realpathSync(options.outDir)
122
+ let started = Date.now()
123
+ let compilation = options.compilation ?? await compiler.compile()
124
+ const ir = await writeIR(compilation.doc, outDir)
125
+ const { selected, findings } = await selectRenderers(compilation, options.to)
126
+ const run = planRenderRun(compilation, selected, false, options.base)
127
+ const translators = new Map<string, Compiler>()
128
+ let translated = await translationsFor(compilation, run, translators)
129
+ findings.push(...translated.findings)
130
+ const translations = translated.views
131
+ const servers = await startDevServers(compilation, selected, run, outDir, options, translations, findings)
132
+ if (servers.length === 0) findings.push(renderFinding(compilation, 'warning', `no renderer dev server; watching the source and writing the IR to ${ir}`))
133
+ const setup = (c: Compilation) => JSON.stringify([c.doc.config.plugins, c.doc.config.renderers])
134
+ const initial = setup(compilation)
135
+
136
+ let timer: NodeJS.Timeout | undefined
137
+ let running = Promise.resolve()
138
+ let queued = false
139
+ let closed = false
140
+ const schedule = () => {
141
+ if (closed) return
142
+ clearTimeout(timer)
143
+ timer = setTimeout(() => {
144
+ if (queued) return
145
+ queued = true
146
+ running = running.then(() => {
147
+ queued = false
148
+ clearTimeout(timer)
149
+ return recompile()
150
+ })
151
+ }, 30)
152
+ }
153
+ const watcher = new FileWatcher(schedule, error => {
154
+ if (closed) return
155
+ if (options.onCompile) options.onCompile(compilation, [...compilation.doc.diagnostics, ...findings, renderFinding(compilation, 'error', error.message)])
156
+ else pluginLogger('presmium.watch').error(error.message)
157
+ })
158
+ const watchFiles = async () => {
159
+ const { files, gitReads } = compilation.inputs
160
+ const git = await gitReadFiles(gitReads)
161
+ if (closed) return
162
+ const watched = new Set([...compilation.sourceFiles, ...files, ...git, ...translated.translations.flatMap(t => t.files)])
163
+ watcher.watch(watched)
164
+ if (!queued && [...watched].some(file => modifiedSince(file, started))) schedule()
165
+ }
166
+ const push = async (next: Compilation, reported: Finding[]) => {
167
+ compilation = next
168
+ await writeIR(compilation.doc, outDir)
169
+ reported.push(...compilation.doc.diagnostics)
170
+ translated = await translationsFor(compilation, run, translators)
171
+ reported.push(...translated.findings)
172
+ translations.splice(0, translations.length, ...translated.views)
173
+ if (servers.length > 0 && setup(compilation) !== initial) {
174
+ reported.push(renderFinding(compilation, 'warning', 'the plugins or renderers in the headmatter changed; restart presmium dev to load them in the dev servers'))
175
+ }
176
+ for (const { renderer, server, ctx } of servers) {
177
+ ctx.nodeVitePlugins = nodeVitePlugins(compilation)
178
+ await server.update(viewFor(compilation.doc, renderer)).catch((error: unknown) => {
179
+ reported.push(renderFinding(compilation, 'error', `renderer ${renderer} failed to update: ${errorMessage(error)}`))
180
+ })
181
+ }
182
+ }
183
+ const recompile = async () => {
184
+ started = Date.now()
185
+ const reported: Finding[] = []
186
+ try {
187
+ await push(await compiler.compile(), reported)
188
+ } catch (error) {
189
+ reported.push(renderFinding(compilation, 'error', errorMessage(error)))
190
+ }
191
+ await watchFiles()
192
+ options.onCompile?.(compilation, reported)
193
+ }
194
+ if (!options.compilation) await watchFiles()
195
+ options.onCompile?.(compilation, [...compilation.doc.diagnostics, ...findings])
196
+
197
+ return {
198
+ servers: servers.map(({ renderer, url }) => ({ renderer, url })),
199
+ ir,
200
+ async update(next) {
201
+ if (closed) return
202
+ const reported: Finding[] = []
203
+ running = running.then(() => push(next, reported)).catch((error: unknown) => {
204
+ reported.push(renderFinding(next, 'error', errorMessage(error)))
205
+ })
206
+ await running
207
+ options.onCompile?.(compilation, reported)
208
+ },
209
+ async close() {
210
+ closed = true
211
+ clearTimeout(timer)
212
+ watcher.close()
213
+ await running
214
+ await Promise.all(servers.map(s => s.server.close()))
215
+ },
216
+ }
217
+ }
218
+
219
+ function modifiedSince(file: string, time: number): boolean {
220
+ const modified = statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? 0
221
+ return modified >= time - 20 && modified <= Date.now()
222
+ }
package/src/directives.ts CHANGED
@@ -88,6 +88,7 @@ const LAYOUT_DIRECTIVES: DirectiveInfo[] = [
88
88
  ]
89
89
 
90
90
  export const BUILTIN_DIRECTIVE_INFO: readonly DirectiveInfo[] = [
91
+ { name: 'narration', doc: { 'zh-CN': '指定步骤的讲解与字幕;正文为 MyST。', en: 'Narration and captions for one step; the body is MyST.' }, options: { at: STEP_RANGE.at!, mode: { type: 'string', values: ['during', 'after'], doc: { 'zh-CN': 'during:边放边讲;after:动画播完再讲。', en: 'during: speak with the animation; after: speak after it finishes.' } } } },
91
92
  { name: 'slide', doc: { 'zh-CN': '上屏内容;正文为 MyST。', 'en': 'Content shown on screen; the body is MyST.' }, options: STEP_RANGE },
92
93
  { name: 'only', doc: { 'zh-CN': '只在这些渲染端出现的内容,如 `{only} web, notebook`。', 'en': 'Content limited to renderers, e.g. `{only} web, notebook`.' }, options: STEP_RANGE },
93
94
  { name: 'hints', doc: { 'zh-CN': '各渲染端的排版提示;正文为 YAML。', 'en': 'Layout hints per renderer; the body is YAML.' }, options: {} },
@@ -0,0 +1,176 @@
1
+ import { captionSourceFingerprint, documentColorValue, isRecord, voiceTarget } from '@lincyaw/kernel'
2
+ import type { CodeNode, ComponentNode, Definition, DocumentBlock, Finding, InlineNode, IRBlock, IRSection, JsonObject, MdastNode, NarrationNode, RichTextNode, SourcePos, TeachingDocument, TextStyle, VisualState } from '@lincyaw/kernel'
3
+ import { relative, resolve, sep } from 'node:path'
4
+
5
+ const pointerKey = (id: string) => id.replaceAll('~', '~0').replaceAll('/', '~1')
6
+ const source = (entry: string, pointer: string): SourcePos => ({ file: entry, line: 1, format: 'json', pointer })
7
+ const root = (children: MdastNode[]): MdastNode => ({ type: 'root', children })
8
+
9
+ /** Lower the validated authoring graph directly; no Markdown is generated or parsed. */
10
+ export function lowerDocument(doc: TeachingDocument, entry: string, deckDir: string, report: (finding: Finding) => void, watch: (...files: string[]) => void): IRSection[] {
11
+ const resolved = new Map<string, Map<string, VisualState>>()
12
+ const visuals = (id: string): Map<string, VisualState> => {
13
+ const known = resolved.get(id)
14
+ if (known) return known
15
+ const step = doc.steps[id]!
16
+ const result = step.inherits ? new Map(visuals(step.inherits)) : new Map<string, VisualState>()
17
+ for (const visual of step.visuals) result.set(visual.blockId, { ...result.get(visual.blockId), ...visual, parameters: { ...result.get(visual.blockId)?.parameters, ...visual.parameters } })
18
+ resolved.set(id, result)
19
+ return result
20
+ }
21
+ return doc.sectionOrder.map((sectionId, index) => {
22
+ const section = doc.sections[sectionId]!
23
+ const states = section.stepOrder.map(visuals)
24
+ const lower = (id: string): IRBlock => {
25
+ const block = doc.blocks[id]!
26
+ const where = source(entry, `/blocks/${pointerKey(id)}`)
27
+ const definition = block.type === 'component' ? doc.definitions[block.definitionId] : undefined
28
+ const stepStates = states.map((state, stepIndex) => {
29
+ const visual = state.get(id)
30
+ const stateIndex = definition?.states.findIndex(state => state.id === visual?.state) ?? -1
31
+ const local = stateIndex < 0 ? 0 : definition!.states[stateIndex]!.legacyIndex ?? stateIndex
32
+ const parameters = { ...block.type === 'component' ? block.parameters : {}, ...visual?.parameters }
33
+ if (definition && isMotionScene(definition)) delete parameters.scene
34
+ return { visible: visual?.visible ?? false, localStep: block.type === 'code' ? stepIndex : local, ...visual?.state !== undefined ? { state: visual.state } : {}, ...block.type === 'component' || visual?.parameters ? { parameters } : {} }
35
+ })
36
+ const visible = stepStates.flatMap((state, at) => state.visible ? [at] : [])
37
+ const at = visible[0] ?? 0
38
+ const node = lowerBlock(block, where, section.stepOrder, lower)
39
+ return { id, role: 'visual', at, span: Math.max(0, (visible.at(-1) ?? at) - at), ...block.only ? { only: block.only } : {}, ...block.audience ? { audience: block.audience } : {}, source: where, node, stepStates }
40
+ }
41
+ const lowerBlock = (block: DocumentBlock, where: SourcePos, stepIds: string[], child: (id: string) => IRBlock): IRBlock['node'] => {
42
+ switch (block.type) {
43
+ case 'text': return { type: 'markdown', mdast: root(block.content.map(richText)), ...block.reveal ? { reveal: 'items' } : {} }
44
+ case 'image': {
45
+ if (!block.assetId) {
46
+ const message = doc.metadata.lang.startsWith('en') ? 'No image selected.' : '未选择图片。'
47
+ report({ checkId: 'presmium.document.image', severity: 'warning', message, source: where })
48
+ return { type: 'markdown', mdast: root([{ type: 'paragraph', children: [{ type: 'text', value: message }] }]) }
49
+ }
50
+ return { type: 'markdown', mdast: root([{ type: 'paragraph', children: [{ type: 'image', url: doc.assets[block.assetId]!.location, alt: block.alt, ...block.width ? { width: block.width } : {} }] }, ...block.caption ? [{ type: 'paragraph', children: inlineText(block.caption) }] : []]) }
51
+ }
52
+ case 'code': {
53
+ const node: CodeNode = { type: 'code', lang: block.language, value: block.value ?? '', ...block.title ? { title: block.title } : {}, ...block.execution ? { run: { lang: block.language, ...block.execution } } : {}, ...block.echo === false ? { echo: false } : {} }
54
+ if (block.origin) {
55
+ node.reference = { anchor: block.origin.anchor as `${string}:${string}`, ...block.origin.pin ? { pin: block.origin.pin } : {}, ...block.origin.repo ? { repo: block.origin.repo } : {}, ...block.origin.highlightRegions ? { highlightRegions: stepIds.map(id => block.origin!.highlightRegions![id] ?? 'none') } : {} }
56
+ }
57
+ if (block.variants?.some(variant => variant.origin)) node.reference = { ...node.reference, anchor: (block.origin?.anchor ?? block.variants.find(variant => variant.origin)!.origin!.anchor) as `${string}:${string}`, variants: block.variants.map(variant => ({ ...variant.origin ? { anchor: variant.origin.anchor as `${string}:${string}`, ...variant.origin.pin ? { pin: variant.origin.pin } : {}, ...variant.origin.repo ? { repo: variant.origin.repo } : {} } : { value: variant.value! }, label: variant.title })) }
58
+ if (block.origin) {
59
+ if (!/^[a-z][a-z0-9+-]*:/i.test(block.origin.anchor)) report({ checkId: 'presmium.document', severity: 'error', message: 'A code origin requires a registered anchor scheme.', source: where })
60
+ else node.origin = { anchor: block.origin.anchor as `${string}:${string}`, file: '', startLine: 0, endLine: 0, status: 'lost', ...block.origin.pin ? { pin: block.origin.pin } : {} }
61
+ }
62
+ if (block.highlight) node.highlight = stepIds.map(id => block.highlight?.[id] ?? [])
63
+ if (block.variants) node.variants = block.variants.map(variant => ({ label: variant.title, value: variant.value ?? '' }))
64
+ return node
65
+ }
66
+ case 'container': return { type: 'component', plugin: 'presmium.layout', name: block.layout === 'columns' ? 'PmColumns' : block.layout === 'tabs' ? 'PmTabs' : 'PmStack', props: { ...block.properties }, children: block.children.map(child) }
67
+ case 'component': {
68
+ const definition = doc.definitions[block.definitionId]!
69
+ const implementation = definition.implementation
70
+ const data = definition.data ?? {}
71
+ const plugin = typeof data.plugin === 'string' ? data.plugin : implementation.type === 'plugin' ? implementation.plugin : isMotionScene(definition) ? 'presmium.motion' : 'deck'
72
+ const name = typeof data.name === 'string' ? data.name : implementation.type === 'plugin' ? implementation.name : isMotionScene(definition) ? 'PmScene' : `presmium-definition:${definition.id}`
73
+ const node: ComponentNode = { type: 'component', plugin, name, props: { ...block.parameters }, ...block.children ? { children: block.children.map(child) } : {} }
74
+ if (Array.isArray(data.durations) && data.durations.every(value => typeof value === 'number')) node.durations = data.durations
75
+ if (isRecord(data.fallback) && typeof data.fallback.type === 'string') node.fallback = structuredClone(data.fallback) as MdastNode
76
+ if (isRecord(data.assets) && Object.values(data.assets).every(value => typeof value === 'string')) node.assets = { ...data.assets } as Record<string, string>
77
+ if (data.layer === 'overlay') node.layer = 'overlay'
78
+ if (data.ownsCode === true) node.ownsCode = true
79
+ if (implementation.type === 'file') {
80
+ const file = resolve(deckDir, implementation.path)
81
+ watch(file)
82
+ if (isMotionScene(definition)) {
83
+ if (implementation.exportName && implementation.exportName !== 'default') report({ checkId: 'presmium.document', severity: 'error', message: 'Motion scene files currently require their default export.', source: where })
84
+ const path = relative(deckDir, file).split(sep).join('/')
85
+ const scene = /^scenes\/([\w-]+(?:\/[\w-]+)*)\.(?:ts|js|mjs)$/.exec(path)?.[1]
86
+ if (scene) node.props.scene = scene
87
+ else report({ checkId: 'presmium.document', severity: 'error', message: 'A motion scene implementation must be a supported file below scenes/.', source: where })
88
+ }
89
+ node.definition = { file, language: typeof data.language === 'string' ? data.language : /\.[cm]?tsx?$/.test(file) ? 'typescript' : 'javascript', ...implementation.exportName !== undefined ? { exportName: implementation.exportName } : {} }
90
+ }
91
+ if (implementation.type === 'module') report({ checkId: 'presmium.document', severity: 'error', message: 'Module component implementations are not available in the current document runtime; use a project file or a registered plugin component.', source: where })
92
+ return node
93
+ }
94
+ }
95
+ }
96
+ const blocks = section.children.map(lower)
97
+ for (const [at, stepId] of section.stepOrder.entries()) {
98
+ const step = doc.steps[stepId]!
99
+ for (const narrationId of step.narrationIds) {
100
+ const narration = doc.narrations[narrationId]!
101
+ const production = { ...doc.production.defaults, ...doc.production.sectionOverrides?.[sectionId], ...doc.production.narrationOverrides?.[narrationId], ...doc.production.stepOverrides[stepId] }
102
+ const mode = production.narrationStart === 'after-animation' ? 'after' : 'during'
103
+ blocks.push({ id: `${stepId}:${narrationId}`, role: 'narration', at, only: ['lecture'], source: source(entry, `/narrations/${pointerKey(narrationId)}`), node: { type: 'markdown', narration: { mode }, mdast: root([{ type: 'paragraph', children: narration.content.map(node => narrationText(node, doc)) }]) } })
104
+ }
105
+ }
106
+ const defaultVoice = doc.production.sectionOverrides?.[sectionId]?.voiceSource ?? doc.production.defaults.voiceSource
107
+ const stepNarrations = Object.fromEntries(section.stepOrder.map(stepId => [stepId, doc.steps[stepId]!.narrationIds.map(id => ({ id, text: doc.narrations[id]!.content.map(node => node.type === 'text' ? node.text : node.type === 'pronunciation' ? node.display : '').join('') }))]))
108
+ const resolvedNarrationVoices = Object.fromEntries(blocks.filter(block => block.role === 'narration').map(block => {
109
+ const stepId = section.stepOrder[block.at]!
110
+ const voice = doc.production.stepOverrides[stepId]?.voiceSource ?? defaultVoice
111
+ const id = block.source.pointer!.split('/').at(-1)!.replaceAll('~1', '/').replaceAll('~0', '~')
112
+ const advances = doc.narrations[id]!.content.some(node => node.type === 'cue' && node.action === 'next')
113
+ const target = advances ? undefined : voiceTarget({ id: sectionId, steps: section.stepOrder.length - 1, stepIds: section.stepOrder, blocks }, block.at)
114
+ return [block.id, { ...target ? { target } : {}, ...advances && section.stepOrder.some(id => doc.production.stepOverrides[id]?.voiceSource) ? { issue: 'cue-step' } : voice?.type === 'tts' ? { voice: voice.voice } : { issue: voice?.type === 'recording' ? 'recording-unavailable' : 'voice-unset' } }]
115
+ }))
116
+ return { id: sectionId, index: index + 1, title: section.title, depth: index === 0 && (section.level ?? 1) === 1 ? 0 : section.level ?? 1, source: source(entry, `/sections/${pointerKey(sectionId)}`), steps: Math.max(0, section.stepOrder.length - 1), stepIds: [...section.stepOrder], stepNarrations, captionSources: Object.fromEntries(section.stepOrder.map(id => [id, captionSourceFingerprint(doc, id)])), hints: section.layout.renderers ?? {}, ...section.audience ? { audience: section.audience } : {}, config: { ...section.config, 'presmium.lecture': { ...section.config?.['presmium.lecture'], resolvedNarrationVoices } }, blocks }
117
+ })
118
+ }
119
+
120
+ function isMotionScene(definition: Definition): boolean {
121
+ return definition.kind === 'scene' && definition.implementation.type === 'file' && (!definition.data?.plugin || definition.data.plugin === 'presmium.motion') && (!definition.data?.name || definition.data.name === 'PmScene')
122
+ }
123
+
124
+ function styled(node: MdastNode, style: TextStyle | undefined): MdastNode {
125
+ if (!style) return node
126
+ const properties: Record<string, string> = {}
127
+ const css = [style.alignment ? `text-align:${style.alignment}` : '', style.color ? `color:${documentColorValue(style.color, 'text')}` : '', style.background ? `background-color:${documentColorValue(style.background, 'background')}` : ''].filter(Boolean).join(';')
128
+ if (css) properties.style = css
129
+ return { ...node, data: { hProperties: properties } }
130
+ }
131
+
132
+ export function inlineText(nodes: InlineNode[]): MdastNode[] {
133
+ return nodes.map(node => {
134
+ if (node.type === 'text') {
135
+ let result: MdastNode = { type: node.marks?.some(mark => mark.type === 'code') ? 'inlineCode' : 'text', value: node.text }
136
+ for (const mark of node.marks ?? []) {
137
+ if (mark.type === 'code') continue
138
+ if (mark.type === 'color' || mark.type === 'background') result = { type: 'emphasis', children: [result], data: { hName: 'span', hProperties: { style: mark.type === 'color' ? `color:${documentColorValue(mark.value, 'text')}` : `background-color:${documentColorValue(mark.value, 'background')}` } } }
139
+ else result = { type: { bold: 'strong', italic: 'emphasis', underline: 'underline', strike: 'delete' }[mark.type], children: [result] }
140
+ }
141
+ return result
142
+ }
143
+ if (node.type === 'link') return { type: 'link', url: node.href, children: inlineText(node.children) }
144
+ if (node.type === 'math') return { type: 'inlineMath', value: node.value }
145
+ if (node.type === 'break') return { type: 'break' }
146
+ return extensionNode(node.kind, node.schemaVersion, node.data, [{ type: 'text', value: node.fallback }])
147
+ })
148
+ }
149
+
150
+ export function richText(node: RichTextNode): MdastNode {
151
+ let result: MdastNode
152
+ switch (node.type) {
153
+ case 'paragraph': result = styled({ type: 'paragraph', children: inlineText(node.children) }, node.style); break
154
+ case 'heading': result = styled({ type: 'heading', depth: node.level, children: inlineText(node.children) }, node.style); break
155
+ case 'list': result = { type: 'list', ordered: node.ordered, start: node.start, spread: false, children: node.children.map(item => ({ type: 'listItem', checked: item.checked, children: item.children.map(richText), identifier: item.id })) }; break
156
+ case 'blockquote': result = { type: 'blockquote', children: node.children.map(richText) }; break
157
+ case 'code': result = { type: 'code', lang: node.language, value: node.value }; break
158
+ case 'math': result = { type: 'math', value: node.value }; break
159
+ case 'table': result = { type: 'table', headerRows: node.headerRows, headerColumns: node.headerColumns, columnWidths: node.columnWidths, children: node.children.map(row => ({ type: 'tableRow', identifier: row.id, children: row.children.map(cell => styled({ type: 'tableCell', identifier: cell.id, colspan: cell.colspan, rowspan: cell.rowspan, children: inlineText(cell.children) }, cell.style)) })) }; break
160
+ case 'extension': result = extensionNode(node.kind, node.schemaVersion, node.data, node.fallback.map(richText)); break
161
+ }
162
+ return { ...result, ...node.type === 'extension' ? {} : { identifier: node.id }, data: { ...isRecord(result.data) ? result.data : {}, presmiumId: node.id } }
163
+ }
164
+
165
+ /** Known imported semantic trees remain structured data, never a reparsed authoring string. */
166
+ function extensionNode(kind: string, schemaVersion: number, data: JsonObject, fallback: MdastNode[]): MdastNode {
167
+ if (kind === 'presmium.mdast' && schemaVersion === 1 && isRecord(data.node) && typeof data.node.type === 'string') return structuredClone(data.node) as MdastNode
168
+ return { type: 'presmiumExtension', kind, schemaVersion, data: structuredClone(data), children: fallback }
169
+ }
170
+
171
+ function narrationText(node: NarrationNode, doc: TeachingDocument): MdastNode {
172
+ if (node.type === 'text') return { type: 'text', value: node.text }
173
+ if (node.type === 'pronunciation') return { type: 'presmiumRole', plugin: 'presmium.lecture', name: 'say', options: { value: node.display, alias: node.spoken }, children: [{ type: 'text', value: node.display }] }
174
+ const arg = node.action === 'pause' ? `${doc.production.cueTimings?.[node.id]?.pauseMs ?? node.parameters?.arg ?? 0}${doc.production.cueTimings?.[node.id] ? 'ms' : ''}` : node.parameters?.arg
175
+ return { type: 'presmiumRole', plugin: 'presmium.lecture', name: 'cue', options: { ...node.parameters, value: `${node.action}${arg === undefined || arg === '' ? '' : ` ${String(arg)}`}`, ...node.target ? { to: node.target.blockId, ...node.target.element ? { sel: node.target.element } : {} } : {} }, children: [] }
176
+ }
@@ -5,7 +5,7 @@ const PREFIX = environmentExecutor('')
5
5
 
6
6
  type At = (needle: string) => { file: string, line: number }
7
7
 
8
- export function readExecute(value: unknown, report: (finding: Finding) => void, at: At): ExecuteConfig {
8
+ export function toExecuteConfig(value: unknown, report: (finding: Finding) => void, at: At): ExecuteConfig {
9
9
  if (value === undefined) return {}
10
10
  const fail = (message: string, needle = 'execute') => report({ checkId: 'presmium.execute', severity: 'error', message, source: at(needle) })
11
11
  if (!isRecord(value)) {
@@ -49,7 +49,7 @@ export async function environmentExecutors(
49
49
  execute: ExecuteConfig,
50
50
  providers: Map<string, EnvironmentProvider>,
51
51
  context: EnvironmentContext,
52
- made: Map<string, Executor>,
52
+ made: WeakMap<EnvironmentProvider, Map<string, Executor>>,
53
53
  report: (finding: Finding) => void,
54
54
  at: At,
55
55
  ): Promise<Executor[]> {
@@ -57,7 +57,9 @@ export async function environmentExecutors(
57
57
  for (const [name, provider] of providers) {
58
58
  const spec = execute.environments![name]!
59
59
  const key = JSON.stringify([name, spec])
60
- let executor = made.get(key)
60
+ let instances = made.get(provider)
61
+ if (!instances) made.set(provider, instances = new Map())
62
+ let executor = instances.get(key)
61
63
  if (!executor) {
62
64
  try {
63
65
  executor = await provider.create(name, spec, context)
@@ -69,7 +71,7 @@ export async function environmentExecutors(
69
71
  report({ checkId: 'presmium.execute', severity: 'error', message: `environment "${name}": the ${spec.kind} provider made the executor ${executor.id}, not ${environmentExecutor(name)}`, source: at(`${name}:`) })
70
72
  continue
71
73
  }
72
- made.set(key, executor)
74
+ instances.set(key, executor)
73
75
  }
74
76
  executors.push(executor)
75
77
  }
package/src/execute.ts CHANGED
@@ -4,6 +4,7 @@ import { homedir } from 'node:os'
4
4
  import { join } from 'node:path'
5
5
  import { CREDENTIAL_VARIABLE, environmentExecutor, errorMessage, irBlocks, isRecord } from '@lincyaw/kernel'
6
6
  import type { CodeNode, Executor, Finding, IRBlock, IRDocument, RecordedRun, RunCache, RunContext, RunOutput, RunRequest, RunResult } from '@lincyaw/kernel'
7
+ import { resourceEnvironment } from '@lincyaw/kernel/node'
7
8
 
8
9
  export const FREEZE_DIR = '.presmium/freeze'
9
10
  const BLOBS = 'blobs'
@@ -40,7 +41,8 @@ export class FreezeCache implements RunCache {
40
41
  this.#write(key, { run })
41
42
  }
42
43
 
43
- record(key: string, input: string, request: RunRequest, run: RecordedRun, environment: string, revision = ''): void {
44
+ /** Records a run under its cache key and drops the older recordings of the same input. */
45
+ recordRun(key: string, input: string, request: RunRequest, run: RecordedRun, environment: string, revision = ''): void {
44
46
  for (const [file, record] of this.#all()) {
45
47
  if (record.input !== input || file === `${key}.json`) continue
46
48
  rmSync(join(this.dir, file), { force: true })
@@ -49,13 +51,12 @@ export class FreezeCache implements RunCache {
49
51
  this.#write(key, { input, entry: this.entry, lang: request.lang, code: request.code, environment, ...revision ? { revision } : {}, run })
50
52
  }
51
53
 
52
- find(input: string): RecordedRun | undefined {
53
- return this.recording(input)?.run
54
- }
55
-
56
- recording(input: string): { run: RecordedRun, environment?: string, revision: string } | undefined {
54
+ /** The recording of the input, made in whichever environment, whatever its cache key. */
55
+ findByInput(input: string): { run: RecordedRun, environment?: string, revision: string } | undefined {
57
56
  for (const record of this.#all().values()) {
58
- if (record.input === input) return { run: this.#restore(record.run), environment: record.environment, revision: record.revision ?? '' }
57
+ if (record.input !== input) continue
58
+ const run = this.#restore(record.run)
59
+ if (run) return { run, environment: record.environment, revision: record.revision ?? '' }
59
60
  }
60
61
  return undefined
61
62
  }
@@ -135,12 +136,16 @@ export class FreezeCache implements RunCache {
135
136
  })) }
136
137
  }
137
138
 
138
- #restore(run: RecordedRun): RecordedRun {
139
+ #restore(run: RecordedRun): RecordedRun | undefined {
139
140
  if (!run.outputs) return run
140
- return { ...run, outputs: run.outputs.map(output => mapData(output, (value) => {
141
+ let complete = true
142
+ const outputs = run.outputs.map(output => mapData(output, (value) => {
141
143
  if (!isRecord(value) || typeof value[BLOB_REF] !== 'string') return value
142
- return this.#text(join(this.dir, BLOBS, value[BLOB_REF].replace(/[^0-9a-f]/g, '')))
143
- })) }
144
+ const text = this.#text(join(this.dir, BLOBS, value[BLOB_REF].replace(/[^0-9a-f]/g, '')))
145
+ if (text === undefined) complete = false
146
+ return text
147
+ }))
148
+ return complete ? { ...run, outputs } : undefined
144
149
  }
145
150
  }
146
151
 
@@ -163,7 +168,7 @@ function blobRefs(run: RecordedRun, into: Set<string>): void {
163
168
  }
164
169
  }
165
170
 
166
- export interface ExecutionOptions {
171
+ export interface RunEnvironmentOptions {
167
172
  env?: 'local' | 'serve'
168
173
  }
169
174
 
@@ -173,7 +178,8 @@ export interface RunStats {
173
178
  sandbox?: string
174
179
  }
175
180
 
176
- export interface ExecuteOptions extends ExecutionOptions {
181
+ export interface ExecutePassOptions extends RunEnvironmentOptions {
182
+ signal?: AbortSignal
177
183
  rerun?: boolean
178
184
  context: RunContext
179
185
  unsupported?: ReadonlySet<CodeNode>
@@ -196,7 +202,8 @@ export function runEnvironment(rules: 'local' | 'serve', passEnv: readonly strin
196
202
  const value = source[name]
197
203
  if (value !== undefined) env[name] = value
198
204
  }
199
- return env
205
+ if (source.PRESMIUM_MAX_THREADS !== undefined) env.PRESMIUM_MAX_THREADS = source.PRESMIUM_MAX_THREADS
206
+ return resourceEnvironment(env)
200
207
  }
201
208
 
202
209
  function runHome(workspaceRoot: string, source: NodeJS.ProcessEnv): string {
@@ -229,7 +236,7 @@ interface Unit {
229
236
 
230
237
  type Report = (severity: Finding['severity'], message: string, block: IRBlock) => void
231
238
 
232
- export async function executePass(doc: IRDocument, executors: Executor[], cache: FreezeCache, report: (finding: Finding) => void, options: ExecuteOptions): Promise<RunStats> {
239
+ export async function executePass(doc: IRDocument, executors: Executor[], cache: FreezeCache, report: (finding: Finding) => void, options: ExecutePassOptions): Promise<RunStats> {
233
240
  const stats: RunStats = { executed: 0, cached: 0, ...options.context.sandbox ? { sandbox: options.context.sandbox.kind } : {} }
234
241
  const say: Report = (severity, message, block) =>
235
242
  report({ checkId: 'presmium.execute', severity, message, source: { file: block.source.file, line: block.source.line } })
@@ -264,7 +271,7 @@ export async function executePass(doc: IRDocument, executors: Executor[], cache:
264
271
  stats.cached += unit.items.length
265
272
  continue
266
273
  }
267
- const others = options.rerun ? [] : unit.items.map(item => cache.recording(item.input)).map(other => (other?.revision === unit.revision ? other : undefined))
274
+ const others = options.rerun ? [] : unit.items.map(item => cache.findByInput(item.input)).map(other => (other?.revision === unit.revision ? other : undefined))
268
275
  if (others.length > 0 && others.every(Boolean)) {
269
276
  unit.items.forEach((item, i) => {
270
277
  const other = others[i]!
@@ -280,26 +287,33 @@ export async function executePass(doc: IRDocument, executors: Executor[], cache:
280
287
 
281
288
  const sessions: [Executor, string][] = []
282
289
  const compile = `compile:${randomUUID()}`
283
- for (const unit of pending) {
284
- const executor = unit.executor!
285
- const session = unit.session && `${compile}:${unit.session}`
286
- const run = (request: RunRequest) => executor.run(session ? { ...request, session } : request, undefined, options.context)
287
- try {
288
- if (session) {
289
- await executor.endSession?.(session)
290
- sessions.push([executor, session])
291
- const results = await Promise.all(unit.items.map(item => run(item.request)))
292
- unit.items.forEach((item, i) => record(item, results[i]!, unit, cache, stats, say))
293
- } else {
294
- const item = unit.items[0]!
295
- record(item, await run(item.request), unit, cache, stats, say)
290
+ try {
291
+ for (const unit of pending) {
292
+ options.signal?.throwIfAborted()
293
+ const executor = unit.executor!
294
+ const session = unit.session && `${compile}:${unit.session}`
295
+ const run = (request: RunRequest) => executor.run(session ? { ...request, session } : request, options.signal, options.context)
296
+ try {
297
+ if (session) {
298
+ await executor.endSession?.(session)
299
+ sessions.push([executor, session])
300
+ // Submit a logical session together: executors batch these into one runtime.
301
+ const results = await Promise.all(unit.items.map(item => run(item.request)))
302
+ options.signal?.throwIfAborted()
303
+ unit.items.forEach((item, i) => record(item, results[i]!, unit, cache, stats, say))
304
+ } else {
305
+ const item = unit.items[0]!
306
+ const result = await run(item.request)
307
+ options.signal?.throwIfAborted()
308
+ record(item, result, unit, cache, stats, say)
309
+ }
310
+ } catch (error) {
311
+ options.signal?.throwIfAborted()
312
+ for (const item of unit.items) fromRecording(item, cache, stats, say, executor.id, errorMessage(error))
296
313
  }
297
- } catch (error) {
298
- for (const item of unit.items) fromRecording(item, cache, stats, say, executor.id, errorMessage(error))
299
314
  }
300
- }
301
- for (const [executor, session] of sessions) {
302
- await executor.endSession?.(session)?.catch(() => {})
315
+ } finally {
316
+ for (const [executor, session] of sessions) await executor.endSession?.(session)?.catch(() => {})
303
317
  }
304
318
  return stats
305
319
  }
@@ -343,12 +357,12 @@ function record(item: Item, result: RunResult, unit: Unit, cache: FreezeCache, s
343
357
  const recorded: RecordedRun = { ...result, cacheKey: item.key! }
344
358
  item.node.outputs = [recorded]
345
359
  stats.executed++
346
- if (!result.timedOut) cache.record(item.key!, item.input, item.request, recorded, unit.environment, unit.revision)
360
+ if (!result.timedOut) cache.recordRun(item.key!, item.input, item.request, recorded, unit.environment, unit.revision)
347
361
  if (!result.ok) say('warning', `block ${item.block.id} exited with ${result.timedOut ? 'a timeout' : result.exitCode === null ? `no exit code: ${result.stderr.trim().split('\n')[0]}` : `code ${result.exitCode}`}`, item.block)
348
362
  }
349
363
 
350
364
  function fromRecording(item: Item, cache: FreezeCache, stats: RunStats, say: Report, executor: string | undefined, failure?: string, reported = false): void {
351
- const recorded = cache.find(item.input)
365
+ const recorded = cache.findByInput(item.input)?.run
352
366
  const which = executor ?? item.request.lang
353
367
  if (recorded) {
354
368
  item.node.outputs = [recorded]