@lincyaw/kernel 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.
Files changed (119) hide show
  1. package/README.md +7 -7
  2. package/package.json +6 -2
  3. package/src/access.ts +18 -0
  4. package/src/anchors.ts +5 -1
  5. package/src/authoring.ts +56 -0
  6. package/src/blocks.ts +0 -8
  7. package/src/component-code.ts +6 -0
  8. package/src/contracts/agent.ts +5 -82
  9. package/src/contracts/anchors.ts +46 -0
  10. package/src/contracts/authoring-actions.ts +60 -0
  11. package/src/contracts/authoring-clients.ts +213 -0
  12. package/src/contracts/authoring.ts +180 -0
  13. package/src/contracts/checks.ts +29 -0
  14. package/src/contracts/classroom.ts +49 -0
  15. package/src/contracts/diagnostics.ts +37 -0
  16. package/src/contracts/document-audio.ts +150 -0
  17. package/src/contracts/document-comments.ts +31 -0
  18. package/src/contracts/document-history.ts +63 -0
  19. package/src/contracts/document-image.ts +32 -0
  20. package/src/contracts/document-reads.ts +57 -0
  21. package/src/contracts/document-voice.ts +44 -0
  22. package/src/contracts/document.ts +240 -0
  23. package/src/contracts/execution.ts +4 -5
  24. package/src/contracts/locale.ts +3 -0
  25. package/src/contracts/offline.ts +2 -0
  26. package/src/contracts/plugin.ts +183 -0
  27. package/src/contracts/presenter-panels.ts +17 -0
  28. package/src/contracts/preview.ts +305 -310
  29. package/src/contracts/production-assets.ts +61 -0
  30. package/src/contracts/production.ts +109 -0
  31. package/src/contracts/projects.ts +59 -0
  32. package/src/contracts/server.ts +1 -51
  33. package/src/contracts/speech-settings.ts +16 -0
  34. package/src/contracts/speech.ts +82 -70
  35. package/src/contracts/steps.ts +15 -0
  36. package/src/contracts/transcription-adoption.ts +79 -0
  37. package/src/contracts/transcription.ts +112 -0
  38. package/src/contracts/voice.ts +97 -0
  39. package/src/contracts/voiceover-execution.ts +68 -0
  40. package/src/contracts/workspace.ts +458 -0
  41. package/src/contracts.ts +28 -278
  42. package/src/diagnostics.ts +7 -0
  43. package/src/document-authoring-actions.ts +47 -0
  44. package/src/document-caption-timings.ts +64 -0
  45. package/src/document-colors.ts +25 -0
  46. package/src/document-comments.ts +118 -0
  47. package/src/document-components.ts +251 -0
  48. package/src/document-operations.ts +132 -0
  49. package/src/document-pages.ts +215 -0
  50. package/src/document-reads.ts +81 -0
  51. package/src/document-schema.ts +85 -0
  52. package/src/document-voices.ts +153 -0
  53. package/src/document.ts +252 -0
  54. package/src/durations.ts +7 -0
  55. package/src/errors.ts +8 -0
  56. package/src/execution.ts +5 -1
  57. package/src/fingerprint.ts +1 -1
  58. package/src/follow.ts +26 -30
  59. package/src/git.ts +1 -1
  60. package/src/index.ts +66 -13
  61. package/src/ir.ts +54 -5
  62. package/src/jupyter-kernel.ts +9 -0
  63. package/src/kernel.ts +38 -25
  64. package/src/keyboard.ts +43 -0
  65. package/src/locale.ts +13 -0
  66. package/src/lock.ts +4 -8
  67. package/src/logging.ts +1 -1
  68. package/src/{markdown.ts → lower-myst.ts} +2 -1
  69. package/src/node/assets.ts +23 -0
  70. package/src/node/authoring-actions.ts +50 -0
  71. package/src/node/authoring-clients.ts +14 -0
  72. package/src/node/document-audio.ts +22 -0
  73. package/src/node/document-history.ts +11 -0
  74. package/src/node/document-image.ts +8 -0
  75. package/src/node/document-reads.ts +10 -0
  76. package/src/node/document-voice.ts +7 -0
  77. package/src/node/ffmpeg.ts +29 -0
  78. package/src/node/file-version.ts +6 -0
  79. package/src/node/fonts.ts +22 -0
  80. package/src/node/index.ts +29 -133
  81. package/src/node/installed-packages.ts +11 -0
  82. package/src/node/lines.ts +24 -0
  83. package/src/node/locale.ts +7 -0
  84. package/src/node/network.ts +11 -0
  85. package/src/node/operator-speech.ts +91 -0
  86. package/src/node/packages.ts +74 -0
  87. package/src/node/paths.ts +7 -0
  88. package/src/node/projects.ts +7 -0
  89. package/src/node/resources.ts +86 -0
  90. package/src/node/revision.ts +6 -0
  91. package/src/node/run-directory.ts +70 -0
  92. package/src/node/transcription-adoption.ts +9 -0
  93. package/src/node/transcription.ts +10 -0
  94. package/src/node/voiceover-routes.ts +73 -0
  95. package/src/node/voiceover.ts +199 -0
  96. package/src/page-source.ts +359 -0
  97. package/src/parameters.ts +81 -0
  98. package/src/preview-edit.ts +18 -0
  99. package/src/preview-events.ts +57 -0
  100. package/src/preview-frame.ts +1234 -0
  101. package/src/props.ts +17 -0
  102. package/src/push-stream.ts +67 -0
  103. package/src/reading.ts +12 -7
  104. package/src/recording.ts +38 -0
  105. package/src/renderer-components.ts +30 -0
  106. package/src/responses.ts +49 -0
  107. package/src/run-outputs.ts +15 -0
  108. package/src/runner.py +3 -3
  109. package/src/services.ts +6 -6
  110. package/src/slides.ts +36 -0
  111. package/src/speech-words.ts +76 -0
  112. package/src/speech.ts +13 -0
  113. package/src/step-keys.ts +38 -0
  114. package/src/steps.ts +7 -0
  115. package/src/transcription-adoption.ts +105 -0
  116. package/src/uri-decoding.ts +8 -0
  117. package/src/urls.ts +13 -0
  118. package/src/views.ts +47 -0
  119. package/src/voice.ts +86 -0
