@slexkit/streamdown 0.2.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 +291 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.js +297 -0
- package/package.json +68 -0
- package/style.css +95 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SlexKit contributors
|
|
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
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# @slexkit/streamdown
|
|
2
|
+
|
|
3
|
+
Streamdown custom renderer for Slex fenced UI blocks.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
npm install slexkit @slexkit/theme-shadcn @slexkit/streamdown streamdown react react-dom
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Import both style sheets in your app:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import "@slexkit/theme-shadcn/style.css";
|
|
15
|
+
import "@slexkit/streamdown/style.css";
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Minimal Usage
|
|
19
|
+
|
|
20
|
+
```tsx
|
|
21
|
+
import { Streamdown } from "streamdown";
|
|
22
|
+
import { slexkitRenderer } from "@slexkit/streamdown";
|
|
23
|
+
|
|
24
|
+
export function Message({ markdown }: { markdown: string }) {
|
|
25
|
+
return (
|
|
26
|
+
<Streamdown plugins={{ renderers: [slexkitRenderer] }}>
|
|
27
|
+
{markdown}
|
|
28
|
+
</Streamdown>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Vercel AI SDK
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
import { useChat } from "@ai-sdk/react";
|
|
37
|
+
import { Streamdown } from "streamdown";
|
|
38
|
+
import { slexkitRenderer } from "@slexkit/streamdown";
|
|
39
|
+
|
|
40
|
+
export function Chat() {
|
|
41
|
+
const { messages, status } = useChat();
|
|
42
|
+
|
|
43
|
+
return messages.map((message) =>
|
|
44
|
+
message.parts.map((part, index) =>
|
|
45
|
+
part.type === "text" ? (
|
|
46
|
+
<Streamdown
|
|
47
|
+
key={index}
|
|
48
|
+
isAnimating={status === "streaming"}
|
|
49
|
+
plugins={{ renderers: [slexkitRenderer] }}
|
|
50
|
+
>
|
|
51
|
+
{part.text}
|
|
52
|
+
</Streamdown>
|
|
53
|
+
) : null,
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Model Output
|
|
60
|
+
|
|
61
|
+
Ask the model to emit Slex only in explicit `slex` fenced blocks. The body is JavaScript-compatible Slex source: a plain object literal with native JS state and expression strings.
|
|
62
|
+
|
|
63
|
+
````md
|
|
64
|
+
```slex
|
|
65
|
+
{
|
|
66
|
+
namespace: "status_demo",
|
|
67
|
+
g: {},
|
|
68
|
+
layout: {
|
|
69
|
+
"text:status": { text: "3/4 checks complete" }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Status:** 3/4 checks complete
|
|
75
|
+
````
|
|
76
|
+
|
|
77
|
+
The renderer intentionally handles only `slex` fences by default. It does not sniff generic JavaScript code blocks.
|
|
78
|
+
|
|
79
|
+
To show the same Slex source inside the embedded playground UI, keep the `slex` fence and switch the renderer mode with fence meta:
|
|
80
|
+
|
|
81
|
+
````md
|
|
82
|
+
```slex render="playground" title="Editable demo"
|
|
83
|
+
{
|
|
84
|
+
namespace: "editable_demo",
|
|
85
|
+
layout: {
|
|
86
|
+
"text:status": { text: "Rendered inside the playground" }
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
````
|
|
91
|
+
|
|
92
|
+
You can also make playground display the default for a Markdown surface:
|
|
93
|
+
|
|
94
|
+
```tsx
|
|
95
|
+
const renderer = createSlexKitRenderer({ renderMode: "playground" });
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Fence meta resolution
|
|
99
|
+
|
|
100
|
+
Fence meta values resolve to the render mode via a flexible parser:
|
|
101
|
+
|
|
102
|
+
| Meta value | Resolved mode |
|
|
103
|
+
|------------|--------------|
|
|
104
|
+
| `render="playground"`, `as="playground"`, `mode="playground"` | `playground` |
|
|
105
|
+
| `render="editor"`, `render="workbench"` | `playground` |
|
|
106
|
+
| `render="component"`, `as="component"`, `as="preview"` | `component` |
|
|
107
|
+
| No meta | Fallback to `renderMode` option |
|
|
108
|
+
|
|
109
|
+
Playground meta also supports `title`, `mode`, `height`/`previewMinHeight`, `webUrl`/`playgroundUrl`, and `pluginVersion`.
|
|
110
|
+
|
|
111
|
+
## Streaming behavior
|
|
112
|
+
|
|
113
|
+
During streaming (`isIncomplete: true`), the renderer shows a placeholder instead of attempting to parse partial Slex source. The placeholder defaults to "Rendering SlexKit..." and can be customized:
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
<SlexKitRenderer
|
|
117
|
+
code={code}
|
|
118
|
+
isIncomplete={true}
|
|
119
|
+
placeholder={<Spinner />}
|
|
120
|
+
/>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
State-only blocks (source with `namespace` and `g` but no renderable `layout`) are processed via `ingest()` and return `null` from the component — they update state silently without rendering DOM.
|
|
124
|
+
|
|
125
|
+
## Trust Boundary
|
|
126
|
+
|
|
127
|
+
Slex source is JavaScript-compatible object literal syntax with runtime expressions. This package is intended for trusted or controlled model output. Do not treat it as a sandbox for arbitrary third-party Markdown.
|
|
128
|
+
|
|
129
|
+
For untrusted model output, opt into the secure runtime. The renderer will pass the fence source text into a sandbox frame and route sensitive capabilities through host policy enforced `api.*` methods:
|
|
130
|
+
|
|
131
|
+
```tsx
|
|
132
|
+
import { createSlexKitRenderer } from "@slexkit/streamdown";
|
|
133
|
+
import "@slexkit/theme-shadcn/style.css";
|
|
134
|
+
import "@slexkit/streamdown/style.css";
|
|
135
|
+
|
|
136
|
+
const renderer = createSlexKitRenderer({
|
|
137
|
+
runtime: "secure",
|
|
138
|
+
securePolicy: {
|
|
139
|
+
network: {
|
|
140
|
+
enabled: true,
|
|
141
|
+
methods: ["GET", "POST"],
|
|
142
|
+
allowOrigins: ["https://api.example.com"],
|
|
143
|
+
credentials: "omit",
|
|
144
|
+
timeoutMs: 15000,
|
|
145
|
+
maxBodyBytes: 4096,
|
|
146
|
+
},
|
|
147
|
+
timer: { enabled: true, maxTimers: 8, minIntervalMs: 16 },
|
|
148
|
+
animation: { enabled: true },
|
|
149
|
+
canvas: { enabled: true, maxCanvases: 4, maxPixels: 1048576, allowedContexts: ["2d"] },
|
|
150
|
+
execution: { heartbeatIntervalMs: 1000, maxUnresponsiveMs: 5000 },
|
|
151
|
+
},
|
|
152
|
+
secureFrame: {
|
|
153
|
+
runtimeUrl: "/slexkit.runtime.js",
|
|
154
|
+
loadTimeoutMs: 8000,
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The secure renderer uses the main `slexkit.runtime.js` module as its iframe runner. You do not need to ship a second runner file.
|
|
160
|
+
|
|
161
|
+
Copy the runtime file into your public static directory:
|
|
162
|
+
|
|
163
|
+
```sh
|
|
164
|
+
npx -y slexkit copy-runtime public/slexkit.runtime.js
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The file must be served by your app or CDN with these response headers:
|
|
168
|
+
|
|
169
|
+
```http
|
|
170
|
+
Access-Control-Allow-Origin: *
|
|
171
|
+
Content-Type: text/javascript
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
This header is configured in your deployment layer, not inside `createSlexKitRenderer()`. Secure iframes intentionally run with an opaque origin, so the browser treats `runtimeUrl` as a cross-origin ES module import even when the URL is on your own site. Do not add `allow-same-origin` to the iframe sandbox to bypass this; that weakens isolation.
|
|
175
|
+
|
|
176
|
+
If the iframe cannot load or mount the runtime, SlexKit renders a `role="alert"` diagnostic next to the iframe and logs the same message to `console.error`. Tune the diagnostic delay with `secureFrame.loadTimeoutMs`.
|
|
177
|
+
|
|
178
|
+
For the full trust boundary and capability contract, see the root package security runtime reference.
|
|
179
|
+
|
|
180
|
+
Examples:
|
|
181
|
+
|
|
182
|
+
```js
|
|
183
|
+
// next.config.js
|
|
184
|
+
export default {
|
|
185
|
+
async headers() {
|
|
186
|
+
return [
|
|
187
|
+
{
|
|
188
|
+
source: "/slexkit.runtime.js",
|
|
189
|
+
headers: [
|
|
190
|
+
{ key: "Access-Control-Allow-Origin", value: "*" },
|
|
191
|
+
{ key: "Content-Type", value: "text/javascript" },
|
|
192
|
+
],
|
|
193
|
+
},
|
|
194
|
+
];
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
```text
|
|
200
|
+
# Netlify / Cloudflare Pages _headers
|
|
201
|
+
/slexkit.runtime.js
|
|
202
|
+
Access-Control-Allow-Origin: *
|
|
203
|
+
Content-Type: text/javascript
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
If your Markdown surface already owns a page-level SlexKit runtime, pass it into the renderer so block mount/unmount events are delegated to that host:
|
|
207
|
+
|
|
208
|
+
```tsx
|
|
209
|
+
import {
|
|
210
|
+
createSlexKitMarkdownRuntimeHost,
|
|
211
|
+
} from "slexkit";
|
|
212
|
+
import { createSlexKitRenderer } from "@slexkit/streamdown";
|
|
213
|
+
|
|
214
|
+
const runtimeHost = createSlexKitMarkdownRuntimeHost({
|
|
215
|
+
mode: "secure",
|
|
216
|
+
policy,
|
|
217
|
+
secureFrame: {
|
|
218
|
+
runtimeUrl: "/slexkit.runtime.js",
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const renderer = createSlexKitRenderer({
|
|
223
|
+
runtimeHost,
|
|
224
|
+
});
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## Custom Renderer Options
|
|
228
|
+
|
|
229
|
+
```tsx
|
|
230
|
+
import { createSlexKitRenderer } from "@slexkit/streamdown";
|
|
231
|
+
|
|
232
|
+
export const renderer = createSlexKitRenderer({
|
|
233
|
+
languages: ["slex"], // Fence language tags to process (default: ["slex"])
|
|
234
|
+
renderMode: "component", // "component" | "playground"
|
|
235
|
+
runtime: "trusted", // "trusted" | "secure"
|
|
236
|
+
domain: "my-app", // Namespace prefix for blocks
|
|
237
|
+
showChrome: true, // Show CodeBlockContainer + toolbar (default: true)
|
|
238
|
+
showSource: false, // Show source code in a details element
|
|
239
|
+
className: "my-block", // Extra CSS class on wrapper
|
|
240
|
+
useGlobalRuntimeHost: false, // Use global markdown runtime host
|
|
241
|
+
playgroundUrl: "/playground.html", // Custom playground iframe URL
|
|
242
|
+
placeholder: null, // ReactNode shown during streaming
|
|
243
|
+
onError(error, code) {
|
|
244
|
+
console.error("Failed to render SlexKit", error, code);
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
### Standalone component usage
|
|
250
|
+
|
|
251
|
+
`SlexKitRenderer` is also exported for direct use without Streamdown:
|
|
252
|
+
|
|
253
|
+
```tsx
|
|
254
|
+
import { SlexKitRenderer } from "@slexkit/streamdown";
|
|
255
|
+
|
|
256
|
+
function MyBlock() {
|
|
257
|
+
return (
|
|
258
|
+
<SlexKitRenderer
|
|
259
|
+
code={`{
|
|
260
|
+
namespace: "inline",
|
|
261
|
+
g: {},
|
|
262
|
+
layout: { "text:hello": { text: "Hello" } }
|
|
263
|
+
}`}
|
|
264
|
+
language="slex"
|
|
265
|
+
renderMode="component"
|
|
266
|
+
/>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
## Error handling
|
|
272
|
+
|
|
273
|
+
Parse errors display a diagnostic panel with line, column, excerpt, and detail. Runtime mount errors render a similar alert. The `onError` callback fires for both cases, receiving the error object and the source code string.
|
|
274
|
+
|
|
275
|
+
## Documentation
|
|
276
|
+
|
|
277
|
+
- [SlexKit Security runtime](https://github.com/slexkit/slexkit/blob/main/site/content/reference/security/en-US.md)
|
|
278
|
+
- [SlexKit Host integration](https://github.com/slexkit/slexkit/blob/main/site/content/reference/integration/en-US.md)
|
|
279
|
+
- [SlexKit Usage guide](https://github.com/slexkit/slexkit/blob/main/site/content/reference/usage/en-US.md)
|
|
280
|
+
|
|
281
|
+
## Release validation
|
|
282
|
+
|
|
283
|
+
The package is considered release-ready when the repository checks pass:
|
|
284
|
+
|
|
285
|
+
```sh
|
|
286
|
+
bun run build
|
|
287
|
+
bun run test
|
|
288
|
+
bun run smoke:release
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Those checks verify the public renderer exports, `style.css` subpath export, trusted rendering, secure frame rendering, runtime-host delegation, state-only fences, domain isolation, embedded playground mode, and package version sync.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { type CustomRenderer, type CustomRendererProps } from "streamdown";
|
|
3
|
+
import { type SlexKitMarkdownRuntimeHost, type HostRuntimeAdapter, type HostRuntimePolicy, type SecureFrameOptions } from "slexkit";
|
|
4
|
+
export type SlexKitRendererOptions = {
|
|
5
|
+
languages?: string | string[];
|
|
6
|
+
domain?: string;
|
|
7
|
+
renderMode?: "component" | "playground";
|
|
8
|
+
runtime?: "trusted" | "secure";
|
|
9
|
+
runtimeHost?: SlexKitMarkdownRuntimeHost;
|
|
10
|
+
useGlobalRuntimeHost?: boolean;
|
|
11
|
+
securePolicy?: HostRuntimePolicy;
|
|
12
|
+
hostAdapter?: HostRuntimeAdapter;
|
|
13
|
+
secureFrame?: boolean | SecureFrameOptions;
|
|
14
|
+
playgroundUrl?: string;
|
|
15
|
+
showChrome?: boolean;
|
|
16
|
+
showSource?: boolean;
|
|
17
|
+
placeholder?: ReactNode;
|
|
18
|
+
className?: string;
|
|
19
|
+
onError?: (error: unknown, code: string) => void;
|
|
20
|
+
};
|
|
21
|
+
export type SlexKitRendererProps = CustomRendererProps & Omit<SlexKitRendererOptions, "languages">;
|
|
22
|
+
export declare function SlexKitRenderer({ code, language, isIncomplete, meta, domain, renderMode, runtime, runtimeHost, useGlobalRuntimeHost, securePolicy, hostAdapter, secureFrame, playgroundUrl, showChrome, showSource, placeholder, className, onError, }: SlexKitRendererProps): import("react").DetailedReactHTMLElement<{
|
|
23
|
+
className: string;
|
|
24
|
+
}, HTMLElement> | import("react").FunctionComponentElement<import("react").ClassAttributes<HTMLDivElement> & import("react").HTMLAttributes<HTMLDivElement> & {
|
|
25
|
+
language: string;
|
|
26
|
+
isIncomplete?: boolean;
|
|
27
|
+
}> | null;
|
|
28
|
+
export declare function createSlexKitRenderer(options?: SlexKitRendererOptions): CustomRenderer;
|
|
29
|
+
export declare const slexkitRenderer: CustomRenderer;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { createElement, Fragment, useEffect, useMemo, useRef, useState, } from "react";
|
|
2
|
+
import { CodeBlockContainer, CodeBlockHeader, } from "streamdown";
|
|
3
|
+
import { ingest, getSlexKitMarkdownRuntimeHost, mount, mountSecureArtifact, parseSlexSource, } from "slexkit";
|
|
4
|
+
const DEFAULT_LANGUAGES = ["slex"];
|
|
5
|
+
const STREAMDOWN_RENDERER_VERSION = "0.2.0";
|
|
6
|
+
const DEFAULT_SECURE_POLICY = {};
|
|
7
|
+
function languageList(languages) {
|
|
8
|
+
return languages ?? [...DEFAULT_LANGUAGES];
|
|
9
|
+
}
|
|
10
|
+
async function copySource(code) {
|
|
11
|
+
if (navigator.clipboard?.writeText) {
|
|
12
|
+
await navigator.clipboard.writeText(code);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const textarea = document.createElement("textarea");
|
|
16
|
+
textarea.value = code;
|
|
17
|
+
textarea.setAttribute("readonly", "");
|
|
18
|
+
textarea.style.position = "fixed";
|
|
19
|
+
textarea.style.opacity = "0";
|
|
20
|
+
document.body.appendChild(textarea);
|
|
21
|
+
textarea.select();
|
|
22
|
+
document.execCommand("copy");
|
|
23
|
+
textarea.remove();
|
|
24
|
+
}
|
|
25
|
+
function defaultPlaceholder() {
|
|
26
|
+
return createElement("div", { className: "slex-streamdown-placeholder" }, "Rendering SlexKit...");
|
|
27
|
+
}
|
|
28
|
+
function parseFenceOptions(meta = "") {
|
|
29
|
+
const options = {};
|
|
30
|
+
for (const match of String(meta).matchAll(/([A-Za-z0-9_-]+)=("([^"]*)"|'([^']*)'|([^\s]+))/g)) {
|
|
31
|
+
options[match[1]] = match[3] ?? match[4] ?? match[5] ?? "";
|
|
32
|
+
}
|
|
33
|
+
return options;
|
|
34
|
+
}
|
|
35
|
+
function resolveRenderMode(meta, fallback) {
|
|
36
|
+
const options = parseFenceOptions(meta);
|
|
37
|
+
const value = String(options.render ?? options.as ?? options.mode ?? "").toLowerCase();
|
|
38
|
+
if (value === "playground" || value === "editor" || value === "workbench")
|
|
39
|
+
return "playground";
|
|
40
|
+
if (value === "component" || value === "render" || value === "preview")
|
|
41
|
+
return "component";
|
|
42
|
+
return fallback;
|
|
43
|
+
}
|
|
44
|
+
function errorMessage(error) {
|
|
45
|
+
return error instanceof Error ? error.message : String(error);
|
|
46
|
+
}
|
|
47
|
+
function errorDiagnostic(error) {
|
|
48
|
+
if (!isRecord(error))
|
|
49
|
+
return null;
|
|
50
|
+
const diagnostic = error.diagnostic;
|
|
51
|
+
if (!isRecord(diagnostic))
|
|
52
|
+
return null;
|
|
53
|
+
if (typeof diagnostic.message !== "string" ||
|
|
54
|
+
typeof diagnostic.line !== "number" ||
|
|
55
|
+
typeof diagnostic.column !== "number" ||
|
|
56
|
+
typeof diagnostic.excerpt !== "string") {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
message: diagnostic.message,
|
|
61
|
+
line: diagnostic.line,
|
|
62
|
+
column: diagnostic.column,
|
|
63
|
+
detail: typeof diagnostic.detail === "string" ? diagnostic.detail : undefined,
|
|
64
|
+
excerpt: diagnostic.excerpt,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function renderError(error) {
|
|
68
|
+
const diagnostic = errorDiagnostic(error);
|
|
69
|
+
if (!diagnostic) {
|
|
70
|
+
return createElement("div", { className: "slex-streamdown-error", role: "alert" }, createElement("div", { className: "slex-streamdown-error-title" }, "Failed to render SlexKit"), createElement("div", { className: "slex-streamdown-error-message" }, errorMessage(error)));
|
|
71
|
+
}
|
|
72
|
+
return createElement("div", { className: "slex-streamdown-error", role: "alert" }, createElement("div", { className: "slex-streamdown-error-title" }, "SlexKit syntax error"), createElement("div", { className: "slex-streamdown-error-message" }, diagnostic.message), createElement("div", { className: "slex-streamdown-error-location" }, `Line ${diagnostic.line}, column ${diagnostic.column}`), diagnostic.detail
|
|
73
|
+
? createElement("div", { className: "slex-streamdown-error-detail" }, diagnostic.detail)
|
|
74
|
+
: null, createElement("pre", { className: "slex-streamdown-error-excerpt" }, diagnostic.excerpt));
|
|
75
|
+
}
|
|
76
|
+
function isRecord(value) {
|
|
77
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
78
|
+
}
|
|
79
|
+
function isRenderableSource(value) {
|
|
80
|
+
if (!isRecord(value))
|
|
81
|
+
return false;
|
|
82
|
+
if ("layout" in value)
|
|
83
|
+
return isRecord(value.layout) && Object.keys(value.layout).length > 0;
|
|
84
|
+
if ("namespace" in value || "g" in value)
|
|
85
|
+
return false;
|
|
86
|
+
return Object.keys(value).some((key) => key.includes(":"));
|
|
87
|
+
}
|
|
88
|
+
function isStateOnlySource(value) {
|
|
89
|
+
return isRecord(value)
|
|
90
|
+
&& !isRenderableSource(value)
|
|
91
|
+
&& ("slex" in value || "namespace" in value || "g" in value);
|
|
92
|
+
}
|
|
93
|
+
function scopedSlexKitInput(code, source, domain) {
|
|
94
|
+
if (!domain || !isRecord(source) || !("slex" in source || "namespace" in source || "g" in source || "layout" in source))
|
|
95
|
+
return code;
|
|
96
|
+
const namespace = String(source.namespace || "default");
|
|
97
|
+
if (!("layout" in source) && isRenderableSource(source)) {
|
|
98
|
+
const { slex, namespace: _namespace, g, layout: _layout, ...bareLayout } = source;
|
|
99
|
+
return {
|
|
100
|
+
...(typeof slex === "string" ? { slex } : {}),
|
|
101
|
+
namespace: `${domain}::${namespace}`,
|
|
102
|
+
...(isRecord(g) ? { g } : {}),
|
|
103
|
+
layout: bareLayout,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
...source,
|
|
108
|
+
namespace: `${domain}::${namespace}`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function playgroundOption(meta, key, fallback = "") {
|
|
112
|
+
return parseFenceOptions(meta)[key] || fallback;
|
|
113
|
+
}
|
|
114
|
+
function playgroundSlexKitInput(code, meta, domain, playgroundUrl) {
|
|
115
|
+
const options = parseFenceOptions(meta);
|
|
116
|
+
const namespace = domain || "streamdown";
|
|
117
|
+
return {
|
|
118
|
+
slex: "0.1",
|
|
119
|
+
namespace: `${namespace}::playground`,
|
|
120
|
+
g: {},
|
|
121
|
+
layout: {
|
|
122
|
+
"playground:inline": {
|
|
123
|
+
domain: `${namespace}::playground`,
|
|
124
|
+
mode: options.mode || options.webMode || "render",
|
|
125
|
+
playgroundUrl: options.webUrl || options.playgroundUrl || playgroundUrl || "/playground.html",
|
|
126
|
+
pluginVersion: options.pluginVersion || STREAMDOWN_RENDERER_VERSION,
|
|
127
|
+
previewMinHeight: playgroundOption(meta, "previewMinHeight", playgroundOption(meta, "height", "360px")),
|
|
128
|
+
source: code,
|
|
129
|
+
sourceType: "slex",
|
|
130
|
+
title: playgroundOption(meta, "title", "SlexKit playground"),
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function PlaygroundShell({ code, domain, meta, playgroundUrl, }) {
|
|
136
|
+
const hostRef = useRef(null);
|
|
137
|
+
const [loadError, setLoadError] = useState(null);
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
const host = hostRef.current;
|
|
140
|
+
if (!host)
|
|
141
|
+
return;
|
|
142
|
+
host.replaceChildren();
|
|
143
|
+
setLoadError(null);
|
|
144
|
+
let active = true;
|
|
145
|
+
let cleanup;
|
|
146
|
+
void import("slexkit/tooling")
|
|
147
|
+
.then(() => {
|
|
148
|
+
if (!active)
|
|
149
|
+
return;
|
|
150
|
+
cleanup = mount(playgroundSlexKitInput(code, meta, domain, playgroundUrl), host);
|
|
151
|
+
})
|
|
152
|
+
.catch((err) => {
|
|
153
|
+
if (active)
|
|
154
|
+
setLoadError(err);
|
|
155
|
+
});
|
|
156
|
+
return () => {
|
|
157
|
+
active = false;
|
|
158
|
+
cleanup?.();
|
|
159
|
+
host.replaceChildren();
|
|
160
|
+
};
|
|
161
|
+
}, [code, domain, meta, playgroundUrl]);
|
|
162
|
+
return createElement(Fragment, null, loadError ? renderError(loadError) : null, createElement("div", { ref: hostRef }));
|
|
163
|
+
}
|
|
164
|
+
export function SlexKitRenderer({ code, language, isIncomplete, meta, domain, renderMode = "component", runtime = "trusted", runtimeHost, useGlobalRuntimeHost = false, securePolicy = DEFAULT_SECURE_POLICY, hostAdapter, secureFrame = true, playgroundUrl, showChrome = true, showSource = false, placeholder, className, onError, }) {
|
|
165
|
+
const hostRef = useRef(null);
|
|
166
|
+
const [error, setError] = useState(null);
|
|
167
|
+
const isSecureRuntime = runtime === "secure";
|
|
168
|
+
const activeRuntimeHost = runtimeHost ?? (useGlobalRuntimeHost ? getSlexKitMarkdownRuntimeHost() : undefined);
|
|
169
|
+
const activeRuntimeMode = activeRuntimeHost?.getMode();
|
|
170
|
+
const delegatesToSecureHost = activeRuntimeMode === "secure";
|
|
171
|
+
const parsedSource = useMemo(() => (isIncomplete || isSecureRuntime || delegatesToSecureHost ? undefined : parseSlexSource(code)), [code, delegatesToSecureHost, isIncomplete, isSecureRuntime]);
|
|
172
|
+
const sourceKind = useMemo(() => {
|
|
173
|
+
if (isIncomplete)
|
|
174
|
+
return "renderable";
|
|
175
|
+
if (isSecureRuntime || delegatesToSecureHost)
|
|
176
|
+
return "renderable";
|
|
177
|
+
return parsedSource?.ok && isStateOnlySource(parsedSource.value) ? "state-only" : "renderable";
|
|
178
|
+
}, [delegatesToSecureHost, isIncomplete, isSecureRuntime, parsedSource]);
|
|
179
|
+
const isStateOnly = sourceKind === "state-only";
|
|
180
|
+
const runtimeInput = useMemo(() => isSecureRuntime || delegatesToSecureHost ? String(code) : scopedSlexKitInput(code, parsedSource?.ok ? parsedSource.value : undefined, domain), [code, delegatesToSecureHost, domain, isSecureRuntime, parsedSource]);
|
|
181
|
+
const effectiveRenderMode = useMemo(() => resolveRenderMode(meta, renderMode), [meta, renderMode]);
|
|
182
|
+
const parseError = parsedSource && !parsedSource.ok ? parsedSource.error : null;
|
|
183
|
+
const displayError = parseError ?? error;
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
setError(null);
|
|
186
|
+
if (isIncomplete)
|
|
187
|
+
return;
|
|
188
|
+
if (parseError) {
|
|
189
|
+
onError?.(parseError, String(code));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (isStateOnly) {
|
|
193
|
+
if (!ingest(runtimeInput)) {
|
|
194
|
+
const err = new Error("Failed to parse Slex state block.");
|
|
195
|
+
setError(err);
|
|
196
|
+
onError?.(err, String(code));
|
|
197
|
+
}
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (effectiveRenderMode === "playground")
|
|
201
|
+
return;
|
|
202
|
+
const host = hostRef.current;
|
|
203
|
+
if (!host)
|
|
204
|
+
return;
|
|
205
|
+
host.replaceChildren();
|
|
206
|
+
let cleanup;
|
|
207
|
+
try {
|
|
208
|
+
cleanup = activeRuntimeHost
|
|
209
|
+
? activeRuntimeHost.mountBlock({
|
|
210
|
+
artifactId: domain,
|
|
211
|
+
source: runtimeInput,
|
|
212
|
+
container: host,
|
|
213
|
+
theme: undefined,
|
|
214
|
+
})
|
|
215
|
+
: isSecureRuntime
|
|
216
|
+
? mountSecureArtifact(runtimeInput, host, {
|
|
217
|
+
policy: securePolicy,
|
|
218
|
+
hostAdapter,
|
|
219
|
+
frame: secureFrame,
|
|
220
|
+
})
|
|
221
|
+
: mount(runtimeInput, host);
|
|
222
|
+
if (!host.querySelector(".slexkit-root")) {
|
|
223
|
+
if (delegatesToSecureHost)
|
|
224
|
+
return;
|
|
225
|
+
if (isSecureRuntime && host.querySelector("iframe[data-slexkit-secure-frame='true']"))
|
|
226
|
+
return;
|
|
227
|
+
throw new Error("SlexKit did not render a root. Check the source syntax.");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
try {
|
|
232
|
+
cleanup?.();
|
|
233
|
+
}
|
|
234
|
+
finally {
|
|
235
|
+
host.replaceChildren();
|
|
236
|
+
}
|
|
237
|
+
setError(err);
|
|
238
|
+
onError?.(err, String(code));
|
|
239
|
+
}
|
|
240
|
+
return () => {
|
|
241
|
+
cleanup?.();
|
|
242
|
+
host.replaceChildren();
|
|
243
|
+
};
|
|
244
|
+
}, [
|
|
245
|
+
code,
|
|
246
|
+
activeRuntimeHost,
|
|
247
|
+
delegatesToSecureHost,
|
|
248
|
+
effectiveRenderMode,
|
|
249
|
+
hostAdapter,
|
|
250
|
+
isIncomplete,
|
|
251
|
+
isSecureRuntime,
|
|
252
|
+
isStateOnly,
|
|
253
|
+
onError,
|
|
254
|
+
parseError,
|
|
255
|
+
runtimeInput,
|
|
256
|
+
secureFrame,
|
|
257
|
+
securePolicy,
|
|
258
|
+
]);
|
|
259
|
+
if (isStateOnly)
|
|
260
|
+
return null;
|
|
261
|
+
const body = createElement("div", { className: "slex-streamdown-body" }, isIncomplete ? (placeholder ?? defaultPlaceholder()) : null, !isIncomplete && effectiveRenderMode === "playground"
|
|
262
|
+
? createElement(PlaygroundShell, {
|
|
263
|
+
code: String(code),
|
|
264
|
+
domain,
|
|
265
|
+
meta,
|
|
266
|
+
playgroundUrl,
|
|
267
|
+
})
|
|
268
|
+
: createElement(Fragment, null, displayError ? renderError(displayError) : null, createElement("div", { ref: hostRef })));
|
|
269
|
+
if (!showChrome) {
|
|
270
|
+
return createElement("div", {
|
|
271
|
+
className: ["slex-streamdown-block", className].filter(Boolean).join(" "),
|
|
272
|
+
}, body, showSource
|
|
273
|
+
? createElement("details", { className: "slex-streamdown-source" }, createElement("summary", null, "Source"), createElement("pre", null, createElement("code", null, code)))
|
|
274
|
+
: null);
|
|
275
|
+
}
|
|
276
|
+
return createElement(CodeBlockContainer, {
|
|
277
|
+
className: ["slex-streamdown-block", className].filter(Boolean).join(" "),
|
|
278
|
+
isIncomplete,
|
|
279
|
+
language,
|
|
280
|
+
}, createElement(CodeBlockHeader, { language }), createElement("div", { className: "slex-streamdown-toolbar" }, createElement("button", {
|
|
281
|
+
className: "slex-streamdown-copy",
|
|
282
|
+
onClick: () => {
|
|
283
|
+
void copySource(code);
|
|
284
|
+
},
|
|
285
|
+
type: "button",
|
|
286
|
+
}, "Copy source")), body, showSource
|
|
287
|
+
? createElement("details", { className: "slex-streamdown-source" }, createElement("summary", null, "Source"), createElement("pre", null, createElement("code", null, code)))
|
|
288
|
+
: null);
|
|
289
|
+
}
|
|
290
|
+
export function createSlexKitRenderer(options = {}) {
|
|
291
|
+
const { languages, ...rendererOptions } = options;
|
|
292
|
+
return {
|
|
293
|
+
language: languageList(languages),
|
|
294
|
+
component: (props) => createElement(SlexKitRenderer, { ...props, ...rendererOptions }),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
export const slexkitRenderer = createSlexKitRenderer();
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@slexkit/streamdown",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Streamdown custom renderer for SlexKit fenced UI blocks.",
|
|
5
|
+
"author": "SlexKit contributors",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/slexkit/slexkit.git",
|
|
10
|
+
"directory": "packages/streamdown"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/slexkit/slexkit/tree/main/packages/streamdown#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/slexkit/slexkit/issues"
|
|
15
|
+
},
|
|
16
|
+
"main": "./dist/index.js",
|
|
17
|
+
"module": "./dist/index.js",
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"import": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./style.css": "./style.css"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist/",
|
|
28
|
+
"style.css",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"sideEffects": [
|
|
33
|
+
"./style.css"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "bun run scripts/build.ts"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"slexkit",
|
|
40
|
+
"streamdown",
|
|
41
|
+
"react",
|
|
42
|
+
"markdown",
|
|
43
|
+
"renderer"
|
|
44
|
+
],
|
|
45
|
+
"license": "MIT",
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"slexkit": "^0.2.0",
|
|
51
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
52
|
+
"react-dom": "^18.0.0 || ^19.0.0",
|
|
53
|
+
"streamdown": "^2.5.0"
|
|
54
|
+
},
|
|
55
|
+
"peerDependenciesMeta": {
|
|
56
|
+
"slexkit": {
|
|
57
|
+
"optional": true
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"devDependencies": {
|
|
61
|
+
"@types/react": "^19.2.7",
|
|
62
|
+
"@types/react-dom": "^19.2.3",
|
|
63
|
+
"react": "^19.2.6",
|
|
64
|
+
"react-dom": "^19.2.6",
|
|
65
|
+
"slexkit": "file:../..",
|
|
66
|
+
"streamdown": "^2.5.0"
|
|
67
|
+
}
|
|
68
|
+
}
|
package/style.css
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
.slex-streamdown-block {
|
|
2
|
+
margin-block: 0.75rem;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.slex-streamdown-toolbar {
|
|
6
|
+
display: flex;
|
|
7
|
+
justify-content: flex-end;
|
|
8
|
+
gap: 0.5rem;
|
|
9
|
+
padding: 0.5rem 0.75rem 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.slex-streamdown-copy {
|
|
13
|
+
border: 1px solid var(--border);
|
|
14
|
+
border-radius: calc(var(--radius) - 2px);
|
|
15
|
+
background: var(--secondary);
|
|
16
|
+
color: var(--secondary-foreground);
|
|
17
|
+
cursor: pointer;
|
|
18
|
+
font: inherit;
|
|
19
|
+
font-size: 0.75rem;
|
|
20
|
+
line-height: 1;
|
|
21
|
+
padding: 0.4rem 0.6rem;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.slex-streamdown-copy:hover {
|
|
25
|
+
background: var(--accent);
|
|
26
|
+
color: var(--accent-foreground);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.slex-streamdown-body {
|
|
30
|
+
padding: 0.75rem;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.slex-streamdown-placeholder {
|
|
34
|
+
border: 1px dashed var(--border);
|
|
35
|
+
border-radius: calc(var(--radius) - 2px);
|
|
36
|
+
color: var(--muted-foreground);
|
|
37
|
+
padding: 1.5rem;
|
|
38
|
+
text-align: center;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.slex-streamdown-error {
|
|
42
|
+
border: 1px dashed var(--destructive);
|
|
43
|
+
border-radius: calc(var(--radius) - 2px);
|
|
44
|
+
color: var(--destructive);
|
|
45
|
+
padding: 1rem;
|
|
46
|
+
text-align: left;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.slex-streamdown-error-title {
|
|
50
|
+
font-weight: 700;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.slex-streamdown-error-message {
|
|
54
|
+
margin-top: 0.5rem;
|
|
55
|
+
color: var(--foreground);
|
|
56
|
+
font-family: var(--font-mono, "Geist Mono", "Noto Sans Mono", "Noto Sans Mono CJK SC", "SFMono-Regular", "Cascadia Code", Consolas, monospace);
|
|
57
|
+
font-size: 0.8125rem;
|
|
58
|
+
line-height: 1.5;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.slex-streamdown-error-location,
|
|
62
|
+
.slex-streamdown-error-detail {
|
|
63
|
+
margin-top: 0.5rem;
|
|
64
|
+
color: var(--muted-foreground);
|
|
65
|
+
font-size: 0.8125rem;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.slex-streamdown-error-excerpt {
|
|
69
|
+
overflow: auto;
|
|
70
|
+
margin: 0.75rem 0 0;
|
|
71
|
+
border-radius: calc(var(--radius) - 2px);
|
|
72
|
+
background: var(--background);
|
|
73
|
+
color: var(--foreground);
|
|
74
|
+
padding: 0.75rem;
|
|
75
|
+
font-family: var(--font-mono, "Geist Mono", "Noto Sans Mono", "Noto Sans Mono CJK SC", "SFMono-Regular", "Cascadia Code", Consolas, monospace);
|
|
76
|
+
font-size: 0.8125rem;
|
|
77
|
+
line-height: 1.55;
|
|
78
|
+
white-space: pre;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.slex-streamdown-source {
|
|
82
|
+
border-top: 1px solid var(--border);
|
|
83
|
+
padding: 0.5rem 0.75rem 0.75rem;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
.slex-streamdown-source summary {
|
|
87
|
+
color: var(--muted-foreground);
|
|
88
|
+
cursor: pointer;
|
|
89
|
+
font-size: 0.75rem;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.slex-streamdown-source pre {
|
|
93
|
+
margin: 0.5rem 0 0;
|
|
94
|
+
overflow: auto;
|
|
95
|
+
}
|