@lincyaw/compiler 1.0.0-rc.0 → 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/references.ts CHANGED
@@ -1,42 +1,53 @@
1
1
  import { irBlocks, nodeMdast } from '@lincyaw/kernel'
2
- import type { Finding, IRDocument, MdastNode } from '@lincyaw/kernel'
2
+ import type { Finding, IRDocument, MdastNode, SourcePos } from '@lincyaw/kernel'
3
3
  import type { GenericParent } from 'myst-common'
4
4
  import { normalizeLabel } from 'myst-common'
5
5
  import { enumerateTargetsTransform, ReferenceState } from 'myst-transforms'
6
6
 
7
7
  type Reference = Parameters<ReferenceState['resolveReferenceContent']>[0]
8
+ const IMPLICIT_TARGETS = new Set(['container', 'mathGroup', 'math', 'heading', 'proof'])
8
9
 
9
10
  export function resolveReferences(doc: IRDocument, report: (finding: Finding) => void): void {
11
+ const references: { node: MdastNode, source: SourcePos }[] = []
12
+ for (const section of doc.sections) {
13
+ for (const block of irBlocks(section.blocks)) {
14
+ for (const tree of nodeMdast(block.node)) collectReferences(tree, block.source, references)
15
+ }
16
+ }
10
17
  const state = new ReferenceState(doc.meta.entry)
11
18
  const targets: MdastNode[] = []
12
19
  for (const section of doc.sections) {
13
20
  targets.push({ type: 'heading', depth: Math.max(section.depth, 1), ...normalizeLabel(section.id), children: [{ type: 'text', value: section.title }] })
14
21
  for (const block of irBlocks(section.blocks)) {
15
- if (block.node.type === 'markdown') targets.push(...block.node.mdast.children ?? [])
22
+ if (block.node.type === 'markdown') for (const child of block.node.mdast.children ?? []) collectTargets(child, targets)
16
23
  }
17
24
  }
18
25
  enumerateTargetsTransform({ type: 'root', children: targets } as GenericParent, { state })
19
26
 
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
- }
27
+ for (const { node: reference, source } of references) {
28
+ const resolved = { ...reference } as Reference
29
+ state.resolveReferenceContent(resolved)
30
+ if (!resolved.resolved) {
31
+ report({
32
+ checkId: 'presmium.references',
33
+ severity: 'warning',
34
+ message: `{${String(reference.kind ?? 'ref')}} target "${String(reference.label ?? reference.identifier)}" not found`,
35
+ source: { file: source.file, line: source.line },
36
+ })
37
+ } else if (!reference.children?.length) {
38
+ reference.children = resolved.children as MdastNode[]
36
39
  }
37
40
  }
38
41
  }
39
42
 
