@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.
- package/LICENSE +202 -0
- package/README.md +29 -2
- package/package.json +47 -3
- package/src/audience.ts +486 -0
- package/src/blocks.ts +557 -0
- package/src/compiler.ts +526 -0
- package/src/dependencies.ts +76 -0
- package/src/directives.ts +116 -0
- package/src/environments.ts +148 -0
- package/src/execute.ts +367 -0
- package/src/footnotes.ts +39 -0
- package/src/forbid.ts +82 -0
- package/src/host.ts +56 -0
- package/src/index.ts +20 -0
- package/src/links.ts +203 -0
- package/src/paths.ts +56 -0
- package/src/plugins.ts +178 -0
- package/src/published.ts +105 -0
- package/src/references.ts +42 -0
- package/src/renderers.ts +535 -0
- package/src/sections.ts +98 -0
- package/src/serve.ts +93 -0
- package/src/settings.ts +105 -0
- package/src/source.ts +300 -0
- package/src/steps.ts +66 -0
- package/src/translations.ts +139 -0
- package/src/validate.ts +105 -0
- package/src/workspace.ts +27 -0
package/src/published.ts
ADDED
|
@@ -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
|
+
}
|
package/src/renderers.ts
ADDED
|
@@ -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
|
+
}
|