@c9up/aurora 0.1.13 → 0.1.15
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 +1 -1
- package/dist/AuroraManager.d.ts +19 -1
- package/dist/AuroraManager.js +31 -3
- package/dist/AuroraProvider.js +4 -2
- package/dist/cn.d.ts +27 -0
- package/dist/cn.js +908 -0
- package/dist/hydrate.js +130 -10
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/render.js +11 -0
- package/dist/server/renderPage.d.ts +8 -0
- package/dist/server/renderPage.js +8 -1
- package/dist/ssr.js +14 -56
- package/dist/url.d.ts +33 -0
- package/dist/url.js +68 -0
- package/package.json +6 -2
- package/src/AuroraManager.ts +41 -3
- package/src/AuroraProvider.ts +10 -2
- package/src/cn.ts +979 -0
- package/src/hydrate.ts +184 -10
- package/src/index.ts +2 -0
- package/src/render.ts +13 -0
- package/src/server/renderPage.ts +16 -1
- package/src/ssr.ts +14 -53
- package/src/url.ts +91 -0
package/dist/hydrate.js
CHANGED
|
@@ -115,11 +115,17 @@ function hydrateReactiveStructured(fn, pair, cleanups, mountHooks, markerCursor)
|
|
|
115
115
|
if (firstRun) {
|
|
116
116
|
firstRun = false;
|
|
117
117
|
// Reuse SSR markup: hydrate reactive bindings INSIDE the nested
|
|
118
|
-
//
|
|
119
|
-
//
|
|
118
|
+
// value against the captured nodes. Inner boundary markers are
|
|
119
|
+
// consumed from the same cursor (document order) — for an ARRAY this
|
|
120
|
+
// MUST recurse into every item, else the items' marker pairs go
|
|
121
|
+
// unconsumed and the cursor desyncs, wiring slots AFTER the list to
|
|
122
|
+
// the wrong range (SSR list "present but not painted").
|
|
120
123
|
if (isTemplateResult(next)) {
|
|
121
124
|
hydrateTemplateResult(next, currentNodes, localCleanups, mountHooks, markerCursor);
|
|
122
125
|
}
|
|
126
|
+
else if (Array.isArray(next)) {
|
|
127
|
+
hydrateArrayItems(next, currentNodes, localCleanups, mountHooks, markerCursor);
|
|
128
|
+
}
|
|
123
129
|
return;
|
|
124
130
|
}
|
|
125
131
|
// Signal changed post-hydration: tear down the old subtree's
|
|
@@ -146,6 +152,50 @@ function hydrateReactiveStructured(fn, pair, cleanups, mountHooks, markerCursor)
|
|
|
146
152
|
localCleanups = [];
|
|
147
153
|
});
|
|
148
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Top-level live (SSR) node count a value contributes inside an array slot: a
|
|
157
|
+
* TemplateResult contributes its template's root-node count, a nested array the
|
|
158
|
+
* sum of its items, a non-empty scalar one text node, null/undefined/false none.
|
|
159
|
+
* Used to slice the array's range per item during hydration.
|
|
160
|
+
*/
|
|
161
|
+
function liveNodeCount(value) {
|
|
162
|
+
if (value === null || value === undefined || value === false)
|
|
163
|
+
return 0;
|
|
164
|
+
if (isTemplateResult(value)) {
|
|
165
|
+
return getTemplate(value.strings).element.content.childNodes.length;
|
|
166
|
+
}
|
|
167
|
+
if (Array.isArray(value)) {
|
|
168
|
+
let n = 0;
|
|
169
|
+
for (const v of value)
|
|
170
|
+
n += liveNodeCount(v);
|
|
171
|
+
return n;
|
|
172
|
+
}
|
|
173
|
+
return 1; // scalar → one inlined text node
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Hydrate the items of a reactive array against the SSR nodes inside its marker
|
|
177
|
+
* range. Each item is hydrated against its own slice of the (marker-collapsed)
|
|
178
|
+
* range, IN ORDER, so every item's inner marker pairs are consumed in document
|
|
179
|
+
* order and the global cursor stays aligned for slots AFTER the list. Item
|
|
180
|
+
* templates need a stable top-level node count (the common
|
|
181
|
+
* `arr.map(x => html`<li>…</li>`)` shape — single root, no surrounding
|
|
182
|
+
* whitespace); bare adjacent scalar items can merge in the browser, so use
|
|
183
|
+
* template items for hydrated lists.
|
|
184
|
+
*/
|
|
185
|
+
function hydrateArrayItems(items, rangeNodes, cleanups, mountHooks, markerCursor) {
|
|
186
|
+
const nodes = collapseMarkerRanges(rangeNodes);
|
|
187
|
+
let offset = 0;
|
|
188
|
+
for (const item of items) {
|
|
189
|
+
const count = liveNodeCount(item);
|
|
190
|
+
if (isTemplateResult(item)) {
|
|
191
|
+
hydrateTemplateResult(item, nodes.slice(offset, offset + count), cleanups, mountHooks, markerCursor);
|
|
192
|
+
}
|
|
193
|
+
else if (Array.isArray(item)) {
|
|
194
|
+
hydrateArrayItems(item, nodes.slice(offset, offset + count), cleanups, mountHooks, markerCursor);
|
|
195
|
+
}
|
|
196
|
+
offset += count;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
149
199
|
/**
|
|
150
200
|
* Adopt SSR markup inside `container`. `factory` is the same function
|
|
151
201
|
* that was rendered server-side — its output (a TemplateResult tree)
|
|
@@ -278,6 +328,17 @@ function resolvePathLive(_root, path, rootNodes) {
|
|
|
278
328
|
return node;
|
|
279
329
|
}
|
|
280
330
|
function hydrateSlot(slot, node, value, cleanups, mountHooks, markerCursor) {
|
|
331
|
+
// Type guard: an attr/bool/prop/event slot needs an Element. On a SSR↔client
|
|
332
|
+
// structural desync the path can resolve to an EXISTING node of the wrong
|
|
333
|
+
// type (Text/Comment); casting it to Element and calling setAttribute would
|
|
334
|
+
// throw "setAttribute is not a function". Skip the binding (fail-soft) rather
|
|
335
|
+
// than crash hydration. Text slots accept text/comment/element, so they pass.
|
|
336
|
+
if (slot.kind !== "text" && node.nodeType !== 1) {
|
|
337
|
+
if (typeof console !== "undefined") {
|
|
338
|
+
console.warn(`[aurora] hydrate: slot (${slot.kind}) path ${slot.path.join(".")} resolved to a non-element node — skipping binding`);
|
|
339
|
+
}
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
281
342
|
switch (slot.kind) {
|
|
282
343
|
case "text":
|
|
283
344
|
hydrateTextSlot(node, value, cleanups, mountHooks, markerCursor);
|
|
@@ -306,15 +367,74 @@ function hydrateSlot(slot, node, value, cleanups, mountHooks, markerCursor) {
|
|
|
306
367
|
* existing text node in place. For nested TemplateResults, we
|
|
307
368
|
* recursively hydrate against the captured sibling range.
|
|
308
369
|
*/
|
|
370
|
+
/**
|
|
371
|
+
* First text node inside a marker pair's range, or a fresh empty one inserted
|
|
372
|
+
* before the end marker (when the SSR value was empty → no text node yet).
|
|
373
|
+
*/
|
|
374
|
+
function reactiveTextNode(pair) {
|
|
375
|
+
for (let n = pair.start.nextSibling; n !== null && n !== pair.end; n = n.nextSibling) {
|
|
376
|
+
if (n.nodeType === 3 /* TEXT */)
|
|
377
|
+
return n;
|
|
378
|
+
}
|
|
379
|
+
const doc = pair.end.ownerDocument ?? document;
|
|
380
|
+
const fresh = doc.createTextNode("");
|
|
381
|
+
pair.end.parentNode?.insertBefore(fresh, pair.end);
|
|
382
|
+
return fresh;
|
|
383
|
+
}
|
|
309
384
|
function hydrateTextSlot(commentMarker, value, cleanups, mountHooks, markerCursor) {
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
385
|
+
// Every text slot is SSR-wrapped in a <!--$-->…<!--/$--> pair, and its path
|
|
386
|
+
// resolves (via collapseMarkerRanges) to the start marker. Consume the
|
|
387
|
+
// matching pair in document order and bind within its range.
|
|
388
|
+
const pair = markerCursor.pairs[markerCursor.i];
|
|
389
|
+
if (pair === undefined) {
|
|
390
|
+
// Legacy markup without per-slot markers (mismatched older SSR build).
|
|
391
|
+
legacyHydrateTextSlot(commentMarker, value, cleanups, mountHooks, markerCursor);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
markerCursor.i += 1;
|
|
395
|
+
const reactiveFn = isSignal(value) || typeof value === "function"
|
|
396
|
+
? value
|
|
397
|
+
: null;
|
|
398
|
+
const current = reactiveFn ? reactiveFn() : value;
|
|
399
|
+
// Structured value (nested template / array): reactive → swap on change;
|
|
400
|
+
// direct → adopt the SSR range once (inner bindings wired against it).
|
|
401
|
+
if (isTemplateResult(current) || Array.isArray(current)) {
|
|
402
|
+
if (reactiveFn) {
|
|
403
|
+
hydrateReactiveStructured(reactiveFn, pair, cleanups, mountHooks, markerCursor);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
const range = [];
|
|
407
|
+
for (let n = pair.start.nextSibling; n !== null && n !== pair.end; n = n.nextSibling) {
|
|
408
|
+
range.push(n);
|
|
409
|
+
}
|
|
410
|
+
if (isTemplateResult(current)) {
|
|
411
|
+
hydrateTemplateResult(current, range, cleanups, mountHooks, markerCursor);
|
|
412
|
+
}
|
|
413
|
+
else if (Array.isArray(current)) {
|
|
414
|
+
// Direct (non-reactive) array — hydrate each item so its inner marker
|
|
415
|
+
// pairs are consumed and the cursor stays aligned (same as a reactive
|
|
416
|
+
// array's first run).
|
|
417
|
+
hydrateArrayItems(current, range, cleanups, mountHooks, markerCursor);
|
|
418
|
+
}
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
// Scalar: a reactive scalar updates the range's text node on change; a static
|
|
422
|
+
// scalar is already rendered between the markers (nothing to wire).
|
|
423
|
+
if (reactiveFn) {
|
|
424
|
+
const textNode = reactiveTextNode(pair);
|
|
425
|
+
const dispose = effect(() => {
|
|
426
|
+
const v = reactiveFn();
|
|
427
|
+
textNode.data = v == null || v === false ? "" : String(v);
|
|
428
|
+
});
|
|
429
|
+
cleanups.push(dispose);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Pre-marker fallback — best-effort hydration when the SSR markup carries no
|
|
434
|
+
* per-slot boundary markers (a mismatched older SSR build). Current builds wrap
|
|
435
|
+
* every text slot, so this path is dead for matched server/client versions.
|
|
436
|
+
*/
|
|
437
|
+
function legacyHydrateTextSlot(commentMarker, value, cleanups, mountHooks, markerCursor) {
|
|
318
438
|
if (isSignal(value) || typeof value === "function") {
|
|
319
439
|
const fn = value;
|
|
320
440
|
// First, evaluate eagerly to detect a structured value (nested
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export type { CookieOptions, PersistedSignalOptions, ShareData, StorageArea, WebStorageOptions, WindowSize, } from "./browser.js";
|
|
2
2
|
export { back, clipboard, cookie, forward, hash, mediaQuery, navigate, online, persistedSignal, prefersDark, queryParam, redirect, reload, replace, session, share, storage, visibility, WebStorage, windowSize, } from "./browser.js";
|
|
3
|
+
export { type ClassValue, clsx, cn, twMerge } from "./cn.js";
|
|
3
4
|
export type { Command } from "./command.js";
|
|
4
5
|
export { command } from "./command.js";
|
|
5
6
|
export { component, onMount, onUnmount } from "./component.js";
|
|
@@ -21,3 +22,4 @@ export { type AuroraHttpContext, type AuroraResponse, type AuroraRouteConfig, au
|
|
|
21
22
|
export { createRpcClient, isRpcError, type RpcCall, type RpcClient, type RpcClientOptions, RpcError, type RpcResult, } from "./rpc.js";
|
|
22
23
|
export { renderToString } from "./ssr.js";
|
|
23
24
|
export type { TemplateResult } from "./types.js";
|
|
25
|
+
export { getRouteManifest, setRouteManifest, urlFor } from "./url.js";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { back, clipboard, cookie, forward, hash, mediaQuery, navigate, online, persistedSignal, prefersDark, queryParam, redirect, reload, replace, session, share, storage, visibility, WebStorage, windowSize, } from "./browser.js";
|
|
2
|
+
export { clsx, cn, twMerge } from "./cn.js";
|
|
2
3
|
export { command } from "./command.js";
|
|
3
4
|
export { component, onMount, onUnmount } from "./component.js";
|
|
4
5
|
export { form } from "./form.js";
|
|
@@ -16,3 +17,4 @@ export { render } from "./render.js";
|
|
|
16
17
|
export { auroraRoute, } from "./route.js";
|
|
17
18
|
export { createRpcClient, isRpcError, RpcError, } from "./rpc.js";
|
|
18
19
|
export { renderToString } from "./ssr.js";
|
|
20
|
+
export { getRouteManifest, setRouteManifest, urlFor } from "./url.js";
|
package/dist/render.js
CHANGED
|
@@ -80,6 +80,17 @@ export function mount(result, cleanups, mounted, mountHooks) {
|
|
|
80
80
|
}
|
|
81
81
|
continue;
|
|
82
82
|
}
|
|
83
|
+
// Type guard: an attr/bool/prop/event slot needs an Element. On a
|
|
84
|
+
// structural desync the path can land on an EXISTING node of the wrong
|
|
85
|
+
// type (Text/Comment) — casting it to Element and calling setAttribute
|
|
86
|
+
// would throw "setAttribute is not a function". Degrade like the null
|
|
87
|
+
// case (skip the binding) instead of crashing the whole render.
|
|
88
|
+
if (slot.kind !== "text" && node.nodeType !== 1) {
|
|
89
|
+
if (typeof console !== "undefined") {
|
|
90
|
+
console.warn(`[aurora] render: slot ${i} (${slot.kind}) path ${slot.path.join(".")} resolved to a non-element node — skipping binding`);
|
|
91
|
+
}
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
83
94
|
if (slot.kind === "attr" && slot.staticParts !== undefined) {
|
|
84
95
|
collectMultiAttr(slot, node, result.values[i], multiGroups);
|
|
85
96
|
}
|
|
@@ -58,5 +58,13 @@ export interface RenderPageOptions {
|
|
|
58
58
|
* targets.
|
|
59
59
|
*/
|
|
60
60
|
rootId?: string;
|
|
61
|
+
/**
|
|
62
|
+
* Named-route manifest (`name → path-pattern`) for the isomorphic
|
|
63
|
+
* `urlFor()` helper — build it with Ream's `router.namedManifest()`. It is
|
|
64
|
+
* installed server-side before the page renders AND serialized into the page
|
|
65
|
+
* so the hydrate bootstrap re-installs it, making `urlFor` work identically
|
|
66
|
+
* in SSR and the browser. Omit if the app doesn't use `urlFor`.
|
|
67
|
+
*/
|
|
68
|
+
routes?: Record<string, string>;
|
|
61
69
|
}
|
|
62
70
|
export declare function renderPage<P>(ctx: RenderHttpContext, pages: Pages, name: string, props: P, options?: RenderPageOptions): Promise<void>;
|
|
@@ -23,7 +23,12 @@
|
|
|
23
23
|
* </body>
|
|
24
24
|
*/
|
|
25
25
|
import { renderToString } from "../ssr.js";
|
|
26
|
+
import { setRouteManifest } from "../url.js";
|
|
26
27
|
export async function renderPage(ctx, pages, name, props, options = {}) {
|
|
28
|
+
// Install the route manifest BEFORE rendering so a page calling `urlFor`
|
|
29
|
+
// during SSR resolves against the same map the client will get.
|
|
30
|
+
if (options.routes)
|
|
31
|
+
setRouteManifest(options.routes);
|
|
27
32
|
const factory = await pages.resolve(name);
|
|
28
33
|
// The factory must be invoked the SAME way client-side for hydrate
|
|
29
34
|
// to find matching slots — `Page(props)` is the contract.
|
|
@@ -51,11 +56,13 @@ ${options.headExtra ?? ""}
|
|
|
51
56
|
props,
|
|
52
57
|
url: pageUrl,
|
|
53
58
|
rootId,
|
|
59
|
+
routes: options.routes ?? {},
|
|
54
60
|
})}</script>
|
|
55
61
|
<script type="module">
|
|
56
|
-
import { hydrate } from '@c9up/aurora'
|
|
62
|
+
import { hydrate, setRouteManifest } from '@c9up/aurora'
|
|
57
63
|
import Page from ${JSON.stringify(pageUrl)}
|
|
58
64
|
const data = JSON.parse(document.getElementById('aurora-page-data').textContent)
|
|
65
|
+
setRouteManifest(data.routes ?? {})
|
|
59
66
|
hydrate(document.getElementById(data.rootId), () => Page(data.props))
|
|
60
67
|
</script>
|
|
61
68
|
</body>
|
package/dist/ssr.js
CHANGED
|
@@ -62,45 +62,25 @@ function stringifyTemplateResult(result) {
|
|
|
62
62
|
if (i < values.length && !skipValue) {
|
|
63
63
|
const value = values[i];
|
|
64
64
|
const inAttr = isInsideAttribute(out);
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
// array — wrap the rendered content in boundary markers so
|
|
68
|
-
// hydration can locate the exact node range and SWAP it when
|
|
69
|
-
// the signal changes client-side. Without these markers a
|
|
70
|
-
// nested-template slot hydrates once and then goes stale
|
|
71
|
-
// (no way to find where the subtree starts/ends). Scalar
|
|
72
|
-
// reactive slots (`${signal}` → text) are NOT wrapped: their
|
|
73
|
-
// hydration updates the text node in place, no range needed.
|
|
74
|
-
out += `<!--${SLOT_START}-->`;
|
|
75
|
-
out += stringifyValue(value, false);
|
|
76
|
-
out += `<!--${SLOT_END}-->`;
|
|
65
|
+
if (inAttr) {
|
|
66
|
+
out += stringifyValue(value, true);
|
|
77
67
|
}
|
|
78
|
-
else
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
//
|
|
68
|
+
else {
|
|
69
|
+
// Text-region slot — ALWAYS wrap in boundary markers so the SSR
|
|
70
|
+
// node structure matches the client template, which keeps exactly
|
|
71
|
+
// ONE comment node per slot. An inlined value otherwise MERGES
|
|
72
|
+
// with adjacent static text or sibling values when the browser
|
|
73
|
+
// parses the SSR HTML (`<p>Hello ${x}!</p>` → ONE text node, not
|
|
74
|
+
// three), dropping the node count and desyncing the slot AND every
|
|
75
|
+
// following sibling binding (text, attr, event). Hydration
|
|
76
|
+
// collapses each `<!--$-->…<!--/$-->` range back to one node
|
|
77
|
+
// (collapseMarkerRanges) so paths align exactly; the range also
|
|
78
|
+
// anchors scalar text updates and nested-template swaps. Same
|
|
79
|
+
// part-marker approach as lit-html / Solid.
|
|
87
80
|
out += `<!--${SLOT_START}-->`;
|
|
88
81
|
out += stringifyValue(value, false);
|
|
89
82
|
out += `<!--${SLOT_END}-->`;
|
|
90
83
|
}
|
|
91
|
-
else if (inAttr) {
|
|
92
|
-
out += stringifyValue(value, true);
|
|
93
|
-
}
|
|
94
|
-
else {
|
|
95
|
-
// Text-region scalar slot. An empty result (e.g. `cond ? x : ''`)
|
|
96
|
-
// would emit NO node and desync the path-based hydration of the
|
|
97
|
-
// following sibling slots (their @input/@submit bindings break).
|
|
98
|
-
// Emit an empty-comment placeholder so the position is preserved
|
|
99
|
-
// — lit-html / Solid do the same; hydration materializes the text
|
|
100
|
-
// node there.
|
|
101
|
-
const text = stringifyValue(value, false);
|
|
102
|
-
out += text === "" ? "<!---->" : text;
|
|
103
|
-
}
|
|
104
84
|
}
|
|
105
85
|
}
|
|
106
86
|
return out;
|
|
@@ -108,28 +88,6 @@ function stringifyTemplateResult(result) {
|
|
|
108
88
|
/** Boundary-marker comment payloads (kept in sync with hydrate.ts). */
|
|
109
89
|
const SLOT_START = "$";
|
|
110
90
|
const SLOT_END = "/$";
|
|
111
|
-
/**
|
|
112
|
-
* True when `value` is a reactive expression (signal / function) whose
|
|
113
|
-
* current evaluation is a structured node payload (a nested
|
|
114
|
-
* TemplateResult, or an array). These are the slots that can SWAP their
|
|
115
|
-
* subtree on a client-side change and therefore need boundary markers
|
|
116
|
-
* for hydration to find the range. A reactive slot resolving to a
|
|
117
|
-
* scalar (string / number) is updated in place and needs no markers.
|
|
118
|
-
*/
|
|
119
|
-
function isReactiveStructuredSlot(value) {
|
|
120
|
-
if (!isSignal(value) && typeof value !== "function")
|
|
121
|
-
return false;
|
|
122
|
-
let evaluated;
|
|
123
|
-
try {
|
|
124
|
-
evaluated = isSignal(value)
|
|
125
|
-
? value()
|
|
126
|
-
: value();
|
|
127
|
-
}
|
|
128
|
-
catch {
|
|
129
|
-
return false;
|
|
130
|
-
}
|
|
131
|
-
return isTemplateResult(evaluated) || Array.isArray(evaluated);
|
|
132
|
-
}
|
|
133
91
|
/**
|
|
134
92
|
* Returns true if the position at the end of `htmlSoFar` lives inside
|
|
135
93
|
* the value region of an HTML tag (between `<` and `>`). The check
|
package/dist/url.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `urlFor` — isomorphic named-route URL builder (AdonisJS v7 parity), the client
|
|
3
|
+
* half of Ream's `router.urlFor()`.
|
|
4
|
+
*
|
|
5
|
+
* Routes live server-side in Ream's router; this helper builds URLs from a
|
|
6
|
+
* serialized `name → path-pattern` manifest so the SAME `urlFor(name, params)`
|
|
7
|
+
* works in a page during SSR and after hydration in the browser — no more
|
|
8
|
+
* hard-coded paths like `redirect('/login')` / `href: '/team'`.
|
|
9
|
+
*
|
|
10
|
+
* urlFor('users.show', { id: 42 }) // → '/users/42'
|
|
11
|
+
* urlFor('auth.login') // → '/login'
|
|
12
|
+
* urlFor('search', {}, { q: 'ream', p: 2 })// → '/search?q=ream&p=2'
|
|
13
|
+
*
|
|
14
|
+
* The manifest is populated by {@link setRouteManifest}: `renderPage` calls it
|
|
15
|
+
* server-side from `options.routes` (build it with `router.namedManifest()`), and
|
|
16
|
+
* injects the same map into the page so the hydrate bootstrap re-sets it client
|
|
17
|
+
* side. Node-free — part of aurora's client runtime.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Install the `name → path-pattern` map `urlFor` resolves against (e.g.
|
|
21
|
+
* `{ 'users.show': '/users/:id' }`, from Ream's `router.namedManifest()`).
|
|
22
|
+
* Replaces any previous manifest. Routes are static per app, so this is set once
|
|
23
|
+
* per environment (server boot / page render, and the hydrate bootstrap).
|
|
24
|
+
*/
|
|
25
|
+
export declare function setRouteManifest(routes: Record<string, string>): void;
|
|
26
|
+
/** The currently-installed route manifest (mainly for tests/introspection). */
|
|
27
|
+
export declare function getRouteManifest(): Record<string, string>;
|
|
28
|
+
/**
|
|
29
|
+
* Build a URL for a named route — fills `:param` placeholders, drops unprovided
|
|
30
|
+
* optional (`:name?`) segments, appends `query` as a query string, and throws on
|
|
31
|
+
* an unknown route or a missing required param. Mirrors Ream's `router.urlFor`.
|
|
32
|
+
*/
|
|
33
|
+
export declare function urlFor(name: string, params?: Record<string, string | number>, query?: Record<string, string | number>): string;
|
package/dist/url.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `urlFor` — isomorphic named-route URL builder (AdonisJS v7 parity), the client
|
|
3
|
+
* half of Ream's `router.urlFor()`.
|
|
4
|
+
*
|
|
5
|
+
* Routes live server-side in Ream's router; this helper builds URLs from a
|
|
6
|
+
* serialized `name → path-pattern` manifest so the SAME `urlFor(name, params)`
|
|
7
|
+
* works in a page during SSR and after hydration in the browser — no more
|
|
8
|
+
* hard-coded paths like `redirect('/login')` / `href: '/team'`.
|
|
9
|
+
*
|
|
10
|
+
* urlFor('users.show', { id: 42 }) // → '/users/42'
|
|
11
|
+
* urlFor('auth.login') // → '/login'
|
|
12
|
+
* urlFor('search', {}, { q: 'ream', p: 2 })// → '/search?q=ream&p=2'
|
|
13
|
+
*
|
|
14
|
+
* The manifest is populated by {@link setRouteManifest}: `renderPage` calls it
|
|
15
|
+
* server-side from `options.routes` (build it with `router.namedManifest()`), and
|
|
16
|
+
* injects the same map into the page so the hydrate bootstrap re-sets it client
|
|
17
|
+
* side. Node-free — part of aurora's client runtime.
|
|
18
|
+
*/
|
|
19
|
+
let manifest = {};
|
|
20
|
+
/**
|
|
21
|
+
* Install the `name → path-pattern` map `urlFor` resolves against (e.g.
|
|
22
|
+
* `{ 'users.show': '/users/:id' }`, from Ream's `router.namedManifest()`).
|
|
23
|
+
* Replaces any previous manifest. Routes are static per app, so this is set once
|
|
24
|
+
* per environment (server boot / page render, and the hydrate bootstrap).
|
|
25
|
+
*/
|
|
26
|
+
export function setRouteManifest(routes) {
|
|
27
|
+
manifest = { ...routes };
|
|
28
|
+
}
|
|
29
|
+
/** The currently-installed route manifest (mainly for tests/introspection). */
|
|
30
|
+
export function getRouteManifest() {
|
|
31
|
+
return { ...manifest };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Build a URL for a named route — fills `:param` placeholders, drops unprovided
|
|
35
|
+
* optional (`:name?`) segments, appends `query` as a query string, and throws on
|
|
36
|
+
* an unknown route or a missing required param. Mirrors Ream's `router.urlFor`.
|
|
37
|
+
*/
|
|
38
|
+
export function urlFor(name, params, query) {
|
|
39
|
+
const pattern = manifest[name];
|
|
40
|
+
if (pattern === undefined) {
|
|
41
|
+
const known = Object.keys(manifest);
|
|
42
|
+
throw new Error(`[aurora] urlFor: unknown route '${name}'. ${known.length > 0
|
|
43
|
+
? `Known: ${known.join(", ")}`
|
|
44
|
+
: "No routes registered — was the manifest passed to render() / setRouteManifest() called?"}`);
|
|
45
|
+
}
|
|
46
|
+
let url = pattern;
|
|
47
|
+
if (params) {
|
|
48
|
+
for (const [key, value] of Object.entries(params)) {
|
|
49
|
+
// Word-boundary substitution so `:id` doesn't corrupt `:idx`.
|
|
50
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
51
|
+
url = url.replace(new RegExp(`:${escaped}\\??(?![\\w])`, "g"), encodeURIComponent(String(value)));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Strip remaining optional placeholders (`:name?` not provided).
|
|
55
|
+
url = url.replace(/\/:[A-Za-z_][\w]*\?/g, "");
|
|
56
|
+
const missing = url.match(/:[A-Za-z_][\w]*/g);
|
|
57
|
+
if (missing && missing.length > 0) {
|
|
58
|
+
throw new Error(`[aurora] urlFor: route '${name}' is missing params ${missing.join(", ")}`);
|
|
59
|
+
}
|
|
60
|
+
if (query) {
|
|
61
|
+
const qs = Object.entries(query)
|
|
62
|
+
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
|
|
63
|
+
.join("&");
|
|
64
|
+
if (qs)
|
|
65
|
+
url += `${url.includes("?") ? "&" : "?"}${qs}`;
|
|
66
|
+
}
|
|
67
|
+
return url;
|
|
68
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@c9up/aurora",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
4
4
|
"description": "Aurora — reactive UI runtime for the Ream framework. Tagged-template DOM, signal-based state, isomorphic SSR + hydration, zero build step.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -46,7 +46,10 @@
|
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@types/node": "^22.19.15",
|
|
49
|
+
"@vitest/browser": "4.1.6",
|
|
50
|
+
"@vitest/browser-playwright": "^4.1.9",
|
|
49
51
|
"happy-dom": "^15.11.7",
|
|
52
|
+
"playwright": "^1.61.1",
|
|
50
53
|
"typescript": "^6.0.2",
|
|
51
54
|
"vitest": "^4.1.2"
|
|
52
55
|
},
|
|
@@ -68,6 +71,7 @@
|
|
|
68
71
|
"typecheck": "tsc --noEmit",
|
|
69
72
|
"test": "vitest run",
|
|
70
73
|
"lint": "biome check src/",
|
|
71
|
-
"test:coverage": "vitest run --coverage"
|
|
74
|
+
"test:coverage": "vitest run --coverage",
|
|
75
|
+
"test:browser": "vitest run -c vitest.browser.config.ts"
|
|
72
76
|
}
|
|
73
77
|
}
|
package/src/AuroraManager.ts
CHANGED
|
@@ -30,6 +30,15 @@ export interface AuroraManagerConfig {
|
|
|
30
30
|
* Override only if you want to serve a custom build.
|
|
31
31
|
*/
|
|
32
32
|
auroraDistRoot?: string;
|
|
33
|
+
/**
|
|
34
|
+
* URL prefix the asset routes mount under. The aurora runtime is served
|
|
35
|
+
* from `<assetsPrefix>/aurora/*` and the app's pages from
|
|
36
|
+
* `<assetsPrefix>/pages/*`, and the SSR importmap + page URLs derive from
|
|
37
|
+
* it. Default `/_assets` (the leading underscore namespaces framework
|
|
38
|
+
* assets away from app routes, Next.js `/_next` style). Set e.g. `/assets`
|
|
39
|
+
* for an underscore-free scheme. An explicit `pages.urlPrefix` still wins.
|
|
40
|
+
*/
|
|
41
|
+
assetsPrefix?: string;
|
|
33
42
|
}
|
|
34
43
|
|
|
35
44
|
const DEFAULT_AURORA_DIST = resolvePath(
|
|
@@ -37,17 +46,40 @@ const DEFAULT_AURORA_DIST = resolvePath(
|
|
|
37
46
|
"../dist",
|
|
38
47
|
);
|
|
39
48
|
|
|
49
|
+
/** Normalize an asset prefix: ensure a leading slash, drop trailing slashes. */
|
|
50
|
+
function normalizePrefix(prefix: string): string {
|
|
51
|
+
const withLead = prefix.startsWith("/") ? prefix : `/${prefix}`;
|
|
52
|
+
return withLead.replace(/\/+$/, "") || "/";
|
|
53
|
+
}
|
|
54
|
+
|
|
40
55
|
export class AuroraManager {
|
|
41
56
|
readonly pages: Pages;
|
|
42
57
|
readonly auroraDistRoot: string;
|
|
58
|
+
/** Resolved asset prefix (default `/_assets`). */
|
|
59
|
+
readonly assetsPrefix: string;
|
|
60
|
+
/** Mount path for the aurora runtime — `<assetsPrefix>/aurora`. */
|
|
61
|
+
readonly auroraAssetPath: string;
|
|
62
|
+
/** Mount path for the app's pages — `<assetsPrefix>/pages`. */
|
|
63
|
+
readonly pageAssetPath: string;
|
|
43
64
|
|
|
44
65
|
constructor(config: AuroraManagerConfig) {
|
|
45
|
-
this.
|
|
66
|
+
this.assetsPrefix = normalizePrefix(config.assetsPrefix ?? "/_assets");
|
|
67
|
+
this.auroraAssetPath = `${this.assetsPrefix}/aurora`;
|
|
68
|
+
this.pageAssetPath = `${this.assetsPrefix}/pages`;
|
|
69
|
+
// Pages serve their compiled JS from the same prefix unless the app
|
|
70
|
+
// pins an explicit urlPrefix.
|
|
71
|
+
this.pages = new Pages({
|
|
72
|
+
...config.pages,
|
|
73
|
+
urlPrefix: config.pages.urlPrefix ?? this.pageAssetPath,
|
|
74
|
+
});
|
|
46
75
|
this.auroraDistRoot = config.auroraDistRoot ?? DEFAULT_AURORA_DIST;
|
|
47
76
|
}
|
|
48
77
|
|
|
49
78
|
/**
|
|
50
|
-
* SSR + hydrate + ship the document.
|
|
79
|
+
* SSR + hydrate + ship the document. The importmap default points
|
|
80
|
+
* `@c9up/aurora` at this manager's `assetsPrefix`; a caller's
|
|
81
|
+
* `options.importmap` still overrides (e.g. to remap to an app-curated
|
|
82
|
+
* browser entry).
|
|
51
83
|
*/
|
|
52
84
|
render(
|
|
53
85
|
ctx: RenderHttpContext,
|
|
@@ -55,7 +87,13 @@ export class AuroraManager {
|
|
|
55
87
|
props: unknown,
|
|
56
88
|
options?: RenderPageOptions,
|
|
57
89
|
): Promise<void> {
|
|
58
|
-
return renderPage(ctx, this.pages, name, props,
|
|
90
|
+
return renderPage(ctx, this.pages, name, props, {
|
|
91
|
+
...options,
|
|
92
|
+
importmap: {
|
|
93
|
+
"@c9up/aurora": `${this.auroraAssetPath}/index.js`,
|
|
94
|
+
...options?.importmap,
|
|
95
|
+
},
|
|
96
|
+
});
|
|
59
97
|
}
|
|
60
98
|
|
|
61
99
|
/**
|
package/src/AuroraProvider.ts
CHANGED
|
@@ -91,8 +91,16 @@ export default class AuroraProvider {
|
|
|
91
91
|
if (!this.app.container.has("router")) return;
|
|
92
92
|
const router = this.app.container.resolve<ReamRouter>("router");
|
|
93
93
|
const manager = this.app.container.resolve<AuroraManager>(AuroraManager);
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
// Mount paths derive from the configured `assetsPrefix` (default
|
|
95
|
+
// `/_assets`) — set `config.aurora.assetsPrefix` to change the scheme.
|
|
96
|
+
router.get(
|
|
97
|
+
`${manager.auroraAssetPath}/*`,
|
|
98
|
+
adaptHandler(manager.auroraAssetsHandler()),
|
|
99
|
+
);
|
|
100
|
+
router.get(
|
|
101
|
+
`${manager.pageAssetPath}/*`,
|
|
102
|
+
adaptHandler(manager.pageAssetsHandler()),
|
|
103
|
+
);
|
|
96
104
|
}
|
|
97
105
|
|
|
98
106
|
async ready(): Promise<void> {}
|