@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.
@@ -0,0 +1,291 @@
1
+ import { lstat, readdir, readFile } from 'node:fs/promises'
2
+ import { basename, extname, join, relative } from 'node:path'
3
+ import { compactSteps, irBlocks, isRecord, nodeMdast, productionAssetIds, propText } from '@lincyaw/kernel'
4
+ import type { Finding, IRBlock, IRDocument, IRNode, IRSection, MdastNode, SourcePos } from '@lincyaw/kernel'
5
+ import { resolveReferences } from './references.ts'
6
+
7
+ export interface Removed {
8
+ source: SourcePos
9
+ audience: string[]
10
+ title?: string
11
+ blocks: IRBlock[]
12
+ nested?: boolean
13
+ }
14
+
15
+ export interface AudienceMark {
16
+ source: SourcePos
17
+ audience: string[]
18
+ }
19
+
20
+ export interface Withheld {
21
+ source: SourcePos
22
+ audience: string[]
23
+ sentences: string[]
24
+ }
25
+
26
+ const MIN_SENTENCE = 12
27
+
28
+ const BINARY = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.ico', '.bmp', '.mp3', '.mp4', '.m4a', '.webm', '.ogg', '.oga', '.wav', '.flac', '.woff', '.woff2', '.ttf', '.otf', '.eot', '.wasm', '.zip', '.gz', '.pdf'])
29
+
30
+ const HTML_ENTITIES: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '"', apos: '\'', nbsp: ' ' }
31
+
32
+ export function includesAudience(audience: string[] | undefined, name: string): boolean {
33
+ return !audience || audience.includes(name)
34
+ }
35
+
36
+ export function markOf(node: unknown): string[] | undefined {
37
+ const audience = isRecord(node) && isRecord(node.data) ? node.data.audience : undefined
38
+ return Array.isArray(audience) ? audience as string[] : undefined
39
+ }
40
+
41
+ /** Removes the sections and blocks not for the audience; `keeps` tells whether a finding lies in what is left. */
42
+ export function filterForAudience(doc: IRDocument, name: string, marks: AudienceMark[] = []): { doc: IRDocument, removed: Removed[], keeps: (finding: Finding) => boolean } {
43
+ const removed: Removed[] = []
44
+ const sections: IRSection[] = []
45
+ for (const section of doc.sections) {
46
+ if (!includesAudience(section.audience, name)) {
47
+ removed.push({ source: section.source, audience: section.audience!, title: section.title, blocks: section.blocks })
48
+ continue
49
+ }
50
+ let dropped = false
51
+ const keep = (blocks: IRBlock[]): IRBlock[] => blocks.flatMap((block) => {
52
+ if (!includesAudience(block.audience, name)) {
53
+ dropped = true
54
+ removed.push({ source: block.source, audience: block.audience!, blocks: [block] })
55
+ return []
56
+ }
57
+ const node = nodeFor(block, name, removed, keep)
58
+ return [node === block.node ? block : { ...block, node }]
59
+ })
60
+ const blocks = keep(section.blocks)
61
+ sections.push(dropped ? compactSteps({ ...section, blocks }) : same(blocks, section.blocks) ? section : { ...section, blocks })
62
+ }
63
+ const marked = marks.filter(mark => !includesAudience(mark.audience, name)).map(mark => mark.source)
64
+ if (removed.length === 0 && marked.length === 0) return { doc, removed, keeps: () => true }
65
+ const spans = [...leftOut(doc, name, removed), ...marked]
66
+ const keeps = ({ source: at }: Finding) => !at || !spans.some(span => at.file === span.file && (at.line ?? 0) >= span.line && (at.line ?? 0) <= (span.endLine ?? span.line))
67
+ const stepIds = new Set(sections.flatMap(section => section.stepIds ?? []))
68
+ const production = doc.production && { ...doc.production, ...doc.production.captionTimings ? { captionTimings: Object.fromEntries(Object.entries(doc.production.captionTimings).filter(([id]) => stepIds.has(id))) } : {}, mediaClips: doc.production.mediaClips.filter(clip => stepIds.has(clip.attachment.stepId)) }
69
+ return {
70
+ doc: {
71
+ ...doc,
72
+ sections: sections.map((section, i) => (section.index === i + 1 ? section : { ...section, index: i + 1 })),
73
+ diagnostics: doc.diagnostics.filter(keeps),
74
+ ...production ? { production, productionAssets: Object.fromEntries(productionAssetIds(production, sections).flatMap(id => doc.productionAssets?.[id] ? [[id, doc.productionAssets[id]!]] : [])) } : {},
75
+ },
76
+ removed,
77
+ keeps,
78
+ }
79
+ }
80
+
81
+ function leftOut(doc: IRDocument, name: string, removed: Removed[]): SourcePos[] {
82
+ const spans: SourcePos[] = removed.filter(item => !item.nested && item.title === undefined).map(item => item.source)
83
+ doc.sections.forEach((section, i) => {
84
+ if (includesAudience(section.audience, name)) return
85
+ const next = doc.sections.slice(i + 1).find(later => later.source.file === section.source.file)
86
+ spans.push({ file: section.source.file, line: section.source.line, endLine: next ? next.source.line - 1 : Number.MAX_SAFE_INTEGER })
87
+ for (const block of irBlocks(section.blocks)) if (block.source.file !== section.source.file) spans.push(block.source)
88
+ })
89
+ return spans
90
+ }
91
+
92
+ export function audienceDocument(doc: IRDocument, name: string, filled: WeakSet<object>, marks: AudienceMark[] = []): IRDocument {
93
+ const { doc: found, removed } = filterForAudience(doc, name, marks)
94
+ const view = { ...found, meta: { ...found.meta, audience: name } }
95
+ if (removed.length === 0) return view
96
+ const own = detached(view, filled)
97
+ resolveReferences(own, () => {})
98
+ return withoutLeaks(own, removed, name)
99
+ }
100
+
101
+ export function emptyReferences(doc: IRDocument): WeakSet<object> {
102
+ const found = new WeakSet<object>()
103
+ const visit = (node: MdastNode) => {
104
+ if (node.type === 'crossReference' && !node.children?.length) found.add(node)
105
+ node.children?.forEach(visit)
106
+ }
107
+ for (const section of doc.sections) for (const block of irBlocks(section.blocks)) nodeMdast(block.node).forEach(visit)
108
+ return found
109
+ }
110
+
111
+ export function withheld(removed: Removed[], kept: IRDocument): Withheld[] {
112
+ const shown = ownText(kept)
113
+ return removed.map(({ source, audience, title, blocks }) => {
114
+ const texts = [...title ? [title] : [], ...blocks.flatMap(block => [...irBlocks([block])].flatMap(sentenceSources))]
115
+ return { source, audience, sentences: [...new Set(texts.flatMap(sentences))].filter(sentence => !shown.includes(sentence)) }
116
+ })
117
+ }
118
+
119
+ export function unshown(left: Withheld[], docs: IRDocument[]): Withheld[] {
120
+ const shown = docs.map(ownText).join('\n')
121
+ return left.map(item => ({ ...item, sentences: item.sentences.filter(sentence => !shown.includes(sentence)) }))
122
+ }
123
+
124
+ function ownText(doc: IRDocument): string {
125
+ return doc.sections.flatMap(section => [section.title, ...[...irBlocks(section.blocks)].flatMap(sentenceSources)]).map(squash).join('\n')
126
+ }
127
+
128
+ export async function scanOutputs(left: Withheld[], paths: string[], audience: string, base: string, quote = true): Promise<Finding[]> {
129
+ const wanted = left.filter(item => item.sentences.length > 0)
130
+ const found = new Map<Withheld, { sentence: string, file: string }>()
131
+ const written = wanted.length > 0 ? (await Promise.all(paths.map(files))).flat() : []
132
+ for (const file of written) {
133
+ const buffer = await readFile(file).catch(() => undefined)
134
+ if (!buffer || BINARY.has(extname(file).toLowerCase()) || buffer.subarray(0, 8000).includes(0)) continue
135
+ const text = readable(buffer.toString('utf8'))
136
+ for (const item of wanted) {
137
+ if (found.has(item)) continue
138
+ const sentence = item.sentences.find(s => text.includes(s))
139
+ if (sentence) found.set(item, { sentence, file })
140
+ }
141
+ if (found.size === wanted.length) break
142
+ }
143
+ return [...found].map(([item, { sentence, file }]): Finding => ({
144
+ checkId: 'presmium.audience',
145
+ severity: 'error',
146
+ message: `content only for ${item.audience.join(', ')} is in the build for ${audience}: ${quote ? `"${sentence}" ` : ''}in ${relative(base, file)}`,
147
+ hint: 'it reached the build another way, e.g. through the output of a code block or a file a block reads; take it out there, or give that block the same :audience:',
148
+ source: { file: item.source.file, line: item.source.line },
149
+ }))
150
+ }
151
+
152
+ function same(blocks: readonly IRBlock[], before: readonly IRBlock[]): boolean {
153
+ return blocks.length === before.length && blocks.every((block, i) => block === before[i])
154
+ }
155
+
156
+ function nodeFor(block: IRBlock, name: string, removed: Removed[], keep: (blocks: IRBlock[]) => IRBlock[]): IRNode {
157
+ const { node } = block
158
+ const left: MdastNode[] = []
159
+ let result: IRNode = node
160
+ if (node.type === 'markdown') {
161
+ const mdast = prune(node.mdast, name, left)
162
+ if (mdast !== node.mdast) result = { ...node, mdast }
163
+ } else if (node.type === 'component') {
164
+ const props = prune(node.props, name, left)
165
+ const fallback = node.fallback && prune(node.fallback, name, left)
166
+ const children = node.children && keep(node.children)
167
+ if (props !== node.props || fallback !== node.fallback || (children && !same(children, node.children!))) {
168
+ result = { ...node, props, ...fallback ? { fallback } : {}, ...children ? { children } : {} }
169
+ }
170
+ }
171
+ for (const nested of left) {
172
+ removed.push({ source: block.source, audience: markOf(nested)!, blocks: [{ ...block, node: { type: 'markdown', mdast: { type: 'root', children: [nested] } } }], nested: true })
173
+ }
174
+ return result
175
+ }
176
+
177
+ function prune<T>(value: T, name: string, left: MdastNode[]): T {
178
+ if (Array.isArray(value)) {
179
+ let changed = false
180
+ const kept = value.flatMap((item: unknown) => {
181
+ if (!includesAudience(markOf(item), name)) {
182
+ left.push(item as MdastNode)
183
+ changed = true
184
+ return []
185
+ }
186
+ const next = prune(item, name, left)
187
+ if (next !== item) changed = true
188
+ return [next]
189
+ })
190
+ return (changed ? kept : value) as T
191
+ }
192
+ if (!isRecord(value)) return value
193
+ let changed = false
194
+ const entries = Object.entries(value).map(([key, item]) => {
195
+ const next = prune(item, name, left)
196
+ if (next !== item) changed = true
197
+ return [key, next] as const
198
+ })
199
+ return (changed ? Object.fromEntries(entries) : value) as T
200
+ }
201
+
202
+ function detached(view: IRDocument, filled: WeakSet<object>): IRDocument {
203
+ const copy = (node: MdastNode): MdastNode => {
204
+ if (filled.has(node)) {
205
+ const { children: _children, ...written } = node
206
+ return written
207
+ }
208
+ return node.children ? { ...node, children: node.children.map(copy) } : { ...node }
209
+ }
210
+ const blocks = (list: IRBlock[]): IRBlock[] => list.map((block) => {
211
+ const { node } = block
212
+ if (node.type === 'markdown') return { ...block, node: { ...node, mdast: copy(node.mdast) } }
213
+ if (node.type !== 'component') return block
214
+ return { ...block, node: { ...node, ...node.fallback ? { fallback: copy(node.fallback) } : {}, ...node.children ? { children: blocks(node.children) } : {} } }
215
+ })
216
+ return { ...view, sections: view.sections.map(section => ({ ...section, blocks: blocks(section.blocks) })) }
217
+ }
218
+
219
+ function withoutLeaks(view: IRDocument, removed: Removed[], name: string): IRDocument {
220
+ const wanted = withheld(removed, view).filter(item => item.sentences.length > 0)
221
+ if (wanted.length === 0) return view
222
+ const findings: Finding[] = []
223
+ const sections = view.sections.map((section) => {
224
+ const blocks = section.blocks.filter((block) => {
225
+ const text = readable(JSON.stringify(block.node, (key, value: unknown) => (key.startsWith('image/') || key === 'application/pdf' ? undefined : value)))
226
+ for (const item of wanted) {
227
+ if (!item.sentences.some(sentence => text.includes(sentence))) continue
228
+ findings.push({
229
+ checkId: 'presmium.audience',
230
+ severity: 'error',
231
+ message: `the block shows content only for ${item.audience.join(', ')} (written at ${basename(item.source.file)}:${item.source.line}); it is left out of the view for ${name}`,
232
+ hint: 'it reached the block another way, e.g. through the output of a code block; take it out there, or give the block the same :audience:',
233
+ source: { file: block.source.file, line: block.source.line },
234
+ })
235
+ return false
236
+ }
237
+ return true
238
+ })
239
+ return blocks.length === section.blocks.length ? section : compactSteps({ ...section, blocks })
240
+ })
241
+ return findings.length === 0 ? view : { ...view, sections, diagnostics: [...view.diagnostics, ...findings] }
242
+ }
243
+
244
+ /** The texts of a block that the leak scan splits into sentences: its own source, without recorded output. */
245
+ function sentenceSources(block: IRBlock): string[] {
246
+ const { node } = block
247
+ switch (node.type) {
248
+ case 'markdown': return mdastSentenceSources(node.mdast)
249
+ case 'code': return [node.title ?? '', node.value, ...(node.variants ?? []).map(variant => variant.value)]
250
+ case 'component': return [...propText(node.props), ...node.fallback ? mdastSentenceSources(node.fallback) : []]
251
+ }
252
+ }
253
+
254
+ function mdastSentenceSources(node: MdastNode): string[] {
255
+ const own: string[] = []
256
+ if (typeof node.value === 'string') own.push(node.type === 'html' ? node.value.replace(/<[^<>]*>/g, ' ') : node.value)
257
+ for (const key of ['alt', 'title'] as const) if (typeof node[key] === 'string') own.push(node[key] as string)
258
+ return [...own, ...(node.children ?? []).flatMap(mdastSentenceSources)]
259
+ }
260
+
261
+ function sentences(text: string): string[] {
262
+ return text.split(/[。!?!?;;\r\n]+|(?<=\.)\s+/u).map(squash).filter(sentence => [...sentence].length >= MIN_SENTENCE)
263
+ }
264
+
265
+ function squash(text: string): string {
266
+ return text.replace(/\s+/g, ' ').trim()
267
+ }
268
+
269
+ function readable(text: string): string {
270
+ const decoded = text
271
+ .replace(/\\u\{([\da-f]+)\}/gi, (_, hex: string) => String.fromCodePoint(Number.parseInt(hex, 16)))
272
+ .replace(/\\u([\da-f]{4})/gi, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)))
273
+ .replace(/\\x([\da-f]{2})/gi, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)))
274
+ .replace(/\\[nrt]/g, ' ')
275
+ .replace(/\\(["'`\\/])/g, '$1')
276
+ .replace(/&#x([\da-f]+);|&#(\d+);/gi, (_, hex: string | undefined, dec: string | undefined) => String.fromCodePoint(hex ? Number.parseInt(hex, 16) : Number(dec)))
277
+ .replace(/&(amp|lt|gt|quot|apos|nbsp);/g, (_, name: string) => HTML_ENTITIES[name]!)
278
+ return `${squash(decoded)}\n${squash(decoded.replace(/<[^<>]*>/g, ''))}`
279
+ }
280
+
281
+ async function files(path: string): Promise<string[]> {
282
+ const entries = await readdir(path, { withFileTypes: true }).catch(() => undefined)
283
+ if (!entries) return (await lstat(path).catch(() => undefined))?.isFile() ? [path] : []
284
+ const found: string[] = []
285
+ for (const entry of entries) {
286
+ const child = join(path, entry.name)
287
+ if (entry.isDirectory()) found.push(...await files(child))
288
+ else if (entry.isFile()) found.push(child)
289
+ }
290
+ return found
291
+ }
package/src/build.ts ADDED
@@ -0,0 +1,101 @@
1
+ import { mkdir } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { errorMessage } from '@lincyaw/kernel'
4
+ import type { Finding, IRDocument, RenderOutput } from '@lincyaw/kernel'
5
+ import { unshown } from './audience.ts'
6
+ import { audienceCompilation } from './compiler.ts'
7
+ import type { AudienceCompilation, Compilation } from './compiler.ts'
8
+ import { relativeIR, WORK_DIR } from './paths.ts'
9
+ import { planRenderRun, renderContext, renderFinding, selectRenderers, translationsFor, unsupportedBlocks, WEB, writeIR } from './renderers.ts'
10
+ import type { InstalledRenderer } from './renderers.ts'
11
+ import type { CompiledTranslation } from './translations.ts'
12
+ import { viewFor } from './views.ts'
13
+
14
+ export interface RendererOutput {
15
+ renderer: string
16
+ outDir: string
17
+ workDir: string
18
+ base: string
19
+ skipped: RenderOutput['skipped']
20
+ }
21
+
22
+ export interface BuildReport {
23
+ ir: string
24
+ outputs: RendererOutput[]
25
+ findings: Finding[]
26
+ }
27
+
28
+ export interface BuildOptions {
29
+ outDir: string
30
+ to?: string[]
31
+ audience?: string
32
+ quote?: boolean
33
+ scan?: 'outDir' | 'written'
34
+ /** The path every renderer's output is served at, in place of each renderer's own. */
35
+ base?: string
36
+ /** The renderers' tools' output, a line at a time (`RenderContext.log`). */
37
+ log?(line: string): void
38
+ }
39
+
40
+ export async function build(whole: Compilation, options: BuildOptions): Promise<BuildReport> {
41
+ const compilation = options.audience && options.audience !== whole.audience?.name
42
+ ? { ...whole, doc: whole.audienceDocument(options.audience), audience: whole.audienceCompilation(options.audience) }
43
+ : whole
44
+ const { doc } = compilation
45
+ const ir = await writeIR(relativeIR(doc, compilation.deckDir, compilation.workspaceRoot), options.outDir)
46
+ const { selected, findings } = await selectRenderers(compilation, options.to)
47
+ if (selected.length === 0) findings.push(renderFinding(compilation, 'warning', `no renderer available; only the IR was written to ${ir}`))
48
+ const run = planRenderRun(compilation, selected, true, options.base)
49
+ const translated = await translationsFor(compilation, run)
50
+ findings.push(...translated.findings)
51
+ const render = async ({ renderer }: InstalledRenderer) => {
52
+ const reported: Finding[] = []
53
+ const ctx = renderContext(compilation, renderer.id, join(options.outDir, renderer.id), join(compilation.deckDir, WORK_DIR, renderer.id), run, reported)
54
+ if (renderer.id === WEB) ctx.translations = translated.views
55
+ if (options.log) ctx.log = options.log
56
+ const view = viewFor(doc, renderer.id)
57
+ const unsupported = unsupportedBlocks(view, renderer, ctx.extensions)
58
+ try {
59
+ await mkdir(ctx.outDir, { recursive: true })
60
+ const output = await renderer.build(view, ctx)
61
+ const listed = new Set(output.skipped.map(s => `${s.section}#${s.block}`))
62
+ const result: RendererOutput = { renderer: renderer.id, outDir: output.outDir, workDir: ctx.workDir, base: ctx.base, skipped: [...output.skipped, ...unsupported.filter(s => !listed.has(`${s.section}#${s.block}`))] }
63
+ return { result, reported }
64
+ } catch (error) {
65
+ reported.push(renderFinding(compilation, 'error', `renderer ${renderer.id} failed: ${errorMessage(error)}`))
66
+ return { reported }
67
+ }
68
+ }
69
+ // Each renderer can start its own toolchain. Do not multiply their native pools.
70
+ const results: Awaited<ReturnType<typeof render>>[] = []
71
+ for (const renderer of selected) results.push(await render(renderer))
72
+ const outputs = results.flatMap(({ result }) => result ? [result] : [])
73
+ if (compilation.audience) {
74
+ const scan = audienceScan(compilation.audience, translated.translations, doc)
75
+ const paths = options.scan === 'written' ? [ir, ...outputs.map(output => output.outDir)] : undefined
76
+ findings.push(...await scan.scan(options.outDir, paths, { quote: options.quote ?? true }))
77
+ }
78
+ return { ir, outputs, findings: unique([...doc.diagnostics, ...findings, ...results.flatMap(({ reported }) => reported)]) }
79
+ }
80
+
81
+ function audienceScan(audience: AudienceCompilation, translations: CompiledTranslation[], doc: IRDocument): AudienceCompilation {
82
+ const kept = [doc, ...translations.map(translation => translation.doc)]
83
+ const left = [audience, ...translations.flatMap(translation => (translation.audience ? [translation.audience] : []))].flatMap(own => own.withheld)
84
+ return audienceCompilation(audience.name, unshown(left, kept))
85
+ }
86
+
87
+ function unique(findings: Finding[]): Finding[] {
88
+ const seen = new Set<string>()
89
+ return findings.filter((finding) => {
90
+ const key = JSON.stringify([finding.checkId, finding.severity, finding.message, finding.source?.file, finding.source?.line])
91
+ if (seen.has(key)) return false
92
+ seen.add(key)
93
+ return true
94
+ })
95
+ }
96
+
97
+ /** The renderers the document is made with: those its configuration names, else every installed one. */
98
+ export async function availableRenderers(compilation: Compilation): Promise<string[]> {
99
+ return (await selectRenderers(compilation)).selected.map(({ renderer }) => renderer.id)
100
+ }
101
+
@@ -0,0 +1,63 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { CheckRegistry, errorMessage, irBlocks } from '@lincyaw/kernel'
4
+ import type { CheckContext, Finding, RunResult } from '@lincyaw/kernel'
5
+ import { build } from './build.ts'
6
+ import type { BuildReport } from './build.ts'
7
+ import type { Compilation } from './compiler.ts'
8
+ import { frontmatterLine } from './source-lines.ts'
9
+
10
+ export function checkContext(compilation: Compilation): CheckContext {
11
+ return {
12
+ deckDir: compilation.deckDir,
13
+ workspaceRoot: compilation.workspaceRoot,
14
+ headmatter: compilation.headmatter,
15
+ src: compilation.src,
16
+ resolvers: compilation.resolvers,
17
+ lock: compilation.lock.toJSON(),
18
+ ir: compilation.doc,
19
+ service: compilation.service,
20
+ }
21
+ }
22
+
23
+ /** The plugins' checks and the publication checks; a crashed check or a duplicate check ID becomes a finding. */
24
+ export async function runAllChecks(compilation: Compilation, options: { externalLinks?: boolean } = {}): Promise<Finding[]> {
25
+ const { entry } = compilation
26
+ const text = await readFile(entry, 'utf8').catch(() => '')
27
+ const at = (name: string) => ({ file: entry, line: frontmatterLine(text, name) })
28
+ const checks = new CheckRegistry()
29
+ const places = new Map<string, Finding['source']>()
30
+ const findings: Finding[] = []
31
+ for (const { name, plugin } of compilation.plugins) {
32
+ for (const check of plugin?.checks ?? []) {
33
+ try {
34
+ checks.register(check)
35
+ places.set(check.id, at(name))
36
+ } catch (error) {
37
+ findings.push({ checkId: 'presmium.plugins', severity: 'error', message: `plugin "${name}": ${errorMessage(error)}`, source: at(name) })
38
+ }
39
+ }
40
+ }
41
+ for (const finding of await checks.run(checkContext(compilation))) {
42
+ findings.push(finding.source ? finding : { ...finding, source: places.get(finding.checkId) ?? { file: entry, line: 1 } })
43
+ }
44
+ findings.push(...await compilation.publishChecks({ externalLinks: options.externalLinks }))
45
+ return findings
46
+ }
47
+
48
+ export async function runBlock(compilation: Compilation, target: { section: string, block: string }, signal?: AbortSignal): Promise<RunResult> {
49
+ const section = compilation.doc.sections.find(s => s.id === target.section)
50
+ const block = section && [...irBlocks(section.blocks)].find(b => b.id === target.block)
51
+ if (!block || block.node.type !== 'code') throw new Error(`no code block ${target.section}/${target.block}`)
52
+ return compilation.run(block.node, signal)
53
+ }
54
+
55
+ /** Builds the compiled document into the deck's `dist/`. */
56
+ export function buildToDist(compilation: Compilation, options: { to?: string[] } = {}): Promise<BuildReport> {
57
+ return build(compilation, { outDir: join(compilation.deckDir, 'dist'), to: options.to })
58
+ }
59
+
60
+ /** What a host answers for a build: each renderer's output directory, and the findings. */
61
+ export function buildSummary(report: BuildReport): { outputs: { renderer: string, outDir: string }[], findings: Finding[] } {
62
+ return { outputs: report.outputs.map(({ renderer, outDir }) => ({ renderer, outDir })), findings: report.findings }
63
+ }
@@ -0,0 +1,28 @@
1
+ import { setImmediate } from 'node:timers/promises'
2
+ import { AsyncLocalStorage } from 'node:async_hooks'
3
+
4
+ type Job = { run(): Promise<void>, interactive: boolean }
5
+ const waiting: Job[] = []
6
+ let running = false
7
+ const nesting = new AsyncLocalStorage<boolean>()
8
+
9
+ /** One compilation per host process. Interactive work precedes queued batch builds. */
10
+ export function enqueueCompile<T>(run: () => Promise<T>, interactive: boolean): Promise<T> {
11
+ if (nesting.getStore()) return run()
12
+ return new Promise((resolve, reject) => {
13
+ waiting.push({ interactive, run: async () => { try { resolve(await nesting.run(true, run)) } catch (error) { reject(error) } } })
14
+ void next()
15
+ })
16
+ }
17
+
18
+ async function next(): Promise<void> {
19
+ if (running || waiting.length === 0) return
20
+ running = true
21
+ const firstInteractive = waiting.findIndex(job => job.interactive)
22
+ const job = waiting.splice(firstInteractive < 0 ? 0 : firstInteractive, 1)[0]!
23
+ try { await job.run() } finally {
24
+ await setImmediate()
25
+ running = false
26
+ void next()
27
+ }
28
+ }