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

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.
@@ -0,0 +1,391 @@
1
+ import { errorMessage } from '../errors.ts'
2
+
3
+ export const PREVIEW_PROTOCOL = 1
4
+
5
+ export const PREVIEW_NAME = 'presmium-preview'
6
+
7
+ export const PREVIEW_PARAM = 'presmium-preview'
8
+
9
+ export const PREVIEW_REVISION = 'presmiumRevision'
10
+
11
+ export const AUTHOR_MARKS_CLASS = 'pm-author-marks'
12
+
13
+ export function previewFrameAncestors(origins: readonly string[]): string {
14
+ return `frame-ancestors ${origins.length > 0 ? origins.join(' ') : "'none'"}`
15
+ }
16
+
17
+ export interface PreviewAnchor {
18
+ section: string
19
+ block?: string
20
+ }
21
+
22
+ export interface PreviewPin {
23
+ id: string
24
+ section: string
25
+ block: string
26
+ label: string
27
+ }
28
+
29
+ export interface PreviewPosition {
30
+ section: string
31
+ step: number
32
+ trail: string[]
33
+ view?: string
34
+ }
35
+
36
+ export type PreviewCommand =
37
+ | { type: 'go', section: string, step?: number, trail?: string[], view?: string }
38
+ | { type: 'highlight', target: PreviewAnchor | null }
39
+ | { type: 'pick', on: boolean }
40
+ | { type: 'pins', targets: PreviewPin[] }
41
+ | { type: 'marks', on: boolean }
42
+ | { type: 'appearance', dark: boolean }
43
+
44
+ export type PreviewCommandType = PreviewCommand['type']
45
+
46
+ export type PreviewReport =
47
+ | { type: 'ready', renderer: 'web' | 'slides', document: string, revision?: string, capabilities: PreviewCommandType[] }
48
+ | ({ type: 'position' } & PreviewPosition)
49
+ | { type: 'picked', section: string, block?: string, step?: number, trail?: string[] }
50
+ | { type: 'selection', selection: (PreviewAnchor & { text: string }) | null }
51
+ | { type: 'updated', revision?: string }
52
+ | { type: 'report', level: 'error' | 'warning', plugin?: string, message: string }
53
+
54
+ export type PreviewEnvelope<M> = M & { protocol: typeof PREVIEW_NAME, version: number, nonce: string }
55
+
56
+ export function previewEnvelope<M extends PreviewCommand | PreviewReport>(nonce: string, message: M): PreviewEnvelope<M> {
57
+ return { protocol: PREVIEW_NAME, version: PREVIEW_PROTOCOL, nonce, ...message }
58
+ }
59
+
60
+ const record = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
61
+ const text = (value: unknown): value is string => typeof value === 'string'
62
+ const optional = <T>(value: unknown, is: (v: unknown) => v is T) => value === undefined || is(value)
63
+ const step = (value: unknown): value is number => Number.isInteger(value) && (value as number) >= 0
64
+ const strings = (value: unknown): value is string[] => Array.isArray(value) && value.length <= 10_000 && value.every(text)
65
+ const anchor = (value: unknown): value is PreviewAnchor => record(value) && text(value.section) && optional(value.block, text)
66
+
67
+ export function isPreviewEnvelope(data: unknown, nonce: string): data is PreviewEnvelope<{ type: string }> {
68
+ return record(data) && data.protocol === PREVIEW_NAME && data.nonce === nonce && typeof data.version === 'number' && text(data.type)
69
+ }
70
+
71
+ export function readPreviewCommand(data: unknown, nonce: string): PreviewCommand | undefined {
72
+ if (!isPreviewEnvelope(data, nonce) || data.version !== PREVIEW_PROTOCOL) return undefined
73
+ const m = data as Record<string, unknown>
74
+ switch (m.type) {
75
+ case 'go': return text(m.section) && optional(m.step, step) && optional(m.trail, strings) && optional(m.view, text) ? { type: 'go', section: m.section, ...m.step === undefined ? {} : { step: m.step as number }, ...m.trail ? { trail: m.trail as string[] } : {}, ...m.view ? { view: m.view as string } : {} } : undefined
76
+ case 'highlight': return m.target === null || anchor(m.target) ? { type: 'highlight', target: m.target === null ? null : { section: m.target.section, ...m.target.block ? { block: m.target.block } : {} } } : undefined
77
+ case 'pick':
78
+ case 'marks': return typeof m.on === 'boolean' ? { type: m.type, on: m.on } : undefined
79
+ case 'appearance': return typeof m.dark === 'boolean' ? { type: 'appearance', dark: m.dark } : undefined
80
+ case 'pins': return Array.isArray(m.targets) && m.targets.length <= 1000 && m.targets.every(t => record(t) && text(t.id) && text(t.section) && text(t.block) && text(t.label))
81
+ ? { type: 'pins', targets: (m.targets as PreviewPin[]).map(({ id, section, block, label }) => ({ id, section, block, label })) }
82
+ : undefined
83
+ default: return undefined
84
+ }
85
+ }
86
+
87
+ export function readPreviewReport(data: unknown, nonce: string): PreviewReport | undefined {
88
+ if (!isPreviewEnvelope(data, nonce) || data.version !== PREVIEW_PROTOCOL) return undefined
89
+ const m = data as Record<string, unknown>
90
+ switch (m.type) {
91
+ case 'ready': return (m.renderer === 'web' || m.renderer === 'slides') && text(m.document) && optional(m.revision, text) && strings(m.capabilities) ? { type: 'ready', renderer: m.renderer, document: m.document, ...m.revision ? { revision: m.revision as string } : {}, capabilities: m.capabilities as PreviewCommandType[] } : undefined
92
+ case 'position': return text(m.section) && step(m.step) && strings(m.trail) && optional(m.view, text) ? { type: 'position', section: m.section, step: m.step, trail: m.trail, ...m.view ? { view: m.view as string } : {} } : undefined
93
+ case 'picked': return text(m.section) && optional(m.block, text) && optional(m.step, step) && optional(m.trail, strings) ? { type: 'picked', section: m.section, ...m.block ? { block: m.block as string } : {}, ...m.step === undefined ? {} : { step: m.step as number }, ...m.trail ? { trail: m.trail as string[] } : {} } : undefined
94
+ case 'selection': return m.selection === null || (record(m.selection) && text(m.selection.text) && anchor(m.selection)) ? { type: 'selection', selection: m.selection as (PreviewAnchor & { text: string }) | null } : undefined
95
+ case 'updated': return optional(m.revision, text) ? { type: 'updated', ...m.revision ? { revision: m.revision as string } : {} } : undefined
96
+ case 'report': return (m.level === 'error' || m.level === 'warning') && text(m.message) && optional(m.plugin, text) ? { type: 'report', level: m.level, message: m.message, ...m.plugin ? { plugin: m.plugin as string } : {} } : undefined
97
+ default: return undefined
98
+ }
99
+ }
100
+
101
+ export interface PreviewPage {
102
+ renderer: 'web' | 'slides'
103
+ document: string
104
+ origins: readonly string[]
105
+ revision(): string | undefined
106
+ position(): PreviewPosition | undefined
107
+ go(command: Extract<PreviewCommand, { type: 'go' }>): void
108
+ reveal(target: PreviewAnchor): void
109
+ setDark(dark: boolean): void
110
+ sectionOf?(element: Element): string | undefined
111
+ sectionElement?(section: string): HTMLElement | undefined
112
+ seen?(element: Element): boolean
113
+ stepOf?(anchor: Required<PreviewAnchor>): { step: number, trail: string[] } | undefined
114
+ }
115
+
116
+ export interface PreviewAgent {
117
+ moved(): void
118
+ updated(): void
119
+ redraw(): void
120
+ report(problem: { level: 'error' | 'warning', plugin?: string, message: string }): void
121
+ stop(): void
122
+ }
123
+
124
+ const FRAME_NAME = `${PREVIEW_NAME}:`
125
+ const STYLE_ID = 'presmium-preview-style'
126
+ const HIGHLIGHT = 'data-preview-highlight'
127
+ const HOVER = 'data-preview-hover'
128
+ const POSITIONED = 'data-preview-positioned'
129
+ const PIN_CLASS = 'pm-preview-pin'
130
+ const CONTROLS = 'a, button, input, select, textarea, label, summary, [contenteditable], [role="dialog"]'
131
+ const COMMANDS: PreviewCommandType[] = ['go', 'highlight', 'pick', 'pins', 'marks', 'appearance']
132
+
133
+ const STYLE = `
134
+ [${HIGHLIGHT}] { outline: 2px solid var(--pm-color-accent, #1d6b7a) !important; outline-offset: 4px; border-radius: 2px; }
135
+ [${HOVER}] { outline: 2px dashed var(--pm-color-accent, #1d6b7a) !important; outline-offset: 2px; cursor: crosshair; }
136
+ [${POSITIONED}] { position: relative; }
137
+ .${PIN_CLASS} { position: absolute; top: -10px; left: -10px; z-index: 50; min-width: 20px; height: 20px; padding: 0 5px; box-sizing: border-box; border-radius: 10px; background: var(--pm-color-accent, #1d6b7a); color: #fff; font: 600 12px/20px system-ui, sans-serif; text-align: center; pointer-events: none; }
138
+ `
139
+
140
+ function previewNonce(): string | undefined {
141
+ if (typeof window === 'undefined') return undefined
142
+ const given = new URL(window.location.href).searchParams.get(PREVIEW_PARAM)
143
+ if (given) return given
144
+ return window.name.startsWith(FRAME_NAME) ? window.name.slice(FRAME_NAME.length) || undefined : undefined
145
+ }
146
+
147
+ export function startPreviewAgent(page: PreviewPage): PreviewAgent | undefined {
148
+ if (typeof window === 'undefined' || window.parent === window || page.origins.length === 0) return undefined
149
+ const nonce = previewNonce()
150
+ if (!nonce) return undefined
151
+ window.name = `${FRAME_NAME}${nonce}`
152
+ const root = document.documentElement
153
+ root.dataset.presmiumPreview = 'on'
154
+ if (!document.getElementById(STYLE_ID)) {
155
+ const style = document.createElement('style')
156
+ style.id = STYLE_ID
157
+ style.textContent = STYLE
158
+ document.head.append(style)
159
+ }
160
+
161
+ const post = (message: PreviewReport) => {
162
+ for (const origin of page.origins) window.parent.postMessage(previewEnvelope(nonce, message), origin)
163
+ }
164
+
165
+ let highlight: PreviewAnchor | null = null
166
+ let pins: PreviewPin[] = []
167
+ let picking = false
168
+ let ready = false
169
+ const waiting: Extract<PreviewReport, { type: 'report' }>[] = []
170
+ let sent: string | undefined
171
+ let revision: string | undefined
172
+
173
+ const seen = (element: Element): boolean =>
174
+ !element.closest('[inert], .pm-hidden') && element.getClientRects().length > 0 && (page.seen?.(element) ?? true)
175
+ const seenBlock = (element: Element): HTMLElement | undefined => {
176
+ for (let block = element.closest<HTMLElement>('.pm-block[data-block]'); block; block = block.parentElement?.closest<HTMLElement>('.pm-block[data-block]') ?? null) {
177
+ if (seen(block)) return block
178
+ }
179
+ return undefined
180
+ }
181
+ const sectionOf = (element: Element): string | undefined =>
182
+ element.closest<HTMLElement>('[data-section]')?.dataset.section ?? page.sectionOf?.(element)
183
+ const blockElement = (section: string, block: string): HTMLElement | undefined => {
184
+ for (const element of document.querySelectorAll<HTMLElement>(`.pm-block[data-block="${CSS.escape(block)}"]`)) {
185
+ if (sectionOf(element) === section && seen(element)) return element
186
+ }
187
+ return undefined
188
+ }
189
+ const sectionElement = (section: string): HTMLElement | undefined => {
190
+ const own = page.sectionElement?.(section)
191
+ if (own) return own
192
+ const all = [...document.querySelectorAll<HTMLElement>(`[data-section="${CSS.escape(section)}"]`)]
193
+ return all.find(el => el.classList.contains('pm-section') && seen(el)) ?? all.find(seen)
194
+ }
195
+ const elementOf = (target: PreviewAnchor) => (target.block ? blockElement(target.section, target.block) : sectionElement(target.section))
196
+ const anchorOf = (element: Element): PreviewAnchor | undefined => {
197
+ const block = seenBlock(element)
198
+ const section = sectionOf(block ?? element)
199
+ if (!section) return undefined
200
+ return block ? { section, block: block.dataset.block! } : { section }
201
+ }
202
+
203
+ const drawHighlight = (scroll: boolean) => {
204
+ const element = highlight && elementOf(highlight)
205
+ for (const other of document.querySelectorAll(`[${HIGHLIGHT}]`)) if (other !== element) other.removeAttribute(HIGHLIGHT)
206
+ if (!element) return false
207
+ element.setAttribute(HIGHLIGHT, '')
208
+ if (scroll) {
209
+ const box = element.getBoundingClientRect()
210
+ if (box.bottom < 0 || box.top > window.innerHeight) element.scrollIntoView({ block: 'center' })
211
+ }
212
+ return true
213
+ }
214
+ const drawPins = () => {
215
+ const wanted = new Map<HTMLElement, PreviewPin[]>()
216
+ for (const pin of pins) {
217
+ const element = blockElement(pin.section, pin.block)
218
+ if (element) wanted.set(element, [...wanted.get(element) ?? [], pin])
219
+ }
220
+ for (const old of document.querySelectorAll<HTMLElement>(`.${PIN_CLASS}`)) {
221
+ const host = old.parentElement
222
+ if (!host || !wanted.get(host)?.some(pin => pin.id === old.dataset.pin && pin.label === old.textContent)) old.remove()
223
+ }
224
+ for (const host of document.querySelectorAll<HTMLElement>(`[${POSITIONED}]`)) {
225
+ if (!wanted.has(host)) host.removeAttribute(POSITIONED)
226
+ }
227
+ for (const [element, own] of wanted) {
228
+ if (getComputedStyle(element).position === 'static') element.setAttribute(POSITIONED, '')
229
+ own.forEach((pin, i) => {
230
+ if ([...element.children].some(child => child.classList.contains(PIN_CLASS) && (child as HTMLElement).dataset.pin === pin.id)) return
231
+ const mark = document.createElement('span')
232
+ mark.className = PIN_CLASS
233
+ mark.dataset.pin = pin.id
234
+ mark.textContent = pin.label
235
+ mark.setAttribute('aria-hidden', 'true')
236
+ if (i > 0) mark.style.left = `${-10 + 24 * i}px`
237
+ element.append(mark)
238
+ })
239
+ }
240
+ }
241
+ let frame = 0
242
+ const redraw = () => {
243
+ cancelAnimationFrame(frame)
244
+ frame = requestAnimationFrame(() => {
245
+ drawHighlight(false)
246
+ drawPins()
247
+ })
248
+ }
249
+ const showHighlight = (target: PreviewAnchor | null) => {
250
+ highlight = target
251
+ if (!target) return void drawHighlight(false)
252
+ if (drawHighlight(true)) return
253
+ page.reveal(target)
254
+ const until = Date.now() + 5000
255
+ const retry = () => {
256
+ if (highlight !== target || drawHighlight(true) || Date.now() > until) return
257
+ setTimeout(retry, 100)
258
+ }
259
+ setTimeout(retry, 50)
260
+ }
261
+
262
+ let hovered: Element | undefined
263
+ const hover = (event: MouseEvent) => {
264
+ const target = event.target instanceof Element ? event.target : undefined
265
+ const block = target && !target.closest(CONTROLS) ? seenBlock(target) : undefined
266
+ if (block === hovered) return
267
+ hovered?.removeAttribute(HOVER)
268
+ hovered = block
269
+ hovered?.setAttribute(HOVER, '')
270
+ }
271
+ const click = (event: MouseEvent) => {
272
+ const target = event.target instanceof Element ? event.target : undefined
273
+ if (!target || event.button !== 0 || target.closest(CONTROLS)) return
274
+ if (document.getSelection()?.isCollapsed === false) return
275
+ const anchor = anchorOf(target)
276
+ if (!anchor) return
277
+ event.preventDefault()
278
+ event.stopImmediatePropagation()
279
+ const at = anchor.block ? page.stepOf?.({ section: anchor.section, block: anchor.block }) : undefined
280
+ post({ type: 'picked', ...anchor, ...at ? { step: at.step, trail: at.trail } : {} })
281
+ }
282
+ const setPicking = (on: boolean) => {
283
+ if (on === picking) return
284
+ picking = on
285
+ if (on) {
286
+ document.addEventListener('mouseover', hover, true)
287
+ document.addEventListener('click', click, true)
288
+ return
289
+ }
290
+ document.removeEventListener('mouseover', hover, true)
291
+ document.removeEventListener('click', click, true)
292
+ hovered?.removeAttribute(HOVER)
293
+ hovered = undefined
294
+ }
295
+
296
+ let selected = false
297
+ let selecting: ReturnType<typeof setTimeout> | undefined
298
+ const selection = () => {
299
+ clearTimeout(selecting)
300
+ selecting = setTimeout(() => {
301
+ const range = document.getSelection()
302
+ const value = range && !range.isCollapsed ? range.toString().trim() : ''
303
+ const node = range?.rangeCount ? range.getRangeAt(0).commonAncestorContainer : undefined
304
+ const anchor = value && node ? anchorOf(node instanceof Element ? node : node.parentElement ?? document.body) : undefined
305
+ if (anchor) {
306
+ selected = true
307
+ post({ type: 'selection', selection: { ...anchor, text: value.slice(0, 4000) } })
308
+ } else if (selected) {
309
+ selected = false
310
+ post({ type: 'selection', selection: null })
311
+ }
312
+ }, 200)
313
+ }
314
+ document.addEventListener('selectionchange', selection)
315
+
316
+ const observer = new MutationObserver(() => {
317
+ if (highlight || pins.length > 0) redraw()
318
+ })
319
+ observer.observe(document.body, { childList: true, subtree: true })
320
+
321
+ const receive = (event: MessageEvent) => {
322
+ if (event.source !== window.parent || !page.origins.includes(event.origin)) return
323
+ const command = readPreviewCommand(event.data, nonce)
324
+ if (!command) return
325
+ try {
326
+ switch (command.type) {
327
+ case 'go': return page.go(command)
328
+ case 'highlight': return showHighlight(command.target)
329
+ case 'pick': return setPicking(command.on)
330
+ case 'pins':
331
+ pins = command.targets
332
+ return drawPins()
333
+ case 'marks': return void root.classList.toggle(AUTHOR_MARKS_CLASS, command.on)
334
+ case 'appearance': return page.setDark(command.dark)
335
+ }
336
+ } catch (error) {
337
+ console.error(`[presmium] the preview could not take ${command.type}: ${errorMessage(error)}`)
338
+ }
339
+ }
340
+ window.addEventListener('message', receive)
341
+
342
+ let timer: ReturnType<typeof setTimeout> | undefined
343
+ let last = 0
344
+ const sendPosition = () => {
345
+ timer = undefined
346
+ const position = page.position()
347
+ if (!position) return
348
+ if (!ready) {
349
+ ready = true
350
+ revision = page.revision()
351
+ post({ type: 'ready', renderer: page.renderer, document: page.document, ...revision ? { revision } : {}, capabilities: COMMANDS })
352
+ for (const problem of waiting.splice(0)) post(problem)
353
+ }
354
+ const key = JSON.stringify(position)
355
+ if (key === sent) return
356
+ sent = key
357
+ last = Date.now()
358
+ post({ type: 'position', ...position })
359
+ }
360
+ const moved = () => {
361
+ if (timer) return
362
+ timer = setTimeout(sendPosition, Math.max(0, 150 - (Date.now() - last)))
363
+ }
364
+ moved()
365
+
366
+ return {
367
+ moved,
368
+ updated() {
369
+ const next = page.revision()
370
+ if (!ready || next === revision) return
371
+ revision = next
372
+ post({ type: 'updated', ...next ? { revision: next } : {} })
373
+ redraw()
374
+ },
375
+ redraw,
376
+ report(problem) {
377
+ const message = { type: 'report' as const, ...problem }
378
+ if (ready) post(message)
379
+ else waiting.push(message)
380
+ },
381
+ stop() {
382
+ window.removeEventListener('message', receive)
383
+ document.removeEventListener('selectionchange', selection)
384
+ observer.disconnect()
385
+ setPicking(false)
386
+ clearTimeout(timer)
387
+ clearTimeout(selecting)
388
+ delete root.dataset.presmiumPreview
389
+ },
390
+ }
391
+ }
@@ -0,0 +1,158 @@
1
+ import type { Logger } from '../contracts.ts'
2
+ import { defineService } from './service.ts'
3
+ import type { ServiceToken } from './service.ts'
4
+
5
+ export type MemberRole = 'reader' | 'editor' | 'owner'
6
+
7
+ export interface Member {
8
+ id: string
9
+ name: string
10
+ email?: string
11
+ roles: MemberRole[]
12
+ }
13
+
14
+ export interface RequestContext {
15
+ member: Member
16
+ document: string
17
+ rest: string
18
+ }
19
+
20
+ export type RouteHandler = (request: Request, ctx: RequestContext) => Response | Promise<Response>
21
+
22
+ export interface Route {
23
+ role?: MemberRole
24
+ maxBody?: number
25
+ handle: RouteHandler
26
+ }
27
+
28
+ export const MAX_BODY = 1 << 20
29
+
30
+ export function json(data: unknown, status = 200, headers: Record<string, string> = {}): Response {
31
+ return new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers } })
32
+ }
33
+
34
+ export function problem(status: number, message: string): Response {
35
+ return json({ error: message }, status)
36
+ }
37
+
38
+ export interface ProxySpec {
39
+ path: string
40
+ target: string
41
+ headers?: Record<string, string>
42
+ role?: MemberRole
43
+ }
44
+
45
+ export interface ServerContribution {
46
+ routes?: Record<string, Route>
47
+ proxies?: ProxySpec[]
48
+ close?(): void | Promise<void>
49
+ }
50
+
51
+ export interface ServerContext {
52
+ deckDir: string
53
+ workspaceRoot: string
54
+ config: Record<string, unknown>
55
+ settings: Record<string, unknown>
56
+ lang?: string
57
+ service<T>(token: ServiceToken<T>): T | undefined
58
+ secret(name: string): string | undefined
59
+ operatorDir?: string
60
+ home?: boolean
61
+ log: Logger
62
+ }
63
+
64
+ export interface NodeServiceContext {
65
+ deckDir: string
66
+ workspaceRoot: string
67
+ config: Record<string, unknown>
68
+ settings: Record<string, unknown>
69
+ lang?: string
70
+ secret(name: string): string | undefined
71
+ operatorDir?: string
72
+ service?<T>(token: ServiceToken<T>): T | undefined
73
+ log: Logger
74
+ }
75
+
76
+ export type ApprovalKind = 'operate' | 'spend' | 'write' | 'agent'
77
+
78
+ export type ApprovalScope = 'content' | 'workspace'
79
+
80
+ export interface ApprovalRequest {
81
+ kind: ApprovalKind
82
+ scope?: ApprovalScope
83
+ title: string
84
+ detail?: string
85
+ from: string
86
+ member?: string
87
+ }
88
+
89
+ export interface Approvals {
90
+ request(request: ApprovalRequest, signal?: AbortSignal): Promise<boolean>
91
+ }
92
+
93
+ export const APPROVALS = defineService<Approvals>('presmium.approvals')
94
+
95
+ export function eventStream(events: AsyncIterable<{ event?: string, data: unknown }>, signal?: AbortSignal): Response {
96
+ const encoder = new TextEncoder()
97
+ const iterator = events[Symbol.asyncIterator]()
98
+ let closed = false
99
+ const stop = (controller?: ReadableStreamDefaultController<Uint8Array>) => {
100
+ if (closed) return
101
+ closed = true
102
+ void iterator.return?.()
103
+ try {
104
+ controller?.close()
105
+ } catch {
106
+ }
107
+ }
108
+ const stream = new ReadableStream<Uint8Array>({
109
+ start(controller) {
110
+ if (signal?.aborted) stop(controller)
111
+ else signal?.addEventListener('abort', () => stop(controller), { once: true })
112
+ },
113
+ async pull(controller) {
114
+ if (closed) return
115
+ try {
116
+ const next = await iterator.next()
117
+ if (closed) return
118
+ if (next.done) {
119
+ stop(controller)
120
+ return
121
+ }
122
+ const { event, data } = next.value
123
+ const lines = `${event ? `event: ${event}\n` : ''}${JSON.stringify(data ?? null).split('\n').map(line => `data: ${line}`).join('\n')}\n\n`
124
+ controller.enqueue(encoder.encode(lines))
125
+ } catch (error) {
126
+ closed = true
127
+ controller.error(error)
128
+ }
129
+ },
130
+ cancel() {
131
+ stop()
132
+ },
133
+ })
134
+ return new Response(stream, { headers: { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store', 'X-Accel-Buffering': 'no' } })
135
+ }
136
+
137
+ export type AgentMode = 'author'
138
+
139
+ export interface McpAccess {
140
+ url: string
141
+ token: string
142
+ }
143
+
144
+ export interface McpGrant extends McpAccess {
145
+ revoke(): void
146
+ }
147
+
148
+ /** @experimental */
149
+ export interface McpIssueOptions {
150
+ confirmed?: boolean
151
+ document?: string
152
+ }
153
+
154
+ export interface McpEndpoint {
155
+ issue(member: Member, mode: AgentMode, options?: McpIssueOptions): McpGrant
156
+ }
157
+
158
+ export const MCP_ENDPOINT = defineService<McpEndpoint>('presmium.mcpEndpoint')
@@ -0,0 +1,8 @@
1
+ export interface ServiceToken<T> {
2
+ readonly id: string
3
+ readonly __service?: T
4
+ }
5
+
6
+ export function defineService<T>(id: string): ServiceToken<T> {
7
+ return Object.freeze({ id }) as ServiceToken<T>
8
+ }
@@ -0,0 +1,137 @@
1
+ import { defineService } from './service.ts'
2
+
3
+ export interface SpeechWord { text: string, start: number, end: number }
4
+
5
+ const CJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u
6
+ const LETTER = /[\p{L}\p{N}'’]/u
7
+
8
+ export interface TextWord { text: string, start: number, end: number }
9
+
10
+ export function splitWords(text: string): TextWord[] {
11
+ const words: TextWord[] = []
12
+ let run: TextWord | undefined
13
+ let at = 0
14
+ for (const char of text) {
15
+ if (CJK.test(char)) {
16
+ if (run) words.push(run)
17
+ run = undefined
18
+ words.push({ text: char, start: at, end: at + char.length })
19
+ } else if (LETTER.test(char)) {
20
+ run ??= { text: '', start: at, end: at }
21
+ run.text += char
22
+ run.end = at + char.length
23
+ } else if (run) {
24
+ words.push(run)
25
+ run = undefined
26
+ }
27
+ at += char.length
28
+ }
29
+ if (run) words.push(run)
30
+ return words
31
+ }
32
+
33
+ export interface WordSpan { from: number, to: number, start: number, end: number }
34
+
35
+ export interface WrittenWord extends SpeechWord { offset?: number }
36
+
37
+ export function remapWords(words: readonly SpeechWord[], written: string, spoken: string, spans: readonly WordSpan[]): WrittenWord[] {
38
+ const places: { start: number, end: number }[] = []
39
+ let at = 0
40
+ for (const word of words) {
41
+ const index = word.text ? spoken.indexOf(word.text, at) : -1
42
+ if (index < 0) return words.map(w => ({ ...w }))
43
+ places.push({ start: index, end: at = index + word.text.length })
44
+ }
45
+ const writtenAt = (offset: number) => {
46
+ let shift = 0
47
+ for (const span of spans) {
48
+ if (offset < span.from) break
49
+ if (offset < span.to) return span.start
50
+ shift += (span.end - span.start) - (span.to - span.from)
51
+ }
52
+ return offset + shift
53
+ }
54
+ const result: WrittenWord[] = []
55
+ let group: { span: WordSpan, word: WrittenWord } | undefined
56
+ words.forEach((word, i) => {
57
+ const { start, end } = places[i]!
58
+ const span = spans.find(s => start < s.to && end > s.from)
59
+ if (span && group?.span === span) {
60
+ group.word.end = Math.max(group.word.end, word.end)
61
+ return
62
+ }
63
+ group = undefined
64
+ if (span) {
65
+ const placed = { text: written.slice(span.start, span.end), start: word.start, end: word.end, offset: span.start }
66
+ group = { span, word: placed }
67
+ result.push(placed)
68
+ } else {
69
+ result.push({ ...word, offset: writtenAt(start) })
70
+ }
71
+ })
72
+ return result
73
+ }
74
+
75
+ export interface Synthesis {
76
+ audio: Blob | string
77
+ mime: string
78
+ duration: number
79
+ words?: SpeechWord[]
80
+ key: string
81
+ engine: string
82
+ model: string
83
+ voice: string
84
+ synthetic: true
85
+ }
86
+
87
+ export interface TranscriptSegment { start: number, end: number, text: string, words: SpeechWord[] }
88
+
89
+ export interface Transcript {
90
+ lang: string
91
+ duration: number
92
+ text: string
93
+ segments: TranscriptSegment[]
94
+ }
95
+
96
+ export interface SpeechEngineInfo {
97
+ id: string
98
+ provider: string
99
+ stt: boolean
100
+ tts: boolean
101
+ wordTimes: boolean
102
+ local: boolean
103
+ }
104
+
105
+ export interface VoiceInfo {
106
+ id: string
107
+ label: string
108
+ lang: string[]
109
+ cloned?: boolean
110
+ }
111
+
112
+ export interface SynthesisRequest {
113
+ engine?: string
114
+ voice: string
115
+ text: string
116
+ lang: string
117
+ rate?: number
118
+ style?: string
119
+ lexicon?: Record<string, string>
120
+ }
121
+
122
+ export interface TranscriptionRequest {
123
+ engine?: string
124
+ audio: Blob
125
+ lang?: string
126
+ hints?: string[]
127
+ }
128
+
129
+ export interface SpeechService {
130
+ engines(): Promise<SpeechEngineInfo[]>
131
+ voices(engine: string, lang?: string): Promise<VoiceInfo[]>
132
+ synthesize(request: SynthesisRequest, signal?: AbortSignal): Promise<Synthesis>
133
+ cached?(key: string, signal?: AbortSignal): Promise<Synthesis | undefined>
134
+ transcribe(request: TranscriptionRequest, signal?: AbortSignal): Promise<Transcript>
135
+ }
136
+
137
+ export const SPEECH = defineService<SpeechService>('presmium.speech')