@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.
- package/LICENSE +202 -0
- package/README.md +32 -2
- package/package.json +41 -3
- package/src/access.ts +18 -0
- package/src/anchors.ts +58 -0
- package/src/authoring.ts +56 -0
- package/src/blocks.ts +19 -0
- package/src/checks.ts +35 -0
- package/src/component-code.ts +6 -0
- package/src/contracts/agent.ts +76 -0
- 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 +118 -0
- package/src/contracts/locale.ts +3 -0
- package/src/contracts/offline.ts +2 -0
- package/src/contracts/operations.ts +8 -0
- package/src/contracts/plugin.ts +183 -0
- package/src/contracts/presenter-panels.ts +17 -0
- package/src/contracts/preview.ts +386 -0
- 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 +108 -0
- package/src/contracts/service.ts +8 -0
- package/src/contracts/speech-settings.ts +16 -0
- package/src/contracts/speech.ts +149 -0
- 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 +35 -0
- 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 +15 -0
- package/src/events.ts +32 -0
- package/src/execution.ts +25 -0
- package/src/fingerprint.ts +31 -0
- package/src/follow.ts +33 -0
- package/src/git.ts +12 -0
- package/src/html.ts +41 -0
- package/src/index.ts +82 -0
- package/src/ir.ts +367 -0
- package/src/jupyter-kernel.ts +9 -0
- package/src/kernel.ts +190 -0
- package/src/keyboard.ts +43 -0
- package/src/locale.ts +13 -0
- package/src/lock.ts +81 -0
- package/src/logging.ts +11 -0
- package/src/lower-myst.ts +312 -0
- 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 -0
- 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/strip-types.mjs +15 -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/published.ts +11 -0
- package/src/push-stream.ts +67 -0
- package/src/reading.ts +96 -0
- package/src/recording.ts +38 -0
- package/src/renderer-components.ts +30 -0
- package/src/requests.ts +28 -0
- package/src/responses.ts +49 -0
- package/src/run-outputs.ts +15 -0
- package/src/runner.py +454 -0
- package/src/services.ts +146 -0
- 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
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
|
+
}
|
package/src/published.ts
ADDED
|
@@ -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
|
+
}
|
package/src/recording.ts
ADDED
|
@@ -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
|
+
}
|
package/src/requests.ts
ADDED
|
@@ -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
|
+
}
|
package/src/responses.ts
ADDED
|
@@ -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
|
+
}
|