diff --git a/OnlyPrompt.Backend/appsettings.json b/OnlyPrompt.Backend/appsettings.json
index 74c60dc..5680f33 100644
--- a/OnlyPrompt.Backend/appsettings.json
+++ b/OnlyPrompt.Backend/appsettings.json
@@ -1,6 +1,6 @@
{
"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": {
"Issuer": "https://onlyprompts.com",
diff --git a/OnlyPrompt.Frontend/js/node-pwa.js b/OnlyPrompt.Frontend/js/node-pwa.js
new file mode 100644
index 0000000..960965f
--- /dev/null
+++ b/OnlyPrompt.Frontend/js/node-pwa.js
@@ -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(
+ `
Failed to load template
${response.status} ${response.statusText}
`,
+ );
+ }
+
+ templateContent = await response.text();
+ }
+
+ try {
+ const template = new NodeTemplate(templateContent);
+ this.templateCache.set(route.id, template);
+ return template;
+ } catch (error) {
+ return new NodeTemplate(
+ `Failed to compile template
${error.message}
`,
+ );
+ }
+ }
+}
diff --git a/OnlyPrompt.Frontend/js/node-template.js b/OnlyPrompt.Frontend/js/node-template.js
new file mode 100644
index 0000000..6eefaab
--- /dev/null
+++ b/OnlyPrompt.Frontend/js/node-template.js
@@ -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 + 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);
+ }
+}
diff --git a/OnlyPrompt.Frontend/js/node-test.js b/OnlyPrompt.Frontend/js/node-test.js
new file mode 100644
index 0000000..a91333c
--- /dev/null
+++ b/OnlyPrompt.Frontend/js/node-test.js
@@ -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: `
+
Test Component
+
This is a test component.
+
+
+
Hello, {{name()}}! Counter is {{count()}}
+
`,
+ });
+ }
+
+ 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]);
diff --git a/OnlyPrompt.Frontend/js/pwa.js b/OnlyPrompt.Frontend/js/pwa.js
index 2af0d18..1e032d4 100644
--- a/OnlyPrompt.Frontend/js/pwa.js
+++ b/OnlyPrompt.Frontend/js/pwa.js
@@ -263,6 +263,7 @@ export class Router {
this._boundUpdateHandler,
);
this.renderActiveComponent();
+ history.pushState(null, "", routeMatch.path);
}
}
diff --git a/OnlyPrompt.Frontend/js/template.js b/OnlyPrompt.Frontend/js/template.js
index 3bc8e50..aed37a4 100644
--- a/OnlyPrompt.Frontend/js/template.js
+++ b/OnlyPrompt.Frontend/js/template.js
@@ -1,455 +1,476 @@
-import './linq.js'
+import "./linq.js";
function selectPath(data, expression) {
- if (!expression) return data;
- try {
- return new Function('$data', `with($data) { return (${expression}); }`)(data);
- } catch {
- console.error(`Error evaluating Template expression: ${expression}`);
- return undefined;
- }
+ if (!expression) return data;
+ try {
+ return new Function("$data", `with($data) { return (${expression}); }`)(
+ data,
+ );
+ } catch {
+ console.error(`Error evaluating Template expression: ${expression}`);
+ return undefined;
+ }
}
class ScopeDict {
- constructor(initial = {}) {
- this._entries = { ...initial };
- }
+ constructor(initial = {}) {
+ this._entries = { ...initial };
+ }
- set(name, value) {
- if (name.startsWith('$')) {
- throw new Error(`Cannot declare '${name}' with @let. Variables starting with '$' are reserved.`);
- }
- this._entries[name] = value;
+ set(name, value) {
+ if (name.startsWith("$")) {
+ throw new Error(
+ `Cannot declare '${name}' with @let. Variables starting with '$' are reserved.`,
+ );
}
+ this._entries[name] = value;
+ }
- setBuiltin(name, value) {
- this._entries[name] = value;
- }
+ setBuiltin(name, value) {
+ this._entries[name] = value;
+ }
- has(name) {
- return name in this._entries;
- }
+ has(name) {
+ return name in this._entries;
+ }
- get(name) {
- return this._entries[name];
- }
+ get(name) {
+ return this._entries[name];
+ }
- clone() {
- return new ScopeDict(this._entries);
- }
+ clone() {
+ return new ScopeDict(this._entries);
+ }
}
function createScopeProxy(data, scope) {
- if (data === null || data === undefined || typeof data !== 'object') {
- scope.setBuiltin('$this', data);
- return new Proxy({}, {
- get(_target, prop) {
- if (prop === '$this') return data;
- if (scope.has(prop)) return scope.get(prop);
- return undefined;
- },
- has(_target, prop) {
- return prop === '$this' || scope.has(prop);
- }
- });
- }
- scope.setBuiltin('$this', data);
- return new Proxy(data, {
- get(target, prop) {
- if (prop === '$this') return data;
- if (scope.has(prop)) return scope.get(prop);
- return target[prop];
+ if (data === null || data === undefined || typeof data !== "object") {
+ scope.setBuiltin("$this", data);
+ return new Proxy(
+ {},
+ {
+ get(_target, prop) {
+ if (prop === "$this") return data;
+ if (scope.has(prop)) return scope.get(prop);
+ return undefined;
},
- has(target, prop) {
- return prop === '$this' || scope.has(prop) || prop in target;
- }
- });
+ has(_target, prop) {
+ return prop === "$this" || scope.has(prop);
+ },
+ },
+ );
+ }
+ scope.setBuiltin("$this", data);
+ return new Proxy(data, {
+ get(target, prop) {
+ if (prop === "$this") return data;
+ if (scope.has(prop)) return scope.get(prop);
+ return target[prop];
+ },
+ has(target, prop) {
+ return prop === "$this" || scope.has(prop) || prop in target;
+ },
+ });
}
function skipString(str, i) {
- const quote = str[i];
+ const quote = str[i];
+ i++;
+ while (i < str.length && str[i] !== quote) {
+ if (str[i] === "\\") i++;
i++;
- while (i < str.length && str[i] !== quote) {
- if (str[i] === '\\') i++;
- i++;
- }
- return i;
+ }
+ return i;
}
function findClosingParen(str, openPos) {
- let depth = 1;
- let i = openPos + 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++;
+ let depth = 1;
+ let i = openPos + 1;
+ while (i < str.length && depth > 0) {
+ if (str[i] === "'" || str[i] === '"' || str[i] === "`") {
+ i = skipString(str, i) + 1;
+ continue;
}
- if (depth !== 0) throw new Error('Unclosed ( in template');
- return i;
+ if (str[i] === "(") depth++;
+ else if (str[i] === ")") depth--;
+ if (depth > 0) i++;
+ }
+ if (depth !== 0) throw new Error("Unclosed ( in template");
+ return i;
}
function findClosingBrace(str, openPos) {
- let depth = 1;
- let i = openPos + 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 {{ }} in template');
- i = end + 2;
- continue;
- }
- if (str[i] === '{') depth++;
- else if (str[i] === '}') depth--;
- if (depth > 0) i++;
+ let depth = 1;
+ let i = openPos + 1;
+ while (i < str.length && depth > 0) {
+ if (str[i] === "'" || str[i] === '"' || str[i] === "`") {
+ i = skipString(str, i) + 1;
+ continue;
}
- if (depth !== 0) throw new Error('Unclosed { in template');
- 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;
+ if (str[i] === "{" && str[i + 1] === "{") {
+ const end = str.indexOf("}}", i + 2);
+ if (end === -1) throw new Error("Unclosed {{ }} in template");
+ i = end + 2;
+ continue;
}
- return -1;
+ if (str[i] === "{") depth++;
+ else if (str[i] === "}") depth--;
+ if (depth > 0) i++;
+ }
+ if (depth !== 0) throw new Error("Unclosed { in template");
+ return i;
}
function formatNumber(value, spec) {
- const hasThousands = spec.includes(',');
- const dotIdx = spec.indexOf('.');
+ const hasThousands = spec.includes(",");
+ const dotIdx = spec.indexOf(".");
- let decimals = 0;
- if (dotIdx !== -1) {
- decimals = spec.length - dotIdx - 1;
+ let decimals = 0;
+ if (dotIdx !== -1) {
+ decimals = spec.length - dotIdx - 1;
+ }
+
+ 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 padLength = spec.replace(/[^0]/g, "").length;
+ if (padLength > 0) {
+ const isNeg = value < 0;
+ let abs = Math.abs(Math.round(value)).toString();
+ abs = abs.padStart(padLength, "0");
+ result = isNeg ? "-" + abs : abs;
}
+ }
- 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 padLength = spec.replace(/[^0]/g, '').length;
- if (padLength > 0) {
- const isNeg = value < 0;
- let abs = Math.abs(Math.round(value)).toString();
- abs = abs.padStart(padLength, '0');
- result = isNeg ? '-' + abs : abs;
- }
- }
-
- return result;
+ return result;
}
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 = {
- '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),
- };
+ 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 [token, value] of Object.entries(tokens).sort((a, b) => b[0].length - a[0].length)) {
- result = result.replaceAll(token, String(value));
- }
+ let result = spec;
+ for (const [token, value] of Object.entries(tokens).sort(
+ (a, b) => b[0].length - a[0].length,
+ )) {
+ result = result.replaceAll(token, String(value));
+ }
- return result;
+ return result;
}
function createFormatter(formatSpec) {
- return (value) => {
- if (value == null) return '';
- if (value instanceof Date) return formatDate(value, formatSpec);
- if (typeof value === 'number') return formatNumber(value, formatSpec);
- return String(value);
- };
+ return (value) => {
+ if (value == null) return "";
+ if (value instanceof Date) return formatDate(value, formatSpec);
+ if (typeof value === "number") return formatNumber(value, formatSpec);
+ return String(value);
+ };
}
-
class StaticTemplatePart {
- constructor(text) {
- this.text = text;
- }
+ constructor(text) {
+ this.text = text;
+ }
- render(_data) {
- return this.text;
- }
+ render(_data) {
+ return this.text;
+ }
}
class VariableTemplatePart {
- constructor(contextPath, formatter) {
- this.contextPath = contextPath;
- this.formatter = formatter || (x => x);
- }
+ constructor(contextPath, formatter) {
+ this.contextPath = contextPath;
+ this.formatter = formatter || ((x) => x);
+ }
- render(data) {
- return this.formatter(selectPath(data, this.contextPath));
- }
+ render(data) {
+ return this.formatter(selectPath(data, this.contextPath));
+ }
}
class LetTemplatePart {
- constructor(name, expression) {
- this.name = name;
- this.expression = expression;
- }
+ constructor(name, expression) {
+ this.name = name;
+ this.expression = expression;
+ }
- declareScoped(scope, data) {
- scope.set(this.name, selectPath(data, this.expression));
- }
+ declareScoped(scope, data) {
+ scope.set(this.name, selectPath(data, this.expression));
+ }
- render(_data) {
- return '';
- }
+ render(_data) {
+ return "";
+ }
}
class ListTemplatePart {
- constructor(varNames, contextPath, innerTemplate, emptyTemplate, formatter) {
- this.varNames = varNames;
- this.contextPath = contextPath;
- this.formatter = formatter || (x => x);
- this.innerTemplate = innerTemplate;
- this.emptyTemplate = emptyTemplate;
+ constructor(varNames, contextPath, innerTemplate, emptyTemplate, formatter) {
+ this.varNames = varNames;
+ this.contextPath = contextPath;
+ this.formatter = formatter || ((x) => x);
+ this.innerTemplate = innerTemplate;
+ this.emptyTemplate = emptyTemplate;
+ }
+
+ render(data) {
+ const list = this.formatter(selectPath(data, this.contextPath));
+ const arr = Array.isArray(list) ? list : Array.from(list);
+
+ if (arr.length === 0) {
+ return this.emptyTemplate ? this.emptyTemplate.render(data) : "";
}
- render(data) {
- const list = this.formatter(selectPath(data, this.contextPath));
- const arr = Array.isArray(list) ? list : Array.from(list);
-
- if (arr.length === 0) {
- return this.emptyTemplate ? this.emptyTemplate.render(data) : '';
+ let result = "";
+ const count = arr.length;
+ for (let index = 0; index < count; index++) {
+ const scope = new ScopeDict();
+ const item = arr[index];
+ 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]);
}
-
- let result = '';
- const count = arr.length;
- for (let index = 0; index < count; index++) {
- const scope = new ScopeDict();
- const item = arr[index];
- 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);
- result += this.innerTemplate.render(data, scope);
- }
-
- return result;
+ }
+ 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);
+ result += this.innerTemplate.render(data, scope);
}
+
+ return result;
+ }
}
class ConditionalTemplatePart {
- constructor(contextPath, condition, trueTemplate, falseTemplate, formatter) {
- this.contextPath = contextPath;
- this.formatter = formatter || (x => x);
- this.condition = condition;
- this.trueTemplate = trueTemplate;
- this.falseTemplate = falseTemplate;
- }
+ constructor(contextPath, condition, trueTemplate, falseTemplate, formatter) {
+ this.contextPath = contextPath;
+ this.formatter = formatter || ((x) => x);
+ this.condition = condition;
+ this.trueTemplate = trueTemplate;
+ this.falseTemplate = falseTemplate;
+ }
- render(data) {
- const selectedData = this.formatter(selectPath(data, this.contextPath));
- if (this.condition(selectedData)) {
- return this.trueTemplate ? this.trueTemplate.render(data) : '';
- } else {
- return this.falseTemplate ? this.falseTemplate.render(data) : '';
- }
+ render(data) {
+ const selectedData = this.formatter(selectPath(data, this.contextPath));
+ if (this.condition(selectedData)) {
+ return this.trueTemplate ? this.trueTemplate.render(data) : "";
+ } else {
+ return this.falseTemplate ? this.falseTemplate.render(data) : "";
}
+ }
}
export class Template {
- constructor(templateString) {
- if (templateString != null) {
- this.parts = this._parseBlock(templateString);
- }
+ constructor(templateString) {
+ if (templateString != null) {
+ this.parts = this._parseBlock(templateString);
}
+ }
- _parseBlock(str) {
- const parts = [];
- let i = 0;
+ _parseBlock(str) {
+ const parts = [];
+ let i = 0;
- while (i < str.length) {
- // {{ expression }} or {{ expression:format }}
- if (str[i] === '{' && str[i + 1] === '{') {
- const end = str.indexOf('}}', i + 2);
- if (end === -1) throw new Error('Unclosed {{ }}');
- const content = str.substring(i + 2, end).trim();
+ while (i < str.length) {
+ // {{ expression }} or {{ expression:format }}
+ if (str[i] === "{" && str[i + 1] === "{") {
+ const end = str.indexOf("}}", i + 2);
+ if (end === -1) throw new Error("Unclosed {{ }}");
+ const content = str.substring(i + 2, end).trim();
- let expression, formatter = null;
- const colonIdx = content.lastIndexOf(':');
- if (colonIdx > 0) {
- const possibleFormat = content.substring(colonIdx + 1).trim();
- if (/^[0#.,yMdHhmsafzZ\-\/: ]+$/.test(possibleFormat)) {
- expression = content.substring(0, colonIdx).trim();
- formatter = createFormatter(possibleFormat);
- } else {
- expression = content;
- }
- } else {
- expression = content;
- }
-
- parts.push(new VariableTemplatePart(expression, formatter));
- i = end + 2;
- continue;
- }
-
- // @for(item of expression){...} @empty{...}
- if (str.startsWith('@for(', i)) {
- const parenOpen = i + 4;
- const parenClose = findClosingParen(str, parenOpen);
- const forClause = str.substring(parenOpen + 1, parenClose).trim();
-
- const ofIdx = forClause.indexOf(' of ');
- if (ofIdx === -1) 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 braceOpen = str.indexOf('{', parenClose + 1);
- if (braceOpen === -1) throw new Error('@for missing block');
- const braceClose = findClosingBrace(str, braceOpen);
- const blockContent = str.substring(braceOpen + 1, braceClose);
-
- const innerTemplate = new Template(null);
- innerTemplate.parts = this._parseBlock(blockContent);
-
- let emptyTemplate = 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);
- const emptyContent = str.substring(emptyBraceOpen + 1, emptyBraceClose);
-
- emptyTemplate = new Template(null);
- emptyTemplate.parts = this._parseBlock(emptyContent);
- afterBlock = emptyBraceClose + 1;
- }
-
- parts.push(new ListTemplatePart(varNames, expression, innerTemplate, emptyTemplate));
- i = afterBlock;
- continue;
- }
-
- // @if(expression){...}@else{...}
- if (str.startsWith('@if(', i)) {
- const parenOpen = i + 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 trueContent = str.substring(trueBraceOpen + 1, trueBraceClose);
-
- const trueTemplate = new Template(null);
- trueTemplate.parts = this._parseBlock(trueContent);
-
- let falseTemplate = 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);
- const falseContent = str.substring(falseBraceOpen + 1, falseBraceClose);
-
- falseTemplate = new Template(null);
- falseTemplate.parts = this._parseBlock(falseContent);
- afterBlock = falseBraceClose + 1;
- }
-
- parts.push(new ConditionalTemplatePart(expression, val => !!val, trueTemplate, falseTemplate));
- i = afterBlock;
- continue;
- }
-
- // @let name = expression (ends at newline or end of string)
- if (str.startsWith('@let ', i)) {
- const lineEnd = str.indexOf('\n', i);
- const end = lineEnd === -1 ? str.length : lineEnd;
- const declaration = str.substring(i + 5, end).trim();
- const eqIdx = declaration.indexOf('=');
- if (eqIdx === -1) throw new Error('Invalid @let: missing =');
-
- const name = declaration.substring(0, eqIdx).trim();
- const expression = declaration.substring(eqIdx + 1).trim();
-
- parts.push(new LetTemplatePart(name, expression));
- i = end + 1;
- continue;
- }
-
- // Static text — collect until next special token
- let end = i;
- while (end < str.length) {
- if (str[end] === '{' && str[end + 1] === '{') break;
- if (str[end] === '@' && (
- str.startsWith('@for(', end) ||
- str.startsWith('@if(', end) ||
- str.startsWith('@let ', end)
- )) break;
- end++;
- }
-
- if (end > i) {
- parts.push(new StaticTemplatePart(str.substring(i, end)));
- i = end;
- }
+ let expression,
+ formatter = null;
+ const colonIdx = content.lastIndexOf(":");
+ if (colonIdx > 0) {
+ const possibleFormat = content.substring(colonIdx + 1).trim();
+ if (/^[0#.,yMdHhmsafzZ\-\/: ]+$/.test(possibleFormat)) {
+ expression = content.substring(0, colonIdx).trim();
+ formatter = createFormatter(possibleFormat);
+ } else {
+ expression = content;
+ }
+ } else {
+ expression = content;
}
- return parts;
- }
+ parts.push(new VariableTemplatePart(expression, formatter));
+ i = end + 2;
+ continue;
+ }
- render(data, parentScope = new ScopeDict()) {
- const scope = parentScope.clone();
- const proxy = createScopeProxy(data, scope);
+ // @for(item of expression){...} @empty{...}
+ if (str.startsWith("@for(", i)) {
+ const parenOpen = i + 4;
+ const parenClose = findClosingParen(str, parenOpen);
+ const forClause = str.substring(parenOpen + 1, parenClose).trim();
- let result = '';
- for (const part of this.parts) {
- part.declareScoped?.(scope, proxy);
- result += part.render(proxy);
+ const ofIdx = forClause.indexOf(" of ");
+ if (ofIdx === -1)
+ 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 braceOpen = str.indexOf("{", parenClose + 1);
+ if (braceOpen === -1) throw new Error("@for missing block");
+ const braceClose = findClosingBrace(str, braceOpen);
+ const blockContent = str.substring(braceOpen + 1, braceClose);
+
+ const innerTemplate = new Template(null);
+ innerTemplate.parts = this._parseBlock(blockContent);
+
+ let emptyTemplate = 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);
+ const emptyContent = str.substring(
+ emptyBraceOpen + 1,
+ emptyBraceClose,
+ );
+
+ emptyTemplate = new Template(null);
+ emptyTemplate.parts = this._parseBlock(emptyContent);
+ afterBlock = emptyBraceClose + 1;
}
- return result;
+
+ parts.push(
+ new ListTemplatePart(
+ varNames,
+ expression,
+ innerTemplate,
+ emptyTemplate,
+ ),
+ );
+ i = afterBlock;
+ continue;
+ }
+
+ // @if(expression){...}@else{...}
+ if (str.startsWith("@if(", i)) {
+ const parenOpen = i + 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 trueContent = str.substring(trueBraceOpen + 1, trueBraceClose);
+
+ const trueTemplate = new Template(null);
+ trueTemplate.parts = this._parseBlock(trueContent);
+
+ let falseTemplate = 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);
+ const falseContent = str.substring(
+ falseBraceOpen + 1,
+ falseBraceClose,
+ );
+
+ falseTemplate = new Template(null);
+ falseTemplate.parts = this._parseBlock(falseContent);
+ afterBlock = falseBraceClose + 1;
+ }
+
+ parts.push(
+ new ConditionalTemplatePart(
+ expression,
+ (val) => !!val,
+ trueTemplate,
+ falseTemplate,
+ ),
+ );
+ i = afterBlock;
+ continue;
+ }
+
+ // @let name = expression (ends at newline or end of string)
+ if (str.startsWith("@let ", i)) {
+ const lineEnd = str.indexOf("\n", i);
+ const end = lineEnd === -1 ? str.length : lineEnd;
+ const declaration = str.substring(i + 5, end).trim();
+ const eqIdx = declaration.indexOf("=");
+ if (eqIdx === -1) throw new Error("Invalid @let: missing =");
+
+ const name = declaration.substring(0, eqIdx).trim();
+ const expression = declaration.substring(eqIdx + 1).trim();
+
+ parts.push(new LetTemplatePart(name, expression));
+ i = end + 1;
+ continue;
+ }
+
+ // Static text — collect until next special token
+ let end = i;
+ while (end < str.length) {
+ if (str[end] === "{" && str[end + 1] === "{") break;
+ if (
+ str[end] === "@" &&
+ (str.startsWith("@for(", end) ||
+ str.startsWith("@if(", end) ||
+ str.startsWith("@let ", end))
+ )
+ break;
+ end++;
+ }
+
+ if (end > i) {
+ parts.push(new StaticTemplatePart(str.substring(i, end)));
+ i = end;
+ }
}
+
+ return parts;
+ }
+
+ render(data, parentScope = new ScopeDict()) {
+ const scope = parentScope.clone();
+ const proxy = createScopeProxy(data, scope);
+
+ let result = "";
+ for (const part of this.parts) {
+ part.declareScoped?.(scope, proxy);
+ result += part.render(proxy);
+ }
+ return result;
+ }
}
diff --git a/OnlyPrompt.Frontend/test.html b/OnlyPrompt.Frontend/test.html
index 81e54f4..337a3c3 100644
--- a/OnlyPrompt.Frontend/test.html
+++ b/OnlyPrompt.Frontend/test.html
@@ -22,7 +22,7 @@
Hello There
-
+