@lincyaw/compiler 0.0.0-stage → 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +33 -2
- package/package.json +46 -3
- package/src/assets.ts +163 -0
- package/src/audience.ts +291 -0
- package/src/build.ts +101 -0
- package/src/compilation-actions.ts +63 -0
- package/src/compile-queue.ts +28 -0
- package/src/compiler.ts +600 -0
- package/src/dev.ts +222 -0
- package/src/directives.ts +117 -0
- package/src/document-lowering.ts +176 -0
- package/src/environments.ts +150 -0
- package/src/execute.ts +381 -0
- package/src/export.ts +49 -0
- package/src/footnotes.ts +39 -0
- package/src/forbid.ts +76 -0
- package/src/frontmatter.ts +16 -0
- package/src/index.ts +30 -0
- package/src/inputs.ts +85 -0
- package/src/links.ts +179 -0
- package/src/logging.ts +16 -0
- package/src/node-services.ts +33 -0
- package/src/paths.ts +42 -0
- package/src/plugins.ts +221 -0
- package/src/references.ts +53 -0
- package/src/renderers.ts +195 -0
- package/src/sections.ts +24 -0
- package/src/serve.ts +95 -0
- package/src/source-lines.ts +47 -0
- package/src/translations.ts +133 -0
- package/src/validate.ts +107 -0
- package/src/views.ts +2 -0
- package/src/workspace-settings.ts +59 -0
- package/src/workspace.ts +37 -0
package/src/forbid.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { irBlocks, isRecord, mdastText, propText } from '@lincyaw/kernel'
|
|
2
|
+
import type { Finding, IRBlock, MdastNode, SourcePos } from '@lincyaw/kernel'
|
|
3
|
+
import type { PublishInput } from './links.ts'
|
|
4
|
+
import { lineFinder } from './source-lines.ts'
|
|
5
|
+
import { viewFor } from './views.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: searchableText(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
|
+
/** What checks.forbid searches in a block: its text with link targets, every code variant and the recorded output. */
|
|
56
|
+
function searchableText(block: IRBlock): string {
|
|
57
|
+
const { node } = block
|
|
58
|
+
switch (node.type) {
|
|
59
|
+
case 'markdown': return `${mdastText(node.mdast)}\n${targets(node.mdast).join('\n')}`
|
|
60
|
+
case 'code': return [node.title ?? '', node.value, ...(node.variants ?? []).map(variant => variant.value), ...(node.outputs ?? []).flatMap(outputText)].join('\n')
|
|
61
|
+
case 'component': return [...propText(node.props), ...node.fallback ? [mdastText(node.fallback)] : []].join('\n')
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function targets(node: MdastNode): string[] {
|
|
66
|
+
const own = node.type === 'html' ? node.value : node.url
|
|
67
|
+
return [...typeof own === 'string' ? [own] : [], ...(node.children ?? []).flatMap(targets)]
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function outputText(run: NonNullable<Extract<IRBlock['node'], { type: 'code' }>['outputs']>[number]): string[] {
|
|
71
|
+
return [run.stdout, run.stderr, ...(run.outputs ?? []).flatMap((output) => {
|
|
72
|
+
if (output.output_type === 'stream') return [output.text]
|
|
73
|
+
if (output.output_type === 'error') return [output.evalue, ...output.traceback]
|
|
74
|
+
return Object.entries(output.data).flatMap(([type, value]) => (type.startsWith('text/') || type === 'application/json' ? propText(value) : []))
|
|
75
|
+
})]
|
|
76
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { isRecord } from '@lincyaw/kernel'
|
|
2
|
+
import { parse as parseYaml } from 'yaml'
|
|
3
|
+
|
|
4
|
+
export function withoutBom(text: string): string {
|
|
5
|
+
return text.charCodeAt(0) === 0xFEFF ? text.slice(1) : text
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function parseFrontmatter(text: string): Record<string, unknown> {
|
|
9
|
+
const match = /^---[ \t]*\r?\n(?:([\s\S]*?)\r?\n)?---[ \t]*(?:\r?\n|$)/.exec(withoutBom(text))
|
|
10
|
+
try {
|
|
11
|
+
const data: unknown = match ? parseYaml(match[1] ?? '') : {}
|
|
12
|
+
return isRecord(data) ? data : {}
|
|
13
|
+
} catch {
|
|
14
|
+
return {}
|
|
15
|
+
}
|
|
16
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export { directoryAsset, fileHash, publishesDirectory } from './assets.ts'
|
|
2
|
+
export { availableRenderers, build } from './build.ts'
|
|
3
|
+
export type { BuildOptions, BuildReport } from './build.ts'
|
|
4
|
+
export { buildSummary, buildToDist, checkContext, runBlock } from './compilation-actions.ts'
|
|
5
|
+
export { Compiler, DECK_ENTRY, resolveEntry, sortFindings, workspacePlugins } from './compiler.ts'
|
|
6
|
+
export type { Compilation, CompileOptions, CompilerOptions, DocumentNodeServices } from './compiler.ts'
|
|
7
|
+
export { dev } from './dev.ts'
|
|
8
|
+
export type { DevOptions, DevServers } from './dev.ts'
|
|
9
|
+
export { BUILTIN_DIRECTIVE_INFO, COMMON_DIRECTIVE_OPTIONS, RUN_OPTION } from './directives.ts'
|
|
10
|
+
export type { DirectiveInfo, DirectiveOptionInfo } from './directives.ts'
|
|
11
|
+
export type { RunStats } from './execute.ts'
|
|
12
|
+
export type { GitRead } from './inputs.ts'
|
|
13
|
+
export { pluginLogger } from './logging.ts'
|
|
14
|
+
export { parseFrontmatter } from './frontmatter.ts'
|
|
15
|
+
export { portable, relativeIR } from './paths.ts'
|
|
16
|
+
export { findPackageDir, loadPlugins, pluginId } from './plugins.ts'
|
|
17
|
+
export type { LoadedPlugin, PluginProblem, PluginSources } from './plugins.ts'
|
|
18
|
+
export { exportDocument } from './export.ts'
|
|
19
|
+
export type { ExportOptions } from './export.ts'
|
|
20
|
+
export { documentExporters, nodeVitePlugins } from './renderers.ts'
|
|
21
|
+
export type { DocumentExporter } from './renderers.ts'
|
|
22
|
+
export { playwrightChromium, serveDirectory } from './serve.ts'
|
|
23
|
+
export type { ServeDirectoryOptions } from './serve.ts'
|
|
24
|
+
export type { ServedExtra, StaticServer } from './serve.ts'
|
|
25
|
+
export { frontmatterLine, lineContaining } from './source-lines.ts'
|
|
26
|
+
export { compileTranslations, documentDigest } from './translations.ts'
|
|
27
|
+
export type { CompiledTranslation } from './translations.ts'
|
|
28
|
+
export { viewFor } from './views.ts'
|
|
29
|
+
export { operatorDir, WORKSPACE_CONFIG, workspaceSettings } from './workspace-settings.ts'
|
|
30
|
+
export type { OperatorSettings } from './workspace-settings.ts'
|
package/src/inputs.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { dirname, resolve } from 'node:path'
|
|
4
|
+
import { promisify } from 'node:util'
|
|
5
|
+
import type { AnchorResolverRegistry, SourceReader } from '@lincyaw/kernel'
|
|
6
|
+
import { COMMIT_HASH } from './workspace.ts'
|
|
7
|
+
|
|
8
|
+
export type CommittedFiles = Map<string, string>
|
|
9
|
+
|
|
10
|
+
const run = promisify(execFile)
|
|
11
|
+
|
|
12
|
+
/** A file the compilation read at a git ref rather than from the working tree. */
|
|
13
|
+
export interface GitRead {
|
|
14
|
+
file: string
|
|
15
|
+
ref: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The files and git refs a compilation read besides its sources, to watch for changes. */
|
|
19
|
+
export class CompileInputs {
|
|
20
|
+
readonly #root: string
|
|
21
|
+
readonly #committed: CommittedFiles
|
|
22
|
+
readonly #files = new Set<string>()
|
|
23
|
+
readonly #gitReads = new Map<string, GitRead>()
|
|
24
|
+
|
|
25
|
+
constructor(root: string, committed: CommittedFiles = new Map()) {
|
|
26
|
+
this.#root = root
|
|
27
|
+
this.#committed = committed
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get files(): string[] {
|
|
31
|
+
return [...this.#files]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
get gitReads(): GitRead[] {
|
|
35
|
+
return [...this.#gitReads.values()]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
watch(...files: string[]): void {
|
|
39
|
+
for (const file of files) this.#files.add(resolve(this.#root, file))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
reader(src: SourceReader): SourceReader {
|
|
43
|
+
return {
|
|
44
|
+
read: async (file, ref) => {
|
|
45
|
+
const path = resolve(this.#root, file)
|
|
46
|
+
if (ref === undefined) {
|
|
47
|
+
this.#files.add(path)
|
|
48
|
+
return src.read(file, ref)
|
|
49
|
+
}
|
|
50
|
+
const key = `${path}\0${ref}`
|
|
51
|
+
this.#gitReads.set(key, { file: path, ref })
|
|
52
|
+
const known = this.#committed.get(key)
|
|
53
|
+
if (known !== undefined) return known
|
|
54
|
+
const text = await src.read(file, ref)
|
|
55
|
+
if (text !== undefined && COMMIT_HASH.test(ref)) this.#committed.set(key, text)
|
|
56
|
+
return text
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
resolvers(registry: AnchorResolverRegistry): AnchorResolverRegistry {
|
|
62
|
+
return {
|
|
63
|
+
register: resolver => registry.register(resolver),
|
|
64
|
+
schemes: () => registry.schemes(),
|
|
65
|
+
resolve: (anchor, src, recorded, ref) => registry.resolve(anchor, this.reader(src), recorded, ref),
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The files of git's own state that change when one of the refs that were read moves. */
|
|
71
|
+
export async function gitReadFiles(reads: readonly GitRead[]): Promise<string[]> {
|
|
72
|
+
const refs = new Map<string, Set<string>>()
|
|
73
|
+
for (const { file, ref } of reads) refs.set(dirname(file), (refs.get(dirname(file)) ?? new Set<string>()).add(ref))
|
|
74
|
+
return (await Promise.all([...refs].map(([dir, names]) => gitRefFiles(dir, names)))).flat()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function gitRefFiles(dir: string, refs: Iterable<string>): Promise<string[]> {
|
|
78
|
+
let cwd = dir
|
|
79
|
+
while (!existsSync(cwd) && dirname(cwd) !== cwd) cwd = dirname(cwd)
|
|
80
|
+
const git = (args: string[]) => run('git', args, { cwd }).then(({ stdout }) => stdout.split('\n').filter(Boolean), () => [])
|
|
81
|
+
const names = new Set(['HEAD', ...[...refs].filter(ref => !ref.startsWith('-') && !COMMIT_HASH.test(ref))])
|
|
82
|
+
const full = await Promise.all([...names].map(name => git(['rev-parse', '--symbolic-full-name', name])))
|
|
83
|
+
const files = new Set(['HEAD', 'packed-refs', ...full.flat()])
|
|
84
|
+
return (await git(['rev-parse', ...[...files].flatMap(name => ['--git-path', name])])).map(path => resolve(cwd, path))
|
|
85
|
+
}
|
package/src/links.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { execFile, spawnSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
3
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
|
|
4
|
+
import { ABSOLUTE_URL, DOCUMENT_ENTRY, htmlAttributes, irBlocks, isRecord, validateDocument } from '@lincyaw/kernel'
|
|
5
|
+
import type { Finding, IRBlock, IRDocument, MdastNode, SourcePos } from '@lincyaw/kernel'
|
|
6
|
+
import { isInside } from '@lincyaw/kernel/node'
|
|
7
|
+
import { publishesDirectory, unpublishedDirectory } from './assets.ts'
|
|
8
|
+
import { lowerDocument } from './document-lowering.ts'
|
|
9
|
+
import { plainText, sectionSlug } from './sections.ts'
|
|
10
|
+
import { lineFinder } from './source-lines.ts'
|
|
11
|
+
import type { LineFinder } from './source-lines.ts'
|
|
12
|
+
import { viewFor } from './views.ts'
|
|
13
|
+
|
|
14
|
+
export interface PublishInput {
|
|
15
|
+
doc: IRDocument
|
|
16
|
+
entry: string
|
|
17
|
+
deckDir: string
|
|
18
|
+
workspaceRoot: string
|
|
19
|
+
renderers: string[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const EXTERNAL = /^https?:\/\//i
|
|
23
|
+
const URL_ATTRIBUTES = new Set(['href', 'src', 'poster'])
|
|
24
|
+
const ID_ATTRIBUTES = new Set(['id', 'name'])
|
|
25
|
+
const LINKED = new Set(['link', 'image', 'definition', 'card'])
|
|
26
|
+
|
|
27
|
+
interface Problem {
|
|
28
|
+
source: SourcePos
|
|
29
|
+
url: string
|
|
30
|
+
message: string
|
|
31
|
+
renderers: string[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function checkLinks(input: PublishInput, options: { externalLinks?: boolean } = {}): Promise<Finding[]> {
|
|
35
|
+
const problems = new Map<string, Problem>()
|
|
36
|
+
const external = new Map<string, SourcePos>()
|
|
37
|
+
const documents = new Map<string, Promise<Set<string>>>()
|
|
38
|
+
for (const renderer of input.renderers) {
|
|
39
|
+
const view = viewFor(input.doc, renderer)
|
|
40
|
+
const anchors = documentAnchors(view)
|
|
41
|
+
for (const section of view.sections) {
|
|
42
|
+
for (const block of irBlocks(section.blocks)) {
|
|
43
|
+
for (const url of blockUrls(block)) {
|
|
44
|
+
if (EXTERNAL.test(url)) {
|
|
45
|
+
if (!external.has(url)) external.set(url, block.source)
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
const message = await linkProblem(url, block.source, input, anchors, documents)
|
|
49
|
+
if (!message) continue
|
|
50
|
+
const key = `${block.source.file}\0${block.source.line}\0${url}`
|
|
51
|
+
const known = problems.get(key)
|
|
52
|
+
if (known) known.renderers.push(renderer)
|
|
53
|
+
else problems.set(key, { source: block.source, url, message, renderers: [renderer] })
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const line = lineFinder()
|
|
59
|
+
const findings = [...problems.values()].map(({ source, url, message, renderers }): Finding => ({
|
|
60
|
+
checkId: 'presmium.links',
|
|
61
|
+
severity: 'error',
|
|
62
|
+
message: `${message}${renderers.length < input.renderers.length ? ` (in ${renderers.join(', ')})` : ''}`,
|
|
63
|
+
source: { file: source.file, line: line(source, needles(url)) },
|
|
64
|
+
}))
|
|
65
|
+
if (options.externalLinks) findings.push(...await externalLinks(external, input.entry, line))
|
|
66
|
+
return findings
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function blockUrls(block: IRBlock): string[] {
|
|
70
|
+
const { node } = block
|
|
71
|
+
const urls: string[] = []
|
|
72
|
+
const walk = (mdast: MdastNode) => {
|
|
73
|
+
if (LINKED.has(mdast.type) && typeof mdast.url === 'string') urls.push(mdast.url)
|
|
74
|
+
if (mdast.type === 'html' && typeof mdast.value === 'string') {
|
|
75
|
+
for (const { value } of htmlAttributes(mdast.value, URL_ATTRIBUTES)) urls.push(value)
|
|
76
|
+
}
|
|
77
|
+
mdast.children?.forEach(walk)
|
|
78
|
+
}
|
|
79
|
+
if (node.type === 'markdown') walk(node.mdast)
|
|
80
|
+
if (node.type === 'component') {
|
|
81
|
+
if (node.fallback) walk(node.fallback)
|
|
82
|
+
for (const url of Object.values(node.assets ?? {})) urls.push(url)
|
|
83
|
+
for (const state of block.stepStates ?? []) for (const url of Object.values(state.assets ?? {})) urls.push(url)
|
|
84
|
+
}
|
|
85
|
+
return urls.map(url => url.trim()).filter(Boolean)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function linkProblem(url: string, source: SourcePos, input: PublishInput, anchors: Set<string>, documents: Map<string, Promise<Set<string>>>): Promise<string | undefined> {
|
|
89
|
+
if (ABSOLUTE_URL.test(url)) return undefined
|
|
90
|
+
const hash = url.indexOf('#')
|
|
91
|
+
const fragment = hash < 0 ? '' : decode(url.slice(hash + 1))
|
|
92
|
+
const path = decode((hash < 0 ? url : url.slice(0, hash)).replace(/\?.*$/s, ''))
|
|
93
|
+
if (!path) return fragment && !anchors.has(fragment) ? `#${fragment} is no section, block, label or heading of the deck` : undefined
|
|
94
|
+
const file = path.startsWith('/') ? join(input.deckDir, 'public', path) : resolve(dirname(source.file), path)
|
|
95
|
+
if (!isInside(file, input.workspaceRoot)) return `${path} lies outside the repository: it cannot be published`
|
|
96
|
+
if (!existsSync(file)) return `${path}: no such file`
|
|
97
|
+
const hidden = publishesDirectory(file, input.deckDir) ? unpublishedDirectory(file, input.deckDir) : undefined
|
|
98
|
+
if (hidden) return `${path}: ${hidden}`
|
|
99
|
+
if (!fragment) return undefined
|
|
100
|
+
const entry = statSync(file).isDirectory() ? join(file, DOCUMENT_ENTRY) : file
|
|
101
|
+
if (basename(entry) !== DOCUMENT_ENTRY || !existsSync(entry)) return undefined
|
|
102
|
+
if (entry === input.entry) return anchors.has(fragment) ? undefined : `${path}#${fragment}: #${fragment} is no section, block, label or heading of the deck`
|
|
103
|
+
if (!documents.has(entry)) documents.set(entry, sourceAnchors(entry))
|
|
104
|
+
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}`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function documentAnchors(doc: Pick<IRDocument, 'sections'>): Set<string> {
|
|
108
|
+
const anchors = new Set<string>()
|
|
109
|
+
for (const section of doc.sections) {
|
|
110
|
+
anchors.add(section.id)
|
|
111
|
+
for (const block of irBlocks(section.blocks)) {
|
|
112
|
+
anchors.add(block.id)
|
|
113
|
+
const { node } = block
|
|
114
|
+
if (node.type === 'markdown') mdastAnchors(node.mdast, anchors)
|
|
115
|
+
if (node.type === 'component' && node.fallback) mdastAnchors(node.fallback, anchors)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return anchors
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function mdastAnchors(node: MdastNode, anchors: Set<string>): void {
|
|
122
|
+
for (const key of ['identifier', 'label', 'html_id'] as const) if (typeof node[key] === 'string' && node[key]) anchors.add(node[key] as string)
|
|
123
|
+
if (node.type === 'footnoteDefinition' && typeof node.identifier === 'string') anchors.add(`fn-${node.identifier}`)
|
|
124
|
+
if (node.type === 'heading') anchors.add(sectionSlug(plainText(node)))
|
|
125
|
+
if (node.type === 'mystDirective' && isRecord(node.options) && typeof node.options.label === 'string') anchors.add(node.options.label)
|
|
126
|
+
if (node.type === 'html' && typeof node.value === 'string') {
|
|
127
|
+
for (const { value } of htmlAttributes(node.value, ID_ATTRIBUTES)) if (value) anchors.add(value)
|
|
128
|
+
}
|
|
129
|
+
node.children?.forEach(child => mdastAnchors(child, anchors))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function sourceAnchors(entry: string): Promise<Set<string>> {
|
|
133
|
+
try {
|
|
134
|
+
const checked = validateDocument(JSON.parse(readFileSync(entry, 'utf8').replace(/^\uFEFF/, '')))
|
|
135
|
+
if (!checked.document) return new Set()
|
|
136
|
+
const sections = lowerDocument(checked.document, entry, dirname(entry), () => {}, () => {})
|
|
137
|
+
return documentAnchors({ sections })
|
|
138
|
+
} catch {
|
|
139
|
+
return new Set()
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function externalLinks(urls: Map<string, SourcePos>, entry: string, line: LineFinder): Promise<Finding[]> {
|
|
144
|
+
if (urls.size === 0) return []
|
|
145
|
+
const at = { file: entry, line: 1 }
|
|
146
|
+
if (spawnSync('lychee', ['--version'], { stdio: 'ignore' }).error) {
|
|
147
|
+
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 }]
|
|
148
|
+
}
|
|
149
|
+
const output = await new Promise<{ stdout: string, error?: Error }>((done) => {
|
|
150
|
+
const child = execFile('lychee', ['--format', 'json', '--no-progress', '-'], { maxBuffer: 64 << 20 }, (error, stdout) => done({ stdout, ...error ? { error } : {} }))
|
|
151
|
+
child.stdin?.on('error', () => {})
|
|
152
|
+
child.stdin?.end(`${[...urls.keys()].join('\n')}\n`)
|
|
153
|
+
})
|
|
154
|
+
let report: unknown
|
|
155
|
+
try {
|
|
156
|
+
report = JSON.parse(output.stdout)
|
|
157
|
+
} catch {
|
|
158
|
+
return [{ checkId: 'presmium.links', severity: 'warning', message: `lychee did not check the external links: ${output.error?.message ?? 'it printed no report'}`, source: at }]
|
|
159
|
+
}
|
|
160
|
+
const failed = isRecord(report) ? [report.fail_map, report.error_map].flatMap(map => (isRecord(map) ? Object.values(map).flat() : [])) : []
|
|
161
|
+
return failed.flatMap((item): Finding[] => {
|
|
162
|
+
if (!isRecord(item) || typeof item.url !== 'string') return []
|
|
163
|
+
const source = urls.get(item.url) ?? urls.get(item.url.replace(/\/$/, '')) ?? at
|
|
164
|
+
const status = isRecord(item.status) && typeof item.status.text === 'string' ? item.status.text : 'failed'
|
|
165
|
+
return [{ checkId: 'presmium.links', severity: 'warning', message: `${item.url}: ${status}`, source: { file: source.file, line: line(source, [item.url]) } }]
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function needles(url: string): string[] {
|
|
170
|
+
return [...new Set([url, decode(url)])]
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function decode(text: string): string {
|
|
174
|
+
try {
|
|
175
|
+
return decodeURIComponent(text)
|
|
176
|
+
} catch {
|
|
177
|
+
return text
|
|
178
|
+
}
|
|
179
|
+
}
|
package/src/logging.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { prefixedLogger } from '@lincyaw/kernel'
|
|
2
|
+
import type { Logger } from '@lincyaw/kernel'
|
|
3
|
+
|
|
4
|
+
const STDERR: Logger = {
|
|
5
|
+
debug: (...args) => {
|
|
6
|
+
if (process.env.PRESMIUM_DEBUG) console.error(...args)
|
|
7
|
+
},
|
|
8
|
+
info: (...args) => console.error(...args),
|
|
9
|
+
warn: (...args) => console.error(...args),
|
|
10
|
+
error: (...args) => console.error(...args),
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A plugin's logger in Node: everything on stderr, debug only with PRESMIUM_DEBUG, tagged with the plugin's ID. */
|
|
14
|
+
export function pluginLogger(id: string): Logger {
|
|
15
|
+
return prefixedLogger(STDERR, id)
|
|
16
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ServiceRegistry } from '@lincyaw/kernel'
|
|
2
|
+
import type { ServiceToken } from '@lincyaw/kernel'
|
|
3
|
+
|
|
4
|
+
/** The Node services of one compilation: the host's, then each plugin's; a later plugin cannot replace a service. */
|
|
5
|
+
export class NodeServices {
|
|
6
|
+
readonly #registry = new ServiceRegistry()
|
|
7
|
+
|
|
8
|
+
constructor(host: [ServiceToken<unknown>, unknown][] = []) {
|
|
9
|
+
const latest = new Map(host.map(entry => [entry[0].id, entry]))
|
|
10
|
+
for (const [token, implementation] of latest.values()) this.#registry.provide(token, implementation)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
add(owner: string, entries: [ServiceToken<unknown>, unknown][]): string[] {
|
|
14
|
+
const problems: string[] = []
|
|
15
|
+
for (const [token, implementation] of entries) {
|
|
16
|
+
if (!token || typeof token.id !== 'string') {
|
|
17
|
+
problems.push(`plugin ${owner}: services() gave an entry without a service token`)
|
|
18
|
+
continue
|
|
19
|
+
}
|
|
20
|
+
const known = this.#registry.owner(token)
|
|
21
|
+
if (known !== undefined) {
|
|
22
|
+
problems.push(`plugin ${owner}: ${token.id} is already provided by ${known}; this implementation is ignored`)
|
|
23
|
+
continue
|
|
24
|
+
}
|
|
25
|
+
this.#registry.provide(token, implementation, owner)
|
|
26
|
+
}
|
|
27
|
+
return problems
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
tryGet<T>(token: ServiceToken<T>): T | undefined {
|
|
31
|
+
return this.#registry.tryGet(token)
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { isAbsolute, relative, sep } from 'node:path'
|
|
2
|
+
import { DOCUMENT_ENTRY, irBlocks } from '@lincyaw/kernel'
|
|
3
|
+
import type { IRDocument, SourcePos } from '@lincyaw/kernel'
|
|
4
|
+
import { isInside } from '@lincyaw/kernel/node'
|
|
5
|
+
|
|
6
|
+
export const DECK_ENTRY = DOCUMENT_ENTRY
|
|
7
|
+
|
|
8
|
+
/** The deck's directory of generated files: renderer work directories, staged files, caches. */
|
|
9
|
+
export const WORK_DIR = '.presmium'
|
|
10
|
+
|
|
11
|
+
function relativePath(from: string, path: string): string {
|
|
12
|
+
return relative(from, path).split(sep).join('/') || '.'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function portable(value: unknown, from: string, root: string): unknown {
|
|
16
|
+
if (typeof value === 'string') return isAbsolute(value) && isInside(value, root) ? relativePath(from, value) : value
|
|
17
|
+
if (Array.isArray(value)) return value.map(item => portable(item, from, root))
|
|
18
|
+
if (value !== null && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, portable(item, from, root)]))
|
|
19
|
+
return value
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function relativeIR(doc: IRDocument, deckDir: string, workspaceRoot: string): IRDocument {
|
|
23
|
+
const copy = structuredClone(doc)
|
|
24
|
+
const source = (pos: SourcePos) => {
|
|
25
|
+
if (isAbsolute(pos.file)) pos.file = relativePath(deckDir, pos.file)
|
|
26
|
+
}
|
|
27
|
+
if (isAbsolute(copy.meta.entry)) copy.meta.entry = relativePath(deckDir, copy.meta.entry)
|
|
28
|
+
copy.config.presmium = portable(copy.config.presmium, deckDir, workspaceRoot) as IRDocument['config']['presmium']
|
|
29
|
+
for (const section of copy.sections) {
|
|
30
|
+
source(section.source)
|
|
31
|
+
if (section.config) section.config = portable(section.config, deckDir, workspaceRoot) as typeof section.config
|
|
32
|
+
for (const block of irBlocks(section.blocks)) {
|
|
33
|
+
source(block.source)
|
|
34
|
+
if (block.node.type === 'component' && block.node.definition && isAbsolute(block.node.definition.file))
|
|
35
|
+
block.node.definition.file = relativePath(deckDir, block.node.definition.file)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
for (const finding of copy.diagnostics) {
|
|
39
|
+
if (finding.source) source(finding.source as SourcePos)
|
|
40
|
+
}
|
|
41
|
+
return copy
|
|
42
|
+
}
|