package/src/props.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { isRecord } from './errors.ts'
2
+
3
+ /** Sets the value at a dotted path, copying the objects on the way. */
4
+ export function withProp(props: Record<string, unknown>, path: string, value: unknown): Record<string, unknown> {
5
+ const [key = '', ...rest] = path.split('.')
6
+ if (rest.length === 0) return { ...props, [key]: value }
7
+ const inner = props[key]
8
+ return { ...props, [key]: withProp(isRecord(inner) ? inner : {}, rest.join('.'), value) }
9
+ }
10
+
11
+ /** Every string in a component's props, nested arrays and objects included. */
12
+ export function propText(value: unknown): string[] {
13
+ if (typeof value === 'string') return [value]
14
+ if (Array.isArray(value)) return value.flatMap(propText)
15
+ if (value && typeof value === 'object') return Object.values(value).flatMap(propText)
16
+ return []
17
+ }
@@ -0,0 +1,67 @@
1
+ export interface PushStream<T> extends AsyncIterable<T> {
2
+ end(): void
3
+ }
4
+
5
+ export interface PushStreamOptions {
6
+ signal?: AbortSignal
7
+ /** Keep only the newest value the reader has not taken, for streams that say "something changed". */
8
+ latestOnly?: boolean
9
+ }
10
+
11
+ /**
12
+ * An async iterable of the values a subscription pushes. `subscribe` runs at once (unless the signal has
13
+ * aborted) and returns its unsubscribe, which runs once when the stream ends: on `end()`, on the signal,
14
+ * or when the reader stops.
15
+ */
16
+ export function pushStream<T>(subscribe: (push: (value: T) => void) => () => void, options: PushStreamOptions = {}): PushStream<T> {
17
+ const { signal } = options
18
+ const queue: T[] = []
19
+ let wake: (() => void) | undefined
20
+ let ended = false
21
+ let unsubscribe: (() => void) | undefined
22
+ const end = () => {
23
+ if (ended) return
24
+ ended = true
25
+ signal?.removeEventListener('abort', end)
26
+ try { unsubscribe?.() }
27
+ finally { wake?.() }
28
+ }
29
+ if (signal?.aborted) {
30
+ ended = true
31
+ } else {
32
+ unsubscribe = subscribe((value) => {
33
+ if (ended) return
34
+ if (options.latestOnly) queue.splice(0, queue.length, value)
35
+ else queue.push(value)
36
+ wake?.()
37
+ })
38
+ if (signal?.aborted) end()
39
+ else signal?.addEventListener('abort', end, { once: true })
40
+ }
41
+ return {
42
+ end,
43
+ [Symbol.asyncIterator](): AsyncIterableIterator<T> {
44
+ const iterator = (async function* () {
45
+ try {
46
+ while (!ended) {
47
+ if (queue.length > 0) {
48
+ yield queue.shift()!
49
+ continue
50
+ }
51
+ await new Promise<void>(resolve => wake = resolve)
52
+ wake = undefined
53
+ }
54
+ } finally {
55
+ end()
56
+ }
57
+ })()
58
+ // Wake a pending next() before the generator queues return() or throw().
59
+ return {
60
+ [Symbol.asyncIterator]() { return this },
61
+ next: value => iterator.next(value),
62
+ return(value) { end(); return iterator.return(value) },
63
+ throw(error) { end(); return iterator.throw(error) },
64
+ }
65
+ },
66
+ }
67
+ }
package/src/reading.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { irBlocks } from './blocks.ts'
2
2
  import type { IRBlock, IRDocument, IRSection, MdastNode } from './ir.ts'
3
+ import { propText } from './props.ts'
3
4
 
