@typstream/react 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +83 -2
- package/dist/index.d.ts +202 -0
- package/dist/index.js +287 -0
- package/package.json +62 -3
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
|
-
#
|
|
1
|
+
# @typstream/react
|
|
2
2
|
|
|
3
|
-
|
|
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
|
package/dist/index.d.ts
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
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.0",
|
|
36
|
+
"@typstream/dom": "0.1.0",
|
|
37
|
+
"@typstream/engine": "0.1.0"
|
|
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.0",
|
|
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
|
}
|