@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,139 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { readFileSync, realpathSync, statSync } from 'node:fs'
3
+ import { readFile } from 'node:fs/promises'
4
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path'
5
+ import { errorMessage, irBlocks, isRecord } from '@lincyaw/kernel'
6
+ import type { Finding, IRDocument } from '@lincyaw/kernel'
7
+ import type { AudienceCompilation, Compilation, Compiler, CompilerOptions } from './compiler.ts'
8
+ import { readFrontmatter } from './source.ts'
9
+
10
+ const CHECK_ID = 'presmium.translations'
11
+
12
+ const ORIGINAL_KEYS = ['plugins', 'presmium', 'renderers', 'execute', 'sectionDepth', 'checks']
13
+
14
+ export interface TranslationFields {
15
+ translations?: Record<string, string>
16
+ translationOf?: { entry: string, digest: string }
17
+ }
18
+
19
+ export function translationFields(headmatter: Record<string, unknown>): TranslationFields {
20
+ const fields: TranslationFields = {}
21
+ const { translations, translationOf } = headmatter
22
+ if (isRecord(translations)) {
23
+ const entries = Object.entries(translations).filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].length > 0)
24
+ if (entries.length > 0) fields.translations = Object.fromEntries(entries)
25
+ }
26
+ if (isRecord(translationOf) && typeof translationOf.entry === 'string' && typeof translationOf.digest === 'string') {
27
+ fields.translationOf = { entry: translationOf.entry, digest: translationOf.digest }
28
+ }
29
+ return fields
30
+ }
31
+
32
+ export function applyTranslations(doc: IRDocument, fields: TranslationFields, report: (finding: Finding) => void): void {
33
+ if (fields.translations) doc.config.translations = fields.translations
34
+ if (fields.translationOf) doc.meta.translationOf = fields.translationOf
35
+ if (fields.translations) staleTranslations(doc, fields.translations).forEach(report)
36
+ }
37
+
38
+ function body(text: string): string {
39
+ return text.replace(/^/, '').replace(/^---[ \t]*\r?\n(?:[\s\S]*?\r?\n)?---[ \t]*(?:\r?\n|$)/, '').replace(/\r\n?/g, '\n').trimEnd()
40
+ }
41
+
42
+ export function originalDigest(doc: IRDocument): string {
43
+ const files = [doc.meta.entry]
44
+ for (const section of doc.sections) {
45
+ for (const { file } of [section.source, ...[...irBlocks(section.blocks)].map(block => block.source)]) if (!files.includes(file)) files.push(file)
46
+ }
47
+ const hash = createHash('sha256').update(doc.meta.title)
48
+ for (const file of files) {
49
+ let text: string
50
+ try {
51
+ text = readFileSync(file, 'utf8')
52
+ } catch {
53
+ continue
54
+ }
55
+ hash.update('\0').update(body(text))
56
+ }
57
+ return hash.digest('hex')
58
+ }
59
+
60
+ function translationFile(deckDir: string, entry: string): string | undefined {
61
+ const file = resolve(deckDir, entry)
62
+ try {
63
+ const rel = relative(realpathSync(deckDir), realpathSync(file))
64
+ return statSync(file).isFile() && !rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel) ? file : undefined
65
+ } catch {
66
+ return undefined
67
+ }
68
+ }
69
+
70
+ function lineWith(text: string, needle: string): number {
71
+ const index = text.split(/\r?\n/).findIndex(line => line.includes(needle))
72
+ return index < 0 ? 1 : index + 1
73
+ }
74
+
75
+ function staleTranslations(doc: IRDocument, translations: Record<string, string>): Finding[] {
76
+ const deckDir = dirname(doc.meta.entry)
77
+ const original = basename(doc.meta.entry)
78
+ const entryText = readFileSync(doc.meta.entry, 'utf8')
79
+ let digest: string | undefined
80
+ const findings: Finding[] = []
81
+ for (const [lang, entry] of Object.entries(translations)) {
82
+ const file = translationFile(deckDir, entry)
83
+ const name = `translation "${lang}" (${entry})`
84
+ if (!file) {
85
+ findings.push({ checkId: CHECK_ID, severity: 'warning', message: `${name} not found in the deck's directory`, source: { file: doc.meta.entry, line: lineWith(entryText, entry) } })
86
+ continue
87
+ }
88
+ const text = readFileSync(file, 'utf8')
89
+ digest ??= originalDigest(doc)
90
+ const { translationOf } = translationFields(readFrontmatter(text))
91
+ const expected = relative(dirname(file), doc.meta.entry).split(sep).join('/')
92
+ if (!translationOf) {
93
+ findings.push({ checkId: CHECK_ID, severity: 'warning', message: `${name} records no digest of the original; once it matches the original, add translationOf: { entry: ${expected}, digest: ${digest} } to its headmatter`, source: { file, line: 1 } })
94
+ } else if (resolve(dirname(file), translationOf.entry) !== doc.meta.entry) {
95
+ findings.push({ checkId: CHECK_ID, severity: 'warning', message: `${name} names ${translationOf.entry} as its original, not ${original}`, source: { file, line: lineWith(text, 'entry') } })
96
+ } else if (translationOf.digest !== digest) {
97
+ 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: { file, line: lineWith(text, translationOf.digest) } })
98
+ }
99
+ }
100
+ return findings
101
+ }
102
+
103
+ export interface CompiledTranslation {
104
+ lang: string
105
+ doc: IRDocument
106
+ files: string[]
107
+ audience?: AudienceCompilation
108
+ }
109
+
110
+ export async function compileTranslations(compilation: Compilation, compilers = new Map<string, Compiler>()): Promise<{ translations: CompiledTranslation[], findings: Finding[] }> {
111
+ const { doc, deckDir, frontmatter } = compilation
112
+ const translations: CompiledTranslation[] = []
113
+ const findings: Finding[] = []
114
+ const { Compiler } = await import('./compiler.ts')
115
+ const { audience: _audience, headmatter: _headmatter, deckDir: _deckDir, ...base } = compilation.options
116
+ const audience = compilation.audience?.name
117
+ for (const [lang, entry] of Object.entries(doc.config.translations ?? {})) {
118
+ const file = translationFile(deckDir, entry)
119
+ if (!file) continue
120
+ try {
121
+ const own = readFrontmatter(await readFile(file, 'utf8'))
122
+ const headmatter: Record<string, unknown> = { translations: undefined }
123
+ for (const key of ORIGINAL_KEYS) headmatter[key] = frontmatter[key]
124
+ if (!(typeof own.lang === 'string' && own.lang.trim())) headmatter.lang = lang
125
+ if (own.title === undefined) headmatter.title = doc.meta.title
126
+ const options: CompilerOptions = { ...base, headmatter, deckDir, ...audience ? { audience } : {} }
127
+ const known = compilers.get(file)
128
+ const same = known && JSON.stringify(known.options.headmatter) === JSON.stringify(headmatter) && known.options.audience === options.audience
129
+ const compiler = same ? known : new Compiler(file, options)
130
+ compilers.set(file, compiler)
131
+ const translated = await compiler.compile()
132
+ findings.push(...translated.doc.diagnostics)
133
+ translations.push({ lang, doc: translated.doc, files: [...new Set([...translated.files, ...translated.dependencies.files])], ...translated.audience ? { audience: translated.audience } : {} })
134
+ } catch (error) {
135
+ findings.push({ checkId: CHECK_ID, severity: 'error', message: `translation "${lang}" (${entry}) could not be compiled: ${errorMessage(error)}`, source: { file, line: 1 } })
136
+ }
137
+ }
138
+ return { translations, findings }
139
+ }
@@ -0,0 +1,105 @@
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
+ return isRecord(node.mdast) ? mdastProblem(node.mdast, 'mdast') : 'markdown node needs an mdast tree'
70
+ case 'code':
71
+ if (typeof node.lang !== 'string' || typeof node.value !== 'string') return 'code node needs lang and value'
72
+ 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'
73
+ 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)'
74
+ if (node.outputs && !node.outputs.every(o => isRecord(o) && typeof o.stdout === 'string' && typeof o.cacheKey === 'string')) return 'outputs must be recorded runs'
75
+ if (node.variants && !node.variants.every(v => isRecord(v) && typeof v.label === 'string' && typeof v.value === 'string')) return 'variants need label and value'
76
+ return undefined
77
+ case 'component':
78
+ if (typeof node.plugin !== 'string' || typeof node.name !== 'string' || node.name === '') return 'component node needs plugin and name'
79
+ if (!isRecord(node.props)) return 'component props must be an object'
80
+ if (node.fallback !== undefined) return isRecord(node.fallback) ? mdastProblem(node.fallback, 'fallback') : 'component fallback must be mdast'
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
+ return undefined
83
+ default:
84
+ return `unknown node type ${String((node as { type?: unknown }).type)}`
85
+ }
86
+ }
87
+
88
+ function mdastProblem(node: MdastNode, path: string): string | undefined {
89
+ if (typeof node.type !== 'string') return `${path} has a node without a type`
90
+ if (node.children === undefined) return undefined
91
+ if (!Array.isArray(node.children)) return `${path}: the children of a ${node.type} node must be a list`
92
+ for (const [i, child] of node.children.entries()) {
93
+ const problem = isRecord(child) ? mdastProblem(child, `${path} > ${node.type}[${i}]`) : `${path} > ${node.type}[${i}] is not a node`
94
+ if (problem) return problem
95
+ }
96
+ return undefined
97
+ }
98
+
99
+ function isCount(value: unknown): value is number {
100
+ return Number.isInteger(value) && (value as number) >= 0
101
+ }
102
+
103
+ function isSource(value: unknown): value is SourcePos {
104
+ return isRecord(value) && typeof value.file === 'string' && Number.isInteger(value.line) && (value.line as number) >= 1
105
+ }
@@ -0,0 +1,27 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path'
4
+ import { promisify } from 'node:util'
5
+ import type { SourceReader } from '@lincyaw/kernel'
6
+
7
+ const run = promisify(execFile)
8
+
9
+ export const COMMIT = /^(?:[\da-f]{40}|[\da-f]{64})$/i
10
+
11
+ export async function findWorkspaceRoot(dir: string): Promise<string> {
12
+ return run('git', ['rev-parse', '--show-toplevel'], { cwd: dir })
13
+ .then(({ stdout }) => resolve(stdout.trim()), () => resolve(dir))
14
+ }
15
+
16
+ export function createSourceReader(root: string): SourceReader {
17
+ return {
18
+ async read(file, ref) {
19
+ const rel = relative(root, resolve(root, file))
20
+ if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return undefined
21
+ if (ref === undefined) return readFile(join(root, rel), 'utf8').catch(() => undefined)
22
+ if (ref.startsWith('-')) return undefined
23
+ return run('git', ['show', `${ref}:${rel.split(sep).join('/')}`], { cwd: root, maxBuffer: 64 << 20 })
24
+ .then(({ stdout }) => stdout, () => undefined)
25
+ },
26
+ }
27
+ }