@tschk/moonshine-solid 0.4.0 → 0.4.4
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/README.md +259 -23
- package/package.json +22 -8
- package/src/children.ts +30 -0
- package/src/cleanup-finalization.ts +91 -0
- package/src/cleanup-policy.ts +39 -0
- package/src/component-factory.ts +62 -0
- package/src/context.ts +53 -0
- package/src/control.ts +6 -0
- package/src/dom.ts +3 -0
- package/src/dynamic.ts +90 -0
- package/src/events.ts +185 -0
- package/src/framework-native.ts +1 -0
- package/src/framework.ts +32 -115
- package/src/h.ts +2 -4
- package/src/index.ts +20 -246
- package/src/ir.ts +213 -0
- package/src/lazy.ts +62 -0
- package/src/portal-h.ts +42 -0
- package/src/portal.ts +56 -0
- package/src/props.ts +152 -0
- package/src/reactive.ts +1 -0
- package/src/solid.ts +198 -16
- package/src/ssr.ts +41 -0
- package/src/store.ts +7 -4
package/src/index.ts
CHANGED
|
@@ -1,272 +1,46 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @tschk/moonshine-solid
|
|
3
|
-
*
|
|
4
|
-
* Solid gets a separate adapter — no shared vnode with React (AGENTS.md).
|
|
5
|
-
* IR types come from `@tschk/crepuscularity-wasm`; host elements via solid-js/h.
|
|
6
|
-
*
|
|
7
|
-
* Prefer Solid signals in Solid apps. Bridge helpers exist only when sharing
|
|
8
|
-
* a moonshine signal across host boundaries.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import type { TabItem, ViewIr, ViewNode } from "@tschk/crepuscularity-wasm";
|
|
1
|
+
/** Independent native runtime surface and explicit kernel signal bridge. */
|
|
12
2
|
import {
|
|
13
3
|
createSignal as msCreateSignal,
|
|
14
4
|
type Signal as MsSignal,
|
|
15
5
|
} from "@tschk/moonshine";
|
|
16
6
|
import {
|
|
17
|
-
|
|
18
|
-
|
|
7
|
+
createSignal,
|
|
8
|
+
onCleanup,
|
|
9
|
+
untrack as nativeUntrack,
|
|
19
10
|
type Accessor,
|
|
20
|
-
type JSX,
|
|
21
11
|
type Setter,
|
|
22
|
-
} from "
|
|
23
|
-
import {
|
|
12
|
+
} from "@tschk/moonshine-dom/reactive";
|
|
13
|
+
import { h, type DOMChild } from "@tschk/moonshine-dom/dom";
|
|
14
|
+
import { createIrRenderer } from "./ir";
|
|
24
15
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
props: Record<string, unknown> | null,
|
|
29
|
-
...children: unknown[]
|
|
30
|
-
): JSX.Element {
|
|
31
|
-
const merged: Record<string, unknown> = { component: type };
|
|
32
|
-
for (const [name, value] of Object.entries(props ?? {})) {
|
|
33
|
-
if (value !== undefined) merged[name] = value;
|
|
34
|
-
}
|
|
35
|
-
if (children.length > 0) merged.children = children;
|
|
36
|
-
return createComponent(Dynamic, merged as never) as unknown as JSX.Element;
|
|
37
|
-
}
|
|
16
|
+
export type { CrepusNode, CrepusIr, RenderCrepusOptions } from "./ir";
|
|
17
|
+
export const { renderCrepusNodeSolid, renderCrepusIrSolid } =
|
|
18
|
+
/* @__PURE__ */ createIrRenderer<DOMChild>(h);
|
|
38
19
|
|
|
39
|
-
/**
|
|
40
|
-
* One-way mirror: moonshine signal → Solid accessor.
|
|
41
|
-
* Prefer native `createSignal` from solid-js when staying in Solid.
|
|
42
|
-
*/
|
|
20
|
+
/** Mirror a kernel signal while its native owner lives; writes still target the kernel. */
|
|
43
21
|
export function fromMoonshineSignal<T>(
|
|
44
22
|
signal: MsSignal<T>,
|
|
45
23
|
): [Accessor<T>, Setter<T>] {
|
|
46
|
-
const [
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return unsub;
|
|
50
|
-
});
|
|
24
|
+
const [read, write] = createSignal(nativeUntrack(signal));
|
|
25
|
+
const stop = signal.subscribe(() => write(() => nativeUntrack(signal)));
|
|
26
|
+
onCleanup(stop);
|
|
51
27
|
return [
|
|
52
|
-
|
|
53
|
-
(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
signal.set(v as T);
|
|
58
|
-
}
|
|
59
|
-
return signal();
|
|
60
|
-
}) as Setter<T>,
|
|
28
|
+
read,
|
|
29
|
+
(next) => {
|
|
30
|
+
signal.set(next);
|
|
31
|
+
return nativeUntrack(signal);
|
|
32
|
+
},
|
|
61
33
|
];
|
|
62
34
|
}
|
|
63
35
|
|
|
64
|
-
/** Create a Solid pair backed by moonshine createSignal (cross-host share). */
|
|
65
36
|
export function createBridgedSignal<T>(initial: T): [Accessor<T>, Setter<T>] {
|
|
66
37
|
return fromMoonshineSignal(msCreateSignal(initial));
|
|
67
38
|
}
|
|
68
39
|
|
|
69
|
-
export type CrepusNode = ViewNode;
|
|
70
|
-
export type CrepusIr = ViewIr;
|
|
71
|
-
|
|
72
|
-
export interface RenderCrepusOptions {
|
|
73
|
-
/** Wrapper element for the document root. */
|
|
74
|
-
rootTag?: string;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Class tokens are carried verbatim from the template, so UnoCSS/Tailwind
|
|
79
|
-
* styles the output. The lowered `style` hints exist for native targets and are
|
|
80
|
-
* deliberately not converted to inline CSS here.
|
|
81
|
-
*/
|
|
82
|
-
function classOf(node: ViewNode): string | undefined {
|
|
83
|
-
const classes = (node as { style?: { classes?: string[] } }).style?.classes;
|
|
84
|
-
if (!classes || classes.length === 0) return undefined;
|
|
85
|
-
const joined = classes.filter(Boolean).join(" ");
|
|
86
|
-
return joined.length > 0 ? joined : undefined;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function renderChildren(children: ViewNode[]): JSX.Element[] {
|
|
90
|
-
return children.map((child) => renderCrepusNodeSolid(child));
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
const RENDERERS: Record<
|
|
94
|
-
string,
|
|
95
|
-
(node: any, cls: string | undefined) => JSX.Element
|
|
96
|
-
> = {
|
|
97
|
-
text: (node, cls) => el("span", { class: cls }, node.content),
|
|
98
|
-
link: (node, cls) =>
|
|
99
|
-
el(
|
|
100
|
-
"a",
|
|
101
|
-
{ class: cls, href: node.href, target: node.target, rel: node.rel },
|
|
102
|
-
...renderChildren(node.children),
|
|
103
|
-
),
|
|
104
|
-
stack: (node, cls) =>
|
|
105
|
-
el("div", { class: cls }, ...renderChildren(node.children)),
|
|
106
|
-
scroll: (node, cls) =>
|
|
107
|
-
el("div", { class: cls }, ...renderChildren(node.children)),
|
|
108
|
-
dropzone: (node, cls) =>
|
|
109
|
-
el(
|
|
110
|
-
"div",
|
|
111
|
-
{ class: cls, "aria-label": node.label },
|
|
112
|
-
...renderChildren(node.children),
|
|
113
|
-
),
|
|
114
|
-
list: (node, cls) =>
|
|
115
|
-
el(
|
|
116
|
-
node.ordered ? "ol" : "ul",
|
|
117
|
-
{ class: cls },
|
|
118
|
-
...renderChildren(node.children),
|
|
119
|
-
),
|
|
120
|
-
listItem: (node, cls) =>
|
|
121
|
-
el("li", { class: cls }, ...renderChildren(node.children)),
|
|
122
|
-
button: (node, cls) =>
|
|
123
|
-
el("button", { class: cls, type: "button" }, node.label),
|
|
124
|
-
badge: (node, cls) =>
|
|
125
|
-
el("span", { class: cls, "data-tone": node.tone }, node.label),
|
|
126
|
-
divider: (node, cls) => el("hr", { class: cls }),
|
|
127
|
-
spacer: (node, cls) => el("div", { class: cls, "aria-hidden": true }),
|
|
128
|
-
image: (node, cls) =>
|
|
129
|
-
el("img", { class: cls, src: node.src, alt: node.alt ?? "" }),
|
|
130
|
-
webView: (node, cls) => el("iframe", { class: cls, src: node.src }),
|
|
131
|
-
toggle: (node, cls) =>
|
|
132
|
-
el(
|
|
133
|
-
"button",
|
|
134
|
-
{
|
|
135
|
-
class: cls,
|
|
136
|
-
type: "button",
|
|
137
|
-
role: "switch",
|
|
138
|
-
"aria-checked": node.checked,
|
|
139
|
-
},
|
|
140
|
-
node.label,
|
|
141
|
-
),
|
|
142
|
-
checkbox: (node, cls) =>
|
|
143
|
-
el(
|
|
144
|
-
"label",
|
|
145
|
-
{ class: cls },
|
|
146
|
-
el("input", { type: "checkbox", checked: node.checked, name: node.bind }),
|
|
147
|
-
node.label,
|
|
148
|
-
),
|
|
149
|
-
slider: (node, cls) =>
|
|
150
|
-
el("input", {
|
|
151
|
-
class: cls,
|
|
152
|
-
type: "range",
|
|
153
|
-
value: node.value,
|
|
154
|
-
min: node.min,
|
|
155
|
-
max: node.max,
|
|
156
|
-
step: node.step,
|
|
157
|
-
name: node.bind,
|
|
158
|
-
}),
|
|
159
|
-
progress: (node, cls) =>
|
|
160
|
-
el("progress", { class: cls, value: node.value, max: node.max }),
|
|
161
|
-
meter: (node, cls) =>
|
|
162
|
-
el("meter", {
|
|
163
|
-
class: cls,
|
|
164
|
-
value: node.value,
|
|
165
|
-
min: node.min,
|
|
166
|
-
max: node.max,
|
|
167
|
-
}),
|
|
168
|
-
input: (node, cls) =>
|
|
169
|
-
el(node.multiline ? "textarea" : "input", {
|
|
170
|
-
class: cls,
|
|
171
|
-
type: node.multiline ? undefined : node.secure ? "password" : "text",
|
|
172
|
-
placeholder: node.placeholder,
|
|
173
|
-
name: node.bind,
|
|
174
|
-
}),
|
|
175
|
-
picker: (node, cls) =>
|
|
176
|
-
el(
|
|
177
|
-
"select",
|
|
178
|
-
{ class: cls, name: node.bind },
|
|
179
|
-
...node.options.map((option: any) =>
|
|
180
|
-
el("option", { value: option.value }, option.label),
|
|
181
|
-
),
|
|
182
|
-
),
|
|
183
|
-
filePicker: (node, cls) =>
|
|
184
|
-
el(
|
|
185
|
-
"label",
|
|
186
|
-
{ class: cls },
|
|
187
|
-
el("input", {
|
|
188
|
-
type: "file",
|
|
189
|
-
accept: node.accept?.join(","),
|
|
190
|
-
multiple: node.multiple,
|
|
191
|
-
}),
|
|
192
|
-
node.label,
|
|
193
|
-
),
|
|
194
|
-
slotRotate: (node, cls) =>
|
|
195
|
-
el(
|
|
196
|
-
"span",
|
|
197
|
-
{
|
|
198
|
-
class: cls,
|
|
199
|
-
"data-crepus-slot-rotate": node.phrases.join("|"),
|
|
200
|
-
"data-interval-ms": node.intervalMs,
|
|
201
|
-
},
|
|
202
|
-
node.phrases[0] ?? "",
|
|
203
|
-
),
|
|
204
|
-
tabs: (node, cls) =>
|
|
205
|
-
el(
|
|
206
|
-
"div",
|
|
207
|
-
{ class: cls },
|
|
208
|
-
el(
|
|
209
|
-
"div",
|
|
210
|
-
{ role: "tablist" },
|
|
211
|
-
...node.tabs.map((tab: TabItem) =>
|
|
212
|
-
el("button", { type: "button", role: "tab" }, tab.label),
|
|
213
|
-
),
|
|
214
|
-
),
|
|
215
|
-
...node.tabs.map((tab: TabItem) =>
|
|
216
|
-
el("div", { role: "tabpanel" }, ...renderChildren(tab.children)),
|
|
217
|
-
),
|
|
218
|
-
),
|
|
219
|
-
if: (node, cls) =>
|
|
220
|
-
el(
|
|
221
|
-
"div",
|
|
222
|
-
{ class: cls, "data-crepus-if": node.condition },
|
|
223
|
-
...renderChildren(node.thenChildren),
|
|
224
|
-
),
|
|
225
|
-
forEach: (node, cls) =>
|
|
226
|
-
el(
|
|
227
|
-
"div",
|
|
228
|
-
{
|
|
229
|
-
class: cls,
|
|
230
|
-
"data-crepus-for-each": node.bind,
|
|
231
|
-
"data-crepus-item": node.itemName,
|
|
232
|
-
},
|
|
233
|
-
...renderChildren(node.itemBody),
|
|
234
|
-
),
|
|
235
|
-
};
|
|
236
|
-
|
|
237
|
-
export function renderCrepusNodeSolid(node: ViewNode): JSX.Element {
|
|
238
|
-
const cls = classOf(node);
|
|
239
|
-
const renderer = RENDERERS[node.kind];
|
|
240
|
-
if (renderer) {
|
|
241
|
-
return renderer(node, cls);
|
|
242
|
-
}
|
|
243
|
-
return null as unknown as JSX.Element;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
/**
|
|
247
|
-
* Map View IR → Solid elements (separate from the React renderer).
|
|
248
|
-
* Same kind surface as `@tschk/crepus-moonshine`, one IR source of truth.
|
|
249
|
-
*/
|
|
250
|
-
export function renderCrepusIrSolid(
|
|
251
|
-
ir: ViewIr,
|
|
252
|
-
options: RenderCrepusOptions = {},
|
|
253
|
-
): JSX.Element {
|
|
254
|
-
const kids = (ir.root ?? []).map((node) => renderCrepusNodeSolid(node));
|
|
255
|
-
return el(
|
|
256
|
-
options.rootTag ?? "div",
|
|
257
|
-
{
|
|
258
|
-
"data-crepus-root": "true",
|
|
259
|
-
"data-crepus-target": "solid",
|
|
260
|
-
"data-crepus-ir-version": ir.version,
|
|
261
|
-
},
|
|
262
|
-
...kids,
|
|
263
|
-
);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
40
|
export { msCreateSignal as createMoonshineSignal };
|
|
267
41
|
export type { MsSignal as MoonshineSignal };
|
|
268
42
|
|
|
269
|
-
//
|
|
43
|
+
// Kernel APIs keep their existing names and contracts.
|
|
270
44
|
export {
|
|
271
45
|
createResource,
|
|
272
46
|
createSignal as createMsSignal,
|
package/src/ir.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/** Static View IR mapping parameterized by independent DOM or SSR factories. */
|
|
2
|
+
import type { TabItem, ViewIr, ViewNode } from "@tschk/crepuscularity-wasm";
|
|
3
|
+
|
|
4
|
+
export type CrepusNode = ViewNode;
|
|
5
|
+
export type CrepusIr = ViewIr;
|
|
6
|
+
|
|
7
|
+
export interface RenderCrepusOptions {
|
|
8
|
+
/** Wrapper element for the document root. */
|
|
9
|
+
rootTag?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Class tokens are carried verbatim from the template, so UnoCSS/Tailwind
|
|
14
|
+
* styles the output. The lowered `style` hints exist for native targets and are
|
|
15
|
+
* deliberately not converted to inline CSS here.
|
|
16
|
+
*/
|
|
17
|
+
function classOf(node: ViewNode): string | undefined {
|
|
18
|
+
const classes = (node as { style?: { classes?: string[] } }).style?.classes;
|
|
19
|
+
if (!classes || classes.length === 0) return undefined;
|
|
20
|
+
const joined = classes.filter(Boolean).join(" ");
|
|
21
|
+
return joined.length > 0 ? joined : undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createIrRenderer<Child>(
|
|
25
|
+
el: (
|
|
26
|
+
tag: string,
|
|
27
|
+
props: Record<string, unknown> | null,
|
|
28
|
+
...children: Array<Child | string | number | boolean | null | undefined>
|
|
29
|
+
) => Child,
|
|
30
|
+
) {
|
|
31
|
+
function renderChildren(children: ViewNode[]): Child[] {
|
|
32
|
+
return children.map((child) => renderCrepusNodeSolid(child));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const RENDERERS: Record<
|
|
36
|
+
string,
|
|
37
|
+
(node: any, cls: string | undefined) => Child
|
|
38
|
+
> = {
|
|
39
|
+
text: (node, cls) => el("span", { class: cls }, node.content),
|
|
40
|
+
link: (node, cls) =>
|
|
41
|
+
el(
|
|
42
|
+
"a",
|
|
43
|
+
{ class: cls, href: node.href, target: node.target, rel: node.rel },
|
|
44
|
+
...renderChildren(node.children),
|
|
45
|
+
),
|
|
46
|
+
stack: (node, cls) =>
|
|
47
|
+
el("div", { class: cls }, ...renderChildren(node.children)),
|
|
48
|
+
scroll: (node, cls) =>
|
|
49
|
+
el("div", { class: cls }, ...renderChildren(node.children)),
|
|
50
|
+
dropzone: (node, cls) =>
|
|
51
|
+
el(
|
|
52
|
+
"div",
|
|
53
|
+
{ class: cls, "aria-label": node.label },
|
|
54
|
+
...renderChildren(node.children),
|
|
55
|
+
),
|
|
56
|
+
list: (node, cls) =>
|
|
57
|
+
el(
|
|
58
|
+
node.ordered ? "ol" : "ul",
|
|
59
|
+
{ class: cls },
|
|
60
|
+
...renderChildren(node.children),
|
|
61
|
+
),
|
|
62
|
+
listItem: (node, cls) =>
|
|
63
|
+
el("li", { class: cls }, ...renderChildren(node.children)),
|
|
64
|
+
button: (node, cls) =>
|
|
65
|
+
el("button", { class: cls, type: "button" }, node.label),
|
|
66
|
+
badge: (node, cls) =>
|
|
67
|
+
el("span", { class: cls, "data-tone": node.tone }, node.label),
|
|
68
|
+
divider: (node, cls) => el("hr", { class: cls }),
|
|
69
|
+
spacer: (node, cls) => el("div", { class: cls, "aria-hidden": true }),
|
|
70
|
+
image: (node, cls) =>
|
|
71
|
+
el("img", { class: cls, src: node.src, alt: node.alt ?? "" }),
|
|
72
|
+
webView: (node, cls) => el("iframe", { class: cls, src: node.src }),
|
|
73
|
+
toggle: (node, cls) =>
|
|
74
|
+
el(
|
|
75
|
+
"button",
|
|
76
|
+
{
|
|
77
|
+
class: cls,
|
|
78
|
+
type: "button",
|
|
79
|
+
role: "switch",
|
|
80
|
+
"aria-checked": node.checked,
|
|
81
|
+
},
|
|
82
|
+
node.label,
|
|
83
|
+
),
|
|
84
|
+
checkbox: (node, cls) =>
|
|
85
|
+
el(
|
|
86
|
+
"label",
|
|
87
|
+
{ class: cls },
|
|
88
|
+
el("input", {
|
|
89
|
+
type: "checkbox",
|
|
90
|
+
checked: node.checked,
|
|
91
|
+
name: node.bind,
|
|
92
|
+
}),
|
|
93
|
+
node.label,
|
|
94
|
+
),
|
|
95
|
+
slider: (node, cls) =>
|
|
96
|
+
el("input", {
|
|
97
|
+
class: cls,
|
|
98
|
+
type: "range",
|
|
99
|
+
value: node.value,
|
|
100
|
+
min: node.min,
|
|
101
|
+
max: node.max,
|
|
102
|
+
step: node.step,
|
|
103
|
+
name: node.bind,
|
|
104
|
+
}),
|
|
105
|
+
progress: (node, cls) =>
|
|
106
|
+
el("progress", { class: cls, value: node.value, max: node.max }),
|
|
107
|
+
meter: (node, cls) =>
|
|
108
|
+
el("meter", {
|
|
109
|
+
class: cls,
|
|
110
|
+
value: node.value,
|
|
111
|
+
min: node.min,
|
|
112
|
+
max: node.max,
|
|
113
|
+
}),
|
|
114
|
+
input: (node, cls) =>
|
|
115
|
+
el(node.multiline ? "textarea" : "input", {
|
|
116
|
+
class: cls,
|
|
117
|
+
type: node.multiline ? undefined : node.secure ? "password" : "text",
|
|
118
|
+
placeholder: node.placeholder,
|
|
119
|
+
name: node.bind,
|
|
120
|
+
}),
|
|
121
|
+
picker: (node, cls) =>
|
|
122
|
+
el(
|
|
123
|
+
"select",
|
|
124
|
+
{ class: cls, name: node.bind },
|
|
125
|
+
...node.options.map((option: any) =>
|
|
126
|
+
el("option", { value: option.value }, option.label),
|
|
127
|
+
),
|
|
128
|
+
),
|
|
129
|
+
filePicker: (node, cls) =>
|
|
130
|
+
el(
|
|
131
|
+
"label",
|
|
132
|
+
{ class: cls },
|
|
133
|
+
el("input", {
|
|
134
|
+
type: "file",
|
|
135
|
+
accept: node.accept?.join(","),
|
|
136
|
+
multiple: node.multiple,
|
|
137
|
+
}),
|
|
138
|
+
node.label,
|
|
139
|
+
),
|
|
140
|
+
slotRotate: (node, cls) =>
|
|
141
|
+
el(
|
|
142
|
+
"span",
|
|
143
|
+
{
|
|
144
|
+
class: cls,
|
|
145
|
+
"data-crepus-slot-rotate": node.phrases.join("|"),
|
|
146
|
+
"data-interval-ms": node.intervalMs,
|
|
147
|
+
},
|
|
148
|
+
node.phrases[0] ?? "",
|
|
149
|
+
),
|
|
150
|
+
tabs: (node, cls) =>
|
|
151
|
+
el(
|
|
152
|
+
"div",
|
|
153
|
+
{ class: cls },
|
|
154
|
+
el(
|
|
155
|
+
"div",
|
|
156
|
+
{ role: "tablist" },
|
|
157
|
+
...node.tabs.map((tab: TabItem) =>
|
|
158
|
+
el("button", { type: "button", role: "tab" }, tab.label),
|
|
159
|
+
),
|
|
160
|
+
),
|
|
161
|
+
...node.tabs.map((tab: TabItem) =>
|
|
162
|
+
el("div", { role: "tabpanel" }, ...renderChildren(tab.children)),
|
|
163
|
+
),
|
|
164
|
+
),
|
|
165
|
+
if: (node, cls) =>
|
|
166
|
+
el(
|
|
167
|
+
"div",
|
|
168
|
+
{ class: cls, "data-crepus-if": node.condition },
|
|
169
|
+
...renderChildren(node.thenChildren),
|
|
170
|
+
),
|
|
171
|
+
forEach: (node, cls) =>
|
|
172
|
+
el(
|
|
173
|
+
"div",
|
|
174
|
+
{
|
|
175
|
+
class: cls,
|
|
176
|
+
"data-crepus-for-each": node.bind,
|
|
177
|
+
"data-crepus-item": node.itemName,
|
|
178
|
+
},
|
|
179
|
+
...renderChildren(node.itemBody),
|
|
180
|
+
),
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
function renderCrepusNodeSolid(node: ViewNode): Child {
|
|
184
|
+
if (!Object.hasOwn(RENDERERS, node.kind)) {
|
|
185
|
+
throw new TypeError(`Unsupported View IR node kind: ${node.kind}`);
|
|
186
|
+
}
|
|
187
|
+
const cls = classOf(node);
|
|
188
|
+
const renderer = RENDERERS[node.kind];
|
|
189
|
+
return renderer(node, cls);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Static mapping only: if/forEach preserve their authored placeholder children.
|
|
194
|
+
* Compile source through Crepus for executable conditions, loops and handlers.
|
|
195
|
+
*/
|
|
196
|
+
function renderCrepusIrSolid(
|
|
197
|
+
ir: ViewIr,
|
|
198
|
+
options: RenderCrepusOptions = {},
|
|
199
|
+
): Child {
|
|
200
|
+
const kids = (ir.root ?? []).map((node) => renderCrepusNodeSolid(node));
|
|
201
|
+
return el(
|
|
202
|
+
options.rootTag ?? "div",
|
|
203
|
+
{
|
|
204
|
+
"data-crepus-root": "true",
|
|
205
|
+
"data-crepus-target": "solid",
|
|
206
|
+
"data-crepus-ir-version": ir.version,
|
|
207
|
+
},
|
|
208
|
+
...kids,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return { renderCrepusNodeSolid, renderCrepusIrSolid };
|
|
213
|
+
}
|
package/src/lazy.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createComponent } from "./component-factory";
|
|
2
|
+
/** Independent client component loading; no Suspense, hydration or async SSR. */
|
|
3
|
+
import { createResource } from "@tschk/moonshine-dom/resource";
|
|
4
|
+
import {
|
|
5
|
+
createMemo,
|
|
6
|
+
getOwner,
|
|
7
|
+
untrack,
|
|
8
|
+
type Accessor,
|
|
9
|
+
} from "@tschk/moonshine-dom/reactive";
|
|
10
|
+
import { peekComponentContext } from "@tschk/moonshine-dom/component";
|
|
11
|
+
import { isSSRRendering } from "@tschk/moonshine-dom/ssr-owner";
|
|
12
|
+
|
|
13
|
+
function guard() {
|
|
14
|
+
if (isSSRRendering() || peekComponentContext()?.mode === "ssr")
|
|
15
|
+
throw new Error("Native lazy components are client-only");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function lazy<P, T>(
|
|
19
|
+
loader: () => Promise<{ default: (props: P) => T }>,
|
|
20
|
+
): ((props: P) => Accessor<T | "">) & {
|
|
21
|
+
preload: () => Promise<{ default: (props: P) => T }>;
|
|
22
|
+
} {
|
|
23
|
+
let pending: Promise<{ default: (props: P) => T }> | undefined;
|
|
24
|
+
let component: ((props: P) => T) | undefined;
|
|
25
|
+
const load = () => {
|
|
26
|
+
guard();
|
|
27
|
+
if (!pending) {
|
|
28
|
+
// Invoke before assigning: synchronous loader failures remain retryable.
|
|
29
|
+
const result = untrack(loader);
|
|
30
|
+
pending = Promise.resolve(result).then((module) => {
|
|
31
|
+
const resolved = module?.default;
|
|
32
|
+
if (typeof resolved !== "function")
|
|
33
|
+
throw new TypeError(
|
|
34
|
+
"Lazy modules require a default component function",
|
|
35
|
+
);
|
|
36
|
+
component = resolved;
|
|
37
|
+
return module;
|
|
38
|
+
});
|
|
39
|
+
// Observe the internal cache; callers still receive its rejecting promise.
|
|
40
|
+
void pending.catch(() => {});
|
|
41
|
+
}
|
|
42
|
+
return pending;
|
|
43
|
+
};
|
|
44
|
+
return Object.assign(
|
|
45
|
+
(props: P) => {
|
|
46
|
+
guard();
|
|
47
|
+
const owner = getOwner();
|
|
48
|
+
if (!owner || owner.disposed)
|
|
49
|
+
throw new Error(
|
|
50
|
+
"Lazy component invocation requires a live native owner",
|
|
51
|
+
);
|
|
52
|
+
if (component)
|
|
53
|
+
return createMemo(() => createComponent(component!, props));
|
|
54
|
+
const [module] = createResource(load);
|
|
55
|
+
return createMemo(() => {
|
|
56
|
+
const ready = module();
|
|
57
|
+
return ready ? createComponent(component!, props) : "";
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
{ preload: load },
|
|
61
|
+
);
|
|
62
|
+
}
|
package/src/portal-h.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {
|
|
2
|
+
h as nativeH,
|
|
3
|
+
eventRegistration,
|
|
4
|
+
type DOMChild,
|
|
5
|
+
} from "@tschk/moonshine-dom/dom";
|
|
6
|
+
import { registerSolidEvent } from "./events";
|
|
7
|
+
const eventProps: ProxyHandler<object> = {
|
|
8
|
+
get(target, key) {
|
|
9
|
+
return key === eventRegistration
|
|
10
|
+
? registerSolidEvent
|
|
11
|
+
: Reflect.get(target, key, target);
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
export function h<K extends keyof HTMLElementTagNameMap>(
|
|
15
|
+
tag: K,
|
|
16
|
+
props?: Record<string, unknown> | null,
|
|
17
|
+
...children: DOMChild[]
|
|
18
|
+
): HTMLElementTagNameMap[K];
|
|
19
|
+
export function h<K extends keyof SVGElementTagNameMap>(
|
|
20
|
+
tag: K,
|
|
21
|
+
props?: Record<string, unknown> | null,
|
|
22
|
+
...children: DOMChild[]
|
|
23
|
+
): SVGElementTagNameMap[K];
|
|
24
|
+
export function h<P extends object>(
|
|
25
|
+
component: (props: P & { children?: DOMChild }) => DOMChild,
|
|
26
|
+
props: P | null,
|
|
27
|
+
...children: DOMChild[]
|
|
28
|
+
): DOMChild;
|
|
29
|
+
export function h(
|
|
30
|
+
tag: string,
|
|
31
|
+
props?: Record<string, unknown> | null,
|
|
32
|
+
...children: DOMChild[]
|
|
33
|
+
): HTMLElement | SVGElement;
|
|
34
|
+
export function h<P extends Record<string, unknown>>(
|
|
35
|
+
tag: string | ((props: P & { children?: DOMChild }) => DOMChild),
|
|
36
|
+
props: P | null = null,
|
|
37
|
+
...children: DOMChild[]
|
|
38
|
+
): DOMChild {
|
|
39
|
+
if (typeof tag === "string")
|
|
40
|
+
return nativeH(tag, new Proxy(props ?? {}, eventProps), ...children);
|
|
41
|
+
return nativeH(tag, props, ...children);
|
|
42
|
+
}
|
package/src/portal.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { render, type DOMChild } from "@tschk/moonshine-dom/dom";
|
|
2
|
+
import { getOwner, onCleanup } from "@tschk/moonshine-dom/reactive";
|
|
3
|
+
import { isSSRRendering } from "@tschk/moonshine-dom/ssr-owner";
|
|
4
|
+
import { peekComponentContext } from "@tschk/moonshine-dom/component";
|
|
5
|
+
import { acquirePortalDocument, portalHosts } from "./events";
|
|
6
|
+
export function Portal(props: {
|
|
7
|
+
mount?: Element;
|
|
8
|
+
children?: DOMChild;
|
|
9
|
+
isSVG?: boolean;
|
|
10
|
+
useShadow?: boolean;
|
|
11
|
+
}): Text | undefined {
|
|
12
|
+
if (isSSRRendering() || peekComponentContext()?.mode === "ssr") return;
|
|
13
|
+
if (!getOwner() || getOwner()!.disposed)
|
|
14
|
+
throw Error("Portal requires a live native owner");
|
|
15
|
+
const mount = props.mount ?? document.body;
|
|
16
|
+
if (mount.ownerDocument !== document)
|
|
17
|
+
onCleanup(acquirePortalDocument(mount.ownerDocument));
|
|
18
|
+
const marker = document.createTextNode("");
|
|
19
|
+
if (mount.localName === "head") {
|
|
20
|
+
let stop: (() => void) | undefined;
|
|
21
|
+
let disposed = false;
|
|
22
|
+
onCleanup(() => {
|
|
23
|
+
disposed = true;
|
|
24
|
+
stop?.();
|
|
25
|
+
});
|
|
26
|
+
stop = render(() => props.children, mount);
|
|
27
|
+
if (disposed) stop();
|
|
28
|
+
return marker;
|
|
29
|
+
}
|
|
30
|
+
const container = props.isSVG
|
|
31
|
+
? document.createElementNS("http://www.w3.org/2000/svg", "g")
|
|
32
|
+
: document.createElement("div");
|
|
33
|
+
portalHosts.set(container, () => marker.parentNode);
|
|
34
|
+
let stop: (() => void) | undefined;
|
|
35
|
+
let disposed = false;
|
|
36
|
+
onCleanup(() => {
|
|
37
|
+
disposed = true;
|
|
38
|
+
try {
|
|
39
|
+
stop?.();
|
|
40
|
+
} finally {
|
|
41
|
+
portalHosts.delete(container);
|
|
42
|
+
container.remove();
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
const renderRoot =
|
|
46
|
+
props.useShadow && container.attachShadow
|
|
47
|
+
? container.attachShadow({ mode: "open" })
|
|
48
|
+
: container;
|
|
49
|
+
stop = render(() => props.children, renderRoot);
|
|
50
|
+
// Child setup can dispose the parent before this root's disposer is available.
|
|
51
|
+
if (disposed) stop();
|
|
52
|
+
else mount.appendChild(container);
|
|
53
|
+
return marker;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export { h } from "./portal-h";
|