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 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 };