@lincyaw/compiler 0.0.0-stage → 1.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,116 @@
1
+ import type { DirectiveOptionType, Locale } from '@lincyaw/kernel'
2
+
3
+ export interface DirectiveOptionInfo {
4
+ type?: DirectiveOptionType
5
+ doc?: Record<Locale, string>
6
+ values?: string[]
7
+ }
8
+
9
+ export interface DirectiveInfo {
10
+ name: string
11
+ doc: Record<Locale, string>
12
+ options: Record<string, DirectiveOptionInfo>
13
+ }
14
+
15
+ export const COMMON_DIRECTIVE_OPTIONS: Record<string, DirectiveOptionInfo> = {
16
+ reveal: {
17
+ doc: { 'zh-CN': '在下一步出现;`{slide}`、`{only}` 上写 `items` 时列表逐项出现。', 'en': 'Appears at the next step; `items` on `{slide}` and `{only}` reveals list items one per step.' },
18
+ values: ['items'],
19
+ },
20
+ label: { type: 'string', doc: { 'zh-CN': '块 ID。', 'en': 'Block id.' } },
21
+ audience: {
22
+ type: 'string',
23
+ doc: { 'zh-CN': '只给这些受众(逗号分隔),如 `teacher`;`presmium build --audience student` 不输出它。', 'en': 'Only for these audiences (comma-separated), e.g. `teacher`; `presmium build --audience student` leaves it out.' },
24
+ values: ['teacher', 'student'],
25
+ },
26
+ }
27
+
28
+ export const RUN_OPTION: DirectiveOptionInfo = {
29
+ type: 'string',
30
+ doc: { 'zh-CN': '`compile` 在构建时执行并录制输出,`live` 只在浏览器中执行,`both` 两者都做。', 'en': '`compile` records the output at build time, `live` runs in the browser, `both` does both.' },
31
+ values: ['compile', 'live', 'both'],
32
+ }
33
+
34
+ const STEP_RANGE: Record<string, DirectiveOptionInfo> = {
35
+ at: { type: 'number', doc: { 'zh-CN': '从指定步骤开始显示(从 0 开始),不能与 reveal 合用。', en: 'Shows from this zero-based step; cannot be combined with reveal.' } },
36
+ until: { type: 'number', doc: { 'zh-CN': '到指定步骤时隐藏;回退时恢复。', en: 'Hides at this step and returns when stepping back.' } },
37
+ }
38
+
39
+ const TITLE = { 'zh-CN': '标题。', 'en': 'Title.' }
40
+
41
+ const CODE_OPTIONS: Record<string, DirectiveOptionInfo> = {
42
+ 'caption': { type: 'string', doc: TITLE },
43
+ 'filename': { type: 'string', doc: TITLE },
44
+ 'emphasize-lines': { type: 'string', doc: { 'zh-CN': '每一步都强调的行,如 `2, 4-5`。', 'en': 'Lines highlighted at every step, e.g. `2, 4-5`.' } },
45
+ 'run': RUN_OPTION,
46
+ 'executor': { type: 'string', doc: { 'zh-CN': '执行器 ID。', 'en': 'Executor id.' } },
47
+ 'timeout': { type: 'number', doc: { 'zh-CN': '毫秒。', 'en': 'Milliseconds.' } },
48
+ 'env': { type: 'string', doc: { 'zh-CN': '运行环境:头部 `execute.environments` 中的名字,或 `browser`。', 'en': 'Where it runs: a name of `execute.environments` in the headmatter, or `browser`.' } },
49
+ 'session': { type: 'string', doc: { 'zh-CN': '执行会话:同一会话的代码块共享解释器。', 'en': 'Execution session: code blocks of one session share the interpreter.' } },
50
+ 'echo': { type: 'string', doc: { 'zh-CN': '`false`:只显示输出,Markdown 输出按正文显示。', 'en': '`false`: only the output shows, Markdown output as prose.' }, values: ['false'] },
51
+ }
52
+
53
+ const CLASS: DirectiveOptionInfo = { type: 'string', doc: { 'zh-CN': 'CSS 类名。', 'en': 'CSS class names.' } }
54
+
55
+ const LAYOUT_DIRECTIVES: DirectiveInfo[] = [
56
+ {
57
+ name: 'grid',
58
+ doc: { 'zh-CN': '栅格:参数为四档屏宽(手机、小、中、大)的列数,如 `1 1 2 3`;正文为 `{card}` 或 `{grid-item}`。', 'en': 'A grid: the argument is the columns at four screen widths (phone, small, medium, large), e.g. `1 1 2 3`; the body holds `{card}` or `{grid-item}`.' },
59
+ options: { class: CLASS },
60
+ },
61
+ {
62
+ name: 'grid-item',
63
+ doc: { 'zh-CN': '栅格中的一格。', 'en': 'An item of a grid.' },
64
+ options: { columns: { type: 'number', doc: { 'zh-CN': '占一行 12 份中的几份。', 'en': 'How many twelfths of a row it takes.' } }, class: CLASS },
65
+ },
66
+ {
67
+ name: 'card',
68
+ doc: { 'zh-CN': '卡片:参数为标题;正文中 `^^^` 之前为页眉,`+++` 之后为页脚。', 'en': 'A card: the argument is its title; in the body, what precedes `^^^` is its header, what follows `+++` its footer.' },
69
+ options: {
70
+ link: { type: 'string', doc: { 'zh-CN': '整张卡片链接到的地址。', 'en': 'Where the whole card links to.' } },
71
+ header: { type: 'string', doc: { 'zh-CN': '页眉。', 'en': 'Header.' } },
72
+ footer: { type: 'string', doc: { 'zh-CN': '页脚。', 'en': 'Footer.' } },
73
+ },
74
+ },
75
+ {
76
+ name: 'tab-set',
77
+ doc: { 'zh-CN': '一组选项卡;正文为 `{tab-item}`。', 'en': 'A set of tabs; the body holds `{tab-item}`.' },
78
+ options: { class: CLASS },
79
+ },
80
+ {
81
+ name: 'tab-item',
82
+ doc: { 'zh-CN': '一个选项卡:参数为标题。', 'en': 'A tab: the argument is its title.' },
83
+ options: {
84
+ sync: { type: 'string', doc: { 'zh-CN': '同步键:选中它时,页面上同一键的选项卡一起切换。', 'en': 'Sync key: choosing it switches the tabs with the same key on the page.' } },
85
+ selected: { type: 'boolean', doc: { 'zh-CN': '开始时选中。', 'en': 'Selected at first.' } },
86
+ },
87
+ },
88
+ ]
89
+
90
+ export const BUILTIN_DIRECTIVE_INFO: readonly DirectiveInfo[] = [
91
+ { name: 'slide', doc: { 'zh-CN': '上屏内容;正文为 MyST。', 'en': 'Content shown on screen; the body is MyST.' }, options: STEP_RANGE },
92
+ { name: 'only', doc: { 'zh-CN': '只在这些渲染端出现的内容,如 `{only} web, notebook`。', 'en': 'Content limited to renderers, e.g. `{only} web, notebook`.' }, options: STEP_RANGE },
93
+ { name: 'hints', doc: { 'zh-CN': '各渲染端的排版提示;正文为 YAML。', 'en': 'Layout hints per renderer; the body is YAML.' }, options: {} },
94
+ { name: 'config', doc: { 'zh-CN': '只在本章节生效的插件配置;正文为 YAML,键为插件 ID。', 'en': 'Plugin configuration of this section; the body is YAML keyed by plugin id.' }, options: {} },
95
+ { name: 'audience', doc: { 'zh-CN': '整个章节只给这些受众,如 `{audience} teacher`;放在章节顶层,没有正文。', 'en': 'The whole section is only for these audiences, e.g. `{audience} teacher`; at the top of a section, without a body.' }, options: {} },
96
+ {
97
+ name: 'component',
98
+ doc: { 'zh-CN': '插件组件,如 `{component} PmQuiz`;正文为 YAML 属性。', 'en': 'A plugin component, e.g. `{component} PmQuiz`; the body is YAML props.' },
99
+ options: { span: { type: 'number', doc: { 'zh-CN': '内部步数。', 'en': 'Internal steps.' } }, plugin: { type: 'string', doc: { 'zh-CN': '插件 ID。', 'en': 'Plugin id.' } } },
100
+ },
101
+ { name: 'include', doc: { 'zh-CN': '引入另一个源文件。', 'en': 'Includes another source file.' }, options: {} },
102
+ {
103
+ name: 'literalinclude',
104
+ doc: { 'zh-CN': '以代码显示一个文件(或其中几行);参数为文件路径。', 'en': 'Shows a file (or some of its lines) as code; the argument is the file path.' },
105
+ options: {
106
+ 'language': { type: 'string', doc: { 'zh-CN': '语言;默认按扩展名。', 'en': 'Language; by default from the extension.' } },
107
+ 'lines': { type: 'string', doc: { 'zh-CN': '只显示这些行,如 `2, 4-6`。', 'en': 'Only these lines, e.g. `2, 4-6`.' } },
108
+ 'start-after': { type: 'string', doc: { 'zh-CN': '从含这段文字的行之后开始。', 'en': 'Starts after the line with this text.' } },
109
+ 'end-before': { type: 'string', doc: { 'zh-CN': '到含这段文字的行之前结束。', 'en': 'Ends before the line with this text.' } },
110
+ 'caption': CODE_OPTIONS.caption!,
111
+ 'emphasize-lines': CODE_OPTIONS['emphasize-lines']!,
112
+ },
113
+ },
114
+ ...['code', 'code-block', 'code-cell', 'sourcecode'].map(name => ({ name, doc: { 'zh-CN': '代码;参数为语言。', 'en': 'Code; the argument is the language.' }, options: CODE_OPTIONS })),
115
+ ...LAYOUT_DIRECTIVES,
116
+ ]
@@ -0,0 +1,148 @@
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 readExecute(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: 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 executor = made.get(key)
61
+ if (!executor) {
62
+ try {
63
+ executor = await provider.create(name, spec, context)
64
+ } catch (error) {
65
+ report({ checkId: 'presmium.execute', severity: 'error', message: `environment "${name}": the ${spec.kind} provider failed: ${errorMessage(error)}`, source: at(`${name}:`) })
66
+ continue
67
+ }
68
+ if (executor.id !== environmentExecutor(name)) {
69
+ report({ checkId: 'presmium.execute', severity: 'error', message: `environment "${name}": the ${spec.kind} provider made the executor ${executor.id}, not ${environmentExecutor(name)}`, source: at(`${name}:`) })
70
+ continue
71
+ }
72
+ made.set(key, executor)
73
+ }
74
+ executors.push(executor)
75
+ }
76
+ return executors
77
+ }
78
+
79
+ export function checkRuns(doc: IRDocument, execute: ExecuteConfig, report: (finding: Finding) => void): void {
80
+ const sessions = new Map<string, string>()
81
+ for (const section of doc.sections) {
82
+ for (const block of irBlocks(section.blocks)) {
83
+ const run = block.node.type === 'code' ? block.node.run : undefined
84
+ if (!run) continue
85
+ const fail = (message: string) => report({ checkId: 'presmium.execute', severity: 'error', message, source: { file: block.source.file, line: block.source.line } })
86
+ const name = run.executor?.startsWith(PREFIX) ? run.executor.slice(PREFIX.length) : undefined
87
+ if (name !== undefined && !execute.environments?.[name]) fail(`:env: ${name}: no such environment in \`execute.environments\` of the headmatter`)
88
+ if (!run.session) continue
89
+ const where = run.executor ?? 'browser'
90
+ const first = sessions.get(run.session)
91
+ if (first === undefined) sessions.set(run.session, where)
92
+ 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`)
93
+ }
94
+ }
95
+ }
96
+
97
+ export function checkLanguages(doc: IRDocument, execute: ExecuteConfig, executors: readonly Executor[], report: (finding: Finding) => void): Set<CodeNode> {
98
+ const reported = new Set<CodeNode>()
99
+ for (const section of doc.sections) {
100
+ for (const block of irBlocks(section.blocks)) {
101
+ const run = block.node.type === 'code' ? block.node.run : undefined
102
+ const name = run?.executor?.startsWith(PREFIX) ? run.executor.slice(PREFIX.length) : undefined
103
+ const spec = name === undefined ? undefined : execute.environments?.[name]
104
+ const executor = spec?.languages?.length ? executors.find(e => e.id === run!.executor) : undefined
105
+ if (!run || !spec?.languages || !executor || executor.supports(run.lang)) continue
106
+ reported.add(block.node as CodeNode)
107
+ const runs = `runs ${spec.languages.join(', ')}, not ${run.lang}`
108
+ const fallback = execute.env === name ? ` (blocks that name none run in "${name}", the headmatter's execute.env)` : ''
109
+ report({
110
+ checkId: 'presmium.execute',
111
+ severity: 'error',
112
+ message: `environment "${name}" (${spec.kind}) ${runs}`,
113
+ hint: `name another environment with :env:${fallback}, or add ${run.lang} to the languages of "${name}"`,
114
+ source: { file: block.source.file, line: block.source.line },
115
+ })
116
+ }
117
+ }
118
+ return reported
119
+ }
120
+
121
+ function describe(executor: string): string {
122
+ return executor.startsWith(PREFIX) ? `environment ${executor.slice(PREFIX.length)}` : executor === 'browser' ? 'the default environment' : `executor ${executor}`
123
+ }
124
+
125
+ const CREDENTIALS = [
126
+ /^sk-[\w-]{16,}$/,
127
+ /^(?:gh[pousr]|github_pat)_\w{20,}$/,
128
+ /^xox[abposr]-[\w-]{10,}$/,
129
+ /^AKIA[0-9A-Z]{16}$/,
130
+ /^AIza[\w-]{30,}$/,
131
+ /^eyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]{10,}$/,
132
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----/,
133
+ ]
134
+ const CREDENTIAL_KEY = /(?:^|[_-])(?:token|secret|password|passwd|api[_-]?key|access[_-]?key|private[_-]?key)$/i
135
+
136
+ export function checkSecrets(headmatter: Record<string, unknown>, report: (finding: Finding) => void, at: At): void {
137
+ const visit = (value: unknown, key: string, path: string) => {
138
+ if (typeof value === 'string') {
139
+ const looks = CREDENTIALS.some(pattern => pattern.test(value.trim())) || (CREDENTIAL_KEY.test(key) && value.trim().length >= 12 && !/\s/.test(value.trim()))
140
+ 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}:`) })
141
+ } else if (Array.isArray(value)) {
142
+ value.forEach((item, i) => visit(item, key, `${path}[${i}]`))
143
+ } else if (isRecord(value)) {
144
+ for (const [k, v] of Object.entries(value)) visit(v, k, path ? `${path}.${k}` : k)
145
+ }
146
+ }
147
+ visit(headmatter, '', '')
148
+ }
package/src/execute.ts ADDED
@@ -0,0 +1,367 @@
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
+
8
+ export const FREEZE_DIR = '.presmium/freeze'
9
+ const BLOBS = 'blobs'
10
+ const BLOB_LENGTH = 4096
11
+ const BLOB_REF = 'presmium/blob'
12
+
13
+ interface FreezeRecord {
14
+ input?: string
15
+ entry?: string
16
+ lang?: string
17
+ code?: string
18
+ environment?: string
19
+ revision?: string
20
+ run: RecordedRun
21
+ }
22
+
23
+ export class FreezeCache implements RunCache {
24
+ readonly dir: string
25
+ readonly entry: string
26
+ readonly used = new Set<string>()
27
+ #records?: Map<string, FreezeRecord>
28
+
29
+ constructor(dir: string, entry: string) {
30
+ this.dir = dir
31
+ this.entry = entry
32
+ }
33
+
34
+ get(key: string): RecordedRun | undefined {
35
+ const record = this.#read(`${key}.json`)
36
+ return record && this.#restore(record.run)
37
+ }
38
+
39
+ set(key: string, run: RecordedRun): void {
40
+ this.#write(key, { run })
41
+ }
42
+
43
+ record(key: string, input: string, request: RunRequest, run: RecordedRun, environment: string, revision = ''): void {
44
+ for (const [file, record] of this.#all()) {
45
+ if (record.input !== input || file === `${key}.json`) continue
46
+ rmSync(join(this.dir, file), { force: true })
47
+ this.#records?.delete(file)
48
+ }
49
+ this.#write(key, { input, entry: this.entry, lang: request.lang, code: request.code, environment, ...revision ? { revision } : {}, run })
50
+ }
51
+
52
+ find(input: string): RecordedRun | undefined {
53
+ return this.recording(input)?.run
54
+ }
55
+
56
+ recording(input: string): { run: RecordedRun, environment?: string, revision: string } | undefined {
57
+ for (const record of this.#all().values()) {
58
+ if (record.input === input) return { run: this.#restore(record.run), environment: record.environment, revision: record.revision ?? '' }
59
+ }
60
+ return undefined
61
+ }
62
+
63
+ prune(): number {
64
+ let removed = 0
65
+ for (const [file, record] of this.#all()) {
66
+ if (record.entry !== this.entry || !record.input || this.used.has(record.input)) continue
67
+ rmSync(join(this.dir, file), { force: true })
68
+ this.#records?.delete(file)
69
+ removed++
70
+ }
71
+ const referred = new Set<string>()
72
+ for (const record of this.#all().values()) blobRefs(record.run, referred)
73
+ let blobs: string[] = []
74
+ try {
75
+ blobs = readdirSync(join(this.dir, BLOBS))
76
+ } catch {}
77
+ for (const blob of blobs.filter(name => !referred.has(name))) rmSync(join(this.dir, BLOBS, blob), { force: true })
78
+ return removed
79
+ }
80
+
81
+ #all(): Map<string, FreezeRecord> {
82
+ if (!this.#records) {
83
+ this.#records = new Map()
84
+ let files: string[] = []
85
+ try {
86
+ files = readdirSync(this.dir).filter(f => f.endsWith('.json')).sort()
87
+ } catch {
88
+ }
89
+ for (const file of files) {
90
+ const record = this.#read(file)
91
+ if (record) this.#records.set(file, record)
92
+ }
93
+ }
94
+ return this.#records
95
+ }
96
+
97
+ #text(file: string): string | undefined {
98
+ try {
99
+ return readFileSync(file, 'utf8')
100
+ } catch {
101
+ return undefined
102
+ }
103
+ }
104
+
105
+ #read(file: string): FreezeRecord | undefined {
106
+ const text = this.#text(join(this.dir, file))
107
+ if (text === undefined) return undefined
108
+ try {
109
+ const data: unknown = JSON.parse(text)
110
+ return isRecord(data) && isRecord(data.run) ? data as unknown as FreezeRecord : undefined
111
+ } catch {
112
+ return undefined
113
+ }
114
+ }
115
+
116
+ #write(key: string, record: FreezeRecord): void {
117
+ mkdirSync(this.dir, { recursive: true })
118
+ const stored = { ...record, run: this.#store(record.run) }
119
+ writeFileSync(join(this.dir, `${key}.json`), `${JSON.stringify(stored, null, 2)}\n`)
120
+ this.#records?.set(`${key}.json`, stored)
121
+ }
122
+
123
+ #store(run: RecordedRun): RecordedRun {
124
+ if (!run.outputs) return run
125
+ return { ...run, outputs: run.outputs.map(output => mapData(output, (value) => {
126
+ const text = Array.isArray(value) && value.every(v => typeof v === 'string') ? value.join('') : value
127
+ if (typeof text !== 'string' || text.length <= BLOB_LENGTH) return value
128
+ const name = createHash('sha256').update(text).digest('hex')
129
+ const file = join(this.dir, BLOBS, name)
130
+ if (!existsSync(file)) {
131
+ mkdirSync(join(this.dir, BLOBS), { recursive: true })
132
+ writeFileSync(file, text)
133
+ }
134
+ return { [BLOB_REF]: name }
135
+ })) }
136
+ }
137
+
138
+ #restore(run: RecordedRun): RecordedRun {
139
+ if (!run.outputs) return run
140
+ return { ...run, outputs: run.outputs.map(output => mapData(output, (value) => {
141
+ if (!isRecord(value) || typeof value[BLOB_REF] !== 'string') return value
142
+ return this.#text(join(this.dir, BLOBS, value[BLOB_REF].replace(/[^0-9a-f]/g, '')))
143
+ })) }
144
+ }
145
+ }
146
+
147
+ function mapData(output: RunOutput, map: (value: unknown) => unknown): RunOutput {
148
+ if (output.output_type !== 'display_data' && output.output_type !== 'execute_result') return output
149
+ const data: Record<string, unknown> = {}
150
+ for (const [mime, value] of Object.entries(output.data)) {
151
+ const mapped = map(value)
152
+ if (mapped !== undefined) data[mime] = mapped
153
+ }
154
+ return { ...output, data }
155
+ }
156
+
157
+ function blobRefs(run: RecordedRun, into: Set<string>): void {
158
+ for (const output of run.outputs ?? []) {
159
+ mapData(output, (value) => {
160
+ if (isRecord(value) && typeof value[BLOB_REF] === 'string') into.add(value[BLOB_REF])
161
+ return value
162
+ })
163
+ }
164
+ }
165
+
166
+ export interface ExecutionOptions {
167
+ env?: 'local' | 'serve'
168
+ }
169
+
170
+ export interface RunStats {
171
+ executed: number
172
+ cached: number
173
+ sandbox?: string
174
+ }
175
+
176
+ export interface ExecuteOptions extends ExecutionOptions {
177
+ rerun?: boolean
178
+ context: RunContext
179
+ unsupported?: ReadonlySet<CodeNode>
180
+ }
181
+
182
+ const SERVE_ALLOWED = /^(?:PATH|LANG|LC_\w+|TZ|TMPDIR)$/
183
+
184
+ export function runEnvironment(rules: 'local' | 'serve', passEnv: readonly string[], workspaceRoot: string, source: NodeJS.ProcessEnv = process.env): Record<string, string> {
185
+ const env: Record<string, string> = {}
186
+ for (const [name, value] of Object.entries(source)) {
187
+ if (value === undefined) continue
188
+ const kept = rules === 'serve' ? SERVE_ALLOWED.test(name) : !/^PRESMIUM_/i.test(name) && !CREDENTIAL_VARIABLE.test(name)
189
+ if (kept) env[name] = value
190
+ }
191
+ if (rules === 'serve') {
192
+ env.HOME = runHome(workspaceRoot, source)
193
+ env.PYTHONNOUSERSITE = '1'
194
+ }
195
+ for (const name of passEnv) {
196
+ const value = source[name]
197
+ if (value !== undefined) env[name] = value
198
+ }
199
+ return env
200
+ }
201
+
202
+ function runHome(workspaceRoot: string, source: NodeJS.ProcessEnv): string {
203
+ const state = source.XDG_STATE_HOME || join(homedir(), '.local', 'state')
204
+ const home = join(state, 'presmium', 'run-home', createHash('sha256').update(workspaceRoot).digest('hex').slice(0, 16))
205
+ mkdirSync(home, { recursive: true, mode: 0o700 })
206
+ const stat = lstatSync(home)
207
+ const uid = process.getuid?.()
208
+ if (!stat.isDirectory() || (uid !== undefined && stat.uid !== uid) || (stat.mode & 0o077) !== 0) {
209
+ throw new Error(`the run HOME ${home} is not a private directory of this user`)
210
+ }
211
+ return home
212
+ }
213
+
214
+ interface Item {
215
+ node: CodeNode
216
+ block: IRBlock
217
+ request: RunRequest
218
+ input: string
219
+ key?: string
220
+ }
221
+
222
+ interface Unit {
223
+ session?: string
224
+ items: Item[]
225
+ executor?: Executor
226
+ environment: string
227
+ revision?: string
228
+ }
229
+
230
+ type Report = (severity: Finding['severity'], message: string, block: IRBlock) => void
231
+
232
+ export async function executePass(doc: IRDocument, executors: Executor[], cache: FreezeCache, report: (finding: Finding) => void, options: ExecuteOptions): Promise<RunStats> {
233
+ const stats: RunStats = { executed: 0, cached: 0, ...options.context.sandbox ? { sandbox: options.context.sandbox.kind } : {} }
234
+ const say: Report = (severity, message, block) =>
235
+ report({ checkId: 'presmium.execute', severity, message, source: { file: block.source.file, line: block.source.line } })
236
+ const units = collect(doc, cache)
237
+
238
+ const pending: Unit[] = []
239
+ for (const unit of units) {
240
+ const run = unit.items[0]!.node.run!
241
+ unit.executor = run.executor
242
+ ? executors.find(e => e.id === run.executor && e.supports(run.lang))
243
+ : executors.find(e => !e.id.startsWith(environmentExecutor('')) && e.supports(run.lang))
244
+ if (!unit.executor) {
245
+ for (const item of unit.items) fromRecording(item, cache, stats, say, run.executor, undefined, options.unsupported?.has(item.node))
246
+ continue
247
+ }
248
+ try {
249
+ unit.environment = await unit.executor.fingerprint?.(run.lang) ?? ''
250
+ unit.revision = await unit.executor.revision?.(run.lang) ?? ''
251
+ } catch (error) {
252
+ for (const item of unit.items) fromRecording(item, cache, stats, say, run.executor, errorMessage(error))
253
+ continue
254
+ }
255
+ let previous = unit.session
256
+ for (const item of unit.items) {
257
+ const made = { executor: unit.executor.id, environment: unit.environment, ...unit.revision ? { revision: unit.revision } : {} }
258
+ item.key = hash(unit.session ? { previous, input: item.input, ...made } : { input: item.input, ...made })
259
+ previous = item.key
260
+ }
261
+ const cached = unit.items.map(item => cache.get(item.key!))
262
+ if (cached.every(Boolean)) {
263
+ unit.items.forEach((item, i) => item.node.outputs = [cached[i]!])
264
+ stats.cached += unit.items.length
265
+ continue
266
+ }
267
+ const others = options.rerun ? [] : unit.items.map(item => cache.recording(item.input)).map(other => (other?.revision === unit.revision ? other : undefined))
268
+ if (others.length > 0 && others.every(Boolean)) {
269
+ unit.items.forEach((item, i) => {
270
+ const other = others[i]!
271
+ item.node.outputs = [other.run]
272
+ const made = other.environment === undefined ? 'another environment' : `"${other.environment || 'no fingerprint'}"`
273
+ 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)
274
+ })
275
+ stats.cached += unit.items.length
276
+ continue
277
+ }
278
+ pending.push(unit)
279
+ }
280
+
281
+ const sessions: [Executor, string][] = []
282
+ const compile = `compile:${randomUUID()}`
283
+ for (const unit of pending) {
284
+ const executor = unit.executor!
285
+ const session = unit.session && `${compile}:${unit.session}`
286
+ const run = (request: RunRequest) => executor.run(session ? { ...request, session } : request, undefined, options.context)
287
+ try {
288
+ if (session) {
289
+ await executor.endSession?.(session)
290
+ sessions.push([executor, session])
291
+ const results = await Promise.all(unit.items.map(item => run(item.request)))
292
+ unit.items.forEach((item, i) => record(item, results[i]!, unit, cache, stats, say))
293
+ } else {
294
+ const item = unit.items[0]!
295
+ record(item, await run(item.request), unit, cache, stats, say)
296
+ }
297
+ } catch (error) {
298
+ for (const item of unit.items) fromRecording(item, cache, stats, say, executor.id, errorMessage(error))
299
+ }
300
+ }
301
+ for (const [executor, session] of sessions) {
302
+ await executor.endSession?.(session)?.catch(() => {})
303
+ }
304
+ return stats
305
+ }
306
+
307
+ function collect(doc: IRDocument, cache: FreezeCache): Unit[] {
308
+ const units: Unit[] = []
309
+ const sessions = new Map<string, Unit>()
310
+ for (const section of doc.sections) {
311
+ for (const block of irBlocks(section.blocks)) {
312
+ const node = block.node
313
+ if (node.type !== 'code' || !node.run || node.run.when === 'live') continue
314
+ const run = node.run
315
+ const request: RunRequest = { lang: run.lang, code: node.value }
316
+ if (run.args) request.args = run.args
317
+ if (run.stdin !== undefined) request.stdin = run.stdin
318
+ if (run.timeoutMs !== undefined) request.timeoutMs = run.timeoutMs
319
+ const fields = { lang: request.lang, code: request.code, args: request.args, stdin: request.stdin, executor: run.executor }
320
+ if (!run.session) {
321
+ const input = hash(fields)
322
+ cache.used.add(input)
323
+ units.push({ items: [{ node, block, request, input }], environment: '' })
324
+ continue
325
+ }
326
+ const session = `${cache.entry}:${run.session}`
327
+ request.session = session
328
+ let unit = sessions.get(session)
329
+ if (!unit) {
330
+ unit = { session, items: [], environment: '' }
331
+ sessions.set(session, unit)
332
+ units.push(unit)
333
+ }
334
+ const input = hash({ previous: unit.items.at(-1)?.input ?? session, ...fields })
335
+ cache.used.add(input)
336
+ unit.items.push({ node, block, request, input })
337
+ }
338
+ }
339
+ return units
340
+ }
341
+
342
+ function record(item: Item, result: RunResult, unit: Unit, cache: FreezeCache, stats: RunStats, say: Report): void {
343
+ const recorded: RecordedRun = { ...result, cacheKey: item.key! }
344
+ item.node.outputs = [recorded]
345
+ stats.executed++
346
+ if (!result.timedOut) cache.record(item.key!, item.input, item.request, recorded, unit.environment, unit.revision)
347
+ 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)
348
+ }
349
+
350
+ function fromRecording(item: Item, cache: FreezeCache, stats: RunStats, say: Report, executor: string | undefined, failure?: string, reported = false): void {
351
+ const recorded = cache.find(item.input)
352
+ const which = executor ?? item.request.lang
353
+ if (recorded) {
354
+ item.node.outputs = [recorded]
355
+ stats.cached++
356
+ if (failure) say('warning', `executor ${which} is unavailable (${failure}); block ${item.block.id} shows its recorded output`, item.block)
357
+ return
358
+ }
359
+ if (reported) return
360
+ if (failure) return say('error', `executor ${which} failed on block ${item.block.id}: ${failure}`, item.block)
361
+ const environment = executor?.startsWith(environmentExecutor(''))
362
+ say(environment ? 'error' : 'warning', `no executor for ${which} and no recorded output in ${FREEZE_DIR}; block ${item.block.id} has no output`, item.block)
363
+ }
364
+
365
+ function hash(value: unknown): string {
366
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex')
367
+ }
@@ -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
+ }