@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.
@@ -0,0 +1,133 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { readFileSync, realpathSync, statSync } from 'node:fs'
3
+ import { basename, dirname, relative, resolve, sep } from 'node:path'
4
+ import { DOCUMENT_ENTRY, errorMessage, isRecord, validateDocument } from '@lincyaw/kernel'
5
+ import type { Finding, IRDocument, TeachingDocument } from '@lincyaw/kernel'
6
+ import { isInside } from '@lincyaw/kernel/node'
7
+ import type { AudienceCompilation, Compilation, Compiler, CompilerOptions } from './compiler.ts'
8
+
9
+ const CHECK_ID = 'presmium.translations'
10
+ const pointerKey = (key: string) => key.replaceAll('~', '~0').replaceAll('/', '~1')
11
+ const sourceAt = (file: string, pointer: string) => ({ file, line: 1, format: 'json' as const, pointer })
12
+
13
+ export interface TranslationFields {
14
+ translations?: Record<string, string>
15
+ translationOf?: { entry: string, digest: string }
16
+ }
17
+
18
+ export function translationFields(metadata: Record<string, unknown>): TranslationFields {
19
+ const fields: TranslationFields = {}
20
+ const { translations, translationOf } = metadata
21
+ if (isRecord(translations)) {
22
+ const entries = Object.entries(translations).filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].length > 0)
23
+ if (entries.length > 0) fields.translations = Object.fromEntries(entries)
24
+ }
25
+ if (isRecord(translationOf) && typeof translationOf.entry === 'string' && typeof translationOf.digest === 'string') {
26
+ fields.translationOf = { entry: translationOf.entry, digest: translationOf.digest }
27
+ }
28
+ return fields
29
+ }
30
+
31
+ export function applyTranslations(doc: IRDocument, fields: TranslationFields, report: (finding: Finding) => void): void {
32
+ if (fields.translations) doc.config.translations = fields.translations
33
+ if (fields.translationOf) doc.meta.translationOf = fields.translationOf
34
+ if (fields.translations) staleTranslations(doc, fields.translations).forEach(report)
35
+ }
36
+
37
+ function documentValue(text: string): TeachingDocument {
38
+ const checked = validateDocument(JSON.parse(text.replace(/^\uFEFF/, '')))
39
+ if (!checked.document) throw new Error(checked.issues.map(issue => `${issue.path || '/'}: ${issue.message}`).join('\n'))
40
+ return checked.document
41
+ }
42
+
43
+ function sorted(value: unknown): unknown {
44
+ if (Array.isArray(value)) return value.map(sorted)
45
+ if (isRecord(value)) return Object.fromEntries(Object.keys(value).sort().map(key => [key, sorted(value[key])]))
46
+ return value
47
+ }
48
+
49
+ /** Translation bookkeeping and JSON formatting do not change the original's content digest. */
50
+ export function documentDigest(document: TeachingDocument): string {
51
+ const metadata = { ...document.metadata }
52
+ delete metadata.translations
53
+ delete metadata.translationOf
54
+ return createHash('sha256').update(JSON.stringify(sorted({ ...document, metadata }))).digest('hex')
55
+ }
56
+
57
+ export function originalDigest(doc: IRDocument): string {
58
+ return documentDigest(documentValue(readFileSync(doc.meta.entry, 'utf8')))
59
+ }
60
+
61
+ function translationFile(deckDir: string, entry: string): string | undefined {
62
+ const file = resolve(deckDir, entry)
63
+ if (basename(file) !== DOCUMENT_ENTRY) return undefined
64
+ try {
65
+ return statSync(file).isFile() && isInside(realpathSync(file), realpathSync(deckDir)) ? file : undefined
66
+ } catch {
67
+ return undefined
68
+ }
69
+ }
70
+
71
+ function staleTranslations(doc: IRDocument, translations: Record<string, string>): Finding[] {
72
+ const deckDir = dirname(doc.meta.entry)
73
+ const original = basename(doc.meta.entry)
74
+ let digest: string | undefined
75
+ const findings: Finding[] = []
76
+ for (const [lang, entry] of Object.entries(translations)) {
77
+ const file = translationFile(deckDir, entry)
78
+ const name = `translation "${lang}" (${entry})`
79
+ if (!file || file === doc.meta.entry) {
80
+ findings.push({ checkId: CHECK_ID, severity: 'warning', message: `${name} must name a distinct ${DOCUMENT_ENTRY} inside the document's directory`, source: sourceAt(doc.meta.entry, `/metadata/translations/${pointerKey(lang)}`) })
81
+ continue
82
+ }
83
+ const at = (pointer: string) => sourceAt(file, pointer)
84
+ try {
85
+ digest ??= originalDigest(doc)
86
+ const { translationOf } = documentValue(readFileSync(file, 'utf8')).metadata
87
+ const expected = relative(dirname(file), doc.meta.entry).split(sep).join('/')
88
+ if (!translationOf) {
89
+ findings.push({ checkId: CHECK_ID, severity: 'warning', message: `${name} records no digest of the original; once it matches the original, set metadata.translationOf to { entry: ${expected}, digest: ${digest} }`, source: at('/metadata') })
90
+ } else if (resolve(dirname(file), translationOf.entry) !== doc.meta.entry) {
91
+ findings.push({ checkId: CHECK_ID, severity: 'warning', message: `${name} names ${translationOf.entry} as its original, not ${original}`, source: at('/metadata/translationOf/entry') })
92
+ } else if (translationOf.digest !== digest) {
93
+ findings.push({ checkId: CHECK_ID, severity: 'warning', message: `${name} is out of date: the original changed since digest ${translationOf.digest.slice(0, 12)}…; check the translation against it, then set translationOf.digest to ${digest}`, source: at('/metadata/translationOf/digest') })
94
+ }
95
+ } catch (error) {
96
+ findings.push({ checkId: CHECK_ID, severity: 'error', message: `${name} is not a valid teaching document: ${errorMessage(error)}`, source: at('') })
97
+ }
98
+ }
99
+ return findings
100
+ }
101
+
102
+ export interface CompiledTranslation {
103
+ lang: string
104
+ doc: IRDocument
105
+ files: string[]
106
+ audience?: AudienceCompilation
107
+ }
108
+
109
+ export async function compileTranslations(compilation: Compilation, compilers = new Map<string, Compiler>()): Promise<{ translations: CompiledTranslation[], findings: Finding[] }> {
110
+ const { doc, deckDir } = compilation
111
+ const translations: CompiledTranslation[] = []
112
+ const findings: Finding[] = []
113
+ const { Compiler } = await import('./compiler.ts')
114
+ const { audience: _audience, headmatter: _headmatter, deckDir: _deckDir, ...base } = compilation.options
115
+ const audience = compilation.audience?.name
116
+ for (const [lang, entry] of Object.entries(doc.config.translations ?? {})) {
117
+ const file = translationFile(deckDir, entry)
118
+ if (!file || file === doc.meta.entry) continue
119
+ try {
120
+ const options: CompilerOptions = { ...base, ...audience ? { audience } : {} }
121
+ const known = compilers.get(file)
122
+ const same = known && known.options.audience === options.audience
123
+ const compiler = same ? known : new Compiler(file, options)
124
+ compilers.set(file, compiler)
125
+ const translated = await compiler.compile()
126
+ findings.push(...translated.doc.diagnostics)
127
+ translations.push({ lang, doc: translated.doc, files: [...new Set([...translated.sourceFiles, ...translated.inputs.files])], ...translated.audience ? { audience: translated.audience } : {} })
128
+ } catch (error) {
129
+ findings.push({ checkId: CHECK_ID, severity: 'error', message: `translation "${lang}" (${entry}) could not be compiled: ${errorMessage(error)}`, source: sourceAt(file, '') })
130
+ }
131
+ }
132
+ return { translations, findings }
133
+ }
@@ -0,0 +1,107 @@
1
+ import { IR_VERSION, isRecord } from '@lincyaw/kernel'
2
+ import type { Finding, IRBlock, IRDocument, IRSection, MdastNode, SourcePos } from '@lincyaw/kernel'
3
+
4
+ export function validateIR(doc: IRDocument, entry?: string): Finding[] {
5
+ const findings: Finding[] = []
6
+ const file = entry ?? (isRecord(doc.meta) && typeof doc.meta.entry === 'string' ? doc.meta.entry : 'ir.json')
7
+ const fail = (message: string, source: SourcePos = { file, line: 1 }) =>
8
+ findings.push({ checkId: 'presmium.ir', severity: 'error', message, source: { file: source.file, line: source.line } })
9
+
10
+ if (doc.ir !== IR_VERSION) fail(`ir must be ${IR_VERSION}`)
11
+ if (!isRecord(doc.meta) || typeof doc.meta.title !== 'string' || typeof doc.meta.entry !== 'string') fail('meta needs a title and an entry')
12
+ const config = doc.config as unknown
13
+ if (!isRecord(config) || !Array.isArray(config.plugins) || !isRecord(config.presmium) || !isRecord(config.renderers)) {
14
+ fail('config needs plugins, presmium and renderers')
15
+ }
16
+ if (!Array.isArray(doc.sections)) {
17
+ fail('sections must be a list')
18
+ return findings
19
+ }
20
+
21
+ const ids = new Set<string>()
22
+ doc.sections.forEach((section, i) => {
23
+ if (!isRecord(section)) return fail(`section ${i + 1} is not an object`)
24
+ const where = isSource(section.source) ? section.source : undefined
25
+ if (!where) fail(`section ${i + 1} has no source position`)
26
+ if (typeof section.id !== 'string' || section.id === '') fail(`section ${i + 1} has no id`, where)
27
+ else if (ids.has(section.id)) fail(`section id ${section.id} is not unique`, where)
28
+ ids.add(section.id)
29
+ if (section.index !== i + 1) fail(`section ${section.id} has index ${section.index}, expected ${i + 1}`, where)
30
+ if (typeof section.title !== 'string') fail(`section ${section.id} has no title`, where)
31
+ if (!isCount(section.steps)) fail(`section ${section.id}: steps must be a non-negative integer`, where)
32
+ if (!isRecord(section.hints) || !Object.values(section.hints).every(isRecord)) fail(`section ${section.id}: hints must map renderer ids to objects`, where)
33
+ if (section.config !== undefined && !(isRecord(section.config) && Object.values(section.config).every(isRecord))) fail(`section ${section.id}: config must map plugin ids to objects`, where)
34
+ if (!Array.isArray(section.blocks)) fail(`section ${section.id}: blocks must be a list`, where)
35
+ else validateBlocks(section.blocks, section, fail)
36
+ })
37
+ return findings
38
+ }
39
+
40
+ function validateBlocks(blocks: IRBlock[], section: IRSection, fail: (message: string, source?: SourcePos) => void): void {
41
+ const ids = new Set<string>()
42
+ for (const block of blocks) {
43
+ if (!isRecord(block)) {
44
+ fail(`a block of section ${section.id} is not an object`, section.source)
45
+ continue
46
+ }
47
+ const where = isSource(block.source) ? block.source : section.source
48
+ const name = `block ${block.id} of section ${section.id}`
49
+ if (!isSource(block.source)) fail(`${name} has no source position`, where)
50
+ if (typeof block.id !== 'string' || block.id === '') fail(`a block of section ${section.id} has no id`, where)
51
+ else if (ids.has(block.id)) fail(`${name}: id is not unique in the section`, where)
52
+ ids.add(block.id)
53
+ if (block.role !== 'visual' && block.role !== 'narration') fail(`${name}: role must be visual or narration`, where)
54
+ if (!isCount(block.at)) fail(`${name}: at must be a non-negative integer`, where)
55
+ if (block.span !== undefined && !isCount(block.span)) fail(`${name}: span must be a non-negative integer`, where)
56
+ if (isCount(block.at) && block.at + (block.span ?? 0) > section.steps) fail(`${name} ends at step ${block.at + (block.span ?? 0)}, after the last step ${section.steps}`, where)
57
+ if (block.until !== undefined && !(isCount(block.until) && block.until > block.at)) fail(`${name}: until must be an integer after at`, where)
58
+ if (block.only !== undefined && !(Array.isArray(block.only) && block.only.every(id => typeof id === 'string'))) fail(`${name}: only must list renderer ids`, where)
59
+ const problem = nodeProblem(block)
60
+ if (problem) fail(`${name}: ${problem}`, where)
61
+ if (block.node?.type === 'component' && block.node.children) validateBlocks(block.node.children, section, fail)
62
+ }
63
+ }
64
+
65
+ function nodeProblem({ node }: IRBlock): string | undefined {
66
+ if (!isRecord(node)) return 'node is missing'
67
+ switch (node.type) {
68
+ case 'markdown':
69
+ if (node.narration !== undefined && (!isRecord(node.narration) || !['during', 'after'].includes(String(node.narration.mode)))) return 'narration mode must be during or after'
70
+ return isRecord(node.mdast) ? mdastProblem(node.mdast, 'mdast') : 'markdown node needs an mdast tree'
71
+ case 'code':
72
+ if (typeof node.lang !== 'string' || typeof node.value !== 'string') return 'code node needs lang and value'
73
+ if (node.highlight && !node.highlight.every(lines => Array.isArray(lines) && lines.every(n => Number.isInteger(n) && n >= 1))) return 'highlight must list 1-based line numbers per step'
74
+ if (node.run && !(typeof node.run.lang === 'string' && ['compile', 'live', 'both'].includes(node.run.when))) return 'run needs lang and when (compile, live or both)'
75
+ if (node.outputs && !node.outputs.every(o => isRecord(o) && typeof o.stdout === 'string' && typeof o.cacheKey === 'string')) return 'outputs must be recorded runs'
76
+ if (node.variants && !node.variants.every(v => isRecord(v) && typeof v.label === 'string' && typeof v.value === 'string')) return 'variants need label and value'
77
+ return undefined
78
+ case 'component':
79
+ if (typeof node.plugin !== 'string' || typeof node.name !== 'string' || node.name === '') return 'component node needs plugin and name'
80
+ if (!isRecord(node.props)) return 'component props must be an object'
81
+ if (node.assets !== undefined && !(isRecord(node.assets) && Object.values(node.assets).every(url => typeof url === 'string'))) return 'component assets must map props to URLs'
82
+ if (node.durations !== undefined && !(Array.isArray(node.durations) && node.durations.every(ms => typeof ms === 'number' && Number.isFinite(ms) && ms >= 0))) return 'component durations must list non-negative milliseconds'
83
+ if (node.fallback !== undefined) return isRecord(node.fallback) ? mdastProblem(node.fallback, 'fallback') : 'component fallback must be mdast'
84
+ return undefined
85
+ default:
86
+ return `unknown node type ${String((node as { type?: unknown }).type)}`
87
+ }
88
+ }
89
+
90
+ function mdastProblem(node: MdastNode, path: string): string | undefined {
91
+ if (typeof node.type !== 'string') return `${path} has a node without a type`
92
+ if (node.children === undefined) return undefined
93
+ if (!Array.isArray(node.children)) return `${path}: the children of a ${node.type} node must be a list`
94
+ for (const [i, child] of node.children.entries()) {
95
+ const problem = isRecord(child) ? mdastProblem(child, `${path} > ${node.type}[${i}]`) : `${path} > ${node.type}[${i}] is not a node`
96
+ if (problem) return problem
97
+ }
98
+ return undefined
99
+ }
100
+
101
+ function isCount(value: unknown): value is number {
102
+ return Number.isInteger(value) && (value as number) >= 0
103
+ }
104
+
105
+ function isSource(value: unknown): value is SourcePos {
106
+ return isRecord(value) && typeof value.file === 'string' && Number.isInteger(value.line) && (value.line as number) >= 1
107
+ }
package/src/views.ts ADDED
@@ -0,0 +1,2 @@
1
+ // The document as one renderer sees it; the kernel holds it, as the web form's server needs it too.
2
+ export { viewFor } from '@lincyaw/kernel'
@@ -0,0 +1,59 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync, readFileSync, realpathSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { join, resolve } from 'node:path'
5
+ import { errorMessage, isRecord } from '@lincyaw/kernel'
6
+ import { isInside } from '@lincyaw/kernel/node'
7
+ import { parse as parseYaml } from 'yaml'
8
+
9
+ export const WORKSPACE_CONFIG = 'presmium.workspace.yaml'
10
+
11
+ export type OperatorSettings = Record<string, Record<string, unknown>>
12
+
13
+ const cached = new Map<string, { settings: OperatorSettings, problem?: string }>()
14
+
15
+ export function workspaceSettings(root: string): { settings: OperatorSettings, problem?: string } {
16
+ const file = join(root, WORKSPACE_CONFIG)
17
+ const known = cached.get(file)
18
+ if (known) return known
19
+ let result: { settings: OperatorSettings, problem?: string } = { settings: {} }
20
+ if (existsSync(file)) {
21
+ try {
22
+ const data: unknown = parseYaml(readFileSync(file, 'utf8'))
23
+ const settings = isRecord(data) && isRecord(data.settings) ? data.settings : {}
24
+ const renderers = isRecord(data) && isRecord(data.renderers) ? data.renderers : {}
25
+ for (const [path, value] of [['renderers.lecture', renderers.lecture], ['settings.presmium.lecture', settings['presmium.lecture']]] as const) {
26
+ if (isRecord(value) && Object.hasOwn(value, 'script')) throw new Error(`${path}.script: External lecture scripts are not supported. Author narration in each document's lecture.json narrations.`)
27
+ }
28
+ result = { settings: Object.fromEntries(Object.entries(settings).map(([id, value]) => [id, isRecord(value) ? value : {}])) }
29
+ } catch (error) {
30
+ result = { settings: {}, problem: `${file}: ${errorMessage(error)}` }
31
+ }
32
+ }
33
+ cached.set(file, result)
34
+ return result
35
+ }
36
+
37
+ export function operatorPlugins(settings: OperatorSettings): string[] {
38
+ const list = settings['presmium.workspace']?.plugins
39
+ return Array.isArray(list) ? list.filter((name): name is string => typeof name === 'string' && name.length > 0) : []
40
+ }
41
+
42
+ function real(path: string): string {
43
+ try {
44
+ return realpathSync(path)
45
+ } catch {
46
+ return resolve(path)
47
+ }
48
+ }
49
+
50
+ export function operatorDir(workspaceRoot: string, given = process.env.PRESMIUM_OPERATOR_DIR): string {
51
+ const root = real(workspaceRoot)
52
+ if (given) {
53
+ const dir = resolve(given)
54
+ if (isInside(real(dir), root)) throw new Error(`the operator directory ${dir} lies inside the workspace ${root}; it must be outside every workspace`)
55
+ return dir
56
+ }
57
+ const state = process.env.XDG_STATE_HOME || join(homedir(), '.local', 'state')
58
+ return join(state, 'presmium', 'operator', createHash('sha256').update(root).digest('hex').slice(0, 16))
59
+ }
@@ -0,0 +1,37 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { existsSync } from 'node:fs'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { dirname, join, relative, resolve, sep } from 'node:path'
5
+ import { promisify } from 'node:util'
6
+ import type { SourceReader } from '@lincyaw/kernel'
7
+ import { isInside } from '@lincyaw/kernel/node'
8
+ import { WORKSPACE_CONFIG } from './workspace-settings.ts'
9
+
10
+ const run = promisify(execFile)
11
+
12
+ export const COMMIT_HASH = /^(?:[\da-f]{40}|[\da-f]{64})$/i
13
+
14
+ export async function findWorkspaceRoot(dir: string): Promise<string> {
15
+ const start = resolve(dir)
16
+ const repository = await run('git', ['rev-parse', '--show-toplevel'], { cwd: start })
17
+ .then(({ stdout }) => resolve(stdout.trim()), () => undefined)
18
+ for (let current = start; ; current = dirname(current)) {
19
+ if (existsSync(join(current, WORKSPACE_CONFIG))) return current
20
+ if (current === repository || dirname(current) === current) break
21
+ }
22
+ return repository ?? start
23
+ }
24
+
25
+ export function createSourceReader(root: string): SourceReader {
26
+ return {
27
+ async read(file, ref) {
28
+ const path = resolve(root, file)
29
+ const rel = relative(root, path)
30
+ if (rel === '' || !isInside(path, root)) return undefined
31
+ if (ref === undefined) return readFile(join(root, rel), 'utf8').catch(() => undefined)
32
+ if (ref.startsWith('-')) return undefined
33
+ return run('git', ['show', `${ref}:./${rel.split(sep).join('/')}`], { cwd: root, maxBuffer: 64 << 20 })
34
+ .then(({ stdout }) => stdout, () => undefined)
35
+ },
36
+ }
37
+ }