@typstream/react 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ikram Hasan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,84 @@
1
- # Temporary Holding Version
1
+ # @typstream/react
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ React bindings for typstream: render Typst streamed from an LLM, live. A thin layer over `@typstream/dom`'s `mountTypstream`. Most apps import the `typstream` package instead, which is this with html-compat and the math font set up.
4
+
5
+ ```tsx
6
+ import { Typstream } from "@typstream/react";
7
+
8
+ <Typstream source={text} isStreaming={status === "streaming"} animated caret />;
9
+ ```
10
+
11
+ ## `<Typstream>`
12
+
13
+ The document is `source`, its whole text so far (streamdown-style), or `stream`, an `AsyncIterable<string>` of chunks (create it once, e.g. in `useMemo`: a new stream starts the document over). Instances given the same stream share one reader, so StrictMode's remount loses no chunk.
14
+
15
+ | Prop | Default | |
16
+ |---|---|---|
17
+ | `source` / `stream` | | The document. |
18
+ | `isStreaming` | `false` | More `source` is coming. When it turns false the document is finished: compiled strictly (final mode), without the caret. Turning true again starts over (a regenerated answer). Ignored with `stream`, which finishes when it ends. |
19
+ | `target` | `"html"` | `"html"` (semantic HTML + MathML) or `"paged"` (SVG pages). Switching keeps the document; the old output stays until the new target's first compile. |
20
+ | `layout`, `pageHeight` | `"fixed"`, `"a4"` | Paged only. `"fit-width"` scales the pages to the element's width; `"reflow"` lays them out that wide (and `pageHeight` sets their height). |
21
+ | `preamble` | | Typst evaluated before the document, after the plugins' and the template's. |
22
+ | `template` | | Name of a template from the plugins' `templates`: its preamble and default `inputs`. |
23
+ | `inputs` | | `sys.inputs`, e.g. `{ showAnswers: "true" }` to recompile with answers instead of re-prompting. |
24
+ | `files` | | Files the document reads (images, data). |
25
+ | `heal` | | `@typstream/heal` options (lint settings, fences). |
26
+ | `schedule` | | Compile pacing (`minInterval`, `maxInterval`). |
27
+ | `plugins`, `stylesheets` | | Plugins' engine files and preambles, heal handlers, templates and sanitizer allowances; extra CSS for the HTML target. |
28
+ | `animated`, `caret` | `false` | Word fade-in (`true` or `AnimateOptions`) and a caret, while streaming (HTML target). |
29
+ | `theme` | `"auto"` | HTML target: `"light"`, `"dark"`, or the page's `color-scheme`. |
30
+ | `linkTarget`, `textLayer` | `"_blank"`, `true` | External links' `target`; paged text layer (selection, find). |
31
+ | `lazy` | `true` | Open and compile only near the viewport, or while streaming. |
32
+ | `engine` | shared | An `Engine` from `createEngine()`. Default: one engine worker for the page, shared by every instance. |
33
+ | `mathFont` | | URL of New Computer Modern Math, registered as a web font for MathML. |
34
+ | `onStatusChange`, `onDiagnostics` | | `idle` / `compiling` / `stale` / `error` / `done`; diagnostics in `source` coordinates. |
35
+ | `onRendered`, `onFallback`, `onWatchdog` | | Telemetry, `@typstream/core`'s event shapes: a render with its compile's timings, heal time and patches, fixes and fallback level; output that fell back (with the input and healed source); a compile the watchdog stopped. |
36
+ | `className`, `style`, `id`, `ref` | | The host element; `ref` receives a `TypstreamHandle`. |
37
+
38
+ `islands` (beta) maps island names to components: a plugin's markers become interactive components rendered through portals over their Typst fallback (`@typstream/mindmap/react`'s `mindmapIslands`).
39
+
40
+ Engine options (an adapter, fonts, packages for every document) aren't props: create an `Engine` with them and pass it as `engine`.
41
+
42
+ ## `TypstreamProvider`
43
+
44
+ App-wide settings for every `<Typstream>` below it: the same props except the document and `onStatusChange`. Lists (`plugins`, `stylesheets`) add up, records (`inputs`, `files`) merge, telemetry hooks (`onRendered`, `onFallback`, `onWatchdog`, `onDiagnostics`) are all called, outer ones first, and anything else an instance sets wins. One provider can wire every document to an analytics sink (`examples/nextjs-ai-sdk/app/telemetry.tsx`). Without an `engine`, the provider keeps the page's shared engine alive while it is mounted, so the worker survives answers coming and going.
45
+
46
+ ```tsx
47
+ <TypstreamProvider plugins={[edu]} theme="dark" heal={{ lint: { "currency-dollar": "warn" } }}>
48
+ <App />
49
+ </TypstreamProvider>
50
+ ```
51
+
52
+ ## `useTypstream()`
53
+
54
+ ```tsx
55
+ const t = useTypstream();
56
+ <Typstream ref={t.ref} source={text} isStreaming />;
57
+ t.status; // "idle" until the document opens
58
+ t.diagnostics; t.healedSource; t.openFrames; // e.g. "writing a table…"
59
+ t.document; // the EngineDocument, once it has opened
60
+ await t.exportPdf(); // Uint8Array, at the document's own page size (also on the ref's TypstreamHandle)
61
+ await t.exportPdf({ inputs: { showAnswers: "true" } }); // inputs for this export only, over the props'
62
+ await t.exportHtml({ title: "Quiz" }); // a standalone HTML file, with the component's plugins' CSS and theme
63
+ t.exportSource(); // the healed Typst the exports compile
64
+ ```
65
+
66
+ React refs don't announce when they're set, so the hook hands out a callback ref rather than reading yours. The exports
67
+ open a lazy document that hasn't opened yet, and reject (`exportSource()` throws) while no `<Typstream>` is mounted.
68
+
69
+ ## Behaviour
70
+
71
+ - **One worker, many documents.** Instances without an `engine` share one; it is created on first use and terminated a second after the last instance (or provider) unmounts. Compiles run one at a time across documents.
72
+ - **Only visible or streaming documents compile.** A document opens when its element comes within one screen of the viewport, or as soon as it streams; offscreen, finished ones apply prop changes when they scroll back.
73
+ - **StrictMode-safe.** The mount follows the component's lifetime; StrictMode's second mount opens a fresh document and the first is disposed. Unmounting disposes the document.
74
+ - **Server rendering.** The component renders an empty `<div>` on the server and starts in the browser. The bundle is marked `"use client"`.
75
+
76
+ ## Tests
77
+
78
+ - `pnpm test`: configuration merging and the shared engine's lifetime, in Node.
79
+ - `pnpm test:browser`: `<Typstream>` with React Testing Library against the real engine worker in Chromium, Firefox and WebKit (`BROWSERS=chromium` runs one).
80
+ - `pnpm size`: ≤ 35.3 KB gzipped with `@typstream/dom` (React and the engine external).
81
+
82
+ ## License
83
+
84
+ MIT
@@ -0,0 +1,202 @@
1
+ import { IslandProps, Diagnostic } from '@typstream/core';
2
+ export { AppliedFix, CompileReport, CompiledEvent, DocumentEvent, FallbackEvent, FallbackLevel, IslandProps, RenderedEvent, WatchdogEvent } from '@typstream/core';
3
+ import { TypstreamOptions, TypstreamExportOptions, TypstreamHtmlExportOptions } from '@typstream/dom';
4
+ export { StandaloneHtmlOptions, TypstFont, TypstreamExportOptions, TypstreamHtmlExportOptions } from '@typstream/dom';
5
+ import { Engine, EngineDocument, DocumentState, DocumentStatus } from '@typstream/engine';
6
+ export { DocumentState, DocumentStatus, Engine } from '@typstream/engine';
7
+ import * as react from 'react';
8
+ import { ComponentType, ReactNode, Ref, CSSProperties } from 'react';
9
+
10
+ /**
11
+ * A React component for an island: it renders into an element positioned
12
+ * over the marker's fallback, which shows until the component renders
13
+ * something (while its code loads, say); paint your own background. @beta
14
+ */
15
+ type IslandComponent<Data = any> = ComponentType<IslandProps<Data>>;
16
+ /** Island components by name, as plugins name them in their `islands` facet. @beta */
17
+ type IslandComponents = Readonly<Record<string, IslandComponent>>;
18
+
19
+ /**
20
+ * Callbacks for logging and telemetry. The library sends nothing itself; the
21
+ * app decides what to collect (render latency, heal patches, fixer hits,
22
+ * fallbacks as candidate test cases, watchdog strikes).
23
+ * @public
24
+ */
25
+ type TelemetryHooks = Pick<TypstreamOptions, "onRendered" | "onFallback" | "onWatchdog" | "onDiagnostics">;
26
+
27
+ /**
28
+ * Settings that make sense for every document of an app. Everything except
29
+ * `engine`, `resolveImage`, `islands` and plugins' functions is serializable, so it can
30
+ * be stored per organization or per user.
31
+ * @public
32
+ */
33
+ interface TypstreamDefaults extends Pick<TypstreamOptions, "target" | "layout" | "pageHeight" | "preamble" | "template" | "inputs" | "files" | "resolveImage" | "heal" | "schedule" | "plugins" | "stylesheets" | "animated" | "caret" | "theme" | "fonts" | "linkTarget" | "textLayer" | "renderer" | "lazy">, TelemetryHooks {
34
+ /**
35
+ * The engine every `<Typstream>` below compiles with, unless it is given its
36
+ * own. Default: one engine worker for the page, shared by all instances.
37
+ */
38
+ engine?: Engine;
39
+ /**
40
+ * URL of the math font for MathML (HTML target), registered as a web font
41
+ * when a document first renders. The engine's copy is in
42
+ * `@typstream/engine-wasm/fonts`; the `typstream` package sets it for you.
43
+ */
44
+ mathFont?: string | URL;
45
+ /**
46
+ * Interactive components by island name (PLAN §8.4), e.g. `@typstream/mindmap/react`'s
47
+ * `{ mindmap: MindMapIsland }`: a marker becomes an island when a plugin
48
+ * declares its name and a component is given for it. Providers' and the
49
+ * component's merge, the inner one winning by name. @beta
50
+ */
51
+ islands?: IslandComponents;
52
+ }
53
+ /** Props of {@link TypstreamProvider}. @public */
54
+ interface TypstreamProviderProps extends TypstreamDefaults {
55
+ /** The app, or the part of it these settings apply to. */
56
+ children?: ReactNode;
57
+ }
58
+ /**
59
+ * App-wide settings for the `<Typstream>`s below it. Nested providers merge:
60
+ * the inner one's settings win. Without an `engine`, it keeps the page's
61
+ * default engine alive while it is mounted, so the worker survives documents
62
+ * coming and going (a chat switching conversations).
63
+ * @public
64
+ */
65
+ declare const TypstreamProvider: ({ children, ...defaults }: TypstreamProviderProps) => react.JSX.Element;
66
+
67
+ /**
68
+ * Props of {@link Typstream}: the document (`source` or `stream`), how to
69
+ * compile and show it, and callbacks. Unset settings come from the nearest
70
+ * {@link TypstreamProvider}, then the defaults.
71
+ * @public
72
+ */
73
+ interface TypstreamProps extends TypstreamDefaults, Pick<TypstreamOptions, "source" | "stream" | "isStreaming" | "streamingRange" | "onStatusChange"> {
74
+ /** Receives the instance's {@link TypstreamHandle}, e.g. `useTypstream().ref`. */
75
+ ref?: Ref<TypstreamHandle>;
76
+ /** Of the element the document renders into. */
77
+ className?: string;
78
+ /** Of the element the document renders into. */
79
+ style?: CSSProperties;
80
+ /** Of the element the document renders into. */
81
+ id?: string;
82
+ }
83
+ /**
84
+ * A `<Typstream>` instance, for reading its document from outside (see
85
+ * {@link useTypstream}). The same object for the component's lifetime.
86
+ * @public
87
+ */
88
+ interface TypstreamHandle {
89
+ /** The element the document renders into, while mounted. */
90
+ readonly element: HTMLElement | null;
91
+ /** The engine document, once it has opened (documents open lazily, near the viewport). */
92
+ readonly document: EngineDocument | null;
93
+ /** The document's state; `null` before it opens. */
94
+ getState(): DocumentState | null;
95
+ /** Calls `listener` whenever `getState()` or `document` changes. */
96
+ subscribe(listener: () => void): () => void;
97
+ /**
98
+ * Exports the document as PDF (#18), compiled by the engine that renders the
99
+ * preview. It compiles with the template's inputs, the `inputs` prop and
100
+ * `options.inputs` (a teacher's copy: `{ showAnswers: "true" }`), but not a
101
+ * `layout="reflow"` preview's page size, so it prints at the document's
102
+ * own size. Opens a lazy document that hasn't opened yet. Rejects with the
103
+ * engine's `ExportError`, or an `Error` when the component isn't mounted.
104
+ */
105
+ exportPdf(options?: TypstreamExportOptions): Promise<Uint8Array>;
106
+ /**
107
+ * Exports the document as an HTML file that opens on its own, laid out as
108
+ * the pages (see `standalonePages` in `@typstream/dom`; `pages: false` for
109
+ * `standaloneHtml`'s reading layout), with the component's plugins,
110
+ * `stylesheets`, `theme` and Typst fonts. `options` add to or override them
111
+ * (a `mathFont`, a `title`). Otherwise as {@link TypstreamHandle.exportPdf},
112
+ * `options.inputs` included.
113
+ */
114
+ exportHtml(options?: TypstreamHtmlExportOptions): Promise<string>;
115
+ /**
116
+ * Exports the document as an editable DOCX file that Word lays out as the PDF (#131). Otherwise as
117
+ * {@link TypstreamHandle.exportPdf}, `options.inputs` included. @beta
118
+ */
119
+ exportDocx(options?: TypstreamExportOptions): Promise<Uint8Array>;
120
+ /**
121
+ * The source the exports compile: the healed input, as a `.typ` file would
122
+ * hold it. Opens a lazy document that hasn't opened yet. Throws when the
123
+ * component isn't mounted.
124
+ */
125
+ exportSource(): string;
126
+ }
127
+ /**
128
+ * Renders a streaming Typst document: pass the text so far as `source` while
129
+ * `isStreaming`, or a `stream` of chunks. Heals each prefix, compiles it in the
130
+ * engine's worker and shows HTML (default) or pages, keeping the last good
131
+ * output while a prefix doesn't compile.
132
+ *
133
+ * @example
134
+ * ```tsx
135
+ * <Typstream source={part.text} isStreaming={status === "streaming"} animated caret />
136
+ * ```
137
+ * @public
138
+ */
139
+ declare const Typstream: ({ ref, className, style, id, ...props }: TypstreamProps) => react.JSX.Element;
140
+
141
+ /** What {@link useTypstream} returns. @public */
142
+ interface TypstreamInfo {
143
+ /** Pass to `<Typstream ref>`. Stable. */
144
+ ref: (handle: TypstreamHandle | null) => void;
145
+ /** The instance, once mounted. */
146
+ handle: TypstreamHandle | null;
147
+ /** The document's whole state; `null` until it opens. */
148
+ state: DocumentState | null;
149
+ /** `idle` until the document opens. */
150
+ status: DocumentStatus;
151
+ /** Diagnostics of the latest compile, in `source` coordinates. */
152
+ diagnostics: readonly Diagnostic[];
153
+ /** The healed source the engine compiles (empty until the document opens). */
154
+ healedSource: string;
155
+ /** Constructs still open at the end of the input, innermost first. */
156
+ openFrames: DocumentState["heal"]["openFrames"];
157
+ /**
158
+ * The engine document. For exports, prefer `exportPdf()` and friends: the
159
+ * document's own exports use its inputs, which include a reflow preview's
160
+ * page size.
161
+ */
162
+ document: EngineDocument | null;
163
+ /**
164
+ * Exports the document as PDF, at its own page size (a reflow preview's
165
+ * width left out), with the template's inputs, the `inputs` prop and
166
+ * `options.inputs`. Opens a lazy document that hasn't opened yet. Rejects
167
+ * with the engine's `ExportError`, or an `Error` when no `<Typstream>` is
168
+ * mounted. See {@link TypstreamHandle.exportPdf}.
169
+ */
170
+ exportPdf(options?: TypstreamExportOptions): Promise<Uint8Array>;
171
+ /** Exports the document as an editable DOCX file. See {@link TypstreamHandle.exportDocx}. @beta */
172
+ exportDocx(options?: TypstreamExportOptions): Promise<Uint8Array>;
173
+ /**
174
+ * Exports the document as an HTML file that opens on its own, with the
175
+ * component's plugins, `stylesheets` and `theme`; `options` add to or
176
+ * override them. See {@link TypstreamHandle.exportHtml}.
177
+ */
178
+ exportHtml(options?: TypstreamHtmlExportOptions): Promise<string>;
179
+ /** The healed source the exports compile. Throws when no `<Typstream>` is mounted. */
180
+ exportSource(): string;
181
+ }
182
+ /**
183
+ * Reads a `<Typstream>`'s document: status, diagnostics, healed source; and
184
+ * exports it (`exportPdf()`, `exportHtml()`, `exportSource()`, as on
185
+ * {@link TypstreamHandle}).
186
+ *
187
+ * @example
188
+ * ```tsx
189
+ * const t = useTypstream();
190
+ * return (
191
+ * <>
192
+ * <Typstream ref={t.ref} source={text} isStreaming />
193
+ * {t.status === "stale" ? <p>Showing the last good render…</p> : null}
194
+ * <button onClick={async () => save(await t.exportPdf())}>Download PDF</button>
195
+ * </>
196
+ * );
197
+ * ```
198
+ * @public
199
+ */
200
+ declare const useTypstream: () => TypstreamInfo;
201
+
202
+ export { type IslandComponent, type IslandComponents, type TelemetryHooks, Typstream, type TypstreamDefaults, type TypstreamHandle, type TypstreamInfo, type TypstreamProps, TypstreamProvider, type TypstreamProviderProps, useTypstream };
package/dist/index.js ADDED
@@ -0,0 +1,287 @@
1
+ "use client";
2
+
3
+ // src/context.tsx
4
+ import { createContext, useContext, useEffect } from "react";
5
+
6
+ // src/engine.ts
7
+ import { createEngine, engineWorker } from "@typstream/engine";
8
+ var RELEASE_DELAY = 1e3;
9
+ var factory = () => createEngine({ spawn: engineWorker(), clientInfo: { name: "@typstream/react" } });
10
+ var engine = null;
11
+ var users = 0;
12
+ var timer;
13
+ var acquireEngine = () => {
14
+ clearTimeout(timer);
15
+ users++;
16
+ engine ??= factory();
17
+ return engine;
18
+ };
19
+ var releaseEngine = () => {
20
+ users = Math.max(0, users - 1);
21
+ if (users > 0) return;
22
+ clearTimeout(timer);
23
+ timer = setTimeout(() => {
24
+ if (users > 0) return;
25
+ engine?.dispose();
26
+ engine = null;
27
+ }, RELEASE_DELAY);
28
+ };
29
+
30
+ // src/telemetry.ts
31
+ var HOOKS = ["onRendered", "onFallback", "onWatchdog", "onDiagnostics"];
32
+ var mergeHooks = (outer, inner) => {
33
+ const merged = {};
34
+ for (const name of HOOKS) {
35
+ const a = outer[name];
36
+ const b = inner[name];
37
+ const both = (...args) => {
38
+ a?.(...args);
39
+ b?.(...args);
40
+ };
41
+ const hook = a && b ? both : a ?? b;
42
+ if (hook) merged[name] = hook;
43
+ }
44
+ return merged;
45
+ };
46
+
47
+ // src/context.tsx
48
+ import { jsx } from "react/jsx-runtime";
49
+ var Context = createContext({});
50
+ var useTypstreamDefaults = () => useContext(Context);
51
+ var TypstreamProvider = ({ children, ...defaults }) => {
52
+ const outer = useContext(Context);
53
+ const own = defaults.engine;
54
+ useEffect(() => {
55
+ if (own) return;
56
+ acquireEngine();
57
+ return releaseEngine;
58
+ }, [own]);
59
+ return /* @__PURE__ */ jsx(Context, { value: mergeDefaults(outer, defaults), children });
60
+ };
61
+ var dedupePlugins = (plugins) => {
62
+ if (!plugins || plugins.length < 2) return plugins;
63
+ const byKey = new Map(plugins.map((p) => [p.key, p]));
64
+ return byKey.size === plugins.length ? plugins : [...byKey.values()];
65
+ };
66
+ var concat = (a, b) => a && b ? [...a, ...b] : b ?? a;
67
+ var assign = (a, b) => a && b ? { ...a, ...b } : b ?? a;
68
+ var mergeDefaults = (outer, inner) => {
69
+ const merged = { ...outer };
70
+ for (const [k, v] of Object.entries(inner)) if (v !== void 0) merged[k] = v;
71
+ const plugins = dedupePlugins(concat(outer.plugins, inner.plugins));
72
+ const stylesheets = concat(outer.stylesheets, inner.stylesheets);
73
+ const inputs = assign(outer.inputs, inner.inputs);
74
+ const files = assign(outer.files, inner.files);
75
+ const islands = assign(outer.islands, inner.islands);
76
+ return {
77
+ ...merged,
78
+ ...plugins ? { plugins } : {},
79
+ ...stylesheets ? { stylesheets: [...new Set(stylesheets)] } : {},
80
+ ...inputs ? { inputs } : {},
81
+ ...files ? { files } : {},
82
+ ...islands ? { islands } : {},
83
+ ...mergeHooks(outer, inner)
84
+ };
85
+ };
86
+
87
+ // src/Typstream.tsx
88
+ import {
89
+ mountTypstream,
90
+ registerMathFont
91
+ } from "@typstream/dom";
92
+ import {
93
+ useEffect as useEffect2,
94
+ useImperativeHandle,
95
+ useMemo,
96
+ useRef,
97
+ useState,
98
+ useSyncExternalStore
99
+ } from "react";
100
+
101
+ // src/islands.tsx
102
+ import { Component, Suspense } from "react";
103
+ import { createPortal } from "react-dom";
104
+ import { jsx as jsx2 } from "react/jsx-runtime";
105
+ var createIslandStore = () => {
106
+ let entries = [];
107
+ let ids = 0;
108
+ const listeners = /* @__PURE__ */ new Set();
109
+ const set = (next) => {
110
+ entries = next;
111
+ for (const listener of listeners) listener();
112
+ };
113
+ return {
114
+ subscribe(listener) {
115
+ listeners.add(listener);
116
+ return () => {
117
+ listeners.delete(listener);
118
+ };
119
+ },
120
+ get: () => entries,
121
+ /** dom renderers for `names` that record their islands here. */
122
+ renderers(names) {
123
+ const render = (host, props) => {
124
+ const id = ids++;
125
+ set([...entries, { id, host, props }]);
126
+ return {
127
+ update: (next) => set(entries.map((e) => e.id === id ? { ...e, props: next } : e)),
128
+ destroy: () => set(entries.filter((e) => e.id !== id))
129
+ };
130
+ };
131
+ return Object.fromEntries(names.map((name) => [name, render]));
132
+ }
133
+ };
134
+ };
135
+ var IslandBoundary = class extends Component {
136
+ state = { failed: false };
137
+ static getDerivedStateFromError() {
138
+ return { failed: true };
139
+ }
140
+ componentDidCatch(error) {
141
+ globalThis.reportError?.(error);
142
+ }
143
+ render() {
144
+ return this.state.failed ? null : this.props.children;
145
+ }
146
+ };
147
+ var islandPortals = (entries, components) => entries.map(({ id, host, props }) => {
148
+ const Island = components && Object.hasOwn(components, props.name) ? components[props.name] : void 0;
149
+ if (!Island) return null;
150
+ return createPortal(
151
+ /* @__PURE__ */ jsx2(IslandBoundary, { children: /* @__PURE__ */ jsx2(Suspense, { fallback: null, children: /* @__PURE__ */ jsx2(Island, { ...props }) }) }),
152
+ host,
153
+ String(id)
154
+ );
155
+ });
156
+
157
+ // src/Typstream.tsx
158
+ import { Fragment, jsx as jsx3, jsxs } from "react/jsx-runtime";
159
+ var createHandle = () => {
160
+ let mounted = null;
161
+ let stop = () => {
162
+ };
163
+ const listeners = /* @__PURE__ */ new Set();
164
+ const notify = () => {
165
+ for (const listener of listeners) listener();
166
+ };
167
+ const mount = () => {
168
+ if (!mounted) throw new Error("<Typstream> isn't mounted");
169
+ return mounted;
170
+ };
171
+ const handle = {
172
+ get element() {
173
+ return mounted?.element ?? null;
174
+ },
175
+ get document() {
176
+ return mounted?.document ?? null;
177
+ },
178
+ getState: () => mounted?.getState() ?? null,
179
+ subscribe(listener) {
180
+ listeners.add(listener);
181
+ return () => listeners.delete(listener);
182
+ },
183
+ exportPdf: async (options) => mount().exportPdf(options),
184
+ exportHtml: async (options) => mount().exportHtml(options),
185
+ exportDocx: async (options) => mount().exportDocx(options),
186
+ exportSource: () => mount().exportSource()
187
+ };
188
+ const attach = (next) => {
189
+ stop();
190
+ mounted = next;
191
+ stop = next ? next.subscribe(notify) : () => {
192
+ };
193
+ notify();
194
+ };
195
+ return { handle, attach };
196
+ };
197
+ var registered = /* @__PURE__ */ new Set();
198
+ var useMathFont = (url) => {
199
+ useEffect2(() => {
200
+ if (!url || registered.has(String(url))) return;
201
+ registered.add(String(url));
202
+ registerMathFont(url).catch((error) => {
203
+ registered.delete(String(url));
204
+ globalThis.reportError?.(error);
205
+ });
206
+ }, [url]);
207
+ };
208
+ var Typstream = ({ ref, className, style, id, ...props }) => {
209
+ const { engine: explicit, mathFont, islands: components, ...rest } = mergeDefaults(useTypstreamDefaults(), props);
210
+ const [store] = useState(createIslandStore);
211
+ const names = Object.keys(components ?? {}).sort().join("\n");
212
+ const islands = useMemo(() => names ? store.renderers(names.split("\n")) : void 0, [store, names]);
213
+ const entries = useSyncExternalStore(store.subscribe, store.get, store.get);
214
+ const options = islands ? { ...rest, islands } : rest;
215
+ const element = useRef(null);
216
+ const mounted = useRef(null);
217
+ const [{ handle, attach }] = useState(createHandle);
218
+ useImperativeHandle(ref, () => handle, [handle]);
219
+ useMathFont(mathFont);
220
+ const latest = useRef(options);
221
+ latest.current = options;
222
+ useEffect2(() => {
223
+ const el = element.current;
224
+ if (!el) return;
225
+ const engine2 = explicit ?? acquireEngine();
226
+ const view = mountTypstream(el, { ...latest.current, engine: engine2 });
227
+ mounted.current = { view, engine: engine2 };
228
+ attach(view);
229
+ return () => {
230
+ attach(null);
231
+ mounted.current = null;
232
+ view.destroy();
233
+ if (!explicit) releaseEngine();
234
+ };
235
+ }, [explicit, attach]);
236
+ useEffect2(() => {
237
+ const m = mounted.current;
238
+ if (m) m.view.update({ ...options, engine: m.engine });
239
+ });
240
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
241
+ /* @__PURE__ */ jsx3(
242
+ "div",
243
+ {
244
+ ref: element,
245
+ className: className ? `typstream-host ${className}` : "typstream-host",
246
+ style,
247
+ id
248
+ }
249
+ ),
250
+ islandPortals(entries, components)
251
+ ] });
252
+ };
253
+
254
+ // src/useTypstream.ts
255
+ import { useCallback, useMemo as useMemo2, useState as useState2, useSyncExternalStore as useSyncExternalStore2 } from "react";
256
+ var EMPTY = [];
257
+ var noSubscription = () => () => {
258
+ };
259
+ var useTypstream = () => {
260
+ const [handle, setHandle] = useState2(null);
261
+ const subscribe = useCallback((listener) => handle?.subscribe(listener) ?? noSubscription(), [handle]);
262
+ const read = () => handle?.getState() ?? null;
263
+ const state = useSyncExternalStore2(subscribe, read, () => null);
264
+ const document = handle?.document ?? null;
265
+ return useMemo2(() => {
266
+ const { exportPdf, exportDocx, exportHtml, exportSource } = handle ?? createHandle().handle;
267
+ return {
268
+ ref: setHandle,
269
+ handle,
270
+ state,
271
+ status: state?.status ?? "idle",
272
+ diagnostics: state?.diagnostics ?? EMPTY,
273
+ healedSource: state?.heal.source ?? "",
274
+ openFrames: state?.heal.openFrames ?? EMPTY,
275
+ document,
276
+ exportPdf,
277
+ exportDocx,
278
+ exportHtml,
279
+ exportSource
280
+ };
281
+ }, [handle, state, document]);
282
+ };
283
+ export {
284
+ Typstream,
285
+ TypstreamProvider,
286
+ useTypstream
287
+ };
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
1
  {
2
2
  "name": "@typstream/react",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "React bindings for typstream: <Typstream>, useTypstream() and TypstreamProvider, for Typst streamed from an LLM.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/ikramhasan/typstream.git",
9
+ "directory": "packages/react"
10
+ },
11
+ "homepage": "https://typstream.vercel.app/docs/reference/react",
12
+ "bugs": "https://github.com/ikramhasan/typstream/issues",
13
+ "keywords": [
14
+ "typst",
15
+ "streaming",
16
+ "llm",
17
+ "react"
18
+ ],
19
+ "type": "module",
20
+ "sideEffects": false,
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "default": "./dist/index.js"
25
+ }
26
+ },
27
+ "files": [
28
+ "dist"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public",
32
+ "provenance": true
33
+ },
34
+ "dependencies": {
35
+ "@typstream/core": "0.1.1",
36
+ "@typstream/dom": "0.1.1",
37
+ "@typstream/engine": "0.1.1"
38
+ },
39
+ "peerDependencies": {
40
+ "react": "^19.0.0",
41
+ "react-dom": "^19.0.0"
42
+ },
43
+ "devDependencies": {
44
+ "@testing-library/dom": "^10.4.2",
45
+ "@testing-library/react": "^16.3.3",
46
+ "@types/react": "^19.3.0",
47
+ "@types/react-dom": "^19.3.0",
48
+ "@typstream/corpus": "0.0.0",
49
+ "@typstream/html-compat": "0.1.1",
50
+ "@vitest/browser-playwright": "4.1.12",
51
+ "playwright": "^1.64.1",
52
+ "react": "^19.3.1",
53
+ "react-dom": "^19.3.1"
54
+ },
55
+ "scripts": {
56
+ "build": "tsup",
57
+ "test": "vitest run --project node",
58
+ "test:browser": "vitest run --project browser",
59
+ "size": "node scripts/size.mjs",
60
+ "check-types": "tsc -p tsconfig.json",
61
+ "check:api": "api-extractor run",
62
+ "api:update": "api-extractor run --local",
63
+ "check:package": "publint --strict --pack npm && attw --pack . --profile esm-only"
64
+ }
6
65
  }