From 870630063aa5c1fe8c304b521683df92ef81f6a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aeolin=20Ferj=C3=BCnnoz?= Date: Tue, 14 Apr 2026 15:00:11 +0200 Subject: [PATCH] - fixed two way binding - added support for list like attributes to toggle classes for example --- OnlyPrompt.Frontend/js/node-pwa.js | 46 ++-- OnlyPrompt.Frontend/js/node-template.js | 333 +++++++++++++++--------- OnlyPrompt.Frontend/js/node-test.js | 42 ++- OnlyPrompt.Frontend/js/pwa.js | 7 +- OnlyPrompt.Frontend/js/test.js | 30 ++- OnlyPrompt.Frontend/js/utils.js | 28 ++ OnlyPrompt.Frontend/test.html | 6 +- 7 files changed, 333 insertions(+), 159 deletions(-) diff --git a/OnlyPrompt.Frontend/js/node-pwa.js b/OnlyPrompt.Frontend/js/node-pwa.js index b3a8a26..d77f125 100644 --- a/OnlyPrompt.Frontend/js/node-pwa.js +++ b/OnlyPrompt.Frontend/js/node-pwa.js @@ -16,7 +16,7 @@ export class Route { constructor(pattern, componentClass) { this.id = crypto.randomUUID(); this.componentClass = componentClass; - this.ComponentDefinition = componentClass.definition; + this.componentDefinition = componentClass.definition; this.fragments = pattern.split("/"); } @@ -27,9 +27,7 @@ export class Route { params[key] = value; }); - const pathFragments = parsedUrl.pathname - .split("/") - .filter((f) => f.length > 0); + const pathFragments = parsedUrl.pathname.split("/"); for (let i = 0; i < this.fragments.length; i++) { const fragment = this.fragments[i]; @@ -144,12 +142,14 @@ export class ComponentDefinition { templatesPath: null, template: null, stylesPath: null, + style: null, scriptsPath: null, }, ) { this.templatesPath = options.templatesPath; this.template = options.template; this.stylesPath = options.stylesPath; + this.style = options.style; this.scriptsPath = options.scriptsPath; } } @@ -257,6 +257,7 @@ export class Router { } async handleNavigate(event) { + if (event.navigationType === 'replace' && event.destination.url === this.activeRoute) return; const routeMatch = this.findMatchingRoute(event.destination.url); if (!routeMatch) return; @@ -270,6 +271,7 @@ export class Router { this.destroyCurrent(); this.activeController = controller; this.activeTemplate = template; + this.activeRoute = event.destination.url; this.insertIncludes(routeMatch.route); this._boundUpdateHandler = this.handleUpdateRequest.bind(this); @@ -279,7 +281,7 @@ export class Router { ); this.renderActiveComponent(); - history.pushState(null, "", routeMatch.path); + history.replaceState({}, "", event.destination.url); } destroyCurrent() { @@ -308,9 +310,17 @@ export class Router { insertIncludes(route) { // Remove previous component styles from shadow root this.shadowRoot - .querySelectorAll("link[data-component-style]") + .querySelectorAll("link[data-component-style], style[data-component-style]") .forEach((l) => l.remove()); - const paths = route.ComponentDefinition.stylesPath; + + if (route.componentDefinition.style) { + const styleEl = document.createElement("style"); + styleEl.textContent = route.componentDefinition.style; + styleEl.setAttribute("data-component-style", ""); + this.shadowRoot.appendChild(styleEl); + } + + const paths = route.componentDefinition.stylesPath; if (!paths) return; const list = Array.isArray(paths) ? paths : [paths]; for (const p of list) { @@ -400,20 +410,20 @@ export class Router { const allElements = root.querySelectorAll("*"); allElements.forEach((element) => { + // ─── Two-way bindings from __templateMeta ─── + if (element.__templateMeta) { + for (const meta of element.__templateMeta) { + if (meta.bracket === "[()]" && meta.expression) { + const domProp = meta.name.replace(/^\(|\)$/g, ""); // strip parens + this._bindTwoWay(controller, element, domProp, meta.expression); + } + } + } + + // ─── Event bindings from DOM attributes ─── 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]; diff --git a/OnlyPrompt.Frontend/js/node-template.js b/OnlyPrompt.Frontend/js/node-template.js index 6eefaab..9fe4236 100644 --- a/OnlyPrompt.Frontend/js/node-template.js +++ b/OnlyPrompt.Frontend/js/node-template.js @@ -1,4 +1,5 @@ import "./linq.js"; +import { formatDate } from "./utils.js"; // ─── Expression evaluation ─── @@ -87,33 +88,7 @@ function formatNumber(value, spec) { 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) => { @@ -196,6 +171,11 @@ class BindingGraph { // ─── String helpers ─── +function skipWhitespace(str, pos) { + while (pos < str.length && /\s/.test(str[pos])) pos++; + return pos; +} + function skipString(str, i) { const q = str[i]; i++; @@ -260,18 +240,67 @@ function parseExpression(content) { return { expression: content, formatter: null }; } +// ───────────────────────────────────────────────── +// Instruction classes — parser output +// ───────────────────────────────────────────────── + +class TextInstr { + constructor(value) { + this.value = value; + } +} + +class ExprInstr { + constructor(expression, formatter) { + this.expression = expression; + this.formatter = formatter; + } +} + +class ElementInstr { + constructor(tag, attrs, children) { + this.tag = tag; + this.attrs = attrs; + this.children = children; + } +} + +class AttrInstr { + constructor(name, parts, bracket = null) { + this.name = name; + this.parts = parts; + this.bracket = bracket; // null | "[]" | "[()]" + } +} + +class ForInstr { + constructor(varNames, expression, trackExpr, children, empty) { + this.varNames = varNames; + this.expression = expression; + this.trackExpr = trackExpr; + this.children = children; + this.empty = empty; + } +} + +class IfInstr { + constructor(expression, trueChildren, falseChildren) { + this.expression = expression; + this.trueChildren = trueChildren; + this.falseChildren = falseChildren; + } +} + +class LetInstr { + constructor(name, expression) { + this.name = name; + this.expression = expression; + } +} + // ───────────────────────────────────────────────── // 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", @@ -366,13 +395,13 @@ function parseText(str, pos) { if (str[pos] === "{" && str[pos + 1] === "{") { if (pos > start) - nodes.push({ type: "text", value: str.substring(start, pos) }); + nodes.push(new TextInstr(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 }); + nodes.push(new ExprInstr(expression, formatter)); pos = end + 2; start = pos; continue; @@ -382,7 +411,7 @@ function parseText(str, pos) { } if (pos > start) - nodes.push({ type: "text", value: str.substring(start, pos) }); + nodes.push(new TextInstr(str.substring(start, pos))); return { nodes, pos }; } @@ -393,7 +422,7 @@ function parseAttrValue(str, pos) { let end = pos; while (end < str.length && !/[\s\/>]/.test(str[end])) end++; return { - parts: [{ type: "text", value: str.substring(pos, end) }], + parts: [new TextInstr(str.substring(pos, end))], pos: end, }; } @@ -405,13 +434,13 @@ function parseAttrValue(str, 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) }); + parts.push(new TextInstr(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 }); + parts.push(new ExprInstr(expression, formatter)); pos = end + 2; start = pos; continue; @@ -420,7 +449,7 @@ function parseAttrValue(str, pos) { } if (pos > start) - parts.push({ type: "text", value: str.substring(start, pos) }); + parts.push(new TextInstr(str.substring(start, pos))); pos++; // closing quote return { parts, pos }; @@ -436,7 +465,7 @@ function parseElement(str, pos) { const attrs = []; while (pos < str.length) { - while (pos < str.length && /\s/.test(str[pos])) pos++; + pos = skipWhitespace(str, pos); if (str[pos] === ">") { pos++; @@ -444,32 +473,51 @@ function parseElement(str, pos) { } if (str[pos] === "/" && str[pos + 1] === ">") { pos += 2; - return { node: { type: "element", tag, attrs, children: [] }, pos }; + return { node: new ElementInstr(tag, attrs, []), pos }; } - // Attribute name — supports (event)="..." syntax + // Attribute name — supports (event)="...", [attr]="...", [(attr)]="..." syntax let nameEnd = pos; while (nameEnd < str.length && !/[\s=\/>]/.test(str[nameEnd])) nameEnd++; - const attrName = str.substring(pos, nameEnd); + const rawAttrName = str.substring(pos, nameEnd); pos = nameEnd; - while (pos < str.length && /\s/.test(str[pos])) pos++; + // [attr]="expr" or [(attr)]="expr" — entire value is a single expression + const bracketMatch = rawAttrName.match(/^\[(\(?\w[\w.\-]*\)?)]/); + const isBracketExpr = bracketMatch !== null; + const attrName = isBracketExpr ? bracketMatch[1] : rawAttrName; + const bracket = isBracketExpr + ? (attrName.startsWith("(") && attrName.endsWith(")") ? "[()]" : "[]") + : null; + + pos = skipWhitespace(str, pos); if (str[pos] !== "=") { - attrs.push({ name: attrName, parts: [] }); // boolean attr + attrs.push(new AttrInstr(attrName, [])); // boolean attr continue; } pos++; // skip = - while (pos < str.length && /\s/.test(str[pos])) pos++; + pos = skipWhitespace(str, pos); - const r = parseAttrValue(str, pos); - attrs.push({ name: attrName, parts: r.parts }); - pos = r.pos; + if (isBracketExpr) { + const quote = str[pos]; + if (quote !== '"' && quote !== "'") throw new Error(`[${attrName}] requires a quoted value`); + pos++; // skip opening quote + const closeQuote = str.indexOf(quote, pos); + if (closeQuote === -1) throw new Error(`Unclosed quote for [${attrName}]`); + const { expression, formatter } = parseExpression(str.substring(pos, closeQuote).trim()); + attrs.push(new AttrInstr(attrName, [new ExprInstr(expression, formatter)], bracket)); + pos = closeQuote + 1; + } else { + const r = parseAttrValue(str, pos); + attrs.push(new AttrInstr(attrName, r.parts)); + pos = r.pos; + } } if (VOID_ELEMENTS.has(tag.toLowerCase())) { - return { node: { type: "element", tag, attrs, children: [] }, pos }; + return { node: new ElementInstr(tag, attrs, []), pos }; } const { children, pos: childEnd } = parseChildren(str, pos); @@ -481,7 +529,7 @@ function parseElement(str, pos) { if (closeEnd !== -1) pos = closeEnd + 1; } - return { node: { type: "element", tag, attrs, children }, pos }; + return { node: new ElementInstr(tag, attrs, children), pos }; } function parseFor(str, pos) { @@ -521,14 +569,7 @@ function parseFor(str, pos) { } return { - node: { - type: "for", - varNames, - expression: rest, - trackExpr, - children, - empty, - }, + node: new ForInstr(varNames, rest, trackExpr, children, empty), pos: afterBlock, }; } @@ -555,7 +596,7 @@ function parseIf(str, pos) { } return { - node: { type: "if", expression, trueChildren, falseChildren }, + node: new IfInstr(expression, trueChildren, falseChildren), pos: afterBlock, }; } @@ -567,11 +608,10 @@ function parseLet(str, pos) { 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(), - }, + node: new LetInstr( + decl.substring(0, eqIdx).trim(), + decl.substring(eqIdx + 1).trim(), + ), pos: end + 1, }; } @@ -603,7 +643,7 @@ class AttrBinding { update(ctx) { let val = ""; for (const p of this.parts) { - if (p.type === "text") { + if (p instanceof TextInstr) { val += p.value; } else { const raw = evaluate(ctx, p.expression); @@ -617,6 +657,31 @@ class AttrBinding { } } +class AttrListBinding { + constructor(element, attrName, attrListPart, expression) { + this.element = element; + this.attrName = attrName; + this.attrListPart = attrListPart.trim(); + this.expression = expression; + this.pattern = new RegExp(`\\b${attrListPart}\\b`); + } + + update(ctx) { + const val = evaluate(ctx, this.expression); + const attrList = (this.element.getAttribute(this.attrName) ?? "").trim(); + const match = this.pattern.exec(attrList); + if (val && !match) { + // works because upper trim() removes all whitespace, so empty string means no attributes + this.element.setAttribute(this.attrName, attrList.length === 0 ? this.attrListPart : `${attrList} ${this.attrListPart}`); + } else if (!val && match) { + const start = match.index; + const end = match.index + this.attrListPart.length; + const newAttrList = attrList.slice(0, start) + attrList.slice(end); + this.element.setAttribute(this.attrName, newAttrList.trim()); + } + } +} + class LetBinding { constructor(name, expression) { this.name = name; @@ -625,7 +690,7 @@ class LetBinding { declare(scope, ctx) { scope.set(this.name, evaluate(ctx, this.expression)); } - update() {} + update() { } } class ForBinding { @@ -826,75 +891,85 @@ function instantiate(instructions, ctx, parentScope) { function buildNodes(instructions, parent, ctx, bindings, graph) { for (const instr of instructions) { - switch (instr.type) { - case "text": { - parent.appendChild(document.createTextNode(instr.value)); - break; - } + if (instr instanceof TextInstr) { + parent.appendChild(document.createTextNode(instr.value)); + } else if (instr instanceof ExprInstr) { + 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)); + } else if (instr instanceof ElementInstr) { + const el = document.createElement(instr.tag); - 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; - } + for (const attr of instr.attrs) { + // Bracket attrs ([]/[()]) → stash as metadata, not real DOM attrs + if (attr.bracket) { + if (!el.__templateMeta) el.__templateMeta = []; + const expr = attr.parts.length === 1 && attr.parts[0] instanceof ExprInstr + ? attr.parts[0].expression : null; + el.__templateMeta.push({ name: attr.name, bracket: attr.bracket, expression: expr }); - 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 { + // Still create bindings for [] attrs that affect rendering + if (attr.bracket === "[]") { const deps = new Set(); for (const p of attr.parts) { - if (p.type === "expr") + if (p instanceof ExprInstr) { for (const d of extractDeps(p.expression)) deps.add(d); + } + } + + if (attr.name.includes(".") && attr.parts.length === 1 && attr.parts[0] instanceof ExprInstr) { + const [attrName, attrListPart] = attr.name.split("."); + const b = new AttrListBinding(el, attrName, attrListPart, attr.parts[0].expression); + bindings.push(b); + graph.register(b, deps); + } else { + const b = new AttrBinding(el, attr.name, attr.parts); + bindings.push(b); + graph.register(b, deps); } - const b = new AttrBinding(el, attr.name, attr.parts); - bindings.push(b); - graph.register(b, deps); } + continue; } - buildNodes(instr.children, el, ctx, bindings, graph); - parent.appendChild(el); - break; + const hasDynamic = attr.parts.some((p) => p instanceof ExprInstr); + if (!hasDynamic) { + const val = attr.parts.length === 0 ? "" : 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 instanceof ExprInstr) { + 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); + } } - 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; - } + buildNodes(instr.children, el, ctx, bindings, graph); + parent.appendChild(el); + } else if (instr instanceof ForInstr) { + 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)); + } else if (instr instanceof IfInstr) { + 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)); + } else if (instr instanceof LetInstr) { + bindings.push(new LetBinding(instr.name, instr.expression)); } } } diff --git a/OnlyPrompt.Frontend/js/node-test.js b/OnlyPrompt.Frontend/js/node-test.js index d405f5b..e18c50e 100644 --- a/OnlyPrompt.Frontend/js/node-test.js +++ b/OnlyPrompt.Frontend/js/node-test.js @@ -8,10 +8,46 @@ class TestComponent extends Component { template: `

Test Component

This is a test component.

- +

Hello, {{name()}}! Counter is {{count()}}

`, + style: ` + .red-button { + background-color: red; + color: white; + border: none; + padding: 10px 20px; + font-size: 16px; + cursor: pointer; + } + + .green-button { + background-color: green; + color: white; + border: none; + padding: 10px 20px; + font-size: 16px; + cursor: pointer; + } + + .counter-button { + border-radius: 10px; + margin: 15px 30px; + font-size: 18px; + transition: opacity 0.3s ease; + } + + .counter-button:hover { + opacity: 0.8; + } + ` }); } @@ -24,6 +60,6 @@ class TestComponent extends Component { } } -const route1 = new Route("counter", TestComponent); -const route2 = new Route("counter/:count", TestComponent); +const route1 = new Route("/counter", TestComponent); +const route2 = new Route("/counter/:count", TestComponent); initRouter("routerOutlet", [route1, route2]); diff --git a/OnlyPrompt.Frontend/js/pwa.js b/OnlyPrompt.Frontend/js/pwa.js index 1e032d4..7e57911 100644 --- a/OnlyPrompt.Frontend/js/pwa.js +++ b/OnlyPrompt.Frontend/js/pwa.js @@ -136,6 +136,7 @@ export class ComponentDefinition { templatesPath: null, template: null, stylesPath: null, + style: null, scriptsPath: null, }, ) { @@ -199,9 +200,9 @@ export class Component extends EventTarget { throw new Error("Component definition is not defined"); } - onInit() {} - onBeforeRender() {} - onAfterRender() {} + onInit() { } + onBeforeRender() { } + onAfterRender() { } onDestroy() { this._eventListeners.forEach((listener) => listener.detach()); } diff --git a/OnlyPrompt.Frontend/js/test.js b/OnlyPrompt.Frontend/js/test.js index 7984e67..95b35ae 100644 --- a/OnlyPrompt.Frontend/js/test.js +++ b/OnlyPrompt.Frontend/js/test.js @@ -14,8 +14,32 @@ class TestComponent extends Component { template: `

Test Component

This is a test component.

- +
`, + style: ` + .red-button { + background-color: red; + color: white; + border: none; + padding: 10px 20px; + font-size: 16px; + cursor: pointer; + } + + .green-button { + background-color: green; + color: white; + border: none; + padding: 10px 20px; + font-size: 16px; + cursor: pointer; + } + ` }); } @@ -26,6 +50,6 @@ class TestComponent extends Component { } } -const route1 = new Route("counter", TestComponent); -const route2 = new Route("counter/:count", TestComponent); +const route1 = new Route("/counter", TestComponent); +const route2 = new Route("/counter/:count", TestComponent); initRouter("routerOutlet", "styleOutlet", [route1, route2]); diff --git a/OnlyPrompt.Frontend/js/utils.js b/OnlyPrompt.Frontend/js/utils.js index 39b9f2d..e9cc0dc 100644 --- a/OnlyPrompt.Frontend/js/utils.js +++ b/OnlyPrompt.Frontend/js/utils.js @@ -152,6 +152,34 @@ export function parseDate(input, format) { return new Date(year, month - 1, day, hour, minute, second, ms); } +export 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; +} + // ─── Input transform presets ─── export const IntInput = { transform: parseInt }; diff --git a/OnlyPrompt.Frontend/test.html b/OnlyPrompt.Frontend/test.html index 337a3c3..2a508a9 100644 --- a/OnlyPrompt.Frontend/test.html +++ b/OnlyPrompt.Frontend/test.html @@ -15,9 +15,9 @@

Test Site

- Go to count 1 - Go to count 2 - Go to count 3 + Go to count 1 + Go to count 2 + Go to count 3
Hello There