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