From 11ebf22ec999ccecd6d7c573d0f6d12346b2289a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aeolin=20Ferj=C3=BCnnoz?= Date: Mon, 13 Apr 2026 20:53:26 +0200 Subject: [PATCH] - virtual dom based approach working --- OnlyPrompt.Backend/appsettings.json | 2 +- OnlyPrompt.Frontend/js/node-pwa.js | 541 +++++++++++++ OnlyPrompt.Frontend/js/node-template.js | 958 ++++++++++++++++++++++++ OnlyPrompt.Frontend/js/node-test.js | 34 + OnlyPrompt.Frontend/js/pwa.js | 1 + OnlyPrompt.Frontend/js/template.js | 767 ++++++++++--------- OnlyPrompt.Frontend/test.html | 2 +- 7 files changed, 1930 insertions(+), 375 deletions(-) create mode 100644 OnlyPrompt.Frontend/js/node-pwa.js create mode 100644 OnlyPrompt.Frontend/js/node-template.js create mode 100644 OnlyPrompt.Frontend/js/node-test.js 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 - + \ No newline at end of file