@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/src/forbid.ts ADDED
@@ -0,0 +1,82 @@
1
+ import { irBlocks, isRecord, mdastText } from '@lincyaw/kernel'
2
+ import type { Finding, IRBlock, MdastNode, SourcePos } from '@lincyaw/kernel'
3
+ import { lineFinder } from './links.ts'
4
+ import type { PublishInput } from './links.ts'
5
+ import { viewFor } from './renderers.ts'
6
+
7
+ export function checkForbidden(input: PublishInput, headmatter: Record<string, unknown>): Finding[] {
8
+ const given = isRecord(headmatter.checks) ? headmatter.checks.forbid : undefined
9
+ if (given === undefined) return []
10
+ const line = lineFinder()
11
+ const at = (needle: string): SourcePos => ({ file: input.entry, line: line({ file: input.entry, line: 1, endLine: Number.MAX_SAFE_INTEGER }, [needle]) })
12
+ if (!Array.isArray(given) || !given.every(item => typeof item === 'string')) {
13
+ return [{ checkId: 'presmium.forbid', severity: 'error', message: 'checks.forbid must be a list of regular expressions', source: at('forbid') }]
14
+ }
15
+ const findings: Finding[] = []
16
+ const patterns: { text: string, pattern: RegExp }[] = []
17
+ for (const text of given) {
18
+ try {
19
+ patterns.push({ text, pattern: new RegExp(text, 'g') })
20
+ } catch (error) {
21
+ findings.push({ checkId: 'presmium.forbid', severity: 'error', message: `checks.forbid: ${(error as Error).message}`, source: at(text) })
22
+ }
23
+ }
24
+
25
+ const hits = new Map<string, { source: SourcePos, match: string, pattern: string, renderers: string[] }>()
26
+ for (const renderer of input.renderers) {
27
+ const view = viewFor(input.doc, renderer)
28
+ const texts = view.sections.flatMap(section => [
29
+ { source: section.source, text: section.title },
30
+ ...[...irBlocks(section.blocks)].map(block => ({ source: block.source, text: blockText(block) })),
31
+ ])
32
+ for (const { source, text } of texts) {
33
+ for (const { text: name, pattern } of patterns) {
34
+ for (const [match] of text.matchAll(pattern)) {
35
+ if (!match) continue
36
+ const key = `${source.file}\0${source.line}\0${name}\0${match}`
37
+ const hit = hits.get(key)
38
+ if (hit && !hit.renderers.includes(renderer)) hit.renderers.push(renderer)
39
+ else if (!hit) hits.set(key, { source, match, pattern: name, renderers: [renderer] })
40
+ }
41
+ }
42
+ }
43
+ }
44
+ for (const { source, match, pattern, renderers } of hits.values()) {
45
+ findings.push({
46
+ checkId: 'presmium.forbid',
47
+ severity: 'error',
48
+ message: `"${match}" matches ${pattern} of checks.forbid${renderers.length < input.renderers.length ? ` (in ${renderers.join(', ')})` : ''}`,
49
+ source: { file: source.file, line: line(source, [match]) },
50
+ })
51
+ }
52
+ return findings
53
+ }
54
+
55
+ function blockText(block: IRBlock): string {
56
+ const { node } = block
57
+ switch (node.type) {
58
+ case 'markdown': return `${mdastText(node.mdast)}\n${targets(node.mdast).join('\n')}`
59
+ case 'code': return [node.title ?? '', node.value, ...(node.variants ?? []).map(variant => variant.value), ...(node.outputs ?? []).flatMap(outputText)].join('\n')
60
+ case 'component': return [...propText(node.props), ...node.fallback ? [mdastText(node.fallback)] : []].join('\n')
61
+ }
62
+ }
63
+
64
+ function targets(node: MdastNode): string[] {
65
+ const own = node.type === 'html' ? node.value : node.url
66
+ return [...typeof own === 'string' ? [own] : [], ...(node.children ?? []).flatMap(targets)]
67
+ }
68
+
69
+ function outputText(run: NonNullable<Extract<IRBlock['node'], { type: 'code' }>['outputs']>[number]): string[] {
70
+ return [run.stdout, run.stderr, ...(run.outputs ?? []).flatMap((output) => {
71
+ if (output.output_type === 'stream') return [output.text]
72
+ if (output.output_type === 'error') return [output.evalue, ...output.traceback]
73
+ return Object.entries(output.data).flatMap(([type, value]) => (type.startsWith('text/') || type === 'application/json' ? propText(value) : []))
74
+ })]
75
+ }
76
+
77
+ function propText(value: unknown): string[] {
78
+ if (typeof value === 'string') return [value]
79
+ if (Array.isArray(value)) return value.flatMap(propText)
80
+ if (value && typeof value === 'object') return Object.values(value).flatMap(propText)
81
+ return []
82
+ }
package/src/host.ts ADDED
@@ -0,0 +1,56 @@
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 { frontmatterLine } from './compiler.ts'
6
+ import type { Compilation } from './compiler.ts'
7
+ import { build } from './renderers.ts'
8
+ import type { BuildReport } from './renderers.ts'
9
+
10
+ export function checkContext(compilation: Compilation): CheckContext {
11
+ return {
12
+ deckDir: compilation.deckDir,
13
+ workspaceRoot: compilation.workspaceRoot,
14
+ headmatter: compilation.frontmatter,
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
+ export async function pluginChecks(compilation: Compilation, options: { externalLinks?: boolean } = {}): Promise<Finding[]> {
24
+ const { entry } = compilation
25
+ const text = await readFile(entry, 'utf8').catch(() => '')
26
+ const at = (name: string) => ({ file: entry, line: frontmatterLine(text, name) })
27
+ const checks = new CheckRegistry()
28
+ const places = new Map<string, Finding['source']>()
29
+ const findings: Finding[] = []
30
+ for (const { name, plugin } of compilation.plugins) {
31
+ for (const check of plugin?.checks ?? []) {
32
+ try {
33
+ checks.register(check)
34
+ places.set(check.id, at(name))
35
+ } catch (error) {
36
+ findings.push({ checkId: 'presmium.plugins', severity: 'error', message: `plugin "${name}": ${errorMessage(error)}`, source: at(name) })
37
+ }
38
+ }
39
+ }
40
+ for (const finding of await checks.run(checkContext(compilation))) {
41
+ findings.push(finding.source ? finding : { ...finding, source: places.get(finding.checkId) ?? { file: entry, line: 1 } })
42
+ }
43
+ findings.push(...await compilation.publishChecks({ externalLinks: options.externalLinks }))
44
+ return findings
45
+ }
46
+
47
+ export async function runBlock(compilation: Compilation, target: { section: string, block: string }, signal?: AbortSignal): Promise<RunResult> {
48
+ const section = compilation.doc.sections.find(s => s.id === target.section)
49
+ const block = section && [...irBlocks(section.blocks)].find(b => b.id === target.block)
50
+ if (!block || block.node.type !== 'code') throw new Error(`no code block ${target.section}/${target.block}`)
51
+ return compilation.run(block.node, signal)
52
+ }
53
+
54
+ export function buildView(compilation: Compilation, options: { to?: string[], outDir?: string } = {}): Promise<BuildReport> {
55
+ return build(compilation, { outDir: options.outDir ?? join(compilation.deckDir, 'dist'), to: options.to })
56
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ export { audienceView } from './audience.ts'
2
+ export { Compiler, DECK_ENTRY, frontmatterLine, resolveEntry, sortFindings, workspacePlugins } from './compiler.ts'
3
+ export type { Compilation, CompilerOptions } from './compiler.ts'
4
+ export { BUILTIN_DIRECTIVE_INFO, COMMON_DIRECTIVE_OPTIONS, RUN_OPTION } from './directives.ts'
5
+ export type { DirectiveInfo, DirectiveOptionInfo } from './directives.ts'
6
+ export type { RunStats } from './execute.ts'
7
+ export { buildView, checkContext, runBlock } from './host.ts'
8
+ export { portable, relativeIR } from './paths.ts'
9
+ export { findPackageDir, loadPlugins, pluginId } from './plugins.ts'
10
+ export type { LoadedPlugin, PluginProblem, PluginSources } from './plugins.ts'
11
+ export { directoryAsset, fileHash, inside, lineOf, publishesDirectory } from './published.ts'
12
+ export { build, dev, prepare, viewFor } from './renderers.ts'
13
+ export type { BuildOptions, BuildReport } from './renderers.ts'
14
+ export { playwrightChromium, serveDirectory } from './serve.ts'
15
+ export type { ServedExtra, StaticServer } from './serve.ts'
16
+ export { operatorDir, operatorPlugins, pluginLogger, WORKSPACE_CONFIG, workspaceSettings } from './settings.ts'
17
+ export type { OperatorSettings } from './settings.ts'
18
+ export { readFrontmatter } from './source.ts'
19
+ export { compileTranslations } from './translations.ts'
20
+ export type { CompiledTranslation } from './translations.ts'
package/src/links.ts ADDED
@@ -0,0 +1,203 @@
1
+ import { execFile, spawnSync } from 'node:child_process'
2
+ import { existsSync, readFileSync, statSync } from 'node:fs'
3
+ import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path'
4
+ import { htmlAttributes, irBlocks, isRecord } from '@lincyaw/kernel'
5
+ import type { Finding, IRBlock, IRDocument, MdastNode, SourcePos } from '@lincyaw/kernel'
6
+ import { BUILTIN_DIRECTIVES } from './blocks.ts'
7
+ import { inside, publishesDirectory, unpublishedDirectory } from './published.ts'
8
+ import { viewFor } from './renderers.ts'
9
+ import { plainText, slugify, splitSections } from './sections.ts'
10
+ import { Parser, readFrontmatter } from './source.ts'
11
+
12
+ export interface PublishInput {
13
+ doc: IRDocument
14
+ entry: string
15
+ deckDir: string
16
+ workspaceRoot: string
17
+ renderers: string[]
18
+ }
19
+
20
+ const REMOTE = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i
21
+ const EXTERNAL = /^https?:\/\//i
22
+ const URL_ATTRIBUTES = new Set(['href', 'src', 'poster'])
23
+ const ID_ATTRIBUTES = new Set(['id', 'name'])
24
+ const LINKED = new Set(['link', 'image', 'definition', 'card'])
25
+
26
+ interface Problem {
27
+ source: SourcePos
28
+ url: string
29
+ message: string
30
+ renderers: string[]
31
+ }
32
+
33
+ export async function checkLinks(input: PublishInput, options: { externalLinks?: boolean } = {}): Promise<Finding[]> {
34
+ const problems = new Map<string, Problem>()
35
+ const external = new Map<string, SourcePos>()
36
+ const documents = new Map<string, Promise<Set<string>>>()
37
+ for (const renderer of input.renderers) {
38
+ const view = viewFor(input.doc, renderer)
39
+ const anchors = documentAnchors(view)
40
+ for (const section of view.sections) {
41
+ for (const block of irBlocks(section.blocks)) {
42
+ for (const url of blockUrls(block)) {
43
+ if (EXTERNAL.test(url)) {
44
+ if (!external.has(url)) external.set(url, block.source)
45
+ continue
46
+ }
47
+ const message = await linkProblem(url, block.source, input, anchors, documents)
48
+ if (!message) continue
49
+ const key = `${block.source.file}\0${block.source.line}\0${url}`
50
+ const known = problems.get(key)
51
+ if (known) known.renderers.push(renderer)
52
+ else problems.set(key, { source: block.source, url, message, renderers: [renderer] })
53
+ }
54
+ }
55
+ }
56
+ }
57
+ const line = lineFinder()
58
+ const findings = [...problems.values()].map(({ source, url, message, renderers }): Finding => ({
59
+ checkId: 'presmium.links',
60
+ severity: 'error',
61
+ message: `${message}${renderers.length < input.renderers.length ? ` (in ${renderers.join(', ')})` : ''}`,
62
+ source: { file: source.file, line: line(source, needles(url)) },
63
+ }))
64
+ if (options.externalLinks) findings.push(...await externalLinks(external, input.entry, line))
65
+ return findings
66
+ }
67
+
68
+ function blockUrls(block: IRBlock): string[] {
69
+ const { node } = block
70
+ const urls: string[] = []
71
+ const walk = (mdast: MdastNode) => {
72
+ if (LINKED.has(mdast.type) && typeof mdast.url === 'string') urls.push(mdast.url)
73
+ if (mdast.type === 'html' && typeof mdast.value === 'string') {
74
+ for (const { value } of htmlAttributes(mdast.value, URL_ATTRIBUTES)) urls.push(value)
75
+ }
76
+ mdast.children?.forEach(walk)
77
+ }
78
+ if (node.type === 'markdown') walk(node.mdast)
79
+ if (node.type === 'component') {
80
+ if (node.fallback) walk(node.fallback)
81
+ for (const url of Object.values(node.assets ?? {})) urls.push(url)
82
+ }
83
+ return urls.map(url => url.trim()).filter(Boolean)
84
+ }
85
+
86
+ async function linkProblem(url: string, source: SourcePos, input: PublishInput, anchors: Set<string>, documents: Map<string, Promise<Set<string>>>): Promise<string | undefined> {
87
+ if (REMOTE.test(url)) return undefined
88
+ const hash = url.indexOf('#')
89
+ const fragment = hash < 0 ? '' : decode(url.slice(hash + 1))
90
+ const path = decode((hash < 0 ? url : url.slice(0, hash)).replace(/\?.*$/s, ''))
91
+ if (!path) return fragment && !anchors.has(fragment) ? `#${fragment} is no section, block, label or heading of the deck` : undefined
92
+ const file = path.startsWith('/') ? join(input.deckDir, 'public', path) : resolve(dirname(source.file), path)
93
+ if (!inside(file, input.workspaceRoot)) return `${path} lies outside the repository: it cannot be published`
94
+ if (!existsSync(file)) return `${path}: no such file`
95
+ const hidden = publishesDirectory(file, input.deckDir) ? unpublishedDirectory(file, input.deckDir) : undefined
96
+ if (hidden) return `${path}: ${hidden}`
97
+ if (!fragment) return undefined
98
+ const entry = statSync(file).isDirectory() ? join(file, 'lecture.md') : file
99
+ if (extname(entry) !== '.md' || !existsSync(entry)) return undefined
100
+ if (entry === input.entry) return anchors.has(fragment) ? undefined : `${path}#${fragment}: #${fragment} is no section, block, label or heading of the deck`
101
+ if (!documents.has(entry)) documents.set(entry, sourceAnchors(entry))
102
+ return (await documents.get(entry)!).has(fragment) ? undefined : `${path}#${fragment}: no section, block, label or heading of ${relative(input.workspaceRoot, entry).split(sep).join('/')} has the id ${fragment}`
103
+ }
104
+
105
+ function documentAnchors(doc: IRDocument): Set<string> {
106
+ const anchors = new Set<string>()
107
+ for (const section of doc.sections) {
108
+ anchors.add(section.id)
109
+ for (const block of irBlocks(section.blocks)) {
110
+ anchors.add(block.id)
111
+ const { node } = block
112
+ if (node.type === 'markdown') mdastAnchors(node.mdast, anchors)
113
+ if (node.type === 'component' && node.fallback) mdastAnchors(node.fallback, anchors)
114
+ }
115
+ }
116
+ return anchors
117
+ }
118
+
119
+ function mdastAnchors(node: MdastNode, anchors: Set<string>): void {
120
+ for (const key of ['identifier', 'label', 'html_id'] as const) if (typeof node[key] === 'string' && node[key]) anchors.add(node[key] as string)
121
+ if (node.type === 'footnoteDefinition' && typeof node.identifier === 'string') anchors.add(`fn-${node.identifier}`)
122
+ if (node.type === 'heading') anchors.add(slugify(plainText(node)))
123
+ if (node.type === 'mystDirective' && isRecord(node.options) && typeof node.options.label === 'string') anchors.add(node.options.label)
124
+ if (node.type === 'html' && typeof node.value === 'string') {
125
+ for (const { value } of htmlAttributes(node.value, ID_ATTRIBUTES)) if (value) anchors.add(value)
126
+ }
127
+ node.children?.forEach(child => mdastAnchors(child, anchors))
128
+ }
129
+
130
+ async function sourceAnchors(entry: string): Promise<Set<string>> {
131
+ const anchors = new Set<string>()
132
+ try {
133
+ const parser = new Parser(new Map([...BUILTIN_DIRECTIVES]))
134
+ const { children } = await parser.expand(entry)
135
+ const text = readFileSync(entry, 'utf8')
136
+ const headmatter = readFrontmatter(text)
137
+ const depth = Number.isInteger(headmatter.sectionDepth) && (headmatter.sectionDepth as number) >= 1 ? headmatter.sectionDepth as number : 2
138
+ const title = typeof headmatter.title === 'string' ? headmatter.title : basename(dirname(entry))
139
+ for (const section of splitSections(children, { entry, title, sectionDepth: depth }, () => {})) anchors.add(section.id)
140
+ for (const node of children) mdastAnchors(node, anchors)
141
+ } catch {
142
+ }
143
+ return anchors
144
+ }
145
+
146
+ async function externalLinks(urls: Map<string, SourcePos>, entry: string, line: LineFinder): Promise<Finding[]> {
147
+ if (urls.size === 0) return []
148
+ const at = { file: entry, line: 1 }
149
+ if (spawnSync('lychee', ['--version'], { stdio: 'ignore' }).error) {
150
+ return [{ checkId: 'presmium.links', severity: 'info', message: `lychee is not installed: ${urls.size === 1 ? 'the external link was' : `the ${urls.size} external links were`} not checked (install lychee, https://lychee.cli.rs, to check them)`, source: at }]
151
+ }
152
+ const output = await new Promise<{ stdout: string, error?: Error }>((done) => {
153
+ const child = execFile('lychee', ['--format', 'json', '--no-progress', '-'], { maxBuffer: 64 << 20 }, (error, stdout) => done({ stdout, ...error ? { error } : {} }))
154
+ child.stdin?.on('error', () => {})
155
+ child.stdin?.end(`${[...urls.keys()].join('\n')}\n`)
156
+ })
157
+ let report: unknown
158
+ try {
159
+ report = JSON.parse(output.stdout)
160
+ } catch {
161
+ return [{ checkId: 'presmium.links', severity: 'warning', message: `lychee did not check the external links: ${output.error?.message ?? 'it printed no report'}`, source: at }]
162
+ }
163
+ const failed = isRecord(report) ? [report.fail_map, report.error_map].flatMap(map => (isRecord(map) ? Object.values(map).flat() : [])) : []
164
+ return failed.flatMap((item): Finding[] => {
165
+ if (!isRecord(item) || typeof item.url !== 'string') return []
166
+ const source = urls.get(item.url) ?? urls.get(item.url.replace(/\/$/, '')) ?? at
167
+ const status = isRecord(item.status) && typeof item.status.text === 'string' ? item.status.text : 'failed'
168
+ return [{ checkId: 'presmium.links', severity: 'warning', message: `${item.url}: ${status}`, source: { file: source.file, line: line(source, [item.url]) } }]
169
+ })
170
+ }
171
+
172
+ function needles(url: string): string[] {
173
+ return [...new Set([url, decode(url)])]
174
+ }
175
+
176
+ export type LineFinder = (source: SourcePos, needles: string[]) => number
177
+
178
+ export function lineFinder(): LineFinder {
179
+ const files = new Map<string, string[]>()
180
+ return (source, needles) => {
181
+ let lines = files.get(source.file)
182
+ if (!lines) {
183
+ try {
184
+ lines = readFileSync(source.file, 'utf8').split(/\r?\n/)
185
+ } catch {
186
+ lines = []
187
+ }
188
+ files.set(source.file, lines)
189
+ }
190
+ for (let line = source.line; line <= Math.min(source.endLine ?? source.line, lines.length); line++) {
191
+ if (needles.some(needle => needle && lines[line - 1]!.includes(needle))) return line
192
+ }
193
+ return source.line
194
+ }
195
+ }
196
+
197
+ function decode(text: string): string {
198
+ try {
199
+ return decodeURIComponent(text)
200
+ } catch {
201
+ return text
202
+ }
203
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,56 @@
1
+ import { existsSync, statSync } from 'node:fs'
2
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
3
+ import { irBlocks } from '@lincyaw/kernel'
4
+ import type { IRDocument, SourcePos } from '@lincyaw/kernel'
5
+ import { fileHash } from './published.ts'
6
+
7
+ const REMOTE = /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i
8
+
9
+ function inRepository(path: string, root: string): boolean {
10
+ if (!isAbsolute(path)) return false
11
+ const rel = relative(root, path)
12
+ return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)
13
+ }
14
+
15
+ function relativePath(from: string, path: string): string {
16
+ return relative(from, path).split(sep).join('/') || '.'
17
+ }
18
+
19
+ export function portable(value: unknown, from: string, root: string): unknown {
20
+ if (typeof value === 'string') return inRepository(value, root) ? relativePath(from, value) : value
21
+ if (Array.isArray(value)) return value.map(item => portable(item, from, root))
22
+ if (value !== null && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, portable(item, from, root)]))
23
+ return value
24
+ }
25
+
26
+ export function relativeIR(doc: IRDocument, deckDir: string, workspaceRoot: string): IRDocument {
27
+ const copy = structuredClone(doc)
28
+ const source = (pos: SourcePos) => {
29
+ if (isAbsolute(pos.file)) pos.file = relativePath(deckDir, pos.file)
30
+ }
31
+ if (isAbsolute(copy.meta.entry)) copy.meta.entry = relativePath(deckDir, copy.meta.entry)
32
+ copy.config.presmium = portable(copy.config.presmium, deckDir, workspaceRoot) as IRDocument['config']['presmium']
33
+ for (const section of copy.sections) {
34
+ source(section.source)
35
+ if (section.config) section.config = portable(section.config, deckDir, workspaceRoot) as typeof section.config
36
+ for (const block of irBlocks(section.blocks)) source(block.source)
37
+ }
38
+ for (const finding of copy.diagnostics) {
39
+ if (finding.source) source(finding.source as SourcePos)
40
+ }
41
+ return copy
42
+ }
43
+
44
+ export function localAsset(deckDir: string, source: SourcePos, url: string): { file: string, name: string } | undefined {
45
+ if (REMOTE.test(url)) return undefined
46
+ const path = url.replace(/[?#].*$/s, '')
47
+ if (!path) return undefined
48
+ let name = path
49
+ try {
50
+ name = decodeURIComponent(path)
51
+ } catch {
52
+ }
53
+ const file = name.startsWith('/') ? join(deckDir, 'public', name) : resolve(dirname(source.file), name)
54
+ if (!existsSync(file) || !statSync(file).isFile()) return undefined
55
+ return { file, name: `${fileHash(file).slice(0, 8)}-${basename(file)}` }
56
+ }
package/src/plugins.ts ADDED
@@ -0,0 +1,178 @@
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 } 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
+
10
+ export const NODE_PLUGIN_EXPORT = './presmium'
11
+
12
+ export interface LoadedPlugin {
13
+ name: string
14
+ dir: string
15
+ plugin?: PresmiumNodePlugin
16
+ }
17
+
18
+ export interface PluginProblem {
19
+ name: string
20
+ severity: Severity
21
+ message: string
22
+ }
23
+
24
+ const BUNDLED = new Set(['@lincyaw/web', '@lincyaw/render-slides', '@lincyaw/render-notebook'])
25
+
26
+ export interface PluginSources {
27
+ workspaceRoot: string
28
+ listed: string[]
29
+ bundled?: string
30
+ }
31
+
32
+ const jiti = createJiti(import.meta.url)
33
+
34
+ export async function loadPlugin(name: string, deckDir: string, sources?: PluginSources): Promise<{ loaded?: LoadedPlugin, problem?: PluginProblem }> {
35
+ const problem = (severity: Severity, message: string): PluginProblem => ({ name, severity, message })
36
+ const dir = findPackageDir(name, resolve(deckDir)) ?? bundledDir(name, sources)
37
+ if (!dir) return { problem: problem('error', `cannot load plugin "${name}": package not found from ${deckDir}`) }
38
+ return loadFrom(name, dir, problem)
39
+ }
40
+
41
+ function bundledDir(name: string, sources?: PluginSources): string | undefined {
42
+ if (!sources?.bundled) return undefined
43
+ return BUNDLED.has(name) || dependsOn(sources.bundled, name) ? findPackageDir(name, sources.bundled) : undefined
44
+ }
45
+
46
+ function dependsOn(dir: string, name: string): boolean {
47
+ try {
48
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { dependencies?: Record<string, string> }
49
+ return Object.hasOwn(pkg.dependencies ?? {}, name)
50
+ } catch {
51
+ return false
52
+ }
53
+ }
54
+
55
+ interface PluginPackage {
56
+ name?: unknown
57
+ version?: unknown
58
+ exports?: Record<string, unknown>
59
+ peerDependencies?: Record<string, unknown>
60
+ }
61
+
62
+ const PRESMIUM_SCOPE = '@lincyaw/'
63
+
64
+ const COMPILER_DIR = fileURLToPath(new URL('..', import.meta.url))
65
+ const COMPILER_PACKAGE = JSON.parse(readFileSync(join(COMPILER_DIR, 'package.json'), 'utf8')) as { name: string, version: string }
66
+
67
+ function peerIncompatibility(pkg: PluginPackage, dir: string): string | undefined {
68
+ const problems: string[] = []
69
+ for (const [dependency, range] of Object.entries(pkg.peerDependencies ?? {})) {
70
+ if (!dependency.startsWith(PRESMIUM_SCOPE) || typeof range !== 'string' || /^[a-z]+:/i.test(range)) continue
71
+ const installed = installedVersion(dependency, dir)
72
+ if (installed === undefined) continue
73
+ if (validRange(range) === null) problems.push(`its peer dependency ${dependency} has an invalid range "${range}"`)
74
+ else if (!satisfies(installed, range, { includePrerelease: true })) problems.push(`it needs ${dependency} ${range} (peerDependencies), and ${installed} is installed`)
75
+ }
76
+ return problems.length > 0 ? problems.join('; ') : undefined
77
+ }
78
+
79
+ function installedVersion(name: string, dir: string): string | undefined {
80
+ const found = findPackageDir(name, dir) ?? (name === COMPILER_PACKAGE.name ? COMPILER_DIR : findPackageDir(name, COMPILER_DIR))
81
+ if (!found) return undefined
82
+ const { version } = JSON.parse(readFileSync(join(found, 'package.json'), 'utf8')) as { version?: unknown }
83
+ return typeof version === 'string' ? version : undefined
84
+ }
85
+
86
+ async function loadFrom(name: string, dir: string, problem: (severity: Severity, message: string) => PluginProblem): Promise<{ loaded?: LoadedPlugin, problem?: PluginProblem }> {
87
+ try {
88
+ const pkg = JSON.parse(await readFile(join(dir, 'package.json'), 'utf8')) as PluginPackage
89
+ const peers = peerIncompatibility(pkg, dir)
90
+ if (peers) {
91
+ const label = typeof pkg.name === 'string' ? ` (${pkg.name}${typeof pkg.version === 'string' ? ` ${pkg.version}` : ''})` : ''
92
+ return { problem: problem('error', `plugin "${name}"${label} is not loaded: ${peers}`) }
93
+ }
94
+ const target = exportTarget(pkg.exports?.[NODE_PLUGIN_EXPORT])
95
+ if (!target) return { loaded: { name, dir } }
96
+ const file = join(dir, target)
97
+ if (!existsSync(file)) {
98
+ return { loaded: { name, dir }, problem: problem('warning', `plugin "${name}": ${NODE_PLUGIN_EXPORT} points to missing file ${target}; only its components are used`) }
99
+ }
100
+ const plugin = await jiti.import(file, { default: true })
101
+ if (!isNodePlugin(plugin)) throw new Error(`default export of ${NODE_PLUGIN_EXPORT} is not a PresmiumNodePlugin`)
102
+ const incompatible = plugin.kernel === undefined ? undefined : kernelIncompatibility(plugin.kernel)
103
+ if (incompatible) {
104
+ const version = plugin.version ? ` ${plugin.version}` : ''
105
+ return { problem: problem('error', `plugin "${name}" (${plugin.id}${version}) is not loaded: ${incompatible}`) }
106
+ }
107
+ return { loaded: { name, dir, plugin } }
108
+ } catch (error) {
109
+ return { problem: problem('error', `cannot load plugin "${name}": ${errorMessage(error)}`) }
110
+ }
111
+ }
112
+
113
+ export async function loadPlugins(deckDir: string, names: string[], sources?: PluginSources): Promise<{ plugins: LoadedPlugin[], problems: PluginProblem[] }> {
114
+ const plugins: LoadedPlugin[] = []
115
+ const problems: PluginProblem[] = []
116
+ for (const name of names) {
117
+ const { loaded, problem } = await loadPlugin(name, deckDir, sources)
118
+ if (loaded) plugins.push(loaded)
119
+ if (problem) problems.push(problem)
120
+ }
121
+ return { plugins, problems }
122
+ }
123
+
124
+ export async function loadOperatorPlugins(sources: PluginSources): Promise<{ plugins: LoadedPlugin[], problems: PluginProblem[] }> {
125
+ const plugins: LoadedPlugin[] = []
126
+ const problems: PluginProblem[] = []
127
+ for (const name of sources.listed) {
128
+ const dir = findPackageDir(name, resolve(sources.workspaceRoot), false) ?? bundledDir(name, sources)
129
+ if (!dir) {
130
+ problems.push({ name, severity: 'error', message: `operator plugin "${name}" (settings.presmium.workspace.plugins) is not installed at the workspace root ${sources.workspaceRoot}` })
131
+ continue
132
+ }
133
+ const { loaded, problem } = await loadFrom(name, dir, (severity, message) => ({ name, severity, message }))
134
+ if (problem) problems.push(problem)
135
+ if (loaded?.plugin?.operator === true) plugins.push(loaded)
136
+ }
137
+ return { plugins, problems }
138
+ }
139
+
140
+ export const DECK_PLUGIN = 'deck'
141
+
142
+ const COMPONENT = /^[^.]+\.(?:vue|[jt]sx?)$/
143
+
144
+ export async function directoryComponents(dir: string): Promise<string[]> {
145
+ const files = await readdir(join(dir, 'components')).catch(() => [])
146
+ return files.filter(f => COMPONENT.test(f)).map(f => f.replace(/\.[^.]+$/, '')).sort()
147
+ }
148
+
149
+ export function pluginId(plugin: LoadedPlugin): string {
150
+ return plugin.plugin?.id ?? plugin.name
151
+ }
152
+
153
+ export function findPackageDir(name: string, from: string, up = true): string | undefined {
154
+ if (name.startsWith('.') || isAbsolute(name)) {
155
+ const dir = resolve(from, name)
156
+ return existsSync(join(dir, 'package.json')) ? dir : undefined
157
+ }
158
+ for (let d = from; ; d = dirname(d)) {
159
+ const dir = join(d, 'node_modules', name)
160
+ if (existsSync(join(dir, 'package.json'))) return realpathSync(dir)
161
+ if (!up || dirname(d) === d) return undefined
162
+ }
163
+ }
164
+
165
+ function exportTarget(value: unknown): string | undefined {
166
+ if (typeof value === 'string') return value
167
+ if (!isRecord(value)) return undefined
168
+ return exportTarget(value.import) ?? exportTarget(value.node) ?? exportTarget(value.default)
169
+ }
170
+
171
+ function isNodePlugin(value: unknown): value is PresmiumNodePlugin {
172
+ if (!isRecord(value) || typeof value.id !== 'string') return false
173
+ const lists = ['resolvers', 'checks', 'directives', 'passes', 'executors', 'roles', 'tools'] as const
174
+ const renderer = value.renderer
175
+ return lists.every(key => value[key] === undefined || Array.isArray(value[key]))
176
+ && ['version', 'kernel'].every(key => value[key] === undefined || typeof value[key] === 'string')
177
+ && (renderer === undefined || (isRecord(renderer) && typeof renderer.id === 'string' && typeof renderer.build === 'function'))
178
+ }