@symbo.ls/router 3.5.1 → 3.14.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/LICENSE ADDED
@@ -0,0 +1,34 @@
1
+ Creative Commons Attribution-NonCommercial 4.0 International License
2
+
3
+ Copyright (c) 2023 symbo.ls
4
+
5
+ This work is licensed under the Creative Commons Attribution-NonCommercial
6
+ 4.0 International License. To view a copy of this license, visit
7
+ https://creativecommons.org/licenses/by-nc/4.0/ or send a letter to
8
+ Creative Commons, PO Box 1866, Mountain View, CA 94042, USA.
9
+
10
+ You are free to:
11
+
12
+ Share — copy and redistribute the material in any medium or format
13
+ Adapt — remix, transform, and build upon the material
14
+
15
+ Under the following terms:
16
+
17
+ Attribution — You must give appropriate credit, provide a link to the
18
+ license, and indicate if changes were made. You may do so in any
19
+ reasonable manner, but not in any way that suggests the licensor endorses
20
+ you or your use.
21
+
22
+ NonCommercial — You may not use the material for commercial purposes.
23
+
24
+ No additional restrictions — You may not apply legal terms or
25
+ technological measures that legally restrict others from doing anything
26
+ the license permits.
27
+
28
+ THE WORK IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
29
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
30
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
31
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
32
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
33
+ OUT OF OR IN CONNECTION WITH THE WORK OR THE USE OR OTHER DEALINGS IN THE
34
+ WORK.
package/README.md ADDED
@@ -0,0 +1,234 @@
1
+ # @domql/router
2
+
3
+ Client-side router plugin for DOMQL. Handles route matching, navigation, scroll management, and state updates within DOMQL elements.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @domql/router
9
+ ```
10
+
11
+ ## Basic Usage
12
+
13
+ ```js
14
+ import { router } from '@domql/router'
15
+
16
+ // Navigate to a path
17
+ router('/about', element)
18
+
19
+ // With state and options
20
+ router('/dashboard', element, { userId: 1 }, { scrollToTop: true })
21
+ ```
22
+
23
+ Define routes on your DOMQL element:
24
+
25
+ ```js
26
+ const App = {
27
+ routes: {
28
+ '/': HomePage,
29
+ '/about': AboutPage,
30
+ '/contact': ContactPage,
31
+ '/*': NotFoundPage
32
+ }
33
+ }
34
+ ```
35
+
36
+ ## Dynamic Route Params
37
+
38
+ Match routes with `:param` segments. Enable with `useParamsMatching: true`.
39
+
40
+ ```js
41
+ const App = {
42
+ routes: {
43
+ '/': HomePage,
44
+ '/:id': UserPage,
45
+ '/:category/:slug': ArticlePage,
46
+ '/*': NotFoundPage
47
+ }
48
+ }
49
+
50
+ router('/users/42', element, {}, { useParamsMatching: true })
51
+ // state.params = { id: '42' }
52
+
53
+ router('/tech/my-article', element, {}, { useParamsMatching: true })
54
+ // state.params = { category: 'tech', slug: 'my-article' }
55
+ ```
56
+
57
+ Exact segments score higher than params, so `/about` will match a literal `/about` route before `/:id`.
58
+
59
+ ## Query String Parsing
60
+
61
+ Query parameters are automatically parsed and stored in state.
62
+
63
+ ```js
64
+ router('/search?q=hello&tag=a&tag=b', element)
65
+ // state.query = { q: 'hello', tag: ['a', 'b'] }
66
+ ```
67
+
68
+ Duplicate keys are collected into arrays.
69
+
70
+ ## Guards / Middleware
71
+
72
+ Run async guard functions before navigation. Return `true` to allow, `false` to block, or a string to redirect.
73
+
74
+ ```js
75
+ const authGuard = ({ element }) => {
76
+ if (!element.state.root.isLoggedIn) return '/login'
77
+ return true
78
+ }
79
+
80
+ const roleGuard = ({ params }) => {
81
+ if (params.section === 'admin') return false
82
+ return true
83
+ }
84
+
85
+ router('/dashboard', element, {}, {
86
+ guards: [authGuard, roleGuard]
87
+ })
88
+ ```
89
+
90
+ Guard functions receive a context object:
91
+
92
+ ```js
93
+ {
94
+ pathname, // full pathname
95
+ route, // matched route key
96
+ params, // dynamic route params
97
+ query, // parsed query string
98
+ hash, // URL hash
99
+ element, // DOMQL element
100
+ state // navigation state
101
+ }
102
+ ```
103
+
104
+ ## 404 Handling
105
+
106
+ Provide an `onNotFound` callback for unmatched routes:
107
+
108
+ ```js
109
+ router('/unknown', element, {}, {
110
+ onNotFound: ({ pathname, route, element }) => {
111
+ console.warn(`No route found for ${pathname}`)
112
+ }
113
+ })
114
+ ```
115
+
116
+ You can also define a `/*` wildcard route as a catch-all fallback.
117
+
118
+ ## Custom Router Element
119
+
120
+ Use `customRouterElement` in your `config.js` to route pages into a specific element within the component tree instead of the root. This is useful for persistent layouts where only the content area changes between routes.
121
+
122
+ ```js
123
+ // symbols/config.js
124
+ export default {
125
+ router: {
126
+ customRouterElement: 'Folder.Content'
127
+ }
128
+ }
129
+ ```
130
+
131
+ The value is a dot-separated path resolved from the root element. For example, `'Folder.Content'` means the router will find `root.Folder.Content` and render page content inside it.
132
+
133
+ This allows you to define a layout once in the `/` (main) page and have sub-pages render inside a specific container:
134
+
135
+ ```js
136
+ // pages/main.js — defines the persistent layout
137
+ export const main = {
138
+ extends: 'Layout',
139
+ Folder: {
140
+ Content: {
141
+ // default content for '/' route
142
+ }
143
+ }
144
+ }
145
+
146
+ // pages/team.js — only defines content (rendered inside Folder.Content)
147
+ export const team = {
148
+ state: 'team',
149
+ extends: 'Grid',
150
+ childExtends: 'TeamItem',
151
+ childrenAs: 'state',
152
+ children: (el, s) => s.data,
153
+ }
154
+ ```
155
+
156
+ ## Options
157
+
158
+ | Option | Type | Default | Description |
159
+ |---|---|---|---|
160
+ | `level` | `number` | `0` | Route nesting level (which path segment to match) |
161
+ | `pushState` | `boolean` | `true` | Push to browser history |
162
+ | `initialRender` | `boolean` | `false` | Whether this is the initial page render |
163
+ | `scrollToTop` | `boolean` | `true` | Scroll to top after navigation |
164
+ | `scrollToNode` | `boolean` | `false` | Scroll within the element node |
165
+ | `scrollNode` | `Element` | `document.documentElement` | Node to scroll |
166
+ | `scrollToOffset` | `number` | `0` | Offset when scrolling to hash anchors |
167
+ | `scrollToOptions` | `object` | `{ behavior: 'smooth' }` | Options passed to `scrollTo()` |
168
+ | `useFragment` | `boolean` | `false` | Use fragment tag for content |
169
+ | `updateState` | `boolean` | `true` | Update element state on navigation |
170
+ | `contentElementKey` | `string` | `'content'` | Key for the content element slot |
171
+ | `removeOldElement` | `boolean` | `false` | Remove old content element before setting new |
172
+ | `useParamsMatching` | `boolean` | `false` | Enable dynamic `:param` route matching |
173
+ | `guards` | `function[]` | `undefined` | Array of guard/middleware functions |
174
+ | `onNotFound` | `function` | `undefined` | Callback when no route matches |
175
+
176
+ ## Exported Utilities
177
+
178
+ ### `getActiveRoute(level, route)`
179
+
180
+ Returns the active route segment at the given nesting level.
181
+
182
+ ```js
183
+ import { getActiveRoute } from '@domql/router'
184
+
185
+ getActiveRoute(0, '/users/42') // '/users'
186
+ getActiveRoute(1, '/users/42') // '/42'
187
+ ```
188
+
189
+ ### `parseQuery(search)`
190
+
191
+ Parses a query string into an object.
192
+
193
+ ```js
194
+ import { parseQuery } from '@domql/router'
195
+
196
+ parseQuery('?page=1&sort=name') // { page: '1', sort: 'name' }
197
+ ```
198
+
199
+ ### `matchRoute(pathname, routes, level)`
200
+
201
+ Matches a pathname against a routes object. Returns `{ key, content, params }`.
202
+
203
+ ```js
204
+ import { matchRoute } from '@domql/router'
205
+
206
+ const routes = { '/': Home, '/:id': Detail, '/*': NotFound }
207
+ const result = matchRoute('/42', routes)
208
+ // { key: '/:id', content: Detail, params: { id: '42' } }
209
+ ```
210
+
211
+ ### `parseRoutePattern(pattern)`
212
+
213
+ Parses a route pattern string into segments, param definitions, and wildcard flag. Results are cached.
214
+
215
+ ### `runGuards(guards, context)`
216
+
217
+ Runs an array of async guard functions sequentially. Returns `true`, `false`, or a redirect path string.
218
+
219
+ ## Events
220
+
221
+ The router triggers an `onRouteChanged` event on the element after navigation completes. Listen for it in your element definition:
222
+
223
+ ```js
224
+ const App = {
225
+ routes: { ... },
226
+ onRouteChanged: (element, options) => {
227
+ console.log('Route changed:', element.state.route)
228
+ }
229
+ }
230
+ ```
231
+
232
+ ## License
233
+
234
+ MIT
@@ -0,0 +1,281 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+ var index_exports = {};
20
+ __export(index_exports, {
21
+ default: () => index_default,
22
+ getActiveRoute: () => getActiveRoute,
23
+ lastLevel: () => lastLevel,
24
+ lastPathname: () => lastPathname,
25
+ matchRoute: () => matchRoute,
26
+ parseQuery: () => parseQuery,
27
+ parseRoutePattern: () => parseRoutePattern,
28
+ router: () => router,
29
+ runGuards: () => runGuards
30
+ });
31
+ module.exports = __toCommonJS(index_exports);
32
+ var import_utils = require("@symbo.ls/utils");
33
+ const paramPattern = /^:(.+)/;
34
+ const wildcardPattern = /^\*$/;
35
+ const routeCache = /* @__PURE__ */ new Map();
36
+ const parseRoutePattern = (pattern) => {
37
+ const cached = routeCache.get(pattern);
38
+ if (cached) return cached;
39
+ const segments = pattern.replace(/^\//, "").split("/");
40
+ const params = [];
41
+ let hasWildcard = false;
42
+ for (let i = 0; i < segments.length; i++) {
43
+ const match = segments[i].match(paramPattern);
44
+ if (match) {
45
+ params.push({ index: i, name: match[1] });
46
+ } else if (wildcardPattern.test(segments[i])) {
47
+ hasWildcard = true;
48
+ }
49
+ }
50
+ const result = { segments, params, hasWildcard, pattern };
51
+ routeCache.set(pattern, result);
52
+ return result;
53
+ };
54
+ const matchRoute = (pathname, routes, level = 0) => {
55
+ const pathSegments = pathname.replace(/^\//, "").split("/").filter(Boolean);
56
+ const relevantSegments = pathSegments.slice(level);
57
+ const routePath = "/" + (relevantSegments[0] || "");
58
+ let bestMatch = null;
59
+ let bestScore = -1;
60
+ let matchedParams = {};
61
+ for (const key in routes) {
62
+ if (key === "/*") continue;
63
+ const parsed = parseRoutePattern(key);
64
+ const score = scoreMatch(relevantSegments, parsed);
65
+ if (score > bestScore) {
66
+ bestScore = score;
67
+ bestMatch = key;
68
+ matchedParams = extractParams(relevantSegments, parsed);
69
+ }
70
+ }
71
+ if (!bestMatch && routes["/*"]) {
72
+ bestMatch = "/*";
73
+ }
74
+ return {
75
+ key: bestMatch,
76
+ content: bestMatch ? routes[bestMatch] : null,
77
+ params: matchedParams,
78
+ routePath
79
+ };
80
+ };
81
+ const scoreMatch = (pathSegments, parsed) => {
82
+ const { segments, hasWildcard } = parsed;
83
+ if (!hasWildcard && segments.length !== pathSegments.length && segments.length !== 1) {
84
+ if (segments.length > pathSegments.length) return -1;
85
+ }
86
+ let score = 0;
87
+ const len = Math.min(segments.length, pathSegments.length);
88
+ for (let i = 0; i < len; i++) {
89
+ if (segments[i] === pathSegments[i]) {
90
+ score += 3;
91
+ } else if (paramPattern.test(segments[i])) {
92
+ score += 1;
93
+ } else if (wildcardPattern.test(segments[i])) {
94
+ score += 0.5;
95
+ } else {
96
+ return -1;
97
+ }
98
+ }
99
+ return score;
100
+ };
101
+ const extractParams = (pathSegments, parsed) => {
102
+ const params = {};
103
+ for (const { index, name } of parsed.params) {
104
+ if (pathSegments[index]) {
105
+ params[name] = decodeURIComponent(pathSegments[index]);
106
+ }
107
+ }
108
+ return params;
109
+ };
110
+ const parseQuery = (search) => {
111
+ if (!search || search === "?") return {};
112
+ const params = {};
113
+ const searchParams = new URLSearchParams(search);
114
+ searchParams.forEach((value, key) => {
115
+ if (params[key] !== void 0) {
116
+ if (!Array.isArray(params[key])) params[key] = [params[key]];
117
+ params[key].push(value);
118
+ } else {
119
+ params[key] = value;
120
+ }
121
+ });
122
+ return params;
123
+ };
124
+ const runGuards = async (guards, context) => {
125
+ if (!guards || !guards.length) return true;
126
+ for (const guard of guards) {
127
+ const result = await guard(context);
128
+ if (result === false) return false;
129
+ if (typeof result === "string") return result;
130
+ }
131
+ return true;
132
+ };
133
+ const getActiveRoute = (level = 0, route) => {
134
+ if (!route) route = typeof import_utils.window !== "undefined" ? import_utils.window.location.pathname : "/";
135
+ const routeArray = route.split("/");
136
+ const activeRoute = routeArray[level + 1];
137
+ if (activeRoute) return `/${activeRoute}`;
138
+ };
139
+ let lastPathname;
140
+ let lastLevel = 0;
141
+ const normalizePath = (p) => !p || p === "srcdoc" || p === "about:srcdoc" ? "/" : p;
142
+ const defaultOptions = {
143
+ level: lastLevel,
144
+ pushState: true,
145
+ initialRender: false,
146
+ scrollToTop: true,
147
+ scrollToNode: false,
148
+ scrollNode: import_utils.document && import_utils.document.documentElement,
149
+ scrollBody: false,
150
+ useFragment: false,
151
+ updateState: true,
152
+ scrollToOffset: 0,
153
+ contentElementKey: "content",
154
+ scrollToOptions: { behavior: "smooth" },
155
+ useParamsMatching: true
156
+ };
157
+ const router = async (path, el, state = {}, options = {}) => {
158
+ const element = el || void 0;
159
+ const win = element?.context?.window || import_utils.window;
160
+ const doc = element?.context?.document || import_utils.document;
161
+ const opts = {
162
+ ...defaultOptions,
163
+ ...element.context.routerOptions,
164
+ ...options
165
+ };
166
+ lastLevel = opts.lastLevel;
167
+ const ref = element.__ref;
168
+ if (opts.contentElementKey !== "content" && opts.contentElementKey !== ref.contentElementKey || !ref.contentElementKey) {
169
+ ref.contentElementKey = opts.contentElementKey || "content";
170
+ }
171
+ const contentElementKey = ref.contentElementKey || opts.contentElementKey || "content";
172
+ const origin = win.location.origin !== "null" ? win.location.origin : "http://localhost";
173
+ path = normalizePath(path);
174
+ const urlObj = new win.URL(origin + path);
175
+ const { pathname, search, hash } = urlObj;
176
+ const query = parseQuery(search);
177
+ const rootNode = element.node;
178
+ const hashChanged = hash && hash !== win.location.hash.slice(1);
179
+ const pathChanged = pathname !== lastPathname;
180
+ lastPathname = pathname;
181
+ let route, content, params;
182
+ if (opts.useParamsMatching) {
183
+ const match = matchRoute(pathname, element.routes, opts.level);
184
+ route = match.routePath;
185
+ content = match.content;
186
+ params = match.params;
187
+ } else {
188
+ route = getActiveRoute(opts.level, pathname);
189
+ content = element.routes[route || "/"] || element.routes["/*"];
190
+ params = {};
191
+ }
192
+ const scrollNode = opts.scrollToNode ? rootNode : opts.scrollNode;
193
+ if (element.state?.root?.debugging) {
194
+ element.state.root.debugging = false;
195
+ return;
196
+ }
197
+ if (!content) {
198
+ if (opts.onNotFound) {
199
+ opts.onNotFound({ pathname, route, element });
200
+ } else if (!opts.silent) {
201
+ console.warn("[smbls/router] no content matched for path", pathname, "\u2014 available routes:", Object.keys(element.routes || {}));
202
+ }
203
+ return;
204
+ }
205
+ if (opts.guards && opts.guards.length) {
206
+ const guardContext = { pathname, route, params, query, hash, element, state };
207
+ const guardResult = await runGuards(opts.guards, guardContext);
208
+ if (guardResult === false) return;
209
+ if (typeof guardResult === "string") {
210
+ return router(guardResult, el, state, { ...options, guards: [] });
211
+ }
212
+ }
213
+ if (opts.pushState) {
214
+ try {
215
+ win.history.pushState(state, null, pathname + (search || "") + (hash || ""));
216
+ } catch (e) {
217
+ }
218
+ }
219
+ if (pathChanged || !hashChanged) {
220
+ const stateUpdate = { route, hash, debugging: false };
221
+ if (Object.keys(params).length) stateUpdate.params = params;
222
+ if (Object.keys(query).length) stateUpdate.query = query;
223
+ if (opts.updateState) {
224
+ element.state.update(
225
+ stateUpdate,
226
+ { preventContentUpdate: true }
227
+ );
228
+ }
229
+ if (contentElementKey && opts.removeOldElement) {
230
+ element[contentElementKey].remove();
231
+ }
232
+ const originContent = element.__ref?.origin?.content;
233
+ const contentStyles = {};
234
+ if (originContent) {
235
+ for (const k in originContent) {
236
+ const v = originContent[k];
237
+ if (k === "__ref" || k === "props" || k === "node" || k === "parent" || k === "key") continue;
238
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean" || typeof v === "object" && v !== null && !v.node && !v.__ref) {
239
+ contentStyles[k] = v;
240
+ }
241
+ }
242
+ }
243
+ const nextContent = {
244
+ ...contentStyles,
245
+ ...typeof content === "object" ? content : { extends: content }
246
+ };
247
+ if (opts.useFragment) nextContent.tag = "fragment";
248
+ try {
249
+ element.set(nextContent, { contentElementKey });
250
+ } catch (err) {
251
+ console.error("[smbls/router] failed to render route content", pathname, err);
252
+ }
253
+ }
254
+ if (opts.scrollToTop && scrollNode?.scrollTo) {
255
+ scrollNode.scrollTo({
256
+ ...opts.scrollToOptions || {},
257
+ top: 0,
258
+ left: 0
259
+ });
260
+ }
261
+ if (opts.scrollToNode && content[contentElementKey]?.node?.scrollTo) {
262
+ content[contentElementKey].node.scrollTo({
263
+ ...opts.scrollToOptions || {},
264
+ top: 0,
265
+ left: 0
266
+ });
267
+ }
268
+ if (hash) {
269
+ const activeNode = doc.getElementById(hash);
270
+ if (activeNode && scrollNode?.scrollTo) {
271
+ const top = activeNode.getBoundingClientRect().top + rootNode.scrollTop - (opts.scrollToOffset || 0);
272
+ scrollNode.scrollTo({
273
+ ...opts.scrollToOptions || {},
274
+ top,
275
+ left: 0
276
+ });
277
+ }
278
+ }
279
+ (0, import_utils.triggerEventOn)("routeChanged", element, opts);
280
+ };
281
+ var index_default = router;