@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/src/plugins.ts ADDED
@@ -0,0 +1,221 @@
1
+ import { existsSync, readFileSync, realpathSync } from 'node:fs'
2
+ import { readdir, readFile } from 'node:fs/promises'
3
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
4
+ import { fileURLToPath, pathToFileURL } from 'node:url'
5
+ import { errorMessage, isRecord, kernelIncompatibility } from '@lincyaw/kernel'
6
+ import type { PresmiumNodePlugin, Severity } from '@lincyaw/kernel'
7
+ import { createJiti } from 'jiti'
8
+ import { satisfies, validRange } from 'semver'
9
+ import { exports as resolveExports } from 'resolve.exports'
10
+
11
+ export const NODE_PLUGIN_EXPORT = './presmium'
12
+
13
+ export interface LoadedPlugin {
14
+ name: string
15
+ dir: string
16
+ plugin?: PresmiumNodePlugin
17
+ entry?: string
18
+ }
19
+
20
+ export interface PluginProblem {
21
+ name: string
22
+ severity: Severity
23
+ message: string
24
+ }
25
+
26
+ const BUNDLED = new Set(['@lincyaw/web', '@lincyaw/render-slides', '@lincyaw/render-notebook'])
27
+
28
+ export interface PluginSources {
29
+ workspaceRoot: string
30
+ listed: string[]
31
+ bundled?: string
32
+ }
33
+
34
+ const jiti = createJiti(import.meta.url)
35
+
36
+ export async function loadPlugin(name: string, deckDir: string, sources?: PluginSources): Promise<{ loaded?: LoadedPlugin, problem?: PluginProblem }> {
37
+ const problem = (severity: Severity, message: string): PluginProblem => ({ name, severity, message })
38
+ const dir = findPackageDir(name, resolve(deckDir)) ?? bundledDir(name, sources)
39
+ if (!dir) return { problem: problem('error', `cannot load plugin "${name}": package not found from ${deckDir}`) }
40
+ return loadFrom(name, dir, problem)
41
+ }
42
+
43
+ function bundledDir(name: string, sources?: PluginSources): string | undefined {
44
+ if (!sources?.bundled) return undefined
45
+ return BUNDLED.has(name) || dependsOn(sources.bundled, name) ? findPackageDir(name, sources.bundled) : undefined
46
+ }
47
+
48
+ function dependsOn(dir: string, name: string): boolean {
49
+ try {
50
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { dependencies?: Record<string, string> }
51
+ return Object.hasOwn(pkg.dependencies ?? {}, name)
52
+ } catch {
53
+ return false
54
+ }
55
+ }
56
+
57
+ interface PluginPackage {
58
+ name?: unknown
59
+ version?: unknown
60
+ exports?: Record<string, unknown>
61
+ peerDependencies?: Record<string, unknown>
62
+ }
63
+
64
+ const PRESMIUM_SCOPE = '@lincyaw/'
65
+
66
+ // The official plugins were named after Slidev's addons; a headmatter that still lists them gets the current packages.
67
+ const LEGACY_PLUGIN = '@lincyaw/slidev-addon-'
68
+ const PLUGIN = '@lincyaw/addon-'
69
+
70
+ /** A plugin's package as the headmatter lists it, under its current name. */
71
+ export function currentPluginName(name: string): string {
72
+ return name.startsWith(LEGACY_PLUGIN) ? PLUGIN + name.slice(LEGACY_PLUGIN.length) : name
73
+ }
74
+
75
+ // Plugins whose work the viewer does itself (the slides' host adapter, Slidev's); a headmatter may still list them.
76
+ const RETIRED_PLUGINS: ReadonlySet<string> = new Set(['@lincyaw/addon-core'])
77
+
78
+ export function isRetiredPlugin(name: string): boolean {
79
+ return RETIRED_PLUGINS.has(currentPluginName(name))
80
+ }
81
+
82
+ /** The plugins a headmatter lists, under their current names, the retired ones left out. */
83
+ export function listedPlugins(names: readonly string[]): string[] {
84
+ return names.filter(name => !isRetiredPlugin(name)).map(currentPluginName)
85
+ }
86
+
87
+ const COMPILER_DIR = fileURLToPath(new URL('..', import.meta.url))
88
+ const COMPILER_PACKAGE = JSON.parse(readFileSync(join(COMPILER_DIR, 'package.json'), 'utf8')) as { name: string, version: string }
89
+
90
+ function peerIncompatibility(pkg: PluginPackage, dir: string): string | undefined {
91
+ const problems: string[] = []
92
+ for (const [dependency, range] of Object.entries(pkg.peerDependencies ?? {})) {
93
+ if (!dependency.startsWith(PRESMIUM_SCOPE) || typeof range !== 'string' || /^[a-z]+:/i.test(range)) continue
94
+ const installed = installedVersion(dependency, dir)
95
+ if (installed === undefined) continue
96
+ if (validRange(range) === null) problems.push(`its peer dependency ${dependency} has an invalid range "${range}"`)
97
+ else if (!satisfies(installed, range, { includePrerelease: true })) problems.push(`it needs ${dependency} ${range} (peerDependencies), and ${installed} is installed`)
98
+ }
99
+ return problems.length > 0 ? problems.join('; ') : undefined
100
+ }
101
+
102
+ function installedVersion(name: string, dir: string): string | undefined {
103
+ const found = findPackageDir(name, dir) ?? (name === COMPILER_PACKAGE.name ? COMPILER_DIR : findPackageDir(name, COMPILER_DIR))
104
+ if (!found) return undefined
105
+ const { version } = JSON.parse(readFileSync(join(found, 'package.json'), 'utf8')) as { version?: unknown }
106
+ return typeof version === 'string' ? version : undefined
107
+ }
108
+
109
+ async function loadFrom(name: string, dir: string, problem: (severity: Severity, message: string) => PluginProblem): Promise<{ loaded?: LoadedPlugin, problem?: PluginProblem }> {
110
+ try {
111
+ const pkg = JSON.parse(await readFile(join(dir, 'package.json'), 'utf8')) as PluginPackage
112
+ const peers = peerIncompatibility(pkg, dir)
113
+ if (peers) {
114
+ const label = typeof pkg.name === 'string' ? ` (${pkg.name}${typeof pkg.version === 'string' ? ` ${pkg.version}` : ''})` : ''
115
+ return { problem: problem('error', `plugin "${name}"${label} is not loaded: ${peers}`) }
116
+ }
117
+ const target = exportTarget(pkg, dir)
118
+ if (!target) return { loaded: { name, dir } }
119
+ const file = fileURLToPath(new URL(target, pathToFileURL(`${dir}/`)))
120
+ if (!existsSync(file)) {
121
+ return { loaded: { name, dir }, problem: problem('warning', `plugin "${name}": ${NODE_PLUGIN_EXPORT} points to missing file ${target}; only its components are used`) }
122
+ }
123
+ const plugin = await jiti.import(file, { default: true })
124
+ if (!isNodePlugin(plugin)) throw new Error(`default export of ${NODE_PLUGIN_EXPORT} is not a PresmiumNodePlugin`)
125
+ const incompatible = plugin.kernel === undefined ? undefined : kernelIncompatibility(plugin.kernel)
126
+ if (incompatible) {
127
+ const version = plugin.version ? ` ${plugin.version}` : ''
128
+ return { problem: problem('error', `plugin "${name}" (${plugin.id}${version}) is not loaded: ${incompatible}`) }
129
+ }
130
+ return { loaded: { name, dir, plugin, entry: file } }
131
+ } catch (error) {
132
+ return { problem: problem('error', `cannot load plugin "${name}": ${errorMessage(error)}`) }
133
+ }
134
+ }
135
+
136
+ export async function loadPlugins(deckDir: string, names: string[], sources?: PluginSources): Promise<{ plugins: LoadedPlugin[], problems: PluginProblem[] }> {
137
+ const plugins: LoadedPlugin[] = []
138
+ const problems: PluginProblem[] = []
139
+ for (const name of names) {
140
+ const { loaded, problem } = await loadPlugin(name, deckDir, sources)
141
+ if (loaded) plugins.push(loaded)
142
+ if (problem) problems.push(problem)
143
+ }
144
+ return { plugins, problems }
145
+ }
146
+
147
+ export async function loadOperatorPlugins(sources: PluginSources): Promise<{ plugins: LoadedPlugin[], problems: PluginProblem[] }> {
148
+ const plugins: LoadedPlugin[] = []
149
+ const problems: PluginProblem[] = []
150
+ for (const name of listedPlugins(sources.listed)) {
151
+ const dir = findPackageDir(name, resolve(sources.workspaceRoot), false) ?? bundledDir(name, sources)
152
+ if (!dir) {
153
+ problems.push({ name, severity: 'error', message: `operator plugin "${name}" (settings.presmium.workspace.plugins) is not installed at the workspace root ${sources.workspaceRoot}` })
154
+ continue
155
+ }
156
+ const { loaded, problem } = await loadFrom(name, dir, (severity, message) => ({ name, severity, message }))
157
+ if (problem) problems.push(problem)
158
+ if (loaded?.plugin?.operator === true) plugins.push(loaded)
159
+ }
160
+ return { plugins, problems }
161
+ }
162
+
163
+ export const DECK_PLUGIN = 'deck'
164
+
165
+ const COMPONENT = /^[^.]+\.(?:[jt]sx?)$/
166
+
167
+ export async function directoryComponents(dir: string): Promise<string[]> {
168
+ const files = await readdir(join(dir, 'components')).catch(() => [])
169
+ return files.filter(f => COMPONENT.test(f)).map(f => f.replace(/\.[^.]+$/, '')).sort()
170
+ }
171
+
172
+ export function pluginId(plugin: LoadedPlugin): string {
173
+ return plugin.plugin?.id ?? plugin.name
174
+ }
175
+
176
+ export function findPackageDir(name: string, from: string, up = true): string | undefined {
177
+ if (name.startsWith('.') || isAbsolute(name)) {
178
+ const dir = resolve(from, name)
179
+ return existsSync(join(dir, 'package.json')) ? dir : undefined
180
+ }
181
+ for (let d = from; ; d = dirname(d)) {
182
+ const dir = join(d, 'node_modules', name)
183
+ if (existsSync(join(dir, 'package.json'))) return realpathSync(dir)
184
+ if (!up || dirname(d) === d) return undefined
185
+ }
186
+ }
187
+
188
+ function exportTarget(pkg: PluginPackage, dir: string): string | undefined {
189
+ let targets: string[] | void
190
+ try {
191
+ targets = resolveExports(pkg, NODE_PLUGIN_EXPORT)
192
+ } catch {
193
+ // A package with no matching Node export still contributes its components.
194
+ return undefined
195
+ }
196
+ if (!targets?.length) return undefined
197
+ const base = pathToFileURL(`${dir}/`)
198
+ for (const target of targets) {
199
+ const path = target.split(/[?#]/, 1)[0]!
200
+ if (!path.startsWith('./') || /%2f|%5c/i.test(path)) continue
201
+ try {
202
+ const segments = path.slice(2).split(/[\\/]/).map(decodeURIComponent)
203
+ if (segments.some(segment => /^(?:\.{1,2}|node_modules)$/i.test(segment))) continue
204
+ const normalized = new URL(target, base)
205
+ if (!normalized.href.startsWith(base.href)) continue
206
+ return target
207
+ } catch {
208
+ // Invalid URL encoding cannot name a package file; arrays may provide another target.
209
+ }
210
+ }
211
+ throw new Error(`${NODE_PLUGIN_EXPORT} has no valid package-relative Node target`)
212
+ }
213
+
214
+ function isNodePlugin(value: unknown): value is PresmiumNodePlugin {
215
+ if (!isRecord(value) || typeof value.id !== 'string') return false
216
+ const lists = ['resolvers', 'checks', 'directives', 'passes', 'executors', 'roles', 'tools'] as const
217
+ const renderer = value.renderer
218
+ return lists.every(key => value[key] === undefined || Array.isArray(value[key]))
219
+ && ['version', 'kernel'].every(key => value[key] === undefined || typeof value[key] === 'string')
220
+ && (renderer === undefined || (isRecord(renderer) && typeof renderer.id === 'string' && typeof renderer.build === 'function'))
221
+ }
@@ -0,0 +1,53 @@
1
+ import { irBlocks, nodeMdast } from '@lincyaw/kernel'
2
+ import type { Finding, IRDocument, MdastNode, SourcePos } 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
+ const IMPLICIT_TARGETS = new Set(['container', 'mathGroup', 'math', 'heading', 'proof'])
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
+ }
17
+ const state = new ReferenceState(doc.meta.entry)
18
+ const targets: MdastNode[] = []
19
+ for (const section of doc.sections) {
20
+ targets.push({ type: 'heading', depth: Math.max(section.depth, 1), ...normalizeLabel(section.id), children: [{ type: 'text', value: section.title }] })
21
+ for (const block of irBlocks(section.blocks)) {
22
+ if (block.node.type === 'markdown') for (const child of block.node.mdast.children ?? []) collectTargets(child, targets)
23
+ }
24
+ }
25
+ enumerateTargetsTransform({ type: 'root', children: targets } as GenericParent, { state })
26
+
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[]
39
+ }
40
+ }
41
+ }
42
+
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)
53
+ }
@@ -0,0 +1,195 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises'
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'
8
+ import { loadPlugin, pluginId } from './plugins.ts'
9
+ import type { LoadedPlugin } from './plugins.ts'
10
+ import { compileTranslations } from './translations.ts'
11
+ import type { CompiledTranslation } from './translations.ts'
12
+ import { viewFor } from './views.ts'
13
+
14
+ export const DEFAULT_RENDERER_PACKAGES: Record<string, string> = {
15
+ web: '@lincyaw/web',
16
+ slides: '@lincyaw/render-slides',
17
+ notebook: '@lincyaw/render-notebook',
18
+ }
19
+
20
+ export const WEB = 'web'
21
+
22
+ export interface InstalledRenderer {
23
+ renderer: Renderer
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>
33
+ }
34
+
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 : {} }))
38
+ }
39
+
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>
44
+ }
45
+
46
+ async function installedRenderers(compilation: Compilation): Promise<{ renderers: Map<string, InstalledRenderer>, missing: Map<string, string> }> {
47
+ const renderers = new Map<string, InstalledRenderer>()
48
+ const missing = new Map<string, string>()
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) })
52
+ }
53
+ const config = compilation.doc.config.renderers
54
+ for (const id of new Set([...Object.keys(DEFAULT_RENDERER_PACKAGES), ...Object.keys(config)])) {
55
+ if (renderers.has(id)) continue
56
+ const pkg = typeof config[id]?.package === 'string' ? config[id].package as string : DEFAULT_RENDERER_PACKAGES[id]
57
+ if (!pkg) {
58
+ missing.set(id, 'no package provides it; set renderers.<id>.package')
59
+ continue
60
+ }
61
+ const { loaded, problem } = await loadPlugin(pkg, compilation.deckDir, compilation.pluginSources)
62
+ const renderer = loaded?.plugin?.renderer
63
+ if (loaded && renderer?.id === id) renderers.set(id, { renderer, package: pkg, exporters: exportersOf(compilation, loaded) })
64
+ else missing.set(id, problem?.message ?? `package ${pkg} provides no renderer "${id}"`)
65
+ }
66
+ return { renderers, missing }
67
+ }
68
+
69
+ export async function selectRenderers(compilation: Compilation, to?: string[]): Promise<{ selected: InstalledRenderer[], findings: Finding[] }> {
70
+ const { renderers, missing } = await installedRenderers(compilation)
71
+ const configured = Object.keys(compilation.doc.config.renderers)
72
+ const ids = to ?? (configured.length > 0 ? configured : [...renderers.keys()])
73
+ const findings: Finding[] = []
74
+ const selected: InstalledRenderer[] = []
75
+ for (const id of ids) {
76
+ const found = renderers.get(id)
77
+ if (found) selected.push(found)
78
+ else findings.push(renderFinding(compilation, to ? 'error' : 'warning', `renderer ${id} is not installed: ${missing.get(id) ?? 'unknown renderer id'}`))
79
+ }
80
+ return { selected, findings }
81
+ }
82
+
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()]
91
+ }
92
+
93
+ export function configuredBase(value: unknown): string | undefined {
94
+ if (typeof value !== 'string' || !value.trim()) return undefined
95
+ const path = value.trim().replace(/^\/+|\/+$/g, '')
96
+ return path ? `/${path}/` : '/'
97
+ }
98
+
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 {
101
+ const renderers = selected.map(({ renderer }) => renderer)
102
+ const configured = new Map(renderers.map(({ id }) => [id, configuredBase(compilation.doc.config.renderers[id]?.base)]))
103
+ const sites = renderers.filter(renderer => renderer.site)
104
+ const together = shared && sites.length > 1 && sites.every(({ id }) => configured.get(id) === undefined)
105
+ const run: RenderRun = {
106
+ ids: renderers.map(({ id }) => id),
107
+ bases: new Map(renderers.map(({ id, site }) => [id, configured.get(id) ?? (together && site ? `/${id}/` : '/')])),
108
+ }
109
+ const given = configuredBase(base)
110
+ if (given) for (const id of run.ids) run.bases.set(id, given)
111
+ return run
112
+ }
113
+
114
+ export async function translationsFor(compilation: Compilation, run: RenderRun, compilers?: Map<string, Compiler>): Promise<{ translations: CompiledTranslation[], views: NonNullable<RenderContext['translations']>, findings: Finding[] }> {
115
+ if (!run.ids.includes(WEB) || !compilation.doc.config.translations) return { translations: [], views: [], findings: [] }
116
+ const { translations, findings } = await compileTranslations(compilation, compilers)
117
+ return { translations, views: translations.map(({ lang, doc }) => ({ lang, doc: viewFor(doc, WEB) })), findings }
118
+ }
119
+
120
+ export async function writeIR(doc: IRDocument, outDir: string): Promise<string> {
121
+ const file = join(outDir, 'ir.json')
122
+ await mkdir(outDir, { recursive: true })
123
+ await writeFile(file, `${JSON.stringify(doc, null, 2)}\n`)
124
+ return file
125
+ }
126
+
127
+ export function renderContext(compilation: Compilation, id: string, outDir: string, workDir: string, run: RenderRun, findings: Finding[], server?: { url: string }): RenderContext {
128
+ const { deckDir, workspaceRoot, doc } = compilation
129
+ const config = doc.config.renderers[id]
130
+ const report = (finding: Finding) => {
131
+ const located = { ...finding, source: finding.source ?? { file: compilation.entry, line: 1 } }
132
+ if (!findings.some(f => JSON.stringify(f) === JSON.stringify(located))) findings.push(located)
133
+ }
134
+ const ctx: RenderContext = {
135
+ deckDir,
136
+ workspaceRoot,
137
+ outDir,
138
+ workDir,
139
+ config: isRecord(config) ? config : {},
140
+ extensions: [],
141
+ plugins: doc.config.plugins,
142
+ pluginDirs: Object.fromEntries(compilation.plugins.map(loaded => [loaded.name, loaded.dir])),
143
+ lang: doc.meta.lang,
144
+ renderers: run.ids,
145
+ base: run.bases.get(id) ?? '/',
146
+ nodeVitePlugins: nodeVitePlugins(compilation),
147
+ extensionFor: node => findComponentRenderer(node, ctx.extensions),
148
+ portable: (value, from) => portable(value, from, workspaceRoot),
149
+ localAsset: (source, url) => publishedAsset(compilation, source, url, report),
150
+ documentLink: (source, url) => documentLink(compilation, source, url),
151
+ report,
152
+ service: compilation.service,
153
+ ...server ? { server } : {},
154
+ }
155
+ const bind = (render: ComponentRenderer) => (node: ComponentNode, block: IRBlock) => render(node, block, ctx)
156
+ ctx.extensions = compilation.plugins.flatMap((loaded): BoundRendererExtension[] => {
157
+ const contribution = loaded.plugin?.rendererExtensions?.[id]
158
+ if (!contribution) return []
159
+ const components = contribution.components && Object.fromEntries(Object.entries(contribution.components).map(([name, render]) => [name, bind(render)]))
160
+ return [{ plugin: pluginId(loaded), ...components ? { components } : {} }]
161
+ })
162
+ return ctx
163
+ }
164
+
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
+ })
173
+ }
174
+
175
+ export function unsupportedBlocks(doc: IRDocument, renderer: Renderer, extensions: BoundRendererExtension[]): RenderOutput['skipped'] {
176
+ const skipped: RenderOutput['skipped'] = []
177
+ const visit = (section: string, blocks: IRBlock[]) => {
178
+ for (const block of blocks) {
179
+ let support
180
+ try {
181
+ support = renderer.support(block.node, extensions)
182
+ } catch {
183
+ support = 'none'
184
+ }
185
+ if (support === 'none') skipped.push({ section, block: block.id, reason: `${block.node.type} node not supported by ${renderer.id}` })
186
+ else if (block.node.type === 'component' && block.node.children) visit(section, block.node.children)
187
+ }
188
+ }
189
+ for (const section of doc.sections) visit(section.id, section.blocks)
190
+ return skipped
191
+ }
192
+
193
+ export function renderFinding(compilation: Compilation, severity: Finding['severity'], message: string): Finding {
194
+ return { checkId: 'presmium.render', severity, message, source: { file: compilation.entry, line: 1 } }
195
+ }
@@ -0,0 +1,24 @@
1
+ import type { MdastNode } from '@lincyaw/kernel'
2
+
3
+ export function plainText(node: MdastNode): string {
4
+ if (typeof node.value === 'string' && !node.children) return node.value
5
+ return (node.children ?? []).map(plainText).join('')
6
+ }
7
+
8
+ /**
9
+ * The section ID a heading's text gives. Unlike the kernel's urlSlug, other characters are dropped rather than
10
+ * replaced, and only spaces, `_` and `-` become `-`; the IDs of existing documents depend on this rule.
11
+ */
12
+ export function sectionSlug(text: string): string {
13
+ return text.normalize('NFKC').toLowerCase()
14
+ .replace(/[^\p{L}\p{N}\s_-]/gu, '')
15
+ .trim()
16
+ .replace(/[\s_-]+/g, '-') || 'section'
17
+ }
18
+
19
+ export function unique(id: string, used: Set<string>): string {
20
+ let result = id
21
+ for (let n = 2; used.has(result); n++) result = `${id}-${n}`
22
+ used.add(result)
23
+ return result
24
+ }
package/src/serve.ts ADDED
@@ -0,0 +1,95 @@
1
+ import { createReadStream, existsSync } from 'node:fs'
2
+ import { createServer } from 'node:http'
3
+ import type { IncomingMessage, ServerResponse } from 'node:http'
4
+ import type { AddressInfo } from 'node:net'
5
+ import { basename, join, resolve } from 'node:path'
6
+ import { pathToFileURL } from 'node:url'
7
+ import contentDisposition from 'content-disposition'
8
+ import cors from 'cors'
9
+ import send from 'send'
10
+
11
+ export type ServedExtra = { file: string, type?: string } | { body: string | Uint8Array, type: string }
12
+
13
+ export interface StaticServer {
14
+ url: string
15
+ close(): Promise<void>
16
+ }
17
+
18
+ export interface ServeDirectoryOptions {
19
+ base?: string
20
+ extra?: Record<string, ServedExtra>
21
+ /** The origins whose pages may read the files (CORS), e.g. a video's captions from another origin's page. */
22
+ origins?: string[]
23
+ }
24
+
25
+ /**
26
+ * Serves the files of `root` below `base` on a loopback port, and `extra` at their paths. The files go through `send`
27
+ * (Express's): their types, ranges (a video's player seeks so), validators and conditional requests as HTTP defines them;
28
+ * `?download` asks for one as an attachment (`content-disposition`), and `origins` may read them (`cors`).
29
+ */
30
+ export async function serveDirectory(root: string, options: ServeDirectoryOptions = {}): Promise<StaticServer> {
31
+ const dir = resolve(root)
32
+ const base = options.base ?? '/'
33
+ const missing = (response: ServerResponse) => {
34
+ const page = join(dir, '404.html')
35
+ response.writeHead(404, existsSync(page) ? { 'Content-Type': 'text/html; charset=utf-8' } : {})
36
+ if (existsSync(page)) createReadStream(page).pipe(response)
37
+ else response.end()
38
+ }
39
+ const failed = (response: ServerResponse) => (error: { status?: number }) => {
40
+ if (response.headersSent) return response.destroy()
41
+ if (error.status === 404) return missing(response)
42
+ response.writeHead(error.status ?? 500)
43
+ response.end()
44
+ }
45
+ const shared = options.origins?.length ? cors({ origin: options.origins }) : undefined
46
+ const attached = (request: { url?: string }, file: string) => (response: ServerResponse) => {
47
+ if (new URL(request.url ?? '/', 'http://localhost').searchParams.has('download')) response.setHeader('Content-Disposition', contentDisposition(basename(file)))
48
+ }
49
+ const answer = (request: IncomingMessage, response: ServerResponse) => {
50
+ const path = new URL(request.url ?? '/', 'http://localhost').pathname
51
+ let decoded: string
52
+ try {
53
+ decoded = decodeURIComponent(path)
54
+ } catch {
55
+ response.writeHead(400)
56
+ response.end()
57
+ return
58
+ }
59
+ const extra = options.extra?.[decoded]
60
+ if (extra && 'body' in extra) {
61
+ response.writeHead(200, { 'Content-Type': extra.type })
62
+ response.end(extra.body)
63
+ return
64
+ }
65
+ if (extra) {
66
+ if (extra.type) response.setHeader('Content-Type', extra.type)
67
+ send(request, pathToFileURL(extra.file).pathname, { root: '/', dotfiles: 'allow' }).on('error', failed(response)).pipe(response)
68
+ return
69
+ }
70
+ if (!path.startsWith(base)) return missing(response)
71
+ send(request, path.slice(base.length - 1), { root: dir })
72
+ .on('error', failed(response))
73
+ .on('headers', attached(request, decoded))
74
+ // A directory's address without its slash: to the one with it, whose index.html is served.
75
+ .on('directory', () => {
76
+ response.writeHead(301, { Location: `${path}/${new URL(request.url ?? '/', 'http://localhost').search}` })
77
+ response.end()
78
+ })
79
+ .pipe(response)
80
+ }
81
+ const server = createServer((request, response) => {
82
+ if (shared) shared(request, response, () => answer(request, response))
83
+ else answer(request, response)
84
+ })
85
+ await new Promise<void>(done => server.listen(0, '127.0.0.1', done))
86
+ return {
87
+ url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
88
+ close: () => new Promise((done) => {
89
+ server.close(() => done())
90
+ server.closeAllConnections()
91
+ }),
92
+ }
93
+ }
94
+
95
+ export { playwrightChromium } from '@lincyaw/kernel/node'
@@ -0,0 +1,47 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import type { SourcePos } from '@lincyaw/kernel'
3
+ import { withoutBom } from './frontmatter.ts'
4
+
5
+ export type LineFinder = (source: SourcePos, needles: string[]) => number
6
+
7
+ /** Finds the first line of a source span that contains one of the needles; reads each file once. */
8
+ export function lineFinder(): LineFinder {
9
+ const files = new Map<string, string[]>()
10
+ return (source, needles) => {
11
+ let lines = files.get(source.file)
12
+ if (!lines) {
13
+ try {
14
+ lines = readFileSync(source.file, 'utf8').split(/\r?\n/)
15
+ } catch {
16
+ lines = []
17
+ }
18
+ files.set(source.file, lines)
19
+ }
20
+ for (let line = source.line; line <= Math.min(source.endLine ?? source.line, lines.length); line++) {
21
+ if (needles.some(needle => needle && lines[line - 1]!.includes(needle))) return line
22
+ }
23
+ return source.line
24
+ }
25
+ }
26
+
27
+ /** The first line of the span that contains the text, or the span's first line. */
28
+ export function lineContaining(source: SourcePos, text: string): number {
29
+ return lineFinder()(source, [text])
30
+ }
31
+
32
+ /** The 1-based line of the text that contains the needle, or 1. */
33
+ export function textLine(text: string, needle: string): number {
34
+ return firstLineWith(text.split(/\r?\n/), needle)
35
+ }
36
+
37
+ /** The line of the headmatter that contains the needle, or 1. */
38
+ export function frontmatterLine(text: string, needle: string): number {
39
+ const lines = withoutBom(text).split(/\r?\n/)
40
+ const end = lines.indexOf('---', 1)
41
+ return firstLineWith(lines.slice(0, end < 0 ? undefined : end), needle)
42
+ }
43
+
44
+ function firstLineWith(lines: string[], needle: string): number {
45
+ const index = lines.findIndex(line => line.includes(needle))
46
+ return index < 0 ? 1 : index + 1
47
+ }