diff --git a/package-lock.json b/package-lock.json
index c5f7246b..a6161e9d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -33,7 +33,6 @@
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.8.0",
"@stencil/core": "^2.6.0",
- "@stencil/router": "^1.0.1",
"@stencil/sass": "^1.4.1",
"@types/jest": "25.2.3",
"@types/prismjs": "^1.16.5",
@@ -1710,15 +1709,6 @@
"npm": ">=6.0.0"
}
},
- "node_modules/@stencil/router": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@stencil/router/-/router-1.0.1.tgz",
- "integrity": "sha512-ZMholl1BE+glNAc/8pcEb9RYkeH0XETxsJbx6D7f3azTmaTXqKYty1IACP/3BtVjuimpfLdxQJ+J95wKmnYBtA==",
- "dev": true,
- "dependencies": {
- "@stencil/state-tunnel": "^1.0.1"
- }
- },
"node_modules/@stencil/sass": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@stencil/sass/-/sass-1.4.1.tgz",
@@ -1728,12 +1718,6 @@
"@stencil/core": ">=1.0.2"
}
},
- "node_modules/@stencil/state-tunnel": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@stencil/state-tunnel/-/state-tunnel-1.0.1.tgz",
- "integrity": "sha512-DYG8uROgL9hkjVTCtCfRBb0d3FwpiFB0muRrNZQ2X1Qo5hxMuNNji76/ILddqeq0AfgkKCW82xrMPDpy+rNIhQ==",
- "dev": true
- },
"node_modules/@tootallnate/once": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
@@ -16340,15 +16324,6 @@
"integrity": "sha512-QsxWayZyusnqSZrlCl81R71rA3KqFjVVQSH4E0rGN15F1GdQaFonKlHLyCOLKLig1zzC+DQkLLiUuocexuvdeQ==",
"dev": true
},
- "@stencil/router": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@stencil/router/-/router-1.0.1.tgz",
- "integrity": "sha512-ZMholl1BE+glNAc/8pcEb9RYkeH0XETxsJbx6D7f3azTmaTXqKYty1IACP/3BtVjuimpfLdxQJ+J95wKmnYBtA==",
- "dev": true,
- "requires": {
- "@stencil/state-tunnel": "^1.0.1"
- }
- },
"@stencil/sass": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@stencil/sass/-/sass-1.4.1.tgz",
@@ -16356,12 +16331,6 @@
"dev": true,
"requires": {}
},
- "@stencil/state-tunnel": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/@stencil/state-tunnel/-/state-tunnel-1.0.1.tgz",
- "integrity": "sha512-DYG8uROgL9hkjVTCtCfRBb0d3FwpiFB0muRrNZQ2X1Qo5hxMuNNji76/ILddqeq0AfgkKCW82xrMPDpy+rNIhQ==",
- "dev": true
- },
"@tootallnate/once": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
diff --git a/package.json b/package.json
index a8f0c14a..8718a3be 100644
--- a/package.json
+++ b/package.json
@@ -60,7 +60,6 @@
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.8.0",
"@stencil/core": "^2.6.0",
- "@stencil/router": "^1.0.1",
"@stencil/sass": "^1.4.1",
"@types/jest": "25.2.3",
"@types/prismjs": "^1.16.5",
diff --git a/src/components/app/app.tsx b/src/components/app/app.tsx
index 20ec527b..2fa71d14 100644
--- a/src/components/app/app.tsx
+++ b/src/components/app/app.tsx
@@ -104,17 +104,16 @@ export class App {
index={this.index}
/>
-
-
-
+
+
-
-
-
-
-
-
+
+
);
diff --git a/src/components/component/component.tsx b/src/components/component/component.tsx
index 79a20a31..61af9c37 100644
--- a/src/components/component/component.tsx
+++ b/src/components/component/component.tsx
@@ -4,7 +4,7 @@ import {
JsonDocsComponent,
JsonDocsTag,
} from '@stencil/core/internal';
-import { MatchResults } from '@stencil/router';
+import { MatchResults } from '../router/route-matching';
import { PropertyList } from './templates/props';
import { EventList } from './templates/events';
import { MethodList } from './templates/methods';
diff --git a/src/components/debug/debug.tsx b/src/components/debug/debug.tsx
index 060bbffd..246feda4 100644
--- a/src/components/debug/debug.tsx
+++ b/src/components/debug/debug.tsx
@@ -4,7 +4,7 @@ import {
JsonDocsComponent,
JsonDocsTag,
} from '@stencil/core/internal';
-import { MatchResults } from '@stencil/router';
+import { MatchResults } from '../router/route-matching';
import { PropsFactory } from '../playground/playground.types';
@Component({
diff --git a/src/components/router/component-key.spec.ts b/src/components/router/component-key.spec.ts
new file mode 100644
index 00000000..d5d3c986
--- /dev/null
+++ b/src/components/router/component-key.spec.ts
@@ -0,0 +1,129 @@
+import { generateComponentKey } from './component-key';
+
+describe('component-key', () => {
+ describe('generateComponentKey()', () => {
+ it('returns empty string for empty params', () => {
+ const key = generateComponentKey({});
+
+ expect(key).toBe('');
+ });
+
+ it('generates key for single parameter', () => {
+ const key = generateComponentKey({ id: '123' });
+
+ expect(key).toBe('id=123');
+ });
+
+ it('generates key for multiple parameters', () => {
+ const key = generateComponentKey({ id: '123', name: 'test' });
+
+ expect(key).toBe('id=123&name=test');
+ });
+
+ it('sorts parameters alphabetically', () => {
+ const key = generateComponentKey({
+ zebra: 'z',
+ apple: 'a',
+ banana: 'b',
+ });
+
+ expect(key).toBe('apple=a&banana=b&zebra=z');
+ });
+
+ it('generates deterministic keys (same input produces same output)', () => {
+ const params = { foo: 'bar', baz: 'qux' };
+
+ const key1 = generateComponentKey(params);
+ const key2 = generateComponentKey(params);
+
+ expect(key1).toBe(key2);
+ });
+
+ it('generates deterministic keys regardless of insertion order', () => {
+ const params1 = { a: '1', b: '2', c: '3' };
+ const params2 = { c: '3', a: '1', b: '2' };
+
+ const key1 = generateComponentKey(params1);
+ const key2 = generateComponentKey(params2);
+
+ expect(key1).toBe(key2);
+ expect(key1).toBe('a=1&b=2&c=3');
+ });
+
+ it('generates different keys for different parameter values', () => {
+ const key1 = generateComponentKey({ id: '123' });
+ const key2 = generateComponentKey({ id: '456' });
+
+ expect(key1).not.toBe(key2);
+ });
+
+ it('generates different keys for different parameter names', () => {
+ const key1 = generateComponentKey({ id: '123' });
+ const key2 = generateComponentKey({ name: '123' });
+
+ expect(key1).not.toBe(key2);
+ });
+
+ it('handles empty parameter values', () => {
+ const key = generateComponentKey({ id: '', name: 'test' });
+
+ expect(key).toBe('id=&name=test');
+ });
+
+ it('handles parameter values with special characters', () => {
+ const key = generateComponentKey({ id: 'user-123_test' });
+
+ expect(key).toBe('id=user-123_test');
+ });
+
+ it('handles parameter values with hyphens', () => {
+ const key = generateComponentKey({ name: 'my-component' });
+
+ expect(key).toBe('name=my-component');
+ });
+
+ it('handles parameter values with spaces', () => {
+ const key = generateComponentKey({ query: 'hello world' });
+
+ expect(key).toBe('query=hello world');
+ });
+
+ it('handles parameter values with URL-like characters', () => {
+ const key = generateComponentKey({ path: '/foo/bar' });
+
+ expect(key).toBe('path=/foo/bar');
+ });
+
+ it('handles numeric string values', () => {
+ const key = generateComponentKey({ userId: '123', postId: '456' });
+
+ expect(key).toBe('postId=456&userId=123');
+ });
+
+ it('handles multiple parameters with empty values', () => {
+ const key = generateComponentKey({ a: '', b: '', c: 'value' });
+
+ expect(key).toBe('a=&b=&c=value');
+ });
+
+ it('generates key compatible with query string format', () => {
+ const key = generateComponentKey({
+ name: 'kompendium-app',
+ section: 'properties',
+ });
+
+ expect(key).toBe('name=kompendium-app§ion=properties');
+ expect(key).toContain('&');
+ expect(key.split('&').length).toBe(2);
+ });
+
+ it('handles real-world route parameters', () => {
+ const key = generateComponentKey({
+ name: 'kompendium-component',
+ section: 'methods',
+ });
+
+ expect(key).toBe('name=kompendium-component§ion=methods');
+ });
+ });
+});
diff --git a/src/components/router/component-key.ts b/src/components/router/component-key.ts
new file mode 100644
index 00000000..bd7db92f
--- /dev/null
+++ b/src/components/router/component-key.ts
@@ -0,0 +1,13 @@
+/**
+ * Generate a stable key from route parameters for component recreation
+ * Keys are deterministic - same params always produce same key
+ * Used to force Stencil component recreation when route params change
+ * @param {Record} params - Route parameters
+ * @returns {string} A stable, deterministic key
+ */
+export function generateComponentKey(params: Record): string {
+ return Object.keys(params)
+ .sort()
+ .map((k) => `${k}=${params[k]}`)
+ .join('&');
+}
diff --git a/src/components/router/kompendium-route-switch.tsx b/src/components/router/kompendium-route-switch.tsx
new file mode 100644
index 00000000..35f724d3
--- /dev/null
+++ b/src/components/router/kompendium-route-switch.tsx
@@ -0,0 +1,48 @@
+import { Component, h, Prop, State } from '@stencil/core';
+import { getHashPath } from './route-matching';
+
+/**
+ * Custom route switch component for Kompendium
+ * Manages navigation state and passes current path to child routes
+ */
+@Component({
+ tag: 'kompendium-route-switch',
+ shadow: false,
+})
+export class KompendiumRouteSwitch {
+ @Prop()
+ public scrollTopOffset?: number = 0;
+
+ @State()
+ private currentPath: string = '/';
+
+ constructor() {
+ this.handleHashChange = this.handleHashChange.bind(this);
+ }
+
+ connectedCallback(): void {
+ window.addEventListener('hashchange', this.handleHashChange);
+ this.handleHashChange();
+ }
+
+ disconnectedCallback(): void {
+ window.removeEventListener('hashchange', this.handleHashChange);
+ }
+
+ private handleHashChange(): void {
+ const newPath = getHashPath();
+ if (newPath !== this.currentPath) {
+ this.currentPath = newPath;
+ if (this.scrollTopOffset !== undefined) {
+ window.scrollTo(0, this.scrollTopOffset);
+ }
+ }
+ }
+
+ render() {
+ // Simply render child routes
+ // The @State currentPath will trigger re-render when hash changes
+ // Each route component will re-render and check if it matches
+ return ;
+ }
+}
diff --git a/src/components/router/kompendium-route.tsx b/src/components/router/kompendium-route.tsx
new file mode 100644
index 00000000..a5efefb6
--- /dev/null
+++ b/src/components/router/kompendium-route.tsx
@@ -0,0 +1,88 @@
+import { Component, h, Prop, Element, State } from '@stencil/core';
+import { getHashPath, matchRoute, MatchResults } from './route-matching';
+import { hasPreviousMatchingSibling } from './route-switch-logic';
+import { generateComponentKey } from './component-key';
+
+/**
+ * Custom route component for Kompendium
+ * Renders a component when the route matches
+ */
+@Component({
+ tag: 'kompendium-route',
+ shadow: false,
+})
+export class KompendiumRoute {
+ @Element()
+ private el: HTMLElement;
+
+ @State()
+ private currentPath: string = '/';
+
+ @Prop()
+ public url?: string;
+
+ @Prop()
+ public component?: string;
+
+ @Prop()
+ public componentProps?: Record;
+
+ @Prop()
+ public routeRender?: (props: { match: MatchResults }) => any;
+
+ constructor() {
+ this.handleHashChange = this.handleHashChange.bind(this);
+ }
+
+ connectedCallback(): void {
+ window.addEventListener('hashchange', this.handleHashChange);
+ this.handleHashChange();
+ }
+
+ disconnectedCallback(): void {
+ window.removeEventListener('hashchange', this.handleHashChange);
+ }
+
+ private handleHashChange(): void {
+ this.currentPath = getHashPath();
+ }
+
+ render() {
+ // Check if a previous sibling route matches (first-match wins)
+ if (hasPreviousMatchingSibling(this.el, this.currentPath)) {
+ return null;
+ }
+
+ // Check if this route matches
+ let match: MatchResults | null;
+ if (this.url) {
+ match = matchRoute(this.currentPath, this.url);
+ } else {
+ match = { params: {} }; // Catch-all route
+ }
+
+ if (!match) {
+ return null;
+ }
+
+ // Render the matched route
+ if (this.routeRender) {
+ return this.routeRender({ match: match });
+ }
+
+ if (this.component) {
+ const props = {
+ ...this.componentProps,
+ match: match,
+ };
+
+ // Create element dynamically using h() with string tag name
+ // Use match params as key to force recreation when params change
+ const key = generateComponentKey(match.params);
+
+ return h(this.component, { key: key, ...props });
+ }
+
+ return ;
+ }
+}
diff --git a/src/components/router/kompendium-router.tsx b/src/components/router/kompendium-router.tsx
new file mode 100644
index 00000000..5f24b4ff
--- /dev/null
+++ b/src/components/router/kompendium-router.tsx
@@ -0,0 +1,15 @@
+import { Component, h } from '@stencil/core';
+
+/**
+ * Custom router component for Kompendium
+ * Manages routing state using hash-based navigation
+ */
+@Component({
+ tag: 'kompendium-router',
+ shadow: false,
+})
+export class KompendiumRouter {
+ render() {
+ return ;
+ }
+}
diff --git a/src/components/router/route-matching.spec.ts b/src/components/router/route-matching.spec.ts
new file mode 100644
index 00000000..953a4f32
--- /dev/null
+++ b/src/components/router/route-matching.spec.ts
@@ -0,0 +1,243 @@
+import { parseRoute, matchRoute, getHashPath } from './route-matching';
+
+describe('route-matching', () => {
+ describe('parseRoute()', () => {
+ it('parses basic path without parameters', () => {
+ const result = parseRoute('/users');
+
+ expect(result.params).toEqual([]);
+ expect(result.regex.test('/users')).toBe(true);
+ expect(result.regex.test('/users/123')).toBe(false);
+ });
+
+ it('parses path with single required parameter', () => {
+ const result = parseRoute('/users/:id');
+
+ expect(result.params).toEqual(['id']);
+ expect(result.regex.test('/users/123')).toBe(true);
+ expect(result.regex.test('/users')).toBe(false);
+ });
+
+ it('parses path with multiple required parameters', () => {
+ const result = parseRoute('/users/:userId/posts/:postId');
+
+ expect(result.params).toEqual(['userId', 'postId']);
+ expect(result.regex.test('/users/123/posts/456')).toBe(true);
+ expect(result.regex.test('/users/123/posts')).toBe(false);
+ });
+
+ it('parses path with single optional parameter', () => {
+ const result = parseRoute('/component/:name/:section?');
+
+ expect(result.params).toEqual(['name', 'section']);
+ expect(result.regex.test('/component/foo/bar')).toBe(true);
+ expect(result.regex.test('/component/foo/bar/')).toBe(true);
+ expect(result.regex.test('/component/foo/')).toBe(true);
+ // Optional param means both the slash and value are optional
+ expect(result.regex.test('/component/foo')).toBe(true);
+ });
+
+ it('parses path with only optional parameter', () => {
+ const result = parseRoute('/search/:query?');
+
+ expect(result.params).toEqual(['query']);
+ expect(result.regex.test('/search/test')).toBe(true);
+ expect(result.regex.test('/search/test/')).toBe(true);
+ expect(result.regex.test('/search/')).toBe(true);
+ // Slash is also optional for optional param
+ expect(result.regex.test('/search')).toBe(true);
+ });
+
+ it('handles trailing slash in pattern', () => {
+ const result = parseRoute('/type/:name/');
+
+ expect(result.params).toEqual(['name']);
+ expect(result.regex.test('/type/foo/')).toBe(true);
+ // Trailing slash in pattern still added optional slash at end
+ expect(result.regex.test('/type/foo//')).toBe(true);
+ });
+
+ it('maintains parameter order', () => {
+ const result = parseRoute('/a/:first/b/:second?/c/:third');
+
+ expect(result.params).toEqual(['first', 'second', 'third']);
+ });
+ });
+
+ describe('matchRoute()', () => {
+ it('returns null when path does not match pattern', () => {
+ const result = matchRoute('/users', '/posts');
+
+ expect(result).toBeNull();
+ });
+
+ it('returns empty params for basic path match', () => {
+ const result = matchRoute('/users', '/users');
+
+ expect(result).toEqual({ params: {} });
+ });
+
+ it('extracts single required parameter', () => {
+ const result = matchRoute('/users/123', '/users/:id');
+
+ expect(result).toEqual({ params: { id: '123' } });
+ });
+
+ it('extracts multiple required parameters', () => {
+ const result = matchRoute(
+ '/users/123/posts/456',
+ '/users/:userId/posts/:postId',
+ );
+
+ expect(result).toEqual({
+ params: { userId: '123', postId: '456' },
+ });
+ });
+
+ it('extracts optional parameter when present', () => {
+ const result = matchRoute(
+ '/component/foo/bar',
+ '/component/:name/:section?',
+ );
+
+ expect(result).toEqual({
+ params: { name: 'foo', section: 'bar' },
+ });
+ });
+
+ it('extracts optional parameter as empty string when absent with trailing slash', () => {
+ const result = matchRoute(
+ '/component/foo/',
+ '/component/:name/:section?',
+ );
+
+ expect(result).toEqual({
+ params: { name: 'foo', section: '' },
+ });
+ });
+
+ it('extracts optional parameter as empty string when absent without trailing slash', () => {
+ const result = matchRoute(
+ '/component/foo',
+ '/component/:name/:section?',
+ );
+
+ expect(result).toEqual({
+ params: { name: 'foo', section: '' },
+ });
+ });
+
+ it('handles path with trailing slash matching pattern without', () => {
+ const result = matchRoute('/type/MyType/', '/type/:name');
+
+ expect(result).toEqual({ params: { name: 'MyType' } });
+ });
+
+ it('handles path without trailing slash for required parameter', () => {
+ const result = matchRoute('/type/MyType', '/type/:name');
+
+ expect(result).toEqual({ params: { name: 'MyType' } });
+ });
+
+ it('handles pattern with extra trailing slash', () => {
+ const result = matchRoute('/type/MyType//', '/type/:name/');
+
+ expect(result).toEqual({ params: { name: 'MyType' } });
+ });
+
+ it('returns empty params object for catch-all route (no pattern)', () => {
+ const result = matchRoute('/any/path', '');
+
+ expect(result).toEqual({ params: {} });
+ });
+
+ it('extracts parameters with special characters', () => {
+ const result = matchRoute('/users/user-123_test', '/users/:id');
+
+ expect(result).toEqual({ params: { id: 'user-123_test' } });
+ });
+
+ it('does not match partial paths', () => {
+ const result = matchRoute('/users/123/extra', '/users/:id');
+
+ expect(result).toBeNull();
+ });
+
+ it('matches root path', () => {
+ const result = matchRoute('/', '/');
+
+ expect(result).toEqual({ params: {} });
+ });
+
+ it('handles complex real-world route pattern', () => {
+ const result = matchRoute(
+ '/component/kompendium-app/properties',
+ '/component/:name/:section?',
+ );
+
+ expect(result).toEqual({
+ params: { name: 'kompendium-app', section: 'properties' },
+ });
+ });
+
+ it('handles parameter values with hyphens and underscores', () => {
+ const result = matchRoute(
+ '/debug/my-custom_component',
+ '/debug/:name',
+ );
+
+ expect(result).toEqual({
+ params: { name: 'my-custom_component' },
+ });
+ });
+ });
+
+ describe('getHashPath()', () => {
+ const originalLocation = window.location;
+
+ beforeEach(() => {
+ delete (window as any).location;
+ window.location = { ...originalLocation, hash: '' } as Location;
+ });
+
+ afterEach(() => {
+ window.location = originalLocation;
+ });
+
+ it('returns path from hash', () => {
+ window.location.hash = '#/users';
+
+ expect(getHashPath()).toBe('/users');
+ });
+
+ it('returns path from hash with parameters', () => {
+ window.location.hash = '#/users/123/posts/456';
+
+ expect(getHashPath()).toBe('/users/123/posts/456');
+ });
+
+ it('returns root path when hash is empty', () => {
+ window.location.hash = '';
+
+ expect(getHashPath()).toBe('/');
+ });
+
+ it('returns root path when hash is only #', () => {
+ window.location.hash = '#';
+
+ expect(getHashPath()).toBe('/');
+ });
+
+ it('handles hash with query parameters', () => {
+ window.location.hash = '#/search?q=test';
+
+ expect(getHashPath()).toBe('/search?q=test');
+ });
+
+ it('handles hash with trailing slash', () => {
+ window.location.hash = '#/users/';
+
+ expect(getHashPath()).toBe('/users/');
+ });
+ });
+});
diff --git a/src/components/router/route-matching.ts b/src/components/router/route-matching.ts
new file mode 100644
index 00000000..3ab4ad96
--- /dev/null
+++ b/src/components/router/route-matching.ts
@@ -0,0 +1,85 @@
+/**
+ * Match results from route matching
+ */
+export interface MatchResults {
+ params: Record;
+}
+
+/**
+ * Cache for parsed route patterns to avoid redundant regex compilation
+ */
+const routeCache = new Map();
+
+/**
+ * Parse route URL pattern into regex and parameter names
+ * @param {string} pattern - Route pattern with optional parameters (e.g., "/component/:name")
+ * @returns {{regex: RegExp, params: string[]}} Regex and parameter names
+ */
+export function parseRoute(pattern: string): {
+ regex: RegExp;
+ params: string[];
+} {
+ const params: string[] = [];
+
+ // First, collect all parameters in order they appear
+ // Match both required (:param) and optional (:param?) parameters
+ const paramMatches = pattern.match(/:(\w+)\??/g) || [];
+ paramMatches.forEach((match) => {
+ const paramName = match.replace(/^:|[?]/g, '');
+ params.push(paramName);
+ });
+
+ // Then build the regex pattern
+ // Process optional params with their slashes first (before escaping slashes)
+ // This makes both the slash AND the parameter value optional
+ const regexPattern = pattern
+ .replace(/\/:(\w+)\?/g, '___OPTIONAL_PARAM_$1___') // Mark optional params with slash
+ .replace(/\//g, '\\/') // Escape remaining slashes
+ .replace(/___OPTIONAL_PARAM_(\w+)___/g, '(?:\\/([^/]*))?') // Optional slash + param
+ .replace(/:(\w+)/g, '([^/]+)'); // Required param
+
+ const regex = new RegExp(`^${regexPattern}\\/?$`);
+
+ return { regex: regex, params: params };
+}
+
+/**
+ * Match a path against a route pattern
+ * @param {string} path - Current path to match
+ * @param {string} pattern - Route pattern to match against
+ * @returns {MatchResults | null} Match results with parameters or null if no match
+ */
+export function matchRoute(path: string, pattern: string): MatchResults | null {
+ if (!pattern) {
+ return { params: {} };
+ }
+
+ // Check cache first, or parse and cache if not found
+ let parsed = routeCache.get(pattern);
+ if (!parsed) {
+ parsed = parseRoute(pattern);
+ routeCache.set(pattern, parsed);
+ }
+
+ const { regex, params } = parsed;
+ const match = path.match(regex);
+
+ if (!match) {
+ return null;
+ }
+
+ const matchParams: Record = {};
+ params.forEach((param, index) => {
+ matchParams[param] = match[index + 1] || '';
+ });
+
+ return { params: matchParams };
+}
+
+/**
+ * Get current hash path
+ * @returns {string} Current hash path from URL
+ */
+export function getHashPath(): string {
+ return location.hash.substring(1) || '/';
+}
diff --git a/src/components/router/route-switch-logic.spec.ts b/src/components/router/route-switch-logic.spec.ts
new file mode 100644
index 00000000..6dee0d19
--- /dev/null
+++ b/src/components/router/route-switch-logic.spec.ts
@@ -0,0 +1,222 @@
+import {
+ isRouteElement,
+ hasPreviousMatchingSibling,
+} from './route-switch-logic';
+
+describe('route-switch-logic', () => {
+ describe('isRouteElement()', () => {
+ it('returns true for kompendium-route element with url property', () => {
+ const element = document.createElement('kompendium-route');
+ (element as any).url = '/test';
+
+ expect(isRouteElement(element)).toBe(true);
+ });
+
+ it('returns true for kompendium-route element without url property', () => {
+ const element = document.createElement('kompendium-route');
+ (element as any).url = undefined;
+
+ expect(isRouteElement(element)).toBe(true);
+ });
+
+ it('returns false for non-route elements', () => {
+ const element = document.createElement('div');
+ (element as any).url = '/test';
+
+ expect(isRouteElement(element)).toBe(false);
+ });
+
+ it('returns true for route element regardless of url property presence', () => {
+ const element = document.createElement('kompendium-route');
+
+ expect(isRouteElement(element)).toBe(true);
+ });
+
+ it('is case-insensitive for tag name', () => {
+ const element = document.createElement('KOMPENDIUM-ROUTE');
+ (element as any).url = '/test';
+
+ expect(isRouteElement(element)).toBe(true);
+ });
+ });
+
+ describe('hasPreviousMatchingSibling()', () => {
+ let parent: HTMLElement;
+ let currentRoute: HTMLElement;
+
+ beforeEach(() => {
+ parent = document.createElement('kompendium-route-switch');
+ currentRoute = document.createElement('kompendium-route') as any;
+ (currentRoute as any).url = '/current';
+ });
+
+ it('returns false when parent is not route-switch', () => {
+ const nonSwitchParent = document.createElement('div');
+ nonSwitchParent.appendChild(currentRoute);
+
+ expect(hasPreviousMatchingSibling(currentRoute, '/test')).toBe(
+ false,
+ );
+ });
+
+ it('returns false when element has no parent', () => {
+ expect(hasPreviousMatchingSibling(currentRoute, '/test')).toBe(
+ false,
+ );
+ });
+
+ it('returns false when there are no previous siblings', () => {
+ parent.appendChild(currentRoute);
+
+ expect(hasPreviousMatchingSibling(currentRoute, '/current')).toBe(
+ false,
+ );
+ });
+
+ it('returns false when previous sibling does not match', () => {
+ const previousRoute = document.createElement(
+ 'kompendium-route',
+ ) as any;
+ previousRoute.url = '/other';
+
+ parent.appendChild(previousRoute);
+ parent.appendChild(currentRoute);
+
+ expect(hasPreviousMatchingSibling(currentRoute, '/current')).toBe(
+ false,
+ );
+ });
+
+ it('returns true when previous sibling matches', () => {
+ const previousRoute = document.createElement(
+ 'kompendium-route',
+ ) as any;
+ previousRoute.url = '/test';
+
+ parent.appendChild(previousRoute);
+ parent.appendChild(currentRoute);
+
+ expect(hasPreviousMatchingSibling(currentRoute, '/test')).toBe(
+ true,
+ );
+ });
+
+ it('returns true for first matching sibling (first-match wins)', () => {
+ const firstRoute = document.createElement(
+ 'kompendium-route',
+ ) as any;
+ firstRoute.url = '/test';
+
+ const secondRoute = document.createElement(
+ 'kompendium-route',
+ ) as any;
+ secondRoute.url = '/other';
+
+ parent.appendChild(firstRoute);
+ parent.appendChild(secondRoute);
+ parent.appendChild(currentRoute);
+
+ expect(hasPreviousMatchingSibling(currentRoute, '/test')).toBe(
+ true,
+ );
+ });
+
+ it('ignores non-route siblings', () => {
+ const divElement = document.createElement('div');
+ const previousRoute = document.createElement(
+ 'kompendium-route',
+ ) as any;
+ previousRoute.url = '/test';
+
+ parent.appendChild(divElement);
+ parent.appendChild(previousRoute);
+ parent.appendChild(currentRoute);
+
+ expect(hasPreviousMatchingSibling(currentRoute, '/test')).toBe(
+ true,
+ );
+ });
+
+ it('handles catch-all routes (no URL)', () => {
+ const catchAllRoute = document.createElement(
+ 'kompendium-route',
+ ) as any;
+ // No url property set - this is a catch-all
+
+ parent.appendChild(catchAllRoute);
+ parent.appendChild(currentRoute);
+
+ // Catch-all routes match everything
+ expect(hasPreviousMatchingSibling(currentRoute, '/anything')).toBe(
+ true,
+ );
+ });
+
+ it('matches routes with parameters', () => {
+ const paramRoute = document.createElement(
+ 'kompendium-route',
+ ) as any;
+ paramRoute.url = '/component/:name';
+
+ parent.appendChild(paramRoute);
+ parent.appendChild(currentRoute);
+
+ expect(
+ hasPreviousMatchingSibling(currentRoute, '/component/test'),
+ ).toBe(true);
+ });
+
+ it('returns false when route pattern does not match path', () => {
+ const paramRoute = document.createElement(
+ 'kompendium-route',
+ ) as any;
+ paramRoute.url = '/component/:name';
+
+ parent.appendChild(paramRoute);
+ parent.appendChild(currentRoute);
+
+ expect(hasPreviousMatchingSibling(currentRoute, '/type/test')).toBe(
+ false,
+ );
+ });
+
+ it('handles multiple non-matching siblings before matching one', () => {
+ const nonMatch1 = document.createElement('kompendium-route') as any;
+ nonMatch1.url = '/path1';
+
+ const nonMatch2 = document.createElement('kompendium-route') as any;
+ nonMatch2.url = '/path2';
+
+ const matchingRoute = document.createElement(
+ 'kompendium-route',
+ ) as any;
+ matchingRoute.url = '/test';
+
+ parent.appendChild(nonMatch1);
+ parent.appendChild(nonMatch2);
+ parent.appendChild(matchingRoute);
+ parent.appendChild(currentRoute);
+
+ expect(hasPreviousMatchingSibling(currentRoute, '/test')).toBe(
+ true,
+ );
+ });
+
+ it('is case-insensitive for parent tag name', () => {
+ const upperCaseParent = document.createElement(
+ 'KOMPENDIUM-ROUTE-SWITCH',
+ );
+ const previousRoute = document.createElement(
+ 'kompendium-route',
+ ) as any;
+ previousRoute.url = '/test';
+
+ upperCaseParent.appendChild(previousRoute);
+ upperCaseParent.appendChild(currentRoute);
+
+ expect(hasPreviousMatchingSibling(currentRoute, '/test')).toBe(
+ true,
+ );
+ });
+ });
+});
diff --git a/src/components/router/route-switch-logic.ts b/src/components/router/route-switch-logic.ts
new file mode 100644
index 00000000..99c40d23
--- /dev/null
+++ b/src/components/router/route-switch-logic.ts
@@ -0,0 +1,65 @@
+import { matchRoute, MatchResults } from './route-matching';
+
+/**
+ * Interface for accessing route element properties
+ * Used for type-safe sibling route checking
+ */
+export interface RouteElement extends Element {
+ url?: string;
+}
+
+/**
+ * Type guard to check if an element is a route element
+ * @param {Element} element - The element to check
+ * @returns {boolean} True if the element is a kompendium-route
+ */
+export function isRouteElement(element: Element): element is RouteElement {
+ return element.tagName.toLowerCase() === 'kompendium-route';
+}
+
+/**
+ * Check if any previous sibling route matches the current path
+ * Used by route-switch to implement first-match-wins behavior
+ * @param {HTMLElement} currentElement - The current route element
+ * @param {string} currentPath - The current path to match
+ * @returns {boolean} True if a previous sibling route matches
+ */
+export function hasPreviousMatchingSibling(
+ currentElement: HTMLElement,
+ currentPath: string,
+): boolean {
+ const parent = currentElement.parentElement;
+ if (parent?.tagName.toLowerCase() !== 'kompendium-route-switch') {
+ return false;
+ }
+
+ const siblings = Array.from(parent.children);
+ const myIndex = siblings.indexOf(currentElement);
+
+ // Check all previous siblings
+ for (let i = 0; i < myIndex; i++) {
+ const sibling = siblings[i];
+
+ // Use type guard to ensure element has expected route properties
+ if (!isRouteElement(sibling)) {
+ continue;
+ }
+
+ // Access sibling's URL property with type safety
+ const siblingUrl = sibling.url;
+
+ // Check if sibling matches current path
+ let siblingMatch: MatchResults;
+ if (siblingUrl) {
+ siblingMatch = matchRoute(currentPath, siblingUrl);
+ } else {
+ siblingMatch = { params: {} }; // Routes without URL are catch-all
+ }
+
+ if (siblingMatch) {
+ return true;
+ }
+ }
+
+ return false;
+}
diff --git a/src/components/type/type.tsx b/src/components/type/type.tsx
index 7bd8aa8f..0cfd4450 100644
--- a/src/components/type/type.tsx
+++ b/src/components/type/type.tsx
@@ -1,6 +1,6 @@
import { Component, h, Prop, State } from '@stencil/core';
import { TypeDescription, TypeDescriptionType } from '../../types';
-import { MatchResults } from '@stencil/router';
+import { MatchResults } from '../router/route-matching';
import { Interface } from './templates/interface';
import { Alias } from './templates/alias';
import { Enum } from './templates/enum';
diff --git a/src/index.ts b/src/index.ts
index 7d022dc0..8e9a0974 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,5 +1,4 @@
export * from './components';
-import '@stencil/router';
export * from './types';
import { KompendiumConfig } from './types';
export declare const kompendium: (