@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/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
|
+
}
|