@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/README.md +8 -4
- package/package.json +12 -13
- package/src/assets.ts +163 -0
- package/src/audience.ts +21 -216
- package/src/build.ts +101 -0
- package/src/{host.ts → compilation-actions.ts} +14 -7
- package/src/compile-queue.ts +28 -0
- package/src/compiler.ts +269 -195
- package/src/dev.ts +222 -0
- package/src/directives.ts +1 -0
- package/src/document-lowering.ts +176 -0
- package/src/environments.ts +6 -4
- package/src/execute.ts +50 -36
- package/src/export.ts +49 -0
- package/src/forbid.ts +6 -12
- package/src/frontmatter.ts +16 -0
- package/src/index.ts +21 -11
- package/src/{dependencies.ts → inputs.ts} +22 -13
- package/src/links.ts +21 -45
- package/src/logging.ts +16 -0
- package/src/node-services.ts +33 -0
- package/src/paths.ts +12 -26
- package/src/plugins.ts +53 -10
- package/src/references.ts +31 -20
- package/src/renderers.ts +61 -401
- package/src/sections.ts +6 -80
- package/src/serve.ts +58 -56
- package/src/source-lines.ts +47 -0
- package/src/translations.ts +51 -57
- package/src/validate.ts +3 -1
- package/src/views.ts +2 -0
- package/src/workspace-settings.ts +59 -0
- package/src/workspace.ts +17 -7
- package/src/blocks.ts +0 -557
- package/src/published.ts +0 -105
- package/src/settings.ts +0 -105
- package/src/source.ts +0 -300
- package/src/steps.ts +0 -66
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
|
+
}
|
package/src/environments.ts
CHANGED
|
@@ -5,7 +5,7 @@ const PREFIX = environmentExecutor('')
|
|
|
5
5
|
|
|
6
6
|
type At = (needle: string) => { file: string, line: number }
|
|
7
7
|
|
|
8
|
-
export function
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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
|
-
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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]
|