@oomfware/jsx 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 +14 -0
- package/README.md +164 -0
- package/dist/index.d.mts +77 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +408 -0
- package/dist/index.mjs.map +1 -0
- package/dist/jsx-runtime-BQHdv_66.d.mts +1311 -0
- package/dist/jsx-runtime-BQHdv_66.d.mts.map +1 -0
- package/dist/jsx-runtime.d.mts +2 -0
- package/dist/jsx-runtime.mjs +34 -0
- package/dist/jsx-runtime.mjs.map +1 -0
- package/package.json +46 -0
- package/src/index.ts +9 -0
- package/src/jsx-runtime.ts +33 -0
- package/src/lib/context.ts +98 -0
- package/src/lib/intrinsic-elements.ts +1592 -0
- package/src/lib/render.ts +504 -0
- package/src/lib/stream.test.tsx +625 -0
- package/src/lib/suspense.ts +57 -0
- package/src/lib/types.ts +37 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
BSD Zero Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mary
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted.
|
|
7
|
+
|
|
8
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
9
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
10
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
11
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
12
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
13
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
14
|
+
PERFORMANCE OF THIS SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# @oomfware/jsx
|
|
2
|
+
|
|
3
|
+
server-side JSX renderer with streaming support, Suspense, and context.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
npm install @oomfware/jsx
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
configure your `tsconfig.json` to use this package as the JSX runtime:
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"compilerOptions": {
|
|
14
|
+
"jsx": "react-jsx",
|
|
15
|
+
"jsxImportSource": "@oomfware/jsx"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## usage
|
|
21
|
+
|
|
22
|
+
render JSX responses in your route handlers:
|
|
23
|
+
|
|
24
|
+
```tsx
|
|
25
|
+
import { createRouter, route } from '@oomfware/fetch-router';
|
|
26
|
+
import { render, Suspense, use } from '@oomfware/jsx';
|
|
27
|
+
|
|
28
|
+
const routes = route({
|
|
29
|
+
home: '/',
|
|
30
|
+
users: {
|
|
31
|
+
index: '/users',
|
|
32
|
+
show: '/users/:id',
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const router = createRouter();
|
|
37
|
+
|
|
38
|
+
router.map(routes, {
|
|
39
|
+
home() {
|
|
40
|
+
return render(<HomePage />);
|
|
41
|
+
},
|
|
42
|
+
users: {
|
|
43
|
+
index() {
|
|
44
|
+
return render(<UserList />);
|
|
45
|
+
},
|
|
46
|
+
show({ params }) {
|
|
47
|
+
return render(<UserProfile userId={params.id} />);
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
function HomePage() {
|
|
53
|
+
return (
|
|
54
|
+
<html>
|
|
55
|
+
<head>
|
|
56
|
+
<title>my app</title>
|
|
57
|
+
</head>
|
|
58
|
+
<body>
|
|
59
|
+
<h1>welcome</h1>
|
|
60
|
+
</body>
|
|
61
|
+
</html>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### streaming with Suspense
|
|
67
|
+
|
|
68
|
+
the page shell streams immediately while async sections resolve in the background:
|
|
69
|
+
|
|
70
|
+
```tsx
|
|
71
|
+
import { render, Suspense, use } from '@oomfware/jsx';
|
|
72
|
+
|
|
73
|
+
interface User {
|
|
74
|
+
name: string;
|
|
75
|
+
posts: { title: string }[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function UserPosts({ user }: { user: Promise<User> }) {
|
|
79
|
+
const { posts } = use(user);
|
|
80
|
+
return (
|
|
81
|
+
<ul>
|
|
82
|
+
{posts.map((post) => (
|
|
83
|
+
<li>{post.title}</li>
|
|
84
|
+
))}
|
|
85
|
+
</ul>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function UserPage({ user }: { user: Promise<User> }) {
|
|
90
|
+
return (
|
|
91
|
+
<html>
|
|
92
|
+
<head>
|
|
93
|
+
<title>user profile</title>
|
|
94
|
+
</head>
|
|
95
|
+
<body>
|
|
96
|
+
<h1>posts</h1>
|
|
97
|
+
<Suspense fallback={<div>loading posts...</div>}>
|
|
98
|
+
<UserPosts user={user} />
|
|
99
|
+
</Suspense>
|
|
100
|
+
</body>
|
|
101
|
+
</html>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
router.get('/users/:id', ({ params }) => {
|
|
106
|
+
const user = fetch(`/api/users/${params.id}`).then((r) => r.json());
|
|
107
|
+
return render(<UserPage user={user} />);
|
|
108
|
+
});
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
the promise is created in the handler and passed down - `use()` caches by promise identity, so the
|
|
112
|
+
same instance must be used across renders.
|
|
113
|
+
|
|
114
|
+
### error responses
|
|
115
|
+
|
|
116
|
+
render errors with custom status codes:
|
|
117
|
+
|
|
118
|
+
```tsx
|
|
119
|
+
router.get('/admin', ({ store }) => {
|
|
120
|
+
const user = store.inject(userKey);
|
|
121
|
+
if (!user) {
|
|
122
|
+
return render(<LoginPage />, { status: 401 });
|
|
123
|
+
}
|
|
124
|
+
return render(<AdminDashboard user={user} />);
|
|
125
|
+
});
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### context
|
|
129
|
+
|
|
130
|
+
share values across components without prop drilling:
|
|
131
|
+
|
|
132
|
+
```tsx
|
|
133
|
+
import { createContext, renderToString, use } from '@oomfware/jsx';
|
|
134
|
+
|
|
135
|
+
const ThemeContext = createContext('light');
|
|
136
|
+
|
|
137
|
+
function ThemedButton() {
|
|
138
|
+
const theme = use(ThemeContext);
|
|
139
|
+
return <button class={theme}>click me</button>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const html = await renderToString(
|
|
143
|
+
<ThemeContext.Provider value="dark">
|
|
144
|
+
<ThemedButton />
|
|
145
|
+
</ThemeContext.Provider>,
|
|
146
|
+
);
|
|
147
|
+
// <button class="dark">click me</button>
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### head hoisting
|
|
151
|
+
|
|
152
|
+
`<title>`, `<meta>`, `<link>`, and `<style>` elements are automatically hoisted to `<head>`:
|
|
153
|
+
|
|
154
|
+
```tsx
|
|
155
|
+
function Page() {
|
|
156
|
+
return (
|
|
157
|
+
<div>
|
|
158
|
+
<title>my page</title>
|
|
159
|
+
<meta name="description" content="page description" />
|
|
160
|
+
<h1>content</h1>
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
```
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { c as JSXElement, i as jsxs, l as JSXNode, n as jsx, o as Component, r as jsxDEV, s as FC, t as Fragment } from "./jsx-runtime-BQHdv_66.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/lib/context.d.ts
|
|
4
|
+
/** context object returned by createContext() */
|
|
5
|
+
interface Context<T> {
|
|
6
|
+
defaultValue: T;
|
|
7
|
+
Provider: (props: {
|
|
8
|
+
value: T;
|
|
9
|
+
children?: JSXNode;
|
|
10
|
+
}) => JSXElement;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* creates a context with a Provider component
|
|
14
|
+
* @param defaultValue value returned by use() when no Provider is above
|
|
15
|
+
* @example
|
|
16
|
+
* const ThemeContext = createContext('light');
|
|
17
|
+
*
|
|
18
|
+
* <ThemeContext.Provider value="dark">
|
|
19
|
+
* <App />
|
|
20
|
+
* </ThemeContext.Provider>
|
|
21
|
+
*
|
|
22
|
+
* function App() {
|
|
23
|
+
* const theme = use(ThemeContext);
|
|
24
|
+
* return <div>{theme}</div>;
|
|
25
|
+
* }
|
|
26
|
+
*/
|
|
27
|
+
declare function createContext<T>(defaultValue: T): Context<T>;
|
|
28
|
+
declare function createContext<T>(): Context<T | undefined>;
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/lib/suspense.d.ts
|
|
31
|
+
interface SuspenseProps {
|
|
32
|
+
fallback: JSXNode;
|
|
33
|
+
children?: JSXNode;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* suspense boundary - renders fallback while children are suspended
|
|
37
|
+
*/
|
|
38
|
+
declare function Suspense({
|
|
39
|
+
children
|
|
40
|
+
}: SuspenseProps): JSXElement;
|
|
41
|
+
/**
|
|
42
|
+
* reads a context value or suspends until a promise resolves
|
|
43
|
+
* @param usable context or promise
|
|
44
|
+
* @returns context value or resolved promise value
|
|
45
|
+
* @throws promise if not yet resolved, or error if rejected
|
|
46
|
+
*/
|
|
47
|
+
declare function use<T>(usable: Context<T>): T;
|
|
48
|
+
declare function use<T>(usable: Promise<T>): T;
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region src/lib/render.d.ts
|
|
51
|
+
interface RenderOptions {
|
|
52
|
+
onError?: (error: unknown) => void;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* renders JSX to a readable stream
|
|
56
|
+
* @param node JSX node to render
|
|
57
|
+
* @param options render options
|
|
58
|
+
* @returns readable stream of UTF-8 encoded HTML
|
|
59
|
+
*/
|
|
60
|
+
declare function renderToStream(node: JSXNode, options?: RenderOptions): ReadableStream<Uint8Array>;
|
|
61
|
+
/**
|
|
62
|
+
* renders JSX to a string (non-streaming)
|
|
63
|
+
* @param node JSX node to render
|
|
64
|
+
* @param options render options
|
|
65
|
+
* @returns promise resolving to HTML string
|
|
66
|
+
*/
|
|
67
|
+
declare function renderToString(node: JSXNode, options?: RenderOptions): Promise<string>;
|
|
68
|
+
/**
|
|
69
|
+
* renders JSX to a streaming Response
|
|
70
|
+
* @param node JSX node to render
|
|
71
|
+
* @param init optional ResponseInit (status, headers, etc.)
|
|
72
|
+
* @returns Response with streaming HTML body
|
|
73
|
+
*/
|
|
74
|
+
declare function render(node: JSXNode, init?: ResponseInit): Response;
|
|
75
|
+
//#endregion
|
|
76
|
+
export { type Component, type Context, type FC, Fragment, type JSXElement, type JSXNode, type RenderOptions, Suspense, type SuspenseProps, createContext, jsx, jsxDEV, jsxs, render, renderToStream, renderToString, use };
|
|
77
|
+
//# sourceMappingURL=index.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../src/lib/context.ts","../src/lib/suspense.ts","../src/lib/render.ts"],"sourcesContent":[],"mappings":";;;;UAKiB;EAAA,YAAO,EACT,CADS;EACT,QAAA,EAAA,CAAA,KAAA,EAAA;IACa,KAAA,EAAA,CAAA;IAAc,QAAA,CAAA,EAAA,OAAA;EAAc,CAAA,EAAA,GAAA,UAAA;;AA+BxD;;;;;AACA;;;;AClCA;AAQA;;;;;AAqBgB,iBDIA,aCJG,CAAA,CAAA,CAAA,CAAA,YAAA,EDI4B,CCJ5B,CAAA,EDIgC,OCJhC,CDIwC,CCJxC,CAAA;AAAoB,iBDKvB,aCLuB,CAAA,CAAA,CAAA,CAAA,CAAA,EDKH,OCLG,CDKK,CCLL,GAAA,SAAA,CAAA;;;UA7BtB,aAAA;EDAA,QAAA,ECCN,ODDa;EACT,QAAA,CAAA,ECCH,ODDG;;;;;AAgCC,iBCzBA,QAAA,CDyBa;EAAA;AAAA,CAAA,ECzBU,aDyBV,CAAA,ECzB0B,UDyB1B;;;;;AAC7B;;iBCLgB,eAAe,QAAQ,KAAK;iBAC5B,eAAe,QAAQ,KAAK;;;ADGO,UE+BlC,aAAA,CF/BkC;EAAO,OAAA,CAAA,EAAA,CAAA,KAAA,EAAA,OAAA,EAAA,GAAA,IAAA;AAC1D;;;;AClCA;AAQA;;AAAuC,iBC2EvB,cAAA,CD3EuB,IAAA,EC2EF,OD3EE,EAAA,OAAA,CAAA,EC2EiB,aD3EjB,CAAA,EC2EiC,cD3EjC,CC2EgD,UD3EhD,CAAA;;;AAqBvC;;;;AAA6C,iBC4FvB,cAAA,CD5FuB,IAAA,EC4FF,OD5FE,EAAA,OAAA,CAAA,EC4FiB,aD5FjB,CAAA,EC4FiC,OD5FjC,CAAA,MAAA,CAAA;AAC7C;;;;;;iBC8GgB,MAAA,OAAa,gBAAgB,eAAe"}
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { Fragment, jsx, jsxDEV, jsxs } from "./jsx-runtime.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/lib/context.ts
|
|
4
|
+
/** internal provider component */
|
|
5
|
+
function Provider({ context, value, children }) {
|
|
6
|
+
provide(context, value);
|
|
7
|
+
return jsx(Fragment, { children });
|
|
8
|
+
}
|
|
9
|
+
function createContext(defaultValue) {
|
|
10
|
+
const context = {
|
|
11
|
+
defaultValue,
|
|
12
|
+
Provider: ({ value, children }) => Provider({
|
|
13
|
+
context,
|
|
14
|
+
value,
|
|
15
|
+
children
|
|
16
|
+
})
|
|
17
|
+
};
|
|
18
|
+
return context;
|
|
19
|
+
}
|
|
20
|
+
const contextStack = [];
|
|
21
|
+
/** current frame being built (lazily initialized on first provide) */
|
|
22
|
+
let currentFrame = null;
|
|
23
|
+
/**
|
|
24
|
+
* provides a value for the context during the current component's render
|
|
25
|
+
* @param context context key from createContext()
|
|
26
|
+
* @param value value to provide
|
|
27
|
+
*/
|
|
28
|
+
function provide(context, value) {
|
|
29
|
+
if (!currentFrame) {
|
|
30
|
+
const prev = contextStack[contextStack.length - 1];
|
|
31
|
+
currentFrame = prev ? new Map(prev) : /* @__PURE__ */ new Map();
|
|
32
|
+
}
|
|
33
|
+
currentFrame.set(context, value);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* returns current provided value, or the default value
|
|
37
|
+
* @param context context key from createContext()
|
|
38
|
+
*/
|
|
39
|
+
function inject(context) {
|
|
40
|
+
const frame = currentFrame ?? contextStack[contextStack.length - 1];
|
|
41
|
+
if (frame?.has(context)) return frame.get(context);
|
|
42
|
+
return context.defaultValue;
|
|
43
|
+
}
|
|
44
|
+
/** push current frame to stack (called before rendering children) */
|
|
45
|
+
function pushContextFrame() {
|
|
46
|
+
if (currentFrame) {
|
|
47
|
+
contextStack.push(currentFrame);
|
|
48
|
+
currentFrame = null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** pop context frame (called after rendering children) */
|
|
52
|
+
function popContextFrame(hadFrame) {
|
|
53
|
+
if (hadFrame) contextStack.pop();
|
|
54
|
+
currentFrame = null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
//#endregion
|
|
58
|
+
//#region src/lib/suspense.ts
|
|
59
|
+
/**
|
|
60
|
+
* suspense boundary - renders fallback while children are suspended
|
|
61
|
+
*/
|
|
62
|
+
function Suspense({ children }) {
|
|
63
|
+
return jsx(Fragment, { children });
|
|
64
|
+
}
|
|
65
|
+
/** cache for resolved/rejected promise values */
|
|
66
|
+
const promiseCache = /* @__PURE__ */ new WeakMap();
|
|
67
|
+
function isContext(value) {
|
|
68
|
+
return typeof value === "object" && value !== null && "defaultValue" in value && "Provider" in value;
|
|
69
|
+
}
|
|
70
|
+
function use(usable) {
|
|
71
|
+
if (isContext(usable)) return inject(usable);
|
|
72
|
+
const cached = promiseCache.get(usable);
|
|
73
|
+
if (cached) if (cached.resolved) return cached.value;
|
|
74
|
+
else throw cached.error;
|
|
75
|
+
usable.then((value) => promiseCache.set(usable, {
|
|
76
|
+
resolved: true,
|
|
77
|
+
value
|
|
78
|
+
}), (error) => promiseCache.set(usable, {
|
|
79
|
+
resolved: false,
|
|
80
|
+
error
|
|
81
|
+
}));
|
|
82
|
+
throw usable;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region src/lib/render.ts
|
|
87
|
+
/**
|
|
88
|
+
* streaming JSX renderer
|
|
89
|
+
*
|
|
90
|
+
* architecture:
|
|
91
|
+
* - segment tree: we build a tree of segments (static text, composites, suspense
|
|
92
|
+
* boundaries) then serialize to HTML
|
|
93
|
+
* - suspense: components can throw promises via use(), caught at Suspense boundaries
|
|
94
|
+
* which render fallback immediately and stream resolved content later
|
|
95
|
+
* - head hoisting: <title>, <meta>, <link>, <style> found outside <head> are
|
|
96
|
+
* collected and injected into <head> during finalization
|
|
97
|
+
*/
|
|
98
|
+
const HEAD_ELEMENTS = new Set([
|
|
99
|
+
"title",
|
|
100
|
+
"meta",
|
|
101
|
+
"link",
|
|
102
|
+
"style"
|
|
103
|
+
]);
|
|
104
|
+
const SELF_CLOSING_TAGS = new Set([
|
|
105
|
+
"area",
|
|
106
|
+
"base",
|
|
107
|
+
"br",
|
|
108
|
+
"col",
|
|
109
|
+
"embed",
|
|
110
|
+
"hr",
|
|
111
|
+
"img",
|
|
112
|
+
"input",
|
|
113
|
+
"link",
|
|
114
|
+
"meta",
|
|
115
|
+
"param",
|
|
116
|
+
"source",
|
|
117
|
+
"track",
|
|
118
|
+
"wbr"
|
|
119
|
+
]);
|
|
120
|
+
/** props that shouldn't be rendered as HTML attributes */
|
|
121
|
+
const FRAMEWORK_PROPS = new Set(["children", "dangerouslySetInnerHTML"]);
|
|
122
|
+
function staticSeg(html) {
|
|
123
|
+
return {
|
|
124
|
+
kind: "static",
|
|
125
|
+
html
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function compositeSeg(parts) {
|
|
129
|
+
return {
|
|
130
|
+
kind: "composite",
|
|
131
|
+
parts
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const EMPTY_SEGMENT = staticSeg("");
|
|
135
|
+
/**
|
|
136
|
+
* renders JSX to a readable stream
|
|
137
|
+
* @param node JSX node to render
|
|
138
|
+
* @param options render options
|
|
139
|
+
* @returns readable stream of UTF-8 encoded HTML
|
|
140
|
+
*/
|
|
141
|
+
function renderToStream(node, options) {
|
|
142
|
+
const encoder = new TextEncoder();
|
|
143
|
+
const onError = options?.onError ?? ((error) => console.error(error));
|
|
144
|
+
const context = {
|
|
145
|
+
headElements: [],
|
|
146
|
+
idsByPath: /* @__PURE__ */ new Map(),
|
|
147
|
+
insideHead: false,
|
|
148
|
+
insideSvg: false,
|
|
149
|
+
onError,
|
|
150
|
+
pendingSuspense: []
|
|
151
|
+
};
|
|
152
|
+
return new ReadableStream({ async start(controller) {
|
|
153
|
+
try {
|
|
154
|
+
const root = buildSegment(node, context, "");
|
|
155
|
+
await resolveBlocking(root);
|
|
156
|
+
const finalHtml = finalizeHtml(serializeSegment(root), context);
|
|
157
|
+
controller.enqueue(encoder.encode(finalHtml));
|
|
158
|
+
if (context.pendingSuspense.length > 0) await streamPendingSuspense(context, controller, encoder);
|
|
159
|
+
controller.close();
|
|
160
|
+
} catch (error) {
|
|
161
|
+
onError(error);
|
|
162
|
+
controller.error(error);
|
|
163
|
+
}
|
|
164
|
+
} });
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* renders JSX to a string (non-streaming)
|
|
168
|
+
* @param node JSX node to render
|
|
169
|
+
* @param options render options
|
|
170
|
+
* @returns promise resolving to HTML string
|
|
171
|
+
*/
|
|
172
|
+
async function renderToString(node, options) {
|
|
173
|
+
const reader = renderToStream(node, options).getReader();
|
|
174
|
+
const decoder = new TextDecoder();
|
|
175
|
+
let html = "";
|
|
176
|
+
while (true) {
|
|
177
|
+
const { done, value } = await reader.read();
|
|
178
|
+
if (done) break;
|
|
179
|
+
html += decoder.decode(value);
|
|
180
|
+
}
|
|
181
|
+
return html;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* renders JSX to a streaming Response
|
|
185
|
+
* @param node JSX node to render
|
|
186
|
+
* @param init optional ResponseInit (status, headers, etc.)
|
|
187
|
+
* @returns Response with streaming HTML body
|
|
188
|
+
*/
|
|
189
|
+
function render(node, init) {
|
|
190
|
+
const stream = renderToStream(node);
|
|
191
|
+
const headers = new Headers(init?.headers);
|
|
192
|
+
if (!headers.has("Content-Type")) headers.set("Content-Type", "text/html; charset=utf-8");
|
|
193
|
+
return new Response(stream, {
|
|
194
|
+
...init,
|
|
195
|
+
headers
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
function isJSXElement(node) {
|
|
199
|
+
return typeof node === "object" && node !== null && "type" in node && "props" in node;
|
|
200
|
+
}
|
|
201
|
+
function isHeadElement(tag) {
|
|
202
|
+
return HEAD_ELEMENTS.has(tag);
|
|
203
|
+
}
|
|
204
|
+
function buildSegment(node, context, path) {
|
|
205
|
+
if (typeof node === "string" || typeof node === "number" || typeof node === "bigint") return staticSeg(escapeHtml(node, false));
|
|
206
|
+
if (node === null || node === void 0 || typeof node === "boolean") return EMPTY_SEGMENT;
|
|
207
|
+
if (typeof node === "object" && Symbol.iterator in node) {
|
|
208
|
+
const parts = [];
|
|
209
|
+
for (const child of node) parts.push(buildSegment(child, context, path));
|
|
210
|
+
return compositeSeg(parts);
|
|
211
|
+
}
|
|
212
|
+
if (isJSXElement(node)) {
|
|
213
|
+
const { type, props } = node;
|
|
214
|
+
if (type === Fragment) {
|
|
215
|
+
const children = props.children;
|
|
216
|
+
return children != null ? buildSegment(children, context, path) : EMPTY_SEGMENT;
|
|
217
|
+
}
|
|
218
|
+
if (typeof type === "string") {
|
|
219
|
+
const tag = type;
|
|
220
|
+
if (tag === "head") return buildHeadElementSegment(tag, props, context, path);
|
|
221
|
+
if (!context.insideHead && isHeadElement(tag)) {
|
|
222
|
+
const elementSeg = buildElementSegment(tag, props, context, path);
|
|
223
|
+
context.headElements.push(serializeSegment(elementSeg));
|
|
224
|
+
return EMPTY_SEGMENT;
|
|
225
|
+
}
|
|
226
|
+
return buildElementSegment(tag, props, context, path);
|
|
227
|
+
}
|
|
228
|
+
if (typeof type === "function") {
|
|
229
|
+
if (type === Suspense) return buildSuspenseSegment(props, context, path);
|
|
230
|
+
return buildComponentSegment(type, props, context, path);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return EMPTY_SEGMENT;
|
|
234
|
+
}
|
|
235
|
+
function buildElementSegment(tag, props, context, path) {
|
|
236
|
+
const currentIsSvg = context.insideSvg || tag === "svg";
|
|
237
|
+
const attrs = renderAttributes(props);
|
|
238
|
+
if (SELF_CLOSING_TAGS.has(tag)) return staticSeg(`<${tag}${attrs}>`);
|
|
239
|
+
const innerHTML = props.dangerouslySetInnerHTML;
|
|
240
|
+
if (innerHTML) return staticSeg(`<${tag}${attrs}>${innerHTML.__html}</${tag}>`);
|
|
241
|
+
const open = staticSeg(`<${tag}${attrs}>`);
|
|
242
|
+
const previousInsideSvg = context.insideSvg;
|
|
243
|
+
context.insideSvg = tag === "foreignObject" ? false : currentIsSvg;
|
|
244
|
+
const children = props.children != null ? buildSegment(props.children, context, path) : EMPTY_SEGMENT;
|
|
245
|
+
context.insideSvg = previousInsideSvg;
|
|
246
|
+
return compositeSeg([
|
|
247
|
+
open,
|
|
248
|
+
children,
|
|
249
|
+
staticSeg(`</${tag}>`)
|
|
250
|
+
]);
|
|
251
|
+
}
|
|
252
|
+
function buildHeadElementSegment(tag, props, context, path) {
|
|
253
|
+
const attrs = renderAttributes(props);
|
|
254
|
+
const previousInsideHead = context.insideHead;
|
|
255
|
+
context.insideHead = true;
|
|
256
|
+
const open = staticSeg(`<${tag}${attrs}>`);
|
|
257
|
+
const children = props.children != null ? buildSegment(props.children, context, path) : EMPTY_SEGMENT;
|
|
258
|
+
context.insideHead = previousInsideHead;
|
|
259
|
+
return compositeSeg([
|
|
260
|
+
open,
|
|
261
|
+
children,
|
|
262
|
+
staticSeg(`</${tag}>`)
|
|
263
|
+
]);
|
|
264
|
+
}
|
|
265
|
+
function renderAttributes(props) {
|
|
266
|
+
let attrs = "";
|
|
267
|
+
for (const key in props) {
|
|
268
|
+
if (FRAMEWORK_PROPS.has(key)) continue;
|
|
269
|
+
const value = props[key];
|
|
270
|
+
if (value === void 0 || value === null || value === false) continue;
|
|
271
|
+
if (typeof value === "function") continue;
|
|
272
|
+
if (key === "style" && typeof value === "object") {
|
|
273
|
+
const styleStr = serializeStyle(value);
|
|
274
|
+
if (styleStr) attrs += ` style="${escapeHtml(styleStr, true)}"`;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (value === true) attrs += ` ${key}`;
|
|
278
|
+
else attrs += ` ${key}="${escapeHtml(value, true)}"`;
|
|
279
|
+
}
|
|
280
|
+
return attrs;
|
|
281
|
+
}
|
|
282
|
+
function serializeStyle(style) {
|
|
283
|
+
const parts = [];
|
|
284
|
+
for (const [key, value] of Object.entries(style)) {
|
|
285
|
+
if (value == null) continue;
|
|
286
|
+
parts.push(`${key}:${value}`);
|
|
287
|
+
}
|
|
288
|
+
return parts.join(";");
|
|
289
|
+
}
|
|
290
|
+
function buildComponentSegment(type, props, ctx, path) {
|
|
291
|
+
const result = type(props);
|
|
292
|
+
const hadFrame = currentFrame !== null;
|
|
293
|
+
pushContextFrame();
|
|
294
|
+
try {
|
|
295
|
+
return buildSegment(result, ctx, path);
|
|
296
|
+
} finally {
|
|
297
|
+
popContextFrame(hadFrame);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function buildSuspenseSegment(props, ctx, path) {
|
|
301
|
+
const nextIndex = (ctx.idsByPath.get(path) ?? 0) + 1;
|
|
302
|
+
ctx.idsByPath.set(path, nextIndex);
|
|
303
|
+
const suspenseId = `s${path ? `${path}-${nextIndex}` : `${nextIndex}`}`;
|
|
304
|
+
try {
|
|
305
|
+
return buildSegment(props.children, ctx, suspenseId);
|
|
306
|
+
} catch (thrown) {
|
|
307
|
+
if (thrown instanceof Promise) {
|
|
308
|
+
const seg = {
|
|
309
|
+
kind: "suspense",
|
|
310
|
+
id: suspenseId,
|
|
311
|
+
fallback: buildSegment(props.fallback, ctx, suspenseId),
|
|
312
|
+
content: null
|
|
313
|
+
};
|
|
314
|
+
const pending = thrown.then(() => {
|
|
315
|
+
seg.content = buildSegment(props.children, ctx, suspenseId);
|
|
316
|
+
});
|
|
317
|
+
seg.pending = pending;
|
|
318
|
+
ctx.pendingSuspense.push({
|
|
319
|
+
id: suspenseId,
|
|
320
|
+
promise: pending.then(() => seg.content)
|
|
321
|
+
});
|
|
322
|
+
return seg;
|
|
323
|
+
}
|
|
324
|
+
throw thrown;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/** resolve all blocking suspense boundaries */
|
|
328
|
+
async function resolveBlocking(segment) {
|
|
329
|
+
if (segment.kind === "suspense") {
|
|
330
|
+
if (segment.pending) {
|
|
331
|
+
await segment.pending;
|
|
332
|
+
segment.pending = void 0;
|
|
333
|
+
}
|
|
334
|
+
if (segment.content) await resolveBlocking(segment.content);
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (segment.kind === "composite") for (const part of segment.parts) await resolveBlocking(part);
|
|
338
|
+
}
|
|
339
|
+
/** serialize segment tree to HTML string */
|
|
340
|
+
function serializeSegment(seg) {
|
|
341
|
+
if (seg.kind === "static") return seg.html;
|
|
342
|
+
if (seg.kind === "composite") return seg.parts.map(serializeSegment).join("");
|
|
343
|
+
return `<!--$s:${seg.id}-->${serializeSegment(seg.fallback)}<!--/$s:${seg.id}-->`;
|
|
344
|
+
}
|
|
345
|
+
/** suspense runtime function name */
|
|
346
|
+
const SR = "$sr";
|
|
347
|
+
/** suspense runtime - injected once, swaps template content with fallback */
|
|
348
|
+
const SUSPENSE_RUNTIME = `<script>${SR}=(t,i,s,e)=>{i="$s:"+t.dataset.suspense;s=document.createTreeWalker(document,128);while(e=s.nextNode())if(e.data===i){while(e.nextSibling?.data!=="/"+i)e.nextSibling.remove();e.nextSibling.replaceWith(...t.content.childNodes);e.remove();break}t.remove()}<\/script>`;
|
|
349
|
+
async function streamPendingSuspense(context, controller, encoder) {
|
|
350
|
+
let runtimeInjected = false;
|
|
351
|
+
const processed = /* @__PURE__ */ new Set();
|
|
352
|
+
while (true) {
|
|
353
|
+
const batch = context.pendingSuspense.filter(({ id }) => !processed.has(id));
|
|
354
|
+
if (batch.length === 0) break;
|
|
355
|
+
await Promise.all(batch.map(async ({ id, promise }) => {
|
|
356
|
+
processed.add(id);
|
|
357
|
+
try {
|
|
358
|
+
const resolvedSegment = await promise;
|
|
359
|
+
await resolveBlocking(resolvedSegment);
|
|
360
|
+
const html = serializeSegment(resolvedSegment);
|
|
361
|
+
const runtime = runtimeInjected ? "" : SUSPENSE_RUNTIME;
|
|
362
|
+
runtimeInjected = true;
|
|
363
|
+
controller.enqueue(encoder.encode(`${runtime}<template data-suspense="${id}">${html}</template><script>${SR}(document.currentScript.previousElementSibling)<\/script>`));
|
|
364
|
+
} catch (error) {
|
|
365
|
+
context.onError(error);
|
|
366
|
+
}
|
|
367
|
+
}));
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const ATTR_REGEX = /[&"]/g;
|
|
371
|
+
const CONTENT_REGEX = /[&<]/g;
|
|
372
|
+
function escapeHtml(value, isAttr) {
|
|
373
|
+
const str = String(value ?? "");
|
|
374
|
+
const pattern = isAttr ? ATTR_REGEX : CONTENT_REGEX;
|
|
375
|
+
pattern.lastIndex = 0;
|
|
376
|
+
let escaped = "";
|
|
377
|
+
let last = 0;
|
|
378
|
+
while (pattern.test(str)) {
|
|
379
|
+
const i = pattern.lastIndex - 1;
|
|
380
|
+
const ch = str[i];
|
|
381
|
+
escaped += str.substring(last, i) + (ch === "&" ? "&" : ch === "\"" ? """ : "<");
|
|
382
|
+
last = i + 1;
|
|
383
|
+
}
|
|
384
|
+
return escaped + str.substring(last);
|
|
385
|
+
}
|
|
386
|
+
function finalizeHtml(html, context) {
|
|
387
|
+
const hasHtmlRoot = html.trimStart().toLowerCase().startsWith("<html");
|
|
388
|
+
if (context.headElements.length > 0) {
|
|
389
|
+
const headContent = context.headElements.join("");
|
|
390
|
+
if (hasHtmlRoot) {
|
|
391
|
+
const headCloseIndex = html.indexOf("</head>");
|
|
392
|
+
if (headCloseIndex !== -1) html = html.slice(0, headCloseIndex) + headContent + html.slice(headCloseIndex);
|
|
393
|
+
else {
|
|
394
|
+
const htmlOpenMatch = html.match(/<html[^>]*>/);
|
|
395
|
+
if (htmlOpenMatch && htmlOpenMatch.index !== void 0) {
|
|
396
|
+
const insertIndex = htmlOpenMatch.index + htmlOpenMatch[0].length;
|
|
397
|
+
html = html.slice(0, insertIndex) + `<head>${headContent}</head>` + html.slice(insertIndex);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
} else html = `<head>${headContent}</head>${html}`;
|
|
401
|
+
}
|
|
402
|
+
if (hasHtmlRoot) html = "<!doctype html>" + html;
|
|
403
|
+
return html;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
//#endregion
|
|
407
|
+
export { Fragment, Suspense, createContext, jsx, jsxDEV, jsxs, render, renderToStream, renderToString, use };
|
|
408
|
+
//# sourceMappingURL=index.mjs.map
|