40
- function crossReferences(node: MdastNode): MdastNode[] {
41
- return node.type === 'crossReference' ? [node] : (node.children ?? []).flatMap(crossReferences)
43
+ function collectReferences(node: MdastNode, source: SourcePos, into: { node: MdastNode, source: SourcePos }[]): void {
44
+ if (node.type === 'crossReference') into.push({ node, source })
45
+ else for (const child of node.children ?? []) collectReferences(child, source, into)
46
+ }
47
+
48
+ function collectTargets(node: MdastNode, into: MdastNode[]): void {
49
+ // Keep target subtrees intact, including containers' subfigures and math groups.
50
+ // Unlabelled prose cannot change the enumeration or reference state.
51
+ if (node.identifier || node.enumerated || IMPLICIT_TARGETS.has(node.type)) into.push(node)
52
+ else for (const child of node.children ?? []) collectTargets(child, into)
42
53
  }
package/src/renderers.ts CHANGED
@@ -1,20 +1,15 @@
1
- import { existsSync, realpathSync, statSync, watch } from 'node:fs'
2
- import type { FSWatcher } from 'node:fs'
3
1
  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'
2
+ import { join } from 'node:path'
3
+ import { errorMessage, findComponentRenderer, isRecord } from '@lincyaw/kernel'
4
+ import type { BoundRendererExtension, ComponentNode, ComponentRenderer, Exporter, Finding, IRBlock, IRDocument, NodeVitePlugin, RenderContext, RenderOutput, Renderer } from '@lincyaw/kernel'
5
+ import { documentLink, publishedAsset } from './assets.ts'
6
+ import type { Compilation, Compiler } from './compiler.ts'
7
+ import { portable } from './paths.ts'
13
8
  import { loadPlugin, pluginId } from './plugins.ts'
9
+ import type { LoadedPlugin } from './plugins.ts'
14
10
  import { compileTranslations } from './translations.ts'
15
11
  import type { CompiledTranslation } from './translations.ts'
16
-
17
- export const WORK_DIR = '.presmium'
12
+ import { viewFor } from './views.ts'
18
13
 
19
14
  export const DEFAULT_RENDERER_PACKAGES: Record<string, string> = {
20
15
  web: '@lincyaw/web',
@@ -22,30 +17,38 @@ export const DEFAULT_RENDERER_PACKAGES: Record<string, string> = {
22
17
  notebook: '@lincyaw/render-notebook',
23
18
  }
24
19
 
20
+ export const WEB = 'web'
21
+
25
22
  export interface InstalledRenderer {
26
23
  renderer: Renderer
27
24
  package: string
25
+ /** The exporters of the renderer's package, with its configuration in the document. */
26
+ exporters: DocumentExporter[]
27
+ }
28
+
29
+ /** An exporter a document can use, with its plugin's configuration in the document. */
30
+ export interface DocumentExporter {
31
+ exporter: Exporter
32
+ config: Record<string, unknown>
28
33
  }
29
34
 
30
- export interface RendererOutput {
31
- renderer: string
32
- outDir: string
33
- workDir: string
34
- base: string
35
- skipped: RenderOutput['skipped']
35
+ function exportersOf(compilation: Compilation, loaded: LoadedPlugin): DocumentExporter[] {
36
+ const config = compilation.doc.config.presmium[pluginId(loaded)]
37
+ return (loaded.plugin?.exporters ?? []).map(exporter => ({ exporter, config: isRecord(config) ? config : {} }))
36
38
  }
37
39
 
38
- export interface BuildReport {
39
- ir: string
40
- outputs: RendererOutput[]
41
- findings: Finding[]
40
+ /** The renderers of one build or dev, and the base path of each. */
41
+ export interface RenderRun {
42
+ ids: string[]
43
+ bases: Map<string, string>
42
44
  }
43
45
 
44
46
  async function installedRenderers(compilation: Compilation): Promise<{ renderers: Map<string, InstalledRenderer>, missing: Map<string, string> }> {
45
47
  const renderers = new Map<string, InstalledRenderer>()
46
48
  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
+ for (const loaded of compilation.plugins) {
50
+ const { name, plugin } = loaded
51
+ if (plugin?.renderer && !renderers.has(plugin.renderer.id)) renderers.set(plugin.renderer.id, { renderer: plugin.renderer, package: name, exporters: exportersOf(compilation, loaded) })
49
52
  }
50
53
  const config = compilation.doc.config.renderers
51
54
  for (const id of new Set([...Object.keys(DEFAULT_RENDERER_PACKAGES), ...Object.keys(config)])) {
@@ -57,13 +60,13 @@ async function installedRenderers(compilation: Compilation): Promise<{ renderers
57
60
  }
58
61
  const { loaded, problem } = await loadPlugin(pkg, compilation.deckDir, compilation.pluginSources)
59
62
  const renderer = loaded?.plugin?.renderer
60
- if (renderer?.id === id) renderers.set(id, { renderer, package: pkg })
63
+ if (loaded && renderer?.id === id) renderers.set(id, { renderer, package: pkg, exporters: exportersOf(compilation, loaded) })
61
64
  else missing.set(id, problem?.message ?? `package ${pkg} provides no renderer "${id}"`)
62
65
  }
63
66
  return { renderers, missing }
64
67
  }
65
68
 
66
- async function selectRenderers(compilation: Compilation, to?: string[]): Promise<{ selected: InstalledRenderer[], findings: Finding[] }> {
69
+ export async function selectRenderers(compilation: Compilation, to?: string[]): Promise<{ selected: InstalledRenderer[], findings: Finding[] }> {
67
70
  const { renderers, missing } = await installedRenderers(compilation)
68
71
  const configured = Object.keys(compilation.doc.config.renderers)
69
72
  const ids = to ?? (configured.length > 0 ? configured : [...renderers.keys()])
@@ -77,353 +80,57 @@ async function selectRenderers(compilation: Compilation, to?: string[]): Promise
77
80
  return { selected, findings }
78
81
  }
79
82
 
80
- interface Run {
81
- ids: string[]
82
- bases: Map<string, string>
83
+ /** The exporters of the document's plugins and of its renderers' packages; one id once, the plugins' first. */
84
+ export async function documentExporters(compilation: Compilation): Promise<DocumentExporter[]> {
85
+ const { selected } = await selectRenderers(compilation)
86
+ const found = new Map<string, DocumentExporter>()
87
+ for (const own of [...compilation.plugins.flatMap(loaded => exportersOf(compilation, loaded)), ...selected.flatMap(installed => installed.exporters)]) {
88
+ if (!found.has(own.exporter.id)) found.set(own.exporter.id, own)
89
+ }
90
+ return [...found.values()]
83
91
  }
84
92
 
85
- function configuredBase(value: unknown): string | undefined {
93
+ export function configuredBase(value: unknown): string | undefined {
86
94
  if (typeof value !== 'string' || !value.trim()) return undefined
87
95
  const path = value.trim().replace(/^\/+|\/+$/g, '')
88
96
  return path ? `/${path}/` : '/'
89
97
  }
90
98
 
91
- function runOf(compilation: Compilation, selected: InstalledRenderer[], shared: boolean): Run {
99
+ /** With `shared`, site renderers without a configured base share the output under `/<id>/`. */
100
+ export function planRenderRun(compilation: Compilation, selected: InstalledRenderer[], shared: boolean, base?: string): RenderRun {
92
101
  const renderers = selected.map(({ renderer }) => renderer)
93
102
  const configured = new Map(renderers.map(({ id }) => [id, configuredBase(compilation.doc.config.renderers[id]?.base)]))
94
103
  const sites = renderers.filter(renderer => renderer.site)
95
104
  const together = shared && sites.length > 1 && sites.every(({ id }) => configured.get(id) === undefined)
96
- return {
105
+ const run: RenderRun = {
97
106
  ids: renderers.map(({ id }) => id),
98
107
  bases: new Map(renderers.map(({ id, site }) => [id, configured.get(id) ?? (together && site ? `/${id}/` : '/')])),
99
108
  }
109
+ const given = configuredBase(base)
110
+ if (given) for (const id of run.ids) run.bases.set(id, given)
111
+ return run
100
112
  }
101
113
 
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[] }> {
114
+ export async function translationsFor(compilation: Compilation, run: RenderRun, compilers?: Map<string, Compiler>): Promise<{ translations: CompiledTranslation[], views: NonNullable<RenderContext['translations']>, findings: Finding[] }> {
156
115
  if (!run.ids.includes(WEB) || !compilation.doc.config.translations) return { translations: [], views: [], findings: [] }
157
116
  const { translations, findings } = await compileTranslations(compilation, compilers)
158
117
  return { translations, views: translations.map(({ lang, doc }) => ({ lang, doc: viewFor(doc, WEB) })), findings }
159
118
  }
160
119
 
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> {
120
+ export async function writeIR(doc: IRDocument, outDir: string): Promise<string> {
397
121
  const file = join(outDir, 'ir.json')
398
122
  await mkdir(outDir, { recursive: true })
399
123
  await writeFile(file, `${JSON.stringify(doc, null, 2)}\n`)
400
124
  return file
401
125
  }
402
126
 
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 {
127
+ export function renderContext(compilation: Compilation, id: string, outDir: string, workDir: string, run: RenderRun, findings: Finding[], server?: { url: string }): RenderContext {
410
128
  const { deckDir, workspaceRoot, doc } = compilation
411
129
  const config = doc.config.renderers[id]
412
130
  const report = (finding: Finding) => {
413
131
  const located = { ...finding, source: finding.source ?? { file: compilation.entry, line: 1 } }
414
132
  if (!findings.some(f => JSON.stringify(f) === JSON.stringify(located))) findings.push(located)
415
133
  }
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
134
  const ctx: RenderContext = {
428
135
  deckDir,
429
136
  workspaceRoot,
@@ -436,8 +143,8 @@ function renderContext(compilation: Compilation, id: string, outDir: string, wor
436
143
  lang: doc.meta.lang,
437
144
  renderers: run.ids,
438
145
  base: run.bases.get(id) ?? '/',
439
- vitePlugins,
440
- extensionFor: node => extensionOf(node, ctx.extensions),
146
+ nodeVitePlugins: nodeVitePlugins(compilation),
147
+ extensionFor: node => findComponentRenderer(node, ctx.extensions),
441
148
  portable: (value, from) => portable(value, from, workspaceRoot),
442
149
  localAsset: (source, url) => publishedAsset(compilation, source, url, report),
443
150
  documentLink: (source, url) => documentLink(compilation, source, url),
@@ -446,7 +153,7 @@ function renderContext(compilation: Compilation, id: string, outDir: string, wor
446
153
  ...server ? { server } : {},
447
154
  }
448
155
  const bind = (render: ComponentRenderer) => (node: ComponentNode, block: IRBlock) => render(node, block, ctx)
449
- ctx.extensions = compilation.plugins.flatMap((loaded): RendererExtension[] => {
156
+ ctx.extensions = compilation.plugins.flatMap((loaded): BoundRendererExtension[] => {
450
157
  const contribution = loaded.plugin?.rendererExtensions?.[id]
451
158
  if (!contribution) return []
452
159
  const components = contribution.components && Object.fromEntries(Object.entries(contribution.components).map(([name, render]) => [name, bind(render)]))
@@ -455,64 +162,17 @@ function renderContext(compilation: Compilation, id: string, outDir: string, wor
455
162
  return ctx
456
163
  }
457
164
 
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 }
165
+ /** The Node entries of the plugins whose browser parts need them (run-time files, document modules), with their configuration. */
166
+ export function nodeVitePlugins(compilation: Pick<Compilation, 'plugins' | 'doc'>): NodeVitePlugin[] {
167
+ return compilation.plugins.flatMap((loaded) => {
168
+ const plugin = loaded.plugin
169
+ if (!loaded.entry || !(plugin?.assets || plugin?.documentModules)) return []
170
+ const config = compilation.doc.config.presmium[pluginId(loaded)]
171
+ return [{ id: pluginId(loaded), entry: loaded.entry, config: isRecord(config) ? config : {} }]
172
+ })
513
173
  }
514
174
 
515
- function unsupportedBlocks(doc: IRDocument, renderer: Renderer, extensions: RendererExtension[]): RenderOutput['skipped'] {
175
+ export function unsupportedBlocks(doc: IRDocument, renderer: Renderer, extensions: BoundRendererExtension[]): RenderOutput['skipped'] {
516
176
  const skipped: RenderOutput['skipped'] = []
517
177
  const visit = (section: string, blocks: IRBlock[]) => {
518
178
  for (const block of blocks) {
@@ -530,6 +190,6 @@ function unsupportedBlocks(doc: IRDocument, renderer: Renderer, extensions: Rend
530
190
  return skipped
531
191
  }
532
192
 
533
- function renderFinding(compilation: Compilation, severity: Finding['severity'], message: string): Finding {
193
+ export function renderFinding(compilation: Compilation, severity: Finding['severity'], message: string): Finding {
534
194
  return { checkId: 'presmium.render', severity, message, source: { file: compilation.entry, line: 1 } }
535
195
  }