zero-query 1.0.2 → 1.0.9

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/index.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Lightweight modern frontend library - jQuery-like selectors, reactive
5
5
  * components, SPA router, state management, HTTP client & utilities.
6
6
  *
7
- * @version 1.0.2
7
+ * @version 1.0.9
8
8
  * @license MIT
9
9
  * @see https://z-query.com/docs
10
10
  */
@@ -46,8 +46,10 @@ export {
46
46
  NavigationContext,
47
47
  RouterConfig,
48
48
  RouterInstance,
49
+ RouteMatch,
49
50
  createRouter,
50
51
  getRouter,
52
+ matchRoute,
51
53
  } from './types/router';
52
54
 
53
55
  export {
@@ -146,7 +148,7 @@ export {
146
148
  import type { ZQueryCollection } from './types/collection';
147
149
  import type { reactive, Signal, signal, computed, effect, batch, untracked } from './types/reactive';
148
150
  import type { component, mount, mountAll, getInstance, destroy, getRegistry, prefetch, style } from './types/component';
149
- import type { createRouter, getRouter } from './types/router';
151
+ import type { createRouter, getRouter, matchRoute } from './types/router';
150
152
  import type { createStore, getStore } from './types/store';
151
153
  import type { HttpClient } from './types/http';
152
154
  import type {
@@ -264,6 +266,7 @@ interface ZQueryStatic {
264
266
  // -- Router --------------------------------------------------------------
265
267
  router: typeof createRouter;
266
268
  getRouter: typeof getRouter;
269
+ matchRoute: typeof matchRoute;
267
270
 
268
271
  // -- Store ---------------------------------------------------------------
269
272
  store: typeof createStore;
package/index.js CHANGED
@@ -12,7 +12,7 @@
12
12
  import { query, queryAll, ZQueryCollection } from './src/core.js';
13
13
  import { reactive, Signal, signal, computed, effect, batch, untracked } from './src/reactive.js';
14
14
  import { component, mount, mountAll, getInstance, destroy, getRegistry, prefetch, style } from './src/component.js';
15
- import { createRouter, getRouter } from './src/router.js';
15
+ import { createRouter, getRouter, matchRoute } from './src/router.js';
16
16
  import { createStore, getStore } from './src/store.js';
17
17
  import { http } from './src/http.js';
18
18
  import { morph, morphElement } from './src/diff.js';
@@ -115,8 +115,9 @@ $.morphElement = morphElement;
115
115
  $.safeEval = safeEval;
116
116
 
117
117
  // --- Router ----------------------------------------------------------------
118
- $.router = createRouter;
119
- $.getRouter = getRouter;
118
+ $.router = createRouter;
119
+ $.getRouter = getRouter;
120
+ $.matchRoute = matchRoute;
120
121
 
121
122
  // --- Store -----------------------------------------------------------------
122
123
  $.store = createStore;
@@ -214,7 +215,7 @@ export {
214
215
  component, mount, mountAll, getInstance, destroy, getRegistry, prefetch, style,
215
216
  morph, morphElement,
216
217
  safeEval,
217
- createRouter, getRouter,
218
+ createRouter, getRouter, matchRoute,
218
219
  createStore, getStore,
219
220
  http,
220
221
  ZQueryError, ErrorCode, onError, reportError, guardCallback, guardAsync, validate, formatError,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zero-query",
3
- "version": "1.0.2",
3
+ "version": "1.0.9",
4
4
  "description": "Lightweight modern frontend library - jQuery-like selectors, reactive components, SPA router, and state management with zero dependencies.",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
@@ -64,6 +64,6 @@
64
64
  "devDependencies": {
65
65
  "jsdom": "^28.1.0",
66
66
  "vitest": "^4.0.18",
67
- "zero-http": "^0.2.3"
67
+ "zero-http": "^0.3.5"
68
68
  }
69
69
  }
package/src/router.js CHANGED
@@ -195,24 +195,15 @@ class Router {
195
195
 
196
196
  add(route) {
197
197
  // Compile path pattern into regex
198
- const keys = [];
199
- const pattern = route.path
200
- .replace(/:(\w+)/g, (_, key) => { keys.push(key); return '([^/]+)'; })
201
- .replace(/\*/g, '(.*)');
202
- const regex = new RegExp(`^${pattern}$`);
203
-
198
+ const { regex, keys } = compilePath(route.path);
204
199
  this._routes.push({ ...route, _regex: regex, _keys: keys });
205
200
 
206
201
  // Per-route fallback: register an alias path for the same component.
207
202
  // e.g. { path: '/docs/:section', fallback: '/docs', component: 'docs-page' }
208
203
  // When matched via fallback, missing params are undefined.
209
204
  if (route.fallback) {
210
- const fbKeys = [];
211
- const fbPattern = route.fallback
212
- .replace(/:(\w+)/g, (_, key) => { fbKeys.push(key); return '([^/]+)'; })
213
- .replace(/\*/g, '(.*)');
214
- const fbRegex = new RegExp(`^${fbPattern}$`);
215
- this._routes.push({ ...route, path: route.fallback, _regex: fbRegex, _keys: fbKeys });
205
+ const fb = compilePath(route.fallback);
206
+ this._routes.push({ ...route, path: route.fallback, _regex: fb.regex, _keys: fb.keys });
216
207
  }
217
208
 
218
209
  return this;
@@ -709,6 +700,64 @@ class Router {
709
700
  }
710
701
 
711
702
 
703
+ // ---------------------------------------------------------------------------
704
+ // Path compilation (shared by Router.add and matchRoute)
705
+ // ---------------------------------------------------------------------------
706
+
707
+ /**
708
+ * Compile a route path pattern into a RegExp and param key list.
709
+ * Supports `:param` segments and `*` wildcard.
710
+ * @param {string} path - e.g. '/user/:id' or '/files/*'
711
+ * @returns {{ regex: RegExp, keys: string[] }}
712
+ */
713
+ function compilePath(path) {
714
+ const keys = [];
715
+ const pattern = path
716
+ .replace(/:(\w+)/g, (_, key) => { keys.push(key); return '([^/]+)'; })
717
+ .replace(/\*/g, '(.*)');
718
+ return { regex: new RegExp(`^${pattern}$`), keys };
719
+ }
720
+
721
+ // ---------------------------------------------------------------------------
722
+ // Standalone route matcher (DOM-free — usable on server and client)
723
+ // ---------------------------------------------------------------------------
724
+
725
+ /**
726
+ * Match a pathname against an array of route definitions.
727
+ * Returns `{ component, params }`. If no route matches, falls back to the
728
+ * `fallback` component name (default `'not-found'`).
729
+ *
730
+ * This is the same matching logic the client-side router uses internally,
731
+ * extracted so SSR servers can resolve URLs without the DOM.
732
+ *
733
+ * @param {Array<{ path: string, component: string, fallback?: string }>} routes
734
+ * @param {string} pathname - URL path to match, e.g. '/blog/my-post'
735
+ * @param {string} [fallback='not-found'] - Component name when nothing matches
736
+ * @returns {{ component: string, params: Record<string, string> }}
737
+ */
738
+ export function matchRoute(routes, pathname, fallback = 'not-found') {
739
+ for (const route of routes) {
740
+ const { regex, keys } = compilePath(route.path);
741
+ const m = pathname.match(regex);
742
+ if (m) {
743
+ const params = {};
744
+ keys.forEach((key, i) => { params[key] = m[i + 1]; });
745
+ return { component: route.component, params };
746
+ }
747
+ // Per-route fallback alias (same as Router.add)
748
+ if (route.fallback) {
749
+ const fb = compilePath(route.fallback);
750
+ const fbm = pathname.match(fb.regex);
751
+ if (fbm) {
752
+ const params = {};
753
+ fb.keys.forEach((key, i) => { params[key] = fbm[i + 1]; });
754
+ return { component: route.component, params };
755
+ }
756
+ }
757
+ }
758
+ return { component: fallback, params: {} };
759
+ }
760
+
712
761
  // ---------------------------------------------------------------------------
713
762
  // Factory
714
763
  // ---------------------------------------------------------------------------
package/src/ssr.js CHANGED
@@ -280,6 +280,103 @@ class SSRApp {
280
280
  </body>
281
281
  </html>`;
282
282
  }
283
+
284
+ /**
285
+ * Render a component into an existing HTML shell template.
286
+ *
287
+ * Unlike renderPage() which generates a full HTML document from scratch,
288
+ * renderShell() takes your own index.html (with nav, footer, custom markup)
289
+ * and injects the SSR-rendered component body plus metadata into it.
290
+ *
291
+ * Handles:
292
+ * - Component rendering into <z-outlet>
293
+ * - <title> replacement
294
+ * - <meta name="description"> replacement
295
+ * - Open Graph meta tag replacement (og:title, og:description, og:type, etc.)
296
+ * - window.__SSR_DATA__ hydration script injection
297
+ *
298
+ * @param {string} shell - HTML template string (your index.html)
299
+ * @param {object} options
300
+ * @param {string} options.component - registered component name to render
301
+ * @param {object} [options.props] - props passed to the component
302
+ * @param {string} [options.title] - page title (replaces <title>)
303
+ * @param {string} [options.description] - meta description (replaces <meta name="description">)
304
+ * @param {object} [options.og] - Open Graph tags to replace (e.g. { title, description, type, image })
305
+ * @param {any} [options.ssrData] - data to embed as window.__SSR_DATA__ for client hydration
306
+ * @param {object} [options.renderOptions] - options passed to renderToString (hydrate, mode)
307
+ * @returns {Promise<string>} - the shell with SSR content and metadata injected
308
+ */
309
+ async renderShell(shell, options = {}) {
310
+ const {
311
+ component: comp,
312
+ props = {},
313
+ title,
314
+ description,
315
+ og,
316
+ ssrData,
317
+ renderOptions,
318
+ } = options;
319
+
320
+ // Render the component
321
+ let body = '';
322
+ if (comp) {
323
+ try {
324
+ body = await this.renderToString(comp, props, renderOptions);
325
+ } catch (err) {
326
+ reportError(ErrorCode.SSR_PAGE, `renderShell failed for component "${comp}"`, { component: comp }, err);
327
+ body = `<!-- SSR error: ${_escapeHtml(err.message)} -->`;
328
+ }
329
+ }
330
+
331
+ let html = shell;
332
+
333
+ // Inject SSR body into <z-outlet>
334
+ // Use a replacer function to avoid $ substitution patterns in body
335
+ html = html.replace(/(<z-outlet[^>]*>)([\s\S]*?)(<\/z-outlet>)?/, (_, open) => `${open}${body}</z-outlet>`);
336
+
337
+ // Replace <title>
338
+ if (title != null) {
339
+ const safeTitle = _escapeHtml(title);
340
+ html = html.replace(/<title>[^<]*<\/title>/, () => `<title>${safeTitle}</title>`);
341
+ }
342
+
343
+ // Replace <meta name="description">
344
+ if (description != null) {
345
+ const safeDesc = _escapeHtml(description);
346
+ html = html.replace(
347
+ /<meta\s+name="description"\s+content="[^"]*">/,
348
+ () => `<meta name="description" content="${safeDesc}">`
349
+ );
350
+ }
351
+
352
+ // Replace Open Graph meta tags
353
+ if (og) {
354
+ for (const [key, value] of Object.entries(og)) {
355
+ // Sanitize key: allow only safe OG property characters (alphanumeric, hyphens, underscores, colons)
356
+ const safeKey = key.replace(/[^a-zA-Z0-9_:\-]/g, '');
357
+ if (!safeKey) continue;
358
+ const escaped = _escapeHtml(String(value));
359
+ // Escape key for use in RegExp to prevent ReDoS
360
+ const escapedKey = safeKey.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
361
+ const pattern = new RegExp(`<meta\\s+property="og:${escapedKey}"\\s+content="[^"]*">`);
362
+ if (pattern.test(html)) {
363
+ html = html.replace(pattern, () => `<meta property="og:${safeKey}" content="${escaped}">`);
364
+ } else {
365
+ // Tag doesn't exist — inject before </head>
366
+ html = html.replace('</head>', () => `<meta property="og:${safeKey}" content="${escaped}">\n</head>`);
367
+ }
368
+ }
369
+ }
370
+
371
+ // Inject hydration data as window.__SSR_DATA__
372
+ if (ssrData !== undefined) {
373
+ // Escape </script> and <!-- sequences to prevent breaking out of the script tag
374
+ const json = JSON.stringify(ssrData).replace(/<\//g, '<\\/').replace(/<!--/g, '<\\!--');
375
+ html = html.replace('</head>', () => `<script>window.__SSR_DATA__=${json};</script>\n</head>`);
376
+ }
377
+
378
+ return html;
379
+ }
283
380
  }
284
381
 
285
382
  // ---------------------------------------------------------------------------
@@ -316,3 +413,6 @@ export function renderToString(definition, props = {}) {
316
413
  export function escapeHtml(str) {
317
414
  return _escapeHtml(String(str));
318
415
  }
416
+
417
+ // Re-export matchRoute so SSR servers can import from 'zero-query/ssr'
418
+ export { matchRoute } from './router.js';
package/tests/cli.test.js CHANGED
@@ -1,6 +1,269 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
 
3
3
 
4
+ // ---------------------------------------------------------------------------
5
+ // CLI bundle - stripModuleSyntax
6
+ // ---------------------------------------------------------------------------
7
+
8
+ describe('CLI - stripModuleSyntax', () => {
9
+ let stripModuleSyntax;
10
+
11
+ beforeEach(async () => {
12
+ vi.resetModules();
13
+ const mod = await import('../cli/commands/bundle.js');
14
+ stripModuleSyntax = mod.stripModuleSyntax;
15
+ });
16
+
17
+ // -- Import stripping ---------------------------------------------------
18
+
19
+ it('strips named import from module', () => {
20
+ const result = stripModuleSyntax("import { foo } from './mod.js';\nconst x = 1;");
21
+ expect(result.code.trim()).toBe('const x = 1;');
22
+ });
23
+
24
+ it('strips default import from module', () => {
25
+ const result = stripModuleSyntax("import foo from './mod.js';\nconst x = 1;");
26
+ expect(result.code.trim()).toBe('const x = 1;');
27
+ });
28
+
29
+ it('strips side-effect import', () => {
30
+ const result = stripModuleSyntax("import './mod.js';\nconst x = 1;");
31
+ expect(result.code.trim()).toBe('const x = 1;');
32
+ });
33
+
34
+ it('strips multi-line import', () => {
35
+ const input = "import {\n a,\n b,\n c\n} from './mod.js';\nconst x = 1;";
36
+ const result = stripModuleSyntax(input);
37
+ expect(result.code.trim()).toBe('const x = 1;');
38
+ });
39
+
40
+ // -- export default -----------------------------------------------------
41
+
42
+ it('strips export default keyword', () => {
43
+ const result = stripModuleSyntax('export default function foo() {}');
44
+ expect(result.code.trim()).toBe('function foo() {}');
45
+ });
46
+
47
+ // -- export const/let/var → var -----------------------------------------
48
+
49
+ it('converts export const to var', () => {
50
+ const result = stripModuleSyntax('export const x = 1;');
51
+ expect(result.code.trim()).toBe('var x = 1;');
52
+ });
53
+
54
+ it('converts export let to var', () => {
55
+ const result = stripModuleSyntax('export let y = 2;');
56
+ expect(result.code.trim()).toBe('var y = 2;');
57
+ });
58
+
59
+ it('converts export var (keeps var)', () => {
60
+ const result = stripModuleSyntax('export var z = 3;');
61
+ expect(result.code.trim()).toBe('var z = 3;');
62
+ });
63
+
64
+ // -- export function → var assignment -----------------------------------
65
+
66
+ it('converts export function to var assignment', () => {
67
+ const result = stripModuleSyntax('export function greet(name) { return name; }');
68
+ expect(result.code.trim()).toBe('var greet = function greet(name) { return name; }');
69
+ });
70
+
71
+ it('converts export async function to var assignment', () => {
72
+ const result = stripModuleSyntax('export async function fetchData() {}');
73
+ expect(result.code.trim()).toBe('var fetchData = async function fetchData() {}');
74
+ });
75
+
76
+ // -- export class → var assignment --------------------------------------
77
+
78
+ it('converts export class to var assignment', () => {
79
+ const result = stripModuleSyntax('export class MyComponent {}');
80
+ expect(result.code.trim()).toBe('var MyComponent = class MyComponent {}');
81
+ });
82
+
83
+ // -- bare export block: export { a, b } ---------------------------------
84
+
85
+ it('converts bare exported function declarations to var', () => {
86
+ const input = 'function buildIndex() {}\nexport { buildIndex };';
87
+ const result = stripModuleSyntax(input);
88
+ expect(result.code).toContain('var buildIndex = function buildIndex()');
89
+ expect(result.code).not.toContain('export');
90
+ expect(result.bareExportNames).toEqual([{ local: 'buildIndex', exported: 'buildIndex' }]);
91
+ });
92
+
93
+ it('converts bare exported async function to var', () => {
94
+ const input = 'async function load() {}\nexport { load };';
95
+ const result = stripModuleSyntax(input);
96
+ expect(result.code).toContain('var load = async function load()');
97
+ });
98
+
99
+ it('converts bare exported const/let declarations to var', () => {
100
+ const input = 'const MAX = 10;\nlet count = 0;\nexport { MAX, count };';
101
+ const result = stripModuleSyntax(input);
102
+ expect(result.code).toContain('var MAX');
103
+ expect(result.code).toContain('var count');
104
+ expect(result.code).not.toContain('const MAX');
105
+ expect(result.code).not.toContain('let count');
106
+ });
107
+
108
+ it('handles multi-line bare export block', () => {
109
+ const input = 'function a() {}\nfunction b() {}\nexport {\n a,\n b\n};';
110
+ const result = stripModuleSyntax(input);
111
+ expect(result.code).toContain('var a = function a()');
112
+ expect(result.code).toContain('var b = function b()');
113
+ expect(result.bareExportNames).toHaveLength(2);
114
+ });
115
+
116
+ // -- export { local as exported } aliasing ------------------------------
117
+
118
+ it('creates alias for export { local as exported }', () => {
119
+ const input = 'function foo() {}\nexport { foo as bar };';
120
+ const result = stripModuleSyntax(input);
121
+ expect(result.code).toContain('var foo = function foo()');
122
+ expect(result.code).toContain('var bar = foo;');
123
+ expect(result.bareExportNames).toEqual([{ local: 'foo', exported: 'bar' }]);
124
+ });
125
+
126
+ it('creates multiple aliases when needed', () => {
127
+ const input = 'function a() {}\nfunction b() {}\nexport { a as x, b as y };';
128
+ const result = stripModuleSyntax(input);
129
+ expect(result.code).toContain('var x = a;');
130
+ expect(result.code).toContain('var y = b;');
131
+ });
132
+
133
+ it('does not create alias when local equals exported', () => {
134
+ const input = 'function foo() {}\nexport { foo };';
135
+ const result = stripModuleSyntax(input);
136
+ expect(result.code).not.toContain('var foo = foo;');
137
+ });
138
+
139
+ it('handles mix of aliased and non-aliased exports', () => {
140
+ const input = 'function a() {}\nfunction b() {}\nexport { a, b as c };';
141
+ const result = stripModuleSyntax(input);
142
+ expect(result.code).toContain('var a = function a()');
143
+ expect(result.code).toContain('var b = function b()');
144
+ expect(result.code).not.toContain('var a = a;');
145
+ expect(result.code).toContain('var c = b;');
146
+ });
147
+
148
+ // -- Template literal preservation --------------------------------------
149
+
150
+ it('preserves export keyword inside template literal', () => {
151
+ const input = "const example = `export const x = 1;`;\nexport const y = 2;";
152
+ const result = stripModuleSyntax(input);
153
+ expect(result.code).toContain('`export const x = 1;`');
154
+ expect(result.code).toContain('var y = 2;');
155
+ });
156
+
157
+ it('preserves export class inside template literal', () => {
158
+ const input = "const code = `export class Counter {}`;\nexport class Real {}";
159
+ const result = stripModuleSyntax(input);
160
+ expect(result.code).toContain('`export class Counter {}`');
161
+ expect(result.code).toContain('var Real = class Real');
162
+ });
163
+
164
+ it('preserves nested template literal content', () => {
165
+ const input = "const x = `outer ${`inner export const a = 1;`} end`;\nexport const b = 2;";
166
+ const result = stripModuleSyntax(input);
167
+ expect(result.code).toContain('inner export const a = 1;');
168
+ expect(result.code).toContain('var b = 2;');
169
+ });
170
+
171
+ it('handles deeply nested template literals', () => {
172
+ const input = "const x = `a ${y ? `b ${`c`}` : ''} d`;\nexport const z = 1;";
173
+ const result = stripModuleSyntax(input);
174
+ expect(result.code).toContain('var z = 1;');
175
+ // Nested templates should be preserved without corruption
176
+ expect(result.code).toContain('`a ${');
177
+ });
178
+
179
+ // -- Edge cases ---------------------------------------------------------
180
+
181
+ it('returns empty bareExportNames when no bare exports', () => {
182
+ const result = stripModuleSyntax('export const x = 1;');
183
+ expect(result.bareExportNames).toEqual([]);
184
+ });
185
+
186
+ it('handles code with no imports or exports', () => {
187
+ const input = 'const x = 1;\nfunction foo() {}';
188
+ const result = stripModuleSyntax(input);
189
+ expect(result.code.trim()).toBe(input);
190
+ expect(result.bareExportNames).toEqual([]);
191
+ });
192
+
193
+ it('preserves indentation', () => {
194
+ const result = stripModuleSyntax(' export const x = 1;');
195
+ expect(result.code).toContain(' var x = 1;');
196
+ });
197
+
198
+ it('handles export inside string (not template)', () => {
199
+ const input = 'const s = "export const x = 1;";\nexport const y = 2;';
200
+ const result = stripModuleSyntax(input);
201
+ expect(result.code).toContain('"export const x = 1;"');
202
+ expect(result.code).toContain('var y = 2;');
203
+ });
204
+ });
205
+
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // CLI - minify ASI preservation
209
+ // ---------------------------------------------------------------------------
210
+
211
+ describe('CLI - minify ASI preservation', () => {
212
+ let minify;
213
+
214
+ beforeEach(async () => {
215
+ vi.resetModules();
216
+ const mod = await import('../cli/utils.js');
217
+ minify = mod.minify;
218
+ });
219
+
220
+ it('preserves newline between } and var', () => {
221
+ const input = 'var x = function() {}\nvar y = 1;';
222
+ const result = minify(input, '');
223
+ expect(result).toContain('}\nvar');
224
+ expect(result).not.toContain('}var');
225
+ });
226
+
227
+ it('preserves newline between } and identifier', () => {
228
+ const input = 'function a() {}\nfunction b() {}';
229
+ const result = minify(input, '');
230
+ expect(result).toContain('}\nfunction');
231
+ expect(result).not.toContain('}function');
232
+ });
233
+
234
+ it('preserves newline between } and const', () => {
235
+ const input = 'if (true) {}\nconst x = 1;';
236
+ const result = minify(input, '');
237
+ expect(result).toContain('}\nconst');
238
+ });
239
+
240
+ it('preserves newline between } and let', () => {
241
+ const input = 'if (true) {}\nlet x = 1;';
242
+ const result = minify(input, '');
243
+ expect(result).toContain('}\nlet');
244
+ });
245
+
246
+ it('does not add newline where none existed', () => {
247
+ const input = 'if(x){a()} else{b()}';
248
+ const result = minify(input, '');
249
+ // } followed by else on same line — no newline needed
250
+ expect(result).not.toContain('}\n');
251
+ });
252
+
253
+ it('handles }\\n followed by underscore-prefixed identifier', () => {
254
+ const input = 'var x = function() {}\n_init();';
255
+ const result = minify(input, '');
256
+ expect(result).toContain('}\n_init');
257
+ });
258
+
259
+ it('handles }\\n followed by $-prefixed identifier', () => {
260
+ const input = 'var x = function() {}\n$el.show();';
261
+ const result = minify(input, '');
262
+ expect(result).toContain('}\n$el');
263
+ });
264
+ });
265
+
266
+
4
267
  // ---------------------------------------------------------------------------
5
268
  // CLI utils - stripComments
6
269
  // ---------------------------------------------------------------------------
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
- import { createRouter, getRouter } from '../src/router.js';
2
+ import { createRouter, getRouter, matchRoute } from '../src/router.js';
3
3
  import { component } from '../src/component.js';
4
4
 
5
5
  // Register stub components used in route definitions so mount() doesn't throw
@@ -809,6 +809,70 @@ describe('Router - navigation chaining', () => {
809
809
  });
810
810
 
811
811
 
812
+ // ---------------------------------------------------------------------------
813
+ // matchRoute - standalone route matcher (DOM-free)
814
+ // ---------------------------------------------------------------------------
815
+
816
+ describe('matchRoute', () => {
817
+ const routes = [
818
+ { path: '/', component: 'home-page' },
819
+ { path: '/blog', component: 'blog-list' },
820
+ { path: '/blog/:slug', component: 'blog-post' },
821
+ { path: '/user/:id', component: 'user-page' },
822
+ { path: '/files/*', component: 'file-browser' },
823
+ ];
824
+
825
+ it('matches a static route', () => {
826
+ expect(matchRoute(routes, '/')).toEqual({ component: 'home-page', params: {} });
827
+ expect(matchRoute(routes, '/blog')).toEqual({ component: 'blog-list', params: {} });
828
+ });
829
+
830
+ it('matches a parameterized route', () => {
831
+ expect(matchRoute(routes, '/blog/hello-world')).toEqual({
832
+ component: 'blog-post',
833
+ params: { slug: 'hello-world' },
834
+ });
835
+ });
836
+
837
+ it('matches multiple params', () => {
838
+ const r = [{ path: '/user/:id/post/:pid', component: 'user-post' }];
839
+ expect(matchRoute(r, '/user/42/post/7')).toEqual({
840
+ component: 'user-post',
841
+ params: { id: '42', pid: '7' },
842
+ });
843
+ });
844
+
845
+ it('matches wildcard routes', () => {
846
+ const result = matchRoute(routes, '/files/docs/readme.md');
847
+ expect(result.component).toBe('file-browser');
848
+ });
849
+
850
+ it('returns fallback when nothing matches', () => {
851
+ expect(matchRoute(routes, '/nope')).toEqual({ component: 'not-found', params: {} });
852
+ });
853
+
854
+ it('accepts a custom fallback component name', () => {
855
+ expect(matchRoute(routes, '/nope', '404-page')).toEqual({ component: '404-page', params: {} });
856
+ });
857
+
858
+ it('matches first route when multiple could match', () => {
859
+ const r = [
860
+ { path: '/a', component: 'first' },
861
+ { path: '/a', component: 'second' },
862
+ ];
863
+ expect(matchRoute(r, '/a').component).toBe('first');
864
+ });
865
+
866
+ it('handles per-route fallback aliases', () => {
867
+ const r = [
868
+ { path: '/docs/:section', component: 'docs-page', fallback: '/docs' },
869
+ ];
870
+ expect(matchRoute(r, '/docs/intro')).toEqual({ component: 'docs-page', params: { section: 'intro' } });
871
+ expect(matchRoute(r, '/docs')).toEqual({ component: 'docs-page', params: {} });
872
+ });
873
+ });
874
+
875
+
812
876
  // ---------------------------------------------------------------------------
813
877
  // Hash mode path parsing
814
878
  // ---------------------------------------------------------------------------