@c9up/aurora 0.1.3
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 +36 -0
- package/dist/AuroraManager.d.ts +44 -0
- package/dist/AuroraManager.js +47 -0
- package/dist/AuroraProvider.d.ts +52 -0
- package/dist/AuroraProvider.js +145 -0
- package/dist/Pages.d.ts +78 -0
- package/dist/Pages.js +116 -0
- package/dist/component.d.ts +55 -0
- package/dist/component.js +97 -0
- package/dist/html.d.ts +30 -0
- package/dist/html.js +246 -0
- package/dist/hydrate.d.ts +29 -0
- package/dist/hydrate.js +379 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/reactive.d.ts +83 -0
- package/dist/reactive.js +217 -0
- package/dist/relay.d.ts +43 -0
- package/dist/relay.js +144 -0
- package/dist/render.d.ts +25 -0
- package/dist/render.js +283 -0
- package/dist/route.d.ts +64 -0
- package/dist/route.js +49 -0
- package/dist/server/renderPage.d.ts +62 -0
- package/dist/server/renderPage.js +83 -0
- package/dist/server/serveAssets.d.ts +43 -0
- package/dist/server/serveAssets.js +89 -0
- package/dist/services/main.d.ts +18 -0
- package/dist/services/main.js +31 -0
- package/dist/ssr.d.ts +22 -0
- package/dist/ssr.js +179 -0
- package/dist/types.d.ts +78 -0
- package/dist/types.js +15 -0
- package/package.json +69 -0
- package/src/AuroraManager.ts +76 -0
- package/src/AuroraProvider.ts +187 -0
- package/src/Pages.ts +164 -0
- package/src/component.ts +138 -0
- package/src/html.ts +296 -0
- package/src/hydrate.ts +518 -0
- package/src/index.ts +43 -0
- package/src/reactive.ts +265 -0
- package/src/relay.ts +171 -0
- package/src/render.ts +378 -0
- package/src/route.ts +96 -0
- package/src/server/renderPage.ts +135 -0
- package/src/server/serveAssets.ts +135 -0
- package/src/services/main.ts +40 -0
- package/src/ssr.ts +179 -0
- package/src/types.ts +97 -0
package/src/render.ts
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render a `TemplateResult` to the DOM and keep it reactive.
|
|
3
|
+
*
|
|
4
|
+
* `render(template, container)` clones the parsed `<template>`, walks to
|
|
5
|
+
* each slot, attaches the corresponding value (with `effect()` for any
|
|
6
|
+
* reactive expression), and appends the result to the container. The
|
|
7
|
+
* returned dispose function tears down every effect and removes the
|
|
8
|
+
* mounted nodes.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readComponentLifecycle } from "./component.js";
|
|
12
|
+
import { getTemplate } from "./html.js";
|
|
13
|
+
import { effect, isSignal } from "./reactive.js";
|
|
14
|
+
import {
|
|
15
|
+
type AttrSlot,
|
|
16
|
+
type BooleanAttrSlot,
|
|
17
|
+
type EffectCallback,
|
|
18
|
+
type EventSlot,
|
|
19
|
+
isTemplateResult,
|
|
20
|
+
type NodePath,
|
|
21
|
+
type PropSlot,
|
|
22
|
+
type Slot,
|
|
23
|
+
type TemplateResult,
|
|
24
|
+
type TextSlot,
|
|
25
|
+
} from "./types.js";
|
|
26
|
+
|
|
27
|
+
export type Disposer = () => void;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Mount a TemplateResult into `container`. Returns a `Disposer` that
|
|
31
|
+
* stops every reactive effect and removes the mounted nodes. Calling it
|
|
32
|
+
* twice is a no-op.
|
|
33
|
+
*/
|
|
34
|
+
export function render(
|
|
35
|
+
result: TemplateResult,
|
|
36
|
+
container: Element | DocumentFragment,
|
|
37
|
+
): Disposer {
|
|
38
|
+
const cleanups: Disposer[] = [];
|
|
39
|
+
const mountedNodes: ChildNode[] = [];
|
|
40
|
+
const mountHooks: Array<EffectCallback> = [];
|
|
41
|
+
|
|
42
|
+
const fragment = mount(result, cleanups, mountedNodes, mountHooks);
|
|
43
|
+
container.appendChild(fragment);
|
|
44
|
+
|
|
45
|
+
// `onMount` hooks fire after the fragment is live in the document so
|
|
46
|
+
// callbacks that measure / focus / observe see a real DOM. A returned
|
|
47
|
+
// cleanup function joins the unmount queue.
|
|
48
|
+
for (const hook of mountHooks) {
|
|
49
|
+
try {
|
|
50
|
+
const teardown = hook();
|
|
51
|
+
if (typeof teardown === "function") cleanups.push(teardown);
|
|
52
|
+
} catch {
|
|
53
|
+
/* swallow — one bad onMount should not block sibling components */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let disposed = false;
|
|
58
|
+
return () => {
|
|
59
|
+
if (disposed) return;
|
|
60
|
+
disposed = true;
|
|
61
|
+
for (const c of cleanups.splice(0)) c();
|
|
62
|
+
for (const node of mountedNodes) node.remove();
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Pending multi-slot attribute group — collected during the per-slot
|
|
68
|
+
* pass so the renderer can wire a single effect per `(element, attr)`
|
|
69
|
+
* after every contributing value is known.
|
|
70
|
+
*/
|
|
71
|
+
interface MultiAttrGroup {
|
|
72
|
+
el: Element;
|
|
73
|
+
name: string;
|
|
74
|
+
staticParts: readonly string[];
|
|
75
|
+
/** Source-ordered values from each contributing slot. */
|
|
76
|
+
values: unknown[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build a fragment for a TemplateResult and register cleanups.
|
|
81
|
+
*
|
|
82
|
+
* @internal Exported so `hydrate.ts` can client-render a reactive
|
|
83
|
+
* nested-template subtree when the signal changes after hydration
|
|
84
|
+
* (the swap path — see `hydrateTextSlot`).
|
|
85
|
+
*/
|
|
86
|
+
export function mount(
|
|
87
|
+
result: TemplateResult,
|
|
88
|
+
cleanups: Disposer[],
|
|
89
|
+
mounted: ChildNode[],
|
|
90
|
+
mountHooks: Array<EffectCallback>,
|
|
91
|
+
): DocumentFragment {
|
|
92
|
+
const tpl = getTemplate(result.strings);
|
|
93
|
+
const fragment = tpl.element.content.cloneNode(true) as DocumentFragment;
|
|
94
|
+
|
|
95
|
+
// Forward any component()-attached lifecycle from this result.
|
|
96
|
+
const lifecycle = readComponentLifecycle(result);
|
|
97
|
+
if (lifecycle) {
|
|
98
|
+
for (const hook of lifecycle.mountHooks) mountHooks.push(hook);
|
|
99
|
+
for (const c of lifecycle.cleanups) cleanups.push(c);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Multi-slot attrs need every contributing value before we can join
|
|
103
|
+
// the final string. Collect them in a first pass, attach effects
|
|
104
|
+
// after.
|
|
105
|
+
const multiGroups = new Map<string, MultiAttrGroup>();
|
|
106
|
+
|
|
107
|
+
for (let i = 0; i < tpl.slots.length; i++) {
|
|
108
|
+
const slot = tpl.slots[i];
|
|
109
|
+
const node = resolvePath(fragment, slot.path);
|
|
110
|
+
if (slot.kind === "attr" && slot.staticParts !== undefined) {
|
|
111
|
+
collectMultiAttr(slot, node as Element, result.values[i], multiGroups);
|
|
112
|
+
} else {
|
|
113
|
+
applySlot(slot, node, result.values[i], cleanups, mounted, mountHooks);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const group of multiGroups.values()) {
|
|
118
|
+
applyMultiAttrGroup(group, cleanups);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (const child of Array.from(fragment.childNodes)) {
|
|
122
|
+
mounted.push(child);
|
|
123
|
+
}
|
|
124
|
+
return fragment;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function resolvePath(root: ParentNode, path: NodePath): Node {
|
|
128
|
+
let node: Node = root;
|
|
129
|
+
for (const i of path) node = node.childNodes[i];
|
|
130
|
+
return node;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function applySlot(
|
|
134
|
+
slot: Slot,
|
|
135
|
+
node: Node,
|
|
136
|
+
value: unknown,
|
|
137
|
+
cleanups: Disposer[],
|
|
138
|
+
mounted: ChildNode[],
|
|
139
|
+
mountHooks: Array<EffectCallback>,
|
|
140
|
+
): void {
|
|
141
|
+
switch (slot.kind) {
|
|
142
|
+
case "text":
|
|
143
|
+
applyTextSlot(
|
|
144
|
+
slot,
|
|
145
|
+
node as Comment,
|
|
146
|
+
value,
|
|
147
|
+
cleanups,
|
|
148
|
+
mounted,
|
|
149
|
+
mountHooks,
|
|
150
|
+
);
|
|
151
|
+
return;
|
|
152
|
+
case "attr":
|
|
153
|
+
applyAttrSlot(slot, node as Element, value, cleanups);
|
|
154
|
+
return;
|
|
155
|
+
case "boolean-attr":
|
|
156
|
+
applyBooleanAttrSlot(slot, node as Element, value, cleanups);
|
|
157
|
+
return;
|
|
158
|
+
case "prop":
|
|
159
|
+
applyPropSlot(slot, node as Element, value, cleanups);
|
|
160
|
+
return;
|
|
161
|
+
case "event":
|
|
162
|
+
applyEventSlot(slot, node as Element, value, cleanups);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Text slot — replace the marker comment with whatever the value
|
|
169
|
+
* resolves to. The anchor comment stays in place; new content is
|
|
170
|
+
* inserted before it, and each re-render swaps out only the nodes it
|
|
171
|
+
* previously inserted.
|
|
172
|
+
*/
|
|
173
|
+
function applyTextSlot(
|
|
174
|
+
_slot: TextSlot,
|
|
175
|
+
anchor: Comment,
|
|
176
|
+
value: unknown,
|
|
177
|
+
cleanups: Disposer[],
|
|
178
|
+
mounted: ChildNode[],
|
|
179
|
+
mountHooks: Array<EffectCallback>,
|
|
180
|
+
): void {
|
|
181
|
+
let currentNodes: ChildNode[] = [];
|
|
182
|
+
// Per-render disposers for whatever the slot currently shows. A
|
|
183
|
+
// reactive slot that swaps a nested TemplateResult for another must
|
|
184
|
+
// dispose the OLD subtree's effects + event listeners — otherwise
|
|
185
|
+
// they'd live in the shared `cleanups` array until the whole root
|
|
186
|
+
// disposes, leaking a stale subscription/listener on every branch
|
|
187
|
+
// change. We hand `localCleanups` (not `cleanups`) to the per-render
|
|
188
|
+
// mount and tear it down at the top of each `set()`.
|
|
189
|
+
let localCleanups: Disposer[] = [];
|
|
190
|
+
|
|
191
|
+
function disposeLocal(): void {
|
|
192
|
+
for (const d of localCleanups) d();
|
|
193
|
+
localCleanups = [];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function set(newValue: unknown): void {
|
|
197
|
+
disposeLocal();
|
|
198
|
+
for (const n of currentNodes) n.remove();
|
|
199
|
+
currentNodes = [];
|
|
200
|
+
const nodes = renderValueIntoNodes(
|
|
201
|
+
newValue,
|
|
202
|
+
localCleanups,
|
|
203
|
+
mounted,
|
|
204
|
+
mountHooks,
|
|
205
|
+
);
|
|
206
|
+
const parent = anchor.parentNode;
|
|
207
|
+
if (!parent) return;
|
|
208
|
+
for (const n of nodes) {
|
|
209
|
+
parent.insertBefore(n, anchor);
|
|
210
|
+
currentNodes.push(n);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (isSignal(value) || typeof value === "function") {
|
|
215
|
+
const dispose = effect(() => {
|
|
216
|
+
set((value as () => unknown)());
|
|
217
|
+
});
|
|
218
|
+
// Root disposal tears down the slot's own effect AND whatever
|
|
219
|
+
// subtree is currently mounted.
|
|
220
|
+
cleanups.push(() => {
|
|
221
|
+
dispose();
|
|
222
|
+
disposeLocal();
|
|
223
|
+
});
|
|
224
|
+
} else {
|
|
225
|
+
set(value);
|
|
226
|
+
// Static value never re-runs, but its subtree (e.g. a one-shot
|
|
227
|
+
// nested template) still needs to be disposed with the root.
|
|
228
|
+
cleanups.push(disposeLocal);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function renderValueIntoNodes(
|
|
233
|
+
value: unknown,
|
|
234
|
+
cleanups: Disposer[],
|
|
235
|
+
mounted: ChildNode[],
|
|
236
|
+
mountHooks: Array<EffectCallback>,
|
|
237
|
+
): ChildNode[] {
|
|
238
|
+
if (value === null || value === undefined || value === false) return [];
|
|
239
|
+
if (Array.isArray(value)) {
|
|
240
|
+
const out: ChildNode[] = [];
|
|
241
|
+
for (const item of value) {
|
|
242
|
+
out.push(...renderValueIntoNodes(item, cleanups, mounted, mountHooks));
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
if (isTemplateResult(value)) {
|
|
247
|
+
const frag = mount(value, cleanups, mounted, mountHooks);
|
|
248
|
+
return Array.from(frag.childNodes);
|
|
249
|
+
}
|
|
250
|
+
if (value instanceof Node) {
|
|
251
|
+
return [value as ChildNode];
|
|
252
|
+
}
|
|
253
|
+
return [document.createTextNode(String(value))];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function applyAttrSlot(
|
|
257
|
+
slot: AttrSlot,
|
|
258
|
+
el: Element,
|
|
259
|
+
value: unknown,
|
|
260
|
+
cleanups: Disposer[],
|
|
261
|
+
): void {
|
|
262
|
+
function apply(v: unknown): void {
|
|
263
|
+
if (v === null || v === undefined || v === false) {
|
|
264
|
+
el.removeAttribute(slot.name);
|
|
265
|
+
} else if (v === true) {
|
|
266
|
+
el.setAttribute(slot.name, "");
|
|
267
|
+
} else {
|
|
268
|
+
el.setAttribute(slot.name, String(v));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (isSignal(value) || typeof value === "function") {
|
|
272
|
+
const dispose = effect(() => apply((value as () => unknown)()));
|
|
273
|
+
cleanups.push(dispose);
|
|
274
|
+
} else {
|
|
275
|
+
apply(value);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function collectMultiAttr(
|
|
280
|
+
slot: AttrSlot,
|
|
281
|
+
el: Element,
|
|
282
|
+
value: unknown,
|
|
283
|
+
groups: Map<string, MultiAttrGroup>,
|
|
284
|
+
): void {
|
|
285
|
+
if (!slot.staticParts) return;
|
|
286
|
+
const key = `${slot.name}::${(slot.path as readonly number[]).join(".")}`;
|
|
287
|
+
let group = groups.get(key);
|
|
288
|
+
if (!group) {
|
|
289
|
+
group = {
|
|
290
|
+
el,
|
|
291
|
+
name: slot.name,
|
|
292
|
+
staticParts: slot.staticParts,
|
|
293
|
+
values: [],
|
|
294
|
+
};
|
|
295
|
+
groups.set(key, group);
|
|
296
|
+
}
|
|
297
|
+
group.values.push(value);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function applyMultiAttrGroup(
|
|
301
|
+
group: MultiAttrGroup,
|
|
302
|
+
cleanups: Disposer[],
|
|
303
|
+
): void {
|
|
304
|
+
function join(): string {
|
|
305
|
+
let out = group.staticParts[0] ?? "";
|
|
306
|
+
for (let i = 0; i < group.values.length; i++) {
|
|
307
|
+
const v = resolveReactive(group.values[i]);
|
|
308
|
+
out += v == null || v === false ? "" : String(v);
|
|
309
|
+
out += group.staticParts[i + 1] ?? "";
|
|
310
|
+
}
|
|
311
|
+
return out;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const hasReactive = group.values.some(
|
|
315
|
+
(v) => isSignal(v) || typeof v === "function",
|
|
316
|
+
);
|
|
317
|
+
if (hasReactive) {
|
|
318
|
+
const dispose = effect(() => {
|
|
319
|
+
group.el.setAttribute(group.name, join());
|
|
320
|
+
});
|
|
321
|
+
cleanups.push(dispose);
|
|
322
|
+
} else {
|
|
323
|
+
group.el.setAttribute(group.name, join());
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function resolveReactive(value: unknown): unknown {
|
|
328
|
+
if (isSignal(value)) return value();
|
|
329
|
+
if (typeof value === "function") return (value as () => unknown)();
|
|
330
|
+
return value;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function applyBooleanAttrSlot(
|
|
334
|
+
slot: BooleanAttrSlot,
|
|
335
|
+
el: Element,
|
|
336
|
+
value: unknown,
|
|
337
|
+
cleanups: Disposer[],
|
|
338
|
+
): void {
|
|
339
|
+
function apply(v: unknown): void {
|
|
340
|
+
if (v) el.setAttribute(slot.name, "");
|
|
341
|
+
else el.removeAttribute(slot.name);
|
|
342
|
+
}
|
|
343
|
+
if (isSignal(value) || typeof value === "function") {
|
|
344
|
+
const dispose = effect(() => apply((value as () => unknown)()));
|
|
345
|
+
cleanups.push(dispose);
|
|
346
|
+
} else {
|
|
347
|
+
apply(value);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function applyPropSlot(
|
|
352
|
+
slot: PropSlot,
|
|
353
|
+
el: Element,
|
|
354
|
+
value: unknown,
|
|
355
|
+
cleanups: Disposer[],
|
|
356
|
+
): void {
|
|
357
|
+
function apply(v: unknown): void {
|
|
358
|
+
(el as unknown as Record<string, unknown>)[slot.name] = v;
|
|
359
|
+
}
|
|
360
|
+
if (isSignal(value) || typeof value === "function") {
|
|
361
|
+
const dispose = effect(() => apply((value as () => unknown)()));
|
|
362
|
+
cleanups.push(dispose);
|
|
363
|
+
} else {
|
|
364
|
+
apply(value);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function applyEventSlot(
|
|
369
|
+
slot: EventSlot,
|
|
370
|
+
el: Element,
|
|
371
|
+
value: unknown,
|
|
372
|
+
cleanups: Disposer[],
|
|
373
|
+
): void {
|
|
374
|
+
if (typeof value !== "function") return;
|
|
375
|
+
const handler = value as EventListener;
|
|
376
|
+
el.addEventListener(slot.event, handler);
|
|
377
|
+
cleanups.push(() => el.removeEventListener(slot.event, handler));
|
|
378
|
+
}
|
package/src/route.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `auroraRoute` — Ream route handler that SSR-renders an aurora
|
|
3
|
+
* component and ships the markup plus the bytes needed for client-side
|
|
4
|
+
* hydration.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
*
|
|
8
|
+
* import { auroraRoute } from '@c9up/aurora/provider'
|
|
9
|
+
* router.get('/dashboard', auroraRoute({
|
|
10
|
+
* entry: '/app/pages/dashboard.client.js',
|
|
11
|
+
* render: () => Dashboard({ user: ... })
|
|
12
|
+
* }))
|
|
13
|
+
*
|
|
14
|
+
* The handler:
|
|
15
|
+
* 1. invokes `render()` and stringifies the result via @c9up/aurora's SSR
|
|
16
|
+
* 2. wraps the markup in the page shell from `shell` (default: a minimal
|
|
17
|
+
* <!doctype html> document)
|
|
18
|
+
* 3. embeds a `<script type="module">` that imports the user-supplied
|
|
19
|
+
* `entry` module — the client bundle is expected to call
|
|
20
|
+
* `hydrate(document.getElementById('aurora-root'), factory)` itself.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { renderToString } from "./ssr.js";
|
|
24
|
+
import type { TemplateResult } from "./types.js";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Structural slice of Ream's HttpContext — the response surface we need.
|
|
28
|
+
* Declaring it locally keeps aurora's bundle free of a `@c9up/ream`
|
|
29
|
+
* import; any framework whose context exposes `response.header()` and
|
|
30
|
+
* `response.send()` satisfies this contract via structural subtyping.
|
|
31
|
+
*/
|
|
32
|
+
export interface AuroraResponse {
|
|
33
|
+
status(code: number): AuroraResponse;
|
|
34
|
+
header(name: string, value: string): AuroraResponse;
|
|
35
|
+
send(data: string): void;
|
|
36
|
+
}
|
|
37
|
+
export interface AuroraHttpContext {
|
|
38
|
+
request: unknown;
|
|
39
|
+
response: AuroraResponse;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Configuration for a single auroraRoute call. */
|
|
43
|
+
export interface AuroraRouteConfig {
|
|
44
|
+
/**
|
|
45
|
+
* SSR factory. Called once per request. Returning a TemplateResult is
|
|
46
|
+
* the common case; returning a Promise<TemplateResult> works too
|
|
47
|
+
* (data-loading components).
|
|
48
|
+
*/
|
|
49
|
+
render: (ctx: AuroraHttpContext) => TemplateResult | Promise<TemplateResult>;
|
|
50
|
+
/**
|
|
51
|
+
* Path to the ES module the browser should import to hydrate. The
|
|
52
|
+
* value is inlined into a `<script type="module" src="...">` tag —
|
|
53
|
+
* make sure the route exists on the Ream router (typically served
|
|
54
|
+
* statically). Defaults to `/aurora-client.js`.
|
|
55
|
+
*/
|
|
56
|
+
entry?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Page shell. Defaults to a minimal HTML5 doctype with a `<div
|
|
59
|
+
* id="aurora-root">` that wraps the SSR markup. Apps swap in their
|
|
60
|
+
* own to control `<head>` (title, meta, fonts, css).
|
|
61
|
+
*/
|
|
62
|
+
shell?: (body: string, entry: string) => string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const DEFAULT_SHELL = (body: string, entry: string): string =>
|
|
66
|
+
`<!doctype html>
|
|
67
|
+
<html lang="en">
|
|
68
|
+
<head>
|
|
69
|
+
<meta charset="utf-8" />
|
|
70
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
71
|
+
<title>Aurora</title>
|
|
72
|
+
</head>
|
|
73
|
+
<body>
|
|
74
|
+
<div id="aurora-root">${body}</div>
|
|
75
|
+
<script type="module" src="${entry}"></script>
|
|
76
|
+
</body>
|
|
77
|
+
</html>`;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build a Ream-compatible route handler that SSR-renders the given
|
|
81
|
+
* factory and serves the full HTML document.
|
|
82
|
+
*/
|
|
83
|
+
export function auroraRoute(
|
|
84
|
+
config: AuroraRouteConfig,
|
|
85
|
+
): (ctx: AuroraHttpContext) => Promise<void> {
|
|
86
|
+
const entry = config.entry ?? "/aurora-client.js";
|
|
87
|
+
const shell = config.shell ?? DEFAULT_SHELL;
|
|
88
|
+
|
|
89
|
+
return async (ctx) => {
|
|
90
|
+
const tree = await config.render(ctx);
|
|
91
|
+
const body = renderToString(tree);
|
|
92
|
+
const html = shell(body, entry);
|
|
93
|
+
ctx.response.header("content-type", "text/html; charset=utf-8");
|
|
94
|
+
ctx.response.send(html);
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `renderPage` — server-side helper that turns a page name + props into
|
|
3
|
+
* a full HTML document, ready to ship from a route handler.
|
|
4
|
+
*
|
|
5
|
+
* The output carries everything the browser needs to hydrate against
|
|
6
|
+
* the SSR markup with zero app-side scripting:
|
|
7
|
+
*
|
|
8
|
+
* <head>
|
|
9
|
+
* ...
|
|
10
|
+
* <script type="importmap">
|
|
11
|
+
* { "imports": { "@c9up/aurora": "/_assets/aurora/index.js" } }
|
|
12
|
+
* </script>
|
|
13
|
+
* </head>
|
|
14
|
+
* <body>
|
|
15
|
+
* <div id="aurora-root">…SSR markup…</div>
|
|
16
|
+
* <script id="aurora-page-data" type="application/json">{…}</script>
|
|
17
|
+
* <script type="module">
|
|
18
|
+
* import { hydrate } from '@c9up/aurora'
|
|
19
|
+
* import Page from '/_assets/pages/ProjectPage.js'
|
|
20
|
+
* const data = JSON.parse(document.getElementById('aurora-page-data').textContent)
|
|
21
|
+
* hydrate(document.getElementById('aurora-root'), () => Page(data.props))
|
|
22
|
+
* </script>
|
|
23
|
+
* </body>
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { Pages } from "../Pages.js";
|
|
27
|
+
import { renderToString } from "../ssr.js";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Structural slice of the host framework's response. Same shape
|
|
31
|
+
* `auroraRoute()` uses — keeps aurora free of a `@c9up/ream` import.
|
|
32
|
+
*/
|
|
33
|
+
export interface RenderResponse {
|
|
34
|
+
status(code: number): RenderResponse;
|
|
35
|
+
header(name: string, value: string): RenderResponse;
|
|
36
|
+
send(body: string): void;
|
|
37
|
+
}
|
|
38
|
+
export interface RenderHttpContext {
|
|
39
|
+
request: unknown;
|
|
40
|
+
response: RenderResponse;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface RenderPageOptions {
|
|
44
|
+
/**
|
|
45
|
+
* Importmap entries injected into `<head>`. Defaults to mapping
|
|
46
|
+
* `@c9up/aurora` to `/_assets/aurora/index.js`. Override to point
|
|
47
|
+
* at a different mount or to add app-side aliases.
|
|
48
|
+
*/
|
|
49
|
+
importmap?: Record<string, string>;
|
|
50
|
+
/**
|
|
51
|
+
* Extra markup spliced into `<head>` after the importmap. Use to
|
|
52
|
+
* inject `<title>`, meta tags, stylesheets.
|
|
53
|
+
*/
|
|
54
|
+
headExtra?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Outer language tag on the `<html>` element. Defaults to `en`.
|
|
57
|
+
*/
|
|
58
|
+
lang?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Mount root id for the SSR + hydrated tree. Defaults to
|
|
61
|
+
* `aurora-root`. Matches the id the client-side hydrate script
|
|
62
|
+
* targets.
|
|
63
|
+
*/
|
|
64
|
+
rootId?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function renderPage<P>(
|
|
68
|
+
ctx: RenderHttpContext,
|
|
69
|
+
pages: Pages,
|
|
70
|
+
name: string,
|
|
71
|
+
props: P,
|
|
72
|
+
options: RenderPageOptions = {},
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
const factory = await pages.resolve(name);
|
|
75
|
+
// The factory must be invoked the SAME way client-side for hydrate
|
|
76
|
+
// to find matching slots — `Page(props)` is the contract.
|
|
77
|
+
const tree = await factory(props as never);
|
|
78
|
+
const body = renderToString(tree);
|
|
79
|
+
|
|
80
|
+
const importmap = {
|
|
81
|
+
"@c9up/aurora": "/_assets/aurora/index.js",
|
|
82
|
+
...options.importmap,
|
|
83
|
+
};
|
|
84
|
+
const rootId = options.rootId ?? "aurora-root";
|
|
85
|
+
const lang = options.lang ?? "en";
|
|
86
|
+
const pageUrl = pages.urlFor(name);
|
|
87
|
+
|
|
88
|
+
const doc = `<!doctype html>
|
|
89
|
+
<html lang="${escapeAttr(lang)}">
|
|
90
|
+
<head>
|
|
91
|
+
<meta charset="utf-8" />
|
|
92
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
93
|
+
<script type="importmap">${JSON.stringify({ imports: importmap })}</script>
|
|
94
|
+
${options.headExtra ?? ""}
|
|
95
|
+
</head>
|
|
96
|
+
<body>
|
|
97
|
+
<div id="${escapeAttr(rootId)}">${body}</div>
|
|
98
|
+
<script id="aurora-page-data" type="application/json">${escapeJsonForScript({
|
|
99
|
+
name,
|
|
100
|
+
props,
|
|
101
|
+
url: pageUrl,
|
|
102
|
+
rootId,
|
|
103
|
+
})}</script>
|
|
104
|
+
<script type="module">
|
|
105
|
+
import { hydrate } from '@c9up/aurora'
|
|
106
|
+
import Page from ${JSON.stringify(pageUrl)}
|
|
107
|
+
const data = JSON.parse(document.getElementById('aurora-page-data').textContent)
|
|
108
|
+
hydrate(document.getElementById(data.rootId), () => Page(data.props))
|
|
109
|
+
</script>
|
|
110
|
+
</body>
|
|
111
|
+
</html>`;
|
|
112
|
+
|
|
113
|
+
ctx.response.header("content-type", "text/html; charset=utf-8");
|
|
114
|
+
ctx.response.send(doc);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function escapeAttr(value: string): string {
|
|
118
|
+
return value
|
|
119
|
+
.replace(/&/g, "&")
|
|
120
|
+
.replace(/"/g, """)
|
|
121
|
+
.replace(/</g, "<");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Escape a JSON payload for safe embedding inside a `<script>` block.
|
|
126
|
+
* The HTML parser closes the script on `</script>` regardless of JSON
|
|
127
|
+
* quoting, so we slash-escape the `/`. We also escape `<!--` and `-->`
|
|
128
|
+
* to dodge HTML-comment interpretation inside the script body.
|
|
129
|
+
*/
|
|
130
|
+
function escapeJsonForScript(value: unknown): string {
|
|
131
|
+
return JSON.stringify(value)
|
|
132
|
+
.replace(/<\/(script)/gi, "<\\/$1")
|
|
133
|
+
.replace(/<!--/g, "<\\!--")
|
|
134
|
+
.replace(/-->/g, "--\\>");
|
|
135
|
+
}
|