import "./linq.js"; import { Template } from "./template.js"; class RouteMatch { constructor(route, path, params) { this.route = route; this.path = path; this.params = params; this.segmentCount = route.fragments.length; } } export class Route { constructor(pattern, componentClass) { this.id = crypto.randomUUID(); this.componentClass = componentClass; this.ComponentDefinition = componentClass.definition; this.fragments = pattern.split("/"); } match(path) { const parsedUrl = new URL(path, window.location.origin); const params = {}; parsedUrl.searchParams.forEach((value, key) => { params[key] = value; }); const pathFragments = parsedUrl.pathname .split("/") .filter((f) => f.length > 0); for (let i = 0; i < this.fragments.length; i++) { const fragment = this.fragments[i]; const pathFragment = pathFragments[i]; if (fragment.startsWith(":")) { params[fragment.substring(1)] = pathFragment; continue; } else if (fragment === "*") { continue; } else if (fragment === "**") { return new RouteMatch(this, path, params); } else if (fragment !== pathFragment) { return null; } } return new RouteMatch(this, path, params); } } export class ComponentInput { constructor( defaultValue, options = { transform: (value) => value, validate: (value) => true, alias: null, }, ) { this._value = defaultValue; this.transform = options.transform; this.validate = options.validate; this.alias = options.alias; this._isComponentInput = true; const self = this; const fn = function () { return self._value; }; return new Proxy(fn, { get(target, prop) { if (prop === Symbol.toPrimitive) return (hint) => self._value; if (prop === "valueOf") return () => self._value; if (prop in self) return self[prop]; const inner = self._value; const value = inner[prop]; return typeof value === "function" ? value.bind(inner) : value; }, set(target, prop, value) { if (Object.prototype.hasOwnProperty.call(self, prop)) { self[prop] = value; return true; } self._value[prop] = value; return true; }, apply(target, thisArg, args) { return self._value; }, }); } set(newValue) { const transformedValue = this.transform(newValue); if (!this.validate(transformedValue)) { throw new Error("Invalid value"); } this._value = transformedValue; } static [Symbol.hasInstance](instance) { return instance?._isComponentInput === true; } } export class ViewChild { constructor(options = { selector: null, id: null, multiple: false }) { this._selector = options.selector; this._id = options.id; this._multiple = options.multiple; this._element = null; this._isViewChild = true; return new Proxy(this, { get(target, prop) { if (prop in target) return target[prop]; if (target._element) { const value = target._element[prop]; return typeof value === "function" ? value.bind(target._element) : value; } }, }); } _setValue(element) { this._element = element; } static [Symbol.hasInstance](instance) { return instance?._isViewChild === true; } } export class ComponentDefinition { constructor( options = { templatesPath: null, template: null, stylesPath: null, scriptsPath: null, }, ) { this.templatesPath = options.templatesPath; this.template = options.template; this.stylesPath = options.stylesPath; this.scriptsPath = options.scriptsPath; } } class EventListener { constructor(event, element, handler) { this.event = event; this.element = element; this.handler = handler; } attach() { this.element.addEventListener(this.event, this.handler); } detach() { this.element.removeEventListener(this.event, this.handler); } } export class Component extends EventTarget { constructor() { super(); this._eventListeners = []; return new Proxy(this, { get(target, prop, receiver) { const value = Reflect.get(target, prop, target); return typeof value === "function" && typeof value.bind === "function" ? value.bind(receiver) : value; }, set(target, prop, value) { if (target[prop] instanceof ComponentInput) { target[prop].set(value); } else if (target[prop] instanceof ViewChild) { target[prop]._setValue(value); } else { target[prop] = value; } target.requestUpdate(); return true; }, }); } requestUpdate() { this.dispatchEvent(new Event("requestUpdate", { component: this })); } static get definition() { throw new Error("Component definition is not defined"); } onInit() {} onBeforeRender() {} onAfterRender() {} onDestroy() { this._eventListeners.forEach((listener) => listener.detach()); } } export function initRouter(outletId, includeId, routes) { const outletRef = document.getElementById(outletId); const includeRef = document.getElementById(includeId); if (!outletRef) { console.error(`Outlet element with id '${outletId}' not found`); return null; } if (!includeRef) { console.error(`Include element with id '${includeId}' not found`); return null; } return new Router(outletRef, includeRef, routes); } export class Router { constructor(outletRef, includeRef, routes) { this.outletRef = outletRef; this.includeRef = includeRef; this.routes = routes; this.templateCache = new Map(); outletRef.innerHTML = "Loading..."; includeRef.innerHTML = ""; navigation.addEventListener("navigate", this.handleNavigate.bind(this)); } findMatchingRoute(path) { const routeMatch = this.routes .asEnumerable() .select((route) => route.match(path)) .where((match) => match !== null) .orderByDescending((match) => match.segmentCount) .firstOrDefault(); return routeMatch; } async handleNavigate(event) { const routeMatch = this.findMatchingRoute(event.destination.url); if (routeMatch) { event.preventDefault(); const template = await this.getCachedTemplate(routeMatch.route); const controller = this.createComponentInstance( routeMatch.route.componentClass, routeMatch.params, ); this.destroyCurrent(); this.activeController = controller; this.activeTemplate = template; this.insertIncludes(routeMatch.route); this.activeController.addEventListener( "requestUpdate", this.handleUpdateRequest.bind(this), ); this.renderActiveComponent(); } } destroyCurrent() { if (this.activeController) { this.activeController.removeEventListener( "requestUpdate", this.handleUpdateRequest, ); this.activeController.onDestroy(); this.activeController = null; } } handleUpdateRequest(args) { if (this._rendering) { this._pendingUpdate = true; return; } else { this.renderActiveComponent(); } } insertIncludes(route) { if (Array.isArray(route.ComponentDefinition.stylesPath)) { route.ComponentDefinition.stylesPath.forEach((path) => { const link = document.createElement("link"); link.rel = "stylesheet"; link.href = path; this.includeRef.appendChild(link); }); } else if (route.ComponentDefinition.stylesPath) { const link = document.createElement("link"); link.rel = "stylesheet"; link.href = route.ComponentDefinition.stylesPath; this.includeRef.appendChild(link); } } renderActiveComponent() { this._rendering = true; this._pendingUpdate = false; this.activeController.onBeforeRender(); const renderedTemplate = this.activeTemplate.render(this.activeController); this.outletRef.innerHTML = renderedTemplate; this.wireEvents(this.activeController); this.wireViewChildren(this.activeController); this.activeController.onAfterRender(); this._rendering = false; if (this._pendingUpdate) { this.renderActiveComponent(); } } wireViewChildren(controller) { const viewChildProps = Object.entries(controller) .filter(([_, value]) => value instanceof ViewChild) .forEach(([key, viewChild]) => { if (viewChild._id) { const element = this.outletRef.querySelector(`#${viewChild._id}`); if (element) viewChild._setValue(element); } else if (viewChild._selector) { const element = this.outletRef.querySelector(viewChild._selector); if (element) viewChild._setValue(element); } else { console.warn(`ViewChild ${key} has no selector or id defined`); } }); } wireEvents(controller) { const allElements = this.outletRef.querySelectorAll("*"); allElements.forEach((element) => { element.getAttributeNames().forEach((attr) => { const match = attr.match(/^\((\w+)\)$/); if (!match) return; const eventName = match[1]; const attrValue = element.getAttribute(attr).trim(); const methodName = attrValue.endsWith("()") ? attrValue.slice(0, -2) : attrValue; let listener; if (typeof controller[methodName] === "function") { // Named method — wire directly const handler = controller[methodName].bind(controller); listener = new EventListener(eventName, element, handler); } else { const fn = new Function("event", attrValue).bind(controller); listener = new EventListener(eventName, element, fn); } listener.attach(); controller._eventListeners.push(listener); }); }); } createComponentInstance(component, params) { const instance = new component(); instance.onInit(); for (const [key, value] of Object.entries(params)) { if (instance[key] instanceof ComponentInput) { instance[key].set(value); } } return instance; } async getCachedTemplate(route) { if (this.templateCache.has(route.id)) { return this.templateCache.get(route.id); } let templateContent = route.componentClass.definition.template; if (!templateContent && route.componentClass.definition.templatesPath) { const response = await fetch( route.componentClass.definition.templatesPath, ); if (response.ok === false) { return new Template(`
${response.status} ${response.statusText}
${error.message}