@lincyaw/compiler 1.0.0-rc.0 → 1.0.0-rc.10

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/src/sections.ts CHANGED
@@ -1,89 +1,15 @@
1
- import type { Finding, MdastNode, SourcePos } from '@lincyaw/kernel'
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
- export function slugify(text: string): string {
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, statSync } from 'node:fs'
1
+ import { createReadStream, existsSync } from 'node:fs'
2
2
  import { createServer } from 'node:http'
3
- import { createRequire } from 'node:module'
3
+ import type { IncomingMessage, ServerResponse } from 'node:http'
4
4
  import type { AddressInfo } from 'node:net'
5
- import { extname, join, resolve, sep } from 'node:path'
5
+ import { basename, join, resolve } from 'node:path'
6
6
  import { pathToFileURL } from 'node:url'
7
-
8
- const TYPES: Record<string, string> = {
9
- '.html': 'text/html; charset=utf-8',
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 async function serveDirectory(root: string, options: { base?: string, extra?: Record<string, ServedExtra> } = {}): Promise<StaticServer> {
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 server = createServer((request, response) => {
45
- let path: string
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
- path = decodeURIComponent(new URL(request.url ?? '/', 'http://localhost').pathname)
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?.[path]
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
- let file = extra ? extra.file : path.startsWith(base) ? join(dir, path.slice(base.length)) : undefined
60
- if (file && !extra && file !== dir && !file.startsWith(dir + sep)) file = undefined
61
- if (file && existsSync(file) && statSync(file).isDirectory()) file = join(file, 'index.html')
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
- response.writeHead(200, { 'Content-Type': (extra && 'type' in extra && extra.type) || TYPES[extname(file).toLowerCase()] || 'application/octet-stream' })
70
- createReadStream(file).pipe(response)
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 async function playwrightChromium<T = unknown>(deckDir: string, fallbackFrom?: string): Promise<T> {
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
+ }
@@ -1,24 +1,23 @@
1
1
  import { createHash } from 'node:crypto'
2
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'
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 ORIGINAL_KEYS = ['plugins', 'presmium', 'renderers', 'execute', 'sectionDepth', 'checks']
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(headmatter: Record<string, unknown>): TranslationFields {
18
+ export function translationFields(metadata: Record<string, unknown>): TranslationFields {
20
19
  const fields: TranslationFields = {}
21
- const { translations, translationOf } = headmatter
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 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()
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
- 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
+ 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
- const rel = relative(realpathSync(deckDir), realpathSync(file))
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} not found in the deck's directory`, source: { file: doc.meta.entry, line: lineWith(entryText, 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)}`) })
86
81
  continue
87
82
  }
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) } })
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, frontmatter } = compilation
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 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 } : {} }
120
+ const options: CompilerOptions = { ...base, ...audience ? { audience } : {} }
127
121
  const known = compilers.get(file)
128
- const same = known && JSON.stringify(known.options.headmatter) === JSON.stringify(headmatter) && known.options.audience === options.audience
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.files, ...translated.dependencies.files])], ...translated.audience ? { audience: translated.audience } : {} })
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: { file, line: 1 } })
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,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
+ }
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 { isAbsolute, join, relative, resolve, sep } from 'node:path'
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 COMMIT = /^(?:[\da-f]{40}|[\da-f]{64})$/i
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
- return run('git', ['rev-parse', '--show-toplevel'], { cwd: dir })
13
- .then(({ stdout }) => resolve(stdout.trim()), () => resolve(dir))
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 rel = relative(root, resolve(root, file))
20
- if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return undefined
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}:${rel.split(sep).join('/')}`], { cwd: root, maxBuffer: 64 << 20 })
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
  }