@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/hydrate.js CHANGED
@@ -278,6 +278,17 @@ function resolvePathLive(_root, path, rootNodes) {
278
278
  return node;
279
279
  }
280
280
  function hydrateSlot(slot, node, value, cleanups, mountHooks, markerCursor) {
281
+ // Type guard: an attr/bool/prop/event slot needs an Element. On a SSR↔client
282
+ // structural desync the path can resolve to an EXISTING node of the wrong
283
+ // type (Text/Comment); casting it to Element and calling setAttribute would
284
+ // throw "setAttribute is not a function". Skip the binding (fail-soft) rather
285
+ // than crash hydration. Text slots accept text/comment/element, so they pass.
286
+ if (slot.kind !== "text" && node.nodeType !== 1) {
287
+ if (typeof console !== "undefined") {
288
+ console.warn(`[aurora] hydrate: slot (${slot.kind}) path ${slot.path.join(".")} resolved to a non-element node — skipping binding`);
289
+ }
290
+ return;
291
+ }
281
292
  switch (slot.kind) {
282
293
  case "text":
283
294
  hydrateTextSlot(node, value, cleanups, mountHooks, markerCursor);
@@ -306,15 +317,70 @@ function hydrateSlot(slot, node, value, cleanups, mountHooks, markerCursor) {
306
317
  * existing text node in place. For nested TemplateResults, we
307
318
  * recursively hydrate against the captured sibling range.
308
319
  */
320
+ /**
321
+ * First text node inside a marker pair's range, or a fresh empty one inserted
322
+ * before the end marker (when the SSR value was empty → no text node yet).
323
+ */
324
+ function reactiveTextNode(pair) {
325
+ for (let n = pair.start.nextSibling; n !== null && n !== pair.end; n = n.nextSibling) {
326
+ if (n.nodeType === 3 /* TEXT */)
327
+ return n;
328
+ }
329
+ const doc = pair.end.ownerDocument ?? document;
330
+ const fresh = doc.createTextNode("");
331
+ pair.end.parentNode?.insertBefore(fresh, pair.end);
332
+ return fresh;
333
+ }
309
334
  function hydrateTextSlot(commentMarker, value, cleanups, mountHooks, markerCursor) {
310
- // The path lands on the comment marker that EXISTS in the parsed
311
- // template but not in SSR output. Hydration walks the live siblings
312
- // to find the text node that holds the SSR value.
313
- // Strategy: the comment was located at child index N inside its
314
- // parent; SSR wrote the value as the immediately-preceding text
315
- // node (or nothing for null/false). Live node here is whatever the
316
- // path resolution returned — often a text node, sometimes an
317
- // element (for nested templates). We rebind in-place.
335
+ // Every text slot is SSR-wrapped in a <!--$-->…<!--/$--> pair, and its path
336
+ // resolves (via collapseMarkerRanges) to the start marker. Consume the
337
+ // matching pair in document order and bind within its range.
338
+ const pair = markerCursor.pairs[markerCursor.i];
339
+ if (pair === undefined) {
340
+ // Legacy markup without per-slot markers (mismatched older SSR build).
341
+ legacyHydrateTextSlot(commentMarker, value, cleanups, mountHooks, markerCursor);
342
+ return;
343
+ }
344
+ markerCursor.i += 1;
345
+ const reactiveFn = isSignal(value) || typeof value === "function"
346
+ ? value
347
+ : null;
348
+ const current = reactiveFn ? reactiveFn() : value;
349
+ // Structured value (nested template / array): reactive → swap on change;
350
+ // direct → adopt the SSR range once (inner bindings wired against it).
351
+ if (isTemplateResult(current) || Array.isArray(current)) {
352
+ if (reactiveFn) {
353
+ hydrateReactiveStructured(reactiveFn, pair, cleanups, mountHooks, markerCursor);
354
+ return;
355
+ }
356
+ const range = [];
357
+ for (let n = pair.start.nextSibling; n !== null && n !== pair.end; n = n.nextSibling) {
358
+ range.push(n);
359
+ }
360
+ if (isTemplateResult(current)) {
361
+ hydrateTemplateResult(current, range, cleanups, mountHooks, markerCursor);
362
+ }
363
+ // A direct (non-reactive) array is static SSR markup; per-item reactive
364
+ // bindings aren't individually re-hydrated (use `${() => arr.map(…)}`).
365
+ return;
366
+ }
367
+ // Scalar: a reactive scalar updates the range's text node on change; a static
368
+ // scalar is already rendered between the markers (nothing to wire).
369
+ if (reactiveFn) {
370
+ const textNode = reactiveTextNode(pair);
371
+ const dispose = effect(() => {
372
+ const v = reactiveFn();
373
+ textNode.data = v == null || v === false ? "" : String(v);
374
+ });
375
+ cleanups.push(dispose);
376
+ }
377
+ }
378
+ /**
379
+ * Pre-marker fallback — best-effort hydration when the SSR markup carries no
380
+ * per-slot boundary markers (a mismatched older SSR build). Current builds wrap
381
+ * every text slot, so this path is dead for matched server/client versions.
382
+ */
383
+ function legacyHydrateTextSlot(commentMarker, value, cleanups, mountHooks, markerCursor) {
318
384
  if (isSignal(value) || typeof value === "function") {
319
385
  const fn = value;
320
386
  // 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 (!inAttr && isReactiveStructuredSlot(value)) {
66
- // Reactive text slot whose value is a nested template /
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 if (!inAttr && isTemplateResult(value)) {
79
- // DIRECT (non-reactive) nested template component composition,
80
- // e.g. `${Layout({ children })}` or `${table}`. It renders to a
81
- // node RANGE just like a reactive structured slot, so it needs the
82
- // SAME boundary markers: the client template counts every slot as
83
- // ONE comment node, so without a markable range a multi-node child
84
- // shifts the childNode indices of every FOLLOWING sibling slot
85
- // dead bindings / "slot path not found". Hydration collapses the
86
- // marked range back to one node so sibling paths stay aligned.
68
+ else {
69
+ // Text-region slotALWAYS 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.13",
3
+ "version": "0.1.14",
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",