@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
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { environmentExecutor, errorMessage, irBlocks, isRecord } from '@lincyaw/kernel'
|
|
2
|
+
import type { CodeNode, EnvironmentContext, EnvironmentProvider, EnvironmentSpec, ExecuteConfig, Executor, Finding, IRDocument } from '@lincyaw/kernel'
|
|
3
|
+
|
|
4
|
+
const PREFIX = environmentExecutor('')
|
|
5
|
+
|
|
6
|
+
type At = (needle: string) => { file: string, line: number }
|
|
7
|
+
|
|
8
|
+
export function toExecuteConfig(value: unknown, report: (finding: Finding) => void, at: At): ExecuteConfig {
|
|
9
|
+
if (value === undefined) return {}
|
|
10
|
+
const fail = (message: string, needle = 'execute') => report({ checkId: 'presmium.execute', severity: 'error', message, source: at(needle) })
|
|
11
|
+
if (!isRecord(value)) {
|
|
12
|
+
fail('`execute` must be a mapping, e.g. `execute: { environments: { box: { kind: devcontainer } } }`')
|
|
13
|
+
return {}
|
|
14
|
+
}
|
|
15
|
+
const execute: ExecuteConfig = {}
|
|
16
|
+
if (value.env !== undefined) {
|
|
17
|
+
if (typeof value.env === 'string' && value.env.trim()) execute.env = value.env.trim()
|
|
18
|
+
else fail('`execute.env` must be the name of an environment')
|
|
19
|
+
}
|
|
20
|
+
if (value.rerun === 'environment') execute.rerun = 'environment'
|
|
21
|
+
if (value.environments !== undefined) {
|
|
22
|
+
if (!isRecord(value.environments)) {
|
|
23
|
+
fail('`execute.environments` must map names to environments, e.g. `box: { kind: devcontainer }`')
|
|
24
|
+
} else {
|
|
25
|
+
const environments: Record<string, EnvironmentSpec> = {}
|
|
26
|
+
for (const [name, spec] of Object.entries(value.environments)) {
|
|
27
|
+
if (name === 'browser' || !/^[\w-]+$/.test(name)) fail(`environment "${name}": a name is letters, digits, \`_\` and \`-\`, and not \`browser\`, which is reserved`, `${name}:`)
|
|
28
|
+
else if (!isRecord(spec) || typeof spec.kind !== 'string' || !spec.kind) fail(`environment "${name}" needs a \`kind\`, e.g. jupyter, devcontainer, wasi, webgpu`, `${name}:`)
|
|
29
|
+
else environments[name] = spec as EnvironmentSpec
|
|
30
|
+
}
|
|
31
|
+
execute.environments = environments
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (execute.env && execute.env !== 'browser' && !execute.environments?.[execute.env]) fail(`\`execute.env\`: no environment "${execute.env}" in \`execute.environments\``)
|
|
35
|
+
return execute
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function environmentProviders(execute: ExecuteConfig, providers: Map<string, EnvironmentProvider>, report: (finding: Finding) => void, at: At): Map<string, EnvironmentProvider> {
|
|
39
|
+
const found = new Map<string, EnvironmentProvider>()
|
|
40
|
+
for (const [name, spec] of Object.entries(execute.environments ?? {})) {
|
|
41
|
+
const provider = providers.get(spec.kind)
|
|
42
|
+
if (provider) found.set(name, provider)
|
|
43
|
+
else report({ checkId: 'presmium.execute', severity: 'error', message: `environment "${name}": no plugin in \`plugins\` provides environments of kind ${spec.kind}`, source: at(`${name}:`) })
|
|
44
|
+
}
|
|
45
|
+
return found
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function environmentExecutors(
|
|
49
|
+
execute: ExecuteConfig,
|
|
50
|
+
providers: Map<string, EnvironmentProvider>,
|
|
51
|
+
context: EnvironmentContext,
|
|
52
|
+
made: WeakMap<EnvironmentProvider, Map<string, Executor>>,
|
|
53
|
+
report: (finding: Finding) => void,
|
|
54
|
+
at: At,
|
|
55
|
+
): Promise<Executor[]> {
|
|
56
|
+
const executors: Executor[] = []
|
|
57
|
+
for (const [name, provider] of providers) {
|
|
58
|
+
const spec = execute.environments![name]!
|
|
59
|
+
const key = JSON.stringify([name, spec])
|
|
60
|
+
let instances = made.get(provider)
|
|
61
|
+
if (!instances) made.set(provider, instances = new Map())
|
|
62
|
+
let executor = instances.get(key)
|
|
63
|
+
if (!executor) {
|
|
64
|
+
try {
|
|
65
|
+
executor = await provider.create(name, spec, context)
|
|
66
|
+
} catch (error) {
|
|
67
|
+
report({ checkId: 'presmium.execute', severity: 'error', message: `environment "${name}": the ${spec.kind} provider failed: ${errorMessage(error)}`, source: at(`${name}:`) })
|
|
68
|
+
continue
|
|
69
|
+
}
|
|
70
|
+
if (executor.id !== environmentExecutor(name)) {
|
|
71
|
+
report({ checkId: 'presmium.execute', severity: 'error', message: `environment "${name}": the ${spec.kind} provider made the executor ${executor.id}, not ${environmentExecutor(name)}`, source: at(`${name}:`) })
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
instances.set(key, executor)
|
|
75
|
+
}
|
|
76
|
+
executors.push(executor)
|
|
77
|
+
}
|
|
78
|
+
return executors
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function checkRuns(doc: IRDocument, execute: ExecuteConfig, report: (finding: Finding) => void): void {
|
|
82
|
+
const sessions = new Map<string, string>()
|
|
83
|
+
for (const section of doc.sections) {
|
|
84
|
+
for (const block of irBlocks(section.blocks)) {
|
|
85
|
+
const run = block.node.type === 'code' ? block.node.run : undefined
|
|
86
|
+
if (!run) continue
|
|
87
|
+
const fail = (message: string) => report({ checkId: 'presmium.execute', severity: 'error', message, source: { file: block.source.file, line: block.source.line } })
|
|
88
|
+
const name = run.executor?.startsWith(PREFIX) ? run.executor.slice(PREFIX.length) : undefined
|
|
89
|
+
if (name !== undefined && !execute.environments?.[name]) fail(`:env: ${name}: no such environment in \`execute.environments\` of the headmatter`)
|
|
90
|
+
if (!run.session) continue
|
|
91
|
+
const where = run.executor ?? 'browser'
|
|
92
|
+
const first = sessions.get(run.session)
|
|
93
|
+
if (first === undefined) sessions.set(run.session, where)
|
|
94
|
+
else if (first !== where) fail(`session "${run.session}" runs in ${describe(first)} and here in ${describe(where)}; the blocks of a session share one environment`)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function checkLanguages(doc: IRDocument, execute: ExecuteConfig, executors: readonly Executor[], report: (finding: Finding) => void): Set<CodeNode> {
|
|
100
|
+
const reported = new Set<CodeNode>()
|
|
101
|
+
for (const section of doc.sections) {
|
|
102
|
+
for (const block of irBlocks(section.blocks)) {
|
|
103
|
+
const run = block.node.type === 'code' ? block.node.run : undefined
|
|
104
|
+
const name = run?.executor?.startsWith(PREFIX) ? run.executor.slice(PREFIX.length) : undefined
|
|
105
|
+
const spec = name === undefined ? undefined : execute.environments?.[name]
|
|
106
|
+
const executor = spec?.languages?.length ? executors.find(e => e.id === run!.executor) : undefined
|
|
107
|
+
if (!run || !spec?.languages || !executor || executor.supports(run.lang)) continue
|
|
108
|
+
reported.add(block.node as CodeNode)
|
|
109
|
+
const runs = `runs ${spec.languages.join(', ')}, not ${run.lang}`
|
|
110
|
+
const fallback = execute.env === name ? ` (blocks that name none run in "${name}", the headmatter's execute.env)` : ''
|
|
111
|
+
report({
|
|
112
|
+
checkId: 'presmium.execute',
|
|
113
|
+
severity: 'error',
|
|
114
|
+
message: `environment "${name}" (${spec.kind}) ${runs}`,
|
|
115
|
+
hint: `name another environment with :env:${fallback}, or add ${run.lang} to the languages of "${name}"`,
|
|
116
|
+
source: { file: block.source.file, line: block.source.line },
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return reported
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function describe(executor: string): string {
|
|
124
|
+
return executor.startsWith(PREFIX) ? `environment ${executor.slice(PREFIX.length)}` : executor === 'browser' ? 'the default environment' : `executor ${executor}`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const CREDENTIALS = [
|
|
128
|
+
/^sk-[\w-]{16,}$/,
|
|
129
|
+
/^(?:gh[pousr]|github_pat)_\w{20,}$/,
|
|
130
|
+
/^xox[abposr]-[\w-]{10,}$/,
|
|
131
|
+
/^AKIA[0-9A-Z]{16}$/,
|
|
132
|
+
/^AIza[\w-]{30,}$/,
|
|
133
|
+
/^eyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]{10,}$/,
|
|
134
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
135
|
+
]
|
|
136
|
+
const CREDENTIAL_KEY = /(?:^|[_-])(?:token|secret|password|passwd|api[_-]?key|access[_-]?key|private[_-]?key)$/i
|
|
137
|
+
|
|
138
|
+
export function checkSecrets(headmatter: Record<string, unknown>, report: (finding: Finding) => void, at: At): void {
|
|
139
|
+
const visit = (value: unknown, key: string, path: string) => {
|
|
140
|
+
if (typeof value === 'string') {
|
|
141
|
+
const looks = CREDENTIALS.some(pattern => pattern.test(value.trim())) || (CREDENTIAL_KEY.test(key) && value.trim().length >= 12 && !/\s/.test(value.trim()))
|
|
142
|
+
if (looks) report({ checkId: 'presmium.secrets', severity: 'warning', message: `${path} looks like a credential; credentials come from environment variables or a login (e.g. PRESMIUM_ENV_<NAME>_TOKEN), never from the deck, which is published`, source: at(`${key}:`) })
|
|
143
|
+
} else if (Array.isArray(value)) {
|
|
144
|
+
value.forEach((item, i) => visit(item, key, `${path}[${i}]`))
|
|
145
|
+
} else if (isRecord(value)) {
|
|
146
|
+
for (const [k, v] of Object.entries(value)) visit(v, k, path ? `${path}.${k}` : k)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
visit(headmatter, '', '')
|
|
150
|
+
}
|
package/src/execute.ts
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { CREDENTIAL_VARIABLE, environmentExecutor, errorMessage, irBlocks, isRecord } from '@lincyaw/kernel'
|
|
6
|
+
import type { CodeNode, Executor, Finding, IRBlock, IRDocument, RecordedRun, RunCache, RunContext, RunOutput, RunRequest, RunResult } from '@lincyaw/kernel'
|
|
7
|
+
import { resourceEnvironment } from '@lincyaw/kernel/node'
|
|
8
|
+
|
|
9
|
+
export const FREEZE_DIR = '.presmium/freeze'
|
|
10
|
+
const BLOBS = 'blobs'
|
|
11
|
+
const BLOB_LENGTH = 4096
|
|
12
|
+
const BLOB_REF = 'presmium/blob'
|
|
13
|
+
|
|
14
|
+
interface FreezeRecord {
|
|
15
|
+
input?: string
|
|
16
|
+
entry?: string
|
|
17
|
+
lang?: string
|
|
18
|
+
code?: string
|
|
19
|
+
environment?: string
|
|
20
|
+
revision?: string
|
|
21
|
+
run: RecordedRun
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class FreezeCache implements RunCache {
|
|
25
|
+
readonly dir: string
|
|
26
|
+
readonly entry: string
|
|
27
|
+
readonly used = new Set<string>()
|
|
28
|
+
#records?: Map<string, FreezeRecord>
|
|
29
|
+
|
|
30
|
+
constructor(dir: string, entry: string) {
|
|
31
|
+
this.dir = dir
|
|
32
|
+
this.entry = entry
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get(key: string): RecordedRun | undefined {
|
|
36
|
+
const record = this.#read(`${key}.json`)
|
|
37
|
+
return record && this.#restore(record.run)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
set(key: string, run: RecordedRun): void {
|
|
41
|
+
this.#write(key, { run })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Records a run under its cache key and drops the older recordings of the same input. */
|
|
45
|
+
recordRun(key: string, input: string, request: RunRequest, run: RecordedRun, environment: string, revision = ''): void {
|
|
46
|
+
for (const [file, record] of this.#all()) {
|
|
47
|
+
if (record.input !== input || file === `${key}.json`) continue
|
|
48
|
+
rmSync(join(this.dir, file), { force: true })
|
|
49
|
+
this.#records?.delete(file)
|
|
50
|
+
}
|
|
51
|
+
this.#write(key, { input, entry: this.entry, lang: request.lang, code: request.code, environment, ...revision ? { revision } : {}, run })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The recording of the input, made in whichever environment, whatever its cache key. */
|
|
55
|
+
findByInput(input: string): { run: RecordedRun, environment?: string, revision: string } | undefined {
|
|
56
|
+
for (const record of this.#all().values()) {
|
|
57
|
+
if (record.input !== input) continue
|
|
58
|
+
const run = this.#restore(record.run)
|
|
59
|
+
if (run) return { run, environment: record.environment, revision: record.revision ?? '' }
|
|
60
|
+
}
|
|
61
|
+
return undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
prune(): number {
|
|
65
|
+
let removed = 0
|
|
66
|
+
for (const [file, record] of this.#all()) {
|
|
67
|
+
if (record.entry !== this.entry || !record.input || this.used.has(record.input)) continue
|
|
68
|
+
rmSync(join(this.dir, file), { force: true })
|
|
69
|
+
this.#records?.delete(file)
|
|
70
|
+
removed++
|
|
71
|
+
}
|
|
72
|
+
const referred = new Set<string>()
|
|
73
|
+
for (const record of this.#all().values()) blobRefs(record.run, referred)
|
|
74
|
+
let blobs: string[] = []
|
|
75
|
+
try {
|
|
76
|
+
blobs = readdirSync(join(this.dir, BLOBS))
|
|
77
|
+
} catch {}
|
|
78
|
+
for (const blob of blobs.filter(name => !referred.has(name))) rmSync(join(this.dir, BLOBS, blob), { force: true })
|
|
79
|
+
return removed
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#all(): Map<string, FreezeRecord> {
|
|
83
|
+
if (!this.#records) {
|
|
84
|
+
this.#records = new Map()
|
|
85
|
+
let files: string[] = []
|
|
86
|
+
try {
|
|
87
|
+
files = readdirSync(this.dir).filter(f => f.endsWith('.json')).sort()
|
|
88
|
+
} catch {
|
|
89
|
+
}
|
|
90
|
+
for (const file of files) {
|
|
91
|
+
const record = this.#read(file)
|
|
92
|
+
if (record) this.#records.set(file, record)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return this.#records
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#text(file: string): string | undefined {
|
|
99
|
+
try {
|
|
100
|
+
return readFileSync(file, 'utf8')
|
|
101
|
+
} catch {
|
|
102
|
+
return undefined
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
#read(file: string): FreezeRecord | undefined {
|
|
107
|
+
const text = this.#text(join(this.dir, file))
|
|
108
|
+
if (text === undefined) return undefined
|
|
109
|
+
try {
|
|
110
|
+
const data: unknown = JSON.parse(text)
|
|
111
|
+
return isRecord(data) && isRecord(data.run) ? data as unknown as FreezeRecord : undefined
|
|
112
|
+
} catch {
|
|
113
|
+
return undefined
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
#write(key: string, record: FreezeRecord): void {
|
|
118
|
+
mkdirSync(this.dir, { recursive: true })
|
|
119
|
+
const stored = { ...record, run: this.#store(record.run) }
|
|
120
|
+
writeFileSync(join(this.dir, `${key}.json`), `${JSON.stringify(stored, null, 2)}\n`)
|
|
121
|
+
this.#records?.set(`${key}.json`, stored)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
#store(run: RecordedRun): RecordedRun {
|
|
125
|
+
if (!run.outputs) return run
|
|
126
|
+
return { ...run, outputs: run.outputs.map(output => mapData(output, (value) => {
|
|
127
|
+
const text = Array.isArray(value) && value.every(v => typeof v === 'string') ? value.join('') : value
|
|
128
|
+
if (typeof text !== 'string' || text.length <= BLOB_LENGTH) return value
|
|
129
|
+
const name = createHash('sha256').update(text).digest('hex')
|
|
130
|
+
const file = join(this.dir, BLOBS, name)
|
|
131
|
+
if (!existsSync(file)) {
|
|
132
|
+
mkdirSync(join(this.dir, BLOBS), { recursive: true })
|
|
133
|
+
writeFileSync(file, text)
|
|
134
|
+
}
|
|
135
|
+
return { [BLOB_REF]: name }
|
|
136
|
+
})) }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#restore(run: RecordedRun): RecordedRun | undefined {
|
|
140
|
+
if (!run.outputs) return run
|
|
141
|
+
let complete = true
|
|
142
|
+
const outputs = run.outputs.map(output => mapData(output, (value) => {
|
|
143
|
+
if (!isRecord(value) || typeof value[BLOB_REF] !== 'string') return value
|
|
144
|
+
const text = this.#text(join(this.dir, BLOBS, value[BLOB_REF].replace(/[^0-9a-f]/g, '')))
|
|
145
|
+
if (text === undefined) complete = false
|
|
146
|
+
return text
|
|
147
|
+
}))
|
|
148
|
+
return complete ? { ...run, outputs } : undefined
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function mapData(output: RunOutput, map: (value: unknown) => unknown): RunOutput {
|
|
153
|
+
if (output.output_type !== 'display_data' && output.output_type !== 'execute_result') return output
|
|
154
|
+
const data: Record<string, unknown> = {}
|
|
155
|
+
for (const [mime, value] of Object.entries(output.data)) {
|
|
156
|
+
const mapped = map(value)
|
|
157
|
+
if (mapped !== undefined) data[mime] = mapped
|
|
158
|
+
}
|
|
159
|
+
return { ...output, data }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function blobRefs(run: RecordedRun, into: Set<string>): void {
|
|
163
|
+
for (const output of run.outputs ?? []) {
|
|
164
|
+
mapData(output, (value) => {
|
|
165
|
+
if (isRecord(value) && typeof value[BLOB_REF] === 'string') into.add(value[BLOB_REF])
|
|
166
|
+
return value
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface RunEnvironmentOptions {
|
|
172
|
+
env?: 'local' | 'serve'
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface RunStats {
|
|
176
|
+
executed: number
|
|
177
|
+
cached: number
|
|
178
|
+
sandbox?: string
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface ExecutePassOptions extends RunEnvironmentOptions {
|
|
182
|
+
signal?: AbortSignal
|
|
183
|
+
rerun?: boolean
|
|
184
|
+
context: RunContext
|
|
185
|
+
unsupported?: ReadonlySet<CodeNode>
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const SERVE_ALLOWED = /^(?:PATH|LANG|LC_\w+|TZ|TMPDIR)$/
|
|
189
|
+
|
|
190
|
+
export function runEnvironment(rules: 'local' | 'serve', passEnv: readonly string[], workspaceRoot: string, source: NodeJS.ProcessEnv = process.env): Record<string, string> {
|
|
191
|
+
const env: Record<string, string> = {}
|
|
192
|
+
for (const [name, value] of Object.entries(source)) {
|
|
193
|
+
if (value === undefined) continue
|
|
194
|
+
const kept = rules === 'serve' ? SERVE_ALLOWED.test(name) : !/^PRESMIUM_/i.test(name) && !CREDENTIAL_VARIABLE.test(name)
|
|
195
|
+
if (kept) env[name] = value
|
|
196
|
+
}
|
|
197
|
+
if (rules === 'serve') {
|
|
198
|
+
env.HOME = runHome(workspaceRoot, source)
|
|
199
|
+
env.PYTHONNOUSERSITE = '1'
|
|
200
|
+
}
|
|
201
|
+
for (const name of passEnv) {
|
|
202
|
+
const value = source[name]
|
|
203
|
+
if (value !== undefined) env[name] = value
|
|
204
|
+
}
|
|
205
|
+
if (source.PRESMIUM_MAX_THREADS !== undefined) env.PRESMIUM_MAX_THREADS = source.PRESMIUM_MAX_THREADS
|
|
206
|
+
return resourceEnvironment(env)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function runHome(workspaceRoot: string, source: NodeJS.ProcessEnv): string {
|
|
210
|
+
const state = source.XDG_STATE_HOME || join(homedir(), '.local', 'state')
|
|
211
|
+
const home = join(state, 'presmium', 'run-home', createHash('sha256').update(workspaceRoot).digest('hex').slice(0, 16))
|
|
212
|
+
mkdirSync(home, { recursive: true, mode: 0o700 })
|
|
213
|
+
const stat = lstatSync(home)
|
|
214
|
+
const uid = process.getuid?.()
|
|
215
|
+
if (!stat.isDirectory() || (uid !== undefined && stat.uid !== uid) || (stat.mode & 0o077) !== 0) {
|
|
216
|
+
throw new Error(`the run HOME ${home} is not a private directory of this user`)
|
|
217
|
+
}
|
|
218
|
+
return home
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
interface Item {
|
|
222
|
+
node: CodeNode
|
|
223
|
+
block: IRBlock
|
|
224
|
+
request: RunRequest
|
|
225
|
+
input: string
|
|
226
|
+
key?: string
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
interface Unit {
|
|
230
|
+
session?: string
|
|
231
|
+
items: Item[]
|
|
232
|
+
executor?: Executor
|
|
233
|
+
environment: string
|
|
234
|
+
revision?: string
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
type Report = (severity: Finding['severity'], message: string, block: IRBlock) => void
|
|
238
|
+
|
|
239
|
+
export async function executePass(doc: IRDocument, executors: Executor[], cache: FreezeCache, report: (finding: Finding) => void, options: ExecutePassOptions): Promise<RunStats> {
|
|
240
|
+
const stats: RunStats = { executed: 0, cached: 0, ...options.context.sandbox ? { sandbox: options.context.sandbox.kind } : {} }
|
|
241
|
+
const say: Report = (severity, message, block) =>
|
|
242
|
+
report({ checkId: 'presmium.execute', severity, message, source: { file: block.source.file, line: block.source.line } })
|
|
243
|
+
const units = collect(doc, cache)
|
|
244
|
+
|
|
245
|
+
const pending: Unit[] = []
|
|
246
|
+
for (const unit of units) {
|
|
247
|
+
const run = unit.items[0]!.node.run!
|
|
248
|
+
unit.executor = run.executor
|
|
249
|
+
? executors.find(e => e.id === run.executor && e.supports(run.lang))
|
|
250
|
+
: executors.find(e => !e.id.startsWith(environmentExecutor('')) && e.supports(run.lang))
|
|
251
|
+
if (!unit.executor) {
|
|
252
|
+
for (const item of unit.items) fromRecording(item, cache, stats, say, run.executor, undefined, options.unsupported?.has(item.node))
|
|
253
|
+
continue
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
unit.environment = await unit.executor.fingerprint?.(run.lang) ?? ''
|
|
257
|
+
unit.revision = await unit.executor.revision?.(run.lang) ?? ''
|
|
258
|
+
} catch (error) {
|
|
259
|
+
for (const item of unit.items) fromRecording(item, cache, stats, say, run.executor, errorMessage(error))
|
|
260
|
+
continue
|
|
261
|
+
}
|
|
262
|
+
let previous = unit.session
|
|
263
|
+
for (const item of unit.items) {
|
|
264
|
+
const made = { executor: unit.executor.id, environment: unit.environment, ...unit.revision ? { revision: unit.revision } : {} }
|
|
265
|
+
item.key = hash(unit.session ? { previous, input: item.input, ...made } : { input: item.input, ...made })
|
|
266
|
+
previous = item.key
|
|
267
|
+
}
|
|
268
|
+
const cached = unit.items.map(item => cache.get(item.key!))
|
|
269
|
+
if (cached.every(Boolean)) {
|
|
270
|
+
unit.items.forEach((item, i) => item.node.outputs = [cached[i]!])
|
|
271
|
+
stats.cached += unit.items.length
|
|
272
|
+
continue
|
|
273
|
+
}
|
|
274
|
+
const others = options.rerun ? [] : unit.items.map(item => cache.findByInput(item.input)).map(other => (other?.revision === unit.revision ? other : undefined))
|
|
275
|
+
if (others.length > 0 && others.every(Boolean)) {
|
|
276
|
+
unit.items.forEach((item, i) => {
|
|
277
|
+
const other = others[i]!
|
|
278
|
+
item.node.outputs = [other.run]
|
|
279
|
+
const made = other.environment === undefined ? 'another environment' : `"${other.environment || 'no fingerprint'}"`
|
|
280
|
+
say('warning', `block ${item.block.id}: its recorded output was made with ${made}, this build has "${unit.environment || 'no fingerprint'}"; the recording is reused (\`execute: { rerun: environment }\` in the headmatter runs it again)`, item.block)
|
|
281
|
+
})
|
|
282
|
+
stats.cached += unit.items.length
|
|
283
|
+
continue
|
|
284
|
+
}
|
|
285
|
+
pending.push(unit)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const sessions: [Executor, string][] = []
|
|
289
|
+
const compile = `compile:${randomUUID()}`
|
|
290
|
+
try {
|
|
291
|
+
for (const unit of pending) {
|
|
292
|
+
options.signal?.throwIfAborted()
|
|
293
|
+
const executor = unit.executor!
|
|
294
|
+
const session = unit.session && `${compile}:${unit.session}`
|
|
295
|
+
const run = (request: RunRequest) => executor.run(session ? { ...request, session } : request, options.signal, options.context)
|
|
296
|
+
try {
|
|
297
|
+
if (session) {
|
|
298
|
+
await executor.endSession?.(session)
|
|
299
|
+
sessions.push([executor, session])
|
|
300
|
+
// Submit a logical session together: executors batch these into one runtime.
|
|
301
|
+
const results = await Promise.all(unit.items.map(item => run(item.request)))
|
|
302
|
+
options.signal?.throwIfAborted()
|
|
303
|
+
unit.items.forEach((item, i) => record(item, results[i]!, unit, cache, stats, say))
|
|
304
|
+
} else {
|
|
305
|
+
const item = unit.items[0]!
|
|
306
|
+
const result = await run(item.request)
|
|
307
|
+
options.signal?.throwIfAborted()
|
|
308
|
+
record(item, result, unit, cache, stats, say)
|
|
309
|
+
}
|
|
310
|
+
} catch (error) {
|
|
311
|
+
options.signal?.throwIfAborted()
|
|
312
|
+
for (const item of unit.items) fromRecording(item, cache, stats, say, executor.id, errorMessage(error))
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
} finally {
|
|
316
|
+
for (const [executor, session] of sessions) await executor.endSession?.(session)?.catch(() => {})
|
|
317
|
+
}
|
|
318
|
+
return stats
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function collect(doc: IRDocument, cache: FreezeCache): Unit[] {
|
|
322
|
+
const units: Unit[] = []
|
|
323
|
+
const sessions = new Map<string, Unit>()
|
|
324
|
+
for (const section of doc.sections) {
|
|
325
|
+
for (const block of irBlocks(section.blocks)) {
|
|
326
|
+
const node = block.node
|
|
327
|
+
if (node.type !== 'code' || !node.run || node.run.when === 'live') continue
|
|
328
|
+
const run = node.run
|
|
329
|
+
const request: RunRequest = { lang: run.lang, code: node.value }
|
|
330
|
+
if (run.args) request.args = run.args
|
|
331
|
+
if (run.stdin !== undefined) request.stdin = run.stdin
|
|
332
|
+
if (run.timeoutMs !== undefined) request.timeoutMs = run.timeoutMs
|
|
333
|
+
const fields = { lang: request.lang, code: request.code, args: request.args, stdin: request.stdin, executor: run.executor }
|
|
334
|
+
if (!run.session) {
|
|
335
|
+
const input = hash(fields)
|
|
336
|
+
cache.used.add(input)
|
|
337
|
+
units.push({ items: [{ node, block, request, input }], environment: '' })
|
|
338
|
+
continue
|
|
339
|
+
}
|
|
340
|
+
const session = `${cache.entry}:${run.session}`
|
|
341
|
+
request.session = session
|
|
342
|
+
let unit = sessions.get(session)
|
|
343
|
+
if (!unit) {
|
|
344
|
+
unit = { session, items: [], environment: '' }
|
|
345
|
+
sessions.set(session, unit)
|
|
346
|
+
units.push(unit)
|
|
347
|
+
}
|
|
348
|
+
const input = hash({ previous: unit.items.at(-1)?.input ?? session, ...fields })
|
|
349
|
+
cache.used.add(input)
|
|
350
|
+
unit.items.push({ node, block, request, input })
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return units
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function record(item: Item, result: RunResult, unit: Unit, cache: FreezeCache, stats: RunStats, say: Report): void {
|
|
357
|
+
const recorded: RecordedRun = { ...result, cacheKey: item.key! }
|
|
358
|
+
item.node.outputs = [recorded]
|
|
359
|
+
stats.executed++
|
|
360
|
+
if (!result.timedOut) cache.recordRun(item.key!, item.input, item.request, recorded, unit.environment, unit.revision)
|
|
361
|
+
if (!result.ok) say('warning', `block ${item.block.id} exited with ${result.timedOut ? 'a timeout' : result.exitCode === null ? `no exit code: ${result.stderr.trim().split('\n')[0]}` : `code ${result.exitCode}`}`, item.block)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function fromRecording(item: Item, cache: FreezeCache, stats: RunStats, say: Report, executor: string | undefined, failure?: string, reported = false): void {
|
|
365
|
+
const recorded = cache.findByInput(item.input)?.run
|
|
366
|
+
const which = executor ?? item.request.lang
|
|
367
|
+
if (recorded) {
|
|
368
|
+
item.node.outputs = [recorded]
|
|
369
|
+
stats.cached++
|
|
370
|
+
if (failure) say('warning', `executor ${which} is unavailable (${failure}); block ${item.block.id} shows its recorded output`, item.block)
|
|
371
|
+
return
|
|
372
|
+
}
|
|
373
|
+
if (reported) return
|
|
374
|
+
if (failure) return say('error', `executor ${which} failed on block ${item.block.id}: ${failure}`, item.block)
|
|
375
|
+
const environment = executor?.startsWith(environmentExecutor(''))
|
|
376
|
+
say(environment ? 'error' : 'warning', `no executor for ${which} and no recorded output in ${FREEZE_DIR}; block ${item.block.id} has no output`, item.block)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function hash(value: unknown): string {
|
|
380
|
+
return createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
|
381
|
+
}
|
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/footnotes.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { docBlocks, nodeMdast } from '@lincyaw/kernel'
|
|
2
|
+
import type { IRDocument, MdastNode } from '@lincyaw/kernel'
|
|
3
|
+
import { unique } from './sections.ts'
|
|
4
|
+
|
|
5
|
+
const FOOTNOTE = new Set(['footnoteReference', 'footnoteDefinition'])
|
|
6
|
+
|
|
7
|
+
export function numberFootnotes(doc: IRDocument, files: readonly string[]): void {
|
|
8
|
+
const byFile = new Map<string, MdastNode[]>(files.map(file => [file, []]))
|
|
9
|
+
const collect = (node: MdastNode, into: MdastNode[]) => {
|
|
10
|
+
if (FOOTNOTE.has(node.type) && typeof node.identifier === 'string') into.push(node)
|
|
11
|
+
node.children?.forEach(child => collect(child, into))
|
|
12
|
+
}
|
|
13
|
+
for (const { block } of docBlocks(doc)) {
|
|
14
|
+
const into = byFile.get(block.source.file) ?? byFile.set(block.source.file, []).get(block.source.file)!
|
|
15
|
+
for (const tree of nodeMdast(block.node)) collect(tree, into)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const used = new Set<string>()
|
|
19
|
+
for (const nodes of byFile.values()) {
|
|
20
|
+
const rename = new Map<string, string>()
|
|
21
|
+
for (const node of nodes) {
|
|
22
|
+
const id = node.identifier as string
|
|
23
|
+
if (node.type !== 'footnoteDefinition' || rename.has(id)) continue
|
|
24
|
+
rename.set(id, unique(id, used))
|
|
25
|
+
}
|
|
26
|
+
const numbers = new Map<string, number>()
|
|
27
|
+
for (const type of ['footnoteReference', 'footnoteDefinition']) {
|
|
28
|
+
for (const node of nodes) {
|
|
29
|
+
const id = node.identifier as string
|
|
30
|
+
if (node.type === type && !numbers.has(id)) numbers.set(id, numbers.size + 1)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
for (const node of nodes) {
|
|
34
|
+
const id = node.identifier as string
|
|
35
|
+
node.enumerator = String(numbers.get(id))
|
|
36
|
+
node.identifier = rename.get(id) ?? id
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|