@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
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) {
|
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/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
|
+
}
|
package/src/runner.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# The Python runner of Presmium (docs/architecture.md
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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>,
|
|
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, {
|
|
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.
|
|
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)?.
|
|
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
|
+
}
|
package/src/step-keys.ts
ADDED
|
@@ -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
|
+
}
|
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
|
+
}
|