From a2b898b54f52cb9ac90a3ae7b89ef68d958b26f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aeolin=20Ferj=C3=BCnnoz?= Date: Mon, 13 Apr 2026 16:35:53 +0200 Subject: [PATCH] experimental pwa implementation --- OnlyPrompt.Frontend/js/linq.js | 242 +++++++++---------- OnlyPrompt.Frontend/js/pwa.js | 410 +++++++++++++++++++++++++++++++++ OnlyPrompt.Frontend/js/test.js | 31 +++ OnlyPrompt.Frontend/test.html | 28 +++ 4 files changed, 595 insertions(+), 116 deletions(-) create mode 100644 OnlyPrompt.Frontend/js/pwa.js create mode 100644 OnlyPrompt.Frontend/js/test.js create mode 100644 OnlyPrompt.Frontend/test.html diff --git a/OnlyPrompt.Frontend/js/linq.js b/OnlyPrompt.Frontend/js/linq.js index 9308259..5823ab9 100644 --- a/OnlyPrompt.Frontend/js/linq.js +++ b/OnlyPrompt.Frontend/js/linq.js @@ -1,140 +1,150 @@ // LINQ-like Enumerable class wrapping lazy generator chains class Enumerable { - constructor(iteratorFn) { - this._iteratorFn = iteratorFn; - } + constructor(iteratorFn) { + this._iteratorFn = iteratorFn; + } - [Symbol.iterator]() { - return this._iteratorFn(); - } + [Symbol.iterator]() { + return this._iteratorFn(); + } - _chain(generatorFn) { - const source = this; - return new Enumerable(function* () { - yield* generatorFn(source); - }); - } + _chain(generatorFn) { + const source = this; + return new Enumerable(function* () { + yield* generatorFn(source); + }); + } - where(predicate) { - return this._chain(function* (source) { - for (const item of source) - if (predicate(item)) - yield item; - }); - } + where(predicate) { + return this._chain(function* (source) { + for (const item of source) if (predicate(item)) yield item; + }); + } - select(selector) { - return this._chain(function* (source) { - for (const item of source) - yield selector(item); - }); - } + select(selector) { + return this._chain(function* (source) { + for (const item of source) yield selector(item); + }); + } - take(count) { - count = Math.max(0, count); - return this._chain(function* (source) { - for (const item of source) { - if (count-- <= 0) break; - yield item; - } - }); - } + take(count) { + count = Math.max(0, count); + return this._chain(function* (source) { + for (const item of source) { + if (count-- <= 0) break; + yield item; + } + }); + } - skip(count) { - count = Math.max(0, count); - return this._chain(function* (source) { - for (const item of source) { - if (count-- > 0) continue; - yield item; - } - }); - } + skip(count) { + count = Math.max(0, count); + return this._chain(function* (source) { + for (const item of source) { + if (count-- > 0) continue; + yield item; + } + }); + } - isLast() { - return this._chain(function* (source) { - const iter = source[Symbol.iterator](); - let current = iter.next(); - let index = 0; - while (!current.done) { - const next = iter.next(); - yield [current.value, next.done, index]; - current = next; - index++; - } - }); - } + isLast() { + return this._chain(function* (source) { + const iter = source[Symbol.iterator](); + let current = iter.next(); + let index = 0; + while (!current.done) { + const next = iter.next(); + yield [current.value, next.done, index]; + current = next; + index++; + } + }); + } - forEach(action) { - for (const item of this) { - if (Array.isArray(item)) { - action(...item); - } else { - action(item); - } - } + forEach(action) { + for (const item of this) { + if (Array.isArray(item)) { + action(...item); + } else { + action(item); + } } + } - toArray() { - return Array.from(this); - } + toArray() { + return Array.from(this); + } - firstOrDefault(predicate) { - const source = predicate ? this.where(predicate) : this; - for (const item of source) return item; - return undefined; - } + orderByDescending(keySelector) { + return this._chain(function* (source) { + const items = Array.from(source); + items.sort((a, b) => keySelector(b) - keySelector(a)); + yield* items; + }); + } - first(predicate) { - const source = predicate ? this.where(predicate) : this; - for (const item of source) return item; - throw new Error("No elements in sequence."); - } + orderBy(keySelector) { + return this._chain(function* (source) { + const items = Array.from(source); + items.sort((a, b) => keySelector(a) - keySelector(b)); + yield* items; + }); + } - lastOrDefault(predicate) { - const source = predicate ? this.where(predicate) : this; - let lastValue = undefined; - for (const item of source) lastValue = item; - return lastValue; - } + firstOrDefault(predicate) { + const source = predicate ? this.where(predicate) : this; + for (const item of source) return item; + return undefined; + } - last(predicate) { - const source = predicate ? this.where(predicate) : this; - let lastValue = undefined; - let found = false; - for (const item of source) { - lastValue = item; - found = true; - } - if (!found) throw new Error("No elements in sequence."); - return lastValue; - } + first(predicate) { + const source = predicate ? this.where(predicate) : this; + for (const item of source) return item; + throw new Error("No elements in sequence."); + } - any(predicate) { - const source = predicate ? this.where(predicate) : this; - for (const _ of source) return true; - return false; - } + lastOrDefault(predicate) { + const source = predicate ? this.where(predicate) : this; + let lastValue = undefined; + for (const item of source) lastValue = item; + return lastValue; + } - all(predicate) { - for (const item of this) - if (!predicate(item)) - return false; - return true; + last(predicate) { + const source = predicate ? this.where(predicate) : this; + let lastValue = undefined; + let found = false; + for (const item of source) { + lastValue = item; + found = true; } + if (!found) throw new Error("No elements in sequence."); + return lastValue; + } - count(predicate) { - let count = 0; - const source = predicate ? this.where(predicate) : this; - for (const _ of source) count++; - return count; - } + any(predicate) { + const source = predicate ? this.where(predicate) : this; + for (const _ of source) return true; + return false; + } + + all(predicate) { + for (const item of this) if (!predicate(item)) return false; + return true; + } + + count(predicate) { + let count = 0; + const source = predicate ? this.where(predicate) : this; + for (const _ of source) count++; + return count; + } } Array.prototype.asEnumerable = function () { - const arr = this; - return new Enumerable(function* () { - for (const item of arr) - yield item; - }); -} \ No newline at end of file + const arr = this; + return new Enumerable(function* () { + for (const item of arr) yield item; + }); +}; diff --git a/OnlyPrompt.Frontend/js/pwa.js b/OnlyPrompt.Frontend/js/pwa.js new file mode 100644 index 0000000..d70b4cc --- /dev/null +++ b/OnlyPrompt.Frontend/js/pwa.js @@ -0,0 +1,410 @@ +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(` +
+

