@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,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
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -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,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
|
+
}
|
package/src/workspace.ts
ADDED
|
@@ -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
|
+
}
|