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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,105 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
3
+ import { basename, join, relative, sep } from 'node:path'
4
+ import { deckLocale, escapeHtml } from '@lincyaw/kernel'
5
+ import type { SourcePos } from '@lincyaw/kernel'
6
+ import { DECK_ENTRY } from './compiler.ts'
7
+ import { inside } from './settings.ts'
8
+
9
+ export { inside }
10
+
11
+ export function lineOf(source: SourcePos, text: string): number {
12
+ try {
13
+ const lines = readFileSync(source.file, 'utf8').split(/\r?\n/)
14
+ for (let line = source.line; line <= Math.max(source.line, source.endLine ?? 0); line++) {
15
+ if (lines[line - 1]?.includes(text)) return line
16
+ }
17
+ } catch {
18
+ }
19
+ return source.line
20
+ }
21
+
22
+ const hashes = new Map<string, { stamp: string, hash: string }>()
23
+
24
+ const RACY_MS = 2000
25
+
26
+ export function fileHash(file: string): string {
27
+ const stat = statSync(file, { bigint: true })
28
+ const stamp = `${stat.size}:${stat.ino}:${stat.mtimeNs}:${stat.ctimeNs}`
29
+ const known = hashes.get(file)
30
+ if (known?.stamp === stamp) return known.hash
31
+ const hash = createHash('sha256').update(readFileSync(file)).digest('hex')
32
+ if (Date.now() - Number(stat.ctimeMs) > RACY_MS && Date.now() - Number(stat.mtimeMs) > RACY_MS) hashes.set(file, { stamp, hash })
33
+ else hashes.delete(file)
34
+ return hash
35
+ }
36
+
37
+ function unpublished(name: string): boolean {
38
+ return name.startsWith('.') || name === 'node_modules'
39
+ }
40
+
41
+ function directoryFiles(dir: string, prefix = ''): string[] {
42
+ return readdirSync(join(dir, prefix), { withFileTypes: true })
43
+ .filter(entry => !unpublished(entry.name))
44
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
45
+ .flatMap((entry) => {
46
+ const path = prefix ? `${prefix}/${entry.name}` : entry.name
47
+ if (entry.isDirectory()) return existsSync(join(dir, path, DECK_ENTRY)) ? [] : directoryFiles(dir, path)
48
+ return entry.isFile() ? [path] : []
49
+ })
50
+ }
51
+
52
+ const LISTING = {
53
+ 'zh-CN': { count: (n: number) => `${n} 个文件,点击下载`, files: '文件' },
54
+ 'en': { count: (n: number) => `${n} file${n === 1 ? '' : 's'}; select one to download it`, files: 'Files' },
55
+ }
56
+
57
+ function publishedDirectory(dir: string, stage: string, lang?: string): { file: string, name: string } {
58
+ const files = directoryFiles(dir)
59
+ const hash = createHash('sha256')
60
+ for (const file of files) hash.update(file).update('\0').update(fileHash(join(dir, file))).update('\0')
61
+ const name = `${hash.digest('hex').slice(0, 8)}-${basename(dir)}`
62
+ const target = join(stage, name)
63
+ if (existsSync(target)) return { file: target, name }
64
+ const partial = `${target}.${process.pid}`
65
+ rmSync(partial, { recursive: true, force: true })
66
+ mkdirSync(partial, { recursive: true })
67
+ for (const file of files) cpSync(join(dir, file), join(partial, file))
68
+ if (!files.includes('index.html')) {
69
+ const t = LISTING[deckLocale(lang)]
70
+ const href = (file: string) => file.split('/').map(encodeURIComponent).join('/')
71
+ const items = files.map(file => `<li><a href="${escapeHtml(href(file))}" download>${escapeHtml(file)}</a> <small>${statSync(join(dir, file)).size} B</small></li>`)
72
+ writeFileSync(join(partial, 'index.html'), `<!doctype html>
73
+ <html lang="${escapeHtml(lang ?? 'zh-CN')}">
74
+ <head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escapeHtml(basename(dir))}</title>
75
+ <style>body{font:16px/1.6 system-ui,sans-serif;max-width:48rem;margin:2rem auto;padding:0 1rem}small{color:#666}</style></head>
76
+ <body><h1>${escapeHtml(basename(dir))}/</h1><p>${escapeHtml(t.count(files.length))}</p>
77
+ <ul aria-label="${escapeHtml(t.files)}">
78
+ ${items.join('\n')}
79
+ </ul></body></html>
80
+ `)
81
+ }
82
+ try {
83
+ renameSync(partial, target)
84
+ } catch {
85
+ rmSync(partial, { recursive: true, force: true })
86
+ }
87
+ return { file: target, name }
88
+ }
89
+
90
+ export function publishesDirectory(target: string, deckDir: string): boolean {
91
+ return Boolean(lstatSync(target, { throwIfNoEntry: false })?.isDirectory()) && target !== deckDir && inside(target, deckDir) && !existsSync(join(target, DECK_ENTRY))
92
+ }
93
+
94
+ export function unpublishedDirectory(target: string, deckDir: string): string | undefined {
95
+ return relative(deckDir, target).split(sep).some(unpublished) ? 'hidden directories and node_modules are not published' : undefined
96
+ }
97
+
98
+ export function directoryAsset(target: string, deckDir: string, stage: string, lang: string | undefined, warn: (message: string) => void): { file: string, name: string } | undefined {
99
+ const refused = unpublishedDirectory(target, deckDir)
100
+ if (refused) {
101
+ warn(refused)
102
+ return undefined
103
+ }
104
+ return publishedDirectory(target, stage, lang)
105
+ }
@@ -0,0 +1,42 @@
1
+ import { irBlocks, nodeMdast } from '@lincyaw/kernel'
2
+ import type { Finding, IRDocument, MdastNode } from '@lincyaw/kernel'
3
+ import type { GenericParent } from 'myst-common'
4
+ import { normalizeLabel } from 'myst-common'
5
+ import { enumerateTargetsTransform, ReferenceState } from 'myst-transforms'
6
+
7
+ type Reference = Parameters<ReferenceState['resolveReferenceContent']>[0]
8
+
9
+ export function resolveReferences(doc: IRDocument, report: (finding: Finding) => void): void {
10
+ const state = new ReferenceState(doc.meta.entry)
11
+ const targets: MdastNode[] = []
12
+ for (const section of doc.sections) {
13
+ targets.push({ type: 'heading', depth: Math.max(section.depth, 1), ...normalizeLabel(section.id), children: [{ type: 'text', value: section.title }] })
14
+ for (const block of irBlocks(section.blocks)) {
15
+ if (block.node.type === 'markdown') targets.push(...block.node.mdast.children ?? [])
16
+ }
17
+ }
18
+ enumerateTargetsTransform({ type: 'root', children: targets } as GenericParent, { state })
19
+
20
+ for (const section of doc.sections) {
21
+ for (const block of irBlocks(section.blocks)) {
22
+ for (const reference of nodeMdast(block.node).flatMap(crossReferences)) {
23
+ const resolved = { ...reference } as Reference
24
+ state.resolveReferenceContent(resolved)
25
+ if (!resolved.resolved) {
26
+ report({
27
+ checkId: 'presmium.references',
28
+ severity: 'warning',
29
+ message: `{${String(reference.kind ?? 'ref')}} target "${String(reference.label ?? reference.identifier)}" not found`,
30
+ source: { file: block.source.file, line: block.source.line },
31
+ })
32
+ } else if (!reference.children?.length) {
33
+ reference.children = resolved.children as MdastNode[]
34
+ }
35
+ }
36
+ }
37
+ }
38
+ }
39
+
40
+ function crossReferences(node: MdastNode): MdastNode[] {
41
+ return node.type === 'crossReference' ? [node] : (node.children ?? []).flatMap(crossReferences)
42
+ }
@@ -0,0 +1,535 @@
1
+ import { existsSync, realpathSync, statSync, watch } from 'node:fs'
2
+ import type { FSWatcher } from 'node:fs'
3
+ import { mkdir, writeFile } from 'node:fs/promises'
4
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path'
5
+ import { errorMessage, irBlocks, isRecord } from '@lincyaw/kernel'
6
+ import type { ComponentNode, ComponentRenderer, DocumentLink, Finding, IRBlock, IRDocument, IRSection, LocalAsset, RenderContext, RenderDevServer, RenderOutput, Renderer, RendererExtension, SourcePos } from '@lincyaw/kernel'
7
+ import { unshown } from './audience.ts'
8
+ import { audienceCompilation, DECK_ENTRY } from './compiler.ts'
9
+ import type { AudienceCompilation, Compilation, Compiler } from './compiler.ts'
10
+ import { revisionFiles } from './dependencies.ts'
11
+ import { localAsset, portable, relativeIR } from './paths.ts'
12
+ import { directoryAsset, inside, lineOf, publishesDirectory } from './published.ts'
13
+ import { loadPlugin, pluginId } from './plugins.ts'
14
+ import { compileTranslations } from './translations.ts'
15
+ import type { CompiledTranslation } from './translations.ts'
16
+
17
+ export const WORK_DIR = '.presmium'
18
+
19
+ export const DEFAULT_RENDERER_PACKAGES: Record<string, string> = {
20
+ web: '@lincyaw/web',
21
+ slides: '@lincyaw/render-slides',
22
+ notebook: '@lincyaw/render-notebook',
23
+ }
24
+
25
+ export interface InstalledRenderer {
26
+ renderer: Renderer
27
+ package: string
28
+ }
29
+
30
+ export interface RendererOutput {
31
+ renderer: string
32
+ outDir: string
33
+ workDir: string
34
+ base: string
35
+ skipped: RenderOutput['skipped']
36
+ }
37
+
38
+ export interface BuildReport {
39
+ ir: string
40
+ outputs: RendererOutput[]
41
+ findings: Finding[]
42
+ }
43
+
44
+ async function installedRenderers(compilation: Compilation): Promise<{ renderers: Map<string, InstalledRenderer>, missing: Map<string, string> }> {
45
+ const renderers = new Map<string, InstalledRenderer>()
46
+ const missing = new Map<string, string>()
47
+ for (const { name, plugin } of compilation.plugins) {
48
+ if (plugin?.renderer && !renderers.has(plugin.renderer.id)) renderers.set(plugin.renderer.id, { renderer: plugin.renderer, package: name })
49
+ }
50
+ const config = compilation.doc.config.renderers
51
+ for (const id of new Set([...Object.keys(DEFAULT_RENDERER_PACKAGES), ...Object.keys(config)])) {
52
+ if (renderers.has(id)) continue
53
+ const pkg = typeof config[id]?.package === 'string' ? config[id].package as string : DEFAULT_RENDERER_PACKAGES[id]
54
+ if (!pkg) {
55
+ missing.set(id, 'no package provides it; set renderers.<id>.package')
56
+ continue
57
+ }
58
+ const { loaded, problem } = await loadPlugin(pkg, compilation.deckDir, compilation.pluginSources)
59
+ const renderer = loaded?.plugin?.renderer
60
+ if (renderer?.id === id) renderers.set(id, { renderer, package: pkg })
61
+ else missing.set(id, problem?.message ?? `package ${pkg} provides no renderer "${id}"`)
62
+ }
63
+ return { renderers, missing }
64
+ }
65
+
66
+ async function selectRenderers(compilation: Compilation, to?: string[]): Promise<{ selected: InstalledRenderer[], findings: Finding[] }> {
67
+ const { renderers, missing } = await installedRenderers(compilation)
68
+ const configured = Object.keys(compilation.doc.config.renderers)
69
+ const ids = to ?? (configured.length > 0 ? configured : [...renderers.keys()])
70
+ const findings: Finding[] = []
71
+ const selected: InstalledRenderer[] = []
72
+ for (const id of ids) {
73
+ const found = renderers.get(id)
74
+ if (found) selected.push(found)
75
+ else findings.push(renderFinding(compilation, to ? 'error' : 'warning', `renderer ${id} is not installed: ${missing.get(id) ?? 'unknown renderer id'}`))
76
+ }
77
+ return { selected, findings }
78
+ }
79
+
80
+ interface Run {
81
+ ids: string[]
82
+ bases: Map<string, string>
83
+ }
84
+
85
+ function configuredBase(value: unknown): string | undefined {
86
+ if (typeof value !== 'string' || !value.trim()) return undefined
87
+ const path = value.trim().replace(/^\/+|\/+$/g, '')
88
+ return path ? `/${path}/` : '/'
89
+ }
90
+
91
+ function runOf(compilation: Compilation, selected: InstalledRenderer[], shared: boolean): Run {
92
+ const renderers = selected.map(({ renderer }) => renderer)
93
+ const configured = new Map(renderers.map(({ id }) => [id, configuredBase(compilation.doc.config.renderers[id]?.base)]))
94
+ const sites = renderers.filter(renderer => renderer.site)
95
+ const together = shared && sites.length > 1 && sites.every(({ id }) => configured.get(id) === undefined)
96
+ return {
97
+ ids: renderers.map(({ id }) => id),
98
+ bases: new Map(renderers.map(({ id, site }) => [id, configured.get(id) ?? (together && site ? `/${id}/` : '/')])),
99
+ }
100
+ }
101
+
102
+ export interface BuildOptions {
103
+ outDir: string
104
+ to?: string[]
105
+ audience?: string
106
+ quote?: boolean
107
+ scan?: 'outDir' | 'written'
108
+ }
109
+
110
+ export async function build(whole: Compilation, options: BuildOptions): Promise<BuildReport> {
111
+ const compilation = options.audience && options.audience !== whole.audience?.name
112
+ ? { ...whole, doc: whole.forAudience(options.audience), audience: whole.audienceOf(options.audience) }
113
+ : whole
114
+ const { doc } = compilation
115
+ const ir = await writeIR(relativeIR(doc, compilation.deckDir, compilation.workspaceRoot), options.outDir)
116
+ const { selected, findings } = await selectRenderers(compilation, options.to)
117
+ if (selected.length === 0) findings.push(renderFinding(compilation, 'warning', `no renderer available; only the IR was written to ${ir}`))
118
+ const run = runOf(compilation, selected, true)
119
+ const translated = await translationsFor(compilation, run)
120
+ findings.push(...translated.findings)
121
+ const results = await Promise.all(selected.map(async ({ renderer }) => {
122
+ const reported: Finding[] = []
123
+ const ctx = renderContext(compilation, renderer.id, join(options.outDir, renderer.id), join(compilation.deckDir, WORK_DIR, renderer.id), run, reported)
124
+ if (renderer.id === WEB) ctx.translations = translated.views
125
+ const view = viewFor(doc, renderer.id)
126
+ const unsupported = unsupportedBlocks(view, renderer, ctx.extensions)
127
+ try {
128
+ await mkdir(ctx.outDir, { recursive: true })
129
+ const output = await renderer.build(view, ctx)
130
+ const listed = new Set(output.skipped.map(s => `${s.section}#${s.block}`))
131
+ 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}`))] }
132
+ return { result, reported }
133
+ } catch (error) {
134
+ reported.push(renderFinding(compilation, 'error', `renderer ${renderer.id} failed: ${errorMessage(error)}`))
135
+ return { reported }
136
+ }
137
+ }))
138
+ const outputs = results.flatMap(({ result }) => result ? [result] : [])
139
+ if (compilation.audience) {
140
+ const scan = audienceScan(compilation.audience, translated.translations, doc)
141
+ const paths = options.scan === 'written' ? [ir, ...outputs.map(output => output.outDir)] : undefined
142
+ findings.push(...await scan.scan(options.outDir, paths, { quote: options.quote ?? true }))
143
+ }
144
+ return { ir, outputs, findings: unique([...doc.diagnostics, ...findings, ...results.flatMap(({ reported }) => reported)]) }
145
+ }
146
+
147
+ function audienceScan(audience: AudienceCompilation, translations: CompiledTranslation[], doc: IRDocument): AudienceCompilation {
148
+ const kept = [doc, ...translations.map(translation => translation.doc)]
149
+ const left = [audience, ...translations.flatMap(translation => (translation.audience ? [translation.audience] : []))].flatMap(own => own.withheld)
150
+ return audienceCompilation(audience.name, unshown(left, kept))
151
+ }
152
+
153
+ const WEB = 'web'
154
+
155
+ async function translationsFor(compilation: Compilation, run: Run, compilers?: Map<string, Compiler>): Promise<{ translations: CompiledTranslation[], views: NonNullable<RenderContext['translations']>, findings: Finding[] }> {
156
+ if (!run.ids.includes(WEB) || !compilation.doc.config.translations) return { translations: [], views: [], findings: [] }
157
+ const { translations, findings } = await compileTranslations(compilation, compilers)
158
+ return { translations, views: translations.map(({ lang, doc }) => ({ lang, doc: viewFor(doc, WEB) })), findings }
159
+ }
160
+
161
+ function unique(findings: Finding[]): Finding[] {
162
+ const seen = new Set<string>()
163
+ return findings.filter((finding) => {
164
+ const key = JSON.stringify([finding.checkId, finding.severity, finding.message, finding.source?.file, finding.source?.line])
165
+ if (seen.has(key)) return false
166
+ seen.add(key)
167
+ return true
168
+ })
169
+ }
170
+
171
+ export async function prepare(compilation: Compilation, options: { outDir: string, to?: string[], base?: string }): Promise<{ outputs: Omit<RendererOutput, 'skipped'>[], findings: Finding[] }> {
172
+ const { selected, findings } = await selectRenderers(compilation, options.to)
173
+ const run = runOf(compilation, selected, true)
174
+ const base = configuredBase(options.base)
175
+ if (base) for (const id of run.ids) run.bases.set(id, base)
176
+ const outputs: Omit<RendererOutput, 'skipped'>[] = []
177
+ for (const { renderer } of selected) {
178
+ const ctx = renderContext(compilation, renderer.id, join(options.outDir, renderer.id), join(compilation.deckDir, WORK_DIR, renderer.id), run, findings)
179
+ if (!renderer.prepare) {
180
+ findings.push(renderFinding(compilation, 'warning', `renderer ${renderer.id} prepares nothing`))
181
+ continue
182
+ }
183
+ try {
184
+ await renderer.prepare(viewFor(compilation.doc, renderer.id), ctx)
185
+ outputs.push({ renderer: renderer.id, outDir: ctx.outDir, workDir: ctx.workDir, base: ctx.base })
186
+ } catch (error) {
187
+ findings.push(renderFinding(compilation, 'error', `renderer ${renderer.id} failed: ${errorMessage(error)}`))
188
+ }
189
+ }
190
+ return { outputs, findings: [...compilation.doc.diagnostics, ...findings] }
191
+ }
192
+
193
+ export interface DevSession {
194
+ servers: { renderer: string, url: string }[]
195
+ ir: string
196
+ update(compilation: Compilation): Promise<void>
197
+ close(): Promise<void>
198
+ }
199
+
200
+ export interface DevOptions {
201
+ outDir: string
202
+ to?: string[]
203
+ server?: { url: string }
204
+ onCompile?(compilation: Compilation, findings: Finding[]): void
205
+ compilation?: Compilation
206
+ base?: string
207
+ port?: number
208
+ preview?: { origins: string[] }
209
+ }
210
+
211
+ export async function dev(compiler: Compiler, options: DevOptions): Promise<DevSession> {
212
+ let started = Date.now()
213
+ let compilation = options.compilation ?? await compiler.compile()
214
+ const ir = await writeIR(compilation.doc, options.outDir)
215
+ const { selected, findings } = await selectRenderers(compilation, options.to)
216
+ const run = runOf(compilation, selected, false)
217
+ const base = configuredBase(options.base)
218
+ if (base) for (const id of run.ids) run.bases.set(id, base)
219
+ const translators = new Map<string, Compiler>()
220
+ let translated = await translationsFor(compilation, run, translators)
221
+ findings.push(...translated.findings)
222
+ const translations = translated.views
223
+ const servers: { renderer: string, url: string, server: RenderDevServer }[] = []
224
+ for (const { renderer } of selected) {
225
+ if (!renderer.dev) {
226
+ findings.push(renderFinding(compilation, 'warning', `renderer ${renderer.id} has no dev server`))
227
+ continue
228
+ }
229
+ const outDir = join(options.outDir, renderer.id)
230
+ const ctx = renderContext(compilation, renderer.id, outDir, outDir, run, findings, options.server)
231
+ if (options.port !== undefined) ctx.config = { ...ctx.config, port: options.port }
232
+ if (options.preview) ctx.preview = { origins: [...options.preview.origins] }
233
+ if (renderer.id === WEB) {
234
+ ctx.translations = translations
235
+ }
236
+ try {
237
+ await mkdir(ctx.outDir, { recursive: true })
238
+ const server = await renderer.dev(viewFor(compilation.doc, renderer.id), ctx)
239
+ servers.push({ renderer: renderer.id, url: server.url, server })
240
+ } catch (error) {
241
+ findings.push(renderFinding(compilation, 'error', `renderer ${renderer.id} failed to start: ${errorMessage(error)}`))
242
+ }
243
+ }
244
+ if (servers.length === 0) findings.push(renderFinding(compilation, 'warning', `no renderer dev server; watching the source and writing the IR to ${ir}`))
245
+ const setup = (c: Compilation) => JSON.stringify([c.doc.config.plugins, c.doc.config.renderers])
246
+ const initial = setup(compilation)
247
+
248
+ const watchers = new Map<string, FSWatcher>()
249
+ let relevant = new Map<string, Set<string>>()
250
+ let watched = new Set<string>()
251
+ let timer: NodeJS.Timeout | undefined
252
+ let running = Promise.resolve()
253
+ let queued = false
254
+ let closed = false
255
+ const schedule = () => {
256
+ if (closed) return
257
+ clearTimeout(timer)
258
+ timer = setTimeout(() => {
259
+ if (queued) return
260
+ queued = true
261
+ running = running.then(() => {
262
+ queued = false
263
+ clearTimeout(timer)
264
+ return recompile()
265
+ })
266
+ }, 30)
267
+ }
268
+ const watchFiles = async () => {
269
+ const { files, revisions } = compilation.dependencies
270
+ const revs = new Map<string, Set<string>>()
271
+ for (const { file, rev } of revisions) revs.set(dirname(file), (revs.get(dirname(file)) ?? new Set<string>()).add(rev))
272
+ const git = await Promise.all([...revs].map(([dir, names]) => revisionFiles(dir, names)))
273
+ if (closed) return
274
+ watched = new Set([...compilation.files, ...files, ...git.flat(), ...translated.translations.flatMap(t => t.files)])
275
+ const next = new Map<string, Set<string>>()
276
+ for (const file of watched) {
277
+ let dir = dirname(file)
278
+ let name = basename(file)
279
+ while (!existsSync(dir) && dirname(dir) !== dir) {
280
+ name = basename(dir)
281
+ dir = dirname(dir)
282
+ }
283
+ next.set(dir, (next.get(dir) ?? new Set<string>()).add(name))
284
+ }
285
+ relevant = next
286
+ for (const [dir, watcher] of watchers) {
287
+ if (relevant.has(dir)) continue
288
+ watcher.close()
289
+ watchers.delete(dir)
290
+ }
291
+ for (const dir of relevant.keys()) {
292
+ if (watchers.has(dir)) continue
293
+ try {
294
+ watchers.set(dir, watch(dir, (_event, name) => {
295
+ if (name && relevant.get(dir)?.has(basename(name))) schedule()
296
+ }))
297
+ } catch {
298
+ }
299
+ }
300
+ if (!queued && [...watched].some(file => modifiedSince(file, started))) schedule()
301
+ }
302
+ const push = async (next: Compilation, reported: Finding[]) => {
303
+ compilation = next
304
+ await writeIR(compilation.doc, options.outDir)
305
+ reported.push(...compilation.doc.diagnostics)
306
+ translated = await translationsFor(compilation, run, translators)
307
+ reported.push(...translated.findings)
308
+ translations.splice(0, translations.length, ...translated.views)
309
+ if (servers.length > 0 && setup(compilation) !== initial) {
310
+ reported.push(renderFinding(compilation, 'warning', 'the plugins or renderers in the headmatter changed; restart presmium dev to load them in the dev servers'))
311
+ }
312
+ for (const { renderer, server } of servers) {
313
+ await server.update(viewFor(compilation.doc, renderer)).catch((error: unknown) => {
314
+ reported.push(renderFinding(compilation, 'error', `renderer ${renderer} failed to update: ${errorMessage(error)}`))
315
+ })
316
+ }
317
+ }
318
+ const recompile = async () => {
319
+ started = Date.now()
320
+ const reported: Finding[] = []
321
+ try {
322
+ await push(await compiler.compile(), reported)
323
+ } catch (error) {
324
+ reported.push(renderFinding(compilation, 'error', errorMessage(error)))
325
+ }
326
+ await watchFiles()
327
+ options.onCompile?.(compilation, reported)
328
+ }
329
+ if (!options.compilation) await watchFiles()
330
+ options.onCompile?.(compilation, [...compilation.doc.diagnostics, ...findings])
331
+
332
+ return {
333
+ servers: servers.map(({ renderer, url }) => ({ renderer, url })),
334
+ ir,
335
+ async update(next) {
336
+ if (closed) return
337
+ const reported: Finding[] = []
338
+ running = running.then(() => push(next, reported)).catch((error: unknown) => {
339
+ reported.push(renderFinding(next, 'error', errorMessage(error)))
340
+ })
341
+ await running
342
+ options.onCompile?.(compilation, reported)
343
+ },
344
+ async close() {
345
+ closed = true
346
+ clearTimeout(timer)
347
+ for (const watcher of watchers.values()) watcher.close()
348
+ await running
349
+ await Promise.all(servers.map(s => s.server.close()))
350
+ },
351
+ }
352
+ }
353
+
354
+ function modifiedSince(file: string, time: number): boolean {
355
+ const modified = statSync(file, { throwIfNoEntry: false })?.mtimeMs ?? 0
356
+ return modified >= time - 20 && modified <= Date.now()
357
+ }
358
+
359
+ export function viewFor(doc: IRDocument, renderer: string): IRDocument {
360
+ let changed = false
361
+ const keep = (blocks: IRBlock[]): IRBlock[] => blocks.flatMap((block) => {
362
+ if (block.only && !block.only.includes(renderer)) {
363
+ changed = true
364
+ return []
365
+ }
366
+ return block.node.type === 'component' && block.node.children ? [{ ...block, node: { ...block.node, children: keep(block.node.children) } }] : [block]
367
+ })
368
+ const sections = doc.sections.map((section) => {
369
+ changed = false
370
+ const blocks = keep(section.blocks)
371
+ return changed ? compactSteps({ ...section, blocks }) : section
372
+ })
373
+ return { ...doc, sections }
374
+ }
375
+
376
+ function compactSteps(section: IRSection): IRSection {
377
+ const used = new Set([0])
378
+ for (const block of irBlocks(section.blocks)) {
379
+ if (block.role !== 'visual') continue
380
+ for (let step = block.at; step <= block.at + (block.span ?? 0); step++) used.add(step)
381
+ if (block.until !== undefined) used.add(block.until)
382
+ }
383
+ if (used.size > section.steps) return section
384
+ const stepMap: number[] = []
385
+ for (let step = 0, n = -1; step <= section.steps; step++) stepMap.push(used.has(step) ? ++n : n)
386
+ const map = (step: number) => stepMap[Math.min(step, section.steps)]!
387
+ const renumber = (blocks: IRBlock[]): IRBlock[] => blocks.map(block => ({
388
+ ...block,
389
+ at: map(block.at),
390
+ ...block.until === undefined ? {} : { until: map(block.until) },
391
+ ...block.node.type === 'component' && block.node.children ? { node: { ...block.node, children: renumber(block.node.children) } } : {},
392
+ }))
393
+ return { ...section, steps: map(section.steps), stepMap, blocks: renumber(section.blocks) }
394
+ }
395
+
396
+ async function writeIR(doc: IRDocument, outDir: string): Promise<string> {
397
+ const file = join(outDir, 'ir.json')
398
+ await mkdir(outDir, { recursive: true })
399
+ await writeFile(file, `${JSON.stringify(doc, null, 2)}\n`)
400
+ return file
401
+ }
402
+
403
+ function extensionOf(node: ComponentNode, extensions: RendererExtension[]) {
404
+ const owner = extensions.find(e => e.plugin === node.plugin && e.components?.[node.name])
405
+ ?? (node.plugin ? undefined : extensions.find(e => e.components?.[node.name]))
406
+ return owner?.components?.[node.name]
407
+ }
408
+
409
+ function renderContext(compilation: Compilation, id: string, outDir: string, workDir: string, run: Run, findings: Finding[], server?: { url: string }): RenderContext {
410
+ const { deckDir, workspaceRoot, doc } = compilation
411
+ const config = doc.config.renderers[id]
412
+ const report = (finding: Finding) => {
413
+ const located = { ...finding, source: finding.source ?? { file: compilation.entry, line: 1 } }
414
+ if (!findings.some(f => JSON.stringify(f) === JSON.stringify(located))) findings.push(located)
415
+ }
416
+ const vitePlugins = compilation.plugins.flatMap((loaded) => {
417
+ const contribute = loaded.plugin?.vitePlugins
418
+ if (!contribute) return []
419
+ const options = doc.config.presmium[pluginId(loaded)]
420
+ try {
421
+ return contribute.call(loaded.plugin, { deckDir, workspaceRoot, config: isRecord(options) ? options : {} })
422
+ } catch (error) {
423
+ report({ checkId: 'presmium.render', severity: 'error', message: `plugin ${pluginId(loaded)} failed to give its Vite plugins: ${errorMessage(error)}` })
424
+ return []
425
+ }
426
+ })
427
+ const ctx: RenderContext = {
428
+ deckDir,
429
+ workspaceRoot,
430
+ outDir,
431
+ workDir,
432
+ config: isRecord(config) ? config : {},
433
+ extensions: [],
434
+ plugins: doc.config.plugins,
435
+ pluginDirs: Object.fromEntries(compilation.plugins.map(loaded => [loaded.name, loaded.dir])),
436
+ lang: doc.meta.lang,
437
+ renderers: run.ids,
438
+ base: run.bases.get(id) ?? '/',
439
+ vitePlugins,
440
+ extensionFor: node => extensionOf(node, ctx.extensions),
441
+ portable: (value, from) => portable(value, from, workspaceRoot),
442
+ localAsset: (source, url) => publishedAsset(compilation, source, url, report),
443
+ documentLink: (source, url) => documentLink(compilation, source, url),
444
+ report,
445
+ service: compilation.service,
446
+ ...server ? { server } : {},
447
+ }
448
+ const bind = (render: ComponentRenderer) => (node: ComponentNode, block: IRBlock) => render(node, block, ctx)
449
+ ctx.extensions = compilation.plugins.flatMap((loaded): RendererExtension[] => {
450
+ const contribution = loaded.plugin?.rendererExtensions?.[id]
451
+ if (!contribution) return []
452
+ const components = contribution.components && Object.fromEntries(Object.entries(contribution.components).map(([name, render]) => [name, bind(render)]))
453
+ return [{ plugin: pluginId(loaded), ...components ? { components } : {} }]
454
+ })
455
+ return ctx
456
+ }
457
+
458
+ const REMOTE = /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i
459
+
460
+ const FILES_DIR = 'files'
461
+
462
+ function localTarget(source: SourcePos, url: string): string | undefined {
463
+ if (REMOTE.test(url)) return undefined
464
+ const path = url.replace(/[?#].*$/s, '')
465
+ if (!path || path.startsWith('/')) return undefined
466
+ let name = path
467
+ try {
468
+ name = decodeURIComponent(path)
469
+ } catch {
470
+ }
471
+ return resolve(dirname(source.file), name)
472
+ }
473
+
474
+ function documentLink(compilation: Compilation, source: SourcePos, url: string): DocumentLink | undefined {
475
+ const target = localTarget(source, url)
476
+ if (!target) return undefined
477
+ const dir = basename(target) === DECK_ENTRY && statSync(target, { throwIfNoEntry: false })?.isFile() ? dirname(target)
478
+ : statSync(target, { throwIfNoEntry: false })?.isDirectory() && existsSync(join(target, DECK_ENTRY)) ? target
479
+ : undefined
480
+ if (dir === undefined || !inside(dir, compilation.workspaceRoot)) return undefined
481
+ const hash = url.indexOf('#')
482
+ let fragment = hash < 0 ? '' : url.slice(hash + 1)
483
+ try {
484
+ fragment = decodeURIComponent(fragment)
485
+ } catch {
486
+ }
487
+ return { dir: relative(compilation.workspaceRoot, dir).split(sep).join('/'), ...fragment ? { fragment } : {} }
488
+ }
489
+
490
+ function publishedAsset(compilation: Compilation, source: SourcePos, url: string, report: (finding: Finding) => void): LocalAsset | undefined {
491
+ const { deckDir, workspaceRoot } = compilation
492
+ const warn = (message: string) => report({ checkId: 'presmium.links', severity: 'warning', message, source: { file: source.file, line: lineOf(source, url) } })
493
+ const target = localTarget(source, url)
494
+ const found = localAsset(deckDir, source, url)
495
+ if (found && target && basename(realpathSync(found.file)) === DECK_ENTRY) {
496
+ const linked = documentLink(compilation, source, url)
497
+ warn(`${url} is not published: it is the source of ${linked?.dir ? `the document ${linked.dir}` : 'a document'}, which a workspace site links as its page; this build keeps the link as written`)
498
+ return undefined
499
+ }
500
+ if (found && !inside(found.file, workspaceRoot) && existsSync(join(workspaceRoot, '.git'))) {
501
+ warn(`${url} is not published: the file lies outside the deck's repository`)
502
+ return undefined
503
+ }
504
+ if (found) return { ...found, directory: false }
505
+ if (!target) return undefined
506
+ if (!existsSync(target)) {
507
+ warn(`${url} is not published: there is no such file`)
508
+ return undefined
509
+ }
510
+ if (!publishesDirectory(target, deckDir)) return undefined
511
+ const directory = directoryAsset(target, deckDir, join(deckDir, WORK_DIR, FILES_DIR), compilation.doc.meta.lang, message => warn(`${url} is not published: ${message}`))
512
+ return directory && { ...directory, directory: true }
513
+ }
514
+
515
+ function unsupportedBlocks(doc: IRDocument, renderer: Renderer, extensions: RendererExtension[]): RenderOutput['skipped'] {
516
+ const skipped: RenderOutput['skipped'] = []
517
+ const visit = (section: string, blocks: IRBlock[]) => {
518
+ for (const block of blocks) {
519
+ let support
520
+ try {
521
+ support = renderer.support(block.node, extensions)
522
+ } catch {
523
+ support = 'none'
524
+ }
525
+ if (support === 'none') skipped.push({ section, block: block.id, reason: `${block.node.type} node not supported by ${renderer.id}` })
526
+ else if (block.node.type === 'component' && block.node.children) visit(section, block.node.children)
527
+ }
528
+ }
529
+ for (const section of doc.sections) visit(section.id, section.blocks)
530
+ return skipped
531
+ }
532
+
533
+ function renderFinding(compilation: Compilation, severity: Finding['severity'], message: string): Finding {
534
+ return { checkId: 'presmium.render', severity, message, source: { file: compilation.entry, line: 1 } }
535
+ }