Failed to load template

+

${response.status} ${response.statusText}

+
+ `); + } + + templateContent = await response.text(); + } + + try { + const template = new Template(templateContent); + this.templateCache.set(route.id, template); + return template; + } catch (error) { + return new Template(` +
+

Failed to compile template

+

${error.message}

+
+ `); + } + } +} diff --git a/OnlyPrompt.Frontend/js/test.js b/OnlyPrompt.Frontend/js/test.js new file mode 100644 index 0000000..7984e67 --- /dev/null +++ b/OnlyPrompt.Frontend/js/test.js @@ -0,0 +1,31 @@ +import { + initRouter, + Route, + ViewChild, + Component, + ComponentInput, + ComponentDefinition, +} from "./pwa.js"; + +class TestComponent extends Component { + static get definition() { + return new ComponentDefinition({ + name: "test-component", + template: `
+

Test Component

+

This is a test component.

+ +
`, + }); + } + + count = new ComponentInput(0); + + incrementCount() { + this.count++; + } +} + +const route1 = new Route("counter", TestComponent); +const route2 = new Route("counter/:count", TestComponent); +initRouter("routerOutlet", "styleOutlet", [route1, route2]); diff --git a/OnlyPrompt.Frontend/test.html b/OnlyPrompt.Frontend/test.html new file mode 100644 index 0000000..81e54f4 --- /dev/null +++ b/OnlyPrompt.Frontend/test.html @@ -0,0 +1,28 @@ + + + + + + + OnlyPrompt - Test + + +
+ +
+ + + +
+

Test Site

+ Go to count 1 + Go to count 2 + Go to count 3 +
+ Hello There +
+
+ + + + \ No newline at end of file