import "./linq.js"; import { NodeTemplate } from "./node-template.js"; // ─── Route matching ─── 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); } } // ─── ComponentInput (signal-like) ─── export class ComponentInput { constructor(defaultValue, options = {}) { this._value = defaultValue; this.transform = options.transform || ((v) => v); this.validate = options.validate || (() => true); this.alias = options.alias || null; this._isComponentInput = true; this._owner = null; const self = this; const fn = function () { return self._value; }; return new Proxy(fn, { get(target, prop) { if (prop === "set") return (v) => self._set(v); if (prop === "_isComponentInput") return true; if (prop === "_self") return self; if (prop === "alias") return self.alias; if (prop === Symbol.toPrimitive) return () => self._value; if (prop === "valueOf") return () => self._value; if (prop === "toString") return () => String(self._value); const inner = self._value; if (inner == null) return undefined; const val = inner[prop]; return typeof val === "function" ? val.bind(inner) : val; }, set(target, prop, value) { if (self._value != null) { self._value[prop] = value; if (self._owner) self._owner.requestUpdate(); } return true; }, apply(target, thisArg, args) { return self._value; }, }); } _set(newValue) { const transformed = this.transform(newValue); if (!this.validate(transformed)) { throw new Error("Invalid value"); } this._value = transformed; if (this._owner) this._owner.requestUpdate(); } static [Symbol.hasInstance](instance) { return instance?._isComponentInput === true; } } // ─── ViewChild ─── 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; } } // ─── ComponentDefinition ─── 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; } } // ─── EventListener ─── class EventBinding { 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); } } // ─── Component ─── export class Component extends EventTarget { constructor() { super(); this._eventBindings = []; return new Proxy(this, { get(target, prop, receiver) { const value = Reflect.get(target, prop, target); if (typeof value === "function" && typeof value.bind === "function") { return prop in EventTarget.prototype ? value.bind(target) : value.bind(receiver); } return value; }, set(target, prop, value) { if (target[prop] instanceof ComponentInput) { target[prop].set(value); return true; } else if (target[prop] instanceof ViewChild) { target[prop]._setValue(value); } else { target[prop] = value; } target.requestUpdate(); return true; }, }); } requestUpdate() { this.dispatchEvent(new Event("requestUpdate")); } static get definition() { throw new Error("Component definition is not defined"); } onInit() {} onBeforeRender() {} onAfterRender() {} onDestroy() { this._eventBindings.forEach((b) => b.detach()); } } // ─── Router init ─── 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); } // ─── Router ─── export class Router { constructor(outletRef, includeRef, routes) { this.outletRef = outletRef; this.includeRef = includeRef; this.routes = routes; this.templateCache = new Map(); this.activeController = null; this.activeRenderResult = null; this._boundUpdateHandler = null; this._rendering = false; this._pendingUpdate = false; outletRef.textContent = "Loading..."; includeRef.innerHTML = ""; navigation.addEventListener("navigate", this.handleNavigate.bind(this)); } findMatchingRoute(path) { return this.routes .asEnumerable() .select((route) => route.match(path)) .where((match) => match !== null) .orderByDescending((match) => match.segmentCount) .firstOrDefault(); } async handleNavigate(event) { const routeMatch = this.findMatchingRoute(event.destination.url); if (!routeMatch) return; 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._boundUpdateHandler = this.handleUpdateRequest.bind(this); this.activeController.addEventListener( "requestUpdate", this._boundUpdateHandler, ); this.renderActiveComponent(); history.pushState(null, "", routeMatch.path); } destroyCurrent() { if (this.activeController) { this.activeController.removeEventListener( "requestUpdate", this._boundUpdateHandler, ); this.activeController.onDestroy(); this.activeController = null; } if (this.activeRenderResult) { this.activeRenderResult.destroy(); this.activeRenderResult = null; } } handleUpdateRequest() { if (this._rendering) { this._pendingUpdate = true; return; } this.updateActiveComponent(); } insertIncludes(route) { this.includeRef.innerHTML = ""; const paths = route.ComponentDefinition.stylesPath; if (!paths) return; const list = Array.isArray(paths) ? paths : [paths]; for (const p of list) { const link = document.createElement("link"); link.rel = "stylesheet"; link.href = p; this.includeRef.appendChild(link); } } // ─── First render: build DOM from scratch ─── renderActiveComponent() { this._rendering = true; this._pendingUpdate = false; this.activeController.onBeforeRender(); const result = this.activeTemplate.render(this.activeController); this.activeRenderResult = result; // Clear outlet, append fragment this.outletRef.textContent = ""; this.outletRef.appendChild(result.fragment); // Wire events + two-way bindings on the live DOM this.wireEvents(this.activeController, this.outletRef); this.wireViewChildren(this.activeController, this.outletRef); this.activeController.onAfterRender(); this._rendering = false; if (this._pendingUpdate) { this.updateActiveComponent(); } } // ─── Subsequent updates: patch bindings only ─── updateActiveComponent() { this._rendering = true; this._pendingUpdate = false; this.activeController.onBeforeRender(); this.activeRenderResult.update(this.activeController); this.activeController.onAfterRender(); this._rendering = false; if (this._pendingUpdate) { this.updateActiveComponent(); } } // ─── ViewChild wiring ─── wireViewChildren(controller, root) { Object.entries(controller) .filter(([_, value]) => value instanceof ViewChild) .forEach(([key, viewChild]) => { if (viewChild._id) { const el = root.querySelector(`#${viewChild._id}`); if (el) viewChild._setValue(el); } else if (viewChild._selector) { const el = root.querySelector(viewChild._selector); if (el) viewChild._setValue(el); } else { console.warn(`ViewChild ${key}: no selector or id`); } }); } // ─── Event wiring ─── // Supports: // (click)="methodName()" — event binding // [(value)]="propName" — two-way binding (banana-in-a-box) wireEvents(controller, root) { const allElements = root.querySelectorAll("*"); allElements.forEach((element) => { for (const attr of Array.from(element.attributes)) { const name = attr.name; // ─── Two-way binding: [(prop)]="field" ─── const twoWayMatch = name.match(/^\[\((\w+)\)\]$/); if (twoWayMatch) { const domProp = twoWayMatch[1]; // e.g. "value" const field = attr.value.trim(); // e.g. "name" (controller property) this._bindTwoWay(controller, element, domProp, field); continue; } // ─── Event binding: (event)="handler()" ─── const eventMatch = name.match(/^\((\w+)\)$/); if (eventMatch) { const eventName = eventMatch[1]; const attrValue = attr.value.trim(); const methodName = attrValue.endsWith("()") ? attrValue.slice(0, -2) : attrValue; let binding; if (typeof controller[methodName] === "function") { const handler = controller[methodName].bind(controller); binding = new EventBinding(eventName, element, handler); } else { const fn = new Function("event", attrValue).bind(controller); binding = new EventBinding(eventName, element, fn); } binding.attach(); controller._eventBindings.push(binding); } } }); } // ─── Two-way bind: DOM property ↔ controller field ─── // Sets DOM prop from controller on each update. // Listens for input/change events to write back to controller. _bindTwoWay(controller, element, domProp, field) { // Controller → DOM: set initial value const getValue = () => { const v = controller[field]; return typeof v === "function" ? v() : v; }; element[domProp] = getValue() ?? ""; // DOM → Controller: on input (for text) + change (for select/checkbox) const writeBack = () => { const domValue = element[domProp]; if (controller[field] instanceof ComponentInput) { controller[field].set(domValue); } else { controller[field] = domValue; } }; const inputBinding = new EventBinding("input", element, writeBack); const changeBinding = new EventBinding("change", element, writeBack); inputBinding.attach(); changeBinding.attach(); controller._eventBindings.push(inputBinding, changeBinding); // Controller → DOM: patch on every update via RenderResult hook // We piggyback on requestUpdate listener — the attr binding in // node-template handles {{expr}} attrs, but for [(prop)] we need // to sync the DOM *property* (not attribute) on update. const updateBinding = new EventBinding("requestUpdate", controller, () => { const val = getValue() ?? ""; if (element[domProp] !== val) element[domProp] = val; }); updateBinding.attach(); controller._eventBindings.push(updateBinding); } // ─── Component instance creation ─── createComponentInstance(component, params) { const instance = new component(); // Wire _owner for ComponentInput reactivity for (const key of Object.keys(instance)) { const val = instance[key]; if (val instanceof ComponentInput) { val._self._owner = instance; } } instance.onInit(); // Bind route/query params → ComponentInputs (by name or alias) for (const [paramKey, paramValue] of Object.entries(params)) { if (instance[paramKey] instanceof ComponentInput) { instance[paramKey].set(paramValue); continue; } for (const key of Object.keys(instance)) { const val = instance[key]; if (val instanceof ComponentInput && val.alias === paramKey) { val.set(paramValue); break; } } } return instance; } // ─── Template caching ─── 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) { return new NodeTemplate( `
${response.status} ${response.statusText}
${error.message}