Skip to content

Commit b7c5b16

Browse files
committed
add router resolver service, closes #11
1 parent 7edb2f2 commit b7c5b16

21 files changed

+514
-19
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
55
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

77
## Unreleased
8+
### Added
9+
- Add router resolver service, closes #11.
810

911
## 0.0.9 - 2026-01-25
1012
### Added

core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@
145145
"last 1 years and not dead"
146146
],
147147
"dependencies": {
148+
"lit": "^3.3.2",
148149
"lru-cache": "^11.1.0",
149150
"luxon": "^3.4.4",
150151
"tslib": "^2.8.1",

core/pnpm-lock.yaml

Lines changed: 45 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { LitElement, TemplateResult } from 'lit';
2+
import { customElement } from 'lit/decorators.js';
3+
import { html } from 'lit/static-html.js';
4+
5+
@customElement('ember-nexus-app-core-page-error-404')
6+
class PageError404 extends LitElement {
7+
render(): TemplateResult {
8+
return html`<div>Error 404: Route could not be resolved.</div>`;
9+
}
10+
}
11+
12+
export { PageError404 };

core/src/Component/Page/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './PageError404.js';

core/src/Component/Router.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { LitElement, TemplateResult } from 'lit';
2+
import { customElement } from 'lit/decorators.js';
3+
import { html, unsafeStatic } from 'lit/static-html.js';
4+
5+
import { GetServiceResolverEvent } from '../BrowserEvent/index.js';
6+
import { RouteResolver } from '../Service/index.js';
7+
import { RouteConfiguration } from '../Type/Definition/RouteConfiguration.js';
8+
9+
@customElement('ember-nexus-app-core-router')
10+
class Router extends LitElement {
11+
protected _routeConfiguration: RouteConfiguration | null = null;
12+
13+
protected _routeResolver: RouteResolver | null = null;
14+
15+
handleNewRoute(route: string): void {
16+
// eslint-disable-next-line no-console
17+
console.log(`handle new route: ${route}`);
18+
this._routeResolver
19+
?.findRouteConfiguration(route)
20+
.then((routeConfiguration) => {
21+
if (routeConfiguration === null) {
22+
// eslint-disable-next-line no-console
23+
console.log(`unable to resolve route: ${route}`);
24+
return;
25+
}
26+
this._routeConfiguration = routeConfiguration;
27+
return;
28+
})
29+
.catch((e) => {
30+
// eslint-disable-next-line no-console
31+
console.log('error during resolving route');
32+
// eslint-disable-next-line no-console
33+
console.log(e);
34+
});
35+
}
36+
37+
handlePopStateEvent(): void {
38+
// eslint-disable-next-line no-console
39+
console.log('(popstate) Location changed to: ', window.location.pathname);
40+
this.handleNewRoute(window.location.pathname);
41+
}
42+
43+
handleLinkClickEvent(event: PointerEvent): void {
44+
const target = event.target;
45+
if (target === null) {
46+
return;
47+
}
48+
if (!(target instanceof HTMLElement)) {
49+
return;
50+
}
51+
52+
const newRawUrl = target.attributes.getNamedItem('href')?.value;
53+
if (newRawUrl === null || newRawUrl === undefined) {
54+
return;
55+
}
56+
57+
const currentUrl = window.location.origin;
58+
59+
// Create a new URL object to resolve the relative path to an absolute URL
60+
const newAbsoluteUrl = new URL(newRawUrl as string, currentUrl);
61+
62+
if (newAbsoluteUrl.host !== window.location.host) {
63+
// clicked link to different domain, ignoring it
64+
return;
65+
}
66+
67+
// eslint-disable-next-line no-console
68+
console.log(`new absolute url: ${newAbsoluteUrl}`);
69+
history.pushState({}, '', newAbsoluteUrl);
70+
event.preventDefault();
71+
72+
this.handleNewRoute(newAbsoluteUrl.pathname);
73+
}
74+
75+
connectedCallback(): void {
76+
super.connectedCallback();
77+
window.addEventListener('popstate', this.handlePopStateEvent.bind(this));
78+
document.addEventListener('click', this.handleLinkClickEvent.bind(this));
79+
80+
const getServiceResolverEvent = new GetServiceResolverEvent();
81+
this.dispatchEvent(getServiceResolverEvent);
82+
const serviceResolver = getServiceResolverEvent.getServiceResolver();
83+
if (serviceResolver !== null) {
84+
const routeResolver = serviceResolver.getService<RouteResolver>(RouteResolver.identifier);
85+
if (routeResolver !== null) {
86+
// eslint-disable-next-line no-console
87+
console.log('router init complete');
88+
this._routeResolver = routeResolver;
89+
this.handleNewRoute(window.location.pathname);
90+
}
91+
}
92+
}
93+
94+
disconnectedCallback(): void {
95+
window.removeEventListener('popstate', this.handlePopStateEvent);
96+
document.removeEventListener('click', this.handleLinkClickEvent);
97+
super.disconnectedCallback();
98+
}
99+
100+
protected getRouteWebComponentTag(): string | null {
101+
if (this._routeConfiguration === null) {
102+
return null;
103+
}
104+
if (typeof this._routeConfiguration.webComponent === 'string') {
105+
return typeof this._routeConfiguration.webComponent;
106+
}
107+
// todo: support function based routes
108+
return null;
109+
}
110+
111+
render(): TemplateResult {
112+
const routeWebComponentTag = this.getRouteWebComponentTag() ?? 'ember-nexus-app-core-page-error-404';
113+
const routeWebComponent = unsafeStatic(routeWebComponentTag);
114+
return html`<${routeWebComponent}></${routeWebComponent}>`;
115+
}
116+
}
117+
118+
export { Router };

core/src/Component/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * as Page from './Page/index.js';
2+
export * from './Router.js';

core/src/Init.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
ElementParser,
3737
EventDispatcher,
3838
FetchHelper,
39+
RouteResolver,
3940
ServiceResolver,
4041
TokenParser,
4142
} from './Service/index.js';
@@ -51,6 +52,8 @@ function init(rootNode: HTMLElement): ServiceResolver {
5152

5253
serviceResolver.setService(ServiceIdentifier.icon, new Registry());
5354

55+
serviceResolver.setService(ServiceIdentifier.routeResolver, new RouteResolver());
56+
5457
const logger = new Logger({
5558
name: 'app-core',
5659
type: 'pretty',

core/src/Service/RouteResolver.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { RouteConfiguration, RouteIdentifier } from '../Type/Definition/index.js';
2+
import { RouteNode } from '../Type/Definition/RouteNode.js';
3+
import { ServiceIdentifier } from '../Type/Enum/index.js';
4+
5+
class RouteResolver {
6+
static identifier: ServiceIdentifier = ServiceIdentifier.routeResolver;
7+
//todo: add some way to replace and/or disable specific routes
8+
private readonly routes: Map<RouteIdentifier, RouteConfiguration> = new Map();
9+
private rootNode: RouteNode = new RouteNode();
10+
11+
hasRouteConfiguration(routeIdentifier: RouteIdentifier): boolean {
12+
return this.routes.has(String(routeIdentifier));
13+
}
14+
15+
getRouteConfiguration(routeIdentifier: RouteIdentifier): null | RouteConfiguration {
16+
const routeEntry = this.routes.get(String(routeIdentifier));
17+
if (routeEntry === undefined) {
18+
return null;
19+
}
20+
return routeEntry;
21+
}
22+
23+
getRouteConfigurationOrFail(routeIdentifier: RouteIdentifier): RouteConfiguration {
24+
const routeEntry = this.routes.get(String(routeIdentifier));
25+
if (routeEntry === undefined) {
26+
throw new Error(`Requested route with identifier ${String(routeIdentifier)} could not be resolved.`);
27+
}
28+
return routeEntry;
29+
}
30+
31+
private getSegmentsFromRoute(input: string): string[] {
32+
return input
33+
.replace(/^\/+|\/+$/g, '')
34+
.split('/')
35+
.filter(Boolean);
36+
}
37+
38+
addRouteConfiguration(routeConfiguration: RouteConfiguration): RouteResolver {
39+
this.routes.set(String(routeConfiguration.routeIdentifier), routeConfiguration);
40+
41+
const segments = this.getSegmentsFromRoute(routeConfiguration.route);
42+
let node = this.rootNode;
43+
for (let i = 0; i < segments.length; i++) {
44+
node = node.getChildRouteNode(segments[i]);
45+
}
46+
node.addRouteHandler(routeConfiguration.routeIdentifier);
47+
48+
return this;
49+
}
50+
51+
findRouteConfigurationsByNodeAndSegments(node: RouteNode, segments: string[]): RouteIdentifier[] {
52+
if (segments.length === 0) {
53+
return node.getRouteHandlers();
54+
}
55+
const routeIdentifiers: RouteIdentifier[] = [];
56+
if (node.hasChildRouteNode(segments[0])) {
57+
routeIdentifiers.push(
58+
...this.findRouteConfigurationsByNodeAndSegments(node.getChildRouteNode(segments[0]), segments.slice(1)),
59+
);
60+
}
61+
if (node.hasChildRouteNode('*')) {
62+
routeIdentifiers.push(
63+
...this.findRouteConfigurationsByNodeAndSegments(node.getChildRouteNode('*'), segments.slice(1)),
64+
);
65+
}
66+
67+
return routeIdentifiers;
68+
}
69+
70+
async findRouteConfiguration(route: string): Promise<RouteConfiguration | null> {
71+
const segments = this.getSegmentsFromRoute(route);
72+
let routeIdentifiers = this.findRouteConfigurationsByNodeAndSegments(this.rootNode, segments);
73+
routeIdentifiers = [...new Set(routeIdentifiers)];
74+
75+
const routeConfigurations: RouteConfiguration[] = routeIdentifiers
76+
.map((id) => this.getRouteConfiguration(id))
77+
.filter((config): config is RouteConfiguration => config !== null)
78+
.sort((a, b) => b.priority - a.priority);
79+
80+
for (const config of routeConfigurations) {
81+
if (await config.guard(route, [], null)) {
82+
return config;
83+
}
84+
}
85+
86+
return null;
87+
}
88+
89+
deleteRouteConfiguration(routeIdentifier: RouteIdentifier): RouteResolver {
90+
// todo: add warning that deleting and re-defining identical route identifiers can lead to issues
91+
this.routes.delete(String(routeIdentifier));
92+
return this;
93+
}
94+
95+
getRouteIdentifiers(): RouteIdentifier[] {
96+
return [...this.routes.keys()];
97+
}
98+
99+
getRoutesConfigurations(): RouteConfiguration[] {
100+
return [...this.routes.values()];
101+
}
102+
103+
clearRoutes(): RouteResolver {
104+
this.routes.clear();
105+
return this;
106+
}
107+
}
108+
109+
export { RouteResolver };

core/src/Service/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@ export * from './ElementParser.js';
55
export * from './EventDispatcher.js';
66
export * from './FetchHelper.js';
77
export * from './Logger.js';
8+
export * from './RouteResolver.js';
89
export * from './ServiceResolver.js';
910
export * from './TokenParser.js';

0 commit comments

Comments
 (0)