@lincyaw/kernel 0.0.0-stage → 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +32 -2
  3. package/package.json +41 -3
  4. package/src/access.ts +18 -0
  5. package/src/anchors.ts +58 -0
  6. package/src/authoring.ts +56 -0
  7. package/src/blocks.ts +19 -0
  8. package/src/checks.ts +35 -0
  9. package/src/component-code.ts +6 -0
  10. package/src/contracts/agent.ts +76 -0
  11. package/src/contracts/anchors.ts +46 -0
  12. package/src/contracts/authoring-actions.ts +60 -0
  13. package/src/contracts/authoring-clients.ts +213 -0
  14. package/src/contracts/authoring.ts +180 -0
  15. package/src/contracts/checks.ts +29 -0
  16. package/src/contracts/classroom.ts +49 -0
  17. package/src/contracts/diagnostics.ts +37 -0
  18. package/src/contracts/document-audio.ts +150 -0
  19. package/src/contracts/document-comments.ts +31 -0
  20. package/src/contracts/document-history.ts +63 -0
  21. package/src/contracts/document-image.ts +32 -0
  22. package/src/contracts/document-reads.ts +57 -0
  23. package/src/contracts/document-voice.ts +44 -0
  24. package/src/contracts/document.ts +240 -0
  25. package/src/contracts/execution.ts +118 -0
  26. package/src/contracts/locale.ts +3 -0
  27. package/src/contracts/offline.ts +2 -0
  28. package/src/contracts/operations.ts +8 -0
  29. package/src/contracts/plugin.ts +183 -0
  30. package/src/contracts/presenter-panels.ts +17 -0
  31. package/src/contracts/preview.ts +386 -0
  32. package/src/contracts/production-assets.ts +61 -0
  33. package/src/contracts/production.ts +109 -0
  34. package/src/contracts/projects.ts +59 -0
  35. package/src/contracts/server.ts +108 -0
  36. package/src/contracts/service.ts +8 -0
  37. package/src/contracts/speech-settings.ts +16 -0
  38. package/src/contracts/speech.ts +149 -0
  39. package/src/contracts/steps.ts +15 -0
  40. package/src/contracts/transcription-adoption.ts +79 -0
  41. package/src/contracts/transcription.ts +112 -0
  42. package/src/contracts/voice.ts +97 -0
  43. package/src/contracts/voiceover-execution.ts +68 -0
  44. package/src/contracts/workspace.ts +458 -0
  45. package/src/contracts.ts +35 -0
  46. package/src/diagnostics.ts +7 -0
  47. package/src/document-authoring-actions.ts +47 -0
  48. package/src/document-caption-timings.ts +64 -0
  49. package/src/document-colors.ts +25 -0
  50. package/src/document-comments.ts +118 -0
  51. package/src/document-components.ts +251 -0
  52. package/src/document-operations.ts +132 -0
  53. package/src/document-pages.ts +215 -0
  54. package/src/document-reads.ts +81 -0
  55. package/src/document-schema.ts +85 -0
  56. package/src/document-voices.ts +153 -0
  57. package/src/document.ts +252 -0
  58. package/src/durations.ts +7 -0
  59. package/src/errors.ts +15 -0
  60. package/src/events.ts +32 -0
  61. package/src/execution.ts +25 -0
  62. package/src/fingerprint.ts +31 -0
  63. package/src/follow.ts +33 -0
  64. package/src/git.ts +12 -0
  65. package/src/html.ts +41 -0
  66. package/src/index.ts +82 -0
  67. package/src/ir.ts +367 -0
  68. package/src/jupyter-kernel.ts +9 -0
  69. package/src/kernel.ts +190 -0
  70. package/src/keyboard.ts +43 -0
  71. package/src/locale.ts +13 -0
  72. package/src/lock.ts +81 -0
  73. package/src/logging.ts +11 -0
  74. package/src/lower-myst.ts +312 -0
  75. package/src/node/assets.ts +23 -0
  76. package/src/node/authoring-actions.ts +50 -0
  77. package/src/node/authoring-clients.ts +14 -0
  78. package/src/node/document-audio.ts +22 -0
  79. package/src/node/document-history.ts +11 -0
  80. package/src/node/document-image.ts +8 -0
  81. package/src/node/document-reads.ts +10 -0
  82. package/src/node/document-voice.ts +7 -0
  83. package/src/node/ffmpeg.ts +29 -0
  84. package/src/node/file-version.ts +6 -0
  85. package/src/node/fonts.ts +22 -0
  86. package/src/node/index.ts +29 -0
  87. package/src/node/installed-packages.ts +11 -0
  88. package/src/node/lines.ts +24 -0
  89. package/src/node/locale.ts +7 -0
  90. package/src/node/network.ts +11 -0
  91. package/src/node/operator-speech.ts +91 -0
  92. package/src/node/packages.ts +74 -0
  93. package/src/node/paths.ts +7 -0
  94. package/src/node/projects.ts +7 -0
  95. package/src/node/resources.ts +86 -0
  96. package/src/node/revision.ts +6 -0
  97. package/src/node/run-directory.ts +70 -0
  98. package/src/node/strip-types.mjs +15 -0
  99. package/src/node/transcription-adoption.ts +9 -0
  100. package/src/node/transcription.ts +10 -0
  101. package/src/node/voiceover-routes.ts +73 -0
  102. package/src/node/voiceover.ts +199 -0
  103. package/src/page-source.ts +359 -0
  104. package/src/parameters.ts +81 -0
  105. package/src/preview-edit.ts +18 -0
  106. package/src/preview-events.ts +57 -0
  107. package/src/preview-frame.ts +1220 -0
  108. package/src/props.ts +17 -0
  109. package/src/published.ts +11 -0
  110. package/src/push-stream.ts +67 -0
  111. package/src/reading.ts +96 -0
  112. package/src/recording.ts +38 -0
  113. package/src/renderer-components.ts +30 -0
  114. package/src/requests.ts +28 -0
  115. package/src/responses.ts +49 -0
  116. package/src/run-outputs.ts +15 -0
  117. package/src/runner.py +454 -0
  118. package/src/services.ts +146 -0
  119. package/src/slides.ts +36 -0
  120. package/src/speech-words.ts +76 -0
  121. package/src/speech.ts +13 -0
  122. package/src/step-keys.ts +38 -0
  123. package/src/steps.ts +7 -0
  124. package/src/transcription-adoption.ts +105 -0
  125. package/src/uri-decoding.ts +8 -0
  126. package/src/urls.ts +13 -0
  127. package/src/views.ts +47 -0
  128. 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,11 @@
