import { createStore } from "@xstate/store"; import type { MarkSet } from "@tooee/marks"; import type { AnyContent, Content, ContentChunk, ContentFormat } from "./types.js"; type ContentProviderResult = AnyContent | Promise | AsyncIterable; export type ContentLoaderStatus = "idle" | "loading" | "streaming" | "ready" | "error"; export interface ContentLoaderContext { status: ContentLoaderStatus; requestId: number; loadSeq: number; content: AnyContent | null; error: string | null; providerMarks: MarkSet[]; title: string | undefined; } interface ContentLoaderEventPayloads { loadStarted: { marks: MarkSet[]; title?: string }; streamStarted: { requestId: number; format: string }; chunkReceived: { requestId: number; chunk: ContentChunk }; loaded: { requestId: number; content: AnyContent }; streamEnded: { requestId: number }; loadFailed: { requestId: number; error: string }; loadCancelled: { requestId: number }; reloadRequested: Record; } export type ContentLoaderEvents = { [Event in keyof ContentLoaderEventPayloads]: ContentLoaderEventPayloads[Event]; }; export const isAsyncIterable = function isAsyncIterable( value: ContentProviderResult, ): value is AsyncIterable { return ( value !== null && value !== undefined && // oxlint-disable-next-line anti-slop/no-runtime-typeof -- provider result boundary distinguishes the documented async-iterable source from content and promises typeof value === "object" && Symbol.asyncIterator in value ); }; export const createEmptyContent = function createEmptyContent( format: string, title?: string, ): AnyContent { switch (format) { case "markdown": { return { format, markdown: "", title }; } case "code": { return { code: "", format, title }; } case "text": { return { format, text: "", title }; } case "table": { return { columns: [], format, rows: [], title }; } default: { return { data: undefined, format, title }; } } }; const ensureContentFormat = function ensureContentFormat( current: AnyContent | null, format: F, title?: string, ): Extract { if (!current || current.format !== format) { // SAFETY: createEmptyContent returns the built-in content variant selected by the same format. // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- format-dependent content is trusted at this provider boundary return createEmptyContent(format, title) as Extract; } // SAFETY: The equality guard narrows current to the built-in content variant selected by format. // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the provider contract supplies the matching format return current as Extract; }; export const applyContentChunk = function applyContentChunk( current: AnyContent | null, chunk: ContentChunk, title?: string, ): AnyContent { switch (chunk.type) { case "replace": { return chunk.content; } case "append": { if (chunk.format === "markdown") { const target = ensureContentFormat(current, "markdown", title); return { ...target, markdown: target.markdown + chunk.data }; } if (chunk.format === "code") { const target = ensureContentFormat(current, "code", title); return { ...target, code: target.code + chunk.data, language: chunk.language ?? target.language, }; } { const target = ensureContentFormat(current, "text", title); return { ...target, text: target.text + chunk.data }; } } case "patch": { return chunk.apply(current); } case "marks": { return current ?? createEmptyContent("markdown", title); } default: { return current ?? createEmptyContent("markdown", title); } } }; export const normalizeError = function normalizeError(cause: unknown): string { return cause instanceof Error ? cause.message : String(cause); }; export const createContentLoaderStore = function createContentLoaderStore() { return createStore({ context: { content: null, error: null, loadSeq: 0, providerMarks: [], requestId: 0, status: "idle", title: undefined, }, on: { chunkReceived: (ctx, event) => { if (event.requestId !== ctx.requestId) { return ctx; } if (event.chunk.type === "marks") { const markSet = event.chunk.set; const providerMarks = [ ...ctx.providerMarks.filter((set) => set.namespace !== markSet.namespace), markSet, ]; return { ...ctx, providerMarks }; } return { ...ctx, content: applyContentChunk(ctx.content, event.chunk, ctx.title) }; }, loadCancelled: (ctx, event) => event.requestId === ctx.requestId ? { ...ctx, requestId: ctx.requestId + 1 } : ctx, loadFailed: (ctx, event) => event.requestId === ctx.requestId ? { ...ctx, error: event.error, status: "error" } : ctx, loadStarted: (ctx, event) => ({ ...ctx, error: null, providerMarks: event.marks, requestId: ctx.requestId + 1, status: "loading", title: event.title, }), loaded: (ctx, event) => event.requestId === ctx.requestId ? { ...ctx, content: event.content, status: "ready" } : ctx, reloadRequested: (ctx) => ({ ...ctx, loadSeq: ctx.loadSeq + 1 }), streamEnded: (ctx, event) => event.requestId === ctx.requestId ? { ...ctx, status: "ready" } : ctx, streamStarted: (ctx, event) => event.requestId === ctx.requestId ? { ...ctx, content: createEmptyContent(event.format, ctx.title), status: "streaming", } : ctx, }, }); }; export const selectContent = (ctx: ContentLoaderContext) => ctx.content; export const selectStatus = (ctx: ContentLoaderContext) => ctx.status; export const selectStreaming = (ctx: ContentLoaderContext) => ctx.status === "streaming"; export const selectError = (ctx: ContentLoaderContext) => ctx.error; export const selectProviderMarks = (ctx: ContentLoaderContext) => ctx.providerMarks; export const selectLoadSeq = (ctx: ContentLoaderContext) => ctx.loadSeq;