4
5
  export interface Chapter {
5
6
  title: string
@@ -35,13 +36,24 @@ export function chapters(doc: IRDocument): Chapter[] {
35
36
  }
36
37
 
37
38
  export function blockVisible(block: IRBlock, step: number): boolean {
39
+ const state = blockStepState(block, step)
40
+ if (state) return state.visible
38
41
  return step >= block.at && (block.until === undefined || step < block.until)
39
42
  }
40
43
 
41
44
  export function blockState(block: IRBlock, step: number): number {
45
+ const state = blockStepState(block, step)
46
+ if (state) return state.localStep
42
47
  return Math.min(Math.max(step - block.at, 0), block.span ?? 0)
43
48
  }
44
49
 
50
+ export function blockStepState(block: IRBlock, step: number): NonNullable<IRBlock['stepStates']>[number] | undefined {
51
+ const states = block.stepStates
52
+ if (!states?.length) return undefined
53
+ const index = step === Number.POSITIVE_INFINITY ? states.length - 1 : Number.isFinite(step) ? Math.max(0, Math.min(Math.trunc(step), states.length - 1)) : 0
54
+ return states[index]
55
+ }
56
+
45
57
  export function narrationAt(section: IRSection, step: number): IRBlock[] {
46
58
  return section.blocks.filter(block => block.role === 'narration' && block.at === step)
47
59
  }
@@ -63,13 +75,6 @@ export function mdastText(node: MdastNode): string {
63
75
  return BREAKS.has(node.type) ? `${text}\n` : text
64
76
  }
65
77
 
66
- function propText(value: unknown): string[] {
67
- if (typeof value === 'string') return [value]
68
- if (Array.isArray(value)) return value.flatMap(propText)
69
- if (value && typeof value === 'object') return Object.values(value).flatMap(propText)
70
- return []
71
- }
72
-
73
78
  export function blockText(block: IRBlock): string {
74
79
  const { node } = block
75
80
  switch (node.type) {
@@ -0,0 +1,38 @@
1
+ import type { Production, TeachingDocument, TimeRange, VoiceSource } from './contracts/document.ts'
2
+
3
+ /** Resolve only against a real inspected duration; no source bytes or authored text are changed. */
4
+ export function recordingSourceRange(source: Extract<VoiceSource, { type: 'recording' }>, durationMs: number): TimeRange | undefined {
5
+ const range = source.sourceRange ?? { startMs: 0, endMs: durationMs }
6
+ return Number.isFinite(durationMs) && durationMs > 0 && Number.isFinite(range.startMs) && Number.isFinite(range.endMs)
7
+ && range.startMs >= 0 && range.endMs > range.startMs && range.endMs <= durationMs ? { ...range } : undefined
8
+ }
9
+
10
+ /** Assets used by surviving steps; inherited recordings are resolved before collecting references. */
11
+ export function productionAssetIds(production: Production | undefined, sections: readonly { id: string, stepIds?: readonly string[] }[]): string[] {
12
+ if (!production) return []
13
+ const ids = new Set(production.mediaClips.map(clip => clip.assetId))
14
+ for (const section of sections) for (const stepId of section.stepIds ?? []) {
15
+ const source = production.stepOverrides[stepId]?.voiceSource ?? production.sectionOverrides?.[section.id]?.voiceSource ?? production.defaults.voiceSource
16
+ if (source?.type === 'recording') ids.add(source.assetId)
17
+ }
18
+ return [...ids]
19
+ }
20
+
21
+ /** Whole-step recordings have no inferred word clock. This check does not change authored atoms. */
22
+ export function recordingStepIssue(document: TeachingDocument, stepId: string): 'recording-cue-unsupported' | 'recording-start-conflict' | undefined {
23
+ const step = document.steps[stepId]
24
+ if (!step) return undefined
25
+ let text = false, mode: string | undefined
26
+ for (const id of step.narrationIds) {
27
+ const value = { ...document.production.defaults, ...document.production.sectionOverrides?.[step.sectionId], ...document.production.narrationOverrides?.[id], ...document.production.stepOverrides[stepId] }
28
+ const next = value.narrationStart ?? 'with-animation'
29
+ if (mode !== undefined && mode !== next) return 'recording-start-conflict'
30
+ mode = next
31
+ for (const node of document.narrations[id]!.content) {
32
+ if (node.type === 'cue') {
33
+ const action = node.action.trim().split(/\s/)[0]
34
+ if (text || action === 'next' || action === 'pause') return 'recording-cue-unsupported'
35
+ } else if ((node.type === 'text' ? node.text : node.display).trim()) text = true
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,30 @@
1
+ import type { BoundRendererExtension, ComponentNode, IRBlock } from './ir.ts'
2
+ import { withProp } from './props.ts'
3
+
4
+ /** The package of the web renderer when a document names none. */
5
+ export const WEB_RENDERER_PACKAGE = '@lincyaw/web'
6
+
7
+ /** The renderer extension's renderer of the component: its own plugin's first, or any plugin's for a document's own component. */
8
+ export function findComponentRenderer(node: ComponentNode, extensions: readonly BoundRendererExtension[]): ((node: ComponentNode, block: IRBlock) => unknown) | undefined {
9
+ const owner = extensions.find(e => e.plugin === node.plugin && e.components?.[node.name])
10
+ ?? (node.plugin ? undefined : extensions.find(e => e.components?.[node.name]))
11
+ return owner?.components?.[node.name]
12
+ }
13
+
14
+ /** What a renderer gives the component: its props, and the node's `durations` as the prop `durations`, kept once in the IR. */
15
+ export function componentProps(node: ComponentNode): Record<string, unknown> {
16
+ return node.durations ? { ...node.props, durations: node.durations } : node.props
17
+ }
18
+
19
+ /** Whether a renderer extension renders the component (`findComponentRenderer` finds one). */
20
+ export function hasRendererExtension(node: ComponentNode, extensions: readonly BoundRendererExtension[]): boolean {
21
+ return findComponentRenderer(node, extensions) !== undefined
22
+ }
23
+
24
+ /** The component with each prop that `assets` names set to the file's published URL; a file not published keeps its URL. */
25
+ export function withPublishedAssets(node: ComponentNode, publish: (url: string) => string | undefined): ComponentNode {
26
+ if (!node.assets) return node
27
+ let props = node.props
28
+ for (const [path, url] of Object.entries(node.assets)) props = withProp(props, path, publish(url) ?? url)
29
+ return { ...node, props }
30
+ }
@@ -0,0 +1,49 @@
1
+ export function jsonResponse(data: unknown, status = 200, headers: Record<string, string> = {}): Response {
2
+ return new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers } })
3
+ }
4
+
5
+ export function problemResponse(status: number, message: string): Response {
6
+ return jsonResponse({ error: message }, status)
7
+ }
8
+
9
+ export function sseResponse(events: AsyncIterable<{ event?: string, data: unknown }>, signal?: AbortSignal): Response {
10
+ const encoder = new TextEncoder()
11
+ const iterator = events[Symbol.asyncIterator]()
12
+ let closed = false
13
+ const stop = (controller?: ReadableStreamDefaultController<Uint8Array>) => {
14
+ if (closed) return
15
+ closed = true
16
+ void iterator.return?.()
17
+ try {
18
+ controller?.close()
19
+ } catch {
20
+ }
21
+ }
22
+ const stream = new ReadableStream<Uint8Array>({
23
+ start(controller) {
24
+ if (signal?.aborted) stop(controller)
25
+ else signal?.addEventListener('abort', () => stop(controller), { once: true })
26
+ },
27
+ async pull(controller) {
28
+ if (closed) return
29
+ try {
30
+ const next = await iterator.next()
31
+ if (closed) return
32
+ if (next.done) {
33
+ stop(controller)
34
+ return
35
+ }
36
+ const { event, data } = next.value
37
+ const lines = `${event ? `event: ${event}\n` : ''}${JSON.stringify(data ?? null).split('\n').map(line => `data: ${line}`).join('\n')}\n\n`
38
+ controller.enqueue(encoder.encode(lines))
39
+ } catch (error) {
40
+ closed = true
41
+ controller.error(error)
42
+ }
43
+ },
44
+ cancel() {
45
+ stop()
46
+ },
47
+ })
48
+ return new Response(stream, { headers: { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no' } })
49
+ }
@@ -0,0 +1,15 @@
1
+ import type { RunResult } from './contracts.ts'
2
+ import type { MdastNode } from './ir.ts'
3
+
4
+ /** Whether a run only displays Markdown, which a renderer can show as prose; `rendered` names the MIME types the renderer draws itself. */
5
+ export function isProseRun(run: RunResult, rendered: (type: string) => boolean = () => false): boolean {
6
+ const outputs = run.outputs ?? []
7
+ return outputs.length > 0 && outputs.every(o => (o.output_type === 'display_data' || o.output_type === 'execute_result')
8
+ && o.data['text/markdown'] !== undefined && !Object.keys(o.data).some(rendered))
9
+ }
10
+
11
+ /** A run's Markdown output with its raw HTML turned into text, so that the HTML shows as written. */
12
+ export function htmlAsText(node: MdastNode): MdastNode {
13
+ if (node.type === 'html') return { type: 'text', value: String(node.value ?? '') }
14
+ return node.children ? { ...node, children: node.children.map(htmlAsText) } : node
15
+ }
package/src/runner.py CHANGED
@@ -1,4 +1,4 @@
1
- # The Python runner of Presmium (docs/architecture.md 9.2), published by the kernel as
1
+ # The Python runner of Presmium (docs/architecture.md 4.6), published by the kernel as
2
2
  # `@lincyaw/kernel/runner.py` and shared by the plugins that run Python: the execution plugin
3
3
  # loads it into Pyodide in the browser and runs it with the local python3 at compile time, the
4
4
  # local environment plugin runs it in the container. The kernel itself never reads it.
@@ -7,7 +7,7 @@
7
7
  # request's stdin to the program and its files in the working directory. Its result is JSON: the
8
8
  # text streams `stdout` and `stderr`, the exit code, and the rich outputs in nbformat form
9
9
  # (`outputs`: the streams in order, what `display()` shows, matplotlib figures, the value of a
10
- # last expression as Jupyter shows it, the error), docs/ir.md 6.1.
10
+ # last expression as Jupyter shows it, the error), docs/ir.md 5.6.
11
11
  #
12
12
  # Without a session every run gets fresh globals, and what it changes in the interpreter is undone
13
13
  # afterwards (_PmIsolation), so one run in the browser's long-lived interpreter cannot affect the
@@ -330,7 +330,7 @@ def _pm_prepare(code, args_json, stdin, files_json, scope):
330
330
 
331
331
  def _pm_split(code, flags=0):
332
332
  """
333
- The code as Jupyter runs it (docs/ir.md 6.1): its statements, and apart the last one when it is
333
+ The code as Jupyter runs it (docs/ir.md 5.6): its statements, and apart the last one when it is
334
334
  an expression, whose value is the run's `execute_result` (`_pm_result`); `(program, None)`
335
335
  without one, or when a `;` ends the code, which hides the value as in Jupyter.
336
336
  """
package/src/services.ts CHANGED
@@ -3,7 +3,7 @@ import validRange from 'semver/ranges/valid.js'
3
3
  import { KERNEL_VERSION } from './contracts.ts'
4
4
  import type { PluginManifest, ServiceToken } from './contracts.ts'
5
5
 
6
- export const HOST_OWNER = 'host'
6
+ const HOST_OWNER = 'host'
7
7
 
8
8
  export interface StartPlan {
9
9
  order: string[]
@@ -12,17 +12,17 @@ export interface StartPlan {
12
12
  }
13
13
 
14
14
  interface Entry {
15
- impl: unknown
15
+ implementation: unknown
16
16
  owner: string
17
17
  }
18
18
 
19
19
  export class ServiceRegistry {
20
20
  #entries = new Map<string, Entry>()
21
21
 
22
- provide<T>(token: ServiceToken<T>, impl: T, owner: string = HOST_OWNER): void {
22
+ provide<T>(token: ServiceToken<T>, implementation: T, owner: string = HOST_OWNER): void {
23
23
  const existing = this.#entries.get(token.id)
24
24
  if (existing) throw new Error(`service ${token.id} is already provided by ${existing.owner}`)
25
- this.#entries.set(token.id, { impl, owner })
25
+ this.#entries.set(token.id, { implementation, owner })
26
26
  }
27
27
 
28
28
  has(token: ServiceToken<unknown>): boolean {
@@ -32,11 +32,11 @@ export class ServiceRegistry {
32
32
  get<T>(token: ServiceToken<T>): T {
33
33
  const entry = this.#entries.get(token.id)
34
34
  if (!entry) throw new Error(`service ${token.id} is not available`)
35
- return entry.impl as T
35
+ return entry.implementation as T
36
36
  }
37
37
 
38
38
  tryGet<T>(token: ServiceToken<T>): T | undefined {
39
- return this.#entries.get(token.id)?.impl as T | undefined
39
+ return this.#entries.get(token.id)?.implementation as T | undefined
40
40
  }
41
41
 
42
42
  owner(token: ServiceToken<unknown>): string | undefined {
package/src/slides.ts ADDED
@@ -0,0 +1,36 @@
1
+ import type { IRDocument, IRSection } from './ir.ts'
2
+
3
+ /** The canvas a document's slides are drawn on, before it is scaled to the screen: 980 wide and 16:9 unless the document's slides say otherwise. */
4
+ export interface SlideShape {
5
+ width: number
6
+ height: number
7
+ }
8
+
9
+ const WIDTH = 980
10
+
11
+ /** `aspectRatio` as the slides' configuration writes it: `16/9`, `4:3` or a number; 16:9 otherwise. */
12
+ function aspectOf(value: unknown): number {
13
+ const [width, height] = typeof value === 'number' ? [value, 1] : typeof value === 'string' ? value.split(/[/:]/).map(Number) : []
14
+ const aspect = height === undefined ? width : (width ?? Number.NaN) / height
15
+ return aspect !== undefined && Number.isFinite(aspect) && aspect > 0 ? aspect : 16 / 9
16
+ }
17
+
18
+ /** The canvas of the document's slides, from `renderers.slides` (`aspectRatio`, `canvasWidth`). */
19
+ export function slideShape(doc: Pick<IRDocument, 'config'>): SlideShape {
20
+ const options = doc.config.renderers.slides ?? {}
21
+ const width = typeof options.canvasWidth === 'number' && options.canvasWidth > 0 ? options.canvasWidth : WIDTH
22
+ return { width, height: Math.round(width / aspectOf(options.aspectRatio)) }
23
+ }
24
+
25
+ /** The chapter each slide is in (the heading above it), which a slide shows above its title. */
26
+ export function slideChapters(doc: Pick<IRDocument, 'sections'>): Map<string, string> {
27
+ const found = new Map<string, string>()
28
+ let open: IRSection[] = []
29
+ for (const section of doc.sections) {
30
+ open = open.filter(outer => outer.depth < section.depth)
31
+ const chapter = open.find(outer => outer.depth >= 1 && outer.title)
32
+ if (chapter) found.set(section.id, chapter.title)
33
+ open.push(section)
34
+ }
35
+ return found
36
+ }
@@ -0,0 +1,76 @@
1
+ import type { SpeechWord } from './contracts/speech.ts'
2
+
3
+ const CJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u
4
+ const LETTER = /[\p{L}\p{N}'’]/u
5
+
6
+ export function isCjk(char: string): boolean {
7
+ return CJK.test(char)
8
+ }
9
+
10
+ /** Words of a text; start and end are offsets in the text, not times. */
11
+ export interface TextWord { text: string, start: number, end: number }
12
+
13
+ export function splitWords(text: string): TextWord[] {
14
+ const words: TextWord[] = []
15
+ let run: TextWord | undefined
16
+ let at = 0
17
+ for (const char of text) {
18
+ if (isCjk(char)) {
19
+ if (run) words.push(run)
20
+ run = undefined
21
+ words.push({ text: char, start: at, end: at + char.length })
22
+ } else if (LETTER.test(char)) {
23
+ run ??= { text: '', start: at, end: at }
24
+ run.text += char
25
+ run.end = at + char.length
26
+ } else if (run) {
27
+ words.push(run)
28
+ run = undefined
29
+ }
30
+ at += char.length
31
+ }
32
+ if (run) words.push(run)
33
+ return words
34
+ }
35
+
36
+ export interface WordSpan { from: number, to: number, start: number, end: number }
37
+
38
+ export interface WrittenWord extends SpeechWord { offset?: number }
39
+
40
+ export function remapWords(words: readonly SpeechWord[], written: string, spoken: string, spans: readonly WordSpan[]): WrittenWord[] {
41
+ const places: { start: number, end: number }[] = []
42
+ let at = 0
43
+ for (const word of words) {
44
+ const index = word.text ? spoken.indexOf(word.text, at) : -1
45
+ if (index < 0) return words.map(w => ({ ...w }))
46
+ places.push({ start: index, end: at = index + word.text.length })
47
+ }
48
+ const writtenAt = (offset: number) => {
49
+ let shift = 0
50
+ for (const span of spans) {
51
+ if (offset < span.from) break
52
+ if (offset < span.to) return span.start
53
+ shift += (span.end - span.start) - (span.to - span.from)
54
+ }
55
+ return offset + shift
56
+ }
57
+ const result: WrittenWord[] = []
58
+ let group: { span: WordSpan, word: WrittenWord } | undefined
59
+ words.forEach((word, i) => {
60
+ const { start, end } = places[i]!
61
+ const span = spans.find(s => start < s.to && end > s.from)
62
+ if (span && group?.span === span) {
63
+ group.word.end = Math.max(group.word.end, word.end)
64
+ return
65
+ }
66
+ group = undefined
67
+ if (span) {
68
+ const placed = { text: written.slice(span.start, span.end), start: word.start, end: word.end, offset: span.start }
69
+ group = { span, word: placed }
70
+ result.push(placed)
71
+ } else {
72
+ result.push({ ...word, offset: writtenAt(start) })
73
+ }
74
+ })
75
+ return result
76
+ }
package/src/speech.ts ADDED
@@ -0,0 +1,13 @@
1
+ import type { AigcLabel } from './contracts/speech.ts'
2
+
3
+ export function speechEngineId(name: string): string {
4
+ return name.trim().toLowerCase().replace(/[^a-z0-9]/g, '_')
5
+ }
6
+
7
+ export function aigcLabel(producer: string, produceId: string): AigcLabel {
8
+ return { Label: '1', ContentProducer: producer, ProduceID: produceId, ReservedCode1: '', ContentPropagator: '', PropagateID: '', ReservedCode2: '' }
9
+ }
10
+
11
+ export function aigcString(label: AigcLabel): string {
12
+ return `AIGC=${JSON.stringify(label)}`
13
+ }
@@ -0,0 +1,38 @@
1
+ import type { IRBlock, IRSection } from './ir.ts'
2
+
3
+ export type StepKeys = string[][]
4
+
5
+ export function stepKeys(section: Pick<IRSection, 'blocks' | 'steps' | 'stepIds'>, renderer?: string): StepKeys {
6
+ if (section.stepIds) return section.stepIds.map(id => [`step:${id}`])
7
+ const keys: StepKeys = Array.from({ length: section.steps + 1 }, () => [])
8
+ const add = (step: number, key: string) => step > 0 && keys[step]?.push(key)
9
+ let left = false
10
+ const visit = (blocks: readonly IRBlock[], prefix: string) => {
11
+ for (const block of blocks) {
12
+ if (renderer !== undefined && block.only && !block.only.includes(renderer)) {
13
+ left = true
14
+ continue
15
+ }
16
+ const id = prefix + block.id
17
+ if (block.role === 'visual') {
18
+ for (let k = 0; k <= (block.span ?? 0); k++) add(block.at + k, k === 0 ? id : `${id}+${k}`)
19
+ if (block.until !== undefined) add(block.until, `${id}-`)
20
+ }
21
+ if (block.node.type === 'component' && block.node.children) visit(block.node.children, `${id}/`)
22
+ }
23
+ }
24
+ visit(section.blocks, '')
25
+ return left ? keys.filter((step, i) => i === 0 || step.length > 0) : keys
26
+ }
27
+
28
+ export function stepTrail(keys: StepKeys, step: number): string[] {
29
+ return keys.slice(1, step + 1).flat()
30
+ }
31
+
32
+ export function trailStep(keys: StepKeys, trail: readonly string[]): number {
33
+ const reached = new Set(trail)
34
+ for (let step = keys.length - 1; step > 0; step--) {
35
+ if (keys[step]!.some(key => reached.has(key))) return step
36
+ }
37
+ return 0
38
+ }
package/src/steps.ts ADDED
@@ -0,0 +1,7 @@
1
+ import type { StepRange, StepState } from './contracts/steps.ts'
2
+
3
+ export function stepStateAt(range: StepRange, step: number): StepState {
4
+ const reached = step >= range.from
5
+ const done = range.to !== undefined && step >= range.to
6
+ return { active: reached && !done, reached, done }
7
+ }
@@ -0,0 +1,105 @@
1
+ import type { Narration, NarrationNode, TeachingDocument } from './contracts/document.ts'
2
+ import type { TranscriptionRevision } from './contracts/transcription.ts'
3
+ import type { TranscriptionAdoptionDraft, TranscriptionAdoptionPreview } from './contracts/transcription-adoption.ts'
4
+ import { fnv1a64 } from './fingerprint.ts'
5
+ import { validateDocument } from './document.ts'
6
+
7
+ const own = (table: object, key: string) => Object.hasOwn(table, key)
8
+ const shown = (nodes: NarrationNode[]) => nodes.map(node => node.type === 'text' ? node.text : node.type === 'pronunciation' ? node.display : '').join('')
9
+ const sameIds = (a: string[], b: string[]) => { const sorted = [...b].sort(); return a.length === b.length && [...a].sort().every((id, index) => id === sorted[index]) }
10
+ const atomKey = (index: number, node: NarrationNode) => `atom:${index}:${fnv1a64(JSON.stringify(node))}`
11
+
12
+ /** Builds one typed candidate from explicit author decisions, without I/O or writing the input document. */
13
+ export function prepareTranscriptionAdoption(document: TeachingDocument, candidate: TranscriptionRevision, draft: TranscriptionAdoptionDraft): TranscriptionAdoptionPreview & { content?: TeachingDocument } {
14
+ const result: TranscriptionAdoptionPreview = { targets: [], conflicts: [], impact: { narrationIds: [], stepIds: [], sharedStepIds: [], forks: [], removedAtoms: [], removedCueTimings: [] } }
15
+ const conflict = (code: string, target?: number, key?: string, stepIds?: string[]) => { result.conflicts.push({ code, ...target !== undefined ? { target } : {}, ...key ? { key } : {}, ...stepIds ? { stepIds } : {} }) }
16
+ if (candidate.transcriptionId !== draft.candidate.id || candidate.revision !== draft.candidate.revision || candidate.source.documentId !== document.id || draft.source.documentId !== document.id) {
17
+ conflict('candidate-document-changed'); return result
18
+ }
19
+ const output = structuredClone(document), changed = new Map<string, Narration>(), targets = new Set<string>(), removedCues = new Set<string>()
20
+ const globalIds = new Set([document.id, ...(['sections', 'blocks', 'steps', 'narrations', 'assets', 'definitions'] as const).flatMap(name => Object.keys(document[name])), ...Object.values(document.narrations).flatMap(narration => narration.content.flatMap(node => node.type === 'cue' ? [node.id] : []))])
21
+ const generate = (kind: string, target: string, extra = '') => `${kind}-${fnv1a64(JSON.stringify([document.id, draft.id, target, extra]))}`
22
+ for (const [index, target] of draft.targets.entries()) {
23
+ const step = own(document.steps, target.stepId) ? document.steps[target.stepId] : undefined
24
+ const section = own(document.sections, target.sectionId) ? document.sections[target.sectionId] : undefined
25
+ if (!step || !section || step.sectionId !== section.id || !section.stepOrder.includes(step.id)) { conflict('target-changed', index); continue }
26
+ const address = JSON.stringify([step.id, target.narrationId ?? null])
27
+ if (targets.has(address)) { conflict('target-repeated', index); continue }
28
+ targets.add(address)
29
+ const before = target.narrationId !== undefined && own(document.narrations, target.narrationId) ? document.narrations[target.narrationId] : undefined
30
+ if (target.narrationId !== undefined ? !before || !step.narrationIds.includes(target.narrationId) : step.narrationIds.length !== 0) { conflict('narration-target-changed', index); continue }
31
+ const refs = before ? Object.values(document.steps).filter(item => item.narrationIds.includes(before.id)).map(item => item.id) : [step.id]
32
+ if (refs.length > 1 && !target.shared) conflict('shared-decision-required', index, undefined, refs)
33
+ if (target.shared === 'edit-shared' && !sameIds(target.affectedStepIds ?? [], refs)) conflict('shared-scope-changed', index, undefined, refs)
34
+ if (target.shared === 'fork' && target.affectedStepIds?.length) conflict('shared-scope-unexpected', index)
35
+ const segments = candidate.result.segments.filter(segment => target.segmentIds.includes(segment.id))
36
+ if (segments.length !== target.segmentIds.length) { conflict('segment-missing', index); continue }
37
+ const sourceText = segments.map(segment => segment.text).join('\n')
38
+ const atoms = (before?.content ?? []).flatMap((node, position) => node.type === 'text' ? [] : [{ key: atomKey(position, node), node }])
39
+ const atomMap = new Map(atoms.map(atom => [atom.key, atom.node])), preserved = new Set<string>(), discarded = new Set(target.discardAtoms ?? [])
40
+ let proposed: NarrationNode[] = []
41
+ if (target.content === undefined && sourceText === shown(before?.content ?? []) && !discarded.size) {
42
+ proposed = structuredClone(before?.content ?? []); atoms.forEach(atom => preserved.add(atom.key))
43
+ } else if (target.content === undefined) proposed = [{ type: 'text', text: sourceText }]
44
+ else for (const part of target.content) {
45
+ if (part.type === 'text') proposed.push({ type: 'text', text: part.text })
46
+ else {
47
+ const atom = atomMap.get(part.key)
48
+ if (!atom) conflict('atom-unknown', index, part.key)
49
+ else if (preserved.has(part.key) || discarded.has(part.key)) conflict('atom-repeated', index, part.key)
50
+ else { preserved.add(part.key); proposed.push(structuredClone(atom)) }
51
+ }
52
+ }
53
+ for (const key of discarded) {
54
+ const atom = atomMap.get(key)
55
+ if (!atom) conflict('atom-unknown', index, key)
56
+ else { result.impact.removedAtoms.push(key); if (atom.type === 'cue') removedCues.add(atom.id) }
57
+ }
58
+ for (const atom of atoms) if (!preserved.has(atom.key) && !discarded.has(atom.key)) conflict('protected-atom-unresolved', index, atom.key)
59
+ const fork = Boolean(before && target.shared === 'fork')
60
+ const outputId = !before || fork ? generate('narration', address) : before.id
61
+ const cueChanges: { from: string, to: string }[] = []
62
+ if ((!before || fork) && globalIds.has(outputId)) conflict('generated-id-collision', index, outputId)
63
+ if (fork) proposed = proposed.map(node => {
64
+ if (node.type !== 'cue') return node
65
+ const id = generate('cue', address, node.id)
66
+ if (globalIds.has(id)) conflict('generated-id-collision', index, id)
67
+ globalIds.add(id); cueChanges.push({ from: node.id, to: id })
68
+ if (own(document.production.cueTimings ?? {}, node.id)) {
69
+ output.production.cueTimings ??= {}
70
+ output.production.cueTimings[id] = structuredClone(document.production.cueTimings![node.id]!)
71
+ }
72
+ return { ...node, id }
73
+ })
74
+ const narration: Narration = { ...before, id: outputId, content: proposed, provenance: { type: 'transcription', transcriptionId: candidate.transcriptionId, revision: candidate.revision, assetId: candidate.audio.assetId, audioSha256: candidate.audio.sha256, segmentIds: segments.map(segment => segment.id) } }
75
+ const prior = changed.get(outputId)
76
+ if (prior && JSON.stringify(prior) !== JSON.stringify(narration)) conflict('shared-content-conflict', index)
77
+ else { changed.set(outputId, narration); Object.defineProperty(output.narrations, outputId, { value: narration, enumerable: true, configurable: true, writable: true }) }
78
+ if (!before) output.steps[step.id]!.narrationIds.push(outputId)
79
+ else if (fork) {
80
+ output.steps[step.id]!.narrationIds = output.steps[step.id]!.narrationIds.map(id => id === before.id ? outputId : id)
81
+ if (own(document.production.narrationOverrides ?? {}, before.id)) {
82
+ output.production.narrationOverrides ??= {}
83
+ output.production.narrationOverrides[outputId] = structuredClone(document.production.narrationOverrides![before.id]!)
84
+ }
85
+ result.impact.forks.push({ from: before.id, to: outputId, stepId: step.id, cues: cueChanges })
86
+ }
87
+ globalIds.add(outputId)
88
+ result.impact.narrationIds.push(outputId)
89
+ result.impact.stepIds.push(...fork ? [step.id] : refs)
90
+ if (refs.length > 1 && !fork) result.impact.sharedStepIds.push(...refs)
91
+ result.targets.push({ sectionId: section.id, stepId: step.id, narrationId: before?.id, outputNarrationId: outputId, segmentIds: segments.map(segment => segment.id), sourceText, before: structuredClone(before?.content ?? []), proposed: structuredClone(proposed), atoms: structuredClone(atoms) })
92
+ }
93
+ const remainingCues = new Set(Object.values(output.narrations).flatMap(narration => narration.content.flatMap(node => node.type === 'cue' ? [node.id] : [])))
94
+ for (const id of removedCues) if (!remainingCues.has(id) && own(document.production.cueTimings ?? {}, id) && !draft.removeCueTimings?.includes(id)) conflict('cue-timing-confirmation-required', undefined, id)
95
+ for (const id of draft.removeCueTimings ?? []) {
96
+ if (!removedCues.has(id) || remainingCues.has(id) || !own(document.production.cueTimings ?? {}, id)) conflict('cue-timing-unexpected', undefined, id)
97
+ else { delete output.production.cueTimings![id]; result.impact.removedCueTimings.push(id) }
98
+ }
99
+ for (const field of ['narrationIds', 'stepIds', 'sharedStepIds', 'removedAtoms'] as const) result.impact[field] = [...new Set(result.impact[field])]
100
+ if (!result.conflicts.length) {
101
+ const checked = validateDocument(output)
102
+ if (!checked.document || checked.issues.some(issue => issue.severity === 'error')) for (const issue of checked.issues.filter(issue => issue.severity === 'error')) conflict('document-invalid', undefined, issue.path)
103
+ }
104
+ return { ...result, ...result.conflicts.length ? {} : { content: output } }
105
+ }
@@ -0,0 +1,8 @@
1
+ /** Decodes a URI component; a malformed one is returned as written. */
2
+ export function safeDecodeURIComponent(value: string): string {
3
+ try {
4
+ return decodeURIComponent(value)
5
+ } catch {
6
+ return value
7
+ }
8
+ }
package/src/urls.ts ADDED
@@ -0,0 +1,13 @@
1
+ /** A URL with a scheme (`https:`, `data:`, `mailto:`) or a protocol-relative one (`//host/path`). */
2
+ export const ABSOLUTE_URL = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i
3
+
4
+ /** A URL that names no local file: an absolute URL or a bare fragment (`#id`). */
5
+ export const NON_FILE_URL = /^(?:[a-z][a-z\d+.-]*:|\/\/|#)/i
6
+
7
+ /**
8
+ * A slug for a URL path segment or fragment: letters and digits, each other run of characters one `-`.
9
+ * The web renderer's page paths and the IR runtime's heading IDs; section IDs follow the compiler's own rule.
10
+ */
11
+ export function urlSlug(text: string): string {
12
+ return text.normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '')
13
+ }