1
+ import type { LocalAsset } from './ir.ts'
2
+
3
+ export interface PublishedUrlOptions {
4
+ prefix?: string
5
+ directoryIndex?: string
6
+ }
7
+
8
+ export function publishedUrl(found: Pick<LocalAsset, 'name' | 'directory'>, url: string, options: PublishedUrlOptions = {}): string {
9
+ const index = found.directory ? options.directoryIndex ?? '/index.html' : ''
10
+ return `${options.prefix ?? ''}${encodeURIComponent(found.name)}${index}${/[?#].*$/s.exec(url)?.[0] ?? ''}`
11
+ }
@@ -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 ADDED
@@ -0,0 +1,96 @@
1
+ import { irBlocks } from './blocks.ts'
2
+ import type { IRBlock, IRDocument, IRSection, MdastNode } from './ir.ts'
3
+ import { propText } from './props.ts'
4
+
5
+ export interface Chapter {
6
+ title: string
7
+ sections: IRSection[]
8
+ }
9
+
10
+ function sectionDepth(doc: IRDocument): number {
11
+ return doc.sections.reduce((depth, section) => Math.max(depth, section.depth), 0)
12
+ }
13
+
14
+ function titles(depth: number, section: IRSection): boolean {
15
+ return section.depth === 0 || section.depth < depth
16
+ }
17
+
18
+ export function isChapterTitle(doc: IRDocument, section: IRSection): boolean {
19
+ return titles(sectionDepth(doc), section)
20
+ }
21
+
22
+ export function chapters(doc: IRDocument): Chapter[] {
23
+ const depth = sectionDepth(doc)
24
+ const result: Chapter[] = []
25
+ let previous: IRSection | undefined
26
+ for (const section of doc.sections) {
27
+ const current = result.at(-1)
28
+ if (!current || section.source.file !== previous?.source.file || titles(depth, section)) {
29
+ result.push({ title: section.title, sections: [section] })
30
+ } else {
31
+ current.sections.push(section)
32
+ }
33
+ previous = section
34
+ }
35
+ return result
36
+ }
37
+
38
+ export function blockVisible(block: IRBlock, step: number): boolean {
39
+ const state = blockStepState(block, step)
40
+ if (state) return state.visible
41
+ return step >= block.at && (block.until === undefined || step < block.until)
42
+ }
43
+
44
+ export function blockState(block: IRBlock, step: number): number {
45
+ const state = blockStepState(block, step)
46
+ if (state) return state.localStep
47
+ return Math.min(Math.max(step - block.at, 0), block.span ?? 0)
48
+ }
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
+
57
+ export function narrationAt(section: IRSection, step: number): IRBlock[] {
58
+ return section.blocks.filter(block => block.role === 'narration' && block.at === step)
59
+ }
60
+
61
+ export function sectionNarration(section: IRSection): IRBlock[] {
62
+ return [...irBlocks(section.blocks)].filter(block => block.role === 'narration')
63
+ }
64
+
65
+ const BREAKS = new Set(['paragraph', 'heading', 'listItem', 'tableRow', 'tableCell', 'code', 'math', 'blockquote', 'break'])
66
+
67
+ function htmlText(html: string): string {
68
+ return html.replace(/<(style|script)\b[\s\S]*?<\/\1\s*>/gi, ' ').replace(/<[^>]*>/g, ' ')
69
+ }
70
+
71
+ export function mdastText(node: MdastNode): string {
72
+ if (node.type === 'html' && typeof node.value === 'string') return htmlText(node.value)
73
+ if (typeof node.value === 'string' && !node.children) return node.value
74
+ const text = (node.children ?? []).map(mdastText).join('')
75
+ return BREAKS.has(node.type) ? `${text}\n` : text
76
+ }
77
+
78
+ export function blockText(block: IRBlock): string {
79
+ const { node } = block
80
+ switch (node.type) {
81
+ case 'markdown': return mdastText(node.mdast).trim()
82
+ case 'code': return [node.title, node.value, ...(node.outputs ?? []).map(o => o.stdout)].filter(Boolean).join('\n')
83
+ case 'component': return [...propText(node.props), ...(node.children ?? []).map(blockText)].join('\n')
84
+ }
85
+ }
86
+
87
+ export function tokenize(text: string): string[] {
88
+ const segmenter = new Intl.Segmenter('zh', { granularity: 'word' })
89
+ const words: string[] = []
90
+ for (const piece of text.split(/[\n\r\p{Z}\p{P}]+/u)) {
91
+ for (const part of segmenter.segment(piece)) {
92
+ if (part.isWordLike) words.push(part.segment)
93
+ }
94
+ }
95
+ return words
96
+ }
@@ -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,28 @@
1
+ export interface RequestHeaders {
2
+ host?: string | string[]
3
+ origin?: string | string[]
4
+ 'sec-fetch-site'?: string | string[]
5
+ }
6
+
7
+ const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]', '::1'])
8
+
9
+ function first(value: string | string[] | undefined): string | undefined {
10
+ return Array.isArray(value) ? value[0] : value
11
+ }
12
+
13
+ export function hostName(value: string): string {
14
+ const host = value.includes('://') ? value.slice(value.indexOf('://') + 3) : value
15
+ if (host.startsWith('[')) return host.slice(0, host.indexOf(']') + 1).toLowerCase()
16
+ return host.split(':')[0]!.toLowerCase()
17
+ }
18
+
19
+ export function localRequestRefusal(headers: RequestHeaders, allowedHosts?: true | readonly string[]): string | undefined {
20
+ if (first(headers['sec-fetch-site']) === 'cross-site') return 'cross-site request'
21
+ if (allowedHosts === true) return undefined
22
+ const allowed = (name: string) => LOOPBACK.has(name) || (allowedHosts ?? []).some(host => host.startsWith('.') ? name.endsWith(host) || name === host.slice(1) : name === host.toLowerCase())
23
+ const host = first(headers.host)
24
+ if (!host || !allowed(hostName(host))) return `Host ${host ?? '(none)'} is not this machine`
25
+ const origin = first(headers.origin)
26
+ if (origin && origin !== 'null' && !allowed(hostName(origin))) return `Origin ${origin} is not this machine`
27
+ return undefined
28
+ }
@@ -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
+ }