@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.
- package/LICENSE +202 -0
- package/README.md +29 -2
- package/package.json +47 -3
- package/src/audience.ts +486 -0
- package/src/blocks.ts +557 -0
- package/src/compiler.ts +526 -0
- package/src/dependencies.ts +76 -0
- package/src/directives.ts +116 -0
- package/src/environments.ts +148 -0
- package/src/execute.ts +367 -0
- package/src/footnotes.ts +39 -0
- package/src/forbid.ts +82 -0
- package/src/host.ts +56 -0
- package/src/index.ts +20 -0
- package/src/links.ts +203 -0
- package/src/paths.ts +56 -0
- package/src/plugins.ts +178 -0
- package/src/published.ts +105 -0
- package/src/references.ts +42 -0
- package/src/renderers.ts +535 -0
- package/src/sections.ts +98 -0
- package/src/serve.ts +93 -0
- package/src/settings.ts +105 -0
- package/src/source.ts +300 -0
- package/src/steps.ts +66 -0
- package/src/translations.ts +139 -0
- package/src/validate.ts +105 -0
- package/src/workspace.ts +27 -0
|
@@ -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
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -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
|
+
}
|
package/src/workspace.ts
ADDED
|
@@ -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
|
+
}
|