experimental pwa implementation

This commit is contained in:
Aeolin Ferjünnoz 2026-04-13 16:35:53 +02:00
parent 4eaea29513
commit a2b898b54f
4 changed files with 595 additions and 116 deletions

View File

@ -18,16 +18,13 @@ class Enumerable {
where(predicate) { where(predicate) {
return this._chain(function* (source) { return this._chain(function* (source) {
for (const item of source) for (const item of source) if (predicate(item)) yield item;
if (predicate(item))
yield item;
}); });
} }
select(selector) { select(selector) {
return this._chain(function* (source) { return this._chain(function* (source) {
for (const item of source) for (const item of source) yield selector(item);
yield selector(item);
}); });
} }
@ -79,6 +76,22 @@ class Enumerable {
return Array.from(this); return Array.from(this);
} }
orderByDescending(keySelector) {
return this._chain(function* (source) {
const items = Array.from(source);
items.sort((a, b) => keySelector(b) - keySelector(a));
yield* items;
});
}
orderBy(keySelector) {
return this._chain(function* (source) {
const items = Array.from(source);
items.sort((a, b) => keySelector(a) - keySelector(b));
yield* items;
});
}
firstOrDefault(predicate) { firstOrDefault(predicate) {
const source = predicate ? this.where(predicate) : this; const source = predicate ? this.where(predicate) : this;
for (const item of source) return item; for (const item of source) return item;
@ -117,9 +130,7 @@ class Enumerable {
} }
all(predicate) { all(predicate) {
for (const item of this) for (const item of this) if (!predicate(item)) return false;
if (!predicate(item))
return false;
return true; return true;
} }
@ -134,7 +145,6 @@ class Enumerable {
Array.prototype.asEnumerable = function () { Array.prototype.asEnumerable = function () {
const arr = this; const arr = this;
return new Enumerable(function* () { return new Enumerable(function* () {
for (const item of arr) for (const item of arr) yield item;
yield item;
}); });
} };

View File

@ -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(`
<div class="error">
<h1>Failed to load template</h1>
<p>${response.status} ${response.statusText}</p>
</div>
`);
}
templateContent = await response.text();
}
try {
const template = new Template(templateContent);
this.templateCache.set(route.id, template);
return template;
} catch (error) {
return new Template(`
<div class="error">
<h1>Failed to compile template</h1>
<p>${error.message}</p>
</div>
`);
}
}
}

View File

@ -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: `<div>
<h1>Test Component</h1>
<p>This is a test component.</p>
<button id="test-button" (click)="incrementCount()">Count: {{count}}</button>
</div>`,
});
}
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]);

View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OnlyPrompt - Test</title>
<link rel="stylesheet" href="../css/variables.css">
<link rel="stylesheet" href="../css/base.css">
<div id="styleOutlet">
</div>
</head>
<body>
<div>
<h1>Test Site</h1>
<a href="counter/1">Go to count 1</a>
<a href="counter/2">Go to count 2</a>
<a href="counter/3">Go to count 3</a>
<div id="routerOutlet">
Hello There
</div>
</div>
<script type="module" src="./js/test.js"></script>
</body>
</html>