spark-html-router 0.1.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 +65 -0
- package/package.json +36 -0
- package/src/index.d.ts +26 -0
- package/src/index.js +140 -0
package/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# ⚡ spark-html-router
|
|
2
|
+
|
|
3
|
+
Declarative client routing for [spark-html](https://www.npmjs.com/package/spark-html) — **no JS config, just markup.** Write your routes as `<template route>` blocks and call `router()` once.
|
|
4
|
+
|
|
5
|
+
```html
|
|
6
|
+
<nav>
|
|
7
|
+
<a href="/">Home</a>
|
|
8
|
+
<a href="/about">About</a>
|
|
9
|
+
<a href="/projects">Projects</a>
|
|
10
|
+
</nav>
|
|
11
|
+
|
|
12
|
+
<template route="/"> <div import="components/home"></div> </template>
|
|
13
|
+
<template route="/about"> <div import="components/about"></div> </template>
|
|
14
|
+
<template route="/projects"> <div import="components/projects"></div> </template>
|
|
15
|
+
<template route="*"> <div import="components/not-found"></div> </template>
|
|
16
|
+
|
|
17
|
+
<script type="module">
|
|
18
|
+
import { router } from 'spark-html-router';
|
|
19
|
+
router(); // that's it
|
|
20
|
+
</script>
|
|
21
|
+
```
|
|
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.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install spark-html-router
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## API
|
|
35
|
+
|
|
36
|
+
```js
|
|
37
|
+
import { router, navigate } from 'spark-html-router';
|
|
38
|
+
|
|
39
|
+
await router({ base: '/spark' }); // base = path prefix (e.g. GitHub Pages)
|
|
40
|
+
navigate('/about'); // navigate programmatically
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
| Option | Meaning |
|
|
44
|
+
|--------|---------|
|
|
45
|
+
| `base` | Path prefix the app is served under (e.g. `/spark`). Stripped before matching, added back when navigating. |
|
|
46
|
+
| `root` | Mount root (default `document.body`). |
|
|
47
|
+
|
|
48
|
+
## Routes
|
|
49
|
+
|
|
50
|
+
- **Exact match** — `route="/about"` matches `/about` (trailing slashes and the
|
|
51
|
+
base path are normalized away).
|
|
52
|
+
- **Catch-all** — `route="*"` renders for any unmatched path (a 404 page).
|
|
53
|
+
|
|
54
|
+
## SEO / prerender
|
|
55
|
+
|
|
56
|
+
Pair it with [`spark-prerender`](https://www.npmjs.com/package/spark-prerender):
|
|
57
|
+
it discovers your `<template route>` routes at build time and emits one
|
|
58
|
+
fully-rendered HTML file per route (`about.html`, `projects.html`, …) plus the
|
|
59
|
+
host rewrite rules — so crawlers get real content per URL, and the client
|
|
60
|
+
adopts the prerendered route with no flash.
|
|
61
|
+
|
|
62
|
+
## Notes
|
|
63
|
+
|
|
64
|
+
- v1 covers flat, exact-match routes + a catch-all. Path params (`/blog/:id`)
|
|
65
|
+
and nested routes are not yet supported.
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "spark-html-router",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Declarative <template route> client routing for spark-html — no JS config, just markup.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"types": "./src/index.d.ts",
|
|
8
|
+
"homepage": "https://wilkinnovo.github.io/spark",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./src/index.d.ts",
|
|
12
|
+
"default": "./src/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node test/router.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"src"
|
|
20
|
+
],
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/wilkinnovo/spark.git",
|
|
24
|
+
"directory": "packages/spark-html-router"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"spark-html": "^0.20.0"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"spark-html",
|
|
31
|
+
"router",
|
|
32
|
+
"spa",
|
|
33
|
+
"routing"
|
|
34
|
+
],
|
|
35
|
+
"license": "MIT"
|
|
36
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* spark-router — declarative <template route> client routing for spark-html.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface RouterOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Path prefix the app is served under (e.g. "/spark" on GitHub Pages).
|
|
8
|
+
* Stripped from the URL before matching; added back when navigating.
|
|
9
|
+
*/
|
|
10
|
+
base?: string;
|
|
11
|
+
/** Mount root (default: document.body). */
|
|
12
|
+
root?: string | Element;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Start the router: mount the page and show the `<template route>` that matches
|
|
17
|
+
* the URL, intercept same-origin `<a>` clicks for SPA navigation, and track
|
|
18
|
+
* Back/Forward. Call it once (it replaces `mount()`).
|
|
19
|
+
*/
|
|
20
|
+
export function router(options?: RouterOptions): Promise<void>;
|
|
21
|
+
|
|
22
|
+
/** Navigate to a route programmatically (route-relative; base is added). */
|
|
23
|
+
export function navigate(to: string): Promise<void> | void;
|
|
24
|
+
|
|
25
|
+
declare const _default: { router: typeof router; navigate: typeof navigate };
|
|
26
|
+
export default _default;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* spark-router — declarative client routing for spark-html.
|
|
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.
|
|
9
|
+
*
|
|
10
|
+
* <template route="/"> <div import="components/home"></div> </template>
|
|
11
|
+
* <template route="/about"> <div import="components/about"></div> </template>
|
|
12
|
+
* <template route="*"> <div import="components/not-found"></div></template>
|
|
13
|
+
*
|
|
14
|
+
* import { router } from 'spark-router';
|
|
15
|
+
* router(); // that's it
|
|
16
|
+
*/
|
|
17
|
+
import { mount, unmount } from 'spark-html';
|
|
18
|
+
|
|
19
|
+
let base = '';
|
|
20
|
+
let rootEl = null;
|
|
21
|
+
let active = null; // the live outlet element for the current route
|
|
22
|
+
let started = false;
|
|
23
|
+
|
|
24
|
+
// Normalize a pathname to a base-stripped, no-trailing-slash route key.
|
|
25
|
+
function normalize(pathname) {
|
|
26
|
+
let p = String(pathname || '/');
|
|
27
|
+
if (base && p.startsWith(base)) p = p.slice(base.length);
|
|
28
|
+
if (!p.startsWith('/')) p = '/' + p;
|
|
29
|
+
if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
|
|
30
|
+
return p || '/';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function currentPath() {
|
|
34
|
+
return normalize((typeof location !== 'undefined' && location.pathname) || '/');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Find the <template route> that matches `path`: exact match first, then a
|
|
38
|
+
// `route="*"` catch-all (404) if present.
|
|
39
|
+
function matchTemplate(path) {
|
|
40
|
+
const templates = [...rootEl.querySelectorAll('template[route]')];
|
|
41
|
+
let fallback = null;
|
|
42
|
+
for (const t of templates) {
|
|
43
|
+
const r = t.getAttribute('route');
|
|
44
|
+
if (r === '*') { fallback = t; continue; }
|
|
45
|
+
if (normalize(r) === path) return t;
|
|
46
|
+
}
|
|
47
|
+
return fallback;
|
|
48
|
+
}
|
|
49
|
+
|
|
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
|
+
|
|
74
|
+
const t = matchTemplate(path);
|
|
75
|
+
if (!t) return; // no route + no catch-all → render nothing
|
|
76
|
+
const outlet = document.createElement('div');
|
|
77
|
+
outlet.setAttribute('data-spark-route', path);
|
|
78
|
+
// Clone the template's children in (appendChild of a DocumentFragment is
|
|
79
|
+
// unreliable across DOM impls).
|
|
80
|
+
for (const child of [...t.content.childNodes]) outlet.appendChild(child.cloneNode(true));
|
|
81
|
+
t.after(outlet);
|
|
82
|
+
active = outlet;
|
|
83
|
+
await mount(outlet);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function cssEscape(s) {
|
|
87
|
+
return String(s).replace(/["\\]/g, '\\$&');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Navigate to a route programmatically (path is route-relative; base is added).
|
|
91
|
+
export function navigate(to) {
|
|
92
|
+
const url = base + normalize(to);
|
|
93
|
+
if (typeof history !== 'undefined') history.pushState({}, '', url);
|
|
94
|
+
return render();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function onClick(e) {
|
|
98
|
+
if (e.defaultPrevented || e.button || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
99
|
+
let el = e.target;
|
|
100
|
+
while (el && el.tagName !== 'A') el = el.parentNode;
|
|
101
|
+
if (!el) return;
|
|
102
|
+
const href = el.getAttribute('href');
|
|
103
|
+
const target = el.getAttribute('target');
|
|
104
|
+
if (!href || (target && target !== '_self') || el.hasAttribute('download') || /^[a-z]+:/i.test(href)) {
|
|
105
|
+
return; // external scheme, new tab, or download — let the browser handle it
|
|
106
|
+
}
|
|
107
|
+
const url = new URL(href, location.href);
|
|
108
|
+
if (url.origin !== location.origin) return;
|
|
109
|
+
e.preventDefault();
|
|
110
|
+
history.pushState({}, '', url.pathname + url.search + url.hash);
|
|
111
|
+
render();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Start the router: mount the page and show the route matching the URL.
|
|
116
|
+
*
|
|
117
|
+
* @param {object} [options]
|
|
118
|
+
* @param {string} [options.base] Path prefix the app is served under (e.g.
|
|
119
|
+
* "/spark" on GitHub Pages). Stripped before
|
|
120
|
+
* matching; added back when navigating.
|
|
121
|
+
* @param {string|Element} [options.root] Mount root (default document.body).
|
|
122
|
+
* @returns {Promise<void>}
|
|
123
|
+
*/
|
|
124
|
+
export async function router(options = {}) {
|
|
125
|
+
if (started) return;
|
|
126
|
+
started = true;
|
|
127
|
+
base = options.base || '';
|
|
128
|
+
if (base.length > 1 && base.endsWith('/')) base = base.slice(0, -1);
|
|
129
|
+
rootEl = typeof options.root === 'string'
|
|
130
|
+
? document.querySelector(options.root)
|
|
131
|
+
: options.root || document.body;
|
|
132
|
+
|
|
133
|
+
if (typeof document !== 'undefined') document.addEventListener('click', onClick);
|
|
134
|
+
if (typeof window !== 'undefined') window.addEventListener('popstate', () => render());
|
|
135
|
+
|
|
136
|
+
await mount(rootEl); // boot the chrome (route templates stay inert)
|
|
137
|
+
await render(); // show the active route
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export default { router, navigate };
|