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.
- package/README.md +26 -4
- package/package.json +1 -1
- 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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
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;
|
|
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
|
-
|
|
83
|
-
await mount(outlet);
|
|
82
|
+
return outlet;
|
|
84
83
|
}
|
|
85
84
|
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
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
|
-
|
|
137
|
-
await
|
|
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 };
|