@lincyaw/kernel 1.0.0-rc.0 → 1.0.0-rc.2
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 +7 -7
- package/package.json +6 -2
- package/src/access.ts +18 -0
- package/src/anchors.ts +5 -1
- package/src/authoring.ts +56 -0
- package/src/blocks.ts +0 -8
- package/src/component-code.ts +6 -0
- package/src/contracts/agent.ts +5 -82
- package/src/contracts/anchors.ts +46 -0
- package/src/contracts/authoring-actions.ts +60 -0
- package/src/contracts/authoring-clients.ts +213 -0
- package/src/contracts/authoring.ts +180 -0
- package/src/contracts/checks.ts +29 -0
- package/src/contracts/classroom.ts +49 -0
- package/src/contracts/diagnostics.ts +37 -0
- package/src/contracts/document-audio.ts +150 -0
- package/src/contracts/document-comments.ts +31 -0
- package/src/contracts/document-history.ts +63 -0
- package/src/contracts/document-image.ts +32 -0
- package/src/contracts/document-reads.ts +57 -0
- package/src/contracts/document-voice.ts +44 -0
- package/src/contracts/document.ts +240 -0
- package/src/contracts/execution.ts +4 -5
- package/src/contracts/locale.ts +3 -0
- package/src/contracts/offline.ts +2 -0
- package/src/contracts/plugin.ts +183 -0
- package/src/contracts/presenter-panels.ts +17 -0
- package/src/contracts/preview.ts +305 -310
- package/src/contracts/production-assets.ts +61 -0
- package/src/contracts/production.ts +109 -0
- package/src/contracts/projects.ts +59 -0
- package/src/contracts/server.ts +1 -51
- package/src/contracts/speech-settings.ts +16 -0
- package/src/contracts/speech.ts +82 -70
- package/src/contracts/steps.ts +15 -0
- package/src/contracts/transcription-adoption.ts +79 -0
- package/src/contracts/transcription.ts +112 -0
- package/src/contracts/voice.ts +97 -0
- package/src/contracts/voiceover-execution.ts +68 -0
- package/src/contracts/workspace.ts +458 -0
- package/src/contracts.ts +28 -278
- package/src/diagnostics.ts +7 -0
- package/src/document-authoring-actions.ts +47 -0
- package/src/document-caption-timings.ts +64 -0
- package/src/document-colors.ts +25 -0
- package/src/document-comments.ts +118 -0
- package/src/document-components.ts +251 -0
- package/src/document-operations.ts +132 -0
- package/src/document-pages.ts +215 -0
- package/src/document-reads.ts +81 -0
- package/src/document-schema.ts +85 -0
- package/src/document-voices.ts +153 -0
- package/src/document.ts +252 -0
- package/src/durations.ts +7 -0
- package/src/errors.ts +8 -0
- package/src/execution.ts +5 -1
- package/src/fingerprint.ts +1 -1
- package/src/follow.ts +26 -30
- package/src/git.ts +1 -1
- package/src/index.ts +66 -13
- package/src/ir.ts +54 -5
- package/src/jupyter-kernel.ts +9 -0
- package/src/kernel.ts +38 -25
- package/src/keyboard.ts +43 -0
- package/src/locale.ts +13 -0
- package/src/lock.ts +4 -8
- package/src/logging.ts +1 -1
- package/src/{markdown.ts → lower-myst.ts} +2 -1
- package/src/node/assets.ts +23 -0
- package/src/node/authoring-actions.ts +50 -0
- package/src/node/authoring-clients.ts +14 -0
- package/src/node/document-audio.ts +22 -0
- package/src/node/document-history.ts +11 -0
- package/src/node/document-image.ts +8 -0
- package/src/node/document-reads.ts +10 -0
- package/src/node/document-voice.ts +7 -0
- package/src/node/ffmpeg.ts +29 -0
- package/src/node/file-version.ts +6 -0
- package/src/node/fonts.ts +22 -0
- package/src/node/index.ts +29 -133
- package/src/node/installed-packages.ts +11 -0
- package/src/node/lines.ts +24 -0
- package/src/node/locale.ts +7 -0
- package/src/node/network.ts +11 -0
- package/src/node/operator-speech.ts +91 -0
- package/src/node/packages.ts +74 -0
- package/src/node/paths.ts +7 -0
- package/src/node/projects.ts +7 -0
- package/src/node/resources.ts +86 -0
- package/src/node/revision.ts +6 -0
- package/src/node/run-directory.ts +70 -0
- package/src/node/transcription-adoption.ts +9 -0
- package/src/node/transcription.ts +10 -0
- package/src/node/voiceover-routes.ts +73 -0
- package/src/node/voiceover.ts +199 -0
- package/src/page-source.ts +359 -0
- package/src/parameters.ts +81 -0
- package/src/preview-edit.ts +18 -0
- package/src/preview-events.ts +57 -0
- package/src/preview-frame.ts +1220 -0
- package/src/props.ts +17 -0
- package/src/push-stream.ts +67 -0
- package/src/reading.ts +12 -7
- package/src/recording.ts +38 -0
- package/src/renderer-components.ts +30 -0
- package/src/responses.ts +49 -0
- package/src/run-outputs.ts +15 -0
- package/src/runner.py +3 -3
- package/src/services.ts +6 -6
- package/src/slides.ts +36 -0
- package/src/speech-words.ts +76 -0
- package/src/speech.ts +13 -0
- package/src/step-keys.ts +38 -0
- package/src/steps.ts +7 -0
- package/src/transcription-adoption.ts +105 -0
- package/src/uri-decoding.ts +8 -0
- package/src/urls.ts +13 -0
- package/src/views.ts +47 -0
- package/src/voice.ts +86 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { DOCUMENT_ENTITY_COLLECTIONS, DOCUMENT_READ_MAX_RESPONSE_CHARS } from './contracts/document-reads.ts'
|
|
2
|
+
import type { DocumentOutlineAnswer, DocumentOutlineEntry, DocumentOutlineQuery, DocumentPartAnswer, DocumentPartQuery } from './contracts/document-reads.ts'
|
|
3
|
+
import type { JsonValue, TeachingDocument } from './contracts/document.ts'
|
|
4
|
+
import type { CanonicalSource } from './ir.ts'
|
|
5
|
+
import { fnv1a64 } from './fingerprint.ts'
|
|
6
|
+
|
|
7
|
+
export class DocumentReadError extends Error {
|
|
8
|
+
readonly status: number
|
|
9
|
+
constructor(status: number, message: string) { super(message); this.status = status }
|
|
10
|
+
}
|
|
11
|
+
const fits = (value: unknown) => JSON.stringify(value, null, 2).length <= DOCUMENT_READ_MAX_RESPONSE_CHARS
|
|
12
|
+
const fail = (status: number, code: string): never => { throw new DocumentReadError(status, code) }
|
|
13
|
+
/** Offsets index decoded UTF-16 text; a valid surrogate pair is always kept together. */
|
|
14
|
+
function boundary(text: string, offset: number): boolean {
|
|
15
|
+
return !(offset > 0 && offset < text.length && /[\uD800-\uDBFF]/.test(text[offset - 1]!) && /[\uDC00-\uDFFF]/.test(text[offset]!))
|
|
16
|
+
}
|
|
17
|
+
function preview(text: string): NonNullable<DocumentOutlineEntry['title']> {
|
|
18
|
+
let end = Math.min(text.length, 80)
|
|
19
|
+
if (!boundary(text, end)) end--
|
|
20
|
+
return { preview: text.slice(0, end), complete: end === text.length, totalChars: text.length }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function documentOutline(content: TeachingDocument, source: CanonicalSource, query: DocumentOutlineQuery): DocumentOutlineAnswer {
|
|
24
|
+
const collection = query.collection ?? 'sections', offset = query.offset ?? 0
|
|
25
|
+
const records = content[collection] ?? {}, ids = collection === 'sections' ? content.sectionOrder : Object.keys(records)
|
|
26
|
+
if (offset > ids.length) return fail(400, 'document-read-offset-invalid')
|
|
27
|
+
const answer: DocumentOutlineAnswer = { source, documentSchemaVersion: content.schemaVersion,
|
|
28
|
+
counts: Object.fromEntries(DOCUMENT_ENTITY_COLLECTIONS.map(name => [name, Object.keys(content[name] ?? {}).length])) as DocumentOutlineAnswer['counts'],
|
|
29
|
+
collection, present: Object.hasOwn(content, collection), offset, total: ids.length, complete: offset === ids.length, entries: [] }
|
|
30
|
+
for (let i = offset; i < ids.length; i++) {
|
|
31
|
+
const id = ids[i]!, record = records[id] as unknown as Record<string, unknown>
|
|
32
|
+
const entry: DocumentOutlineEntry = { id }
|
|
33
|
+
if (typeof record.type === 'string') entry.type = record.type
|
|
34
|
+
if (typeof record.sectionId === 'string') entry.sectionId = record.sectionId
|
|
35
|
+
if (typeof record.title === 'string') entry.title = preview(record.title)
|
|
36
|
+
if (collection === 'sections') entry.counts = { children: (record.children as unknown[]).length, steps: (record.stepOrder as unknown[]).length }
|
|
37
|
+
if (collection === 'steps') entry.counts = { narrations: (record.narrationIds as unknown[]).length }
|
|
38
|
+
answer.entries.push(entry)
|
|
39
|
+
answer.complete = i + 1 === ids.length
|
|
40
|
+
if (answer.complete) delete answer.nextOffset
|
|
41
|
+
else answer.nextOffset = i + 1
|
|
42
|
+
if (fits(answer)) continue
|
|
43
|
+
answer.entries.pop(); answer.complete = false; answer.nextOffset = i
|
|
44
|
+
if (!answer.entries.length) return fail(413, 'document-read-response-too-large')
|
|
45
|
+
break
|
|
46
|
+
}
|
|
47
|
+
if (!fits(answer)) return fail(413, 'document-read-response-too-large')
|
|
48
|
+
return answer
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function documentPart(content: TeachingDocument, source: CanonicalSource, query: DocumentPartQuery): DocumentPartAnswer {
|
|
52
|
+
const { target } = query
|
|
53
|
+
let value: unknown = content[target.collection]
|
|
54
|
+
if ('id' in target) {
|
|
55
|
+
if (!value || typeof value !== 'object' || !Object.hasOwn(value, target.id)) return fail(404, 'document-entity-not-found')
|
|
56
|
+
value = (value as Record<string, unknown>)[target.id]
|
|
57
|
+
}
|
|
58
|
+
for (const field of target.path ?? []) {
|
|
59
|
+
if (!value || typeof value !== 'object' || !Object.hasOwn(value, field) || (Array.isArray(value) && (!/^(0|[1-9][0-9]*)$/.test(field) || !Number.isSafeInteger(Number(field))))) return fail(404, 'document-field-not-found')
|
|
60
|
+
value = (value as Record<string, unknown>)[field]
|
|
61
|
+
}
|
|
62
|
+
if (value === undefined) return fail(404, 'document-field-not-found')
|
|
63
|
+
const text = JSON.stringify(value), offset = query.offset ?? 0
|
|
64
|
+
if (offset > text.length || !boundary(text, offset)) return fail(400, 'document-read-offset-invalid')
|
|
65
|
+
const base = { source, target, valueDigest: { algorithm: 'fnv1a64' as const, serialization: 'JSON.stringify' as const, value: fnv1a64(text) } }
|
|
66
|
+
const whole: DocumentPartAnswer = { ...base, encoding: 'value', complete: true, value: value as JsonValue }
|
|
67
|
+
if (offset === 0 && fits(whole)) return whole
|
|
68
|
+
const fragment = (end: number): DocumentPartAnswer => ({ ...base, encoding: 'json-text-fragment', offsetUnit: 'utf16', offset, ...end < text.length ? { nextOffset: end } : {}, totalChars: text.length, complete: end === text.length, text: text.slice(offset, end) })
|
|
69
|
+
// Find the largest prefix whose fully escaped, indented response fits the actual text budget.
|
|
70
|
+
let low = offset, high = text.length
|
|
71
|
+
while (low < high) {
|
|
72
|
+
const mid = Math.ceil((low + high) / 2)
|
|
73
|
+
if (fits(fragment(mid))) low = mid
|
|
74
|
+
else high = mid - 1
|
|
75
|
+
}
|
|
76
|
+
if (!boundary(text, low)) low--
|
|
77
|
+
if (low === offset && offset < text.length) return fail(413, 'document-read-response-too-large')
|
|
78
|
+
const answer = fragment(low)
|
|
79
|
+
if (!fits(answer)) return fail(413, 'document-read-response-too-large')
|
|
80
|
+
return answer
|
|
81
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Schema } from '@cfworker/json-schema'
|
|
2
|
+
import { ASSET_TITLE_MAX_LENGTH, DOCUMENT_SCHEMA_VERSION } from './contracts/document.ts'
|
|
3
|
+
|
|
4
|
+
const ref = (name: string): Schema => ({ $ref: `#/$defs/${name}` })
|
|
5
|
+
const string: Schema = { type: 'string' }
|
|
6
|
+
const id: Schema = { type: 'string', minLength: 1, maxLength: 200, pattern: '^[^\\u0000-\\u001f]+$', not: { enum: ['__proto__', 'constructor', 'prototype'] } }
|
|
7
|
+
const positive: Schema = { type: 'number', exclusiveMinimum: 0 }
|
|
8
|
+
const nonnegative: Schema = { type: 'number', minimum: 0 }
|
|
9
|
+
const integer: Schema = { type: 'integer', minimum: 0 }
|
|
10
|
+
const list = (items: Schema): Schema => ({ type: 'array', items })
|
|
11
|
+
const ids: Schema = { type: 'array', items: id, uniqueItems: true }
|
|
12
|
+
const record = (value: Schema): Schema => ({ type: 'object', additionalProperties: value })
|
|
13
|
+
const object = (properties: Record<string, Schema>, required = Object.keys(properties)): Schema => ({ type: 'object', properties, required, additionalProperties: false })
|
|
14
|
+
const tag = (type: string): Schema => ({ const: type })
|
|
15
|
+
const scope = { only: list(string), audience: list(string) }
|
|
16
|
+
const style = object({ alignment: { enum: ['left', 'center', 'right', 'justify'] }, color: string, background: string }, [])
|
|
17
|
+
const exclusiveCode = [{ required: ['value'], not: { required: ['origin'] } }, { required: ['origin'], not: { required: ['value'] } }]
|
|
18
|
+
const rich = (type: string, properties: Record<string, Schema>, required: string[] = Object.keys(properties)): Schema => object({ id, type: tag(type), ...properties }, ['id', 'type', ...required])
|
|
19
|
+
const block = (type: string, properties: Record<string, Schema>, required: string[] = Object.keys(properties)): Schema => object({ id, type: tag(type), ...scope, ...properties }, ['id', 'type', ...required])
|
|
20
|
+
|
|
21
|
+
/** Public authoring schema; domain graph checks are shared by the UI, agent and compiler validator. */
|
|
22
|
+
export const DOCUMENT_JSON_SCHEMA: Schema = {
|
|
23
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
24
|
+
$id: 'urn:presmium:teaching-document:1',
|
|
25
|
+
...object({
|
|
26
|
+
schemaVersion: { const: DOCUMENT_SCHEMA_VERSION }, id,
|
|
27
|
+
metadata: object({ title: string, description: string, tags: list(string), lang: { type: 'string', minLength: 1 }, plugins: list(string), presmium: record(ref('JsonObject')), renderers: record(ref('JsonObject')), execute: ref('JsonObject'), checks: ref('JsonObject'), translations: record(string), translationOf: object({ entry: string, digest: string }) }, ['title', 'lang']),
|
|
28
|
+
sectionOrder: { ...ids, minItems: 1 }, sections: record(ref('Section')), blocks: record(ref('Block')),
|
|
29
|
+
steps: record(ref('Step')), narrations: record(ref('Narration')), assets: record(ref('Asset')),
|
|
30
|
+
definitions: record(ref('Definition')), production: ref('Production'), comments: record(ref('Thread')),
|
|
31
|
+
}, ['schemaVersion', 'id', 'metadata', 'sectionOrder', 'sections', 'blocks', 'steps', 'narrations', 'assets', 'definitions', 'production']),
|
|
32
|
+
$defs: {
|
|
33
|
+
JsonValue: { anyOf: [{ type: ['null', 'boolean', 'number', 'string'] }, { type: 'array', items: ref('JsonValue') }, ref('JsonObject')] },
|
|
34
|
+
JsonObject: { type: 'object', additionalProperties: ref('JsonValue') },
|
|
35
|
+
JsonSchema: { anyOf: [{ type: 'boolean' }, ref('JsonObject')] },
|
|
36
|
+
Layout: object({ type: { enum: ['stack', 'columns', 'canvas', 'scrolly'] }, properties: ref('JsonObject'), renderers: record(ref('JsonObject')) }, ['type']),
|
|
37
|
+
Section: object({ id, title: string, level: { type: 'integer', minimum: 1, maximum: 6 }, children: ids, stepOrder: { ...ids, minItems: 1 }, layout: ref('Layout'), audience: list(string), config: record(ref('JsonObject')) }, ['id', 'title', 'children', 'stepOrder', 'layout']),
|
|
38
|
+
Mark: { oneOf: [object({ type: { enum: ['bold', 'italic', 'underline', 'strike', 'code'] } }), object({ type: { enum: ['color', 'background'] }, value: string })] },
|
|
39
|
+
Inline: { oneOf: [
|
|
40
|
+
object({ type: tag('text'), text: string, marks: list(ref('Mark')) }, ['type', 'text']),
|
|
41
|
+
object({ type: tag('link'), href: string, children: list(ref('Inline')) }),
|
|
42
|
+
object({ type: tag('math'), value: string }), object({ type: tag('break') }),
|
|
43
|
+
object({ type: tag('extension'), kind: id, schemaVersion: { type: 'integer', minimum: 1 }, data: ref('JsonObject'), fallback: string }),
|
|
44
|
+
] },
|
|
45
|
+
RichTextNode: { oneOf: [
|
|
46
|
+
rich('paragraph', { children: list(ref('Inline')), style }, ['children']),
|
|
47
|
+
rich('heading', { level: { type: 'integer', minimum: 1, maximum: 6 }, children: list(ref('Inline')), style }, ['level', 'children']),
|
|
48
|
+
rich('list', { ordered: { type: 'boolean' }, start: { type: 'integer', minimum: 1 }, children: list(ref('ListItem')) }, ['ordered', 'children']),
|
|
49
|
+
rich('blockquote', { children: list(ref('RichTextNode')) }),
|
|
50
|
+
rich('code', { language: string, value: string }), rich('math', { value: string }),
|
|
51
|
+
rich('table', { children: list(ref('TableRow')), headerRows: integer, headerColumns: integer, columnWidths: list(positive) }, ['children']),
|
|
52
|
+
rich('extension', { kind: id, schemaVersion: { type: 'integer', minimum: 1 }, data: ref('JsonObject'), fallback: list(ref('RichTextNode')) }),
|
|
53
|
+
] },
|
|
54
|
+
ListItem: rich('listItem', { children: list(ref('RichTextNode')), checked: { type: 'boolean' } }, ['children']),
|
|
55
|
+
TableRow: rich('tableRow', { children: list(ref('TableCell')) }),
|
|
56
|
+
TableCell: rich('tableCell', { children: list(ref('Inline')), style, colspan: { type: 'integer', minimum: 1 }, rowspan: { type: 'integer', minimum: 1 } }, ['children']),
|
|
57
|
+
CodeReference: object({ anchor: { type: 'string', minLength: 1 }, pin: string, repo: string, highlightRegions: record({ type: 'string', minLength: 1 }) }, ['anchor']),
|
|
58
|
+
VariantReference: object({ anchor: { type: 'string', minLength: 1 }, pin: string, repo: string }, ['anchor']),
|
|
59
|
+
CodeVariant: { ...object({ id, title: string, value: string, origin: ref('VariantReference') }, ['id', 'title']), oneOf: exclusiveCode },
|
|
60
|
+
CodeExecution: object({ args: record(string), stdin: string, timeoutMs: positive, executor: string, when: { enum: ['compile', 'live', 'both'] }, session: string }, ['when']),
|
|
61
|
+
Block: { oneOf: [
|
|
62
|
+
block('text', { content: list(ref('RichTextNode')), reveal: object({ type: tag('items'), order: ids }) }, ['content']),
|
|
63
|
+
block('container', { layout: { enum: ['stack', 'columns', 'tabs'] }, children: ids, properties: object({ title: string, columnWidths: list(positive), tabTitles: list(string) }, []) }),
|
|
64
|
+
block('image', { assetId: id, alt: string, caption: list(ref('Inline')), width: positive, alignment: { enum: ['left', 'center', 'right', 'justify'] } }, ['alt']),
|
|
65
|
+
{ ...block('code', { language: string, value: string, origin: ref('CodeReference'), title: string, execution: ref('CodeExecution'), highlight: record(list({ type: 'integer', minimum: 1 })), variants: list(ref('CodeVariant')), echo: { type: 'boolean' } }, ['language']), oneOf: exclusiveCode },
|
|
66
|
+
block('component', { definitionId: id, parameters: ref('JsonObject'), children: ids }, ['definitionId', 'parameters']),
|
|
67
|
+
] },
|
|
68
|
+
Visual: object({ blockId: id, visible: { type: 'boolean' }, state: id, parameters: ref('JsonObject') }, ['blockId', 'visible']),
|
|
69
|
+
Step: object({ id, sectionId: id, title: string, visuals: list(ref('Visual')), narrationIds: ids, inherits: id }, ['id', 'sectionId', 'visuals', 'narrationIds']),
|
|
70
|
+
NarrationNode: { oneOf: [object({ type: tag('text'), text: string }), object({ type: tag('pronunciation'), display: string, spoken: string }), object({ id, type: tag('cue'), action: { type: 'string', minLength: 1 }, target: object({ blockId: id, element: string }, ['blockId']), parameters: ref('JsonObject') }, ['id', 'type', 'action'])] },
|
|
71
|
+
Narration: object({ id, content: list(ref('NarrationNode')), provenance: object({ type: tag('transcription'), transcriptionId: { type: 'string', pattern: '^[a-f0-9]{64}$' }, revision: { type: 'string', pattern: '^[a-f0-9]{64}$' }, assetId: id, audioSha256: { type: 'string', pattern: '^[a-f0-9]{64}$' }, segmentIds: { ...ids, minItems: 1 } }) }, ['id', 'content']),
|
|
72
|
+
Asset: object({ id, kind: { enum: ['image', 'audio', 'video', 'file'] }, location: { type: 'string', minLength: 1 }, title: { type: 'string', minLength: 1, maxLength: ASSET_TITLE_MAX_LENGTH }, fingerprint: string, mediaType: string }, ['id', 'kind', 'location']),
|
|
73
|
+
Implementation: { oneOf: [object({ type: tag('file'), path: { type: 'string', minLength: 1 }, exportName: string, fingerprint: string }, ['type', 'path']), object({ type: tag('module'), specifier: { type: 'string', minLength: 1 }, exportName: string }, ['type', 'specifier']), object({ type: tag('plugin'), plugin: id, name: id })] },
|
|
74
|
+
State: object({ id, title: string, parameterSchema: ref('JsonSchema'), legacyIndex: integer }, ['id']),
|
|
75
|
+
Definition: object({ id, kind: { type: 'string', minLength: 1 }, schemaVersion: { type: 'integer', minimum: 1 }, implementation: ref('Implementation'), parameterSchema: ref('JsonSchema'), states: list(ref('State')), data: ref('JsonObject') }, ['id', 'kind', 'schemaVersion', 'implementation', 'parameterSchema', 'states']),
|
|
76
|
+
VoiceSource: { oneOf: [object({ type: tag('none') }), object({ type: tag('tts'), voice: object({ voice: { type: 'string', minLength: 1 }, engine: string, rate: positive, style: string }, ['voice']) }), object({ type: tag('recording'), assetId: id, sourceRange: object({ startMs: nonnegative, endMs: nonnegative }) }, ['type', 'assetId'])] },
|
|
77
|
+
StepProduction: object({ narrationStart: { enum: ['with-animation', 'after-animation'] }, minimumHoldMs: nonnegative, trailingPauseMs: nonnegative, voiceSource: ref('VoiceSource') }, []),
|
|
78
|
+
MediaClip: object({ id, assetId: id, sourceRange: object({ startMs: nonnegative, endMs: nonnegative }), attachment: object({ stepId: id, offsetMs: nonnegative }), volume: { type: 'number', minimum: 0, maximum: 1 }, muted: { type: 'boolean' } }, ['id', 'assetId', 'sourceRange', 'attachment', 'volume']),
|
|
79
|
+
CaptionTiming: object({ narrationIds: ids, sourceFingerprint: { type: 'string', pattern: '^[a-f0-9]{16}$' }, playbackFingerprint: { type: 'string', pattern: '^[a-f0-9]{16}$' }, durationMs: { type: 'integer', minimum: 1 }, segments: { type: 'array', minItems: 1, maxItems: 32, items: object({ id, from: integer, to: integer, startMs: integer, endMs: integer }) } }),
|
|
80
|
+
Production: object({ defaults: ref('StepProduction'), sectionOverrides: record(ref('StepProduction')), stepOverrides: record(ref('StepProduction')), narrationOverrides: record(object({ narrationStart: { enum: ['with-animation', 'after-animation'] } })), cueTimings: record(object({ pauseMs: nonnegative })), captionTimings: record(ref('CaptionTiming')), mediaClips: list(ref('MediaClip')) }, ['defaults', 'stepOverrides', 'mediaClips']),
|
|
81
|
+
TextPoint: object({ blockId: { type: 'string', minLength: 1, maxLength: 205 }, offset: integer }),
|
|
82
|
+
Comment: object({ id, type: tag('comment'), userId: id, createdAt: string, updatedAt: string, deletedAt: string, content: list(ref('RichTextNode')), metadata: ref('JsonObject'), reactions: list(object({ emoji: string, createdAt: string, userIds: ids })) }, ['id', 'type', 'userId', 'createdAt', 'updatedAt', 'reactions']),
|
|
83
|
+
Thread: object({ id, type: tag('thread'), createdAt: string, updatedAt: string, resolved: { type: 'boolean' }, resolvedUpdatedAt: string, resolvedBy: id, metadata: ref('JsonObject'), deletedAt: string, anchor: object({ from: ref('TextPoint'), to: ref('TextPoint') }), comments: list(ref('Comment')) }, ['id', 'type', 'createdAt', 'updatedAt', 'resolved', 'comments']),
|
|
84
|
+
},
|
|
85
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { StepProduction, TeachingDocument, VoiceSource } from './contracts/document.ts'
|
|
2
|
+
|
|
3
|
+
/** Authoring scopes use stable source IDs, independent of compiled numerical step bindings. */
|
|
4
|
+
export type DocumentVoiceScope = { kind: 'document' } | { kind: 'page', sectionId: string } | { kind: 'step', stepId: string } | { kind: 'steps', stepIds: readonly string[] }
|
|
5
|
+
export interface DocumentVoiceOptions { replaceOverrides?: boolean }
|
|
6
|
+
export interface DocumentStepVoice {
|
|
7
|
+
stepId: string
|
|
8
|
+
sectionId: string
|
|
9
|
+
value?: VoiceSource
|
|
10
|
+
inheritedFrom: 'step' | 'page' | 'document' | 'unset'
|
|
11
|
+
}
|
|
12
|
+
export interface DocumentVoiceImpact {
|
|
13
|
+
total: number
|
|
14
|
+
/** Steps whose effective value or inheritance source will change. */
|
|
15
|
+
updated: number
|
|
16
|
+
/** Steps protected by an existing local voiceSource, including none and recording. */
|
|
17
|
+
preserved: number
|
|
18
|
+
unchanged: number
|
|
19
|
+
/** Existing configuration slots, not steps: page + step counts equal none + tts + recording. */
|
|
20
|
+
overrides: { pages: number, steps: number, none: number, tts: number, recording: number }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function page(document: TeachingDocument, id: string) {
|
|
24
|
+
const section = Object.hasOwn(document.sections, id) && document.sections[id]
|
|
25
|
+
if (!section || !document.sectionOrder.includes(id)) throw new Error(`document-voice-page-missing: ${id}`)
|
|
26
|
+
return section
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function step(document: TeachingDocument, id: string) {
|
|
30
|
+
const value = Object.hasOwn(document.steps, id) && document.steps[id]
|
|
31
|
+
if (!value || !page(document, value.sectionId).stepOrder.includes(id)) throw new Error(`document-voice-step-missing: ${id}`)
|
|
32
|
+
return value
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Deduplicated in authored page/step order; every requested ID is checked before any change. */
|
|
36
|
+
export function documentVoiceStepIds(document: TeachingDocument, scope: DocumentVoiceScope): string[] {
|
|
37
|
+
const ordered = () => [...new Set(document.sectionOrder.flatMap(id => page(document, id).stepOrder))]
|
|
38
|
+
switch (scope.kind) {
|
|
39
|
+
case 'document': return ordered()
|
|
40
|
+
case 'page': return [...page(document, scope.sectionId).stepOrder]
|
|
41
|
+
case 'step': step(document, scope.stepId); return [scope.stepId]
|
|
42
|
+
case 'steps': {
|
|
43
|
+
if (!scope.stepIds.length) throw new Error('document-voice-steps-empty')
|
|
44
|
+
scope.stepIds.forEach(id => step(document, id))
|
|
45
|
+
const selected = new Set(scope.stepIds)
|
|
46
|
+
return ordered().filter(id => selected.has(id))
|
|
47
|
+
}
|
|
48
|
+
default: throw new Error('document-voice-scope-invalid')
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** An absent voice remains unset; none and recording retain their canonical identities. */
|
|
53
|
+
export function resolveDocumentStepVoice(document: TeachingDocument, stepId: string): DocumentStepVoice {
|
|
54
|
+
const { sectionId } = step(document, stepId), production = document.production
|
|
55
|
+
const layers = [[production.stepOverrides[stepId]?.voiceSource, 'step'], [production.sectionOverrides?.[sectionId]?.voiceSource, 'page'], [production.defaults.voiceSource, 'document']] as const
|
|
56
|
+
for (const [value, inheritedFrom] of layers) if (value !== undefined) return { stepId, sectionId, value, inheritedFrom }
|
|
57
|
+
return { stepId, sectionId, inheritedFrom: 'unset' }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function same(left: unknown, right: unknown): boolean {
|
|
61
|
+
if (Object.is(left, right)) return true
|
|
62
|
+
if (!left || !right || typeof left !== 'object' || typeof right !== 'object' || Array.isArray(left) !== Array.isArray(right)) return false
|
|
63
|
+
const a = Object.keys(left), b = Object.keys(right)
|
|
64
|
+
return a.length === b.length && a.every(key => Object.hasOwn(right, key) && same((left as Record<string, unknown>)[key], (right as Record<string, unknown>)[key]))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function checkVoice(document: TeachingDocument, source: VoiceSource | null | undefined): void {
|
|
68
|
+
if (source === null || source === undefined) return
|
|
69
|
+
const keys = (value: object, allowed: string[]) => Object.keys(value).every(key => allowed.includes(key))
|
|
70
|
+
if (typeof source !== 'object' || Array.isArray(source)) throw new Error('document-voice-source-invalid')
|
|
71
|
+
if (source.type === 'none' && keys(source, ['type'])) return
|
|
72
|
+
if (source.type === 'tts' && keys(source, ['type', 'voice'])) {
|
|
73
|
+
const voice = source.voice
|
|
74
|
+
if (voice && typeof voice === 'object' && !Array.isArray(voice) && keys(voice, ['voice', 'engine', 'rate', 'style']) && typeof voice.voice === 'string' && voice.voice.length > 0
|
|
75
|
+
&& (voice.engine === undefined || typeof voice.engine === 'string') && (voice.style === undefined || typeof voice.style === 'string')
|
|
76
|
+
&& (voice.rate === undefined || typeof voice.rate === 'number' && Number.isFinite(voice.rate) && voice.rate > 0)) return
|
|
77
|
+
}
|
|
78
|
+
if (source.type === 'recording' && keys(source, ['type', 'assetId']) && typeof source.assetId === 'string') {
|
|
79
|
+
const asset = Object.hasOwn(document.assets, source.assetId) && document.assets[source.assetId]
|
|
80
|
+
if (!asset) throw new Error(`document-voice-asset-missing: ${source.assetId}`)
|
|
81
|
+
if (asset.kind === 'audio' || asset.kind === 'video') return
|
|
82
|
+
throw new Error(`document-voice-asset-kind: ${source.assetId}`)
|
|
83
|
+
}
|
|
84
|
+
throw new Error('document-voice-source-invalid')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function protectedStep(document: TeachingDocument, scope: DocumentVoiceScope, id: string): boolean {
|
|
88
|
+
if (scope.kind === 'step') return false
|
|
89
|
+
if (document.production.stepOverrides[id]?.voiceSource !== undefined) return true
|
|
90
|
+
return scope.kind === 'document' && document.production.sectionOverrides?.[document.steps[id]!.sectionId]?.voiceSource !== undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function settings(previous: StepProduction | undefined, source: VoiceSource | null | undefined): StepProduction | undefined {
|
|
94
|
+
if (same(previous?.voiceSource, source ?? undefined)) return previous
|
|
95
|
+
const next = { ...previous }
|
|
96
|
+
if (source === null || source === undefined) delete next.voiceSource
|
|
97
|
+
else next.voiceSource = structuredClone(source)
|
|
98
|
+
return Object.keys(next).length ? next : undefined
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Page/document writes preserve descendant voice slots unless replaceOverrides is explicit.
|
|
103
|
+
* A single step always edits its own slot; a steps batch preserves existing step slots by default.
|
|
104
|
+
* null/undefined removes only voiceSource under the same scope/protection rules. Timing and opaque
|
|
105
|
+
* sibling fields survive, including when the last voice setting is removed. A no-op retains identity.
|
|
106
|
+
*/
|
|
107
|
+
export function changeDocumentVoice(document: TeachingDocument, scope: DocumentVoiceScope, source: VoiceSource | null | undefined, options: DocumentVoiceOptions = {}): TeachingDocument {
|
|
108
|
+
const ids = documentVoiceStepIds(document, scope)
|
|
109
|
+
checkVoice(document, source)
|
|
110
|
+
const previous = document.production
|
|
111
|
+
let defaults = previous.defaults, sectionOverrides = previous.sectionOverrides, stepOverrides = previous.stepOverrides
|
|
112
|
+
const writeStep = (id: string, value: VoiceSource | null | undefined) => {
|
|
113
|
+
const next = settings(stepOverrides[id], value)
|
|
114
|
+
if (next === stepOverrides[id]) return
|
|
115
|
+
if (stepOverrides === previous.stepOverrides) stepOverrides = { ...stepOverrides }
|
|
116
|
+
if (next) stepOverrides[id] = next
|
|
117
|
+
else delete stepOverrides[id]
|
|
118
|
+
}
|
|
119
|
+
const writePage = (id: string, value: VoiceSource | null | undefined) => {
|
|
120
|
+
const next = settings(sectionOverrides?.[id], value)
|
|
121
|
+
if (next === sectionOverrides?.[id]) return
|
|
122
|
+
if (sectionOverrides === previous.sectionOverrides) sectionOverrides = { ...sectionOverrides }
|
|
123
|
+
if (next) sectionOverrides![id] = next
|
|
124
|
+
else delete sectionOverrides![id]
|
|
125
|
+
}
|
|
126
|
+
if (scope.kind === 'document') {
|
|
127
|
+
defaults = settings(defaults, source) ?? {}
|
|
128
|
+
if (options.replaceOverrides) {
|
|
129
|
+
document.sectionOrder.forEach(id => writePage(id, undefined))
|
|
130
|
+
ids.forEach(id => writeStep(id, undefined))
|
|
131
|
+
}
|
|
132
|
+
} else if (scope.kind === 'page') {
|
|
133
|
+
writePage(scope.sectionId, source)
|
|
134
|
+
if (options.replaceOverrides) ids.forEach(id => writeStep(id, undefined))
|
|
135
|
+
} else for (const id of ids) if (options.replaceOverrides || !protectedStep(document, scope, id)) writeStep(id, source)
|
|
136
|
+
if (defaults === previous.defaults && sectionOverrides === previous.sectionOverrides && stepOverrides === previous.stepOverrides) return document
|
|
137
|
+
return { ...document, production: { ...previous, defaults, ...sectionOverrides !== undefined ? { sectionOverrides } : {}, stepOverrides } }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Override counts include every existing local slot, even a page slot shadowed by a step slot. */
|
|
141
|
+
export function previewDocumentVoiceChange(document: TeachingDocument, scope: DocumentVoiceScope, source: VoiceSource | null | undefined, options: DocumentVoiceOptions = {}): DocumentVoiceImpact {
|
|
142
|
+
const ids = documentVoiceStepIds(document, scope), next = changeDocumentVoice(document, scope, source, options)
|
|
143
|
+
const impact: DocumentVoiceImpact = { total: ids.length, updated: 0, preserved: 0, unchanged: 0, overrides: { pages: 0, steps: 0, none: 0, tts: 0, recording: 0 } }
|
|
144
|
+
const count = (value: VoiceSource | undefined, kind: 'pages' | 'steps') => { if (value) { impact.overrides[kind]++; impact.overrides[value.type]++ } }
|
|
145
|
+
if (scope.kind === 'document') document.sectionOrder.forEach(id => count(document.production.sectionOverrides?.[id]?.voiceSource, 'pages'))
|
|
146
|
+
for (const id of ids) {
|
|
147
|
+
count(document.production.stepOverrides[id]?.voiceSource, 'steps')
|
|
148
|
+
if (!options.replaceOverrides && protectedStep(document, scope, id)) impact.preserved++
|
|
149
|
+
else if (!same(resolveDocumentStepVoice(document, id), resolveDocumentStepVoice(next, id))) impact.updated++
|
|
150
|
+
else impact.unchanged++
|
|
151
|
+
}
|
|
152
|
+
return impact
|
|
153
|
+
}
|
package/src/document.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { Validator } from '@cfworker/json-schema'
|
|
2
|
+
import { DOCUMENT_JSON_SCHEMA } from './document-schema.ts'
|
|
3
|
+
import { isRecord } from './errors.ts'
|
|
4
|
+
import { captionSourceFingerprint, captionTimingProblem, stepCaptionText } from './document-caption-timings.ts'
|
|
5
|
+
import { validateDocumentParameters } from './parameters.ts'
|
|
6
|
+
import type { DocumentIssue, DocumentValidation, RichTextNode, TeachingDocument } from './contracts/document.ts'
|
|
7
|
+
|
|
8
|
+
export { DOCUMENT_JSON_SCHEMA } from './document-schema.ts'
|
|
9
|
+
export { DOCUMENT_ENTRY, DOCUMENT_SCHEMA_VERSION } from './contracts/document.ts'
|
|
10
|
+
|
|
11
|
+
const structural = new Validator(DOCUMENT_JSON_SCHEMA, '2020-12', false)
|
|
12
|
+
const escape = (key: string) => key.replaceAll('~', '~0').replaceAll('/', '~1')
|
|
13
|
+
const at = (path: string, key: string | number) => `${path}/${escape(String(key))}`
|
|
14
|
+
const own = (table: object, key: string) => Object.hasOwn(table, key)
|
|
15
|
+
|
|
16
|
+
/** The same structural and semantic checks serve agent changes, native editing and compilation. */
|
|
17
|
+
export function validateDocument(value: unknown): DocumentValidation {
|
|
18
|
+
const issues: DocumentIssue[] = []
|
|
19
|
+
const issue = (path: string, code: string, message: string, severity: DocumentIssue['severity'] = 'error') => { issues.push({ path, code, message, severity }) }
|
|
20
|
+
const active = new WeakSet<object>()
|
|
21
|
+
let nodes = 0
|
|
22
|
+
const json = (item: unknown, path: string, depth = 0): boolean => {
|
|
23
|
+
if (++nodes > 1_000_000 || depth > 128) { issue(path, 'json.limit', 'The document exceeds the supported JSON size or nesting limit.'); return false }
|
|
24
|
+
if (item === null || typeof item === 'string' || typeof item === 'boolean') return true
|
|
25
|
+
if (typeof item === 'number') { if (Number.isFinite(item)) return true; issue(path, 'json.number', 'JSON numbers must be finite.'); return false }
|
|
26
|
+
if (typeof item !== 'object') { issue(path, 'json.value', 'A document contains only JSON values.'); return false }
|
|
27
|
+
if (active.has(item)) { issue(path, 'json.cycle', 'A JSON value cannot contain an object cycle.'); return false }
|
|
28
|
+
if (!Array.isArray(item) && ![Object.prototype, null].includes(Object.getPrototypeOf(item))) { issue(path, 'json.object', 'A JSON object cannot have a custom object prototype.'); return false }
|
|
29
|
+
active.add(item)
|
|
30
|
+
const valid = Object.entries(item).every(([key, child]) => json(child, at(path, key), depth + 1))
|
|
31
|
+
active.delete(item)
|
|
32
|
+
return valid
|
|
33
|
+
}
|
|
34
|
+
if (!json(value, '')) return { issues }
|
|
35
|
+
let checked
|
|
36
|
+
try { checked = structural.validate(value) }
|
|
37
|
+
catch (error) { issue('', 'schema.invalid', error instanceof Error ? error.message : 'Document schema validation failed.'); return { issues } }
|
|
38
|
+
if (!checked.valid) {
|
|
39
|
+
for (const error of checked.errors.slice(0, 100)) issue(error.instanceLocation.replace(/^#/, ''), `schema.${error.keyword}`, error.error)
|
|
40
|
+
return { issues }
|
|
41
|
+
}
|
|
42
|
+
const doc = value as TeachingDocument
|
|
43
|
+
const lectureRenderer = doc.metadata.renderers?.lecture
|
|
44
|
+
if (isRecord(lectureRenderer) && own(lectureRenderer, 'script')) issue('/metadata/renderers/lecture/script', 'narration.external-source', 'External lecture scripts are not supported. Author narration in lecture.json narrations and bind it through each step narrationIds.')
|
|
45
|
+
const titleTargets = new Map(Object.keys(doc.sections).map(id => [`page:${id}`, id]))
|
|
46
|
+
const ids = new Map<string, string>()
|
|
47
|
+
const identify = (id: string, path: string) => {
|
|
48
|
+
const section = titleTargets.get(id)
|
|
49
|
+
if (section !== undefined) issue(path, 'id.reserved', `ID ${id} is reserved for the title of section ${section}.`)
|
|
50
|
+
const prior = ids.get(id)
|
|
51
|
+
if (prior !== undefined) issue(path, 'id.duplicate', `ID ${id} is already used at ${prior}.`)
|
|
52
|
+
else ids.set(id, path)
|
|
53
|
+
}
|
|
54
|
+
identify(doc.id, '/id')
|
|
55
|
+
for (const name of ['sections', 'blocks', 'steps', 'narrations', 'assets', 'definitions'] as const) {
|
|
56
|
+
for (const [key, entity] of Object.entries(doc[name])) {
|
|
57
|
+
const path = at(`/${name}`, key)
|
|
58
|
+
if (entity.id !== key) issue(`${path}/id`, 'id.key', 'An entity ID must match its map key.')
|
|
59
|
+
identify(entity.id, `${path}/id`)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const reference = (table: object, id: string, path: string, kind: string): boolean => {
|
|
63
|
+
if (own(table, id)) return true
|
|
64
|
+
issue(path, 'reference.missing', `Unknown ${kind} ${id}.`)
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
for (const [index, id] of doc.sectionOrder.entries()) reference(doc.sections, id, `/sectionOrder/${index}`, 'section')
|
|
68
|
+
for (const id of Object.keys(doc.sections)) if (!doc.sectionOrder.includes(id)) issue(at('/sections', id), 'section.order', 'Every section must appear once in sectionOrder.')
|
|
69
|
+
const parents = new Map<string, string>()
|
|
70
|
+
const sectionOf = new Map<string, string>()
|
|
71
|
+
const visiting = new Set<string>()
|
|
72
|
+
const visit = (id: string, path: string, section: string) => {
|
|
73
|
+
if (!reference(doc.blocks, id, path, 'block')) return
|
|
74
|
+
if (visiting.has(id)) { issue(path, 'block.cycle', 'Block ownership cannot contain a cycle.'); return }
|
|
75
|
+
if (parents.has(id)) { issue(path, 'block.parent', `Block ${id} already belongs to ${parents.get(id)}.`); return }
|
|
76
|
+
parents.set(id, path); sectionOf.set(id, section); visiting.add(id)
|
|
77
|
+
const block = doc.blocks[id]!
|
|
78
|
+
if (block.type === 'container' || block.type === 'component') for (const [index, child] of (block.children ?? []).entries()) visit(child, at(at('/blocks', id), `children`) + `/${index}`, section)
|
|
79
|
+
visiting.delete(id)
|
|
80
|
+
}
|
|
81
|
+
for (const [id, section] of Object.entries(doc.sections)) for (const [index, child] of section.children.entries()) visit(child, at('/sections', id) + `/children/${index}`, id)
|
|
82
|
+
for (const id of Object.keys(doc.blocks)) if (!parents.has(id)) issue(at('/blocks', id), 'block.orphan', 'Every content block must have one section or container parent.')
|
|
83
|
+
const richIds = new Map<string, Set<string>>()
|
|
84
|
+
const rich = (node: RichTextNode | { id: string, type: string, children?: unknown[] }, path: string, collected: Set<string>) => {
|
|
85
|
+
identify(node.id, `${path}/id`); collected.add(node.id)
|
|
86
|
+
if (node.type === 'table') {
|
|
87
|
+
const table = node as Extract<RichTextNode, { type: 'table' }>
|
|
88
|
+
if ((table.headerRows ?? 0) > table.children.length) issue(`${path}/headerRows`, 'table.header', 'Header rows cannot exceed table rows.')
|
|
89
|
+
let spans: { from: number, to: number, until: number }[] = []
|
|
90
|
+
const widths = table.children.map((row, rowIndex) => {
|
|
91
|
+
spans = spans.filter(span => span.until > rowIndex).sort((a, b) => a.from - b.from)
|
|
92
|
+
let column = 0, index = 0
|
|
93
|
+
const added: typeof spans = []
|
|
94
|
+
for (const [cellIndex, cell] of row.children.entries()) {
|
|
95
|
+
const cellPath = `${path}/children/${rowIndex}/children/${cellIndex}`
|
|
96
|
+
const colspan = cell.colspan ?? 1, rowspan = cell.rowspan ?? 1
|
|
97
|
+
if (!Number.isSafeInteger(colspan) || !Number.isSafeInteger(rowspan)) {
|
|
98
|
+
issue(cellPath, 'table.span', 'Cell spans must be safe positive integers.')
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
101
|
+
while (spans[index] && spans[index]!.from <= column) { column = Math.max(column, spans[index]!.to); index++ }
|
|
102
|
+
const end = column + colspan
|
|
103
|
+
const overlap = spans[index] && spans[index]!.from < end
|
|
104
|
+
if (overlap) issue(cellPath, 'table.overlap', 'A cell cannot overlap a cell spanning from an earlier row.')
|
|
105
|
+
if (rowIndex + rowspan > table.children.length) issue(`${cellPath}/rowspan`, 'table.span', 'A row span cannot extend beyond the table rows.')
|
|
106
|
+
if (!Number.isSafeInteger(end) || table.columnWidths && end > table.columnWidths.length) issue(`${cellPath}/colspan`, 'table.span', 'A cell span cannot extend beyond the declared table columns.')
|
|
107
|
+
if (rowspan > 1 && !overlap) added.push({ from: column, to: end, until: rowIndex + rowspan })
|
|
108
|
+
column = end
|
|
109
|
+
}
|
|
110
|
+
const width = spans.reduce((width, span) => Math.max(width, span.to), column)
|
|
111
|
+
spans.push(...added)
|
|
112
|
+
return width
|
|
113
|
+
})
|
|
114
|
+
if (table.headerColumns !== undefined && widths.some(width => table.headerColumns! > width)) issue(`${path}/headerColumns`, 'table.header', 'Header columns cannot exceed table columns.')
|
|
115
|
+
if (table.columnWidths && widths.some(width => width !== table.columnWidths!.length)) issue(`${path}/columnWidths`, 'table.widths', 'Column widths must match the logical table columns.')
|
|
116
|
+
}
|
|
117
|
+
if ('children' in node && Array.isArray(node.children)) for (const [index, child] of node.children.entries()) if (isRecord(child) && typeof child.id === 'string') rich(child as unknown as RichTextNode, `${path}/children/${index}`, collected)
|
|
118
|
+
if (node.type === 'extension' && 'fallback' in node && Array.isArray(node.fallback)) node.fallback.forEach((child, index) => rich(child as RichTextNode, `${path}/fallback/${index}`, collected))
|
|
119
|
+
}
|
|
120
|
+
const schema = (value: JsonSchemaLike, parameters: unknown, path: string) => {
|
|
121
|
+
const result = validateDocumentParameters(value, parameters)
|
|
122
|
+
for (const error of result.issues) issue(path + error.path, result.available ? 'parameters.schema' : 'parameters.schema-unavailable', error.message)
|
|
123
|
+
}
|
|
124
|
+
for (const [id, block] of Object.entries(doc.blocks)) {
|
|
125
|
+
const path = at('/blocks', id)
|
|
126
|
+
if (block.type === 'text') {
|
|
127
|
+
const collected = new Set<string>(); richIds.set(id, collected)
|
|
128
|
+
block.content.forEach((node, index) => rich(node, `${path}/content/${index}`, collected))
|
|
129
|
+
for (const [index, item] of (block.reveal?.order ?? []).entries()) if (!collected.has(item)) issue(`${path}/reveal/order/${index}`, 'reveal.target', 'Reveal items must name rich-text nodes of this block.')
|
|
130
|
+
} else if (block.type === 'image') {
|
|
131
|
+
if (block.assetId !== undefined && reference(doc.assets, block.assetId, `${path}/assetId`, 'asset') && doc.assets[block.assetId]!.kind !== 'image') issue(`${path}/assetId`, 'asset.kind', 'An image block requires an image asset.')
|
|
132
|
+
} else if (block.type === 'component') {
|
|
133
|
+
if (reference(doc.definitions, block.definitionId, `${path}/definitionId`, 'definition')) schema(doc.definitions[block.definitionId]!.parameterSchema, block.parameters, `${path}/parameters`)
|
|
134
|
+
} else if (block.type === 'code') {
|
|
135
|
+
if (block.highlight && block.origin?.highlightRegions) issue(`${path}/highlight`, 'code.highlight-owner', 'Choose authored line numbers or tracked source regions, not both.')
|
|
136
|
+
for (const [field, values] of [['highlight', block.highlight], ['origin/highlightRegions', block.origin?.highlightRegions]] as const) for (const step of Object.keys(values ?? {})) {
|
|
137
|
+
if (reference(doc.steps, step, `${path}/${field}/${escape(step)}`, 'step') && doc.steps[step]!.sectionId !== sectionOf.get(id)) issue(`${path}/${field}/${escape(step)}`, 'code.highlight-section', 'A code highlight must belong to a step of its own section.')
|
|
138
|
+
}
|
|
139
|
+
for (const [index, variant] of (block.variants ?? []).entries()) identify(variant.id, `${path}/variants/${index}/id`)
|
|
140
|
+
} else if (block.type === 'container') {
|
|
141
|
+
if (block.properties.columnWidths && block.properties.columnWidths.length !== block.children.length) issue(`${path}/properties/columnWidths`, 'container.columns', 'Column widths must match the child count.')
|
|
142
|
+
if (block.layout === 'tabs' && block.properties.tabTitles && block.properties.tabTitles.length !== block.children.length) issue(`${path}/properties/tabTitles`, 'container.tabs', 'Tab titles must match the child count.')
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
for (const [id, definition] of Object.entries(doc.definitions)) {
|
|
146
|
+
const states = new Set<string>()
|
|
147
|
+
for (const [index, state] of definition.states.entries()) {
|
|
148
|
+
if (states.has(state.id)) issue(at('/definitions', id) + `/states/${index}/id`, 'state.duplicate', 'Definition state IDs must be unique within the definition.')
|
|
149
|
+
states.add(state.id)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const effectiveVisuals = (id: string, visiting = new Set<string>()): Map<string, TeachingDocument['steps'][string]['visuals'][number]> => {
|
|
153
|
+
if (visiting.has(id) || !own(doc.steps, id)) return new Map()
|
|
154
|
+
visiting.add(id)
|
|
155
|
+
const step = doc.steps[id]!
|
|
156
|
+
const result = step.inherits ? effectiveVisuals(step.inherits, visiting) : new Map<string, TeachingDocument['steps'][string]['visuals'][number]>()
|
|
157
|
+
for (const visual of step.visuals) result.set(visual.blockId, { ...result.get(visual.blockId), ...visual, parameters: { ...result.get(visual.blockId)?.parameters, ...visual.parameters } })
|
|
158
|
+
return result
|
|
159
|
+
}
|
|
160
|
+
const orderedSteps = new Set<string>()
|
|
161
|
+
for (const [id, section] of Object.entries(doc.sections)) for (const [index, stepId] of section.stepOrder.entries()) {
|
|
162
|
+
const path = at('/sections', id) + `/stepOrder/${index}`
|
|
163
|
+
if (orderedSteps.has(stepId)) issue(path, 'step.owner', 'A step belongs to one section order.')
|
|
164
|
+
orderedSteps.add(stepId)
|
|
165
|
+
if (reference(doc.steps, stepId, path, 'step') && doc.steps[stepId]!.sectionId !== id) issue(path, 'step.section', 'A step must belong to the section that orders it.')
|
|
166
|
+
}
|
|
167
|
+
for (const [id, step] of Object.entries(doc.steps)) {
|
|
168
|
+
const path = at('/steps', id)
|
|
169
|
+
if (!orderedSteps.has(id)) issue(path, 'step.orphan', 'Every step must appear in its section stepOrder.')
|
|
170
|
+
reference(doc.sections, step.sectionId, `${path}/sectionId`, 'section')
|
|
171
|
+
const visuals = new Set<string>()
|
|
172
|
+
for (const [index, visual] of step.visuals.entries()) {
|
|
173
|
+
const location = `${path}/visuals/${index}`
|
|
174
|
+
if (visuals.has(visual.blockId)) issue(`${location}/blockId`, 'visual.duplicate', 'A step has one override per block.')
|
|
175
|
+
visuals.add(visual.blockId)
|
|
176
|
+
if (!reference(doc.blocks, visual.blockId, `${location}/blockId`, 'block')) continue
|
|
177
|
+
if (sectionOf.get(visual.blockId) !== step.sectionId) issue(`${location}/blockId`, 'visual.section', 'Visual state must target a block owned by the same section.')
|
|
178
|
+
const actual = effectiveVisuals(id).get(visual.blockId)!
|
|
179
|
+
const block = doc.blocks[visual.blockId]!
|
|
180
|
+
if (block.type === 'component') {
|
|
181
|
+
const definition = doc.definitions[block.definitionId]
|
|
182
|
+
if (definition) {
|
|
183
|
+
if (actual.parameters) schema(definition.parameterSchema, { ...block.parameters, ...actual.parameters }, `${location}/parameters`)
|
|
184
|
+
const state = actual.state === undefined ? undefined : definition.states.find(state => state.id === actual.state)
|
|
185
|
+
if (actual.state !== undefined && !state) issue(`${location}/state`, 'state.missing', 'The component definition does not declare this state.')
|
|
186
|
+
if (state?.parameterSchema) schema(state.parameterSchema, { ...block.parameters, ...actual.parameters }, `${location}/parameters`)
|
|
187
|
+
}
|
|
188
|
+
} else if (visual.state !== undefined) issue(`${location}/state`, 'state.unsupported', 'Named animation states require a component definition.')
|
|
189
|
+
}
|
|
190
|
+
for (const [index, narration] of step.narrationIds.entries()) reference(doc.narrations, narration, `${path}/narrationIds/${index}`, 'narration')
|
|
191
|
+
const seen = new Set<string>([id]); let inherited = step.inherits
|
|
192
|
+
while (inherited !== undefined) {
|
|
193
|
+
if (seen.has(inherited)) { issue(`${path}/inherits`, 'step.cycle', 'Step inheritance cannot contain a cycle.'); break }
|
|
194
|
+
seen.add(inherited)
|
|
195
|
+
if (!reference(doc.steps, inherited, `${path}/inherits`, 'step')) break
|
|
196
|
+
if (doc.steps[inherited]!.sectionId !== step.sectionId) { issue(`${path}/inherits`, 'step.section', 'Inherited steps must belong to the same section.'); break }
|
|
197
|
+
inherited = doc.steps[inherited]!.inherits
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const cues = new Set<string>()
|
|
201
|
+
for (const [id, narration] of Object.entries(doc.narrations)) if (narration.provenance) {
|
|
202
|
+
const path = at('/narrations', id) + '/provenance/assetId'
|
|
203
|
+
if (reference(doc.assets, narration.provenance.assetId, path, 'asset') && doc.assets[narration.provenance.assetId]!.kind !== 'audio') issue(path, 'asset.kind', 'Transcription provenance requires the original audio asset.')
|
|
204
|
+
}
|
|
205
|
+
for (const [id, narration] of Object.entries(doc.narrations)) for (const [index, node] of narration.content.entries()) if (node.type === 'cue') {
|
|
206
|
+
const path = at('/narrations', id) + `/content/${index}`
|
|
207
|
+
identify(node.id, `${path}/id`); cues.add(node.id)
|
|
208
|
+
if (node.target) reference(doc.blocks, node.target.blockId, `${path}/target/blockId`, 'block')
|
|
209
|
+
if (node.parameters && ['pauseMs', 'durationMs', 'delayMs'].some(key => own(node.parameters!, key))) issue(`${path}/parameters`, 'production.owner', 'Cue timing belongs to production.cueTimings.')
|
|
210
|
+
}
|
|
211
|
+
const voice = (production: TeachingDocument['production']['defaults'], path: string) => {
|
|
212
|
+
const source = production.voiceSource
|
|
213
|
+
if (source?.type === 'recording' && reference(doc.assets, source.assetId, `${path}/voiceSource/assetId`, 'asset') && !['audio', 'video'].includes(doc.assets[source.assetId]!.kind)) issue(`${path}/voiceSource/assetId`, 'asset.kind', 'Recorded speech requires audio or video.')
|
|
214
|
+
if (source?.type === 'recording' && source.sourceRange && source.sourceRange.endMs <= source.sourceRange.startMs) issue(`${path}/voiceSource/sourceRange`, 'media.range', 'A recording range must end after it starts.')
|
|
215
|
+
}
|
|
216
|
+
voice(doc.production.defaults, '/production/defaults')
|
|
217
|
+
for (const [id, override] of Object.entries(doc.production.sectionOverrides ?? {})) { reference(doc.sections, id, at('/production/sectionOverrides', id), 'section'); voice(override, at('/production/sectionOverrides', id)) }
|
|
218
|
+
for (const [id, override] of Object.entries(doc.production.stepOverrides)) { reference(doc.steps, id, at('/production/stepOverrides', id), 'step'); voice(override, at('/production/stepOverrides', id)) }
|
|
219
|
+
for (const id of Object.keys(doc.production.narrationOverrides ?? {})) reference(doc.narrations, id, at('/production/narrationOverrides', id), 'narration')
|
|
220
|
+
for (const id of Object.keys(doc.production.cueTimings ?? {})) if (!cues.has(id)) issue(at('/production/cueTimings', id), 'cue.missing', 'Cue timing must reference an authored narration cue.')
|
|
221
|
+
for (const [stepId, timing] of Object.entries(doc.production.captionTimings ?? {})) {
|
|
222
|
+
const step = doc.steps[stepId]
|
|
223
|
+
const stale = !step || JSON.stringify(step.narrationIds) !== JSON.stringify(timing.narrationIds) || captionSourceFingerprint(doc, stepId) !== timing.sourceFingerprint
|
|
224
|
+
const problem = stale ? 'caption-source-changed' : captionTimingProblem(timing, stepCaptionText(doc, stepId))
|
|
225
|
+
if (problem) issue(at('/production/captionTimings', stepId), 'caption.timing-stale', 'Manual caption timing requires review: ' + problem, 'warning')
|
|
226
|
+
}
|
|
227
|
+
for (const [index, clip] of doc.production.mediaClips.entries()) {
|
|
228
|
+
const path = `/production/mediaClips/${index}`
|
|
229
|
+
identify(clip.id, `${path}/id`)
|
|
230
|
+
if (reference(doc.assets, clip.assetId, `${path}/assetId`, 'asset') && !['audio', 'video'].includes(doc.assets[clip.assetId]!.kind)) issue(`${path}/assetId`, 'asset.kind', 'A media clip requires audio or video.')
|
|
231
|
+
reference(doc.steps, clip.attachment.stepId, `${path}/attachment/stepId`, 'step')
|
|
232
|
+
if (clip.sourceRange.endMs <= clip.sourceRange.startMs) issue(`${path}/sourceRange`, 'media.range', 'A media range must end after it starts.')
|
|
233
|
+
}
|
|
234
|
+
const lecture = doc.metadata.renderers?.lecture
|
|
235
|
+
if (lecture && ['voice', 'gap', 'script'].some(key => own(lecture, key))) issue('/metadata/renderers/lecture', 'production.owner', 'Voice, narration sources and playback timing belong only to production and narration entities.')
|
|
236
|
+
const textTargets = new Set([...richIds.values()].flatMap(values => [...values]))
|
|
237
|
+
for (const id of doc.sectionOrder) textTargets.add(`page:${id}`)
|
|
238
|
+
for (const [id, block] of Object.entries(doc.blocks)) if (block.type === 'code' && block.value !== undefined) textTargets.add(id)
|
|
239
|
+
for (const [id, thread] of Object.entries(doc.comments ?? {})) {
|
|
240
|
+
const path = at('/comments', id)
|
|
241
|
+
if (id !== thread.id) issue(`${path}/id`, 'id.key', 'A thread ID must match its map key.')
|
|
242
|
+
identify(thread.id, `${path}/id`)
|
|
243
|
+
for (const edge of ['from', 'to'] as const) if (thread.anchor && !textTargets.has(thread.anchor[edge].blockId)) issue(`${path}/anchor/${edge}/blockId`, 'comment.target', 'A comment anchor must name a stable rich-text node or section title.')
|
|
244
|
+
for (const [index, comment] of thread.comments.entries()) {
|
|
245
|
+
identify(comment.id, `${path}/comments/${index}/id`)
|
|
246
|
+
comment.content?.forEach((node, child) => rich(node, `${path}/comments/${index}/content/${child}`, new Set()))
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return issues.some(issue => issue.severity === 'error') ? { issues } : { document: doc, issues }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
type JsonSchemaLike = TeachingDocument['definitions'][string]['parameterSchema']
|
package/src/durations.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** A duration as authors write one (`{cue}` `pause`, `:durations:`): `1.2` and `1.2s` are seconds, `800ms` milliseconds; in milliseconds. */
|
|
2
|
+
export function parseDuration(value: string): number | undefined {
|
|
3
|
+
const match = /^(\d+(?:\.\d+)?)\s*(ms|s)?$/.exec(value.trim())
|
|
4
|
+
if (!match) return undefined
|
|
5
|
+
const n = Number(match[1])
|
|
6
|
+
return Math.round(match[2] === 'ms' ? n : n * 1000)
|
|
7
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -5,3 +5,11 @@ export function errorMessage(error: unknown): string {
|
|
|
5
5
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
6
6
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
7
7
|
}
|
|
8
|
+
|
|
9
|
+
/** What a preview needs (the viewer app, the document's code) is still being built: ask again later; not a failure. */
|
|
10
|
+
export class PreviewPreparing extends Error {
|
|
11
|
+
constructor(message = 'preview-preparing') {
|
|
12
|
+
super(message)
|
|
13
|
+
this.name = 'PreviewPreparing'
|
|
14
|
+
}
|
|
15
|
+
}
|
package/src/execution.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { RunResult } from './contracts.ts'
|
|
1
|
+
import type { RunResult } from './contracts/execution.ts'
|
|
2
2
|
|
|
3
3
|
const ALIASES: Record<string, string> = {
|
|
4
4
|
py: 'python',
|
|
@@ -19,3 +19,7 @@ export function failedRun(executorId: string, stderr: string, durationMs = 0): R
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
export const CREDENTIAL_VARIABLE = /_(?:KEY|TOKEN|SECRET|PASSWORD|APPID)$/i
|
|
22
|
+
|
|
23
|
+
export function environmentExecutor(name: string): string {
|
|
24
|
+
return `presmium.env.${name}`
|
|
25
|
+
}
|