spark-html-router 0.1.0 → 0.2.0

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.
Files changed (3) hide show
  1. package/README.md +26 -4
  2. package/package.json +1 -1
  3. package/src/index.js +80 -41
package/README.md CHANGED
@@ -20,10 +20,32 @@ Declarative client routing for [spark-html](https://www.npmjs.com/package/spark-
20
20
  </script>
21
21
  ```
22
22
 
23
- `router()` mounts the page, shows the `<template route>` that matches the URL,
24
- intercepts same-origin `<a>` clicks for SPA navigation (no full reload), and
25
- tracks Back/Forward. The route templates are inert to the core runtime, so this
26
- is a tiny add-on the `spark-html` core stays router-free.
23
+ `router()` mounts the page **once** (chrome + the active route together every
24
+ component's `onMount` fires exactly once), shows the `<template route>` that
25
+ matches the URL, intercepts same-origin `<a>` clicks for SPA navigation (no full
26
+ reload), and tracks Back/Forward. The route templates are inert to the core
27
+ runtime, so this is a tiny add-on — the `spark-html` core stays router-free.
28
+
29
+ ## Reactive active route
30
+
31
+ The router publishes the current path to a built-in `route` store, so any
32
+ component can highlight the active link (or set the title, fire analytics, …)
33
+ with `useStore('route')` — no manual `popstate`/`pushState` wiring:
34
+
35
+ ```html
36
+ <!-- components/site-nav.html -->
37
+ <nav>
38
+ <a href="/" class="link {homeActive}">Home</a>
39
+ <a href="/about" class="link {aboutActive}">About</a>
40
+ </nav>
41
+
42
+ <script>
43
+ const route = useStore('route');
44
+ let homeActive = '', aboutActive = '';
45
+ $: homeActive = route.path === '/' ? 'active' : '';
46
+ $: aboutActive = route.path === '/about' ? 'active' : '';
47
+ </script>
48
+ ```
27
49
 
28
50
  ## Install
29
51
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spark-html-router",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Declarative <template route> client routing for spark-html — no JS config, just markup.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/index.js CHANGED
@@ -1,24 +1,36 @@
1
1
  /**
2
- * spark-router — declarative client routing for spark-html.
2
+ * spark-html-router — declarative client routing for spark-html.
3
3
  *
4
- * Author routes as inert <template route> blocks (the core runtime ignores
5
- * them querySelectorAll('[import]') doesn't descend into <template> content),
6
- * then call router() once. It mounts the page chrome, renders the route that
7
- * matches the URL, intercepts same-origin <a> clicks for SPA navigation, and
8
- * tracks Back/Forward.
4
+ * Author routes as inert <template route> blocks (the runtime never descends
5
+ * into <template> content, so they're invisible to mount()), then call
6
+ * router() once. It:
7
+ *
8
+ * mounts the page (chrome + the active route) a SINGLE time — no flash,
9
+ * no double-boot, onMount fires exactly once per component;
10
+ * • adopts a prerendered route outlet in place (spark-prerender bakes the
11
+ * active route as <div data-spark-route> — the runtime hydrates over it);
12
+ * • intercepts same-origin <a> clicks for SPA navigation and tracks
13
+ * Back/Forward;
14
+ * • exposes a reactive `route` store ({ path }) so nav links, titles, and
15
+ * analytics can react to the current route with `useStore('route')`.
9
16
  *
10
17
  * <template route="/"> <div import="components/home"></div> </template>
11
18
  * <template route="/about"> <div import="components/about"></div> </template>
12
19
  * <template route="*"> <div import="components/not-found"></div></template>
13
20
  *
14
- * import { router } from 'spark-router';
21
+ * import { router } from 'spark-html-router';
15
22
  * router(); // that's it
23
+ *
24
+ * // anywhere, to highlight the active link:
25
+ * const route = useStore('route');
26
+ * $: active = route.path === '/about';
16
27
  */
17
- import { mount, unmount } from 'spark-html';
28
+ import { mount, unmount, store } from 'spark-html';
18
29
 
19
30
  let base = '';
20
31
  let rootEl = null;
21
32
  let active = null; // the live outlet element for the current route
33
+ let routeProxy = null; // the reactive `route` store proxy
22
34
  let started = false;
23
35
 
24
36
  // Normalize a pathname to a base-stripped, no-trailing-slash route key.
@@ -34,6 +46,14 @@ function currentPath() {
34
46
  return normalize((typeof location !== 'undefined' && location.pathname) || '/');
35
47
  }
36
48
 
49
+ // Publish the active path to the reactive `route` store so any component can
50
+ // `useStore('route')` and react (nav highlight, document.title, analytics…).
51
+ // Created here BEFORE the first mount so components find it on boot.
52
+ function setRoute(path) {
53
+ if (!routeProxy) routeProxy = store('route', { path });
54
+ routeProxy.path = path;
55
+ }
56
+
37
57
  // Find the <template route> that matches `path`: exact match first, then a
38
58
  // `route="*"` catch-all (404) if present.
39
59
  function matchTemplate(path) {
@@ -47,44 +67,61 @@ function matchTemplate(path) {
47
67
  return fallback;
48
68
  }
49
69
 
50
- // Render the route matching the current URL. Adopts prerendered route content
51
- // in place (no flash) when it's already there; otherwise clones the template
52
- // into a fresh outlet and mounts it.
53
- async function render() {
54
- const path = currentPath();
55
-
56
- // Prerendered output marks the active route's content with data-spark-route.
57
- const prerendered = rootEl.querySelector(`[data-spark-route="${cssEscape(path)}"]`);
58
- if (prerendered && active === prerendered) return; // already showing it
59
-
60
- if (active && active !== prerendered) {
61
- unmount(active);
62
- active.remove();
63
- active = null;
64
- }
65
-
66
- if (prerendered) {
67
- // Adopt: the content is already in the DOM from prerender — just (re)boot
68
- // its components in place. The runtime hydrates over them without a flash.
69
- active = prerendered;
70
- await mount(prerendered);
71
- return;
72
- }
73
-
70
+ // Clone the matching <template route> into a fresh outlet element and insert
71
+ // it after the template. The caller mounts it. Returns the outlet, or null
72
+ // when there's no matching route and no catch-all.
73
+ function buildOutlet(path) {
74
74
  const t = matchTemplate(path);
75
- if (!t) return; // no route + no catch-all → render nothing
75
+ if (!t) return null;
76
76
  const outlet = document.createElement('div');
77
77
  outlet.setAttribute('data-spark-route', path);
78
78
  // Clone the template's children in (appendChild of a DocumentFragment is
79
- // unreliable across DOM impls).
79
+ // unreliable across DOM impls, so copy node by node).
80
80
  for (const child of [...t.content.childNodes]) outlet.appendChild(child.cloneNode(true));
81
81
  t.after(outlet);
82
- active = outlet;
83
- await mount(outlet);
82
+ return outlet;
84
83
  }
85
84
 
86
- function cssEscape(s) {
87
- return String(s).replace(/["\\]/g, '\\$&');
85
+ // Initial render, folded INTO the single mount(). If the page was prerendered
86
+ // the active route is already baked as <div data-spark-route> — adopt it in
87
+ // place so mount() hydrates over it (no flash, no clone, no second mount).
88
+ // Otherwise clone the matching template into an outlet. Either way the one
89
+ // mount(rootEl) that follows resolves its imports and boots it once.
90
+ function prepareInitial() {
91
+ const path = currentPath();
92
+ setRoute(path);
93
+ const baked = rootEl.querySelector('[data-spark-route]');
94
+ if (baked) {
95
+ if (normalize(baked.getAttribute('data-spark-route')) === path) {
96
+ active = baked; // prerendered outlet matches the URL — adopt
97
+ return;
98
+ }
99
+ baked.remove(); // stale outlet (shouldn't happen) — rebuild
100
+ }
101
+ active = buildOutlet(path); // no prerendered outlet (dev / SPA-only)
102
+ }
103
+
104
+ // SPA navigation render: swap the active outlet for the route matching the URL.
105
+ // The new outlet is mounted BEFORE the old one is torn down, so there's no
106
+ // blank frame between routes, and onMount fires once for the new route.
107
+ async function render() {
108
+ const path = currentPath();
109
+ if (active && normalize(active.getAttribute('data-spark-route')) === path) {
110
+ setRoute(path);
111
+ return; // already showing this route
112
+ }
113
+
114
+ const old = active;
115
+ const outlet = buildOutlet(path);
116
+ active = outlet;
117
+ setRoute(path);
118
+
119
+ if (outlet) await mount(outlet); // resolve imports + boot — exactly once
120
+
121
+ if (old && old !== outlet) {
122
+ unmount(old);
123
+ old.remove();
124
+ }
88
125
  }
89
126
 
90
127
  // Navigate to a route programmatically (path is route-relative; base is added).
@@ -112,7 +149,9 @@ function onClick(e) {
112
149
  }
113
150
 
114
151
  /**
115
- * Start the router: mount the page and show the route matching the URL.
152
+ * Start the router: mount the page (chrome + active route, once) and show the
153
+ * route matching the URL, intercept same-origin <a> clicks for SPA navigation,
154
+ * and track Back/Forward. Call it once instead of mount().
116
155
  *
117
156
  * @param {object} [options]
118
157
  * @param {string} [options.base] Path prefix the app is served under (e.g.
@@ -133,8 +172,8 @@ export async function router(options = {}) {
133
172
  if (typeof document !== 'undefined') document.addEventListener('click', onClick);
134
173
  if (typeof window !== 'undefined') window.addEventListener('popstate', () => render());
135
174
 
136
- await mount(rootEl); // boot the chrome (route templates stay inert)
137
- await render(); // show the active route
175
+ prepareInitial(); // put the active route's outlet in the DOM (adopt/clone)
176
+ await mount(rootEl); // ONE mount: chrome + the active route, booted once
138
177
  }
139
178
 
140
179
  export default { router, navigate };