- virtual dom based approach working
This commit is contained in:
parent
62ac0a6bb7
commit
11ebf22ec9
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": "Include Error Detail=true;User ID=onlyprompt;Password=onlyprompt;Host=localhost;Port=1803;Database=onlyprompt;Pooling=true;MinPoolSize=0;MaxPoolSize=100;Connection Lifetime=0;"
|
"DefaultConnection": "Include Error Detail=true;User ID=onlyprompt;Password=onlyprompt;Host=localhost;Port=2803;Database=onlyprompt;Pooling=true;MinPoolSize=0;MaxPoolSize=100;Connection Lifetime=0;"
|
||||||
},
|
},
|
||||||
"Jwt": {
|
"Jwt": {
|
||||||
"Issuer": "https://onlyprompts.com",
|
"Issuer": "https://onlyprompts.com",
|
||||||
|
|||||||
541
OnlyPrompt.Frontend/js/node-pwa.js
Normal file
541
OnlyPrompt.Frontend/js/node-pwa.js
Normal file
@ -0,0 +1,541 @@
|
|||||||
|
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(
|
||||||
|
`<div class="error"><h1>Failed to load template</h1><p>${response.status} ${response.statusText}</p></div>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
templateContent = await response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const template = new NodeTemplate(templateContent);
|
||||||
|
this.templateCache.set(route.id, template);
|
||||||
|
return template;
|
||||||
|
} catch (error) {
|
||||||
|
return new NodeTemplate(
|
||||||
|
`<div class="error"><h1>Failed to compile template</h1><p>${error.message}</p></div>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
958
OnlyPrompt.Frontend/js/node-template.js
Normal file
958
OnlyPrompt.Frontend/js/node-template.js
Normal file
@ -0,0 +1,958 @@
|
|||||||
|
import "./linq.js";
|
||||||
|
|
||||||
|
// ─── Expression evaluation ───
|
||||||
|
|
||||||
|
function evaluate(data, expression) {
|
||||||
|
if (!expression) return data;
|
||||||
|
try {
|
||||||
|
return new Function("$data", `with($data) { return (${expression}); }`)(
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Scope ───
|
||||||
|
|
||||||
|
class ScopeDict {
|
||||||
|
constructor(initial = {}) {
|
||||||
|
this._entries = { ...initial };
|
||||||
|
}
|
||||||
|
set(name, value) {
|
||||||
|
if (name.startsWith("$"))
|
||||||
|
throw new Error(`'${name}' reserved — cannot use @let`);
|
||||||
|
this._entries[name] = value;
|
||||||
|
}
|
||||||
|
setBuiltin(name, value) {
|
||||||
|
this._entries[name] = value;
|
||||||
|
}
|
||||||
|
has(name) {
|
||||||
|
return name in this._entries;
|
||||||
|
}
|
||||||
|
get(name) {
|
||||||
|
return this._entries[name];
|
||||||
|
}
|
||||||
|
clone() {
|
||||||
|
return new ScopeDict(this._entries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeProxy(data, scope) {
|
||||||
|
if (data === null || data === undefined || typeof data !== "object") {
|
||||||
|
scope.setBuiltin("$this", data);
|
||||||
|
return new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get(_, p) {
|
||||||
|
return p === "$this" ? data : scope.has(p) ? scope.get(p) : undefined;
|
||||||
|
},
|
||||||
|
has(_, p) {
|
||||||
|
return p === "$this" || scope.has(p);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
scope.setBuiltin("$this", data);
|
||||||
|
return new Proxy(data, {
|
||||||
|
get(t, p) {
|
||||||
|
return p === "$this" ? data : scope.has(p) ? scope.get(p) : t[p];
|
||||||
|
},
|
||||||
|
has(t, p) {
|
||||||
|
return p === "$this" || scope.has(p) || p in t;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Formatting ───
|
||||||
|
|
||||||
|
function formatNumber(value, spec) {
|
||||||
|
const hasThousands = spec.includes(",");
|
||||||
|
const dotIdx = spec.indexOf(".");
|
||||||
|
let decimals = dotIdx !== -1 ? spec.length - dotIdx - 1 : 0;
|
||||||
|
let result = value.toFixed(decimals);
|
||||||
|
if (hasThousands) {
|
||||||
|
const parts = result.split(".");
|
||||||
|
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||||
|
result = parts.join(".");
|
||||||
|
}
|
||||||
|
if (!spec.includes(".") && !hasThousands) {
|
||||||
|
const padLen = spec.replace(/[^0]/g, "").length;
|
||||||
|
if (padLen > 0) {
|
||||||
|
const neg = value < 0;
|
||||||
|
let abs = Math.abs(Math.round(value)).toString().padStart(padLen, "0");
|
||||||
|
result = neg ? "-" + abs : abs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date, spec) {
|
||||||
|
const pad = (n, l = 2) => String(n).padStart(l, "0");
|
||||||
|
const tokens = {
|
||||||
|
yyyy: date.getFullYear(),
|
||||||
|
yy: String(date.getFullYear()).slice(-2),
|
||||||
|
MM: pad(date.getMonth() + 1),
|
||||||
|
M: date.getMonth() + 1,
|
||||||
|
dd: pad(date.getDate()),
|
||||||
|
d: date.getDate(),
|
||||||
|
HH: pad(date.getHours()),
|
||||||
|
H: date.getHours(),
|
||||||
|
hh: pad(date.getHours() % 12 || 12),
|
||||||
|
h: date.getHours() % 12 || 12,
|
||||||
|
mm: pad(date.getMinutes()),
|
||||||
|
m: date.getMinutes(),
|
||||||
|
ss: pad(date.getSeconds()),
|
||||||
|
s: date.getSeconds(),
|
||||||
|
fff: pad(date.getMilliseconds(), 3),
|
||||||
|
};
|
||||||
|
let result = spec;
|
||||||
|
for (const [tok, val] of Object.entries(tokens).sort(
|
||||||
|
(a, b) => b[0].length - a[0].length,
|
||||||
|
)) {
|
||||||
|
result = result.replaceAll(tok, String(val));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFormatter(spec) {
|
||||||
|
return (v) => {
|
||||||
|
if (v == null) return "";
|
||||||
|
if (v instanceof Date) return formatDate(v, spec);
|
||||||
|
if (typeof v === "number") return formatNumber(v, spec);
|
||||||
|
return String(v);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dependency extraction ───
|
||||||
|
|
||||||
|
const RESERVED = new Set([
|
||||||
|
"true",
|
||||||
|
"false",
|
||||||
|
"null",
|
||||||
|
"undefined",
|
||||||
|
"typeof",
|
||||||
|
"instanceof",
|
||||||
|
"new",
|
||||||
|
"delete",
|
||||||
|
"void",
|
||||||
|
"in",
|
||||||
|
"of",
|
||||||
|
"if",
|
||||||
|
"else",
|
||||||
|
"return",
|
||||||
|
"this",
|
||||||
|
"event",
|
||||||
|
"Math",
|
||||||
|
"Date",
|
||||||
|
"Array",
|
||||||
|
"Object",
|
||||||
|
"String",
|
||||||
|
"Number",
|
||||||
|
"Boolean",
|
||||||
|
"JSON",
|
||||||
|
"console",
|
||||||
|
"window",
|
||||||
|
"document",
|
||||||
|
"parseInt",
|
||||||
|
"parseFloat",
|
||||||
|
"NaN",
|
||||||
|
"Infinity",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function extractDeps(expression) {
|
||||||
|
const cleaned = expression.replace(
|
||||||
|
/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`/g,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
const deps = new Set();
|
||||||
|
for (const m of cleaned.matchAll(/\b([a-zA-Z_$][\w$]*)\b/g)) {
|
||||||
|
if (!RESERVED.has(m[1]) && !m[1].startsWith("$")) deps.add(m[1]);
|
||||||
|
}
|
||||||
|
return deps;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Binding graph ───
|
||||||
|
|
||||||
|
class BindingGraph {
|
||||||
|
constructor() {
|
||||||
|
this._map = new Map();
|
||||||
|
}
|
||||||
|
register(binding, deps) {
|
||||||
|
for (const d of deps) {
|
||||||
|
if (!this._map.has(d)) this._map.set(d, new Set());
|
||||||
|
this._map.get(d).add(binding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getAffected(props) {
|
||||||
|
const out = new Set();
|
||||||
|
for (const p of props) {
|
||||||
|
const bs = this._map.get(p);
|
||||||
|
if (bs) for (const b of bs) out.add(b);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── String helpers ───
|
||||||
|
|
||||||
|
function skipString(str, i) {
|
||||||
|
const q = str[i];
|
||||||
|
i++;
|
||||||
|
while (i < str.length && str[i] !== q) {
|
||||||
|
if (str[i] === "\\") i++;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findClosingParen(str, open) {
|
||||||
|
let depth = 1,
|
||||||
|
i = open + 1;
|
||||||
|
while (i < str.length && depth > 0) {
|
||||||
|
if (str[i] === "'" || str[i] === '"' || str[i] === "`") {
|
||||||
|
i = skipString(str, i) + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (str[i] === "(") depth++;
|
||||||
|
else if (str[i] === ")") depth--;
|
||||||
|
if (depth > 0) i++;
|
||||||
|
}
|
||||||
|
if (depth !== 0) throw new Error("Unclosed (");
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findClosingBrace(str, open) {
|
||||||
|
let depth = 1,
|
||||||
|
i = open + 1;
|
||||||
|
while (i < str.length && depth > 0) {
|
||||||
|
if (str[i] === "'" || str[i] === '"' || str[i] === "`") {
|
||||||
|
i = skipString(str, i) + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (str[i] === "{" && str[i + 1] === "{") {
|
||||||
|
const end = str.indexOf("}}", i + 2);
|
||||||
|
if (end === -1) throw new Error("Unclosed {{ }}");
|
||||||
|
i = end + 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (str[i] === "{") depth++;
|
||||||
|
else if (str[i] === "}") depth--;
|
||||||
|
if (depth > 0) i++;
|
||||||
|
}
|
||||||
|
if (depth !== 0) throw new Error("Unclosed {");
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Parse expression with optional format spec ───
|
||||||
|
|
||||||
|
function parseExpression(content) {
|
||||||
|
const colonIdx = content.lastIndexOf(":");
|
||||||
|
if (colonIdx > 0) {
|
||||||
|
const fmt = content.substring(colonIdx + 1).trim();
|
||||||
|
if (/^[0#.,yMdHhmsafzZ\-\/: ]+$/.test(fmt)) {
|
||||||
|
return {
|
||||||
|
expression: content.substring(0, colonIdx).trim(),
|
||||||
|
formatter: createFormatter(fmt),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { expression: content, formatter: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// Parser — template string → instruction tree
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Produces pure-data nodes. No DOM, no strings.
|
||||||
|
//
|
||||||
|
// { type: 'text', value }
|
||||||
|
// { type: 'expr', expression, formatter }
|
||||||
|
// { type: 'element', tag, attrs:[{name, parts}], children }
|
||||||
|
// { type: 'for', varNames, expression, trackExpr, children, empty }
|
||||||
|
// { type: 'if', expression, trueChildren, falseChildren }
|
||||||
|
// { type: 'let', name, expression }
|
||||||
|
|
||||||
|
const VOID_ELEMENTS = new Set([
|
||||||
|
"area",
|
||||||
|
"base",
|
||||||
|
"br",
|
||||||
|
"col",
|
||||||
|
"embed",
|
||||||
|
"hr",
|
||||||
|
"img",
|
||||||
|
"input",
|
||||||
|
"link",
|
||||||
|
"meta",
|
||||||
|
"param",
|
||||||
|
"source",
|
||||||
|
"track",
|
||||||
|
"wbr",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function parse(str) {
|
||||||
|
return parseChildren(str, 0).children;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseChildren(str, pos) {
|
||||||
|
const children = [];
|
||||||
|
|
||||||
|
while (pos < str.length) {
|
||||||
|
// Closing tag → return to parent
|
||||||
|
if (str[pos] === "<" && str[pos + 1] === "/") break;
|
||||||
|
|
||||||
|
// HTML comment (skip)
|
||||||
|
if (str.startsWith("<!--", pos)) {
|
||||||
|
const end = str.indexOf("-->", pos + 4);
|
||||||
|
if (end === -1) throw new Error("Unclosed HTML comment");
|
||||||
|
pos = end + 3;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opening tag
|
||||||
|
if (str[pos] === "<" && /[a-zA-Z]/.test(str[pos + 1])) {
|
||||||
|
const r = parseElement(str, pos);
|
||||||
|
children.push(r.node);
|
||||||
|
pos = r.pos;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @for
|
||||||
|
if (str.startsWith("@for(", pos)) {
|
||||||
|
const r = parseFor(str, pos);
|
||||||
|
children.push(r.node);
|
||||||
|
pos = r.pos;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @if
|
||||||
|
if (str.startsWith("@if(", pos)) {
|
||||||
|
const r = parseIf(str, pos);
|
||||||
|
children.push(r.node);
|
||||||
|
pos = r.pos;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @let
|
||||||
|
if (str.startsWith("@let ", pos)) {
|
||||||
|
const r = parseLet(str, pos);
|
||||||
|
children.push(r.node);
|
||||||
|
pos = r.pos;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text / expressions
|
||||||
|
const r = parseText(str, pos);
|
||||||
|
children.push(...r.nodes);
|
||||||
|
pos = r.pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { children, pos };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseText(str, pos) {
|
||||||
|
const nodes = [];
|
||||||
|
let start = pos;
|
||||||
|
|
||||||
|
while (pos < str.length) {
|
||||||
|
if (str[pos] === "<") break;
|
||||||
|
if (
|
||||||
|
str[pos] === "@" &&
|
||||||
|
(str.startsWith("@for(", pos) ||
|
||||||
|
str.startsWith("@if(", pos) ||
|
||||||
|
str.startsWith("@let ", pos))
|
||||||
|
)
|
||||||
|
break;
|
||||||
|
|
||||||
|
if (str[pos] === "{" && str[pos + 1] === "{") {
|
||||||
|
if (pos > start)
|
||||||
|
nodes.push({ type: "text", value: str.substring(start, pos) });
|
||||||
|
const end = str.indexOf("}}", pos + 2);
|
||||||
|
if (end === -1) throw new Error("Unclosed {{ }}");
|
||||||
|
const { expression, formatter } = parseExpression(
|
||||||
|
str.substring(pos + 2, end).trim(),
|
||||||
|
);
|
||||||
|
nodes.push({ type: "expr", expression, formatter });
|
||||||
|
pos = end + 2;
|
||||||
|
start = pos;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pos > start)
|
||||||
|
nodes.push({ type: "text", value: str.substring(start, pos) });
|
||||||
|
|
||||||
|
return { nodes, pos };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAttrValue(str, pos) {
|
||||||
|
const quote = str[pos];
|
||||||
|
if (quote !== '"' && quote !== "'") {
|
||||||
|
let end = pos;
|
||||||
|
while (end < str.length && !/[\s\/>]/.test(str[end])) end++;
|
||||||
|
return {
|
||||||
|
parts: [{ type: "text", value: str.substring(pos, end) }],
|
||||||
|
pos: end,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pos++;
|
||||||
|
const parts = [];
|
||||||
|
let start = pos;
|
||||||
|
|
||||||
|
while (pos < str.length && str[pos] !== quote) {
|
||||||
|
if (str[pos] === "{" && str[pos + 1] === "{") {
|
||||||
|
if (pos > start)
|
||||||
|
parts.push({ type: "text", value: str.substring(start, pos) });
|
||||||
|
const end = str.indexOf("}}", pos + 2);
|
||||||
|
if (end === -1) throw new Error("Unclosed {{ }} in attribute");
|
||||||
|
const { expression, formatter } = parseExpression(
|
||||||
|
str.substring(pos + 2, end).trim(),
|
||||||
|
);
|
||||||
|
parts.push({ type: "expr", expression, formatter });
|
||||||
|
pos = end + 2;
|
||||||
|
start = pos;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pos > start)
|
||||||
|
parts.push({ type: "text", value: str.substring(start, pos) });
|
||||||
|
|
||||||
|
pos++; // closing quote
|
||||||
|
return { parts, pos };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseElement(str, pos) {
|
||||||
|
pos++; // skip <
|
||||||
|
|
||||||
|
let tagEnd = pos;
|
||||||
|
while (tagEnd < str.length && /[\w\-]/.test(str[tagEnd])) tagEnd++;
|
||||||
|
const tag = str.substring(pos, tagEnd);
|
||||||
|
pos = tagEnd;
|
||||||
|
|
||||||
|
const attrs = [];
|
||||||
|
while (pos < str.length) {
|
||||||
|
while (pos < str.length && /\s/.test(str[pos])) pos++;
|
||||||
|
|
||||||
|
if (str[pos] === ">") {
|
||||||
|
pos++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (str[pos] === "/" && str[pos + 1] === ">") {
|
||||||
|
pos += 2;
|
||||||
|
return { node: { type: "element", tag, attrs, children: [] }, pos };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attribute name — supports (event)="..." syntax
|
||||||
|
let nameEnd = pos;
|
||||||
|
while (nameEnd < str.length && !/[\s=\/>]/.test(str[nameEnd])) nameEnd++;
|
||||||
|
const attrName = str.substring(pos, nameEnd);
|
||||||
|
pos = nameEnd;
|
||||||
|
|
||||||
|
while (pos < str.length && /\s/.test(str[pos])) pos++;
|
||||||
|
|
||||||
|
if (str[pos] !== "=") {
|
||||||
|
attrs.push({ name: attrName, parts: [] }); // boolean attr
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
pos++; // skip =
|
||||||
|
while (pos < str.length && /\s/.test(str[pos])) pos++;
|
||||||
|
|
||||||
|
const r = parseAttrValue(str, pos);
|
||||||
|
attrs.push({ name: attrName, parts: r.parts });
|
||||||
|
pos = r.pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (VOID_ELEMENTS.has(tag.toLowerCase())) {
|
||||||
|
return { node: { type: "element", tag, attrs, children: [] }, pos };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { children, pos: childEnd } = parseChildren(str, pos);
|
||||||
|
pos = childEnd;
|
||||||
|
|
||||||
|
// Consume closing tag
|
||||||
|
if (str[pos] === "<" && str[pos + 1] === "/") {
|
||||||
|
const closeEnd = str.indexOf(">", pos + 2);
|
||||||
|
if (closeEnd !== -1) pos = closeEnd + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { node: { type: "element", tag, attrs, children }, pos };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFor(str, pos) {
|
||||||
|
const parenOpen = pos + 4;
|
||||||
|
const parenClose = findClosingParen(str, parenOpen);
|
||||||
|
const clause = str.substring(parenOpen + 1, parenClose).trim();
|
||||||
|
|
||||||
|
const ofIdx = clause.indexOf(" of ");
|
||||||
|
if (ofIdx === -1) throw new Error('@for missing "of"');
|
||||||
|
const varNames = clause
|
||||||
|
.substring(0, ofIdx)
|
||||||
|
.split(",")
|
||||||
|
.map((v) => v.trim());
|
||||||
|
|
||||||
|
let rest = clause.substring(ofIdx + 4).trim();
|
||||||
|
let trackExpr = null;
|
||||||
|
const trackIdx = rest.indexOf("; track ");
|
||||||
|
if (trackIdx !== -1) {
|
||||||
|
trackExpr = rest.substring(trackIdx + 8).trim();
|
||||||
|
rest = rest.substring(0, trackIdx).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const braceOpen = str.indexOf("{", parenClose + 1);
|
||||||
|
if (braceOpen === -1) throw new Error("@for missing block");
|
||||||
|
const braceClose = findClosingBrace(str, braceOpen);
|
||||||
|
const children = parse(str.substring(braceOpen + 1, braceClose));
|
||||||
|
|
||||||
|
let empty = null;
|
||||||
|
let afterBlock = braceClose + 1;
|
||||||
|
const remaining = str.substring(afterBlock);
|
||||||
|
const emptyMatch = remaining.match(/^\s*@empty\s*\{/);
|
||||||
|
if (emptyMatch) {
|
||||||
|
const emptyBraceOpen = afterBlock + emptyMatch[0].length - 1;
|
||||||
|
const emptyBraceClose = findClosingBrace(str, emptyBraceOpen);
|
||||||
|
empty = parse(str.substring(emptyBraceOpen + 1, emptyBraceClose));
|
||||||
|
afterBlock = emptyBraceClose + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
node: {
|
||||||
|
type: "for",
|
||||||
|
varNames,
|
||||||
|
expression: rest,
|
||||||
|
trackExpr,
|
||||||
|
children,
|
||||||
|
empty,
|
||||||
|
},
|
||||||
|
pos: afterBlock,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIf(str, pos) {
|
||||||
|
const parenOpen = pos + 3;
|
||||||
|
const parenClose = findClosingParen(str, parenOpen);
|
||||||
|
const expression = str.substring(parenOpen + 1, parenClose).trim();
|
||||||
|
|
||||||
|
const trueBraceOpen = str.indexOf("{", parenClose + 1);
|
||||||
|
if (trueBraceOpen === -1) throw new Error("@if missing block");
|
||||||
|
const trueBraceClose = findClosingBrace(str, trueBraceOpen);
|
||||||
|
const trueChildren = parse(str.substring(trueBraceOpen + 1, trueBraceClose));
|
||||||
|
|
||||||
|
let falseChildren = null;
|
||||||
|
let afterBlock = trueBraceClose + 1;
|
||||||
|
const remaining = str.substring(afterBlock);
|
||||||
|
const elseMatch = remaining.match(/^\s*@else\s*\{/);
|
||||||
|
if (elseMatch) {
|
||||||
|
const falseBraceOpen = afterBlock + elseMatch[0].length - 1;
|
||||||
|
const falseBraceClose = findClosingBrace(str, falseBraceOpen);
|
||||||
|
falseChildren = parse(str.substring(falseBraceOpen + 1, falseBraceClose));
|
||||||
|
afterBlock = falseBraceClose + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
node: { type: "if", expression, trueChildren, falseChildren },
|
||||||
|
pos: afterBlock,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLet(str, pos) {
|
||||||
|
const lineEnd = str.indexOf("\n", pos);
|
||||||
|
const end = lineEnd === -1 ? str.length : lineEnd;
|
||||||
|
const decl = str.substring(pos + 5, end).trim();
|
||||||
|
const eqIdx = decl.indexOf("=");
|
||||||
|
if (eqIdx === -1) throw new Error("@let missing =");
|
||||||
|
return {
|
||||||
|
node: {
|
||||||
|
type: "let",
|
||||||
|
name: decl.substring(0, eqIdx).trim(),
|
||||||
|
expression: decl.substring(eqIdx + 1).trim(),
|
||||||
|
},
|
||||||
|
pos: end + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// Bindings — live references to DOM nodes
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TextBinding {
|
||||||
|
constructor(node, expression, formatter) {
|
||||||
|
this.node = node;
|
||||||
|
this.expression = expression;
|
||||||
|
this.formatter = formatter;
|
||||||
|
}
|
||||||
|
update(ctx) {
|
||||||
|
const raw = evaluate(ctx, this.expression);
|
||||||
|
const val = this.formatter ? this.formatter(raw) : raw;
|
||||||
|
const text = val == null ? "" : String(val);
|
||||||
|
if (this.node.textContent !== text) this.node.textContent = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AttrBinding {
|
||||||
|
constructor(element, attrName, parts) {
|
||||||
|
this.element = element;
|
||||||
|
this.attrName = attrName;
|
||||||
|
this.parts = parts;
|
||||||
|
}
|
||||||
|
update(ctx) {
|
||||||
|
let val = "";
|
||||||
|
for (const p of this.parts) {
|
||||||
|
if (p.type === "text") {
|
||||||
|
val += p.value;
|
||||||
|
} else {
|
||||||
|
const raw = evaluate(ctx, p.expression);
|
||||||
|
const v = p.formatter ? p.formatter(raw) : raw;
|
||||||
|
val += v == null ? "" : String(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.element.getAttribute(this.attrName) !== val) {
|
||||||
|
this.element.setAttribute(this.attrName, val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LetBinding {
|
||||||
|
constructor(name, expression) {
|
||||||
|
this.name = name;
|
||||||
|
this.expression = expression;
|
||||||
|
}
|
||||||
|
declare(scope, ctx) {
|
||||||
|
scope.set(this.name, evaluate(ctx, this.expression));
|
||||||
|
}
|
||||||
|
update() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ForBinding {
|
||||||
|
constructor(startMarker, endMarker, instr) {
|
||||||
|
this.startMarker = startMarker;
|
||||||
|
this.endMarker = endMarker;
|
||||||
|
this.varNames = instr.varNames;
|
||||||
|
this.expression = instr.expression;
|
||||||
|
this.trackExpr = instr.trackExpr;
|
||||||
|
this.childInstructions = instr.children;
|
||||||
|
this.emptyInstructions = instr.empty;
|
||||||
|
this.entries = new Map(); // key → { nodes, result }
|
||||||
|
this.emptyNodes = [];
|
||||||
|
this.emptyResult = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
update(ctx) {
|
||||||
|
const list = evaluate(ctx, this.expression);
|
||||||
|
const arr = Array.isArray(list) ? list : list ? Array.from(list) : [];
|
||||||
|
const count = arr.length;
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
this._clearItems();
|
||||||
|
if (this.emptyInstructions && this.emptyNodes.length === 0) {
|
||||||
|
const r = instantiate(this.emptyInstructions, ctx);
|
||||||
|
this.emptyNodes = Array.from(r.fragment.childNodes);
|
||||||
|
this.emptyResult = r;
|
||||||
|
this.endMarker.parentNode.insertBefore(r.fragment, this.endMarker);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._clearEmpty();
|
||||||
|
|
||||||
|
const newKeys = arr.map((item, i) => this._trackKey(item, i, ctx));
|
||||||
|
const newKeySet = new Set(newKeys);
|
||||||
|
|
||||||
|
// Remove stale entries
|
||||||
|
for (const [key, entry] of this.entries) {
|
||||||
|
if (!newKeySet.has(key)) {
|
||||||
|
for (const n of entry.nodes) n.remove();
|
||||||
|
entry.result.destroy();
|
||||||
|
this.entries.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild in correct order
|
||||||
|
const newEntries = new Map();
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const item = arr[i];
|
||||||
|
const key = newKeys[i];
|
||||||
|
const scope = this._makeScope(item, i, count);
|
||||||
|
const proxy = scopeProxy(ctx, scope);
|
||||||
|
|
||||||
|
let entry;
|
||||||
|
if (this.entries.has(key)) {
|
||||||
|
entry = this.entries.get(key);
|
||||||
|
entry.result.update(proxy);
|
||||||
|
} else {
|
||||||
|
const result = instantiate(this.childInstructions, proxy);
|
||||||
|
entry = { nodes: Array.from(result.fragment.childNodes), result };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const n of entry.nodes) {
|
||||||
|
this.endMarker.parentNode.insertBefore(n, this.endMarker);
|
||||||
|
}
|
||||||
|
newEntries.set(key, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.entries = newEntries;
|
||||||
|
}
|
||||||
|
|
||||||
|
_trackKey(item, index, ctx) {
|
||||||
|
if (!this.trackExpr) return item;
|
||||||
|
const scope = new ScopeDict();
|
||||||
|
if (this.varNames.length === 1) {
|
||||||
|
scope.setBuiltin(this.varNames[0], item);
|
||||||
|
} else {
|
||||||
|
for (let v = 0; v < this.varNames.length; v++)
|
||||||
|
scope.setBuiltin(this.varNames[v], item[v]);
|
||||||
|
}
|
||||||
|
scope.setBuiltin("$index", index);
|
||||||
|
return evaluate(scopeProxy(ctx, scope), this.trackExpr);
|
||||||
|
}
|
||||||
|
|
||||||
|
_makeScope(item, index, count) {
|
||||||
|
const scope = new ScopeDict();
|
||||||
|
if (this.varNames.length === 1) {
|
||||||
|
scope.setBuiltin(this.varNames[0], item);
|
||||||
|
} else {
|
||||||
|
for (let v = 0; v < this.varNames.length; v++)
|
||||||
|
scope.setBuiltin(this.varNames[v], item[v]);
|
||||||
|
}
|
||||||
|
scope.setBuiltin("$index", index);
|
||||||
|
scope.setBuiltin("$first", index === 0);
|
||||||
|
scope.setBuiltin("$last", index === count - 1);
|
||||||
|
scope.setBuiltin("$even", index % 2 === 0);
|
||||||
|
scope.setBuiltin("$odd", index % 2 === 1);
|
||||||
|
scope.setBuiltin("$count", count);
|
||||||
|
return scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearItems() {
|
||||||
|
for (const [, entry] of this.entries) {
|
||||||
|
for (const n of entry.nodes) n.remove();
|
||||||
|
entry.result.destroy();
|
||||||
|
}
|
||||||
|
this.entries.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearEmpty() {
|
||||||
|
for (const n of this.emptyNodes) n.remove();
|
||||||
|
this.emptyNodes = [];
|
||||||
|
if (this.emptyResult) {
|
||||||
|
this.emptyResult.destroy();
|
||||||
|
this.emptyResult = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this._clearItems();
|
||||||
|
this._clearEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class IfBinding {
|
||||||
|
constructor(startMarker, endMarker, instr) {
|
||||||
|
this.startMarker = startMarker;
|
||||||
|
this.endMarker = endMarker;
|
||||||
|
this.expression = instr.expression;
|
||||||
|
this.trueInstructions = instr.trueChildren;
|
||||||
|
this.falseInstructions = instr.falseChildren;
|
||||||
|
this.currentBranch = null;
|
||||||
|
this.currentResult = null;
|
||||||
|
this.currentNodes = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
update(ctx) {
|
||||||
|
const branch = evaluate(ctx, this.expression) ? "true" : "false";
|
||||||
|
|
||||||
|
if (branch === this.currentBranch) {
|
||||||
|
if (this.currentResult) this.currentResult.update(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._clear();
|
||||||
|
this.currentBranch = branch;
|
||||||
|
|
||||||
|
const instructions =
|
||||||
|
branch === "true" ? this.trueInstructions : this.falseInstructions;
|
||||||
|
if (!instructions) return;
|
||||||
|
|
||||||
|
this.currentResult = instantiate(instructions, ctx);
|
||||||
|
this.currentNodes = Array.from(this.currentResult.fragment.childNodes);
|
||||||
|
this.endMarker.parentNode.insertBefore(
|
||||||
|
this.currentResult.fragment,
|
||||||
|
this.endMarker,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_clear() {
|
||||||
|
for (const n of this.currentNodes) n.remove();
|
||||||
|
if (this.currentResult) this.currentResult.destroy();
|
||||||
|
this.currentNodes = [];
|
||||||
|
this.currentResult = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this._clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// Instantiate — instruction tree → DOM + bindings
|
||||||
|
// ─────────────────────────────────────────────────
|
||||||
|
// Walks instruction tree once, calls createElement / createTextNode,
|
||||||
|
// stores live node references in Binding objects.
|
||||||
|
|
||||||
|
function instantiate(instructions, ctx, parentScope) {
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
const graph = new BindingGraph();
|
||||||
|
const bindings = [];
|
||||||
|
const scope = parentScope ? parentScope.clone() : new ScopeDict();
|
||||||
|
const proxy = scopeProxy(ctx, scope);
|
||||||
|
|
||||||
|
buildNodes(instructions, fragment, proxy, bindings, graph);
|
||||||
|
|
||||||
|
// Declare @let, then evaluate all bindings once
|
||||||
|
for (const b of bindings) {
|
||||||
|
if (b instanceof LetBinding) b.declare(scope, proxy);
|
||||||
|
}
|
||||||
|
for (const b of bindings) {
|
||||||
|
if (!(b instanceof LetBinding)) b.update(proxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RenderResult(fragment, bindings, graph, scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNodes(instructions, parent, ctx, bindings, graph) {
|
||||||
|
for (const instr of instructions) {
|
||||||
|
switch (instr.type) {
|
||||||
|
case "text": {
|
||||||
|
parent.appendChild(document.createTextNode(instr.value));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "expr": {
|
||||||
|
const node = document.createTextNode("");
|
||||||
|
parent.appendChild(node);
|
||||||
|
const b = new TextBinding(node, instr.expression, instr.formatter);
|
||||||
|
bindings.push(b);
|
||||||
|
graph.register(b, extractDeps(instr.expression));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "element": {
|
||||||
|
const el = document.createElement(instr.tag);
|
||||||
|
|
||||||
|
for (const attr of instr.attrs) {
|
||||||
|
const hasDynamic = attr.parts.some((p) => p.type === "expr");
|
||||||
|
if (!hasDynamic) {
|
||||||
|
const val =
|
||||||
|
attr.parts.length === 0
|
||||||
|
? "" // boolean attr
|
||||||
|
: attr.parts.map((p) => p.value).join("");
|
||||||
|
el.setAttribute(attr.name, val);
|
||||||
|
} else {
|
||||||
|
const deps = new Set();
|
||||||
|
for (const p of attr.parts) {
|
||||||
|
if (p.type === "expr")
|
||||||
|
for (const d of extractDeps(p.expression)) deps.add(d);
|
||||||
|
}
|
||||||
|
const b = new AttrBinding(el, attr.name, attr.parts);
|
||||||
|
bindings.push(b);
|
||||||
|
graph.register(b, deps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildNodes(instr.children, el, ctx, bindings, graph);
|
||||||
|
parent.appendChild(el);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "for": {
|
||||||
|
const start = document.createComment("for");
|
||||||
|
const end = document.createComment("/for");
|
||||||
|
parent.appendChild(start);
|
||||||
|
parent.appendChild(end);
|
||||||
|
const b = new ForBinding(start, end, instr);
|
||||||
|
bindings.push(b);
|
||||||
|
graph.register(b, extractDeps(instr.expression));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "if": {
|
||||||
|
const start = document.createComment("if");
|
||||||
|
const end = document.createComment("/if");
|
||||||
|
parent.appendChild(start);
|
||||||
|
parent.appendChild(end);
|
||||||
|
const b = new IfBinding(start, end, instr);
|
||||||
|
bindings.push(b);
|
||||||
|
graph.register(b, extractDeps(instr.expression));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "let": {
|
||||||
|
bindings.push(new LetBinding(instr.name, instr.expression));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── RenderResult ───
|
||||||
|
|
||||||
|
export class RenderResult {
|
||||||
|
constructor(fragment, bindings, graph, scope) {
|
||||||
|
this.fragment = fragment;
|
||||||
|
this.bindings = bindings;
|
||||||
|
this.graph = graph;
|
||||||
|
this._scope = scope;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-evaluate bindings with new data.
|
||||||
|
* @param {object} data
|
||||||
|
* @param {string[]} [changedProps] — only update bindings for these props
|
||||||
|
*/
|
||||||
|
update(data, changedProps) {
|
||||||
|
const scope = this._scope.clone();
|
||||||
|
const proxy = scopeProxy(data, scope);
|
||||||
|
|
||||||
|
for (const b of this.bindings) {
|
||||||
|
if (b instanceof LetBinding) b.declare(scope, proxy);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changedProps) {
|
||||||
|
for (const b of this.graph.getAffected(changedProps)) {
|
||||||
|
if (!(b instanceof LetBinding)) b.update(proxy);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const b of this.bindings) {
|
||||||
|
if (!(b instanceof LetBinding)) b.update(proxy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
for (const b of this.bindings) {
|
||||||
|
if (b.destroy) b.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public API ───
|
||||||
|
|
||||||
|
export class NodeTemplate {
|
||||||
|
constructor(templateString) {
|
||||||
|
this._instructions = templateString != null ? parse(templateString) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build DOM once from template.
|
||||||
|
* Returns RenderResult with .fragment, .update(), .destroy().
|
||||||
|
*/
|
||||||
|
render(data, parentScope) {
|
||||||
|
if (!this._instructions) throw new Error("No template");
|
||||||
|
return instantiate(this._instructions, data, parentScope);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
OnlyPrompt.Frontend/js/node-test.js
Normal file
34
OnlyPrompt.Frontend/js/node-test.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import {
|
||||||
|
initRouter,
|
||||||
|
Route,
|
||||||
|
ViewChild,
|
||||||
|
Component,
|
||||||
|
ComponentInput,
|
||||||
|
ComponentDefinition,
|
||||||
|
} from "./node-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>
|
||||||
|
<input type="text" [(value)]="name" placeholder="Enter your name" />
|
||||||
|
<p>Hello, {{name()}}! Counter is {{count()}}</p>
|
||||||
|
</div>`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
count = new ComponentInput(0);
|
||||||
|
name = new ComponentInput("World");
|
||||||
|
|
||||||
|
incrementCount() {
|
||||||
|
this.count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const route1 = new Route("counter", TestComponent);
|
||||||
|
const route2 = new Route("counter/:count", TestComponent);
|
||||||
|
initRouter("routerOutlet", "styleOutlet", [route1, route2]);
|
||||||
@ -263,6 +263,7 @@ export class Router {
|
|||||||
this._boundUpdateHandler,
|
this._boundUpdateHandler,
|
||||||
);
|
);
|
||||||
this.renderActiveComponent();
|
this.renderActiveComponent();
|
||||||
|
history.pushState(null, "", routeMatch.path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import './linq.js'
|
import "./linq.js";
|
||||||
|
|
||||||
function selectPath(data, expression) {
|
function selectPath(data, expression) {
|
||||||
if (!expression) return data;
|
if (!expression) return data;
|
||||||
try {
|
try {
|
||||||
return new Function('$data', `with($data) { return (${expression}); }`)(data);
|
return new Function("$data", `with($data) { return (${expression}); }`)(
|
||||||
|
data,
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
console.error(`Error evaluating Template expression: ${expression}`);
|
console.error(`Error evaluating Template expression: ${expression}`);
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -16,8 +18,10 @@ class ScopeDict {
|
|||||||
}
|
}
|
||||||
|
|
||||||
set(name, value) {
|
set(name, value) {
|
||||||
if (name.startsWith('$')) {
|
if (name.startsWith("$")) {
|
||||||
throw new Error(`Cannot declare '${name}' with @let. Variables starting with '$' are reserved.`);
|
throw new Error(
|
||||||
|
`Cannot declare '${name}' with @let. Variables starting with '$' are reserved.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
this._entries[name] = value;
|
this._entries[name] = value;
|
||||||
}
|
}
|
||||||
@ -40,29 +44,32 @@ class ScopeDict {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createScopeProxy(data, scope) {
|
function createScopeProxy(data, scope) {
|
||||||
if (data === null || data === undefined || typeof data !== 'object') {
|
if (data === null || data === undefined || typeof data !== "object") {
|
||||||
scope.setBuiltin('$this', data);
|
scope.setBuiltin("$this", data);
|
||||||
return new Proxy({}, {
|
return new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
get(_target, prop) {
|
get(_target, prop) {
|
||||||
if (prop === '$this') return data;
|
if (prop === "$this") return data;
|
||||||
if (scope.has(prop)) return scope.get(prop);
|
if (scope.has(prop)) return scope.get(prop);
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
has(_target, prop) {
|
has(_target, prop) {
|
||||||
return prop === '$this' || scope.has(prop);
|
return prop === "$this" || scope.has(prop);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
scope.setBuiltin("$this", data);
|
||||||
}
|
|
||||||
scope.setBuiltin('$this', data);
|
|
||||||
return new Proxy(data, {
|
return new Proxy(data, {
|
||||||
get(target, prop) {
|
get(target, prop) {
|
||||||
if (prop === '$this') return data;
|
if (prop === "$this") return data;
|
||||||
if (scope.has(prop)) return scope.get(prop);
|
if (scope.has(prop)) return scope.get(prop);
|
||||||
return target[prop];
|
return target[prop];
|
||||||
},
|
},
|
||||||
has(target, prop) {
|
has(target, prop) {
|
||||||
return prop === '$this' || scope.has(prop) || prop in target;
|
return prop === "$this" || scope.has(prop) || prop in target;
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,7 +77,7 @@ function skipString(str, i) {
|
|||||||
const quote = str[i];
|
const quote = str[i];
|
||||||
i++;
|
i++;
|
||||||
while (i < str.length && str[i] !== quote) {
|
while (i < str.length && str[i] !== quote) {
|
||||||
if (str[i] === '\\') i++;
|
if (str[i] === "\\") i++;
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
return i;
|
return i;
|
||||||
@ -80,15 +87,15 @@ function findClosingParen(str, openPos) {
|
|||||||
let depth = 1;
|
let depth = 1;
|
||||||
let i = openPos + 1;
|
let i = openPos + 1;
|
||||||
while (i < str.length && depth > 0) {
|
while (i < str.length && depth > 0) {
|
||||||
if (str[i] === "'" || str[i] === '"' || str[i] === '`') {
|
if (str[i] === "'" || str[i] === '"' || str[i] === "`") {
|
||||||
i = skipString(str, i) + 1;
|
i = skipString(str, i) + 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (str[i] === '(') depth++;
|
if (str[i] === "(") depth++;
|
||||||
else if (str[i] === ')') depth--;
|
else if (str[i] === ")") depth--;
|
||||||
if (depth > 0) i++;
|
if (depth > 0) i++;
|
||||||
}
|
}
|
||||||
if (depth !== 0) throw new Error('Unclosed ( in template');
|
if (depth !== 0) throw new Error("Unclosed ( in template");
|
||||||
return i;
|
return i;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,41 +103,27 @@ function findClosingBrace(str, openPos) {
|
|||||||
let depth = 1;
|
let depth = 1;
|
||||||
let i = openPos + 1;
|
let i = openPos + 1;
|
||||||
while (i < str.length && depth > 0) {
|
while (i < str.length && depth > 0) {
|
||||||
if (str[i] === "'" || str[i] === '"' || str[i] === '`') {
|
if (str[i] === "'" || str[i] === '"' || str[i] === "`") {
|
||||||
i = skipString(str, i) + 1;
|
i = skipString(str, i) + 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (str[i] === '{' && str[i + 1] === '{') {
|
if (str[i] === "{" && str[i + 1] === "{") {
|
||||||
const end = str.indexOf('}}', i + 2);
|
const end = str.indexOf("}}", i + 2);
|
||||||
if (end === -1) throw new Error('Unclosed {{ }} in template');
|
if (end === -1) throw new Error("Unclosed {{ }} in template");
|
||||||
i = end + 2;
|
i = end + 2;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (str[i] === '{') depth++;
|
if (str[i] === "{") depth++;
|
||||||
else if (str[i] === '}') depth--;
|
else if (str[i] === "}") depth--;
|
||||||
if (depth > 0) i++;
|
if (depth > 0) i++;
|
||||||
}
|
}
|
||||||
if (depth !== 0) throw new Error('Unclosed { in template');
|
if (depth !== 0) throw new Error("Unclosed { in template");
|
||||||
return i;
|
return i;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findTopLevelComma(str) {
|
|
||||||
let depth = 0;
|
|
||||||
for (let i = 0; i < str.length; i++) {
|
|
||||||
if (str[i] === "'" || str[i] === '"' || str[i] === '`') {
|
|
||||||
i = skipString(str, i);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (str[i] === '(') depth++;
|
|
||||||
else if (str[i] === ')') depth--;
|
|
||||||
else if (str[i] === ',' && depth === 0) return i;
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatNumber(value, spec) {
|
function formatNumber(value, spec) {
|
||||||
const hasThousands = spec.includes(',');
|
const hasThousands = spec.includes(",");
|
||||||
const dotIdx = spec.indexOf('.');
|
const dotIdx = spec.indexOf(".");
|
||||||
|
|
||||||
let decimals = 0;
|
let decimals = 0;
|
||||||
if (dotIdx !== -1) {
|
if (dotIdx !== -1) {
|
||||||
@ -140,18 +133,18 @@ function formatNumber(value, spec) {
|
|||||||
let result = value.toFixed(decimals);
|
let result = value.toFixed(decimals);
|
||||||
|
|
||||||
if (hasThousands) {
|
if (hasThousands) {
|
||||||
const parts = result.split('.');
|
const parts = result.split(".");
|
||||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||||
result = parts.join('.');
|
result = parts.join(".");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!spec.includes('.') && !hasThousands) {
|
if (!spec.includes(".") && !hasThousands) {
|
||||||
const padLength = spec.replace(/[^0]/g, '').length;
|
const padLength = spec.replace(/[^0]/g, "").length;
|
||||||
if (padLength > 0) {
|
if (padLength > 0) {
|
||||||
const isNeg = value < 0;
|
const isNeg = value < 0;
|
||||||
let abs = Math.abs(Math.round(value)).toString();
|
let abs = Math.abs(Math.round(value)).toString();
|
||||||
abs = abs.padStart(padLength, '0');
|
abs = abs.padStart(padLength, "0");
|
||||||
result = isNeg ? '-' + abs : abs;
|
result = isNeg ? "-" + abs : abs;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,28 +152,30 @@ function formatNumber(value, spec) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(date, spec) {
|
function formatDate(date, spec) {
|
||||||
const pad = (n, len = 2) => String(n).padStart(len, '0');
|
const pad = (n, len = 2) => String(n).padStart(len, "0");
|
||||||
|
|
||||||
const tokens = {
|
const tokens = {
|
||||||
'yyyy': date.getFullYear(),
|
yyyy: date.getFullYear(),
|
||||||
'yy': String(date.getFullYear()).slice(-2),
|
yy: String(date.getFullYear()).slice(-2),
|
||||||
'MM': pad(date.getMonth() + 1),
|
MM: pad(date.getMonth() + 1),
|
||||||
'M': date.getMonth() + 1,
|
M: date.getMonth() + 1,
|
||||||
'dd': pad(date.getDate()),
|
dd: pad(date.getDate()),
|
||||||
'd': date.getDate(),
|
d: date.getDate(),
|
||||||
'HH': pad(date.getHours()),
|
HH: pad(date.getHours()),
|
||||||
'H': date.getHours(),
|
H: date.getHours(),
|
||||||
'hh': pad(date.getHours() % 12 || 12),
|
hh: pad(date.getHours() % 12 || 12),
|
||||||
'h': date.getHours() % 12 || 12,
|
h: date.getHours() % 12 || 12,
|
||||||
'mm': pad(date.getMinutes()),
|
mm: pad(date.getMinutes()),
|
||||||
'm': date.getMinutes(),
|
m: date.getMinutes(),
|
||||||
'ss': pad(date.getSeconds()),
|
ss: pad(date.getSeconds()),
|
||||||
's': date.getSeconds(),
|
s: date.getSeconds(),
|
||||||
'fff': pad(date.getMilliseconds(), 3),
|
fff: pad(date.getMilliseconds(), 3),
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = spec;
|
let result = spec;
|
||||||
for (const [token, value] of Object.entries(tokens).sort((a, b) => b[0].length - a[0].length)) {
|
for (const [token, value] of Object.entries(tokens).sort(
|
||||||
|
(a, b) => b[0].length - a[0].length,
|
||||||
|
)) {
|
||||||
result = result.replaceAll(token, String(value));
|
result = result.replaceAll(token, String(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -189,14 +184,13 @@ function formatDate(date, spec) {
|
|||||||
|
|
||||||
function createFormatter(formatSpec) {
|
function createFormatter(formatSpec) {
|
||||||
return (value) => {
|
return (value) => {
|
||||||
if (value == null) return '';
|
if (value == null) return "";
|
||||||
if (value instanceof Date) return formatDate(value, formatSpec);
|
if (value instanceof Date) return formatDate(value, formatSpec);
|
||||||
if (typeof value === 'number') return formatNumber(value, formatSpec);
|
if (typeof value === "number") return formatNumber(value, formatSpec);
|
||||||
return String(value);
|
return String(value);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class StaticTemplatePart {
|
class StaticTemplatePart {
|
||||||
constructor(text) {
|
constructor(text) {
|
||||||
this.text = text;
|
this.text = text;
|
||||||
@ -210,7 +204,7 @@ class StaticTemplatePart {
|
|||||||
class VariableTemplatePart {
|
class VariableTemplatePart {
|
||||||
constructor(contextPath, formatter) {
|
constructor(contextPath, formatter) {
|
||||||
this.contextPath = contextPath;
|
this.contextPath = contextPath;
|
||||||
this.formatter = formatter || (x => x);
|
this.formatter = formatter || ((x) => x);
|
||||||
}
|
}
|
||||||
|
|
||||||
render(data) {
|
render(data) {
|
||||||
@ -229,7 +223,7 @@ class LetTemplatePart {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render(_data) {
|
render(_data) {
|
||||||
return '';
|
return "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -237,7 +231,7 @@ class ListTemplatePart {
|
|||||||
constructor(varNames, contextPath, innerTemplate, emptyTemplate, formatter) {
|
constructor(varNames, contextPath, innerTemplate, emptyTemplate, formatter) {
|
||||||
this.varNames = varNames;
|
this.varNames = varNames;
|
||||||
this.contextPath = contextPath;
|
this.contextPath = contextPath;
|
||||||
this.formatter = formatter || (x => x);
|
this.formatter = formatter || ((x) => x);
|
||||||
this.innerTemplate = innerTemplate;
|
this.innerTemplate = innerTemplate;
|
||||||
this.emptyTemplate = emptyTemplate;
|
this.emptyTemplate = emptyTemplate;
|
||||||
}
|
}
|
||||||
@ -247,10 +241,10 @@ class ListTemplatePart {
|
|||||||
const arr = Array.isArray(list) ? list : Array.from(list);
|
const arr = Array.isArray(list) ? list : Array.from(list);
|
||||||
|
|
||||||
if (arr.length === 0) {
|
if (arr.length === 0) {
|
||||||
return this.emptyTemplate ? this.emptyTemplate.render(data) : '';
|
return this.emptyTemplate ? this.emptyTemplate.render(data) : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = '';
|
let result = "";
|
||||||
const count = arr.length;
|
const count = arr.length;
|
||||||
for (let index = 0; index < count; index++) {
|
for (let index = 0; index < count; index++) {
|
||||||
const scope = new ScopeDict();
|
const scope = new ScopeDict();
|
||||||
@ -262,12 +256,12 @@ class ListTemplatePart {
|
|||||||
scope.setBuiltin(this.varNames[v], item[v]);
|
scope.setBuiltin(this.varNames[v], item[v]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
scope.setBuiltin('$index', index);
|
scope.setBuiltin("$index", index);
|
||||||
scope.setBuiltin('$first', index === 0);
|
scope.setBuiltin("$first", index === 0);
|
||||||
scope.setBuiltin('$last', index === count - 1);
|
scope.setBuiltin("$last", index === count - 1);
|
||||||
scope.setBuiltin('$even', index % 2 === 0);
|
scope.setBuiltin("$even", index % 2 === 0);
|
||||||
scope.setBuiltin('$odd', index % 2 === 1);
|
scope.setBuiltin("$odd", index % 2 === 1);
|
||||||
scope.setBuiltin('$count', count);
|
scope.setBuiltin("$count", count);
|
||||||
result += this.innerTemplate.render(data, scope);
|
result += this.innerTemplate.render(data, scope);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -278,7 +272,7 @@ class ListTemplatePart {
|
|||||||
class ConditionalTemplatePart {
|
class ConditionalTemplatePart {
|
||||||
constructor(contextPath, condition, trueTemplate, falseTemplate, formatter) {
|
constructor(contextPath, condition, trueTemplate, falseTemplate, formatter) {
|
||||||
this.contextPath = contextPath;
|
this.contextPath = contextPath;
|
||||||
this.formatter = formatter || (x => x);
|
this.formatter = formatter || ((x) => x);
|
||||||
this.condition = condition;
|
this.condition = condition;
|
||||||
this.trueTemplate = trueTemplate;
|
this.trueTemplate = trueTemplate;
|
||||||
this.falseTemplate = falseTemplate;
|
this.falseTemplate = falseTemplate;
|
||||||
@ -287,9 +281,9 @@ class ConditionalTemplatePart {
|
|||||||
render(data) {
|
render(data) {
|
||||||
const selectedData = this.formatter(selectPath(data, this.contextPath));
|
const selectedData = this.formatter(selectPath(data, this.contextPath));
|
||||||
if (this.condition(selectedData)) {
|
if (this.condition(selectedData)) {
|
||||||
return this.trueTemplate ? this.trueTemplate.render(data) : '';
|
return this.trueTemplate ? this.trueTemplate.render(data) : "";
|
||||||
} else {
|
} else {
|
||||||
return this.falseTemplate ? this.falseTemplate.render(data) : '';
|
return this.falseTemplate ? this.falseTemplate.render(data) : "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -307,13 +301,14 @@ export class Template {
|
|||||||
|
|
||||||
while (i < str.length) {
|
while (i < str.length) {
|
||||||
// {{ expression }} or {{ expression:format }}
|
// {{ expression }} or {{ expression:format }}
|
||||||
if (str[i] === '{' && str[i + 1] === '{') {
|
if (str[i] === "{" && str[i + 1] === "{") {
|
||||||
const end = str.indexOf('}}', i + 2);
|
const end = str.indexOf("}}", i + 2);
|
||||||
if (end === -1) throw new Error('Unclosed {{ }}');
|
if (end === -1) throw new Error("Unclosed {{ }}");
|
||||||
const content = str.substring(i + 2, end).trim();
|
const content = str.substring(i + 2, end).trim();
|
||||||
|
|
||||||
let expression, formatter = null;
|
let expression,
|
||||||
const colonIdx = content.lastIndexOf(':');
|
formatter = null;
|
||||||
|
const colonIdx = content.lastIndexOf(":");
|
||||||
if (colonIdx > 0) {
|
if (colonIdx > 0) {
|
||||||
const possibleFormat = content.substring(colonIdx + 1).trim();
|
const possibleFormat = content.substring(colonIdx + 1).trim();
|
||||||
if (/^[0#.,yMdHhmsafzZ\-\/: ]+$/.test(possibleFormat)) {
|
if (/^[0#.,yMdHhmsafzZ\-\/: ]+$/.test(possibleFormat)) {
|
||||||
@ -332,18 +327,22 @@ export class Template {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// @for(item of expression){...} @empty{...}
|
// @for(item of expression){...} @empty{...}
|
||||||
if (str.startsWith('@for(', i)) {
|
if (str.startsWith("@for(", i)) {
|
||||||
const parenOpen = i + 4;
|
const parenOpen = i + 4;
|
||||||
const parenClose = findClosingParen(str, parenOpen);
|
const parenClose = findClosingParen(str, parenOpen);
|
||||||
const forClause = str.substring(parenOpen + 1, parenClose).trim();
|
const forClause = str.substring(parenOpen + 1, parenClose).trim();
|
||||||
|
|
||||||
const ofIdx = forClause.indexOf(' of ');
|
const ofIdx = forClause.indexOf(" of ");
|
||||||
if (ofIdx === -1) throw new Error('@for missing "of": @for(item of expression)');
|
if (ofIdx === -1)
|
||||||
const varNames = forClause.substring(0, ofIdx).split(',').map(v => v.trim());
|
throw new Error('@for missing "of": @for(item of expression)');
|
||||||
|
const varNames = forClause
|
||||||
|
.substring(0, ofIdx)
|
||||||
|
.split(",")
|
||||||
|
.map((v) => v.trim());
|
||||||
const expression = forClause.substring(ofIdx + 4).trim();
|
const expression = forClause.substring(ofIdx + 4).trim();
|
||||||
|
|
||||||
const braceOpen = str.indexOf('{', parenClose + 1);
|
const braceOpen = str.indexOf("{", parenClose + 1);
|
||||||
if (braceOpen === -1) throw new Error('@for missing block');
|
if (braceOpen === -1) throw new Error("@for missing block");
|
||||||
const braceClose = findClosingBrace(str, braceOpen);
|
const braceClose = findClosingBrace(str, braceOpen);
|
||||||
const blockContent = str.substring(braceOpen + 1, braceClose);
|
const blockContent = str.substring(braceOpen + 1, braceClose);
|
||||||
|
|
||||||
@ -358,26 +357,36 @@ export class Template {
|
|||||||
if (emptyMatch) {
|
if (emptyMatch) {
|
||||||
const emptyBraceOpen = afterBlock + emptyMatch[0].length - 1;
|
const emptyBraceOpen = afterBlock + emptyMatch[0].length - 1;
|
||||||
const emptyBraceClose = findClosingBrace(str, emptyBraceOpen);
|
const emptyBraceClose = findClosingBrace(str, emptyBraceOpen);
|
||||||
const emptyContent = str.substring(emptyBraceOpen + 1, emptyBraceClose);
|
const emptyContent = str.substring(
|
||||||
|
emptyBraceOpen + 1,
|
||||||
|
emptyBraceClose,
|
||||||
|
);
|
||||||
|
|
||||||
emptyTemplate = new Template(null);
|
emptyTemplate = new Template(null);
|
||||||
emptyTemplate.parts = this._parseBlock(emptyContent);
|
emptyTemplate.parts = this._parseBlock(emptyContent);
|
||||||
afterBlock = emptyBraceClose + 1;
|
afterBlock = emptyBraceClose + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
parts.push(new ListTemplatePart(varNames, expression, innerTemplate, emptyTemplate));
|
parts.push(
|
||||||
|
new ListTemplatePart(
|
||||||
|
varNames,
|
||||||
|
expression,
|
||||||
|
innerTemplate,
|
||||||
|
emptyTemplate,
|
||||||
|
),
|
||||||
|
);
|
||||||
i = afterBlock;
|
i = afterBlock;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// @if(expression){...}@else{...}
|
// @if(expression){...}@else{...}
|
||||||
if (str.startsWith('@if(', i)) {
|
if (str.startsWith("@if(", i)) {
|
||||||
const parenOpen = i + 3;
|
const parenOpen = i + 3;
|
||||||
const parenClose = findClosingParen(str, parenOpen);
|
const parenClose = findClosingParen(str, parenOpen);
|
||||||
const expression = str.substring(parenOpen + 1, parenClose).trim();
|
const expression = str.substring(parenOpen + 1, parenClose).trim();
|
||||||
|
|
||||||
const trueBraceOpen = str.indexOf('{', parenClose + 1);
|
const trueBraceOpen = str.indexOf("{", parenClose + 1);
|
||||||
if (trueBraceOpen === -1) throw new Error('@if missing block');
|
if (trueBraceOpen === -1) throw new Error("@if missing block");
|
||||||
const trueBraceClose = findClosingBrace(str, trueBraceOpen);
|
const trueBraceClose = findClosingBrace(str, trueBraceOpen);
|
||||||
const trueContent = str.substring(trueBraceOpen + 1, trueBraceClose);
|
const trueContent = str.substring(trueBraceOpen + 1, trueBraceClose);
|
||||||
|
|
||||||
@ -392,25 +401,35 @@ export class Template {
|
|||||||
if (elseMatch) {
|
if (elseMatch) {
|
||||||
const falseBraceOpen = afterBlock + elseMatch[0].length - 1;
|
const falseBraceOpen = afterBlock + elseMatch[0].length - 1;
|
||||||
const falseBraceClose = findClosingBrace(str, falseBraceOpen);
|
const falseBraceClose = findClosingBrace(str, falseBraceOpen);
|
||||||
const falseContent = str.substring(falseBraceOpen + 1, falseBraceClose);
|
const falseContent = str.substring(
|
||||||
|
falseBraceOpen + 1,
|
||||||
|
falseBraceClose,
|
||||||
|
);
|
||||||
|
|
||||||
falseTemplate = new Template(null);
|
falseTemplate = new Template(null);
|
||||||
falseTemplate.parts = this._parseBlock(falseContent);
|
falseTemplate.parts = this._parseBlock(falseContent);
|
||||||
afterBlock = falseBraceClose + 1;
|
afterBlock = falseBraceClose + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
parts.push(new ConditionalTemplatePart(expression, val => !!val, trueTemplate, falseTemplate));
|
parts.push(
|
||||||
|
new ConditionalTemplatePart(
|
||||||
|
expression,
|
||||||
|
(val) => !!val,
|
||||||
|
trueTemplate,
|
||||||
|
falseTemplate,
|
||||||
|
),
|
||||||
|
);
|
||||||
i = afterBlock;
|
i = afterBlock;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// @let name = expression (ends at newline or end of string)
|
// @let name = expression (ends at newline or end of string)
|
||||||
if (str.startsWith('@let ', i)) {
|
if (str.startsWith("@let ", i)) {
|
||||||
const lineEnd = str.indexOf('\n', i);
|
const lineEnd = str.indexOf("\n", i);
|
||||||
const end = lineEnd === -1 ? str.length : lineEnd;
|
const end = lineEnd === -1 ? str.length : lineEnd;
|
||||||
const declaration = str.substring(i + 5, end).trim();
|
const declaration = str.substring(i + 5, end).trim();
|
||||||
const eqIdx = declaration.indexOf('=');
|
const eqIdx = declaration.indexOf("=");
|
||||||
if (eqIdx === -1) throw new Error('Invalid @let: missing =');
|
if (eqIdx === -1) throw new Error("Invalid @let: missing =");
|
||||||
|
|
||||||
const name = declaration.substring(0, eqIdx).trim();
|
const name = declaration.substring(0, eqIdx).trim();
|
||||||
const expression = declaration.substring(eqIdx + 1).trim();
|
const expression = declaration.substring(eqIdx + 1).trim();
|
||||||
@ -423,12 +442,14 @@ export class Template {
|
|||||||
// Static text — collect until next special token
|
// Static text — collect until next special token
|
||||||
let end = i;
|
let end = i;
|
||||||
while (end < str.length) {
|
while (end < str.length) {
|
||||||
if (str[end] === '{' && str[end + 1] === '{') break;
|
if (str[end] === "{" && str[end + 1] === "{") break;
|
||||||
if (str[end] === '@' && (
|
if (
|
||||||
str.startsWith('@for(', end) ||
|
str[end] === "@" &&
|
||||||
str.startsWith('@if(', end) ||
|
(str.startsWith("@for(", end) ||
|
||||||
str.startsWith('@let ', end)
|
str.startsWith("@if(", end) ||
|
||||||
)) break;
|
str.startsWith("@let ", end))
|
||||||
|
)
|
||||||
|
break;
|
||||||
end++;
|
end++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -445,7 +466,7 @@ export class Template {
|
|||||||
const scope = parentScope.clone();
|
const scope = parentScope.clone();
|
||||||
const proxy = createScopeProxy(data, scope);
|
const proxy = createScopeProxy(data, scope);
|
||||||
|
|
||||||
let result = '';
|
let result = "";
|
||||||
for (const part of this.parts) {
|
for (const part of this.parts) {
|
||||||
part.declareScoped?.(scope, proxy);
|
part.declareScoped?.(scope, proxy);
|
||||||
result += part.render(proxy);
|
result += part.render(proxy);
|
||||||
|
|||||||
@ -22,7 +22,7 @@
|
|||||||
Hello There
|
Hello There
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="./js/test.js"></script>
|
<script type="module" src="./js/node-test.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user