@lincyaw/compiler 1.0.0-rc.0 → 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/README.md +8 -4
- package/package.json +12 -13
- package/src/assets.ts +163 -0
- package/src/audience.ts +21 -216
- package/src/build.ts +101 -0
- package/src/{host.ts → compilation-actions.ts} +14 -7
- package/src/compile-queue.ts +28 -0
- package/src/compiler.ts +269 -195
- package/src/dev.ts +222 -0
- package/src/directives.ts +1 -0
- package/src/document-lowering.ts +176 -0
- package/src/environments.ts +6 -4
- package/src/execute.ts +50 -36
- package/src/export.ts +49 -0
- package/src/forbid.ts +6 -12
- package/src/frontmatter.ts +16 -0
- package/src/index.ts +21 -11
- package/src/{dependencies.ts → inputs.ts} +22 -13
- package/src/links.ts +21 -45
- package/src/logging.ts +16 -0
- package/src/node-services.ts +33 -0
- package/src/paths.ts +12 -26
- package/src/plugins.ts +53 -10
- package/src/references.ts +31 -20
- package/src/renderers.ts +61 -401
- package/src/sections.ts +6 -80
- package/src/serve.ts +58 -56
- package/src/source-lines.ts +47 -0
- package/src/translations.ts +51 -57
- package/src/validate.ts +3 -1
- package/src/views.ts +2 -0
- package/src/workspace-settings.ts +59 -0
- package/src/workspace.ts +17 -7
- package/src/blocks.ts +0 -557
- package/src/published.ts +0 -105
- package/src/settings.ts +0 -105
- package/src/source.ts +0 -300
- package/src/steps.ts +0 -66
package/src/export.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { rm } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import type { ExportOutput } from '@lincyaw/kernel'
|
|
4
|
+
import { build } from './build.ts'
|
|
5
|
+
import type { Compilation } from './compiler.ts'
|
|
6
|
+
import { documentExporters } from './renderers.ts'
|
|
7
|
+
|
|
8
|
+
// Below the export's directory: the builds its exporter asks for, removed once it is done.
|
|
9
|
+
const BUILDS = '.build'
|
|
10
|
+
|
|
11
|
+
export interface ExportOptions {
|
|
12
|
+
/** An empty directory for the files. */
|
|
13
|
+
outDir: string
|
|
14
|
+
/** What the one who asked chose (`ExportContext.options`). */
|
|
15
|
+
options?: Record<string, unknown>
|
|
16
|
+
signal?: AbortSignal
|
|
17
|
+
progress?(phase: string, done?: number, total?: number): void
|
|
18
|
+
log?(line: string): void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Makes the document's export `id` (a plugin's exporter: the slides as a PDF, the lecture as a video) into `outDir`. */
|
|
22
|
+
export async function exportDocument(compilation: Compilation, id: string, options: ExportOptions): Promise<ExportOutput> {
|
|
23
|
+
const found = (await documentExporters(compilation)).find(({ exporter }) => exporter.id === id)
|
|
24
|
+
if (!found) throw new Error(`no plugin of the document exports ${id}`)
|
|
25
|
+
const builds = join(options.outDir, BUILDS)
|
|
26
|
+
try {
|
|
27
|
+
return await found.exporter.run({
|
|
28
|
+
deckDir: compilation.deckDir,
|
|
29
|
+
workspaceRoot: compilation.workspaceRoot,
|
|
30
|
+
source: compilation.doc.source,
|
|
31
|
+
config: found.config,
|
|
32
|
+
outDir: options.outDir,
|
|
33
|
+
signal: options.signal ?? new AbortController().signal,
|
|
34
|
+
progress: options.progress ?? (() => {}),
|
|
35
|
+
log: options.log ?? (() => {}),
|
|
36
|
+
options: options.options ?? {},
|
|
37
|
+
async build(renderer, config = {}) {
|
|
38
|
+
const { renderers } = compilation.doc.config
|
|
39
|
+
const configured = { ...compilation, doc: { ...compilation.doc, config: { ...compilation.doc.config, renderers: { ...renderers, [renderer]: { ...renderers[renderer], ...config } } } } }
|
|
40
|
+
const report = await build(configured, { outDir: join(builds, renderer), to: [renderer], base: '/', ...options.log ? { log: options.log } : {} })
|
|
41
|
+
const own = report.outputs.find(output => output.renderer === renderer)
|
|
42
|
+
if (!own) throw new Error(report.findings.filter(f => f.severity === 'error').map(f => f.message).join('; ') || `the ${renderer} renderer made nothing`)
|
|
43
|
+
return own.outDir
|
|
44
|
+
},
|
|
45
|
+
})
|
|
46
|
+
} finally {
|
|
47
|
+
await rm(builds, { recursive: true, force: true })
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/forbid.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { irBlocks, isRecord, mdastText } from '@lincyaw/kernel'
|
|
1
|
+
import { irBlocks, isRecord, mdastText, propText } from '@lincyaw/kernel'
|
|
2
2
|
import type { Finding, IRBlock, MdastNode, SourcePos } from '@lincyaw/kernel'
|
|
3
|
-
import { lineFinder } from './links.ts'
|
|
4
3
|
import type { PublishInput } from './links.ts'
|
|
5
|
-
import {
|
|
4
|
+
import { lineFinder } from './source-lines.ts'
|
|
5
|
+
import { viewFor } from './views.ts'
|
|
6
6
|
|
|
7
7
|
export function checkForbidden(input: PublishInput, headmatter: Record<string, unknown>): Finding[] {
|
|
8
8
|
const given = isRecord(headmatter.checks) ? headmatter.checks.forbid : undefined
|
|
@@ -27,7 +27,7 @@ export function checkForbidden(input: PublishInput, headmatter: Record<string, u
|
|
|
27
27
|
const view = viewFor(input.doc, renderer)
|
|
28
28
|
const texts = view.sections.flatMap(section => [
|
|
29
29
|
{ source: section.source, text: section.title },
|
|
30
|
-
...[...irBlocks(section.blocks)].map(block => ({ source: block.source, text:
|
|
30
|
+
...[...irBlocks(section.blocks)].map(block => ({ source: block.source, text: searchableText(block) })),
|
|
31
31
|
])
|
|
32
32
|
for (const { source, text } of texts) {
|
|
33
33
|
for (const { text: name, pattern } of patterns) {
|
|
@@ -52,7 +52,8 @@ export function checkForbidden(input: PublishInput, headmatter: Record<string, u
|
|
|
52
52
|
return findings
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
|
|
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 {
|
|
56
57
|
const { node } = block
|
|
57
58
|
switch (node.type) {
|
|
58
59
|
case 'markdown': return `${mdastText(node.mdast)}\n${targets(node.mdast).join('\n')}`
|
|
@@ -73,10 +74,3 @@ function outputText(run: NonNullable<Extract<IRBlock['node'], { type: 'code' }>[
|
|
|
73
74
|
return Object.entries(output.data).flatMap(([type, value]) => (type.startsWith('text/') || type === 'application/json' ? propText(value) : []))
|
|
74
75
|
})]
|
|
75
76
|
}
|
|
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
|
-
}
|
|
@@ -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
CHANGED
|
@@ -1,20 +1,30 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export {
|
|
3
|
-
export type {
|
|
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'
|
|
4
9
|
export { BUILTIN_DIRECTIVE_INFO, COMMON_DIRECTIVE_OPTIONS, RUN_OPTION } from './directives.ts'
|
|
5
10
|
export type { DirectiveInfo, DirectiveOptionInfo } from './directives.ts'
|
|
6
11
|
export type { RunStats } from './execute.ts'
|
|
7
|
-
export {
|
|
12
|
+
export type { GitRead } from './inputs.ts'
|
|
13
|
+
export { pluginLogger } from './logging.ts'
|
|
14
|
+
export { parseFrontmatter } from './frontmatter.ts'
|
|
8
15
|
export { portable, relativeIR } from './paths.ts'
|
|
9
16
|
export { findPackageDir, loadPlugins, pluginId } from './plugins.ts'
|
|
10
17
|
export type { LoadedPlugin, PluginProblem, PluginSources } from './plugins.ts'
|
|
11
|
-
export {
|
|
12
|
-
export {
|
|
13
|
-
export
|
|
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'
|
|
14
22
|
export { playwrightChromium, serveDirectory } from './serve.ts'
|
|
23
|
+
export type { ServeDirectoryOptions } from './serve.ts'
|
|
15
24
|
export type { ServedExtra, StaticServer } from './serve.ts'
|
|
16
|
-
export {
|
|
17
|
-
export
|
|
18
|
-
export { readFrontmatter } from './source.ts'
|
|
19
|
-
export { compileTranslations } from './translations.ts'
|
|
25
|
+
export { frontmatterLine, lineContaining } from './source-lines.ts'
|
|
26
|
+
export { compileTranslations, documentDigest } from './translations.ts'
|
|
20
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'
|
|
@@ -3,22 +3,24 @@ import { existsSync } from 'node:fs'
|
|
|
3
3
|
import { dirname, resolve } from 'node:path'
|
|
4
4
|
import { promisify } from 'node:util'
|
|
5
5
|
import type { AnchorResolverRegistry, SourceReader } from '@lincyaw/kernel'
|
|
6
|
-
import {
|
|
6
|
+
import { COMMIT_HASH } from './workspace.ts'
|
|
7
7
|
|
|
8
8
|
export type CommittedFiles = Map<string, string>
|
|
9
9
|
|
|
10
10
|
const run = promisify(execFile)
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
/** A file the compilation read at a git ref rather than from the working tree. */
|
|
13
|
+
export interface GitRead {
|
|
13
14
|
file: string
|
|
14
|
-
|
|
15
|
+
ref: string
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
/** The files and git refs a compilation read besides its sources, to watch for changes. */
|
|
19
|
+
export class CompileInputs {
|
|
18
20
|
readonly #root: string
|
|
19
21
|
readonly #committed: CommittedFiles
|
|
20
22
|
readonly #files = new Set<string>()
|
|
21
|
-
readonly #
|
|
23
|
+
readonly #gitReads = new Map<string, GitRead>()
|
|
22
24
|
|
|
23
25
|
constructor(root: string, committed: CommittedFiles = new Map()) {
|
|
24
26
|
this.#root = root
|
|
@@ -29,8 +31,8 @@ export class Dependencies {
|
|
|
29
31
|
return [...this.#files]
|
|
30
32
|
}
|
|
31
33
|
|
|
32
|
-
get
|
|
33
|
-
return [...this.#
|
|
34
|
+
get gitReads(): GitRead[] {
|
|
35
|
+
return [...this.#gitReads.values()]
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
watch(...files: string[]): void {
|
|
@@ -46,11 +48,11 @@ export class Dependencies {
|
|
|
46
48
|
return src.read(file, ref)
|
|
47
49
|
}
|
|
48
50
|
const key = `${path}\0${ref}`
|
|
49
|
-
this.#
|
|
51
|
+
this.#gitReads.set(key, { file: path, ref })
|
|
50
52
|
const known = this.#committed.get(key)
|
|
51
53
|
if (known !== undefined) return known
|
|
52
54
|
const text = await src.read(file, ref)
|
|
53
|
-
if (text !== undefined &&
|
|
55
|
+
if (text !== undefined && COMMIT_HASH.test(ref)) this.#committed.set(key, text)
|
|
54
56
|
return text
|
|
55
57
|
},
|
|
56
58
|
}
|
|
@@ -65,12 +67,19 @@ export class Dependencies {
|
|
|
65
67
|
}
|
|
66
68
|
}
|
|
67
69
|
|
|
68
|
-
|
|
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[]> {
|
|
69
78
|
let cwd = dir
|
|
70
79
|
while (!existsSync(cwd) && dirname(cwd) !== cwd) cwd = dirname(cwd)
|
|
71
80
|
const git = (args: string[]) => run('git', args, { cwd }).then(({ stdout }) => stdout.split('\n').filter(Boolean), () => [])
|
|
72
|
-
const names = new Set(['HEAD', ...[...
|
|
73
|
-
const
|
|
74
|
-
const files = new Set(['HEAD', 'packed-refs', ...
|
|
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()])
|
|
75
84
|
return (await git(['rev-parse', ...[...files].flatMap(name => ['--git-path', name])])).map(path => resolve(cwd, path))
|
|
76
85
|
}
|
package/src/links.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { execFile, spawnSync } from 'node:child_process'
|
|
2
2
|
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
3
|
-
import { basename, dirname,
|
|
4
|
-
import { htmlAttributes, irBlocks, isRecord } from '@lincyaw/kernel'
|
|
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
5
|
import type { Finding, IRBlock, IRDocument, MdastNode, SourcePos } from '@lincyaw/kernel'
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import { plainText,
|
|
10
|
-
import {
|
|
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'
|
|
11
13
|
|
|
12
14
|
export interface PublishInput {
|
|
13
15
|
doc: IRDocument
|
|
@@ -17,7 +19,6 @@ export interface PublishInput {
|
|
|
17
19
|
renderers: string[]
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
const REMOTE = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i
|
|
21
22
|
const EXTERNAL = /^https?:\/\//i
|
|
22
23
|
const URL_ATTRIBUTES = new Set(['href', 'src', 'poster'])
|
|
23
24
|
const ID_ATTRIBUTES = new Set(['id', 'name'])
|
|
@@ -79,30 +80,31 @@ function blockUrls(block: IRBlock): string[] {
|
|
|
79
80
|
if (node.type === 'component') {
|
|
80
81
|
if (node.fallback) walk(node.fallback)
|
|
81
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)
|
|
82
84
|
}
|
|
83
85
|
return urls.map(url => url.trim()).filter(Boolean)
|
|
84
86
|
}
|
|
85
87
|
|
|
86
88
|
async function linkProblem(url: string, source: SourcePos, input: PublishInput, anchors: Set<string>, documents: Map<string, Promise<Set<string>>>): Promise<string | undefined> {
|
|
87
|
-
if (
|
|
89
|
+
if (ABSOLUTE_URL.test(url)) return undefined
|
|
88
90
|
const hash = url.indexOf('#')
|
|
89
91
|
const fragment = hash < 0 ? '' : decode(url.slice(hash + 1))
|
|
90
92
|
const path = decode((hash < 0 ? url : url.slice(0, hash)).replace(/\?.*$/s, ''))
|
|
91
93
|
if (!path) return fragment && !anchors.has(fragment) ? `#${fragment} is no section, block, label or heading of the deck` : undefined
|
|
92
94
|
const file = path.startsWith('/') ? join(input.deckDir, 'public', path) : resolve(dirname(source.file), path)
|
|
93
|
-
if (!
|
|
95
|
+
if (!isInside(file, input.workspaceRoot)) return `${path} lies outside the repository: it cannot be published`
|
|
94
96
|
if (!existsSync(file)) return `${path}: no such file`
|
|
95
97
|
const hidden = publishesDirectory(file, input.deckDir) ? unpublishedDirectory(file, input.deckDir) : undefined
|
|
96
98
|
if (hidden) return `${path}: ${hidden}`
|
|
97
99
|
if (!fragment) return undefined
|
|
98
|
-
const entry = statSync(file).isDirectory() ? join(file,
|
|
99
|
-
if (
|
|
100
|
+
const entry = statSync(file).isDirectory() ? join(file, DOCUMENT_ENTRY) : file
|
|
101
|
+
if (basename(entry) !== DOCUMENT_ENTRY || !existsSync(entry)) return undefined
|
|
100
102
|
if (entry === input.entry) return anchors.has(fragment) ? undefined : `${path}#${fragment}: #${fragment} is no section, block, label or heading of the deck`
|
|
101
103
|
if (!documents.has(entry)) documents.set(entry, sourceAnchors(entry))
|
|
102
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}`
|
|
103
105
|
}
|
|
104
106
|
|
|
105
|
-
function documentAnchors(doc: IRDocument): Set<string> {
|
|
107
|
+
function documentAnchors(doc: Pick<IRDocument, 'sections'>): Set<string> {
|
|
106
108
|
const anchors = new Set<string>()
|
|
107
109
|
for (const section of doc.sections) {
|
|
108
110
|
anchors.add(section.id)
|
|
@@ -119,7 +121,7 @@ function documentAnchors(doc: IRDocument): Set<string> {
|
|
|
119
121
|
function mdastAnchors(node: MdastNode, anchors: Set<string>): void {
|
|
120
122
|
for (const key of ['identifier', 'label', 'html_id'] as const) if (typeof node[key] === 'string' && node[key]) anchors.add(node[key] as string)
|
|
121
123
|
if (node.type === 'footnoteDefinition' && typeof node.identifier === 'string') anchors.add(`fn-${node.identifier}`)
|
|
122
|
-
if (node.type === 'heading') anchors.add(
|
|
124
|
+
if (node.type === 'heading') anchors.add(sectionSlug(plainText(node)))
|
|
123
125
|
if (node.type === 'mystDirective' && isRecord(node.options) && typeof node.options.label === 'string') anchors.add(node.options.label)
|
|
124
126
|
if (node.type === 'html' && typeof node.value === 'string') {
|
|
125
127
|
for (const { value } of htmlAttributes(node.value, ID_ATTRIBUTES)) if (value) anchors.add(value)
|
|
@@ -128,19 +130,14 @@ function mdastAnchors(node: MdastNode, anchors: Set<string>): void {
|
|
|
128
130
|
}
|
|
129
131
|
|
|
130
132
|
async function sourceAnchors(entry: string): Promise<Set<string>> {
|
|
131
|
-
const anchors = new Set<string>()
|
|
132
133
|
try {
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
|
|
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)
|
|
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 })
|
|
141
138
|
} catch {
|
|
139
|
+
return new Set()
|
|
142
140
|
}
|
|
143
|
-
return anchors
|
|
144
141
|
}
|
|
145
142
|
|
|
146
143
|
async function externalLinks(urls: Map<string, SourcePos>, entry: string, line: LineFinder): Promise<Finding[]> {
|
|
@@ -173,27 +170,6 @@ function needles(url: string): string[] {
|
|
|
173
170
|
return [...new Set([url, decode(url)])]
|
|
174
171
|
}
|
|
175
172
|
|
|
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
173
|
function decode(text: string): string {
|
|
198
174
|
try {
|
|
199
175
|
return decodeURIComponent(text)
|
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
CHANGED
|
@@ -1,23 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { irBlocks } from '@lincyaw/kernel'
|
|
1
|
+
import { isAbsolute, relative, sep } from 'node:path'
|
|
2
|
+
import { DOCUMENT_ENTRY, irBlocks } from '@lincyaw/kernel'
|
|
4
3
|
import type { IRDocument, SourcePos } from '@lincyaw/kernel'
|
|
5
|
-
import {
|
|
4
|
+
import { isInside } from '@lincyaw/kernel/node'
|
|
6
5
|
|
|
7
|
-
const
|
|
6
|
+
export const DECK_ENTRY = DOCUMENT_ENTRY
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const rel = relative(root, path)
|
|
12
|
-
return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)
|
|
13
|
-
}
|
|
8
|
+
/** The deck's directory of generated files: renderer work directories, staged files, caches. */
|
|
9
|
+
export const WORK_DIR = '.presmium'
|
|
14
10
|
|
|
15
11
|
function relativePath(from: string, path: string): string {
|
|
16
12
|
return relative(from, path).split(sep).join('/') || '.'
|
|
17
13
|
}
|
|
18
14
|
|
|
19
15
|
export function portable(value: unknown, from: string, root: string): unknown {
|
|
20
|
-
if (typeof value === 'string') return
|
|
16
|
+
if (typeof value === 'string') return isAbsolute(value) && isInside(value, root) ? relativePath(from, value) : value
|
|
21
17
|
if (Array.isArray(value)) return value.map(item => portable(item, from, root))
|
|
22
18
|
if (value !== null && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, portable(item, from, root)]))
|
|
23
19
|
return value
|
|
@@ -33,24 +29,14 @@ export function relativeIR(doc: IRDocument, deckDir: string, workspaceRoot: stri
|
|
|
33
29
|
for (const section of copy.sections) {
|
|
34
30
|
source(section.source)
|
|
35
31
|
if (section.config) section.config = portable(section.config, deckDir, workspaceRoot) as typeof section.config
|
|
36
|
-
for (const block of irBlocks(section.blocks))
|
|
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
37
|
}
|
|
38
38
|
for (const finding of copy.diagnostics) {
|
|
39
39
|
if (finding.source) source(finding.source as SourcePos)
|
|
40
40
|
}
|
|
41
41
|
return copy
|
|
42
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
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
|
2
2
|
import { readdir, readFile } from 'node:fs/promises'
|
|
3
3
|
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
4
|
-
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
5
5
|
import { errorMessage, isRecord, kernelIncompatibility } from '@lincyaw/kernel'
|
|
6
6
|
import type { PresmiumNodePlugin, Severity } from '@lincyaw/kernel'
|
|
7
7
|
import { createJiti } from 'jiti'
|
|
8
8
|
import { satisfies, validRange } from 'semver'
|
|
9
|
+
import { exports as resolveExports } from 'resolve.exports'
|
|
9
10
|
|
|
10
11
|
export const NODE_PLUGIN_EXPORT = './presmium'
|
|
11
12
|
|
|
@@ -13,6 +14,7 @@ export interface LoadedPlugin {
|
|
|
13
14
|
name: string
|
|
14
15
|
dir: string
|
|
15
16
|
plugin?: PresmiumNodePlugin
|
|
17
|
+
entry?: string
|
|
16
18
|
}
|
|
17
19
|
|
|
18
20
|
export interface PluginProblem {
|
|
@@ -61,6 +63,27 @@ interface PluginPackage {
|
|
|
61
63
|
|
|
62
64
|
const PRESMIUM_SCOPE = '@lincyaw/'
|
|
63
65
|
|
|
66
|
+
// The official plugins were named after Slidev's addons; a headmatter that still lists them gets the current packages.
|
|
67
|
+
const LEGACY_PLUGIN = '@lincyaw/slidev-addon-'
|
|
68
|
+
const PLUGIN = '@lincyaw/addon-'
|
|
69
|
+
|
|
70
|
+
/** A plugin's package as the headmatter lists it, under its current name. */
|
|
71
|
+
export function currentPluginName(name: string): string {
|
|
72
|
+
return name.startsWith(LEGACY_PLUGIN) ? PLUGIN + name.slice(LEGACY_PLUGIN.length) : name
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Plugins whose work the viewer does itself (the slides' host adapter, Slidev's); a headmatter may still list them.
|
|
76
|
+
const RETIRED_PLUGINS: ReadonlySet<string> = new Set(['@lincyaw/addon-core'])
|
|
77
|
+
|
|
78
|
+
export function isRetiredPlugin(name: string): boolean {
|
|
79
|
+
return RETIRED_PLUGINS.has(currentPluginName(name))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The plugins a headmatter lists, under their current names, the retired ones left out. */
|
|
83
|
+
export function listedPlugins(names: readonly string[]): string[] {
|
|
84
|
+
return names.filter(name => !isRetiredPlugin(name)).map(currentPluginName)
|
|
85
|
+
}
|
|
86
|
+
|
|
64
87
|
const COMPILER_DIR = fileURLToPath(new URL('..', import.meta.url))
|
|
65
88
|
const COMPILER_PACKAGE = JSON.parse(readFileSync(join(COMPILER_DIR, 'package.json'), 'utf8')) as { name: string, version: string }
|
|
66
89
|
|
|
@@ -91,9 +114,9 @@ async function loadFrom(name: string, dir: string, problem: (severity: Severity,
|
|
|
91
114
|
const label = typeof pkg.name === 'string' ? ` (${pkg.name}${typeof pkg.version === 'string' ? ` ${pkg.version}` : ''})` : ''
|
|
92
115
|
return { problem: problem('error', `plugin "${name}"${label} is not loaded: ${peers}`) }
|
|
93
116
|
}
|
|
94
|
-
const target = exportTarget(pkg
|
|
117
|
+
const target = exportTarget(pkg, dir)
|
|
95
118
|
if (!target) return { loaded: { name, dir } }
|
|
96
|
-
const file =
|
|
119
|
+
const file = fileURLToPath(new URL(target, pathToFileURL(`${dir}/`)))
|
|
97
120
|
if (!existsSync(file)) {
|
|
98
121
|
return { loaded: { name, dir }, problem: problem('warning', `plugin "${name}": ${NODE_PLUGIN_EXPORT} points to missing file ${target}; only its components are used`) }
|
|
99
122
|
}
|
|
@@ -104,7 +127,7 @@ async function loadFrom(name: string, dir: string, problem: (severity: Severity,
|
|
|
104
127
|
const version = plugin.version ? ` ${plugin.version}` : ''
|
|
105
128
|
return { problem: problem('error', `plugin "${name}" (${plugin.id}${version}) is not loaded: ${incompatible}`) }
|
|
106
129
|
}
|
|
107
|
-
return { loaded: { name, dir, plugin } }
|
|
130
|
+
return { loaded: { name, dir, plugin, entry: file } }
|
|
108
131
|
} catch (error) {
|
|
109
132
|
return { problem: problem('error', `cannot load plugin "${name}": ${errorMessage(error)}`) }
|
|
110
133
|
}
|
|
@@ -124,7 +147,7 @@ export async function loadPlugins(deckDir: string, names: string[], sources?: Pl
|
|
|
124
147
|
export async function loadOperatorPlugins(sources: PluginSources): Promise<{ plugins: LoadedPlugin[], problems: PluginProblem[] }> {
|
|
125
148
|
const plugins: LoadedPlugin[] = []
|
|
126
149
|
const problems: PluginProblem[] = []
|
|
127
|
-
for (const name of sources.listed) {
|
|
150
|
+
for (const name of listedPlugins(sources.listed)) {
|
|
128
151
|
const dir = findPackageDir(name, resolve(sources.workspaceRoot), false) ?? bundledDir(name, sources)
|
|
129
152
|
if (!dir) {
|
|
130
153
|
problems.push({ name, severity: 'error', message: `operator plugin "${name}" (settings.presmium.workspace.plugins) is not installed at the workspace root ${sources.workspaceRoot}` })
|
|
@@ -139,7 +162,7 @@ export async function loadOperatorPlugins(sources: PluginSources): Promise<{ plu
|
|
|
139
162
|
|
|
140
163
|
export const DECK_PLUGIN = 'deck'
|
|
141
164
|
|
|
142
|
-
const COMPONENT = /^[^.]+\.(?:
|
|
165
|
+
const COMPONENT = /^[^.]+\.(?:[jt]sx?)$/
|
|
143
166
|
|
|
144
167
|
export async function directoryComponents(dir: string): Promise<string[]> {
|
|
145
168
|
const files = await readdir(join(dir, 'components')).catch(() => [])
|
|
@@ -162,10 +185,30 @@ export function findPackageDir(name: string, from: string, up = true): string |
|
|
|
162
185
|
}
|
|
163
186
|
}
|
|
164
187
|
|
|
165
|
-
function exportTarget(
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
188
|
+
function exportTarget(pkg: PluginPackage, dir: string): string | undefined {
|
|
189
|
+
let targets: string[] | void
|
|
190
|
+
try {
|
|
191
|
+
targets = resolveExports(pkg, NODE_PLUGIN_EXPORT)
|
|
192
|
+
} catch {
|
|
193
|
+
// A package with no matching Node export still contributes its components.
|
|
194
|
+
return undefined
|
|
195
|
+
}
|
|
196
|
+
if (!targets?.length) return undefined
|
|
197
|
+
const base = pathToFileURL(`${dir}/`)
|
|
198
|
+
for (const target of targets) {
|
|
199
|
+
const path = target.split(/[?#]/, 1)[0]!
|
|
200
|
+
if (!path.startsWith('./') || /%2f|%5c/i.test(path)) continue
|
|
201
|
+
try {
|
|
202
|
+
const segments = path.slice(2).split(/[\\/]/).map(decodeURIComponent)
|
|
203
|
+
if (segments.some(segment => /^(?:\.{1,2}|node_modules)$/i.test(segment))) continue
|
|
204
|
+
const normalized = new URL(target, base)
|
|
205
|
+
if (!normalized.href.startsWith(base.href)) continue
|
|
206
|
+
return target
|
|
207
|
+
} catch {
|
|
208
|
+
// Invalid URL encoding cannot name a package file; arrays may provide another target.
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
throw new Error(`${NODE_PLUGIN_EXPORT} has no valid package-relative Node target`)
|
|
169
212
|
}
|
|
170
213
|
|
|
171
214
|
function isNodePlugin(value: unknown): value is PresmiumNodePlugin {
|