@c9up/aurora 0.1.13 → 0.1.14
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/dist/cn.d.ts +27 -0
- package/dist/cn.js +908 -0
- package/dist/hydrate.js +74 -8
- 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 +1 -1
- package/src/cn.ts +979 -0
- package/src/hydrate.ts +107 -8
- 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/src/hydrate.ts
CHANGED
|
@@ -376,6 +376,19 @@ function hydrateSlot(
|
|
|
376
376
|
mountHooks: Array<EffectCallback>,
|
|
377
377
|
markerCursor: MarkerCursor,
|
|
378
378
|
): void {
|
|
379
|
+
// Type guard: an attr/bool/prop/event slot needs an Element. On a SSR↔client
|
|
380
|
+
// structural desync the path can resolve to an EXISTING node of the wrong
|
|
381
|
+
// type (Text/Comment); casting it to Element and calling setAttribute would
|
|
382
|
+
// throw "setAttribute is not a function". Skip the binding (fail-soft) rather
|
|
383
|
+
// than crash hydration. Text slots accept text/comment/element, so they pass.
|
|
384
|
+
if (slot.kind !== "text" && node.nodeType !== 1) {
|
|
385
|
+
if (typeof console !== "undefined") {
|
|
386
|
+
console.warn(
|
|
387
|
+
`[aurora] hydrate: slot (${slot.kind}) path ${slot.path.join(".")} resolved to a non-element node — skipping binding`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
379
392
|
switch (slot.kind) {
|
|
380
393
|
case "text":
|
|
381
394
|
hydrateTextSlot(node, value, cleanups, mountHooks, markerCursor);
|
|
@@ -405,6 +418,24 @@ function hydrateSlot(
|
|
|
405
418
|
* existing text node in place. For nested TemplateResults, we
|
|
406
419
|
* recursively hydrate against the captured sibling range.
|
|
407
420
|
*/
|
|
421
|
+
/**
|
|
422
|
+
* First text node inside a marker pair's range, or a fresh empty one inserted
|
|
423
|
+
* before the end marker (when the SSR value was empty → no text node yet).
|
|
424
|
+
*/
|
|
425
|
+
function reactiveTextNode(pair: MarkerPair): Text {
|
|
426
|
+
for (
|
|
427
|
+
let n = pair.start.nextSibling;
|
|
428
|
+
n !== null && n !== pair.end;
|
|
429
|
+
n = n.nextSibling
|
|
430
|
+
) {
|
|
431
|
+
if (n.nodeType === 3 /* TEXT */) return n as Text;
|
|
432
|
+
}
|
|
433
|
+
const doc = pair.end.ownerDocument ?? document;
|
|
434
|
+
const fresh = doc.createTextNode("");
|
|
435
|
+
pair.end.parentNode?.insertBefore(fresh, pair.end);
|
|
436
|
+
return fresh;
|
|
437
|
+
}
|
|
438
|
+
|
|
408
439
|
function hydrateTextSlot(
|
|
409
440
|
commentMarker: Node,
|
|
410
441
|
value: unknown,
|
|
@@ -412,14 +443,82 @@ function hydrateTextSlot(
|
|
|
412
443
|
mountHooks: Array<EffectCallback>,
|
|
413
444
|
markerCursor: MarkerCursor,
|
|
414
445
|
): void {
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
446
|
+
// Every text slot is SSR-wrapped in a <!--$-->…<!--/$--> pair, and its path
|
|
447
|
+
// resolves (via collapseMarkerRanges) to the start marker. Consume the
|
|
448
|
+
// matching pair in document order and bind within its range.
|
|
449
|
+
const pair = markerCursor.pairs[markerCursor.i];
|
|
450
|
+
if (pair === undefined) {
|
|
451
|
+
// Legacy markup without per-slot markers (mismatched older SSR build).
|
|
452
|
+
legacyHydrateTextSlot(
|
|
453
|
+
commentMarker,
|
|
454
|
+
value,
|
|
455
|
+
cleanups,
|
|
456
|
+
mountHooks,
|
|
457
|
+
markerCursor,
|
|
458
|
+
);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
markerCursor.i += 1;
|
|
462
|
+
|
|
463
|
+
const reactiveFn =
|
|
464
|
+
isSignal(value) || typeof value === "function"
|
|
465
|
+
? (value as () => unknown)
|
|
466
|
+
: null;
|
|
467
|
+
const current = reactiveFn ? reactiveFn() : value;
|
|
468
|
+
|
|
469
|
+
// Structured value (nested template / array): reactive → swap on change;
|
|
470
|
+
// direct → adopt the SSR range once (inner bindings wired against it).
|
|
471
|
+
if (isTemplateResult(current) || Array.isArray(current)) {
|
|
472
|
+
if (reactiveFn) {
|
|
473
|
+
hydrateReactiveStructured(
|
|
474
|
+
reactiveFn,
|
|
475
|
+
pair,
|
|
476
|
+
cleanups,
|
|
477
|
+
mountHooks,
|
|
478
|
+
markerCursor,
|
|
479
|
+
);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const range: ChildNode[] = [];
|
|
483
|
+
for (
|
|
484
|
+
let n = pair.start.nextSibling;
|
|
485
|
+
n !== null && n !== pair.end;
|
|
486
|
+
n = n.nextSibling
|
|
487
|
+
) {
|
|
488
|
+
range.push(n as ChildNode);
|
|
489
|
+
}
|
|
490
|
+
if (isTemplateResult(current)) {
|
|
491
|
+
hydrateTemplateResult(current, range, cleanups, mountHooks, markerCursor);
|
|
492
|
+
}
|
|
493
|
+
// A direct (non-reactive) array is static SSR markup; per-item reactive
|
|
494
|
+
// bindings aren't individually re-hydrated (use `${() => arr.map(…)}`).
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Scalar: a reactive scalar updates the range's text node on change; a static
|
|
499
|
+
// scalar is already rendered between the markers (nothing to wire).
|
|
500
|
+
if (reactiveFn) {
|
|
501
|
+
const textNode = reactiveTextNode(pair);
|
|
502
|
+
const dispose = effect(() => {
|
|
503
|
+
const v = reactiveFn();
|
|
504
|
+
textNode.data = v == null || v === false ? "" : String(v);
|
|
505
|
+
});
|
|
506
|
+
cleanups.push(dispose);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Pre-marker fallback — best-effort hydration when the SSR markup carries no
|
|
512
|
+
* per-slot boundary markers (a mismatched older SSR build). Current builds wrap
|
|
513
|
+
* every text slot, so this path is dead for matched server/client versions.
|
|
514
|
+
*/
|
|
515
|
+
function legacyHydrateTextSlot(
|
|
516
|
+
commentMarker: Node,
|
|
517
|
+
value: unknown,
|
|
518
|
+
cleanups: Disposer[],
|
|
519
|
+
mountHooks: Array<EffectCallback>,
|
|
520
|
+
markerCursor: MarkerCursor,
|
|
521
|
+
): void {
|
|
423
522
|
if (isSignal(value) || typeof value === "function") {
|
|
424
523
|
const fn = value as () => unknown;
|
|
425
524
|
// First, evaluate eagerly to detect a structured value (nested
|
package/src/index.ts
CHANGED
|
@@ -34,6 +34,7 @@ export {
|
|
|
34
34
|
WebStorage,
|
|
35
35
|
windowSize,
|
|
36
36
|
} from "./browser.js";
|
|
37
|
+
export { type ClassValue, clsx, cn, twMerge } from "./cn.js";
|
|
37
38
|
export type { Command } from "./command.js";
|
|
38
39
|
export { command } from "./command.js";
|
|
39
40
|
export { component, onMount, onUnmount } from "./component.js";
|
|
@@ -126,3 +127,4 @@ export {
|
|
|
126
127
|
} from "./rpc.js";
|
|
127
128
|
export { renderToString } from "./ssr.js";
|
|
128
129
|
export type { TemplateResult } from "./types.js";
|
|
130
|
+
export { getRouteManifest, setRouteManifest, urlFor } from "./url.js";
|
package/src/render.ts
CHANGED
|
@@ -118,6 +118,19 @@ export function mount(
|
|
|
118
118
|
}
|
|
119
119
|
continue;
|
|
120
120
|
}
|
|
121
|
+
// Type guard: an attr/bool/prop/event slot needs an Element. On a
|
|
122
|
+
// structural desync the path can land on an EXISTING node of the wrong
|
|
123
|
+
// type (Text/Comment) — casting it to Element and calling setAttribute
|
|
124
|
+
// would throw "setAttribute is not a function". Degrade like the null
|
|
125
|
+
// case (skip the binding) instead of crashing the whole render.
|
|
126
|
+
if (slot.kind !== "text" && node.nodeType !== 1) {
|
|
127
|
+
if (typeof console !== "undefined") {
|
|
128
|
+
console.warn(
|
|
129
|
+
`[aurora] render: slot ${i} (${slot.kind}) path ${slot.path.join(".")} resolved to a non-element node — skipping binding`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
121
134
|
if (slot.kind === "attr" && slot.staticParts !== undefined) {
|
|
122
135
|
collectMultiAttr(slot, node as Element, result.values[i], multiGroups);
|
|
123
136
|
} else {
|
package/src/server/renderPage.ts
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
import type { Pages } from "../Pages.js";
|
|
27
27
|
import { renderToString } from "../ssr.js";
|
|
28
|
+
import { setRouteManifest } from "../url.js";
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* Structural slice of the host framework's response. Same shape
|
|
@@ -62,6 +63,14 @@ export interface RenderPageOptions {
|
|
|
62
63
|
* targets.
|
|
63
64
|
*/
|
|
64
65
|
rootId?: string;
|
|
66
|
+
/**
|
|
67
|
+
* Named-route manifest (`name → path-pattern`) for the isomorphic
|
|
68
|
+
* `urlFor()` helper — build it with Ream's `router.namedManifest()`. It is
|
|
69
|
+
* installed server-side before the page renders AND serialized into the page
|
|
70
|
+
* so the hydrate bootstrap re-installs it, making `urlFor` work identically
|
|
71
|
+
* in SSR and the browser. Omit if the app doesn't use `urlFor`.
|
|
72
|
+
*/
|
|
73
|
+
routes?: Record<string, string>;
|
|
65
74
|
}
|
|
66
75
|
|
|
67
76
|
export async function renderPage<P>(
|
|
@@ -71,6 +80,10 @@ export async function renderPage<P>(
|
|
|
71
80
|
props: P,
|
|
72
81
|
options: RenderPageOptions = {},
|
|
73
82
|
): Promise<void> {
|
|
83
|
+
// Install the route manifest BEFORE rendering so a page calling `urlFor`
|
|
84
|
+
// during SSR resolves against the same map the client will get.
|
|
85
|
+
if (options.routes) setRouteManifest(options.routes);
|
|
86
|
+
|
|
74
87
|
const factory = await pages.resolve(name);
|
|
75
88
|
// The factory must be invoked the SAME way client-side for hydrate
|
|
76
89
|
// to find matching slots — `Page(props)` is the contract.
|
|
@@ -100,11 +113,13 @@ ${options.headExtra ?? ""}
|
|
|
100
113
|
props,
|
|
101
114
|
url: pageUrl,
|
|
102
115
|
rootId,
|
|
116
|
+
routes: options.routes ?? {},
|
|
103
117
|
})}</script>
|
|
104
118
|
<script type="module">
|
|
105
|
-
import { hydrate } from '@c9up/aurora'
|
|
119
|
+
import { hydrate, setRouteManifest } from '@c9up/aurora'
|
|
106
120
|
import Page from ${JSON.stringify(pageUrl)}
|
|
107
121
|
const data = JSON.parse(document.getElementById('aurora-page-data').textContent)
|
|
122
|
+
setRouteManifest(data.routes ?? {})
|
|
108
123
|
hydrate(document.getElementById(data.rootId), () => Page(data.props))
|
|
109
124
|
</script>
|
|
110
125
|
</body>
|
package/src/ssr.ts
CHANGED
|
@@ -66,41 +66,23 @@ function stringifyTemplateResult(result: TemplateResult): string {
|
|
|
66
66
|
if (i < values.length && !skipValue) {
|
|
67
67
|
const value = values[i];
|
|
68
68
|
const inAttr = isInsideAttribute(out);
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
//
|
|
83
|
-
// e.g. `${Layout({ children })}` or `${table}`. It renders to a
|
|
84
|
-
// node RANGE just like a reactive structured slot, so it needs the
|
|
85
|
-
// SAME boundary markers: the client template counts every slot as
|
|
86
|
-
// ONE comment node, so without a markable range a multi-node child
|
|
87
|
-
// shifts the childNode indices of every FOLLOWING sibling slot →
|
|
88
|
-
// dead bindings / "slot path not found". Hydration collapses the
|
|
89
|
-
// marked range back to one node so sibling paths stay aligned.
|
|
69
|
+
if (inAttr) {
|
|
70
|
+
out += stringifyValue(value, true);
|
|
71
|
+
} else {
|
|
72
|
+
// Text-region slot — ALWAYS wrap in boundary markers so the SSR
|
|
73
|
+
// node structure matches the client template, which keeps exactly
|
|
74
|
+
// ONE comment node per slot. An inlined value otherwise MERGES
|
|
75
|
+
// with adjacent static text or sibling values when the browser
|
|
76
|
+
// parses the SSR HTML (`<p>Hello ${x}!</p>` → ONE text node, not
|
|
77
|
+
// three), dropping the node count and desyncing the slot AND every
|
|
78
|
+
// following sibling binding (text, attr, event). Hydration
|
|
79
|
+
// collapses each `<!--$-->…<!--/$-->` range back to one node
|
|
80
|
+
// (collapseMarkerRanges) so paths align exactly; the range also
|
|
81
|
+
// anchors scalar text updates and nested-template swaps. Same
|
|
82
|
+
// part-marker approach as lit-html / Solid.
|
|
90
83
|
out += `<!--${SLOT_START}-->`;
|
|
91
84
|
out += stringifyValue(value, false);
|
|
92
85
|
out += `<!--${SLOT_END}-->`;
|
|
93
|
-
} else if (inAttr) {
|
|
94
|
-
out += stringifyValue(value, true);
|
|
95
|
-
} else {
|
|
96
|
-
// Text-region scalar slot. An empty result (e.g. `cond ? x : ''`)
|
|
97
|
-
// would emit NO node and desync the path-based hydration of the
|
|
98
|
-
// following sibling slots (their @input/@submit bindings break).
|
|
99
|
-
// Emit an empty-comment placeholder so the position is preserved
|
|
100
|
-
// — lit-html / Solid do the same; hydration materializes the text
|
|
101
|
-
// node there.
|
|
102
|
-
const text = stringifyValue(value, false);
|
|
103
|
-
out += text === "" ? "<!---->" : text;
|
|
104
86
|
}
|
|
105
87
|
}
|
|
106
88
|
}
|
|
@@ -111,27 +93,6 @@ function stringifyTemplateResult(result: TemplateResult): string {
|
|
|
111
93
|
const SLOT_START = "$";
|
|
112
94
|
const SLOT_END = "/$";
|
|
113
95
|
|
|
114
|
-
/**
|
|
115
|
-
* True when `value` is a reactive expression (signal / function) whose
|
|
116
|
-
* current evaluation is a structured node payload (a nested
|
|
117
|
-
* TemplateResult, or an array). These are the slots that can SWAP their
|
|
118
|
-
* subtree on a client-side change and therefore need boundary markers
|
|
119
|
-
* for hydration to find the range. A reactive slot resolving to a
|
|
120
|
-
* scalar (string / number) is updated in place and needs no markers.
|
|
121
|
-
*/
|
|
122
|
-
function isReactiveStructuredSlot(value: unknown): boolean {
|
|
123
|
-
if (!isSignal(value) && typeof value !== "function") return false;
|
|
124
|
-
let evaluated: unknown;
|
|
125
|
-
try {
|
|
126
|
-
evaluated = isSignal(value)
|
|
127
|
-
? (value as () => unknown)()
|
|
128
|
-
: (value as () => unknown)();
|
|
129
|
-
} catch {
|
|
130
|
-
return false;
|
|
131
|
-
}
|
|
132
|
-
return isTemplateResult(evaluated) || Array.isArray(evaluated);
|
|
133
|
-
}
|
|
134
|
-
|
|
135
96
|
/**
|
|
136
97
|
* Returns true if the position at the end of `htmlSoFar` lives inside
|
|
137
98
|
* the value region of an HTML tag (between `<` and `>`). The check
|
package/src/url.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
let manifest: Record<string, string> = {};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Install the `name → path-pattern` map `urlFor` resolves against (e.g.
|
|
24
|
+
* `{ 'users.show': '/users/:id' }`, from Ream's `router.namedManifest()`).
|
|
25
|
+
* Replaces any previous manifest. Routes are static per app, so this is set once
|
|
26
|
+
* per environment (server boot / page render, and the hydrate bootstrap).
|
|
27
|
+
*/
|
|
28
|
+
export function setRouteManifest(routes: Record<string, string>): void {
|
|
29
|
+
manifest = { ...routes };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The currently-installed route manifest (mainly for tests/introspection). */
|
|
33
|
+
export function getRouteManifest(): Record<string, string> {
|
|
34
|
+
return { ...manifest };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build a URL for a named route — fills `:param` placeholders, drops unprovided
|
|
39
|
+
* optional (`:name?`) segments, appends `query` as a query string, and throws on
|
|
40
|
+
* an unknown route or a missing required param. Mirrors Ream's `router.urlFor`.
|
|
41
|
+
*/
|
|
42
|
+
export function urlFor(
|
|
43
|
+
name: string,
|
|
44
|
+
params?: Record<string, string | number>,
|
|
45
|
+
query?: Record<string, string | number>,
|
|
46
|
+
): string {
|
|
47
|
+
const pattern = manifest[name];
|
|
48
|
+
if (pattern === undefined) {
|
|
49
|
+
const known = Object.keys(manifest);
|
|
50
|
+
throw new Error(
|
|
51
|
+
`[aurora] urlFor: unknown route '${name}'. ${
|
|
52
|
+
known.length > 0
|
|
53
|
+
? `Known: ${known.join(", ")}`
|
|
54
|
+
: "No routes registered — was the manifest passed to render() / setRouteManifest() called?"
|
|
55
|
+
}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let url = pattern;
|
|
60
|
+
if (params) {
|
|
61
|
+
for (const [key, value] of Object.entries(params)) {
|
|
62
|
+
// Word-boundary substitution so `:id` doesn't corrupt `:idx`.
|
|
63
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
64
|
+
url = url.replace(
|
|
65
|
+
new RegExp(`:${escaped}\\??(?![\\w])`, "g"),
|
|
66
|
+
encodeURIComponent(String(value)),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Strip remaining optional placeholders (`:name?` not provided).
|
|
72
|
+
url = url.replace(/\/:[A-Za-z_][\w]*\?/g, "");
|
|
73
|
+
|
|
74
|
+
const missing = url.match(/:[A-Za-z_][\w]*/g);
|
|
75
|
+
if (missing && missing.length > 0) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`[aurora] urlFor: route '${name}' is missing params ${missing.join(", ")}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (query) {
|
|
82
|
+
const qs = Object.entries(query)
|
|
83
|
+
.map(
|
|
84
|
+
([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`,
|
|
85
|
+
)
|
|
86
|
+
.join("&");
|
|
87
|
+
if (qs) url += `${url.includes("?") ? "&" : "?"}${qs}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return url;
|
|
91
|
+
}
|