@lincyaw/compiler 1.0.0-rc.0 → 1.0.0-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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 { viewFor } from './renderers.ts'
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: blockText(block) })),
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
- function blockText(block: IRBlock): string {
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 { audienceView } from './audience.ts'
2
- export { Compiler, DECK_ENTRY, frontmatterLine, resolveEntry, sortFindings, workspacePlugins } from './compiler.ts'
3
- export type { Compilation, CompilerOptions } from './compiler.ts'
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 { buildView, checkContext, runBlock } from './host.ts'
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 { 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'
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 { 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'
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 { COMMIT } from './workspace.ts'
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
- export interface Revision {
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
- rev: string
15
+ ref: string
15
16
  }
16
17
 
17
- export class Dependencies {
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 #revisions = new Map<string, Revision>()
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 revisions(): Revision[] {
33
- return [...this.#revisions.values()]
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.#revisions.set(key, { file: path, rev: ref })
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 && COMMIT.test(ref)) this.#committed.set(key, text)
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
- export async function revisionFiles(dir: string, revs: Iterable<string>): Promise<string[]> {
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', ...[...revs].filter(rev => !rev.startsWith('-') && !COMMIT.test(rev))])
73
- const refs = await Promise.all([...names].map(name => git(['rev-parse', '--symbolic-full-name', name])))
74
- const files = new Set(['HEAD', 'packed-refs', ...refs.flat()])
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, extname, join, relative, resolve, sep } from 'node:path'
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 { 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'
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 (REMOTE.test(url)) return undefined
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 (!inside(file, input.workspaceRoot)) return `${path} lies outside the repository: it cannot be published`
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, 'lecture.md') : file
99
- if (extname(entry) !== '.md' || !existsSync(entry)) return undefined
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(slugify(plainText(node)))
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 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)
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 { existsSync, statSync } from 'node:fs'
2
- import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
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 { fileHash } from './published.ts'
4
+ import { isInside } from '@lincyaw/kernel/node'
6
5
 
7
- const REMOTE = /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i
6
+ export const DECK_ENTRY = DOCUMENT_ENTRY
8
7
 
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
- }
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 inRepository(value, root) ? relativePath(from, value) : value
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)) source(block.source)
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.exports?.[NODE_PLUGIN_EXPORT])
117
+ const target = exportTarget(pkg, dir)
95
118
  if (!target) return { loaded: { name, dir } }
96
- const file = join(dir, target)
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 = /^[^.]+\.(?:vue|[jt]sx?)$/
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(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)
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 {