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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
+ }
@@ -0,0 +1,117 @@
1
+ import type { DirectiveOptionType, Locale } from '@lincyaw/kernel'
2
+
3
+ export interface DirectiveOptionInfo {
4
+ type?: DirectiveOptionType
5
+ doc?: Record<Locale, string>
6
+ values?: string[]
7
+ }
8
+
9
+ export interface DirectiveInfo {
10
+ name: string
11
+ doc: Record<Locale, string>
12
+ options: Record<string, DirectiveOptionInfo>
13
+ }
14
+
15
+ export const COMMON_DIRECTIVE_OPTIONS: Record<string, DirectiveOptionInfo> = {
16
+ reveal: {
17
+ doc: { 'zh-CN': '在下一步出现;`{slide}`、`{only}` 上写 `items` 时列表逐项出现。', 'en': 'Appears at the next step; `items` on `{slide}` and `{only}` reveals list items one per step.' },
18
+ values: ['items'],
19
+ },
20
+ label: { type: 'string', doc: { 'zh-CN': '块 ID。', 'en': 'Block id.' } },
21
+ audience: {
22
+ type: 'string',
23
+ doc: { 'zh-CN': '只给这些受众(逗号分隔),如 `teacher`;`presmium build --audience student` 不输出它。', 'en': 'Only for these audiences (comma-separated), e.g. `teacher`; `presmium build --audience student` leaves it out.' },
24
+ values: ['teacher', 'student'],
25
+ },
26
+ }
27
+
28
+ export const RUN_OPTION: DirectiveOptionInfo = {
29
+ type: 'string',
30
+ doc: { 'zh-CN': '`compile` 在构建时执行并录制输出,`live` 只在浏览器中执行,`both` 两者都做。', 'en': '`compile` records the output at build time, `live` runs in the browser, `both` does both.' },
31
+ values: ['compile', 'live', 'both'],
32
+ }
33
+
34
+ const STEP_RANGE: Record<string, DirectiveOptionInfo> = {
35
+ at: { type: 'number', doc: { 'zh-CN': '从指定步骤开始显示(从 0 开始),不能与 reveal 合用。', en: 'Shows from this zero-based step; cannot be combined with reveal.' } },
36
+ until: { type: 'number', doc: { 'zh-CN': '到指定步骤时隐藏;回退时恢复。', en: 'Hides at this step and returns when stepping back.' } },
37
+ }
38
+
39
+ const TITLE = { 'zh-CN': '标题。', 'en': 'Title.' }
40
+
41
+ const CODE_OPTIONS: Record<string, DirectiveOptionInfo> = {
42
+ 'caption': { type: 'string', doc: TITLE },
43
+ 'filename': { type: 'string', doc: TITLE },
44
+ 'emphasize-lines': { type: 'string', doc: { 'zh-CN': '每一步都强调的行,如 `2, 4-5`。', 'en': 'Lines highlighted at every step, e.g. `2, 4-5`.' } },
45
+ 'run': RUN_OPTION,
46
+ 'executor': { type: 'string', doc: { 'zh-CN': '执行器 ID。', 'en': 'Executor id.' } },
47
+ 'timeout': { type: 'number', doc: { 'zh-CN': '毫秒。', 'en': 'Milliseconds.' } },
48
+ 'env': { type: 'string', doc: { 'zh-CN': '运行环境:头部 `execute.environments` 中的名字,或 `browser`。', 'en': 'Where it runs: a name of `execute.environments` in the headmatter, or `browser`.' } },
49
+ 'session': { type: 'string', doc: { 'zh-CN': '执行会话:同一会话的代码块共享解释器。', 'en': 'Execution session: code blocks of one session share the interpreter.' } },
50
+ 'echo': { type: 'string', doc: { 'zh-CN': '`false`:只显示输出,Markdown 输出按正文显示。', 'en': '`false`: only the output shows, Markdown output as prose.' }, values: ['false'] },
51
+ }
52
+
53
+ const CLASS: DirectiveOptionInfo = { type: 'string', doc: { 'zh-CN': 'CSS 类名。', 'en': 'CSS class names.' } }
54
+
55
+ const LAYOUT_DIRECTIVES: DirectiveInfo[] = [
56
+ {
57
+ name: 'grid',
58
+ doc: { 'zh-CN': '栅格:参数为四档屏宽(手机、小、中、大)的列数,如 `1 1 2 3`;正文为 `{card}` 或 `{grid-item}`。', 'en': 'A grid: the argument is the columns at four screen widths (phone, small, medium, large), e.g. `1 1 2 3`; the body holds `{card}` or `{grid-item}`.' },
59
+ options: { class: CLASS },
60
+ },
61
+ {
62
+ name: 'grid-item',
63
+ doc: { 'zh-CN': '栅格中的一格。', 'en': 'An item of a grid.' },
64
+ options: { columns: { type: 'number', doc: { 'zh-CN': '占一行 12 份中的几份。', 'en': 'How many twelfths of a row it takes.' } }, class: CLASS },
65
+ },
66
+ {
67
+ name: 'card',
68
+ doc: { 'zh-CN': '卡片:参数为标题;正文中 `^^^` 之前为页眉,`+++` 之后为页脚。', 'en': 'A card: the argument is its title; in the body, what precedes `^^^` is its header, what follows `+++` its footer.' },
69
+ options: {
70
+ link: { type: 'string', doc: { 'zh-CN': '整张卡片链接到的地址。', 'en': 'Where the whole card links to.' } },
71
+ header: { type: 'string', doc: { 'zh-CN': '页眉。', 'en': 'Header.' } },
72
+ footer: { type: 'string', doc: { 'zh-CN': '页脚。', 'en': 'Footer.' } },
73
+ },
74
+ },
75
+ {
76
+ name: 'tab-set',
77
+ doc: { 'zh-CN': '一组选项卡;正文为 `{tab-item}`。', 'en': 'A set of tabs; the body holds `{tab-item}`.' },
78
+ options: { class: CLASS },
79
+ },
80
+ {
81
+ name: 'tab-item',
82
+ doc: { 'zh-CN': '一个选项卡:参数为标题。', 'en': 'A tab: the argument is its title.' },
83
+ options: {
84
+ sync: { type: 'string', doc: { 'zh-CN': '同步键:选中它时,页面上同一键的选项卡一起切换。', 'en': 'Sync key: choosing it switches the tabs with the same key on the page.' } },
85
+ selected: { type: 'boolean', doc: { 'zh-CN': '开始时选中。', 'en': 'Selected at first.' } },
86
+ },
87
+ },
88
+ ]
89
+
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.' } } } },
92
+ { name: 'slide', doc: { 'zh-CN': '上屏内容;正文为 MyST。', 'en': 'Content shown on screen; the body is MyST.' }, options: STEP_RANGE },
93
+ { name: 'only', doc: { 'zh-CN': '只在这些渲染端出现的内容,如 `{only} web, notebook`。', 'en': 'Content limited to renderers, e.g. `{only} web, notebook`.' }, options: STEP_RANGE },
94
+ { name: 'hints', doc: { 'zh-CN': '各渲染端的排版提示;正文为 YAML。', 'en': 'Layout hints per renderer; the body is YAML.' }, options: {} },
95
+ { name: 'config', doc: { 'zh-CN': '只在本章节生效的插件配置;正文为 YAML,键为插件 ID。', 'en': 'Plugin configuration of this section; the body is YAML keyed by plugin id.' }, options: {} },
96
+ { name: 'audience', doc: { 'zh-CN': '整个章节只给这些受众,如 `{audience} teacher`;放在章节顶层,没有正文。', 'en': 'The whole section is only for these audiences, e.g. `{audience} teacher`; at the top of a section, without a body.' }, options: {} },
97
+ {
98
+ name: 'component',
99
+ doc: { 'zh-CN': '插件组件,如 `{component} PmQuiz`;正文为 YAML 属性。', 'en': 'A plugin component, e.g. `{component} PmQuiz`; the body is YAML props.' },
100
+ options: { span: { type: 'number', doc: { 'zh-CN': '内部步数。', 'en': 'Internal steps.' } }, plugin: { type: 'string', doc: { 'zh-CN': '插件 ID。', 'en': 'Plugin id.' } } },
101
+ },
102
+ { name: 'include', doc: { 'zh-CN': '引入另一个源文件。', 'en': 'Includes another source file.' }, options: {} },
103
+ {
104
+ name: 'literalinclude',
105
+ doc: { 'zh-CN': '以代码显示一个文件(或其中几行);参数为文件路径。', 'en': 'Shows a file (or some of its lines) as code; the argument is the file path.' },
106
+ options: {
107
+ 'language': { type: 'string', doc: { 'zh-CN': '语言;默认按扩展名。', 'en': 'Language; by default from the extension.' } },
108
+ 'lines': { type: 'string', doc: { 'zh-CN': '只显示这些行,如 `2, 4-6`。', 'en': 'Only these lines, e.g. `2, 4-6`.' } },
109
+ 'start-after': { type: 'string', doc: { 'zh-CN': '从含这段文字的行之后开始。', 'en': 'Starts after the line with this text.' } },
110
+ 'end-before': { type: 'string', doc: { 'zh-CN': '到含这段文字的行之前结束。', 'en': 'Ends before the line with this text.' } },
111
+ 'caption': CODE_OPTIONS.caption!,
112
+ 'emphasize-lines': CODE_OPTIONS['emphasize-lines']!,
113
+ },
114
+ },
115
+ ...['code', 'code-block', 'code-cell', 'sourcecode'].map(name => ({ name, doc: { 'zh-CN': '代码;参数为语言。', 'en': 'Code; the argument is the language.' }, options: CODE_OPTIONS })),
116
+ ...LAYOUT_DIRECTIVES,
117
+ ]
@@ -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
+ }