@lincyaw/compiler 1.0.0-rc.0 → 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/README.md +8 -4
- package/package.json +12 -13
- package/src/assets.ts +163 -0
- package/src/audience.ts +21 -216
- package/src/build.ts +101 -0
- package/src/{host.ts → compilation-actions.ts} +14 -7
- package/src/compile-queue.ts +28 -0
- package/src/compiler.ts +269 -195
- package/src/dev.ts +222 -0
- package/src/directives.ts +1 -0
- package/src/document-lowering.ts +176 -0
- package/src/environments.ts +6 -4
- package/src/execute.ts +50 -36
- package/src/export.ts +49 -0
- package/src/forbid.ts +6 -12
- package/src/frontmatter.ts +16 -0
- package/src/index.ts +21 -11
- package/src/{dependencies.ts → inputs.ts} +22 -13
- package/src/links.ts +21 -45
- package/src/logging.ts +16 -0
- package/src/node-services.ts +33 -0
- package/src/paths.ts +12 -26
- package/src/plugins.ts +53 -10
- package/src/references.ts +31 -20
- package/src/renderers.ts +61 -401
- package/src/sections.ts +6 -80
- package/src/serve.ts +58 -56
- package/src/source-lines.ts +47 -0
- package/src/translations.ts +51 -57
- package/src/validate.ts +3 -1
- package/src/views.ts +2 -0
- package/src/workspace-settings.ts +59 -0
- package/src/workspace.ts +17 -7
- package/src/blocks.ts +0 -557
- package/src/published.ts +0 -105
- package/src/settings.ts +0 -105
- package/src/source.ts +0 -300
- package/src/steps.ts +0 -66
package/src/sections.ts
CHANGED
|
@@ -1,89 +1,15 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import { fileOf, lineOf, sourceOf } from './source.ts'
|
|
3
|
-
|
|
4
|
-
export interface RawSection {
|
|
5
|
-
id: string
|
|
6
|
-
title: string
|
|
7
|
-
depth: number
|
|
8
|
-
source: SourcePos
|
|
9
|
-
nodes: MdastNode[]
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export interface SplitOptions {
|
|
13
|
-
entry: string
|
|
14
|
-
title: string
|
|
15
|
-
sectionDepth: number
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
interface Draft extends Omit<RawSection, 'id'> {
|
|
19
|
-
label?: string
|
|
20
|
-
titled?: boolean
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function splitSections(nodes: MdastNode[], options: SplitOptions, report: (finding: Finding) => void): RawSection[] {
|
|
24
|
-
const drafts: Draft[] = []
|
|
25
|
-
let opened = false
|
|
26
|
-
|
|
27
|
-
for (const node of nodes) {
|
|
28
|
-
const current = drafts.at(-1)
|
|
29
|
-
const depth = node.type === 'heading' && typeof node.depth === 'number' ? node.depth : undefined
|
|
30
|
-
if (depth !== undefined && depth <= options.sectionDepth && (depth === options.sectionDepth || opened || drafts[0]?.titled)) {
|
|
31
|
-
opened = true
|
|
32
|
-
const label = current ? takeLabel(current.nodes) : undefined
|
|
33
|
-
drafts.push({ label, title: plainText(node), depth, source: sourceOf(node, options.entry), nodes: [] })
|
|
34
|
-
continue
|
|
35
|
-
}
|
|
36
|
-
if (opened && current) {
|
|
37
|
-
current.nodes.push(node)
|
|
38
|
-
continue
|
|
39
|
-
}
|
|
40
|
-
const cover = current ?? { title: options.title, depth: 0, source: sourceOf(node, options.entry), nodes: [] }
|
|
41
|
-
if (!current) drafts.push(cover)
|
|
42
|
-
if (depth !== undefined && depth < options.sectionDepth && !cover.titled) {
|
|
43
|
-
cover.titled = true
|
|
44
|
-
cover.label = takeLabel(cover.nodes)
|
|
45
|
-
cover.title = plainText(node)
|
|
46
|
-
cover.source = sourceOf(node, options.entry)
|
|
47
|
-
continue
|
|
48
|
-
}
|
|
49
|
-
cover.nodes.push(node)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const cover = drafts[0]
|
|
53
|
-
if (cover?.depth === 0 && !cover.titled && cover.nodes.length === 0) drafts.shift()
|
|
54
|
-
|
|
55
|
-
const used = new Set<string>()
|
|
56
|
-
const labelled = new Set<Draft>()
|
|
57
|
-
for (const draft of drafts) {
|
|
58
|
-
if (!draft.label) continue
|
|
59
|
-
if (used.has(draft.label)) {
|
|
60
|
-
report({ checkId: 'presmium.sections', severity: 'error', message: `duplicate section label (${draft.label})=`, source: draft.source })
|
|
61
|
-
continue
|
|
62
|
-
}
|
|
63
|
-
used.add(draft.label)
|
|
64
|
-
labelled.add(draft)
|
|
65
|
-
}
|
|
66
|
-
return drafts.map((draft): RawSection => {
|
|
67
|
-
const { title, depth, source, nodes } = draft
|
|
68
|
-
const ends = nodes.filter(n => fileOf(n, options.entry) === source.file).map(n => lineOf(n)?.endLine ?? 0)
|
|
69
|
-
const id = labelled.has(draft) ? draft.label! : unique(draft.label ?? slugify(title), used)
|
|
70
|
-
return { id, title, depth, source: { ...source, endLine: Math.max(source.line, ...ends) }, nodes }
|
|
71
|
-
})
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function takeLabel(nodes: MdastNode[]): string | undefined {
|
|
75
|
-
const last = nodes.at(-1)
|
|
76
|
-
if (last?.type !== 'mystTarget' || typeof last.label !== 'string') return undefined
|
|
77
|
-
nodes.pop()
|
|
78
|
-
return last.label
|
|
79
|
-
}
|
|
1
|
+
import type { MdastNode } from '@lincyaw/kernel'
|
|
80
2
|
|
|
81
3
|
export function plainText(node: MdastNode): string {
|
|
82
4
|
if (typeof node.value === 'string' && !node.children) return node.value
|
|
83
5
|
return (node.children ?? []).map(plainText).join('')
|
|
84
6
|
}
|
|
85
7
|
|
|
86
|
-
|
|
8
|
+
/**
|
|
9
|
+
* The section ID a heading's text gives. Unlike the kernel's urlSlug, other characters are dropped rather than
|
|
10
|
+
* replaced, and only spaces, `_` and `-` become `-`; the IDs of existing documents depend on this rule.
|
|
11
|
+
*/
|
|
12
|
+
export function sectionSlug(text: string): string {
|
|
87
13
|
return text.normalize('NFKC').toLowerCase()
|
|
88
14
|
.replace(/[^\p{L}\p{N}\s_-]/gu, '')
|
|
89
15
|
.trim()
|
package/src/serve.ts
CHANGED
|
@@ -1,35 +1,12 @@
|
|
|
1
|
-
import { createReadStream, existsSync
|
|
1
|
+
import { createReadStream, existsSync } from 'node:fs'
|
|
2
2
|
import { createServer } from 'node:http'
|
|
3
|
-
import {
|
|
3
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
4
4
|
import type { AddressInfo } from 'node:net'
|
|
5
|
-
import {
|
|
5
|
+
import { basename, join, resolve } from 'node:path'
|
|
6
6
|
import { pathToFileURL } from 'node:url'
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
'.js': 'text/javascript',
|
|
11
|
-
'.mjs': 'text/javascript',
|
|
12
|
-
'.css': 'text/css',
|
|
13
|
-
'.json': 'application/json',
|
|
14
|
-
'.svg': 'image/svg+xml',
|
|
15
|
-
'.png': 'image/png',
|
|
16
|
-
'.jpg': 'image/jpeg',
|
|
17
|
-
'.jpeg': 'image/jpeg',
|
|
18
|
-
'.gif': 'image/gif',
|
|
19
|
-
'.webp': 'image/webp',
|
|
20
|
-
'.ico': 'image/x-icon',
|
|
21
|
-
'.woff': 'font/woff',
|
|
22
|
-
'.woff2': 'font/woff2',
|
|
23
|
-
'.wasm': 'application/wasm',
|
|
24
|
-
'.wav': 'audio/wav',
|
|
25
|
-
'.ogg': 'audio/ogg',
|
|
26
|
-
'.webm': 'video/webm',
|
|
27
|
-
'.m4a': 'audio/mp4',
|
|
28
|
-
'.mp3': 'audio/mpeg',
|
|
29
|
-
'.mp4': 'video/mp4',
|
|
30
|
-
'.vtt': 'text/vtt',
|
|
31
|
-
'.txt': 'text/plain; charset=utf-8',
|
|
32
|
-
}
|
|
7
|
+
import contentDisposition from 'content-disposition'
|
|
8
|
+
import cors from 'cors'
|
|
9
|
+
import send from 'send'
|
|
33
10
|
|
|
34
11
|
export type ServedExtra = { file: string, type?: string } | { body: string | Uint8Array, type: string }
|
|
35
12
|
|
|
@@ -38,36 +15,72 @@ export interface StaticServer {
|
|
|
38
15
|
close(): Promise<void>
|
|
39
16
|
}
|
|
40
17
|
|
|
41
|
-
export
|
|
18
|
+
export interface ServeDirectoryOptions {
|
|
19
|
+
base?: string
|
|
20
|
+
extra?: Record<string, ServedExtra>
|
|
21
|
+
/** The origins whose pages may read the files (CORS), e.g. a video's captions from another origin's page. */
|
|
22
|
+
origins?: string[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Serves the files of `root` below `base` on a loopback port, and `extra` at their paths. The files go through `send`
|
|
27
|
+
* (Express's): their types, ranges (a video's player seeks so), validators and conditional requests as HTTP defines them;
|
|
28
|
+
* `?download` asks for one as an attachment (`content-disposition`), and `origins` may read them (`cors`).
|
|
29
|
+
*/
|
|
30
|
+
export async function serveDirectory(root: string, options: ServeDirectoryOptions = {}): Promise<StaticServer> {
|
|
42
31
|
const dir = resolve(root)
|
|
43
32
|
const base = options.base ?? '/'
|
|
44
|
-
const
|
|
45
|
-
|
|
33
|
+
const missing = (response: ServerResponse) => {
|
|
34
|
+
const page = join(dir, '404.html')
|
|
35
|
+
response.writeHead(404, existsSync(page) ? { 'Content-Type': 'text/html; charset=utf-8' } : {})
|
|
36
|
+
if (existsSync(page)) createReadStream(page).pipe(response)
|
|
37
|
+
else response.end()
|
|
38
|
+
}
|
|
39
|
+
const failed = (response: ServerResponse) => (error: { status?: number }) => {
|
|
40
|
+
if (response.headersSent) return response.destroy()
|
|
41
|
+
if (error.status === 404) return missing(response)
|
|
42
|
+
response.writeHead(error.status ?? 500)
|
|
43
|
+
response.end()
|
|
44
|
+
}
|
|
45
|
+
const shared = options.origins?.length ? cors({ origin: options.origins }) : undefined
|
|
46
|
+
const attached = (request: { url?: string }, file: string) => (response: ServerResponse) => {
|
|
47
|
+
if (new URL(request.url ?? '/', 'http://localhost').searchParams.has('download')) response.setHeader('Content-Disposition', contentDisposition(basename(file)))
|
|
48
|
+
}
|
|
49
|
+
const answer = (request: IncomingMessage, response: ServerResponse) => {
|
|
50
|
+
const path = new URL(request.url ?? '/', 'http://localhost').pathname
|
|
51
|
+
let decoded: string
|
|
46
52
|
try {
|
|
47
|
-
|
|
53
|
+
decoded = decodeURIComponent(path)
|
|
48
54
|
} catch {
|
|
49
55
|
response.writeHead(400)
|
|
50
56
|
response.end()
|
|
51
57
|
return
|
|
52
58
|
}
|
|
53
|
-
const extra = options.extra?.[
|
|
59
|
+
const extra = options.extra?.[decoded]
|
|
54
60
|
if (extra && 'body' in extra) {
|
|
55
61
|
response.writeHead(200, { 'Content-Type': extra.type })
|
|
56
62
|
response.end(extra.body)
|
|
57
63
|
return
|
|
58
64
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if (!file || !existsSync(file)) {
|
|
63
|
-
const missing = join(dir, '404.html')
|
|
64
|
-
response.writeHead(404, existsSync(missing) ? { 'Content-Type': TYPES['.html']! } : {})
|
|
65
|
-
if (existsSync(missing)) createReadStream(missing).pipe(response)
|
|
66
|
-
else response.end()
|
|
65
|
+
if (extra) {
|
|
66
|
+
if (extra.type) response.setHeader('Content-Type', extra.type)
|
|
67
|
+
send(request, pathToFileURL(extra.file).pathname, { root: '/', dotfiles: 'allow' }).on('error', failed(response)).pipe(response)
|
|
67
68
|
return
|
|
68
69
|
}
|
|
69
|
-
|
|
70
|
-
|
|
70
|
+
if (!path.startsWith(base)) return missing(response)
|
|
71
|
+
send(request, path.slice(base.length - 1), { root: dir })
|
|
72
|
+
.on('error', failed(response))
|
|
73
|
+
.on('headers', attached(request, decoded))
|
|
74
|
+
// A directory's address without its slash: to the one with it, whose index.html is served.
|
|
75
|
+
.on('directory', () => {
|
|
76
|
+
response.writeHead(301, { Location: `${path}/${new URL(request.url ?? '/', 'http://localhost').search}` })
|
|
77
|
+
response.end()
|
|
78
|
+
})
|
|
79
|
+
.pipe(response)
|
|
80
|
+
}
|
|
81
|
+
const server = createServer((request, response) => {
|
|
82
|
+
if (shared) shared(request, response, () => answer(request, response))
|
|
83
|
+
else answer(request, response)
|
|
71
84
|
})
|
|
72
85
|
await new Promise<void>(done => server.listen(0, '127.0.0.1', done))
|
|
73
86
|
return {
|
|
@@ -79,15 +92,4 @@ export async function serveDirectory(root: string, options: { base?: string, ext
|
|
|
79
92
|
}
|
|
80
93
|
}
|
|
81
94
|
|
|
82
|
-
export
|
|
83
|
-
for (const from of [join(deckDir, 'package.json'), ...fallbackFrom ? [fallbackFrom] : []]) {
|
|
84
|
-
let entry: string
|
|
85
|
-
try {
|
|
86
|
-
entry = createRequire(from).resolve('playwright-chromium')
|
|
87
|
-
} catch {
|
|
88
|
-
continue
|
|
89
|
-
}
|
|
90
|
-
return (await import(pathToFileURL(entry).href) as { chromium: T }).chromium
|
|
91
|
-
}
|
|
92
|
-
throw new Error('this needs playwright-chromium, as `slidev export` does: install it in the deck (and its browser with pnpm exec playwright install chromium)')
|
|
93
|
-
}
|
|
95
|
+
export { playwrightChromium } from '@lincyaw/kernel/node'
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import type { SourcePos } from '@lincyaw/kernel'
|
|
3
|
+
import { withoutBom } from './frontmatter.ts'
|
|
4
|
+
|
|
5
|
+
export type LineFinder = (source: SourcePos, needles: string[]) => number
|
|
6
|
+
|
|
7
|
+
/** Finds the first line of a source span that contains one of the needles; reads each file once. */
|
|
8
|
+
export function lineFinder(): LineFinder {
|
|
9
|
+
const files = new Map<string, string[]>()
|
|
10
|
+
return (source, needles) => {
|
|
11
|
+
let lines = files.get(source.file)
|
|
12
|
+
if (!lines) {
|
|
13
|
+
try {
|
|
14
|
+
lines = readFileSync(source.file, 'utf8').split(/\r?\n/)
|
|
15
|
+
} catch {
|
|
16
|
+
lines = []
|
|
17
|
+
}
|
|
18
|
+
files.set(source.file, lines)
|
|
19
|
+
}
|
|
20
|
+
for (let line = source.line; line <= Math.min(source.endLine ?? source.line, lines.length); line++) {
|
|
21
|
+
if (needles.some(needle => needle && lines[line - 1]!.includes(needle))) return line
|
|
22
|
+
}
|
|
23
|
+
return source.line
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The first line of the span that contains the text, or the span's first line. */
|
|
28
|
+
export function lineContaining(source: SourcePos, text: string): number {
|
|
29
|
+
return lineFinder()(source, [text])
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The 1-based line of the text that contains the needle, or 1. */
|
|
33
|
+
export function textLine(text: string, needle: string): number {
|
|
34
|
+
return firstLineWith(text.split(/\r?\n/), needle)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The line of the headmatter that contains the needle, or 1. */
|
|
38
|
+
export function frontmatterLine(text: string, needle: string): number {
|
|
39
|
+
const lines = withoutBom(text).split(/\r?\n/)
|
|
40
|
+
const end = lines.indexOf('---', 1)
|
|
41
|
+
return firstLineWith(lines.slice(0, end < 0 ? undefined : end), needle)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function firstLineWith(lines: string[], needle: string): number {
|
|
45
|
+
const index = lines.findIndex(line => line.includes(needle))
|
|
46
|
+
return index < 0 ? 1 : index + 1
|
|
47
|
+
}
|
package/src/translations.ts
CHANGED
|
@@ -1,24 +1,23 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
2
|
import { readFileSync, realpathSync, statSync } from 'node:fs'
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
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
7
|
import type { AudienceCompilation, Compilation, Compiler, CompilerOptions } from './compiler.ts'
|
|
8
|
-
import { readFrontmatter } from './source.ts'
|
|
9
8
|
|
|
10
9
|
const CHECK_ID = 'presmium.translations'
|
|
11
|
-
|
|
12
|
-
const
|
|
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 })
|
|
13
12
|
|
|
14
13
|
export interface TranslationFields {
|
|
15
14
|
translations?: Record<string, string>
|
|
16
15
|
translationOf?: { entry: string, digest: string }
|
|
17
16
|
}
|
|
18
17
|
|
|
19
|
-
export function translationFields(
|
|
18
|
+
export function translationFields(metadata: Record<string, unknown>): TranslationFields {
|
|
20
19
|
const fields: TranslationFields = {}
|
|
21
|
-
const { translations, translationOf } =
|
|
20
|
+
const { translations, translationOf } = metadata
|
|
22
21
|
if (isRecord(translations)) {
|
|
23
22
|
const entries = Object.entries(translations).filter((entry): entry is [string, string] => typeof entry[1] === 'string' && entry[1].length > 0)
|
|
24
23
|
if (entries.length > 0) fields.translations = Object.fromEntries(entries)
|
|
@@ -35,66 +34,66 @@ export function applyTranslations(doc: IRDocument, fields: TranslationFields, re
|
|
|
35
34
|
if (fields.translations) staleTranslations(doc, fields.translations).forEach(report)
|
|
36
35
|
}
|
|
37
36
|
|
|
38
|
-
function
|
|
39
|
-
|
|
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')
|
|
40
55
|
}
|
|
41
56
|
|
|
42
57
|
export function originalDigest(doc: IRDocument): string {
|
|
43
|
-
|
|
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
|
+
return documentDigest(documentValue(readFileSync(doc.meta.entry, 'utf8')))
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
function translationFile(deckDir: string, entry: string): string | undefined {
|
|
61
62
|
const file = resolve(deckDir, entry)
|
|
63
|
+
if (basename(file) !== DOCUMENT_ENTRY) return undefined
|
|
62
64
|
try {
|
|
63
|
-
|
|
64
|
-
return statSync(file).isFile() && !rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel) ? file : undefined
|
|
65
|
+
return statSync(file).isFile() && isInside(realpathSync(file), realpathSync(deckDir)) ? file : undefined
|
|
65
66
|
} catch {
|
|
66
67
|
return undefined
|
|
67
68
|
}
|
|
68
69
|
}
|
|
69
70
|
|
|
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
71
|
function staleTranslations(doc: IRDocument, translations: Record<string, string>): Finding[] {
|
|
76
72
|
const deckDir = dirname(doc.meta.entry)
|
|
77
73
|
const original = basename(doc.meta.entry)
|
|
78
|
-
const entryText = readFileSync(doc.meta.entry, 'utf8')
|
|
79
74
|
let digest: string | undefined
|
|
80
75
|
const findings: Finding[] = []
|
|
81
76
|
for (const [lang, entry] of Object.entries(translations)) {
|
|
82
77
|
const file = translationFile(deckDir, entry)
|
|
83
78
|
const name = `translation "${lang}" (${entry})`
|
|
84
|
-
if (!file) {
|
|
85
|
-
findings.push({ checkId: CHECK_ID, severity: 'warning', message: `${name}
|
|
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)}`) })
|
|
86
81
|
continue
|
|
87
82
|
}
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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('') })
|
|
98
97
|
}
|
|
99
98
|
}
|
|
100
99
|
return findings
|
|
@@ -108,7 +107,7 @@ export interface CompiledTranslation {
|
|
|
108
107
|
}
|
|
109
108
|
|
|
110
109
|
export async function compileTranslations(compilation: Compilation, compilers = new Map<string, Compiler>()): Promise<{ translations: CompiledTranslation[], findings: Finding[] }> {
|
|
111
|
-
const { doc, deckDir
|
|
110
|
+
const { doc, deckDir } = compilation
|
|
112
111
|
const translations: CompiledTranslation[] = []
|
|
113
112
|
const findings: Finding[] = []
|
|
114
113
|
const { Compiler } = await import('./compiler.ts')
|
|
@@ -116,23 +115,18 @@ export async function compileTranslations(compilation: Compilation, compilers =
|
|
|
116
115
|
const audience = compilation.audience?.name
|
|
117
116
|
for (const [lang, entry] of Object.entries(doc.config.translations ?? {})) {
|
|
118
117
|
const file = translationFile(deckDir, entry)
|
|
119
|
-
if (!file) continue
|
|
118
|
+
if (!file || file === doc.meta.entry) continue
|
|
120
119
|
try {
|
|
121
|
-
const
|
|
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 } : {} }
|
|
120
|
+
const options: CompilerOptions = { ...base, ...audience ? { audience } : {} }
|
|
127
121
|
const known = compilers.get(file)
|
|
128
|
-
const same = known &&
|
|
122
|
+
const same = known && known.options.audience === options.audience
|
|
129
123
|
const compiler = same ? known : new Compiler(file, options)
|
|
130
124
|
compilers.set(file, compiler)
|
|
131
125
|
const translated = await compiler.compile()
|
|
132
126
|
findings.push(...translated.doc.diagnostics)
|
|
133
|
-
translations.push({ lang, doc: translated.doc, files: [...new Set([...translated.
|
|
127
|
+
translations.push({ lang, doc: translated.doc, files: [...new Set([...translated.sourceFiles, ...translated.inputs.files])], ...translated.audience ? { audience: translated.audience } : {} })
|
|
134
128
|
} catch (error) {
|
|
135
|
-
findings.push({ checkId: CHECK_ID, severity: 'error', message: `translation "${lang}" (${entry}) could not be compiled: ${errorMessage(error)}`, source:
|
|
129
|
+
findings.push({ checkId: CHECK_ID, severity: 'error', message: `translation "${lang}" (${entry}) could not be compiled: ${errorMessage(error)}`, source: sourceAt(file, '') })
|
|
136
130
|
}
|
|
137
131
|
}
|
|
138
132
|
return { translations, findings }
|
package/src/validate.ts
CHANGED
|
@@ -66,6 +66,7 @@ function nodeProblem({ node }: IRBlock): string | undefined {
|
|
|
66
66
|
if (!isRecord(node)) return 'node is missing'
|
|
67
67
|
switch (node.type) {
|
|
68
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'
|
|
69
70
|
return isRecord(node.mdast) ? mdastProblem(node.mdast, 'mdast') : 'markdown node needs an mdast tree'
|
|
70
71
|
case 'code':
|
|
71
72
|
if (typeof node.lang !== 'string' || typeof node.value !== 'string') return 'code node needs lang and value'
|
|
@@ -77,8 +78,9 @@ function nodeProblem({ node }: IRBlock): string | undefined {
|
|
|
77
78
|
case 'component':
|
|
78
79
|
if (typeof node.plugin !== 'string' || typeof node.name !== 'string' || node.name === '') return 'component node needs plugin and name'
|
|
79
80
|
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
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'
|
|
82
84
|
return undefined
|
|
83
85
|
default:
|
|
84
86
|
return `unknown node type ${String((node as { type?: unknown }).type)}`
|
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
CHANGED
|
@@ -1,26 +1,36 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
2
3
|
import { readFile } from 'node:fs/promises'
|
|
3
|
-
import {
|
|
4
|
+
import { dirname, join, relative, resolve, sep } from 'node:path'
|
|
4
5
|
import { promisify } from 'node:util'
|
|
5
6
|
import type { SourceReader } from '@lincyaw/kernel'
|
|
7
|
+
import { isInside } from '@lincyaw/kernel/node'
|
|
8
|
+
import { WORKSPACE_CONFIG } from './workspace-settings.ts'
|
|
6
9
|
|
|
7
10
|
const run = promisify(execFile)
|
|
8
11
|
|
|
9
|
-
export const
|
|
12
|
+
export const COMMIT_HASH = /^(?:[\da-f]{40}|[\da-f]{64})$/i
|
|
10
13
|
|
|
11
14
|
export async function findWorkspaceRoot(dir: string): Promise<string> {
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
14
23
|
}
|
|
15
24
|
|
|
16
25
|
export function createSourceReader(root: string): SourceReader {
|
|
17
26
|
return {
|
|
18
27
|
async read(file, ref) {
|
|
19
|
-
const
|
|
20
|
-
|
|
28
|
+
const path = resolve(root, file)
|
|
29
|
+
const rel = relative(root, path)
|
|
30
|
+
if (rel === '' || !isInside(path, root)) return undefined
|
|
21
31
|
if (ref === undefined) return readFile(join(root, rel), 'utf8').catch(() => undefined)
|
|
22
32
|
if (ref.startsWith('-')) return undefined
|
|
23
|
-
return run('git', ['show', `${ref}
|
|
33
|
+
return run('git', ['show', `${ref}:./${rel.split(sep).join('/')}`], { cwd: root, maxBuffer: 64 << 20 })
|
|
24
34
|
.then(({ stdout }) => stdout, () => undefined)
|
|
25
35
|
},
|
|
26
36
|
}
|