From fb2a288f28e533b27efc499f2ea58d3daa2dc7e8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Oct 2025 01:52:27 +0000 Subject: [PATCH 1/8] Initial plan From f072136279e22c0b853721a80bb962aa547a2e70 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Oct 2025 01:59:27 +0000 Subject: [PATCH 2/8] Add button tag support with click handlers and data-payload Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- docs/assets/treebark-browser.js | 6 +- docs/assets/treebark-browser.min.js | 4 +- .../playground/src/examples/button-example.ts | 37 +++ nodejs/packages/test/src/dom.test.ts | 242 ++++++++++++++++++ nodejs/packages/treebark/src/common.ts | 5 +- nodejs/packages/treebark/src/dom.ts | 26 ++ nodejs/packages/treebark/src/types.ts | 8 +- 7 files changed, 320 insertions(+), 8 deletions(-) create mode 100644 nodejs/packages/playground/src/examples/button-example.ts diff --git a/docs/assets/treebark-browser.js b/docs/assets/treebark-browser.js index 30394142..614e630e 100644 --- a/docs/assets/treebark-browser.js +++ b/docs/assets/treebark-browser.js @@ -31,7 +31,8 @@ "tr", "th", "td", - "a" + "a", + "button" ]); const SPECIAL_TAGS = /* @__PURE__ */ new Set([ "$comment", @@ -50,7 +51,8 @@ "table": /* @__PURE__ */ new Set(["summary"]), "th": /* @__PURE__ */ new Set(["scope", "colspan", "rowspan"]), "td": /* @__PURE__ */ new Set(["scope", "colspan", "rowspan"]), - "blockquote": /* @__PURE__ */ new Set(["cite"]) + "blockquote": /* @__PURE__ */ new Set(["cite"]), + "button": /* @__PURE__ */ new Set(["type", "disabled"]) }; const OPERATORS = /* @__PURE__ */ new Set(["$<", "$>", "$<=", "$>=", "$=", "$in"]); const CONDITIONALKEYS = /* @__PURE__ */ new Set(["$check", "$then", "$else", "$not", "$join", ...OPERATORS]); diff --git a/docs/assets/treebark-browser.min.js b/docs/assets/treebark-browser.min.js index d3a1c825..b760e6aa 100644 --- a/docs/assets/treebark-browser.min.js +++ b/docs/assets/treebark-browser.min.js @@ -1,9 +1,9 @@ -(function($,p){typeof exports=="object"&&typeof module<"u"?p(exports):typeof define=="function"&&define.amd?define(["exports"],p):($=typeof globalThis<"u"?globalThis:$||self,p($.Treebark={}))})(this,(function($){"use strict";const p=new Set(["div","span","p","header","footer","main","section","article","h1","h2","h3","h4","h5","h6","strong","em","blockquote","code","pre","ul","ol","li","table","thead","tbody","tr","th","td","a"]),_=new Set(["$comment","$if"]),y=new Set(["img","br","hr"]),D=new Set([...p,..._,...y]),C=new Set(["id","class","style","title","role","data-","aria-"]),L={a:new Set(["href","target","rel"]),img:new Set(["src","alt","width","height"]),table:new Set(["summary"]),th:new Set(["scope","colspan","rowspan"]),td:new Set(["scope","colspan","rowspan"]),blockquote:new Set(["cite"])},O=new Set(["$<","$>","$<=","$>=","$=","$in"]),R=new Set(["$check","$then","$else","$not","$join",...O]),W=new Set(["behavior","-moz-binding"]);function m(e,n,o=[],r){if(n===".")return e;let i=e,s=n;for(;s.startsWith("..");){let t=0,c=s;for(;c.startsWith("..");)t++,c=c.substring(2),c.startsWith("/")&&(c=c.substring(1));if(t<=o.length)i=o[o.length-t],s=c.startsWith(".")?c.substring(1):c;else return}if(s){if(r&&typeof i!="object"&&i!==null&&i!==void 0){r.error(`Cannot access property "${s}" on primitive value of type "${typeof i}"`);return}return s.split(".").reduce((t,c)=>t&&typeof t=="object"&&t!==null?t[c]:void 0,i)}return i}function k(e){return e.replace(/[&<>"']/g,n=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[n]||n)}function A(e,n,o=!0,r=[],i){return e.replace(/\{\{\{([^{]*?)\}\}\}|\{\{([^{]*?)\}\}/g,(s,t,c)=>{if(t!==void 0)return`{{${t.trim()}}}`;const l=c.trim(),u=m(n,l,r,i);return u==null?"":o?k(String(u)):String(u)})}function P(e,n){const o=[];for(const[r,i]of Object.entries(e)){const s=r;if(!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(s)){n.warn(`CSS property "${r}" has invalid format (must be kebab-case)`);continue}if(W.has(s)){n.warn(`CSS property "${r}" is blocked for security reasons`);continue}if(i==null)continue;let t=String(i).trim();if(t.includes(";")){const u=t;t=t.split(";")[0].trim(),t&&t!==u.trim()&&n.warn(`CSS value for "${r}" contained semicolon - using only first part: "${t}"`)}if(!t)continue;const c=/url\s*\(/i.test(t),l=/url\s*\(\s*['"]?data:/i.test(t);if(c&&!l||/expression\s*\(/i.test(t)||/javascript:/i.test(t)||/@import/i.test(t)){n.warn(`CSS value for "${r}" contains potentially dangerous pattern: "${t}"`);continue}o.push(`${s}: ${t}`)}return o.join("; ").trim()}function G(e,n,o,r){if(e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"){const i=e;if(!b(i.$check,"$check",r))return"";const s=m(n,i.$check,o),c=v(s,i)?i.$then:i.$else;return c===void 0?"":typeof c=="object"&&c!==null&&!Array.isArray(c)?P(c,r):""}return typeof e=="object"&&e!==null&&!Array.isArray(e)?P(e,r):(r.error(`Style attribute must be an object with CSS properties, not ${typeof e}. Example: style: { "color": "red", "font-size": "14px" }`),"")}function z(e,n,o){const r=C.has(e)||[...C].some(t=>t.endsWith("-")&&e.startsWith(t)),i=L[n],s=i&&i.has(e);return!r&&!s?(o.warn(`Attribute "${e}" is not allowed on tag "${n}"`),!1):!0}function N(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$bind"in e}function b(e,n,o){return e==="."?!0:e.includes("..")?(o.error(`${n} does not support parent context access (..) - use interpolation {{..prop}} in content/attributes instead. Invalid: ${n}: "${e}"`),!1):e.includes("{{")?(o.error(`${n} does not support interpolation {{...}} - use literal property paths only. Invalid: ${n}: "${e}"`),!1):!0}function v(e,n){const o=[];for(const t of O)t in n&&o.push({key:t,value:n[t]});if(o.length===0){const t=!!e;return n.$not?!t:t}const r=o.map(t=>{switch(t.key){case"$<":return typeof e=="number"&&typeof t.value=="number"&&e":return typeof e=="number"&&typeof t.value=="number"&&e>t.value;case"$<=":return typeof e=="number"&&typeof t.value=="number"&&e<=t.value;case"$>=":return typeof e=="number"&&typeof t.value=="number"&&e>=t.value;case"$=":return e===t.value;case"$in":return Array.isArray(t.value)&&t.value.includes(e);default:return!1}}),i=n.$join==="OR";let s;return i?s=r.some(t=>t):s=r.every(t=>t),n.$not?!s:s}function q(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function B(e,n,o=[],r){if(!b(e.$check,"$check",r))return"";const i=m(n,e.$check,o);return v(i,e)?e.$then!==void 0?e.$then:"":e.$else!==void 0?e.$else:""}function K(e,n){if(!e||typeof e!="object"){n.error("Template object cannot be null, undefined, or non-object");return}const o=Object.entries(e);if(o.length===0){n.error("Template object must have at least one tag");return}const r=o[0];if(!r){n.error("Template object must have at least one tag");return}const[i,s]=r,t=typeof s=="string"?[s]:Array.isArray(s)?s:s?.$children||[],c=s&&typeof s=="object"&&!Array.isArray(s)?Object.fromEntries(Object.entries(s).filter(([l])=>l!=="$children")):{};return{tag:i,rest:s,children:t,attrs:c}}function U(e,n,o=[],r){const i=e;if(!i.$check)return r.error('"$if" tag requires $check attribute to specify the condition'),{valueToRender:void 0};if(!b(i.$check,"$check",r))return{valueToRender:void 0};const s=m(n,i.$check,o);typeof e=="object"&&e!==null&&!Array.isArray(e)&&"$children"in e&&r.warn('"$if" tag does not support $children, use $then and $else instead');const{$then:t,$else:c}=i;if(t!==void 0&&Array.isArray(t))return r.error('"$if" tag $then must be a string or single element object, not an array'),{valueToRender:void 0};if(c!==void 0&&Array.isArray(c))return r.error('"$if" tag $else must be a string or single element object, not an array'),{valueToRender:void 0};const u=(typeof e=="object"&&e!==null&&!Array.isArray(e)?Object.keys(e):[]).filter(d=>!R.has(d));return u.length>0&&r.warn(`"$if" tag does not support attributes: ${u.join(", ")}. Allowed: ${[...R].join(", ")}`),{valueToRender:v(s,i)?t:c}}const F=(e,n)=>{if(!n)return e.length<=1?e[0]?.[1]??"":e.reduce((r,[,i])=>r+i,"");if(e.length===0)return"";if(e.length===1&&!e[0][1].includes("<"))return e[0][1];let o=` +(function($,p){typeof exports=="object"&&typeof module<"u"?p(exports):typeof define=="function"&&define.amd?define(["exports"],p):($=typeof globalThis<"u"?globalThis:$||self,p($.Treebark={}))})(this,(function($){"use strict";const p=new Set(["div","span","p","header","footer","main","section","article","h1","h2","h3","h4","h5","h6","strong","em","blockquote","code","pre","ul","ol","li","table","thead","tbody","tr","th","td","a","button"]),_=new Set(["$comment","$if"]),y=new Set(["img","br","hr"]),D=new Set([...p,..._,...y]),C=new Set(["id","class","style","title","role","data-","aria-"]),L={a:new Set(["href","target","rel"]),img:new Set(["src","alt","width","height"]),table:new Set(["summary"]),th:new Set(["scope","colspan","rowspan"]),td:new Set(["scope","colspan","rowspan"]),blockquote:new Set(["cite"]),button:new Set(["type","disabled"])},O=new Set(["$<","$>","$<=","$>=","$=","$in"]),R=new Set(["$check","$then","$else","$not","$join",...O]),W=new Set(["behavior","-moz-binding"]);function b(e,n,o=[],r){if(n===".")return e;let i=e,s=n;for(;s.startsWith("..");){let t=0,c=s;for(;c.startsWith("..");)t++,c=c.substring(2),c.startsWith("/")&&(c=c.substring(1));if(t<=o.length)i=o[o.length-t],s=c.startsWith(".")?c.substring(1):c;else return}if(s){if(r&&typeof i!="object"&&i!==null&&i!==void 0){r.error(`Cannot access property "${s}" on primitive value of type "${typeof i}"`);return}return s.split(".").reduce((t,c)=>t&&typeof t=="object"&&t!==null?t[c]:void 0,i)}return i}function k(e){return e.replace(/[&<>"']/g,n=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[n]||n)}function A(e,n,o=!0,r=[],i){return e.replace(/\{\{\{([^{]*?)\}\}\}|\{\{([^{]*?)\}\}/g,(s,t,c)=>{if(t!==void 0)return`{{${t.trim()}}}`;const l=c.trim(),u=b(n,l,r,i);return u==null?"":o?k(String(u)):String(u)})}function P(e,n){const o=[];for(const[r,i]of Object.entries(e)){const s=r;if(!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(s)){n.warn(`CSS property "${r}" has invalid format (must be kebab-case)`);continue}if(W.has(s)){n.warn(`CSS property "${r}" is blocked for security reasons`);continue}if(i==null)continue;let t=String(i).trim();if(t.includes(";")){const u=t;t=t.split(";")[0].trim(),t&&t!==u.trim()&&n.warn(`CSS value for "${r}" contained semicolon - using only first part: "${t}"`)}if(!t)continue;const c=/url\s*\(/i.test(t),l=/url\s*\(\s*['"]?data:/i.test(t);if(c&&!l||/expression\s*\(/i.test(t)||/javascript:/i.test(t)||/@import/i.test(t)){n.warn(`CSS value for "${r}" contains potentially dangerous pattern: "${t}"`);continue}o.push(`${s}: ${t}`)}return o.join("; ").trim()}function G(e,n,o,r){if(e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"){const i=e;if(!m(i.$check,"$check",r))return"";const s=b(n,i.$check,o),c=v(s,i)?i.$then:i.$else;return c===void 0?"":typeof c=="object"&&c!==null&&!Array.isArray(c)?P(c,r):""}return typeof e=="object"&&e!==null&&!Array.isArray(e)?P(e,r):(r.error(`Style attribute must be an object with CSS properties, not ${typeof e}. Example: style: { "color": "red", "font-size": "14px" }`),"")}function z(e,n,o){const r=C.has(e)||[...C].some(t=>t.endsWith("-")&&e.startsWith(t)),i=L[n],s=i&&i.has(e);return!r&&!s?(o.warn(`Attribute "${e}" is not allowed on tag "${n}"`),!1):!0}function N(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$bind"in e}function m(e,n,o){return e==="."?!0:e.includes("..")?(o.error(`${n} does not support parent context access (..) - use interpolation {{..prop}} in content/attributes instead. Invalid: ${n}: "${e}"`),!1):e.includes("{{")?(o.error(`${n} does not support interpolation {{...}} - use literal property paths only. Invalid: ${n}: "${e}"`),!1):!0}function v(e,n){const o=[];for(const t of O)t in n&&o.push({key:t,value:n[t]});if(o.length===0){const t=!!e;return n.$not?!t:t}const r=o.map(t=>{switch(t.key){case"$<":return typeof e=="number"&&typeof t.value=="number"&&e":return typeof e=="number"&&typeof t.value=="number"&&e>t.value;case"$<=":return typeof e=="number"&&typeof t.value=="number"&&e<=t.value;case"$>=":return typeof e=="number"&&typeof t.value=="number"&&e>=t.value;case"$=":return e===t.value;case"$in":return Array.isArray(t.value)&&t.value.includes(e);default:return!1}}),i=n.$join==="OR";let s;return i?s=r.some(t=>t):s=r.every(t=>t),n.$not?!s:s}function q(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function B(e,n,o=[],r){if(!m(e.$check,"$check",r))return"";const i=b(n,e.$check,o);return v(i,e)?e.$then!==void 0?e.$then:"":e.$else!==void 0?e.$else:""}function K(e,n){if(!e||typeof e!="object"){n.error("Template object cannot be null, undefined, or non-object");return}const o=Object.entries(e);if(o.length===0){n.error("Template object must have at least one tag");return}const r=o[0];if(!r){n.error("Template object must have at least one tag");return}const[i,s]=r,t=typeof s=="string"?[s]:Array.isArray(s)?s:s?.$children||[],c=s&&typeof s=="object"&&!Array.isArray(s)?Object.fromEntries(Object.entries(s).filter(([l])=>l!=="$children")):{};return{tag:i,rest:s,children:t,attrs:c}}function U(e,n,o=[],r){const i=e;if(!i.$check)return r.error('"$if" tag requires $check attribute to specify the condition'),{valueToRender:void 0};if(!m(i.$check,"$check",r))return{valueToRender:void 0};const s=b(n,i.$check,o);typeof e=="object"&&e!==null&&!Array.isArray(e)&&"$children"in e&&r.warn('"$if" tag does not support $children, use $then and $else instead');const{$then:t,$else:c}=i;if(t!==void 0&&Array.isArray(t))return r.error('"$if" tag $then must be a string or single element object, not an array'),{valueToRender:void 0};if(c!==void 0&&Array.isArray(c))return r.error('"$if" tag $else must be a string or single element object, not an array'),{valueToRender:void 0};const u=(typeof e=="object"&&e!==null&&!Array.isArray(e)?Object.keys(e):[]).filter(d=>!R.has(d));return u.length>0&&r.warn(`"$if" tag does not support attributes: ${u.join(", ")}. Allowed: ${[...R].join(", ")}`),{valueToRender:v(s,i)?t:c}}const F=(e,n)=>{if(!n)return e.length<=1?e[0]?.[1]??"":e.reduce((r,[,i])=>r+i,"");if(e.length===0)return"";if(e.length===1&&!e[0][1].includes("<"))return e[0][1];let o=` `;for(let r=0;r`;const f=`<${e}${Y(n,o,e,c,i)}>`;return y.has(e)?f:`${f}${l}${u}`}function h(e,n,o){const r=o.parents||[],i=o.logger;if(typeof e=="string")return A(e,n,!0,r,i);if(Array.isArray(e))return e.map(a=>h(a,n,o)).join(o.indentStr?` `:"");const s=K(e,i);if(!s)return"";const{tag:t,rest:c,children:l,attrs:u}=s;if(!D.has(t))return i.error(`Tag "${t}" is not allowed`),"";if(t==="$comment"&&o.insideComment)return i.error("Nested comments are not allowed"),"";if(t==="$if"){const{valueToRender:a}=U(c,n,r,i);return a===void 0?"":h(a,n,o)}y.has(t)&&l.length>0&&i.warn(`Tag "${t}" is a void element and cannot have children`);const f={...o,insideComment:t==="$comment"||o.insideComment,level:(o.level||0)+1},j=a=>a===""?[]:o.indentStr&&a.includes(` `)&&!a.includes("<")?a.split(` -`).map(S=>[f.level,S]):[[f.level,a]];let d,T;if(N(c)){if(!b(c.$bind,"$bind",i))return"";const a=m(n,c.$bind,[],i),{$bind:S,$children:E=[],...I}=c;if(!Array.isArray(a)){if(a!=null&&typeof a!="object")return i.error(`$bind resolved to primitive value of type "${typeof a}", cannot render children`),"";const w=a&&typeof a=="object"&&a!==null?a:{},g=[...r,n];return h({[t]:{...I,$children:E}},w,{...o,parents:g})}if(d=[],!y.has(t))for(const w of a){const g=[...r,n];for(const H of E){const J=h(H,w,{...f,parents:g});d.push(...j(J))}}T=I}else{if(d=[],!y.has(t))for(const a of l){const S=h(a,n,{...f,parents:r});d.push(...j(S))}T=u}return V(t,T,n,d,i,o.indentStr,o.level,r)}function Y(e,n,o,r=[],i){const s=Object.entries(e).filter(([t])=>z(t,o,i)).map(([t,c])=>{let l;if(t==="style"){if(l=G(c,n,r,i),!l)return null}else if(q(c)){const u=B(c,n,r,i);l=A(String(u),n,!1,r,i)}else l=A(String(c),n,!1,r,i);return`${t}="${k(l)}"`}).filter(t=>t!==null).join(" ");return s?" "+s:""}$.renderToString=M,Object.defineProperty($,Symbol.toStringTag,{value:"Module"})})); +`).map(S=>[f.level,S]):[[f.level,a]];let d,w;if(N(c)){if(!m(c.$bind,"$bind",i))return"";const a=b(n,c.$bind,[],i),{$bind:S,$children:E=[],...I}=c;if(!Array.isArray(a)){if(a!=null&&typeof a!="object")return i.error(`$bind resolved to primitive value of type "${typeof a}", cannot render children`),"";const T=a&&typeof a=="object"&&a!==null?a:{},g=[...r,n];return h({[t]:{...I,$children:E}},T,{...o,parents:g})}if(d=[],!y.has(t))for(const T of a){const g=[...r,n];for(const H of E){const J=h(H,T,{...f,parents:g});d.push(...j(J))}}w=I}else{if(d=[],!y.has(t))for(const a of l){const S=h(a,n,{...f,parents:r});d.push(...j(S))}w=u}return V(t,w,n,d,i,o.indentStr,o.level,r)}function Y(e,n,o,r=[],i){const s=Object.entries(e).filter(([t])=>z(t,o,i)).map(([t,c])=>{let l;if(t==="style"){if(l=G(c,n,r,i),!l)return null}else if(q(c)){const u=B(c,n,r,i);l=A(String(u),n,!1,r,i)}else l=A(String(c),n,!1,r,i);return`${t}="${k(l)}"`}).filter(t=>t!==null).join(" ");return s?" "+s:""}$.renderToString=M,Object.defineProperty($,Symbol.toStringTag,{value:"Module"})})); //# sourceMappingURL=treebark-browser.min.js.map diff --git a/nodejs/packages/playground/src/examples/button-example.ts b/nodejs/packages/playground/src/examples/button-example.ts new file mode 100644 index 00000000..e8f44667 --- /dev/null +++ b/nodejs/packages/playground/src/examples/button-example.ts @@ -0,0 +1,37 @@ +import type { Example } from './types.js'; + +export const buttonExample: Example = { + template: { + div: { + class: "button-demo", + $children: [ + { h2: "Button Tag Example (DOM-only)" }, + { p: "Buttons can have click handlers and payloads:" }, + { + button: { + class: "btn-primary", + type: "button", + 'data-payload': JSON.stringify({ action: 'save', id: 123 }), + $children: ['Save Item'] + } + }, + { + button: { + class: "btn-danger", + type: "button", + 'data-payload': JSON.stringify({ action: 'delete', id: 123 }), + $children: ['Delete Item'] + } + }, + { + button: { + class: "btn-info", + disabled: "false", + $children: ['Info Button'] + } + } + ] + } + }, + data: {} +}; diff --git a/nodejs/packages/test/src/dom.test.ts b/nodejs/packages/test/src/dom.test.ts index 9b7b943f..940cbba7 100644 --- a/nodejs/packages/test/src/dom.test.ts +++ b/nodejs/packages/test/src/dom.test.ts @@ -935,4 +935,246 @@ describe('DOM Renderer', () => { createErrorTest(testCase, renderToDOM); }); }); + + // Button tag tests (DOM-only feature) + describe('Button Tag with Click Handlers', () => { + test('renders basic button tag', () => { + const fragment = renderToDOM({ + template: { + button: 'Click me' + } + }); + + const button = fragment.firstChild as HTMLButtonElement; + expect(button.tagName).toBe('BUTTON'); + expect(button.textContent).toBe('Click me'); + }); + + test('renders button with attributes', () => { + const fragment = renderToDOM({ + template: { + button: { + type: 'submit', + class: 'btn-primary', + id: 'submit-btn', + $children: ['Submit'] + } + } + }); + + const button = fragment.firstChild as HTMLButtonElement; + expect(button.tagName).toBe('BUTTON'); + expect(button.getAttribute('type')).toBe('submit'); + expect(button.getAttribute('class')).toBe('btn-primary'); + expect(button.getAttribute('id')).toBe('submit-btn'); + expect(button.textContent).toBe('Submit'); + }); + + test('button with $onClick handler', () => { + let clicked = false; + let receivedEvent: MouseEvent | null = null; + + const fragment = renderToDOM({ + template: { + button: { + $onClick: (event: MouseEvent) => { + clicked = true; + receivedEvent = event; + }, + $children: ['Click me'] + } + } + }); + + const button = fragment.firstChild as HTMLButtonElement; + expect(button.tagName).toBe('BUTTON'); + + // Click the button + button.click(); + + expect(clicked).toBe(true); + expect(receivedEvent).toBeTruthy(); + }); + + test('button with data-payload attribute', () => { + const fragment = renderToDOM({ + template: { + button: { + 'data-payload': JSON.stringify({ action: 'delete', id: 123 }), + $children: ['Delete'] + } + } + }); + + const button = fragment.firstChild as HTMLButtonElement; + expect(button.getAttribute('data-payload')).toBe('{"action":"delete","id":123}'); + }); + + test('button with $onClick handler receives payload from data-payload', () => { + let receivedPayload: unknown = undefined; + + const fragment = renderToDOM({ + template: { + button: { + 'data-payload': JSON.stringify({ action: 'submit', formId: 'form-1' }), + $onClick: (event: MouseEvent, payload?: unknown) => { + receivedPayload = payload; + }, + $children: ['Submit Form'] + } + } + }); + + const button = fragment.firstChild as HTMLButtonElement; + button.click(); + + expect(receivedPayload).toEqual({ action: 'submit', formId: 'form-1' }); + }); + + test('button with $onClick handler receives string payload if JSON parsing fails', () => { + let receivedPayload: unknown = undefined; + + const fragment = renderToDOM({ + template: { + button: { + 'data-payload': 'not-valid-json', + $onClick: (event: MouseEvent, payload?: unknown) => { + receivedPayload = payload; + }, + $children: ['Click'] + } + } + }); + + const button = fragment.firstChild as HTMLButtonElement; + button.click(); + + // Should receive the raw string when JSON parsing fails + expect(receivedPayload).toBe('not-valid-json'); + }); + + test('button with $onClick handler receives undefined payload when no data-payload', () => { + let receivedPayload: unknown = 'initial-value'; + + const fragment = renderToDOM({ + template: { + button: { + $onClick: (event: MouseEvent, payload?: unknown) => { + receivedPayload = payload; + }, + $children: ['Click'] + } + } + }); + + const button = fragment.firstChild as HTMLButtonElement; + button.click(); + + expect(receivedPayload).toBeUndefined(); + }); + + test('button with disabled attribute using interpolation', () => { + const fragment = renderToDOM({ + template: { + button: { + disabled: '{{isDisabled}}', + $children: ['Submit'] + } + }, + data: { isDisabled: 'true' } + }); + + const button = fragment.firstChild as HTMLButtonElement; + expect(button.getAttribute('disabled')).toBe('true'); + }); + + test('button with conditional disabled attribute', () => { + const fragment = renderToDOM({ + template: { + button: { + disabled: { + $check: 'isProcessing', + $then: 'true', + $else: 'false' + }, + $children: ['Submit'] + } + }, + data: { isProcessing: true } + }); + + const button = fragment.firstChild as HTMLButtonElement; + expect(button.getAttribute('disabled')).toBe('true'); + }); + + test('warns when $onClick is not a function', () => { + const mockLogger = { + error: jest.fn(), + warn: jest.fn(), + log: jest.fn() + }; + + const fragment = renderToDOM({ + template: { + button: { + $onClick: 'not-a-function' as any, + $children: ['Click'] + } + } + }, { logger: mockLogger }); + + expect(mockLogger.warn).toHaveBeenCalledWith('$onClick must be a function'); + }); + + test('button with nested elements', () => { + const fragment = renderToDOM({ + template: { + button: { + class: 'icon-btn', + $children: [ + { span: { class: 'icon', $children: ['🔔'] } }, + ' Notify' + ] + } + } + }); + + const button = fragment.firstChild as HTMLButtonElement; + expect(button.tagName).toBe('BUTTON'); + expect(button.querySelector('span.icon')?.textContent).toBe('🔔'); + expect(button.textContent).toBe('🔔 Notify'); + }); + + test('multiple buttons with different handlers', () => { + let clickedButton: string | null = null; + + const fragment = renderToDOM({ + template: [ + { + button: { + id: 'btn1', + $onClick: () => { clickedButton = 'button1'; }, + $children: ['Button 1'] + } + }, + { + button: { + id: 'btn2', + $onClick: () => { clickedButton = 'button2'; }, + $children: ['Button 2'] + } + } + ] + }); + + const button1 = fragment.querySelector('#btn1') as HTMLButtonElement; + const button2 = fragment.querySelector('#btn2') as HTMLButtonElement; + + button1.click(); + expect(clickedButton).toBe('button1'); + + button2.click(); + expect(clickedButton).toBe('button2'); + }); + }); }); \ No newline at end of file diff --git a/nodejs/packages/treebark/src/common.ts b/nodejs/packages/treebark/src/common.ts index 4b1f0a40..4602e1b3 100644 --- a/nodejs/packages/treebark/src/common.ts +++ b/nodejs/packages/treebark/src/common.ts @@ -21,7 +21,7 @@ export const CONTAINER_TAGS = new Set([ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong', 'em', 'blockquote', 'code', 'pre', 'ul', 'ol', 'li', 'table', 'thead', 'tbody', 'tr', 'th', 'td', - 'a' + 'a', 'button' ]); // Special tags that have unique behavior @@ -49,7 +49,8 @@ export const TAG_SPECIFIC_ATTRS: Record> = { 'table': new Set(['summary']), 'th': new Set(['scope', 'colspan', 'rowspan']), 'td': new Set(['scope', 'colspan', 'rowspan']), - 'blockquote': new Set(['cite']) + 'blockquote': new Set(['cite']), + 'button': new Set(['type', 'disabled']) }; export const OPERATORS = new Set(['$<', '$>', '$<=', '$>=', '$=', '$in']); diff --git a/nodejs/packages/treebark/src/dom.ts b/nodejs/packages/treebark/src/dom.ts index 6f637883..beacd90d 100644 --- a/nodejs/packages/treebark/src/dom.ts +++ b/nodejs/packages/treebark/src/dom.ts @@ -176,6 +176,32 @@ function render(template: TemplateElement | TemplateElement[], data: Data, conte function setAttrs(element: HTMLElement, attrs: Record, data: Data, tag: string, parents: Data[] = [], logger: Logger): void { Object.entries(attrs).forEach(([key, value]) => { + // Special handling for $onClick - DOM-only feature for button tags + if (key === '$onClick' && tag === 'button') { + if (typeof value === 'function') { + const handler = value as (event: MouseEvent, payload?: unknown) => void; + element.addEventListener('click', (event: Event) => { + // Check if data-payload attribute exists + const payloadAttr = element.getAttribute('data-payload'); + let payload: unknown = undefined; + + if (payloadAttr) { + try { + payload = JSON.parse(payloadAttr); + } catch (e) { + // If parsing fails, use the raw string value + payload = payloadAttr; + } + } + + handler(event as MouseEvent, payload); + }); + } else { + logger.warn('$onClick must be a function'); + } + return; // Don't set $onClick as an HTML attribute + } + if (!validateAttribute(key, tag, logger)) { return; // Skip invalid attributes } diff --git a/nodejs/packages/treebark/src/types.ts b/nodejs/packages/treebark/src/types.ts index c37187c8..c5073929 100644 --- a/nodejs/packages/treebark/src/types.ts +++ b/nodejs/packages/treebark/src/types.ts @@ -51,7 +51,7 @@ export type ContainerTag = 'div' | 'span' | 'p' | 'header' | 'footer' | 'main' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'strong' | 'em' | 'blockquote' | 'code' | 'pre' | 'ul' | 'ol' | 'li' | 'table' | 'thead' | 'tbody' | 'tr' | 'th' | 'td' | - 'a'; + 'a' | 'button'; export type VoidTag = 'img' | 'br' | 'hr'; @@ -79,6 +79,9 @@ type BaseContainerAttrs = GlobalAttrs & { $children?: (string | TemplateObject)[]; }; +// Button-specific event handler type (DOM-only) +export type ButtonClickHandler = (event: MouseEvent, payload?: unknown) => void; + // Base attributes for void tags (no children allowed) type BaseVoidAttrs = GlobalAttrs & { $bind?: string; @@ -114,6 +117,7 @@ export type TrTag = { tr: TagContent }; export type ThTag = { th: TagContent }; export type TdTag = { td: TagContent }; export type ATag = { a: TagContent }; +export type ButtonTag = { button: TagContent }; export type CommentTag = { $comment: TagContent }; // Void tag types @@ -129,7 +133,7 @@ export type RegularTags = | DivTag | SpanTag | PTag | HeaderTag | FooterTag | MainTag | SectionTag | ArticleTag | H1Tag | H2Tag | H3Tag | H4Tag | H5Tag | H6Tag | StrongTag | EmTag | BlockquoteTag | CodeTag | PreTag | UlTag | OlTag | LiTag | TableTag | TheadTag | TbodyTag | TrTag - | ThTag | TdTag | ATag | ImgTag | BrTag | HrTag | CommentTag; + | ThTag | TdTag | ATag | ButtonTag | ImgTag | BrTag | HrTag | CommentTag; // Generic template attributes (for backwards compatibility with runtime code) export type TemplateAttributes = BaseContainerAttrs; From 84706027356d595099255f772661fc1a444e4b0e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Oct 2025 02:02:15 +0000 Subject: [PATCH 3/8] Add comprehensive documentation for button tag feature Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- README.md | 114 ++++++++- button-demo.html | 238 ++++++++++++++++++ docs/assets/markdown-it-treebark-browser.js | 6 +- .../markdown-it-treebark-browser.min.js | 12 +- spec.md | 67 ++++- 5 files changed, 425 insertions(+), 12 deletions(-) create mode 100644 button-demo.html diff --git a/README.md b/README.md index f98e322c..4b4d640d 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ This means the implementation is featherweight. `h1`–`h6`, `strong`, `em`, `blockquote`, `code`, `pre`, `ul`, `ol`, `li`, `table`, `thead`, `tbody`, `tr`, `th`, `td`, -`a`, `img`, `br`, `hr` +`a`, `img`, `br`, `hr`, `button` **Special tags:** - `$comment` — Emits HTML comments. Cannot be nested inside another `$comment`. @@ -101,6 +101,7 @@ This means the implementation is featherweight. | `table` | `summary` | | `th`, `td` | `scope`, `colspan`, `rowspan` | | `blockquote` | `cite` | +| `button` | `type`, `disabled` | ### Special Keys @@ -938,6 +939,117 @@ The `$if` tag follows JavaScript truthiness when no operators are provided: - **Truthy:** `true`, non-empty strings, non-zero numbers, objects, arrays - **Falsy:** `false`, `null`, `undefined`, `0`, `""`, `NaN` +### Button Tag with Click Handlers + +The `button` tag supports interactive click handlers when using the DOM renderer (`renderToDOM`). This feature is **not available** in the string renderer. + +**Key features:** +- **Click handler**: Use `$onClick` to attach an event handler +- **Data payload**: Store data in `data-payload` attribute (automatically parsed from JSON) +- **Standard attributes**: Supports `type`, `disabled`, and all global attributes + +**Basic example:** +```json +{ + "button": { + "class": "btn-primary", + "type": "button", + "$children": ["Click Me"] + } +} +``` + +Output: +```html + +``` + +**With click handler (DOM-only):** +```javascript +renderToDOM({ + template: { + button: { + class: "btn-save", + type: "button", + $onClick: (event, payload) => { + console.log('Button clicked!', event, payload); + }, + $children: ['Save'] + } + } +}); +``` + +**With data payload:** +```javascript +renderToDOM({ + template: { + button: { + class: "btn-delete", + 'data-payload': JSON.stringify({ action: 'delete', id: 123 }), + $onClick: (event, payload) => { + // payload will be: { action: 'delete', id: 123 } + console.log('Delete item:', payload.id); + }, + $children: ['Delete Item'] + } + } +}); +``` + +**Conditional disabled state:** +```json +{ + "button": { + "disabled": { + "$check": "isProcessing", + "$then": "true", + "$else": "false" + }, + "$children": ["Submit"] + } +} +``` + +Data: +```json +{ "isProcessing": true } +``` + +Output: +```html + +``` + +**Multiple buttons with different handlers:** +```javascript +renderToDOM({ + template: { + div: { + class: "button-group", + $children: [ + { + button: { + 'data-payload': JSON.stringify({ action: 'save' }), + $onClick: (e, payload) => handleAction(payload), + $children: ['Save'] + } + }, + { + button: { + 'data-payload': JSON.stringify({ action: 'cancel' }), + $onClick: (e, payload) => handleAction(payload), + $children: ['Cancel'] + } + } + ] + } + } +}); +``` + +**Note:** The `$onClick` handler is a DOM-only feature. When using `renderToString`, the button will be rendered but without any click handler attached. The `data-payload` attribute will still be present in the HTML output. + ## Error Handling Treebark follows a **no-throw policy**: instead of throwing exceptions, errors and warnings are sent to a logger. This allows your application to continue rendering even when there are invalid tags, attributes, or other issues. diff --git a/button-demo.html b/button-demo.html new file mode 100644 index 00000000..d73edc32 --- /dev/null +++ b/button-demo.html @@ -0,0 +1,238 @@ + + + + + + Treebark Button Tag Demo + + + +
+

đŸŽ¯ Treebark Button Tag Demo

+

This demo showcases the new button tag feature with click handlers and data payloads.

+ +
+

Demo Buttons

+
+
+ +
+

Event Log

+
Waiting for button clicks...
+
+ +
+

Template Code

+
{ + template: { + div: { + class: "button-group", + $children: [ + { + button: { + class: "btn-primary", + type: "button", + "data-payload": JSON.stringify({ action: "save", id: 1 }), + $onClick: (event, payload) => { + logEvent("Save Button", payload); + }, + $children: ["💾 Save"] + } + }, + { + button: { + class: "btn-danger", + type: "button", + "data-payload": JSON.stringify({ action: "delete", id: 2 }), + $onClick: (event, payload) => { + logEvent("Delete Button", payload); + }, + $children: ["đŸ—‘ī¸ Delete"] + } + }, + { + button: { + class: "btn-success", + type: "button", + "data-payload": JSON.stringify({ action: "submit", formId: "form-1" }), + $onClick: (event, payload) => { + logEvent("Submit Button", payload); + }, + $children: ["✅ Submit"] + } + }, + { + button: { + class: "btn-info", + type: "button", + $onClick: (event, payload) => { + logEvent("Info Button (no payload)", payload); + }, + $children: ["â„šī¸ Info (no payload)"] + } + } + ] + } + } +}
+
+
+ + + + diff --git a/docs/assets/markdown-it-treebark-browser.js b/docs/assets/markdown-it-treebark-browser.js index ff1d886d..90d14251 100644 --- a/docs/assets/markdown-it-treebark-browser.js +++ b/docs/assets/markdown-it-treebark-browser.js @@ -31,7 +31,8 @@ "tr", "th", "td", - "a" + "a", + "button" ]); const SPECIAL_TAGS = /* @__PURE__ */ new Set([ "$comment", @@ -50,7 +51,8 @@ "table": /* @__PURE__ */ new Set(["summary"]), "th": /* @__PURE__ */ new Set(["scope", "colspan", "rowspan"]), "td": /* @__PURE__ */ new Set(["scope", "colspan", "rowspan"]), - "blockquote": /* @__PURE__ */ new Set(["cite"]) + "blockquote": /* @__PURE__ */ new Set(["cite"]), + "button": /* @__PURE__ */ new Set(["type", "disabled"]) }; const OPERATORS = /* @__PURE__ */ new Set(["$<", "$>", "$<=", "$>=", "$=", "$in"]); const CONDITIONALKEYS = /* @__PURE__ */ new Set(["$check", "$then", "$else", "$not", "$join", ...OPERATORS]); diff --git a/docs/assets/markdown-it-treebark-browser.min.js b/docs/assets/markdown-it-treebark-browser.min.js index 12f7b225..d0086747 100644 --- a/docs/assets/markdown-it-treebark-browser.min.js +++ b/docs/assets/markdown-it-treebark-browser.min.js @@ -1,11 +1,11 @@ -(function(y,m){typeof exports=="object"&&typeof module<"u"?module.exports=m():typeof define=="function"&&define.amd?define(m):(y=typeof globalThis<"u"?globalThis:y||self,y.MarkdownItTreebark=m())})(this,(function(){"use strict";const y=new Set(["div","span","p","header","footer","main","section","article","h1","h2","h3","h4","h5","h6","strong","em","blockquote","code","pre","ul","ol","li","table","thead","tbody","tr","th","td","a"]),m=new Set(["$comment","$if"]),b=new Set(["img","br","hr"]),W=new Set([...y,...m,...b]),E=new Set(["id","class","style","title","role","data-","aria-"]),_={a:new Set(["href","target","rel"]),img:new Set(["src","alt","width","height"]),table:new Set(["summary"]),th:new Set(["scope","colspan","rowspan"]),td:new Set(["scope","colspan","rowspan"]),blockquote:new Set(["cite"])},C=new Set(["$<","$>","$<=","$>=","$=","$in"]),O=new Set(["$check","$then","$else","$not","$join",...C]),D=new Set(["behavior","-moz-binding"]);function A(e,n,o=[],r){if(n===".")return e;let i=e,s=n;for(;s.startsWith("..");){let t=0,c=s;for(;c.startsWith("..");)t++,c=c.substring(2),c.startsWith("/")&&(c=c.substring(1));if(t<=o.length)i=o[o.length-t],s=c.startsWith(".")?c.substring(1):c;else return}if(s){if(r&&typeof i!="object"&&i!==null&&i!==void 0){r.error(`Cannot access property "${s}" on primitive value of type "${typeof i}"`);return}return s.split(".").reduce((t,c)=>t&&typeof t=="object"&&t!==null?t[c]:void 0,i)}return i}function k(e){return e.replace(/[&<>"']/g,n=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[n]||n)}function w(e,n,o=!0,r=[],i){return e.replace(/\{\{\{([^{]*?)\}\}\}|\{\{([^{]*?)\}\}/g,(s,t,c)=>{if(t!==void 0)return`{{${t.trim()}}}`;const a=c.trim(),u=A(n,a,r,i);return u==null?"":o?k(String(u)):String(u)})}function R(e,n){const o=[];for(const[r,i]of Object.entries(e)){const s=r;if(!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(s)){n.warn(`CSS property "${r}" has invalid format (must be kebab-case)`);continue}if(D.has(s)){n.warn(`CSS property "${r}" is blocked for security reasons`);continue}if(i==null)continue;let t=String(i).trim();if(t.includes(";")){const u=t;t=t.split(";")[0].trim(),t&&t!==u.trim()&&n.warn(`CSS value for "${r}" contained semicolon - using only first part: "${t}"`)}if(!t)continue;const c=/url\s*\(/i.test(t),a=/url\s*\(\s*['"]?data:/i.test(t);if(c&&!a||/expression\s*\(/i.test(t)||/javascript:/i.test(t)||/@import/i.test(t)){n.warn(`CSS value for "${r}" contains potentially dangerous pattern: "${t}"`);continue}o.push(`${s}: ${t}`)}return o.join("; ").trim()}function N(e,n,o,r){if(e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"){const i=e;if(!v(i.$check,"$check",r))return"";const s=A(n,i.$check,o),c=g(s,i)?i.$then:i.$else;return c===void 0?"":typeof c=="object"&&c!==null&&!Array.isArray(c)?R(c,r):""}return typeof e=="object"&&e!==null&&!Array.isArray(e)?R(e,r):(r.error(`Style attribute must be an object with CSS properties, not ${typeof e}. Example: style: { "color": "red", "font-size": "14px" }`),"")}function G(e,n,o){const r=E.has(e)||[...E].some(t=>t.endsWith("-")&&e.startsWith(t)),i=_[n],s=i&&i.has(e);return!r&&!s?(o.warn(`Attribute "${e}" is not allowed on tag "${n}"`),!1):!0}function q(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$bind"in e}function v(e,n,o){return e==="."?!0:e.includes("..")?(o.error(`${n} does not support parent context access (..) - use interpolation {{..prop}} in content/attributes instead. Invalid: ${n}: "${e}"`),!1):e.includes("{{")?(o.error(`${n} does not support interpolation {{...}} - use literal property paths only. Invalid: ${n}: "${e}"`),!1):!0}function g(e,n){const o=[];for(const t of C)t in n&&o.push({key:t,value:n[t]});if(o.length===0){const t=!!e;return n.$not?!t:t}const r=o.map(t=>{switch(t.key){case"$<":return typeof e=="number"&&typeof t.value=="number"&&e":return typeof e=="number"&&typeof t.value=="number"&&e>t.value;case"$<=":return typeof e=="number"&&typeof t.value=="number"&&e<=t.value;case"$>=":return typeof e=="number"&&typeof t.value=="number"&&e>=t.value;case"$=":return e===t.value;case"$in":return Array.isArray(t.value)&&t.value.includes(e);default:return!1}}),i=n.$join==="OR";let s;return i?s=r.some(t=>t):s=r.every(t=>t),n.$not?!s:s}function z(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function B(e,n,o=[],r){if(!v(e.$check,"$check",r))return"";const i=A(n,e.$check,o);return g(i,e)?e.$then!==void 0?e.$then:"":e.$else!==void 0?e.$else:""}function M(e,n){if(!e||typeof e!="object"){n.error("Template object cannot be null, undefined, or non-object");return}const o=Object.entries(e);if(o.length===0){n.error("Template object must have at least one tag");return}const r=o[0];if(!r){n.error("Template object must have at least one tag");return}const[i,s]=r,t=typeof s=="string"?[s]:Array.isArray(s)?s:s?.$children||[],c=s&&typeof s=="object"&&!Array.isArray(s)?Object.fromEntries(Object.entries(s).filter(([a])=>a!=="$children")):{};return{tag:i,rest:s,children:t,attrs:c}}function F(e,n,o=[],r){const i=e;if(!i.$check)return r.error('"$if" tag requires $check attribute to specify the condition'),{valueToRender:void 0};if(!v(i.$check,"$check",r))return{valueToRender:void 0};const s=A(n,i.$check,o);typeof e=="object"&&e!==null&&!Array.isArray(e)&&"$children"in e&&r.warn('"$if" tag does not support $children, use $then and $else instead');const{$then:t,$else:c}=i;if(t!==void 0&&Array.isArray(t))return r.error('"$if" tag $then must be a string or single element object, not an array'),{valueToRender:void 0};if(c!==void 0&&Array.isArray(c))return r.error('"$if" tag $else must be a string or single element object, not an array'),{valueToRender:void 0};const u=(typeof e=="object"&&e!==null&&!Array.isArray(e)?Object.keys(e):[]).filter(d=>!O.has(d));return u.length>0&&r.warn(`"$if" tag does not support attributes: ${u.join(", ")}. Allowed: ${[...O].join(", ")}`),{valueToRender:g(s,i)?t:c}}const K=(e,n)=>{if(!n)return e.length<=1?e[0]?.[1]??"":e.reduce((r,[,i])=>r+i,"");if(e.length===0)return"";if(e.length===1&&!e[0][1].includes("<"))return e[0][1];let o=` +(function(y,m){typeof exports=="object"&&typeof module<"u"?module.exports=m():typeof define=="function"&&define.amd?define(m):(y=typeof globalThis<"u"?globalThis:y||self,y.MarkdownItTreebark=m())})(this,(function(){"use strict";const y=new Set(["div","span","p","header","footer","main","section","article","h1","h2","h3","h4","h5","h6","strong","em","blockquote","code","pre","ul","ol","li","table","thead","tbody","tr","th","td","a","button"]),m=new Set(["$comment","$if"]),b=new Set(["img","br","hr"]),W=new Set([...y,...m,...b]),E=new Set(["id","class","style","title","role","data-","aria-"]),_={a:new Set(["href","target","rel"]),img:new Set(["src","alt","width","height"]),table:new Set(["summary"]),th:new Set(["scope","colspan","rowspan"]),td:new Set(["scope","colspan","rowspan"]),blockquote:new Set(["cite"]),button:new Set(["type","disabled"])},C=new Set(["$<","$>","$<=","$>=","$=","$in"]),O=new Set(["$check","$then","$else","$not","$join",...C]),D=new Set(["behavior","-moz-binding"]);function A(e,n,o=[],r){if(n===".")return e;let i=e,s=n;for(;s.startsWith("..");){let t=0,a=s;for(;a.startsWith("..");)t++,a=a.substring(2),a.startsWith("/")&&(a=a.substring(1));if(t<=o.length)i=o[o.length-t],s=a.startsWith(".")?a.substring(1):a;else return}if(s){if(r&&typeof i!="object"&&i!==null&&i!==void 0){r.error(`Cannot access property "${s}" on primitive value of type "${typeof i}"`);return}return s.split(".").reduce((t,a)=>t&&typeof t=="object"&&t!==null?t[a]:void 0,i)}return i}function k(e){return e.replace(/[&<>"']/g,n=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[n]||n)}function w(e,n,o=!0,r=[],i){return e.replace(/\{\{\{([^{]*?)\}\}\}|\{\{([^{]*?)\}\}/g,(s,t,a)=>{if(t!==void 0)return`{{${t.trim()}}}`;const c=a.trim(),u=A(n,c,r,i);return u==null?"":o?k(String(u)):String(u)})}function R(e,n){const o=[];for(const[r,i]of Object.entries(e)){const s=r;if(!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(s)){n.warn(`CSS property "${r}" has invalid format (must be kebab-case)`);continue}if(D.has(s)){n.warn(`CSS property "${r}" is blocked for security reasons`);continue}if(i==null)continue;let t=String(i).trim();if(t.includes(";")){const u=t;t=t.split(";")[0].trim(),t&&t!==u.trim()&&n.warn(`CSS value for "${r}" contained semicolon - using only first part: "${t}"`)}if(!t)continue;const a=/url\s*\(/i.test(t),c=/url\s*\(\s*['"]?data:/i.test(t);if(a&&!c||/expression\s*\(/i.test(t)||/javascript:/i.test(t)||/@import/i.test(t)){n.warn(`CSS value for "${r}" contains potentially dangerous pattern: "${t}"`);continue}o.push(`${s}: ${t}`)}return o.join("; ").trim()}function N(e,n,o,r){if(e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"){const i=e;if(!v(i.$check,"$check",r))return"";const s=A(n,i.$check,o),a=g(s,i)?i.$then:i.$else;return a===void 0?"":typeof a=="object"&&a!==null&&!Array.isArray(a)?R(a,r):""}return typeof e=="object"&&e!==null&&!Array.isArray(e)?R(e,r):(r.error(`Style attribute must be an object with CSS properties, not ${typeof e}. Example: style: { "color": "red", "font-size": "14px" }`),"")}function G(e,n,o){const r=E.has(e)||[...E].some(t=>t.endsWith("-")&&e.startsWith(t)),i=_[n],s=i&&i.has(e);return!r&&!s?(o.warn(`Attribute "${e}" is not allowed on tag "${n}"`),!1):!0}function q(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$bind"in e}function v(e,n,o){return e==="."?!0:e.includes("..")?(o.error(`${n} does not support parent context access (..) - use interpolation {{..prop}} in content/attributes instead. Invalid: ${n}: "${e}"`),!1):e.includes("{{")?(o.error(`${n} does not support interpolation {{...}} - use literal property paths only. Invalid: ${n}: "${e}"`),!1):!0}function g(e,n){const o=[];for(const t of C)t in n&&o.push({key:t,value:n[t]});if(o.length===0){const t=!!e;return n.$not?!t:t}const r=o.map(t=>{switch(t.key){case"$<":return typeof e=="number"&&typeof t.value=="number"&&e":return typeof e=="number"&&typeof t.value=="number"&&e>t.value;case"$<=":return typeof e=="number"&&typeof t.value=="number"&&e<=t.value;case"$>=":return typeof e=="number"&&typeof t.value=="number"&&e>=t.value;case"$=":return e===t.value;case"$in":return Array.isArray(t.value)&&t.value.includes(e);default:return!1}}),i=n.$join==="OR";let s;return i?s=r.some(t=>t):s=r.every(t=>t),n.$not?!s:s}function z(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function B(e,n,o=[],r){if(!v(e.$check,"$check",r))return"";const i=A(n,e.$check,o);return g(i,e)?e.$then!==void 0?e.$then:"":e.$else!==void 0?e.$else:""}function M(e,n){if(!e||typeof e!="object"){n.error("Template object cannot be null, undefined, or non-object");return}const o=Object.entries(e);if(o.length===0){n.error("Template object must have at least one tag");return}const r=o[0];if(!r){n.error("Template object must have at least one tag");return}const[i,s]=r,t=typeof s=="string"?[s]:Array.isArray(s)?s:s?.$children||[],a=s&&typeof s=="object"&&!Array.isArray(s)?Object.fromEntries(Object.entries(s).filter(([c])=>c!=="$children")):{};return{tag:i,rest:s,children:t,attrs:a}}function F(e,n,o=[],r){const i=e;if(!i.$check)return r.error('"$if" tag requires $check attribute to specify the condition'),{valueToRender:void 0};if(!v(i.$check,"$check",r))return{valueToRender:void 0};const s=A(n,i.$check,o);typeof e=="object"&&e!==null&&!Array.isArray(e)&&"$children"in e&&r.warn('"$if" tag does not support $children, use $then and $else instead');const{$then:t,$else:a}=i;if(t!==void 0&&Array.isArray(t))return r.error('"$if" tag $then must be a string or single element object, not an array'),{valueToRender:void 0};if(a!==void 0&&Array.isArray(a))return r.error('"$if" tag $else must be a string or single element object, not an array'),{valueToRender:void 0};const u=(typeof e=="object"&&e!==null&&!Array.isArray(e)?Object.keys(e):[]).filter(d=>!O.has(d));return u.length>0&&r.warn(`"$if" tag does not support attributes: ${u.join(", ")}. Allowed: ${[...O].join(", ")}`),{valueToRender:g(s,i)?t:a}}const K=(e,n)=>{if(!n)return e.length<=1?e[0]?.[1]??"":e.reduce((r,[,i])=>r+i,"");if(e.length===0)return"";if(e.length===1&&!e[0][1].includes("<"))return e[0][1];let o=` `;for(let r=0;r`;const f=`<${e}${J(n,o,e,c,i)}>`;return b.has(e)?f:`${f}${a}${u}`}function $(e,n,o){const r=o.parents||[],i=o.logger;if(typeof e=="string")return w(e,n,!0,r,i);if(Array.isArray(e))return e.map(l=>$(l,n,o)).join(o.indentStr?` -`:"");const s=M(e,i);if(!s)return"";const{tag:t,rest:c,children:a,attrs:u}=s;if(!W.has(t))return i.error(`Tag "${t}" is not allowed`),"";if(t==="$comment"&&o.insideComment)return i.error("Nested comments are not allowed"),"";if(t==="$if"){const{valueToRender:l}=F(c,n,r,i);return l===void 0?"":$(l,n,o)}b.has(t)&&a.length>0&&i.warn(`Tag "${t}" is a void element and cannot have children`);const f={...o,insideComment:t==="$comment"||o.insideComment,level:(o.level||0)+1},S=l=>l===""?[]:o.indentStr&&l.includes(` +`,o};function I(e,n={}){const o=e.data,r=n.logger||console,i=n.indent?{indentStr:typeof n.indent=="number"?" ".repeat(n.indent):typeof n.indent=="string"?n.indent:" ",level:0,logger:r}:{logger:r};return $(e.template,o,i)}function Y(e,n,o,r,i,s,t,a=[]){const c=K(r,s),u=c.startsWith(` +`)&&s?s.repeat(t||0):"";if(e==="$comment")return``;const f=`<${e}${J(n,o,e,a,i)}>`;return b.has(e)?f:`${f}${c}${u}`}function $(e,n,o){const r=o.parents||[],i=o.logger;if(typeof e=="string")return w(e,n,!0,r,i);if(Array.isArray(e))return e.map(l=>$(l,n,o)).join(o.indentStr?` +`:"");const s=M(e,i);if(!s)return"";const{tag:t,rest:a,children:c,attrs:u}=s;if(!W.has(t))return i.error(`Tag "${t}" is not allowed`),"";if(t==="$comment"&&o.insideComment)return i.error("Nested comments are not allowed"),"";if(t==="$if"){const{valueToRender:l}=F(a,n,r,i);return l===void 0?"":$(l,n,o)}b.has(t)&&c.length>0&&i.warn(`Tag "${t}" is a void element and cannot have children`);const f={...o,insideComment:t==="$comment"||o.insideComment,level:(o.level||0)+1},S=l=>l===""?[]:o.indentStr&&l.includes(` `)&&!l.includes("<")?l.split(` -`).map(h=>[f.level,h]):[[f.level,l]];let d,p;if(q(c)){if(!v(c.$bind,"$bind",i))return"";const l=A(n,c.$bind,[],i),{$bind:h,$children:P=[],...L}=c;if(!Array.isArray(l)){if(l!=null&&typeof l!="object")return i.error(`$bind resolved to primitive value of type "${typeof l}", cannot render children`),"";const j=l&&typeof l=="object"&&l!==null?l:{},T=[...r,n];return $({[t]:{...L,$children:P}},j,{...o,parents:T})}if(d=[],!b.has(t))for(const j of l){const T=[...r,n];for(const Q of P){const X=$(Q,j,{...f,parents:T});d.push(...S(X))}}p=L}else{if(d=[],!b.has(t))for(const l of a){const h=$(l,n,{...f,parents:r});d.push(...S(h))}p=u}return Y(t,p,n,d,i,o.indentStr,o.level,r)}function J(e,n,o,r=[],i){const s=Object.entries(e).filter(([t])=>G(t,o,i)).map(([t,c])=>{let a;if(t==="style"){if(a=N(c,n,r,i),!a)return null}else if(z(c)){const u=B(c,n,r,i);a=w(String(u),n,!1,r,i)}else a=w(String(c),n,!1,r,i);return`${t}="${k(a)}"`}).filter(t=>t!==null).join(" ");return s?" "+s:""}function U(e,n={}){const{data:o={},yaml:r,indent:i,logger:s}=n,t=e.renderer.rules.fence;e.renderer.rules.fence=function(c,a,u,f,S){const d=c[a],p=d.info?d.info.trim():"";if(p==="treebark"||p.startsWith("treebark "))try{return H(d.content,o,r,i,s)+` +`).map(h=>[f.level,h]):[[f.level,l]];let d,p;if(q(a)){if(!v(a.$bind,"$bind",i))return"";const l=A(n,a.$bind,[],i),{$bind:h,$children:P=[],...L}=a;if(!Array.isArray(l)){if(l!=null&&typeof l!="object")return i.error(`$bind resolved to primitive value of type "${typeof l}", cannot render children`),"";const j=l&&typeof l=="object"&&l!==null?l:{},T=[...r,n];return $({[t]:{...L,$children:P}},j,{...o,parents:T})}if(d=[],!b.has(t))for(const j of l){const T=[...r,n];for(const Q of P){const X=$(Q,j,{...f,parents:T});d.push(...S(X))}}p=L}else{if(d=[],!b.has(t))for(const l of c){const h=$(l,n,{...f,parents:r});d.push(...S(h))}p=u}return Y(t,p,n,d,i,o.indentStr,o.level,r)}function J(e,n,o,r=[],i){const s=Object.entries(e).filter(([t])=>G(t,o,i)).map(([t,a])=>{let c;if(t==="style"){if(c=N(a,n,r,i),!c)return null}else if(z(a)){const u=B(a,n,r,i);c=w(String(u),n,!1,r,i)}else c=w(String(a),n,!1,r,i);return`${t}="${k(c)}"`}).filter(t=>t!==null).join(" ");return s?" "+s:""}function U(e,n={}){const{data:o={},yaml:r,indent:i,logger:s}=n,t=e.renderer.rules.fence;e.renderer.rules.fence=function(a,c,u,f,S){const d=a[c],p=d.info?d.info.trim():"";if(p==="treebark"||p.startsWith("treebark "))try{return H(d.content,o,r,i,s)+` `}catch(l){const h=l instanceof Error?l.message:"Unknown error";return`
Treebark Error: ${V(h)}
-`}return t?t(c,a,u,f,S):""}}function H(e,n,o,r,i){let s,t=null;if(!e.trim())throw new Error("Empty or invalid template");if(o)try{s=o.load(e)}catch(a){t=a instanceof Error?a:new Error("YAML parsing failed")}if(!s)try{s=JSON.parse(e)}catch(a){throw o&&t?new Error(`Failed to parse as YAML or JSON. YAML error: ${t.message}`):new Error(`Failed to parse as JSON: ${a instanceof Error?a.message:"Invalid format"}`)}if(!s)throw new Error("Empty or invalid template");const c={indent:r,logger:i};if(s&&typeof s=="object"&&"template"in s){const a={...n,...s.data};return I({template:s.template,data:a},c)}else return I({template:s,data:n},c)}function V(e){const n={"&":"&","<":"<",">":">",'"':""","'":"'"};return e.replace(/[&<>"']/g,o=>n[o])}return U})); +`}return t?t(a,c,u,f,S):""}}function H(e,n,o,r,i){let s,t=null;if(!e.trim())throw new Error("Empty or invalid template");if(o)try{s=o.load(e)}catch(c){t=c instanceof Error?c:new Error("YAML parsing failed")}if(!s)try{s=JSON.parse(e)}catch(c){throw o&&t?new Error(`Failed to parse as YAML or JSON. YAML error: ${t.message}`):new Error(`Failed to parse as JSON: ${c instanceof Error?c.message:"Invalid format"}`)}if(!s)throw new Error("Empty or invalid template");const a={indent:r,logger:i};if(s&&typeof s=="object"&&"template"in s){const c={...n,...s.data};return I({template:s.template,data:c},a)}else return I({template:s,data:n},a)}function V(e){const n={"&":"&","<":"<",">":">",'"':""","'":"'"};return e.replace(/[&<>"']/g,o=>n[o])}return U})); //# sourceMappingURL=markdown-it-treebark-browser.min.js.map diff --git a/spec.md b/spec.md index 7f18d547..45aca52c 100644 --- a/spec.md +++ b/spec.md @@ -161,9 +161,70 @@ div: - `img`: `src`, `alt`, `width`, `height` - `table`: `summary` - `th`/`td`: `scope`, `colspan`, `rowspan` - - `blockquote`: `cite` + - `blockquote`: `cite` + - `button`: `type`, `disabled` - Blocked: event handlers (`on*`), dangerous protocols (`javascript:`). +### Button Tag (DOM-only feature) + +The `button` tag supports interactive click handlers in the DOM renderer. This feature is **not available** in the string renderer. + +**Special attribute:** +- `$onClick`: Function that handles click events. Receives `(event: MouseEvent, payload?: unknown)` as parameters. + +**Data payload:** +- Use the `data-payload` attribute to attach data to the button +- The payload is automatically parsed from JSON and passed to the `$onClick` handler +- If JSON parsing fails, the raw string value is passed instead + +**Example:** +```javascript +{ + template: { + button: { + class: 'btn-primary', + type: 'button', + 'data-payload': JSON.stringify({ action: 'save', id: 123 }), + $onClick: (event, payload) => { + console.log('Clicked!', payload); + // payload will be: { action: 'save', id: 123 } + }, + $children: ['Save'] + } + } +} +``` + +**Basic button without handler:** +```javascript +{ + template: { + button: { + type: 'submit', + disabled: 'false', + $children: ['Submit Form'] + } + } +} +``` + +**Button with conditional disabled state:** +```javascript +{ + template: { + button: { + disabled: { + $check: 'isProcessing', + $then: 'true', + $else: 'false' + }, + $children: ['Submit'] + } + }, + data: { isProcessing: false } +} +``` + --- ## 9. Advanced Array Binding with $bind @@ -258,14 +319,14 @@ For complex array scenarios where you need a wrapper element or nested structure `h1`–`h6`, `strong`, `em`, `blockquote`, `code`, `pre`, `ul`, `ol`, `li`, `table`, `thead`, `tbody`, `tr`, `th`, `td`, -`a`, `img` +`a`, `img`, `button` **Special tags:** `comment`, `if` Blocked tags: `script`, `iframe`, `embed`, `object`, `applet`, -`form`, `input`, `button`, `select`, +`form`, `input`, `select`, `video`, `audio`, `style`, `link`, `meta`, `base` From 9cbede045a7e5184d6e2e50808c45bf3a9c582d7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Oct 2025 02:05:46 +0000 Subject: [PATCH 4/8] Add interactive button demo HTML file Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- button-demo.html | 123 ++++++++++++++++++++++++----------------------- 1 file changed, 64 insertions(+), 59 deletions(-) diff --git a/button-demo.html b/button-demo.html index d73edc32..94427f02 100644 --- a/button-demo.html +++ b/button-demo.html @@ -162,9 +162,12 @@

Template Code

From 633feda2a1695d419913f413fcdcbbd94b075bb2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Oct 2025 02:08:47 +0000 Subject: [PATCH 5/8] Fix code review issues: add error handling and remove code duplication in demo Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- button-demo.html | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/button-demo.html b/button-demo.html index 94427f02..37034aeb 100644 --- a/button-demo.html +++ b/button-demo.html @@ -177,6 +177,22 @@

Template Code

output.textContent = message + output.textContent; } + // Helper function to safely parse JSON from data-payload attribute + // Matches the error handling in the actual Treebark DOM renderer + function parsePayload(element) { + const payloadAttr = element.getAttribute('data-payload'); + if (!payloadAttr) { + return undefined; + } + + try { + return JSON.parse(payloadAttr); + } catch (e) { + // If parsing fails, return the raw string value + return payloadAttr; + } + } + // Create buttons manually to demonstrate the concept // In a real app, these would be created using renderToDOM from treebark @@ -190,8 +206,7 @@

Template Code

saveBtn.setAttribute('data-payload', JSON.stringify({ action: "save", id: 1 })); saveBtn.innerHTML = '💾 Save'; saveBtn.addEventListener('click', (event) => { - const payload = JSON.parse(saveBtn.getAttribute('data-payload')); - logEvent("Save Button", payload); + logEvent("Save Button", parsePayload(saveBtn)); }); buttonGroup.appendChild(saveBtn); @@ -202,8 +217,7 @@

Template Code

deleteBtn.setAttribute('data-payload', JSON.stringify({ action: "delete", id: 2 })); deleteBtn.innerHTML = 'đŸ—‘ī¸ Delete'; deleteBtn.addEventListener('click', (event) => { - const payload = JSON.parse(deleteBtn.getAttribute('data-payload')); - logEvent("Delete Button", payload); + logEvent("Delete Button", parsePayload(deleteBtn)); }); buttonGroup.appendChild(deleteBtn); @@ -214,8 +228,7 @@

Template Code

submitBtn.setAttribute('data-payload', JSON.stringify({ action: "submit", formId: "form-1" })); submitBtn.innerHTML = '✅ Submit'; submitBtn.addEventListener('click', (event) => { - const payload = JSON.parse(submitBtn.getAttribute('data-payload')); - logEvent("Submit Button", payload); + logEvent("Submit Button", parsePayload(submitBtn)); }); buttonGroup.appendChild(submitBtn); @@ -234,7 +247,7 @@

Template Code

// Add a note explaining this is a manual demo const note = document.createElement('p'); note.style.cssText = 'margin-top: 10px; font-size: 12px; color: #666;'; - note.textContent = '📝 Note: This demo uses native DOM APIs. With Treebark\'s renderToDOM, the buttons above would be created automatically from the template shown below.'; + note.textContent = "📝 Note: This demo uses native DOM APIs. With Treebark's renderToDOM, the buttons above would be created automatically from the template shown below."; buttonContainer.appendChild(note); console.log('Button demo loaded successfully!'); From 0a1106004af751efbbf02239ee68a21e430b85a5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Oct 2025 02:12:11 +0000 Subject: [PATCH 6/8] Remove $onClick handler - button tag is template-only (no JavaScript handlers) Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- README.md | 111 -------- button-demo.html | 256 ------------------ .../playground/src/examples/button-example.ts | 37 --- nodejs/packages/test/src/dom.test.ts | 135 +-------- nodejs/packages/treebark/src/dom.ts | 26 -- nodejs/packages/treebark/src/types.ts | 5 +- spec.md | 60 ---- 7 files changed, 12 insertions(+), 618 deletions(-) delete mode 100644 button-demo.html delete mode 100644 nodejs/packages/playground/src/examples/button-example.ts diff --git a/README.md b/README.md index 4b4d640d..dbc855b4 100644 --- a/README.md +++ b/README.md @@ -939,117 +939,6 @@ The `$if` tag follows JavaScript truthiness when no operators are provided: - **Truthy:** `true`, non-empty strings, non-zero numbers, objects, arrays - **Falsy:** `false`, `null`, `undefined`, `0`, `""`, `NaN` -### Button Tag with Click Handlers - -The `button` tag supports interactive click handlers when using the DOM renderer (`renderToDOM`). This feature is **not available** in the string renderer. - -**Key features:** -- **Click handler**: Use `$onClick` to attach an event handler -- **Data payload**: Store data in `data-payload` attribute (automatically parsed from JSON) -- **Standard attributes**: Supports `type`, `disabled`, and all global attributes - -**Basic example:** -```json -{ - "button": { - "class": "btn-primary", - "type": "button", - "$children": ["Click Me"] - } -} -``` - -Output: -```html - -``` - -**With click handler (DOM-only):** -```javascript -renderToDOM({ - template: { - button: { - class: "btn-save", - type: "button", - $onClick: (event, payload) => { - console.log('Button clicked!', event, payload); - }, - $children: ['Save'] - } - } -}); -``` - -**With data payload:** -```javascript -renderToDOM({ - template: { - button: { - class: "btn-delete", - 'data-payload': JSON.stringify({ action: 'delete', id: 123 }), - $onClick: (event, payload) => { - // payload will be: { action: 'delete', id: 123 } - console.log('Delete item:', payload.id); - }, - $children: ['Delete Item'] - } - } -}); -``` - -**Conditional disabled state:** -```json -{ - "button": { - "disabled": { - "$check": "isProcessing", - "$then": "true", - "$else": "false" - }, - "$children": ["Submit"] - } -} -``` - -Data: -```json -{ "isProcessing": true } -``` - -Output: -```html - -``` - -**Multiple buttons with different handlers:** -```javascript -renderToDOM({ - template: { - div: { - class: "button-group", - $children: [ - { - button: { - 'data-payload': JSON.stringify({ action: 'save' }), - $onClick: (e, payload) => handleAction(payload), - $children: ['Save'] - } - }, - { - button: { - 'data-payload': JSON.stringify({ action: 'cancel' }), - $onClick: (e, payload) => handleAction(payload), - $children: ['Cancel'] - } - } - ] - } - } -}); -``` - -**Note:** The `$onClick` handler is a DOM-only feature. When using `renderToString`, the button will be rendered but without any click handler attached. The `data-payload` attribute will still be present in the HTML output. - ## Error Handling Treebark follows a **no-throw policy**: instead of throwing exceptions, errors and warnings are sent to a logger. This allows your application to continue rendering even when there are invalid tags, attributes, or other issues. diff --git a/button-demo.html b/button-demo.html deleted file mode 100644 index 37034aeb..00000000 --- a/button-demo.html +++ /dev/null @@ -1,256 +0,0 @@ - - - - - - Treebark Button Tag Demo - - - -
-

đŸŽ¯ Treebark Button Tag Demo

-

This demo showcases the new button tag feature with click handlers and data payloads.

- -
-

Demo Buttons

-
-
- -
-

Event Log

-
Waiting for button clicks...
-
- -
-

Template Code

-
{ - template: { - div: { - class: "button-group", - $children: [ - { - button: { - class: "btn-primary", - type: "button", - "data-payload": JSON.stringify({ action: "save", id: 1 }), - $onClick: (event, payload) => { - logEvent("Save Button", payload); - }, - $children: ["💾 Save"] - } - }, - { - button: { - class: "btn-danger", - type: "button", - "data-payload": JSON.stringify({ action: "delete", id: 2 }), - $onClick: (event, payload) => { - logEvent("Delete Button", payload); - }, - $children: ["đŸ—‘ī¸ Delete"] - } - }, - { - button: { - class: "btn-success", - type: "button", - "data-payload": JSON.stringify({ action: "submit", formId: "form-1" }), - $onClick: (event, payload) => { - logEvent("Submit Button", payload); - }, - $children: ["✅ Submit"] - } - }, - { - button: { - class: "btn-info", - type: "button", - $onClick: (event, payload) => { - logEvent("Info Button (no payload)", payload); - }, - $children: ["â„šī¸ Info (no payload)"] - } - } - ] - } - } -}
-
-
- - - - diff --git a/nodejs/packages/playground/src/examples/button-example.ts b/nodejs/packages/playground/src/examples/button-example.ts deleted file mode 100644 index e8f44667..00000000 --- a/nodejs/packages/playground/src/examples/button-example.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { Example } from './types.js'; - -export const buttonExample: Example = { - template: { - div: { - class: "button-demo", - $children: [ - { h2: "Button Tag Example (DOM-only)" }, - { p: "Buttons can have click handlers and payloads:" }, - { - button: { - class: "btn-primary", - type: "button", - 'data-payload': JSON.stringify({ action: 'save', id: 123 }), - $children: ['Save Item'] - } - }, - { - button: { - class: "btn-danger", - type: "button", - 'data-payload': JSON.stringify({ action: 'delete', id: 123 }), - $children: ['Delete Item'] - } - }, - { - button: { - class: "btn-info", - disabled: "false", - $children: ['Info Button'] - } - } - ] - } - }, - data: {} -}; diff --git a/nodejs/packages/test/src/dom.test.ts b/nodejs/packages/test/src/dom.test.ts index 940cbba7..3f838230 100644 --- a/nodejs/packages/test/src/dom.test.ts +++ b/nodejs/packages/test/src/dom.test.ts @@ -936,8 +936,8 @@ describe('DOM Renderer', () => { }); }); - // Button tag tests (DOM-only feature) - describe('Button Tag with Click Handlers', () => { + // Button tag tests (template-only, no event handlers) + describe('Button Tag', () => { test('renders basic button tag', () => { const fragment = renderToDOM({ template: { @@ -970,107 +970,20 @@ describe('DOM Renderer', () => { expect(button.textContent).toBe('Submit'); }); - test('button with $onClick handler', () => { - let clicked = false; - let receivedEvent: MouseEvent | null = null; - - const fragment = renderToDOM({ - template: { - button: { - $onClick: (event: MouseEvent) => { - clicked = true; - receivedEvent = event; - }, - $children: ['Click me'] - } - } - }); - - const button = fragment.firstChild as HTMLButtonElement; - expect(button.tagName).toBe('BUTTON'); - - // Click the button - button.click(); - - expect(clicked).toBe(true); - expect(receivedEvent).toBeTruthy(); - }); - - test('button with data-payload attribute', () => { + test('button with data attributes', () => { const fragment = renderToDOM({ template: { button: { - 'data-payload': JSON.stringify({ action: 'delete', id: 123 }), - $children: ['Delete'] + 'data-action': 'save', + 'data-id': '123', + $children: ['Save'] } } }); const button = fragment.firstChild as HTMLButtonElement; - expect(button.getAttribute('data-payload')).toBe('{"action":"delete","id":123}'); - }); - - test('button with $onClick handler receives payload from data-payload', () => { - let receivedPayload: unknown = undefined; - - const fragment = renderToDOM({ - template: { - button: { - 'data-payload': JSON.stringify({ action: 'submit', formId: 'form-1' }), - $onClick: (event: MouseEvent, payload?: unknown) => { - receivedPayload = payload; - }, - $children: ['Submit Form'] - } - } - }); - - const button = fragment.firstChild as HTMLButtonElement; - button.click(); - - expect(receivedPayload).toEqual({ action: 'submit', formId: 'form-1' }); - }); - - test('button with $onClick handler receives string payload if JSON parsing fails', () => { - let receivedPayload: unknown = undefined; - - const fragment = renderToDOM({ - template: { - button: { - 'data-payload': 'not-valid-json', - $onClick: (event: MouseEvent, payload?: unknown) => { - receivedPayload = payload; - }, - $children: ['Click'] - } - } - }); - - const button = fragment.firstChild as HTMLButtonElement; - button.click(); - - // Should receive the raw string when JSON parsing fails - expect(receivedPayload).toBe('not-valid-json'); - }); - - test('button with $onClick handler receives undefined payload when no data-payload', () => { - let receivedPayload: unknown = 'initial-value'; - - const fragment = renderToDOM({ - template: { - button: { - $onClick: (event: MouseEvent, payload?: unknown) => { - receivedPayload = payload; - }, - $children: ['Click'] - } - } - }); - - const button = fragment.firstChild as HTMLButtonElement; - button.click(); - - expect(receivedPayload).toBeUndefined(); + expect(button.getAttribute('data-action')).toBe('save'); + expect(button.getAttribute('data-id')).toBe('123'); }); test('button with disabled attribute using interpolation', () => { @@ -1107,25 +1020,6 @@ describe('DOM Renderer', () => { expect(button.getAttribute('disabled')).toBe('true'); }); - test('warns when $onClick is not a function', () => { - const mockLogger = { - error: jest.fn(), - warn: jest.fn(), - log: jest.fn() - }; - - const fragment = renderToDOM({ - template: { - button: { - $onClick: 'not-a-function' as any, - $children: ['Click'] - } - } - }, { logger: mockLogger }); - - expect(mockLogger.warn).toHaveBeenCalledWith('$onClick must be a function'); - }); - test('button with nested elements', () => { const fragment = renderToDOM({ template: { @@ -1145,22 +1039,18 @@ describe('DOM Renderer', () => { expect(button.textContent).toBe('🔔 Notify'); }); - test('multiple buttons with different handlers', () => { - let clickedButton: string | null = null; - + test('multiple buttons', () => { const fragment = renderToDOM({ template: [ { button: { id: 'btn1', - $onClick: () => { clickedButton = 'button1'; }, $children: ['Button 1'] } }, { button: { id: 'btn2', - $onClick: () => { clickedButton = 'button2'; }, $children: ['Button 2'] } } @@ -1170,11 +1060,8 @@ describe('DOM Renderer', () => { const button1 = fragment.querySelector('#btn1') as HTMLButtonElement; const button2 = fragment.querySelector('#btn2') as HTMLButtonElement; - button1.click(); - expect(clickedButton).toBe('button1'); - - button2.click(); - expect(clickedButton).toBe('button2'); + expect(button1.textContent).toBe('Button 1'); + expect(button2.textContent).toBe('Button 2'); }); }); }); \ No newline at end of file diff --git a/nodejs/packages/treebark/src/dom.ts b/nodejs/packages/treebark/src/dom.ts index beacd90d..6f637883 100644 --- a/nodejs/packages/treebark/src/dom.ts +++ b/nodejs/packages/treebark/src/dom.ts @@ -176,32 +176,6 @@ function render(template: TemplateElement | TemplateElement[], data: Data, conte function setAttrs(element: HTMLElement, attrs: Record, data: Data, tag: string, parents: Data[] = [], logger: Logger): void { Object.entries(attrs).forEach(([key, value]) => { - // Special handling for $onClick - DOM-only feature for button tags - if (key === '$onClick' && tag === 'button') { - if (typeof value === 'function') { - const handler = value as (event: MouseEvent, payload?: unknown) => void; - element.addEventListener('click', (event: Event) => { - // Check if data-payload attribute exists - const payloadAttr = element.getAttribute('data-payload'); - let payload: unknown = undefined; - - if (payloadAttr) { - try { - payload = JSON.parse(payloadAttr); - } catch (e) { - // If parsing fails, use the raw string value - payload = payloadAttr; - } - } - - handler(event as MouseEvent, payload); - }); - } else { - logger.warn('$onClick must be a function'); - } - return; // Don't set $onClick as an HTML attribute - } - if (!validateAttribute(key, tag, logger)) { return; // Skip invalid attributes } diff --git a/nodejs/packages/treebark/src/types.ts b/nodejs/packages/treebark/src/types.ts index c5073929..3d8cd2d2 100644 --- a/nodejs/packages/treebark/src/types.ts +++ b/nodejs/packages/treebark/src/types.ts @@ -79,9 +79,6 @@ type BaseContainerAttrs = GlobalAttrs & { $children?: (string | TemplateObject)[]; }; -// Button-specific event handler type (DOM-only) -export type ButtonClickHandler = (event: MouseEvent, payload?: unknown) => void; - // Base attributes for void tags (no children allowed) type BaseVoidAttrs = GlobalAttrs & { $bind?: string; @@ -117,7 +114,7 @@ export type TrTag = { tr: TagContent }; export type ThTag = { th: TagContent }; export type TdTag = { td: TagContent }; export type ATag = { a: TagContent }; -export type ButtonTag = { button: TagContent }; +export type ButtonTag = { button: TagContent }; export type CommentTag = { $comment: TagContent }; // Void tag types diff --git a/spec.md b/spec.md index 45aca52c..c852dc3c 100644 --- a/spec.md +++ b/spec.md @@ -165,66 +165,6 @@ div: - `button`: `type`, `disabled` - Blocked: event handlers (`on*`), dangerous protocols (`javascript:`). -### Button Tag (DOM-only feature) - -The `button` tag supports interactive click handlers in the DOM renderer. This feature is **not available** in the string renderer. - -**Special attribute:** -- `$onClick`: Function that handles click events. Receives `(event: MouseEvent, payload?: unknown)` as parameters. - -**Data payload:** -- Use the `data-payload` attribute to attach data to the button -- The payload is automatically parsed from JSON and passed to the `$onClick` handler -- If JSON parsing fails, the raw string value is passed instead - -**Example:** -```javascript -{ - template: { - button: { - class: 'btn-primary', - type: 'button', - 'data-payload': JSON.stringify({ action: 'save', id: 123 }), - $onClick: (event, payload) => { - console.log('Clicked!', payload); - // payload will be: { action: 'save', id: 123 } - }, - $children: ['Save'] - } - } -} -``` - -**Basic button without handler:** -```javascript -{ - template: { - button: { - type: 'submit', - disabled: 'false', - $children: ['Submit Form'] - } - } -} -``` - -**Button with conditional disabled state:** -```javascript -{ - template: { - button: { - disabled: { - $check: 'isProcessing', - $then: 'true', - $else: 'false' - }, - $children: ['Submit'] - } - }, - data: { isProcessing: false } -} -``` - --- ## 9. Advanced Array Binding with $bind From 1fb476c984ca8909e5cda7be528e1503f5eaba61 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Oct 2025 02:25:45 +0000 Subject: [PATCH 7/8] Revert implementation and add exploration document for button/form elements Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- BUTTON_FORM_EXPLORATION.md | 465 ++++++++++++++++++ README.md | 3 +- docs/assets/markdown-it-treebark-browser.js | 6 +- .../markdown-it-treebark-browser.min.js | 12 +- docs/assets/treebark-browser.js | 6 +- docs/assets/treebark-browser.min.js | 4 +- nodejs/packages/test/src/dom.test.ts | 129 ----- nodejs/packages/treebark/src/common.ts | 5 +- nodejs/packages/treebark/src/types.ts | 5 +- spec.md | 7 +- 10 files changed, 485 insertions(+), 157 deletions(-) create mode 100644 BUTTON_FORM_EXPLORATION.md diff --git a/BUTTON_FORM_EXPLORATION.md b/BUTTON_FORM_EXPLORATION.md new file mode 100644 index 00000000..97c6492e --- /dev/null +++ b/BUTTON_FORM_EXPLORATION.md @@ -0,0 +1,465 @@ +# Button Tag and Form Elements - Exploration + +This document explores the possibilities for supporting button tags and form elements in Treebark, taking inspiration from [AdaptiveCards actions pattern](https://learn.microsoft.com/en-us/adaptive-cards/sdk/rendering-cards/javascript/actions). + +## Problem Statement + +Treebark currently blocks interactive elements like `button`, `input`, `select`, `textarea`, and `form` for security reasons. However, there may be use cases where safe, template-driven interactive elements could be valuable, especially in content-driven applications. + +## Key Questions to Explore + +1. **Should we support interactive elements at all?** What's the use case? +2. **If yes, how do we maintain security?** No arbitrary JavaScript execution +3. **How would event handling work?** Context-based handlers vs. inline handlers +4. **What's the API surface?** Minimal and intuitive +5. **DOM-only or both renderers?** String renderer limitations + +## Exploration 1: Button Tag with Context-Based Actions + +### AdaptiveCards Pattern + +AdaptiveCards uses a pattern where: +- Actions are defined in the template with a `type` and `data` +- A single action handler is registered for the entire card context +- The handler receives the action type and data when triggered + +Example from AdaptiveCards: +```json +{ + "type": "Action.Submit", + "title": "Save", + "data": { + "action": "save", + "id": "123" + } +} +``` + +### Possible Treebark Pattern + +#### Option A: Single Context Handler (AdaptiveCards-style) + +Template defines actions declaratively: +```javascript +{ + template: { + div: { + $children: [ + { + button: { + type: "button", + "data-action": "save", + "data-id": "123", + $children: ["Save"] + } + }, + { + button: { + type: "button", + "data-action": "delete", + "data-id": "456", + $children: ["Delete"] + } + } + ] + } + }, + data: { /* template data */ } +} +``` + +Application provides a single handler: +```javascript +const fragment = renderToDOM(input, { + onAction: (action, payload) => { + switch(action) { + case 'save': + console.log('Save item', payload.id); + break; + case 'delete': + console.log('Delete item', payload.id); + break; + } + } +}); +``` + +**Pros:** +- Clean separation: templates define structure, app defines behavior +- Single handler reduces boilerplate +- Easy to understand and maintain +- Aligns with declarative template philosophy + +**Cons:** +- Requires DOM renderer (won't work in string renderer) +- Additional API surface in RenderOptions +- Need to decide how to extract action/payload from button + +#### Option B: External Handler Attachment (Current HTML standard) + +Template only defines structure: +```javascript +{ + template: { + button: { + class: "btn-save", + "data-action": "save", + "data-id": "123", + $children: ["Save"] + } + } +} +``` + +Application attaches handlers after rendering: +```javascript +const fragment = renderToDOM(input); +document.body.appendChild(fragment); + +// App adds handlers externally +document.querySelectorAll('button').forEach(btn => { + btn.addEventListener('click', (e) => { + const action = e.target.getAttribute('data-action'); + const id = e.target.getAttribute('data-id'); + handleAction(action, { id }); + }); +}); +``` + +**Pros:** +- Simplest implementation (just allow the tag) +- No new API surface +- Works with standard DOM APIs +- Clearest security model + +**Cons:** +- More boilerplate in application code +- Not as elegant as declarative actions + +#### Option C: Hybrid - Optional Context Handler + +Allow button tag always, but provide optional context handler: +```javascript +// Without handler - external attachment needed +const fragment1 = renderToDOM({ template: { button: "Click" } }); + +// With optional handler +const fragment2 = renderToDOM( + { template: { button: { "data-action": "save", $children: ["Save"] } } }, + { + onAction: (action, payload) => { /* handle */ } + } +); +``` + +**Pros:** +- Flexibility for different use cases +- Progressive enhancement +- Doesn't force a pattern + +**Cons:** +- Two ways to do the same thing +- More complex to document and maintain + +### Payload Extraction Strategy + +If we go with context handlers, how do we extract action/payload? + +**Strategy 1: Convention-based** +- Look for `data-action` attribute for action name +- Collect all `data-*` attributes as payload + +**Strategy 2: Explicit configuration** +- Button has special `$action` property +```javascript +{ + button: { + $action: "save", + $actionData: { id: 123 }, + $children: ["Save"] + } +} +``` + +**Strategy 3: Mixed** +- Support both conventions and explicit for flexibility + +## Exploration 2: Form Elements + +### Which Form Elements? + +If we allow buttons, should we also allow: +- `input` (text, checkbox, radio, etc.) +- `textarea` +- `select` / `option` +- `label` +- `form` (container) + +### Security Considerations + +**Safe:** +- `button` - can only trigger actions +- `label` - just associates with inputs +- Read-only inputs with `readonly` attribute + +**Potentially risky:** +- `form` with `action` attribute (server-side submission) +- File inputs (`input type="file"`) +- Hidden inputs that could be manipulated + +### Possible Form Pattern + +#### Read-only Forms (Display Only) + +```javascript +{ + template: { + form: { + $children: [ + { label: { for: "name", $children: ["Name:"] } }, + { input: { type: "text", id: "name", readonly: "true", value: "{{userName}}" } }, + + { label: { for: "email", $children: ["Email:"] } }, + { input: { type: "email", id: "email", readonly: "true", value: "{{userEmail}}" } } + ] + } + }, + data: { userName: "Alice", userEmail: "alice@example.com" } +} +``` + +**Use case:** Displaying form-like data (receipts, confirmations, etc.) + +#### Interactive Forms with Context Handler + +```javascript +{ + template: { + form: { + "data-form-id": "user-form", + $children: [ + { label: { for: "name", $children: ["Name:"] } }, + { input: { type: "text", id: "name", name: "name", value: "{{userName}}" } }, + + { label: { for: "email", $children: ["Email:"] } }, + { input: { type: "email", id: "email", name: "email", value: "{{userEmail}}" } }, + + { button: { type: "submit", "data-action": "submitForm", $children: ["Save"] } } + ] + } + }, + data: { userName: "Alice", userEmail: "alice@example.com" } +} + +// Render with handler +const fragment = renderToDOM(input, { + onAction: (action, payload) => { + if (action === 'submitForm') { + const formData = new FormData(payload.target.closest('form')); + console.log('Form submitted:', Object.fromEntries(formData)); + } + } +}); +``` + +**Use case:** Simple data collection forms in content + +### Form Element Attributes + +Which attributes should be allowed? + +**Definitely safe:** +- `type`, `id`, `name`, `value`, `placeholder` +- `readonly`, `disabled` +- `for` (on labels) +- `rows`, `cols` (on textarea) +- `checked` (on checkbox/radio) +- `selected` (on option) +- `multiple` (on select) + +**Questionable:** +- `action` (on form) - could submit to arbitrary URLs +- `method` (on form) - GET vs POST +- `autocomplete` - privacy implications? + +**Definitely block:** +- `formaction`, `formmethod` - can override form behavior +- `onfocus`, `onblur`, `onchange` - event handlers + +## Implementation Considerations + +### 1. Tag Whitelist Updates + +```javascript +// In common.ts +export const CONTAINER_TAGS = new Set([ + // ... existing tags ... + 'button', + 'form', + 'label' +]); + +export const VOID_TAGS = new Set([ + // ... existing tags ... + 'input' +]); + +// Conditionally allowed (needs discussion) +export const FORM_TAGS = new Set([ + 'select', + 'option', + 'textarea' +]); +``` + +### 2. Attribute Whitelist Updates + +```javascript +export const TAG_SPECIFIC_ATTRS: Record> = { + // ... existing ... + 'button': new Set(['type', 'disabled']), + 'input': new Set(['type', 'name', 'value', 'placeholder', 'readonly', 'disabled', 'checked']), + 'textarea': new Set(['name', 'rows', 'cols', 'placeholder', 'readonly', 'disabled']), + 'select': new Set(['name', 'multiple', 'disabled']), + 'option': new Set(['value', 'selected']), + 'label': new Set(['for']), + 'form': new Set(['data-form-id']) // Explicitly NOT action/method +}; +``` + +### 3. Context Handler API + +```typescript +interface RenderOptions { + indent?: string | number | boolean; + logger?: Logger; + onAction?: ActionHandler; // NEW +} + +type ActionHandler = (action: string, payload: ActionPayload) => void; + +interface ActionPayload { + target: HTMLElement; + [key: string]: unknown; // Additional data-* attributes +} +``` + +### 4. DOM Renderer Changes + +```javascript +function setAttrs(element: HTMLElement, attrs: Record, data: Data, tag: string, parents: Data[] = [], logger: Logger, onAction?: ActionHandler): void { + // ... existing attribute handling ... + + // If button and onAction handler provided + if (tag === 'button' && onAction) { + element.addEventListener('click', (event) => { + event.preventDefault(); + + const action = element.getAttribute('data-action'); + if (!action) { + logger.warn('Button with onAction handler must have data-action attribute'); + return; + } + + // Collect all data-* attributes as payload + const payload: ActionPayload = { target: element }; + for (const attr of element.attributes) { + if (attr.name.startsWith('data-') && attr.name !== 'data-action') { + const key = attr.name.substring(5); // Remove 'data-' prefix + payload[key] = attr.value; + } + } + + onAction(action, payload); + }); + } +} +``` + +## Recommendations + +Based on this exploration, here are potential paths forward: + +### Path 1: Minimal - Button Tag Only + +**Implementation:** +- Allow `button` tag in whitelist +- Support `type` and `disabled` attributes +- No special event handling - apps attach handlers externally +- Document best practices for using data-* attributes + +**Pros:** Minimal change, clear security model, easy to understand +**Cons:** Doesn't provide much value over current workarounds + +### Path 2: Button with Optional Context Handler + +**Implementation:** +- Allow `button` tag in whitelist +- Add optional `onAction` to RenderOptions (DOM only) +- Auto-wire buttons with `data-action` to the handler +- Extract data-* attributes as payload + +**Pros:** Elegant for simple cases, optional for complex cases +**Cons:** DOM-only feature, new API to maintain + +### Path 3: Full Form Support with Context Handlers + +**Implementation:** +- Allow button, input, select, textarea, label, form +- Strict attribute whitelisting (no action/method on form) +- Optional context handlers for both actions and form submission +- Comprehensive documentation on security model + +**Pros:** Powerful for content-driven apps with forms +**Cons:** Large API surface, security concerns, complexity + +### Path 4: No Changes + +**Implementation:** +- Keep current blocking of interactive elements +- Document workarounds (render content, attach handlers externally) + +**Pros:** Zero risk, zero maintenance +**Cons:** Less useful for interactive content scenarios + +## Questions for Discussion + +1. **What's the actual use case?** Is this for CMS content with occasional buttons, or for building full form-based UIs? + +2. **DOM-only acceptable?** If features only work in DOM renderer, is that okay? + +3. **Security vs. convenience tradeoff?** Where do we draw the line on what's allowed? + +4. **Maintenance burden?** How much API surface are we willing to support long-term? + +5. **Alternative approaches?** Could we provide helper utilities instead of built-in support? + +## Next Steps + +To move this forward, we need to: + +1. **Define the use case clearly** - What problem are we actually solving? +2. **Choose a path** - Which approach aligns with Treebark's goals? +3. **Prototype** - Build a minimal proof-of-concept +4. **Test with real content** - Does it work for actual use cases? +5. **Document** - Clear security model and best practices +6. **Decide** - Ship it, iterate, or abandon? + +## Appendix: AdaptiveCards Reference + +AdaptiveCards action types: +- `Action.OpenUrl` - Opens a URL +- `Action.Submit` - Submits data to the host app +- `Action.ShowCard` - Shows another card +- `Action.ToggleVisibility` - Shows/hides elements + +Their pattern: +```javascript +adaptiveCard.onExecuteAction = function(action) { + if (action instanceof AC.SubmitAction) { + console.log("Submitted data:", action.data); + } +} +``` + +This is similar to our proposed `onAction` handler pattern. diff --git a/README.md b/README.md index dbc855b4..f98e322c 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ This means the implementation is featherweight. `h1`–`h6`, `strong`, `em`, `blockquote`, `code`, `pre`, `ul`, `ol`, `li`, `table`, `thead`, `tbody`, `tr`, `th`, `td`, -`a`, `img`, `br`, `hr`, `button` +`a`, `img`, `br`, `hr` **Special tags:** - `$comment` — Emits HTML comments. Cannot be nested inside another `$comment`. @@ -101,7 +101,6 @@ This means the implementation is featherweight. | `table` | `summary` | | `th`, `td` | `scope`, `colspan`, `rowspan` | | `blockquote` | `cite` | -| `button` | `type`, `disabled` | ### Special Keys diff --git a/docs/assets/markdown-it-treebark-browser.js b/docs/assets/markdown-it-treebark-browser.js index 90d14251..ff1d886d 100644 --- a/docs/assets/markdown-it-treebark-browser.js +++ b/docs/assets/markdown-it-treebark-browser.js @@ -31,8 +31,7 @@ "tr", "th", "td", - "a", - "button" + "a" ]); const SPECIAL_TAGS = /* @__PURE__ */ new Set([ "$comment", @@ -51,8 +50,7 @@ "table": /* @__PURE__ */ new Set(["summary"]), "th": /* @__PURE__ */ new Set(["scope", "colspan", "rowspan"]), "td": /* @__PURE__ */ new Set(["scope", "colspan", "rowspan"]), - "blockquote": /* @__PURE__ */ new Set(["cite"]), - "button": /* @__PURE__ */ new Set(["type", "disabled"]) + "blockquote": /* @__PURE__ */ new Set(["cite"]) }; const OPERATORS = /* @__PURE__ */ new Set(["$<", "$>", "$<=", "$>=", "$=", "$in"]); const CONDITIONALKEYS = /* @__PURE__ */ new Set(["$check", "$then", "$else", "$not", "$join", ...OPERATORS]); diff --git a/docs/assets/markdown-it-treebark-browser.min.js b/docs/assets/markdown-it-treebark-browser.min.js index d0086747..12f7b225 100644 --- a/docs/assets/markdown-it-treebark-browser.min.js +++ b/docs/assets/markdown-it-treebark-browser.min.js @@ -1,11 +1,11 @@ -(function(y,m){typeof exports=="object"&&typeof module<"u"?module.exports=m():typeof define=="function"&&define.amd?define(m):(y=typeof globalThis<"u"?globalThis:y||self,y.MarkdownItTreebark=m())})(this,(function(){"use strict";const y=new Set(["div","span","p","header","footer","main","section","article","h1","h2","h3","h4","h5","h6","strong","em","blockquote","code","pre","ul","ol","li","table","thead","tbody","tr","th","td","a","button"]),m=new Set(["$comment","$if"]),b=new Set(["img","br","hr"]),W=new Set([...y,...m,...b]),E=new Set(["id","class","style","title","role","data-","aria-"]),_={a:new Set(["href","target","rel"]),img:new Set(["src","alt","width","height"]),table:new Set(["summary"]),th:new Set(["scope","colspan","rowspan"]),td:new Set(["scope","colspan","rowspan"]),blockquote:new Set(["cite"]),button:new Set(["type","disabled"])},C=new Set(["$<","$>","$<=","$>=","$=","$in"]),O=new Set(["$check","$then","$else","$not","$join",...C]),D=new Set(["behavior","-moz-binding"]);function A(e,n,o=[],r){if(n===".")return e;let i=e,s=n;for(;s.startsWith("..");){let t=0,a=s;for(;a.startsWith("..");)t++,a=a.substring(2),a.startsWith("/")&&(a=a.substring(1));if(t<=o.length)i=o[o.length-t],s=a.startsWith(".")?a.substring(1):a;else return}if(s){if(r&&typeof i!="object"&&i!==null&&i!==void 0){r.error(`Cannot access property "${s}" on primitive value of type "${typeof i}"`);return}return s.split(".").reduce((t,a)=>t&&typeof t=="object"&&t!==null?t[a]:void 0,i)}return i}function k(e){return e.replace(/[&<>"']/g,n=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[n]||n)}function w(e,n,o=!0,r=[],i){return e.replace(/\{\{\{([^{]*?)\}\}\}|\{\{([^{]*?)\}\}/g,(s,t,a)=>{if(t!==void 0)return`{{${t.trim()}}}`;const c=a.trim(),u=A(n,c,r,i);return u==null?"":o?k(String(u)):String(u)})}function R(e,n){const o=[];for(const[r,i]of Object.entries(e)){const s=r;if(!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(s)){n.warn(`CSS property "${r}" has invalid format (must be kebab-case)`);continue}if(D.has(s)){n.warn(`CSS property "${r}" is blocked for security reasons`);continue}if(i==null)continue;let t=String(i).trim();if(t.includes(";")){const u=t;t=t.split(";")[0].trim(),t&&t!==u.trim()&&n.warn(`CSS value for "${r}" contained semicolon - using only first part: "${t}"`)}if(!t)continue;const a=/url\s*\(/i.test(t),c=/url\s*\(\s*['"]?data:/i.test(t);if(a&&!c||/expression\s*\(/i.test(t)||/javascript:/i.test(t)||/@import/i.test(t)){n.warn(`CSS value for "${r}" contains potentially dangerous pattern: "${t}"`);continue}o.push(`${s}: ${t}`)}return o.join("; ").trim()}function N(e,n,o,r){if(e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"){const i=e;if(!v(i.$check,"$check",r))return"";const s=A(n,i.$check,o),a=g(s,i)?i.$then:i.$else;return a===void 0?"":typeof a=="object"&&a!==null&&!Array.isArray(a)?R(a,r):""}return typeof e=="object"&&e!==null&&!Array.isArray(e)?R(e,r):(r.error(`Style attribute must be an object with CSS properties, not ${typeof e}. Example: style: { "color": "red", "font-size": "14px" }`),"")}function G(e,n,o){const r=E.has(e)||[...E].some(t=>t.endsWith("-")&&e.startsWith(t)),i=_[n],s=i&&i.has(e);return!r&&!s?(o.warn(`Attribute "${e}" is not allowed on tag "${n}"`),!1):!0}function q(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$bind"in e}function v(e,n,o){return e==="."?!0:e.includes("..")?(o.error(`${n} does not support parent context access (..) - use interpolation {{..prop}} in content/attributes instead. Invalid: ${n}: "${e}"`),!1):e.includes("{{")?(o.error(`${n} does not support interpolation {{...}} - use literal property paths only. Invalid: ${n}: "${e}"`),!1):!0}function g(e,n){const o=[];for(const t of C)t in n&&o.push({key:t,value:n[t]});if(o.length===0){const t=!!e;return n.$not?!t:t}const r=o.map(t=>{switch(t.key){case"$<":return typeof e=="number"&&typeof t.value=="number"&&e":return typeof e=="number"&&typeof t.value=="number"&&e>t.value;case"$<=":return typeof e=="number"&&typeof t.value=="number"&&e<=t.value;case"$>=":return typeof e=="number"&&typeof t.value=="number"&&e>=t.value;case"$=":return e===t.value;case"$in":return Array.isArray(t.value)&&t.value.includes(e);default:return!1}}),i=n.$join==="OR";let s;return i?s=r.some(t=>t):s=r.every(t=>t),n.$not?!s:s}function z(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function B(e,n,o=[],r){if(!v(e.$check,"$check",r))return"";const i=A(n,e.$check,o);return g(i,e)?e.$then!==void 0?e.$then:"":e.$else!==void 0?e.$else:""}function M(e,n){if(!e||typeof e!="object"){n.error("Template object cannot be null, undefined, or non-object");return}const o=Object.entries(e);if(o.length===0){n.error("Template object must have at least one tag");return}const r=o[0];if(!r){n.error("Template object must have at least one tag");return}const[i,s]=r,t=typeof s=="string"?[s]:Array.isArray(s)?s:s?.$children||[],a=s&&typeof s=="object"&&!Array.isArray(s)?Object.fromEntries(Object.entries(s).filter(([c])=>c!=="$children")):{};return{tag:i,rest:s,children:t,attrs:a}}function F(e,n,o=[],r){const i=e;if(!i.$check)return r.error('"$if" tag requires $check attribute to specify the condition'),{valueToRender:void 0};if(!v(i.$check,"$check",r))return{valueToRender:void 0};const s=A(n,i.$check,o);typeof e=="object"&&e!==null&&!Array.isArray(e)&&"$children"in e&&r.warn('"$if" tag does not support $children, use $then and $else instead');const{$then:t,$else:a}=i;if(t!==void 0&&Array.isArray(t))return r.error('"$if" tag $then must be a string or single element object, not an array'),{valueToRender:void 0};if(a!==void 0&&Array.isArray(a))return r.error('"$if" tag $else must be a string or single element object, not an array'),{valueToRender:void 0};const u=(typeof e=="object"&&e!==null&&!Array.isArray(e)?Object.keys(e):[]).filter(d=>!O.has(d));return u.length>0&&r.warn(`"$if" tag does not support attributes: ${u.join(", ")}. Allowed: ${[...O].join(", ")}`),{valueToRender:g(s,i)?t:a}}const K=(e,n)=>{if(!n)return e.length<=1?e[0]?.[1]??"":e.reduce((r,[,i])=>r+i,"");if(e.length===0)return"";if(e.length===1&&!e[0][1].includes("<"))return e[0][1];let o=` +(function(y,m){typeof exports=="object"&&typeof module<"u"?module.exports=m():typeof define=="function"&&define.amd?define(m):(y=typeof globalThis<"u"?globalThis:y||self,y.MarkdownItTreebark=m())})(this,(function(){"use strict";const y=new Set(["div","span","p","header","footer","main","section","article","h1","h2","h3","h4","h5","h6","strong","em","blockquote","code","pre","ul","ol","li","table","thead","tbody","tr","th","td","a"]),m=new Set(["$comment","$if"]),b=new Set(["img","br","hr"]),W=new Set([...y,...m,...b]),E=new Set(["id","class","style","title","role","data-","aria-"]),_={a:new Set(["href","target","rel"]),img:new Set(["src","alt","width","height"]),table:new Set(["summary"]),th:new Set(["scope","colspan","rowspan"]),td:new Set(["scope","colspan","rowspan"]),blockquote:new Set(["cite"])},C=new Set(["$<","$>","$<=","$>=","$=","$in"]),O=new Set(["$check","$then","$else","$not","$join",...C]),D=new Set(["behavior","-moz-binding"]);function A(e,n,o=[],r){if(n===".")return e;let i=e,s=n;for(;s.startsWith("..");){let t=0,c=s;for(;c.startsWith("..");)t++,c=c.substring(2),c.startsWith("/")&&(c=c.substring(1));if(t<=o.length)i=o[o.length-t],s=c.startsWith(".")?c.substring(1):c;else return}if(s){if(r&&typeof i!="object"&&i!==null&&i!==void 0){r.error(`Cannot access property "${s}" on primitive value of type "${typeof i}"`);return}return s.split(".").reduce((t,c)=>t&&typeof t=="object"&&t!==null?t[c]:void 0,i)}return i}function k(e){return e.replace(/[&<>"']/g,n=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[n]||n)}function w(e,n,o=!0,r=[],i){return e.replace(/\{\{\{([^{]*?)\}\}\}|\{\{([^{]*?)\}\}/g,(s,t,c)=>{if(t!==void 0)return`{{${t.trim()}}}`;const a=c.trim(),u=A(n,a,r,i);return u==null?"":o?k(String(u)):String(u)})}function R(e,n){const o=[];for(const[r,i]of Object.entries(e)){const s=r;if(!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(s)){n.warn(`CSS property "${r}" has invalid format (must be kebab-case)`);continue}if(D.has(s)){n.warn(`CSS property "${r}" is blocked for security reasons`);continue}if(i==null)continue;let t=String(i).trim();if(t.includes(";")){const u=t;t=t.split(";")[0].trim(),t&&t!==u.trim()&&n.warn(`CSS value for "${r}" contained semicolon - using only first part: "${t}"`)}if(!t)continue;const c=/url\s*\(/i.test(t),a=/url\s*\(\s*['"]?data:/i.test(t);if(c&&!a||/expression\s*\(/i.test(t)||/javascript:/i.test(t)||/@import/i.test(t)){n.warn(`CSS value for "${r}" contains potentially dangerous pattern: "${t}"`);continue}o.push(`${s}: ${t}`)}return o.join("; ").trim()}function N(e,n,o,r){if(e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"){const i=e;if(!v(i.$check,"$check",r))return"";const s=A(n,i.$check,o),c=g(s,i)?i.$then:i.$else;return c===void 0?"":typeof c=="object"&&c!==null&&!Array.isArray(c)?R(c,r):""}return typeof e=="object"&&e!==null&&!Array.isArray(e)?R(e,r):(r.error(`Style attribute must be an object with CSS properties, not ${typeof e}. Example: style: { "color": "red", "font-size": "14px" }`),"")}function G(e,n,o){const r=E.has(e)||[...E].some(t=>t.endsWith("-")&&e.startsWith(t)),i=_[n],s=i&&i.has(e);return!r&&!s?(o.warn(`Attribute "${e}" is not allowed on tag "${n}"`),!1):!0}function q(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$bind"in e}function v(e,n,o){return e==="."?!0:e.includes("..")?(o.error(`${n} does not support parent context access (..) - use interpolation {{..prop}} in content/attributes instead. Invalid: ${n}: "${e}"`),!1):e.includes("{{")?(o.error(`${n} does not support interpolation {{...}} - use literal property paths only. Invalid: ${n}: "${e}"`),!1):!0}function g(e,n){const o=[];for(const t of C)t in n&&o.push({key:t,value:n[t]});if(o.length===0){const t=!!e;return n.$not?!t:t}const r=o.map(t=>{switch(t.key){case"$<":return typeof e=="number"&&typeof t.value=="number"&&e":return typeof e=="number"&&typeof t.value=="number"&&e>t.value;case"$<=":return typeof e=="number"&&typeof t.value=="number"&&e<=t.value;case"$>=":return typeof e=="number"&&typeof t.value=="number"&&e>=t.value;case"$=":return e===t.value;case"$in":return Array.isArray(t.value)&&t.value.includes(e);default:return!1}}),i=n.$join==="OR";let s;return i?s=r.some(t=>t):s=r.every(t=>t),n.$not?!s:s}function z(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function B(e,n,o=[],r){if(!v(e.$check,"$check",r))return"";const i=A(n,e.$check,o);return g(i,e)?e.$then!==void 0?e.$then:"":e.$else!==void 0?e.$else:""}function M(e,n){if(!e||typeof e!="object"){n.error("Template object cannot be null, undefined, or non-object");return}const o=Object.entries(e);if(o.length===0){n.error("Template object must have at least one tag");return}const r=o[0];if(!r){n.error("Template object must have at least one tag");return}const[i,s]=r,t=typeof s=="string"?[s]:Array.isArray(s)?s:s?.$children||[],c=s&&typeof s=="object"&&!Array.isArray(s)?Object.fromEntries(Object.entries(s).filter(([a])=>a!=="$children")):{};return{tag:i,rest:s,children:t,attrs:c}}function F(e,n,o=[],r){const i=e;if(!i.$check)return r.error('"$if" tag requires $check attribute to specify the condition'),{valueToRender:void 0};if(!v(i.$check,"$check",r))return{valueToRender:void 0};const s=A(n,i.$check,o);typeof e=="object"&&e!==null&&!Array.isArray(e)&&"$children"in e&&r.warn('"$if" tag does not support $children, use $then and $else instead');const{$then:t,$else:c}=i;if(t!==void 0&&Array.isArray(t))return r.error('"$if" tag $then must be a string or single element object, not an array'),{valueToRender:void 0};if(c!==void 0&&Array.isArray(c))return r.error('"$if" tag $else must be a string or single element object, not an array'),{valueToRender:void 0};const u=(typeof e=="object"&&e!==null&&!Array.isArray(e)?Object.keys(e):[]).filter(d=>!O.has(d));return u.length>0&&r.warn(`"$if" tag does not support attributes: ${u.join(", ")}. Allowed: ${[...O].join(", ")}`),{valueToRender:g(s,i)?t:c}}const K=(e,n)=>{if(!n)return e.length<=1?e[0]?.[1]??"":e.reduce((r,[,i])=>r+i,"");if(e.length===0)return"";if(e.length===1&&!e[0][1].includes("<"))return e[0][1];let o=` `;for(let r=0;r`;const f=`<${e}${J(n,o,e,a,i)}>`;return b.has(e)?f:`${f}${c}${u}`}function $(e,n,o){const r=o.parents||[],i=o.logger;if(typeof e=="string")return w(e,n,!0,r,i);if(Array.isArray(e))return e.map(l=>$(l,n,o)).join(o.indentStr?` -`:"");const s=M(e,i);if(!s)return"";const{tag:t,rest:a,children:c,attrs:u}=s;if(!W.has(t))return i.error(`Tag "${t}" is not allowed`),"";if(t==="$comment"&&o.insideComment)return i.error("Nested comments are not allowed"),"";if(t==="$if"){const{valueToRender:l}=F(a,n,r,i);return l===void 0?"":$(l,n,o)}b.has(t)&&c.length>0&&i.warn(`Tag "${t}" is a void element and cannot have children`);const f={...o,insideComment:t==="$comment"||o.insideComment,level:(o.level||0)+1},S=l=>l===""?[]:o.indentStr&&l.includes(` +`,o};function I(e,n={}){const o=e.data,r=n.logger||console,i=n.indent?{indentStr:typeof n.indent=="number"?" ".repeat(n.indent):typeof n.indent=="string"?n.indent:" ",level:0,logger:r}:{logger:r};return $(e.template,o,i)}function Y(e,n,o,r,i,s,t,c=[]){const a=K(r,s),u=a.startsWith(` +`)&&s?s.repeat(t||0):"";if(e==="$comment")return``;const f=`<${e}${J(n,o,e,c,i)}>`;return b.has(e)?f:`${f}${a}${u}`}function $(e,n,o){const r=o.parents||[],i=o.logger;if(typeof e=="string")return w(e,n,!0,r,i);if(Array.isArray(e))return e.map(l=>$(l,n,o)).join(o.indentStr?` +`:"");const s=M(e,i);if(!s)return"";const{tag:t,rest:c,children:a,attrs:u}=s;if(!W.has(t))return i.error(`Tag "${t}" is not allowed`),"";if(t==="$comment"&&o.insideComment)return i.error("Nested comments are not allowed"),"";if(t==="$if"){const{valueToRender:l}=F(c,n,r,i);return l===void 0?"":$(l,n,o)}b.has(t)&&a.length>0&&i.warn(`Tag "${t}" is a void element and cannot have children`);const f={...o,insideComment:t==="$comment"||o.insideComment,level:(o.level||0)+1},S=l=>l===""?[]:o.indentStr&&l.includes(` `)&&!l.includes("<")?l.split(` -`).map(h=>[f.level,h]):[[f.level,l]];let d,p;if(q(a)){if(!v(a.$bind,"$bind",i))return"";const l=A(n,a.$bind,[],i),{$bind:h,$children:P=[],...L}=a;if(!Array.isArray(l)){if(l!=null&&typeof l!="object")return i.error(`$bind resolved to primitive value of type "${typeof l}", cannot render children`),"";const j=l&&typeof l=="object"&&l!==null?l:{},T=[...r,n];return $({[t]:{...L,$children:P}},j,{...o,parents:T})}if(d=[],!b.has(t))for(const j of l){const T=[...r,n];for(const Q of P){const X=$(Q,j,{...f,parents:T});d.push(...S(X))}}p=L}else{if(d=[],!b.has(t))for(const l of c){const h=$(l,n,{...f,parents:r});d.push(...S(h))}p=u}return Y(t,p,n,d,i,o.indentStr,o.level,r)}function J(e,n,o,r=[],i){const s=Object.entries(e).filter(([t])=>G(t,o,i)).map(([t,a])=>{let c;if(t==="style"){if(c=N(a,n,r,i),!c)return null}else if(z(a)){const u=B(a,n,r,i);c=w(String(u),n,!1,r,i)}else c=w(String(a),n,!1,r,i);return`${t}="${k(c)}"`}).filter(t=>t!==null).join(" ");return s?" "+s:""}function U(e,n={}){const{data:o={},yaml:r,indent:i,logger:s}=n,t=e.renderer.rules.fence;e.renderer.rules.fence=function(a,c,u,f,S){const d=a[c],p=d.info?d.info.trim():"";if(p==="treebark"||p.startsWith("treebark "))try{return H(d.content,o,r,i,s)+` +`).map(h=>[f.level,h]):[[f.level,l]];let d,p;if(q(c)){if(!v(c.$bind,"$bind",i))return"";const l=A(n,c.$bind,[],i),{$bind:h,$children:P=[],...L}=c;if(!Array.isArray(l)){if(l!=null&&typeof l!="object")return i.error(`$bind resolved to primitive value of type "${typeof l}", cannot render children`),"";const j=l&&typeof l=="object"&&l!==null?l:{},T=[...r,n];return $({[t]:{...L,$children:P}},j,{...o,parents:T})}if(d=[],!b.has(t))for(const j of l){const T=[...r,n];for(const Q of P){const X=$(Q,j,{...f,parents:T});d.push(...S(X))}}p=L}else{if(d=[],!b.has(t))for(const l of a){const h=$(l,n,{...f,parents:r});d.push(...S(h))}p=u}return Y(t,p,n,d,i,o.indentStr,o.level,r)}function J(e,n,o,r=[],i){const s=Object.entries(e).filter(([t])=>G(t,o,i)).map(([t,c])=>{let a;if(t==="style"){if(a=N(c,n,r,i),!a)return null}else if(z(c)){const u=B(c,n,r,i);a=w(String(u),n,!1,r,i)}else a=w(String(c),n,!1,r,i);return`${t}="${k(a)}"`}).filter(t=>t!==null).join(" ");return s?" "+s:""}function U(e,n={}){const{data:o={},yaml:r,indent:i,logger:s}=n,t=e.renderer.rules.fence;e.renderer.rules.fence=function(c,a,u,f,S){const d=c[a],p=d.info?d.info.trim():"";if(p==="treebark"||p.startsWith("treebark "))try{return H(d.content,o,r,i,s)+` `}catch(l){const h=l instanceof Error?l.message:"Unknown error";return`
Treebark Error: ${V(h)}
-`}return t?t(a,c,u,f,S):""}}function H(e,n,o,r,i){let s,t=null;if(!e.trim())throw new Error("Empty or invalid template");if(o)try{s=o.load(e)}catch(c){t=c instanceof Error?c:new Error("YAML parsing failed")}if(!s)try{s=JSON.parse(e)}catch(c){throw o&&t?new Error(`Failed to parse as YAML or JSON. YAML error: ${t.message}`):new Error(`Failed to parse as JSON: ${c instanceof Error?c.message:"Invalid format"}`)}if(!s)throw new Error("Empty or invalid template");const a={indent:r,logger:i};if(s&&typeof s=="object"&&"template"in s){const c={...n,...s.data};return I({template:s.template,data:c},a)}else return I({template:s,data:n},a)}function V(e){const n={"&":"&","<":"<",">":">",'"':""","'":"'"};return e.replace(/[&<>"']/g,o=>n[o])}return U})); +`}return t?t(c,a,u,f,S):""}}function H(e,n,o,r,i){let s,t=null;if(!e.trim())throw new Error("Empty or invalid template");if(o)try{s=o.load(e)}catch(a){t=a instanceof Error?a:new Error("YAML parsing failed")}if(!s)try{s=JSON.parse(e)}catch(a){throw o&&t?new Error(`Failed to parse as YAML or JSON. YAML error: ${t.message}`):new Error(`Failed to parse as JSON: ${a instanceof Error?a.message:"Invalid format"}`)}if(!s)throw new Error("Empty or invalid template");const c={indent:r,logger:i};if(s&&typeof s=="object"&&"template"in s){const a={...n,...s.data};return I({template:s.template,data:a},c)}else return I({template:s,data:n},c)}function V(e){const n={"&":"&","<":"<",">":">",'"':""","'":"'"};return e.replace(/[&<>"']/g,o=>n[o])}return U})); //# sourceMappingURL=markdown-it-treebark-browser.min.js.map diff --git a/docs/assets/treebark-browser.js b/docs/assets/treebark-browser.js index 614e630e..30394142 100644 --- a/docs/assets/treebark-browser.js +++ b/docs/assets/treebark-browser.js @@ -31,8 +31,7 @@ "tr", "th", "td", - "a", - "button" + "a" ]); const SPECIAL_TAGS = /* @__PURE__ */ new Set([ "$comment", @@ -51,8 +50,7 @@ "table": /* @__PURE__ */ new Set(["summary"]), "th": /* @__PURE__ */ new Set(["scope", "colspan", "rowspan"]), "td": /* @__PURE__ */ new Set(["scope", "colspan", "rowspan"]), - "blockquote": /* @__PURE__ */ new Set(["cite"]), - "button": /* @__PURE__ */ new Set(["type", "disabled"]) + "blockquote": /* @__PURE__ */ new Set(["cite"]) }; const OPERATORS = /* @__PURE__ */ new Set(["$<", "$>", "$<=", "$>=", "$=", "$in"]); const CONDITIONALKEYS = /* @__PURE__ */ new Set(["$check", "$then", "$else", "$not", "$join", ...OPERATORS]); diff --git a/docs/assets/treebark-browser.min.js b/docs/assets/treebark-browser.min.js index b760e6aa..d3a1c825 100644 --- a/docs/assets/treebark-browser.min.js +++ b/docs/assets/treebark-browser.min.js @@ -1,9 +1,9 @@ -(function($,p){typeof exports=="object"&&typeof module<"u"?p(exports):typeof define=="function"&&define.amd?define(["exports"],p):($=typeof globalThis<"u"?globalThis:$||self,p($.Treebark={}))})(this,(function($){"use strict";const p=new Set(["div","span","p","header","footer","main","section","article","h1","h2","h3","h4","h5","h6","strong","em","blockquote","code","pre","ul","ol","li","table","thead","tbody","tr","th","td","a","button"]),_=new Set(["$comment","$if"]),y=new Set(["img","br","hr"]),D=new Set([...p,..._,...y]),C=new Set(["id","class","style","title","role","data-","aria-"]),L={a:new Set(["href","target","rel"]),img:new Set(["src","alt","width","height"]),table:new Set(["summary"]),th:new Set(["scope","colspan","rowspan"]),td:new Set(["scope","colspan","rowspan"]),blockquote:new Set(["cite"]),button:new Set(["type","disabled"])},O=new Set(["$<","$>","$<=","$>=","$=","$in"]),R=new Set(["$check","$then","$else","$not","$join",...O]),W=new Set(["behavior","-moz-binding"]);function b(e,n,o=[],r){if(n===".")return e;let i=e,s=n;for(;s.startsWith("..");){let t=0,c=s;for(;c.startsWith("..");)t++,c=c.substring(2),c.startsWith("/")&&(c=c.substring(1));if(t<=o.length)i=o[o.length-t],s=c.startsWith(".")?c.substring(1):c;else return}if(s){if(r&&typeof i!="object"&&i!==null&&i!==void 0){r.error(`Cannot access property "${s}" on primitive value of type "${typeof i}"`);return}return s.split(".").reduce((t,c)=>t&&typeof t=="object"&&t!==null?t[c]:void 0,i)}return i}function k(e){return e.replace(/[&<>"']/g,n=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[n]||n)}function A(e,n,o=!0,r=[],i){return e.replace(/\{\{\{([^{]*?)\}\}\}|\{\{([^{]*?)\}\}/g,(s,t,c)=>{if(t!==void 0)return`{{${t.trim()}}}`;const l=c.trim(),u=b(n,l,r,i);return u==null?"":o?k(String(u)):String(u)})}function P(e,n){const o=[];for(const[r,i]of Object.entries(e)){const s=r;if(!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(s)){n.warn(`CSS property "${r}" has invalid format (must be kebab-case)`);continue}if(W.has(s)){n.warn(`CSS property "${r}" is blocked for security reasons`);continue}if(i==null)continue;let t=String(i).trim();if(t.includes(";")){const u=t;t=t.split(";")[0].trim(),t&&t!==u.trim()&&n.warn(`CSS value for "${r}" contained semicolon - using only first part: "${t}"`)}if(!t)continue;const c=/url\s*\(/i.test(t),l=/url\s*\(\s*['"]?data:/i.test(t);if(c&&!l||/expression\s*\(/i.test(t)||/javascript:/i.test(t)||/@import/i.test(t)){n.warn(`CSS value for "${r}" contains potentially dangerous pattern: "${t}"`);continue}o.push(`${s}: ${t}`)}return o.join("; ").trim()}function G(e,n,o,r){if(e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"){const i=e;if(!m(i.$check,"$check",r))return"";const s=b(n,i.$check,o),c=v(s,i)?i.$then:i.$else;return c===void 0?"":typeof c=="object"&&c!==null&&!Array.isArray(c)?P(c,r):""}return typeof e=="object"&&e!==null&&!Array.isArray(e)?P(e,r):(r.error(`Style attribute must be an object with CSS properties, not ${typeof e}. Example: style: { "color": "red", "font-size": "14px" }`),"")}function z(e,n,o){const r=C.has(e)||[...C].some(t=>t.endsWith("-")&&e.startsWith(t)),i=L[n],s=i&&i.has(e);return!r&&!s?(o.warn(`Attribute "${e}" is not allowed on tag "${n}"`),!1):!0}function N(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$bind"in e}function m(e,n,o){return e==="."?!0:e.includes("..")?(o.error(`${n} does not support parent context access (..) - use interpolation {{..prop}} in content/attributes instead. Invalid: ${n}: "${e}"`),!1):e.includes("{{")?(o.error(`${n} does not support interpolation {{...}} - use literal property paths only. Invalid: ${n}: "${e}"`),!1):!0}function v(e,n){const o=[];for(const t of O)t in n&&o.push({key:t,value:n[t]});if(o.length===0){const t=!!e;return n.$not?!t:t}const r=o.map(t=>{switch(t.key){case"$<":return typeof e=="number"&&typeof t.value=="number"&&e":return typeof e=="number"&&typeof t.value=="number"&&e>t.value;case"$<=":return typeof e=="number"&&typeof t.value=="number"&&e<=t.value;case"$>=":return typeof e=="number"&&typeof t.value=="number"&&e>=t.value;case"$=":return e===t.value;case"$in":return Array.isArray(t.value)&&t.value.includes(e);default:return!1}}),i=n.$join==="OR";let s;return i?s=r.some(t=>t):s=r.every(t=>t),n.$not?!s:s}function q(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function B(e,n,o=[],r){if(!m(e.$check,"$check",r))return"";const i=b(n,e.$check,o);return v(i,e)?e.$then!==void 0?e.$then:"":e.$else!==void 0?e.$else:""}function K(e,n){if(!e||typeof e!="object"){n.error("Template object cannot be null, undefined, or non-object");return}const o=Object.entries(e);if(o.length===0){n.error("Template object must have at least one tag");return}const r=o[0];if(!r){n.error("Template object must have at least one tag");return}const[i,s]=r,t=typeof s=="string"?[s]:Array.isArray(s)?s:s?.$children||[],c=s&&typeof s=="object"&&!Array.isArray(s)?Object.fromEntries(Object.entries(s).filter(([l])=>l!=="$children")):{};return{tag:i,rest:s,children:t,attrs:c}}function U(e,n,o=[],r){const i=e;if(!i.$check)return r.error('"$if" tag requires $check attribute to specify the condition'),{valueToRender:void 0};if(!m(i.$check,"$check",r))return{valueToRender:void 0};const s=b(n,i.$check,o);typeof e=="object"&&e!==null&&!Array.isArray(e)&&"$children"in e&&r.warn('"$if" tag does not support $children, use $then and $else instead');const{$then:t,$else:c}=i;if(t!==void 0&&Array.isArray(t))return r.error('"$if" tag $then must be a string or single element object, not an array'),{valueToRender:void 0};if(c!==void 0&&Array.isArray(c))return r.error('"$if" tag $else must be a string or single element object, not an array'),{valueToRender:void 0};const u=(typeof e=="object"&&e!==null&&!Array.isArray(e)?Object.keys(e):[]).filter(d=>!R.has(d));return u.length>0&&r.warn(`"$if" tag does not support attributes: ${u.join(", ")}. Allowed: ${[...R].join(", ")}`),{valueToRender:v(s,i)?t:c}}const F=(e,n)=>{if(!n)return e.length<=1?e[0]?.[1]??"":e.reduce((r,[,i])=>r+i,"");if(e.length===0)return"";if(e.length===1&&!e[0][1].includes("<"))return e[0][1];let o=` +(function($,p){typeof exports=="object"&&typeof module<"u"?p(exports):typeof define=="function"&&define.amd?define(["exports"],p):($=typeof globalThis<"u"?globalThis:$||self,p($.Treebark={}))})(this,(function($){"use strict";const p=new Set(["div","span","p","header","footer","main","section","article","h1","h2","h3","h4","h5","h6","strong","em","blockquote","code","pre","ul","ol","li","table","thead","tbody","tr","th","td","a"]),_=new Set(["$comment","$if"]),y=new Set(["img","br","hr"]),D=new Set([...p,..._,...y]),C=new Set(["id","class","style","title","role","data-","aria-"]),L={a:new Set(["href","target","rel"]),img:new Set(["src","alt","width","height"]),table:new Set(["summary"]),th:new Set(["scope","colspan","rowspan"]),td:new Set(["scope","colspan","rowspan"]),blockquote:new Set(["cite"])},O=new Set(["$<","$>","$<=","$>=","$=","$in"]),R=new Set(["$check","$then","$else","$not","$join",...O]),W=new Set(["behavior","-moz-binding"]);function m(e,n,o=[],r){if(n===".")return e;let i=e,s=n;for(;s.startsWith("..");){let t=0,c=s;for(;c.startsWith("..");)t++,c=c.substring(2),c.startsWith("/")&&(c=c.substring(1));if(t<=o.length)i=o[o.length-t],s=c.startsWith(".")?c.substring(1):c;else return}if(s){if(r&&typeof i!="object"&&i!==null&&i!==void 0){r.error(`Cannot access property "${s}" on primitive value of type "${typeof i}"`);return}return s.split(".").reduce((t,c)=>t&&typeof t=="object"&&t!==null?t[c]:void 0,i)}return i}function k(e){return e.replace(/[&<>"']/g,n=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[n]||n)}function A(e,n,o=!0,r=[],i){return e.replace(/\{\{\{([^{]*?)\}\}\}|\{\{([^{]*?)\}\}/g,(s,t,c)=>{if(t!==void 0)return`{{${t.trim()}}}`;const l=c.trim(),u=m(n,l,r,i);return u==null?"":o?k(String(u)):String(u)})}function P(e,n){const o=[];for(const[r,i]of Object.entries(e)){const s=r;if(!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(s)){n.warn(`CSS property "${r}" has invalid format (must be kebab-case)`);continue}if(W.has(s)){n.warn(`CSS property "${r}" is blocked for security reasons`);continue}if(i==null)continue;let t=String(i).trim();if(t.includes(";")){const u=t;t=t.split(";")[0].trim(),t&&t!==u.trim()&&n.warn(`CSS value for "${r}" contained semicolon - using only first part: "${t}"`)}if(!t)continue;const c=/url\s*\(/i.test(t),l=/url\s*\(\s*['"]?data:/i.test(t);if(c&&!l||/expression\s*\(/i.test(t)||/javascript:/i.test(t)||/@import/i.test(t)){n.warn(`CSS value for "${r}" contains potentially dangerous pattern: "${t}"`);continue}o.push(`${s}: ${t}`)}return o.join("; ").trim()}function G(e,n,o,r){if(e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"){const i=e;if(!b(i.$check,"$check",r))return"";const s=m(n,i.$check,o),c=v(s,i)?i.$then:i.$else;return c===void 0?"":typeof c=="object"&&c!==null&&!Array.isArray(c)?P(c,r):""}return typeof e=="object"&&e!==null&&!Array.isArray(e)?P(e,r):(r.error(`Style attribute must be an object with CSS properties, not ${typeof e}. Example: style: { "color": "red", "font-size": "14px" }`),"")}function z(e,n,o){const r=C.has(e)||[...C].some(t=>t.endsWith("-")&&e.startsWith(t)),i=L[n],s=i&&i.has(e);return!r&&!s?(o.warn(`Attribute "${e}" is not allowed on tag "${n}"`),!1):!0}function N(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$bind"in e}function b(e,n,o){return e==="."?!0:e.includes("..")?(o.error(`${n} does not support parent context access (..) - use interpolation {{..prop}} in content/attributes instead. Invalid: ${n}: "${e}"`),!1):e.includes("{{")?(o.error(`${n} does not support interpolation {{...}} - use literal property paths only. Invalid: ${n}: "${e}"`),!1):!0}function v(e,n){const o=[];for(const t of O)t in n&&o.push({key:t,value:n[t]});if(o.length===0){const t=!!e;return n.$not?!t:t}const r=o.map(t=>{switch(t.key){case"$<":return typeof e=="number"&&typeof t.value=="number"&&e":return typeof e=="number"&&typeof t.value=="number"&&e>t.value;case"$<=":return typeof e=="number"&&typeof t.value=="number"&&e<=t.value;case"$>=":return typeof e=="number"&&typeof t.value=="number"&&e>=t.value;case"$=":return e===t.value;case"$in":return Array.isArray(t.value)&&t.value.includes(e);default:return!1}}),i=n.$join==="OR";let s;return i?s=r.some(t=>t):s=r.every(t=>t),n.$not?!s:s}function q(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function B(e,n,o=[],r){if(!b(e.$check,"$check",r))return"";const i=m(n,e.$check,o);return v(i,e)?e.$then!==void 0?e.$then:"":e.$else!==void 0?e.$else:""}function K(e,n){if(!e||typeof e!="object"){n.error("Template object cannot be null, undefined, or non-object");return}const o=Object.entries(e);if(o.length===0){n.error("Template object must have at least one tag");return}const r=o[0];if(!r){n.error("Template object must have at least one tag");return}const[i,s]=r,t=typeof s=="string"?[s]:Array.isArray(s)?s:s?.$children||[],c=s&&typeof s=="object"&&!Array.isArray(s)?Object.fromEntries(Object.entries(s).filter(([l])=>l!=="$children")):{};return{tag:i,rest:s,children:t,attrs:c}}function U(e,n,o=[],r){const i=e;if(!i.$check)return r.error('"$if" tag requires $check attribute to specify the condition'),{valueToRender:void 0};if(!b(i.$check,"$check",r))return{valueToRender:void 0};const s=m(n,i.$check,o);typeof e=="object"&&e!==null&&!Array.isArray(e)&&"$children"in e&&r.warn('"$if" tag does not support $children, use $then and $else instead');const{$then:t,$else:c}=i;if(t!==void 0&&Array.isArray(t))return r.error('"$if" tag $then must be a string or single element object, not an array'),{valueToRender:void 0};if(c!==void 0&&Array.isArray(c))return r.error('"$if" tag $else must be a string or single element object, not an array'),{valueToRender:void 0};const u=(typeof e=="object"&&e!==null&&!Array.isArray(e)?Object.keys(e):[]).filter(d=>!R.has(d));return u.length>0&&r.warn(`"$if" tag does not support attributes: ${u.join(", ")}. Allowed: ${[...R].join(", ")}`),{valueToRender:v(s,i)?t:c}}const F=(e,n)=>{if(!n)return e.length<=1?e[0]?.[1]??"":e.reduce((r,[,i])=>r+i,"");if(e.length===0)return"";if(e.length===1&&!e[0][1].includes("<"))return e[0][1];let o=` `;for(let r=0;r`;const f=`<${e}${Y(n,o,e,c,i)}>`;return y.has(e)?f:`${f}${l}${u}`}function h(e,n,o){const r=o.parents||[],i=o.logger;if(typeof e=="string")return A(e,n,!0,r,i);if(Array.isArray(e))return e.map(a=>h(a,n,o)).join(o.indentStr?` `:"");const s=K(e,i);if(!s)return"";const{tag:t,rest:c,children:l,attrs:u}=s;if(!D.has(t))return i.error(`Tag "${t}" is not allowed`),"";if(t==="$comment"&&o.insideComment)return i.error("Nested comments are not allowed"),"";if(t==="$if"){const{valueToRender:a}=U(c,n,r,i);return a===void 0?"":h(a,n,o)}y.has(t)&&l.length>0&&i.warn(`Tag "${t}" is a void element and cannot have children`);const f={...o,insideComment:t==="$comment"||o.insideComment,level:(o.level||0)+1},j=a=>a===""?[]:o.indentStr&&a.includes(` `)&&!a.includes("<")?a.split(` -`).map(S=>[f.level,S]):[[f.level,a]];let d,w;if(N(c)){if(!m(c.$bind,"$bind",i))return"";const a=b(n,c.$bind,[],i),{$bind:S,$children:E=[],...I}=c;if(!Array.isArray(a)){if(a!=null&&typeof a!="object")return i.error(`$bind resolved to primitive value of type "${typeof a}", cannot render children`),"";const T=a&&typeof a=="object"&&a!==null?a:{},g=[...r,n];return h({[t]:{...I,$children:E}},T,{...o,parents:g})}if(d=[],!y.has(t))for(const T of a){const g=[...r,n];for(const H of E){const J=h(H,T,{...f,parents:g});d.push(...j(J))}}w=I}else{if(d=[],!y.has(t))for(const a of l){const S=h(a,n,{...f,parents:r});d.push(...j(S))}w=u}return V(t,w,n,d,i,o.indentStr,o.level,r)}function Y(e,n,o,r=[],i){const s=Object.entries(e).filter(([t])=>z(t,o,i)).map(([t,c])=>{let l;if(t==="style"){if(l=G(c,n,r,i),!l)return null}else if(q(c)){const u=B(c,n,r,i);l=A(String(u),n,!1,r,i)}else l=A(String(c),n,!1,r,i);return`${t}="${k(l)}"`}).filter(t=>t!==null).join(" ");return s?" "+s:""}$.renderToString=M,Object.defineProperty($,Symbol.toStringTag,{value:"Module"})})); +`).map(S=>[f.level,S]):[[f.level,a]];let d,T;if(N(c)){if(!b(c.$bind,"$bind",i))return"";const a=m(n,c.$bind,[],i),{$bind:S,$children:E=[],...I}=c;if(!Array.isArray(a)){if(a!=null&&typeof a!="object")return i.error(`$bind resolved to primitive value of type "${typeof a}", cannot render children`),"";const w=a&&typeof a=="object"&&a!==null?a:{},g=[...r,n];return h({[t]:{...I,$children:E}},w,{...o,parents:g})}if(d=[],!y.has(t))for(const w of a){const g=[...r,n];for(const H of E){const J=h(H,w,{...f,parents:g});d.push(...j(J))}}T=I}else{if(d=[],!y.has(t))for(const a of l){const S=h(a,n,{...f,parents:r});d.push(...j(S))}T=u}return V(t,T,n,d,i,o.indentStr,o.level,r)}function Y(e,n,o,r=[],i){const s=Object.entries(e).filter(([t])=>z(t,o,i)).map(([t,c])=>{let l;if(t==="style"){if(l=G(c,n,r,i),!l)return null}else if(q(c)){const u=B(c,n,r,i);l=A(String(u),n,!1,r,i)}else l=A(String(c),n,!1,r,i);return`${t}="${k(l)}"`}).filter(t=>t!==null).join(" ");return s?" "+s:""}$.renderToString=M,Object.defineProperty($,Symbol.toStringTag,{value:"Module"})})); //# sourceMappingURL=treebark-browser.min.js.map diff --git a/nodejs/packages/test/src/dom.test.ts b/nodejs/packages/test/src/dom.test.ts index 3f838230..9b7b943f 100644 --- a/nodejs/packages/test/src/dom.test.ts +++ b/nodejs/packages/test/src/dom.test.ts @@ -935,133 +935,4 @@ describe('DOM Renderer', () => { createErrorTest(testCase, renderToDOM); }); }); - - // Button tag tests (template-only, no event handlers) - describe('Button Tag', () => { - test('renders basic button tag', () => { - const fragment = renderToDOM({ - template: { - button: 'Click me' - } - }); - - const button = fragment.firstChild as HTMLButtonElement; - expect(button.tagName).toBe('BUTTON'); - expect(button.textContent).toBe('Click me'); - }); - - test('renders button with attributes', () => { - const fragment = renderToDOM({ - template: { - button: { - type: 'submit', - class: 'btn-primary', - id: 'submit-btn', - $children: ['Submit'] - } - } - }); - - const button = fragment.firstChild as HTMLButtonElement; - expect(button.tagName).toBe('BUTTON'); - expect(button.getAttribute('type')).toBe('submit'); - expect(button.getAttribute('class')).toBe('btn-primary'); - expect(button.getAttribute('id')).toBe('submit-btn'); - expect(button.textContent).toBe('Submit'); - }); - - test('button with data attributes', () => { - const fragment = renderToDOM({ - template: { - button: { - 'data-action': 'save', - 'data-id': '123', - $children: ['Save'] - } - } - }); - - const button = fragment.firstChild as HTMLButtonElement; - expect(button.getAttribute('data-action')).toBe('save'); - expect(button.getAttribute('data-id')).toBe('123'); - }); - - test('button with disabled attribute using interpolation', () => { - const fragment = renderToDOM({ - template: { - button: { - disabled: '{{isDisabled}}', - $children: ['Submit'] - } - }, - data: { isDisabled: 'true' } - }); - - const button = fragment.firstChild as HTMLButtonElement; - expect(button.getAttribute('disabled')).toBe('true'); - }); - - test('button with conditional disabled attribute', () => { - const fragment = renderToDOM({ - template: { - button: { - disabled: { - $check: 'isProcessing', - $then: 'true', - $else: 'false' - }, - $children: ['Submit'] - } - }, - data: { isProcessing: true } - }); - - const button = fragment.firstChild as HTMLButtonElement; - expect(button.getAttribute('disabled')).toBe('true'); - }); - - test('button with nested elements', () => { - const fragment = renderToDOM({ - template: { - button: { - class: 'icon-btn', - $children: [ - { span: { class: 'icon', $children: ['🔔'] } }, - ' Notify' - ] - } - } - }); - - const button = fragment.firstChild as HTMLButtonElement; - expect(button.tagName).toBe('BUTTON'); - expect(button.querySelector('span.icon')?.textContent).toBe('🔔'); - expect(button.textContent).toBe('🔔 Notify'); - }); - - test('multiple buttons', () => { - const fragment = renderToDOM({ - template: [ - { - button: { - id: 'btn1', - $children: ['Button 1'] - } - }, - { - button: { - id: 'btn2', - $children: ['Button 2'] - } - } - ] - }); - - const button1 = fragment.querySelector('#btn1') as HTMLButtonElement; - const button2 = fragment.querySelector('#btn2') as HTMLButtonElement; - - expect(button1.textContent).toBe('Button 1'); - expect(button2.textContent).toBe('Button 2'); - }); - }); }); \ No newline at end of file diff --git a/nodejs/packages/treebark/src/common.ts b/nodejs/packages/treebark/src/common.ts index 4602e1b3..4b1f0a40 100644 --- a/nodejs/packages/treebark/src/common.ts +++ b/nodejs/packages/treebark/src/common.ts @@ -21,7 +21,7 @@ export const CONTAINER_TAGS = new Set([ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong', 'em', 'blockquote', 'code', 'pre', 'ul', 'ol', 'li', 'table', 'thead', 'tbody', 'tr', 'th', 'td', - 'a', 'button' + 'a' ]); // Special tags that have unique behavior @@ -49,8 +49,7 @@ export const TAG_SPECIFIC_ATTRS: Record> = { 'table': new Set(['summary']), 'th': new Set(['scope', 'colspan', 'rowspan']), 'td': new Set(['scope', 'colspan', 'rowspan']), - 'blockquote': new Set(['cite']), - 'button': new Set(['type', 'disabled']) + 'blockquote': new Set(['cite']) }; export const OPERATORS = new Set(['$<', '$>', '$<=', '$>=', '$=', '$in']); diff --git a/nodejs/packages/treebark/src/types.ts b/nodejs/packages/treebark/src/types.ts index 3d8cd2d2..c37187c8 100644 --- a/nodejs/packages/treebark/src/types.ts +++ b/nodejs/packages/treebark/src/types.ts @@ -51,7 +51,7 @@ export type ContainerTag = 'div' | 'span' | 'p' | 'header' | 'footer' | 'main' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'strong' | 'em' | 'blockquote' | 'code' | 'pre' | 'ul' | 'ol' | 'li' | 'table' | 'thead' | 'tbody' | 'tr' | 'th' | 'td' | - 'a' | 'button'; + 'a'; export type VoidTag = 'img' | 'br' | 'hr'; @@ -114,7 +114,6 @@ export type TrTag = { tr: TagContent }; export type ThTag = { th: TagContent }; export type TdTag = { td: TagContent }; export type ATag = { a: TagContent }; -export type ButtonTag = { button: TagContent }; export type CommentTag = { $comment: TagContent }; // Void tag types @@ -130,7 +129,7 @@ export type RegularTags = | DivTag | SpanTag | PTag | HeaderTag | FooterTag | MainTag | SectionTag | ArticleTag | H1Tag | H2Tag | H3Tag | H4Tag | H5Tag | H6Tag | StrongTag | EmTag | BlockquoteTag | CodeTag | PreTag | UlTag | OlTag | LiTag | TableTag | TheadTag | TbodyTag | TrTag - | ThTag | TdTag | ATag | ButtonTag | ImgTag | BrTag | HrTag | CommentTag; + | ThTag | TdTag | ATag | ImgTag | BrTag | HrTag | CommentTag; // Generic template attributes (for backwards compatibility with runtime code) export type TemplateAttributes = BaseContainerAttrs; diff --git a/spec.md b/spec.md index c852dc3c..7f18d547 100644 --- a/spec.md +++ b/spec.md @@ -161,8 +161,7 @@ div: - `img`: `src`, `alt`, `width`, `height` - `table`: `summary` - `th`/`td`: `scope`, `colspan`, `rowspan` - - `blockquote`: `cite` - - `button`: `type`, `disabled` + - `blockquote`: `cite` - Blocked: event handlers (`on*`), dangerous protocols (`javascript:`). --- @@ -259,14 +258,14 @@ For complex array scenarios where you need a wrapper element or nested structure `h1`–`h6`, `strong`, `em`, `blockquote`, `code`, `pre`, `ul`, `ol`, `li`, `table`, `thead`, `tbody`, `tr`, `th`, `td`, -`a`, `img`, `button` +`a`, `img` **Special tags:** `comment`, `if` Blocked tags: `script`, `iframe`, `embed`, `object`, `applet`, -`form`, `input`, `select`, +`form`, `input`, `button`, `select`, `video`, `audio`, `style`, `link`, `meta`, `base` From e6f964cb8b755e2ed50cb84dab5721e1cebfaa40 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 18 Oct 2025 04:28:54 +0000 Subject: [PATCH 8/8] Update exploration with name-value pair pattern for buttons and forms Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- BUTTON_FORM_EXPLORATION.md | 333 ++++++++++++++++++++++++++++--------- 1 file changed, 251 insertions(+), 82 deletions(-) diff --git a/BUTTON_FORM_EXPLORATION.md b/BUTTON_FORM_EXPLORATION.md index 97c6492e..2c857fd0 100644 --- a/BUTTON_FORM_EXPLORATION.md +++ b/BUTTON_FORM_EXPLORATION.md @@ -2,6 +2,21 @@ This document explores the possibilities for supporting button tags and form elements in Treebark, taking inspiration from [AdaptiveCards actions pattern](https://learn.microsoft.com/en-us/adaptive-cards/sdk/rendering-cards/javascript/actions). +## Executive Summary + +**Key Design Insight:** Use the **name-value pair pattern** for interactive elements: +- **Buttons** return a single name-value pair: `(name: string, value: string)` +- **Forms** return an array of name-value pairs: `Array<[name, value]>` (via FormData) + +This pattern: +- ✅ Aligns with HTML semantics (input elements already use name/value) +- ✅ Simple and intuitive API +- ✅ Provides natural foundation for form support +- ✅ No JSON parsing or complex payload extraction needed +- ✅ Type-safe and easy to test + +**Recommended Approach:** Start with Path 2 (Button with Name-Value Handler), optionally expand to Path 3 (Full Form Support) if use cases emerge. + ## Problem Statement Treebark currently blocks interactive elements like `button`, `input`, `select`, `textarea`, and `form` for security reasons. However, there may be use cases where safe, template-driven interactive elements could be valuable, especially in content-driven applications. @@ -16,7 +31,14 @@ Treebark currently blocks interactive elements like `button`, `input`, `select`, ## Exploration 1: Button Tag with Context-Based Actions -### AdaptiveCards Pattern +### Design Principle: Name-Value Pairs + +**Key Insight:** Buttons should return a **name:value pair** when clicked. This simple pattern: +- Aligns with HTML form semantics (input elements have name/value) +- Provides a natural foundation for form support +- Keeps the API minimal and intuitive + +### AdaptiveCards Pattern Reference AdaptiveCards uses a pattern where: - Actions are defined in the template with a `type` and `data` @@ -35,11 +57,11 @@ Example from AdaptiveCards: } ``` -### Possible Treebark Pattern +### Proposed Treebark Pattern: Name-Value Pairs -#### Option A: Single Context Handler (AdaptiveCards-style) +#### Option A: Single Context Handler with Name-Value Pairs -Template defines actions declaratively: +Template defines buttons with `name` and `value` attributes: ```javascript { template: { @@ -48,18 +70,26 @@ Template defines actions declaratively: { button: { type: "button", - "data-action": "save", - "data-id": "123", + name: "action", + value: "save", $children: ["Save"] } }, { button: { type: "button", - "data-action": "delete", - "data-id": "456", + name: "action", + value: "delete", $children: ["Delete"] } + }, + { + button: { + type: "button", + name: "itemId", + value: "123", + $children: ["Select Item 123"] + } } ] } @@ -68,32 +98,47 @@ Template defines actions declaratively: } ``` -Application provides a single handler: +Application provides a single handler that receives name-value pairs: ```javascript const fragment = renderToDOM(input, { - onAction: (action, payload) => { - switch(action) { - case 'save': - console.log('Save item', payload.id); - break; - case 'delete': - console.log('Delete item', payload.id); - break; + onAction: (name, value, event) => { + // Receives: name (string), value (string), event (MouseEvent) + console.log(`Button clicked: ${name}=${value}`); + + if (name === 'action') { + switch(value) { + case 'save': + console.log('Save action triggered'); + break; + case 'delete': + console.log('Delete action triggered'); + break; + } + } else if (name === 'itemId') { + console.log(`Item ${value} selected`); } } }); ``` +**Benefits of Name-Value Pattern:** +- **Familiar:** Mirrors HTML form input pattern (``) +- **Simple:** Just two strings - easy to understand and use +- **Flexible:** Can represent actions, IDs, or any semantic data +- **Form-ready:** Same pattern works for form inputs (see below) +- **Type-safe:** Both name and value are always strings + **Pros:** - Clean separation: templates define structure, app defines behavior - Single handler reduces boilerplate -- Easy to understand and maintain -- Aligns with declarative template philosophy +- Natural extension to forms +- Aligns with HTML semantics +- No need for JSON parsing or complex payload extraction **Cons:** - Requires DOM renderer (won't work in string renderer) - Additional API surface in RenderOptions -- Need to decide how to extract action/payload from button +- Limited to string values (but can use JSON.stringify if needed) #### Option B: External Handler Attachment (Current HTML standard) @@ -161,30 +206,52 @@ const fragment2 = renderToDOM( - Two ways to do the same thing - More complex to document and maintain -### Payload Extraction Strategy - -If we go with context handlers, how do we extract action/payload? +### Additional Data with Buttons -**Strategy 1: Convention-based** -- Look for `data-action` attribute for action name -- Collect all `data-*` attributes as payload +If buttons need to carry additional data beyond name-value: -**Strategy 2: Explicit configuration** -- Button has special `$action` property +**Option 1: Use data attributes (read but not sent):** ```javascript { button: { - $action: "save", - $actionData: { id: 123 }, - $children: ["Save"] + name: "action", + value: "delete", + "data-item-id": "123", + "data-confirm": "true", + $children: ["Delete Item 123"] + } +} + +// Handler can access via event.target +onAction: (name, value, event) => { + if (name === 'action' && value === 'delete') { + const itemId = event.target.getAttribute('data-item-id'); + const needsConfirm = event.target.getAttribute('data-confirm'); + // Handle deletion... } } ``` -**Strategy 3: Mixed** -- Support both conventions and explicit for flexibility +**Option 2: Encode in value (if needed):** +```javascript +{ + button: { + name: "action", + value: JSON.stringify({ type: "delete", itemId: 123 }), + $children: ["Delete"] + } +} -## Exploration 2: Form Elements +// Handler parses if needed +onAction: (name, value, event) => { + if (name === 'action') { + const action = JSON.parse(value); + // action.type, action.itemId... + } +} +``` + +## Exploration 2: Form Elements with Name-Value Pairs ### Which Form Elements? @@ -207,6 +274,10 @@ If we allow buttons, should we also allow: - File inputs (`input type="file"`) - Hidden inputs that could be manipulated +### Key Insight: Forms Return Array of Name-Value Pairs + +Building on the button pattern, **forms should return an array of name-value pairs** - exactly like HTML's FormData. + ### Possible Form Pattern #### Read-only Forms (Display Only) @@ -217,10 +288,10 @@ If we allow buttons, should we also allow: form: { $children: [ { label: { for: "name", $children: ["Name:"] } }, - { input: { type: "text", id: "name", readonly: "true", value: "{{userName}}" } }, + { input: { type: "text", id: "name", name: "name", readonly: "true", value: "{{userName}}" } }, { label: { for: "email", $children: ["Email:"] } }, - { input: { type: "email", id: "email", readonly: "true", value: "{{userEmail}}" } } + { input: { type: "email", id: "email", name: "email", readonly: "true", value: "{{userEmail}}" } } ] } }, @@ -232,11 +303,11 @@ If we allow buttons, should we also allow: #### Interactive Forms with Context Handler +Template with form elements (each has `name` attribute): ```javascript { template: { form: { - "data-form-id": "user-form", $children: [ { label: { for: "name", $children: ["Name:"] } }, { input: { type: "text", id: "name", name: "name", value: "{{userName}}" } }, @@ -244,24 +315,70 @@ If we allow buttons, should we also allow: { label: { for: "email", $children: ["Email:"] } }, { input: { type: "email", id: "email", name: "email", value: "{{userEmail}}" } }, - { button: { type: "submit", "data-action": "submitForm", $children: ["Save"] } } + { label: { for: "role", $children: ["Role:"] } }, + { + select: { + id: "role", + name: "role", + $children: [ + { option: { value: "user", $children: ["User"] } }, + { option: { value: "admin", selected: "true", $children: ["Admin"] } } + ] + } + }, + + { button: { type: "submit", name: "action", value: "save", $children: ["Save"] } } ] } }, data: { userName: "Alice", userEmail: "alice@example.com" } } +``` -// Render with handler +Handler receives array of name-value pairs: +```javascript const fragment = renderToDOM(input, { - onAction: (action, payload) => { - if (action === 'submitForm') { - const formData = new FormData(payload.target.closest('form')); - console.log('Form submitted:', Object.fromEntries(formData)); + onAction: (name, value, event) => { + if (name === 'action' && value === 'save') { + // Get form data as array of [name, value] pairs + const form = event.target.closest('form'); + const formData = new FormData(form); + const formValues = Array.from(formData.entries()); // [["name", "Alice"], ["email", "alice@..."], ["role", "admin"]] + + console.log('Form submitted with values:', formValues); + + // Or convert to object if preferred + const formObject = Object.fromEntries(formValues); + console.log('As object:', formObject); // { name: "Alice", email: "alice@...", role: "admin" } } } }); ``` +**Benefits of Array of Name-Value Pairs:** +- **Standard HTML:** Exactly how FormData works +- **Consistent:** Same pattern as button (single name-value pair) +- **Multiple values:** Supports multiple inputs with same name (checkboxes, multi-select) +- **Preserves order:** Array maintains form field order +- **Simple:** No parsing needed, just extract from FormData + +**Alternative: Provide helper in handler:** +```javascript +onAction: (name, value, event) => { + if (name === 'action' && value === 'save') { + // Helper function extracts form data + const formValues = getFormValues(event.target); + // formValues is array: [["name", "Alice"], ["email", "..."], ...] + } +} + +// Could be provided as utility +function getFormValues(button) { + const form = button.closest('form'); + return Array.from(new FormData(form).entries()); +} +``` + **Use case:** Simple data collection forms in content ### Form Element Attributes @@ -317,7 +434,7 @@ export const FORM_TAGS = new Set([ ```javascript export const TAG_SPECIFIC_ATTRS: Record> = { // ... existing ... - 'button': new Set(['type', 'disabled']), + 'button': new Set(['type', 'disabled', 'name', 'value']), // name and value for action pattern 'input': new Set(['type', 'name', 'value', 'placeholder', 'readonly', 'disabled', 'checked']), 'textarea': new Set(['name', 'rows', 'cols', 'placeholder', 'readonly', 'disabled']), 'select': new Set(['name', 'multiple', 'disabled']), @@ -327,23 +444,23 @@ export const TAG_SPECIFIC_ATTRS: Record> = { }; ``` -### 3. Context Handler API +### 3. Context Handler API with Name-Value Pairs ```typescript interface RenderOptions { indent?: string | number | boolean; logger?: Logger; - onAction?: ActionHandler; // NEW + onAction?: ActionHandler; // NEW - receives name-value pairs } -type ActionHandler = (action: string, payload: ActionPayload) => void; - -interface ActionPayload { - target: HTMLElement; - [key: string]: unknown; // Additional data-* attributes -} +type ActionHandler = (name: string, value: string, event: MouseEvent) => void; ``` +**Simple and aligned with HTML semantics:** +- `name`: The button's `name` attribute +- `value`: The button's `value` attribute +- `event`: The click event (access to target element, form context, etc.) + ### 4. DOM Renderer Changes ```javascript @@ -352,66 +469,118 @@ function setAttrs(element: HTMLElement, attrs: Record, data: Da // If button and onAction handler provided if (tag === 'button' && onAction) { - element.addEventListener('click', (event) => { + element.addEventListener('click', (event: MouseEvent) => { event.preventDefault(); - const action = element.getAttribute('data-action'); - if (!action) { - logger.warn('Button with onAction handler must have data-action attribute'); - return; - } + const nameAttr = element.getAttribute('name'); + const valueAttr = element.getAttribute('value'); - // Collect all data-* attributes as payload - const payload: ActionPayload = { target: element }; - for (const attr of element.attributes) { - if (attr.name.startsWith('data-') && attr.name !== 'data-action') { - const key = attr.name.substring(5); // Remove 'data-' prefix - payload[key] = attr.value; - } + if (!nameAttr) { + logger.warn('Button with onAction handler should have a name attribute'); + return; } - onAction(action, payload); + // Call handler with name-value pair + // Value defaults to empty string if not provided + onAction(nameAttr, valueAttr || '', event); }); } } ``` +**Benefits:** +- Simple implementation - just extract two attributes +- No JSON parsing or complex payload logic +- Aligns perfectly with HTML button semantics +- Easy to test and maintain + ## Recommendations -Based on this exploration, here are potential paths forward: +Based on this exploration with **name-value pair pattern**, here are potential paths forward: -### Path 1: Minimal - Button Tag Only +### Path 1: Minimal - Button Tag Only (No Handler) **Implementation:** - Allow `button` tag in whitelist -- Support `type` and `disabled` attributes +- Support `type`, `disabled`, `name`, `value` attributes - No special event handling - apps attach handlers externally -- Document best practices for using data-* attributes +- Document best practices for using name/value attributes **Pros:** Minimal change, clear security model, easy to understand **Cons:** Doesn't provide much value over current workarounds -### Path 2: Button with Optional Context Handler +### Path 2: Button with Name-Value Handler (Recommended) **Implementation:** -- Allow `button` tag in whitelist -- Add optional `onAction` to RenderOptions (DOM only) -- Auto-wire buttons with `data-action` to the handler -- Extract data-* attributes as payload +- Allow `button` tag in whitelist with `name` and `value` attributes +- Add optional `onAction: (name, value, event) => void` to RenderOptions (DOM only) +- Auto-wire buttons that have `name` attribute to the handler +- Simple pattern: button returns name-value pair when clicked -**Pros:** Elegant for simple cases, optional for complex cases -**Cons:** DOM-only feature, new API to maintain +**Example:** +```javascript +// Template +{ button: { name: "action", value: "save", $children: ["Save"] } } + +// Handler +renderToDOM(input, { + onAction: (name, value, event) => { + console.log(`${name}=${value}`); // "action=save" + } +}); +``` -### Path 3: Full Form Support with Context Handlers +**Pros:** +- Elegant and simple +- Aligns with HTML semantics +- Natural foundation for forms +- Optional - works without handler too + +**Cons:** +- DOM-only feature +- New API to maintain + +### Path 3: Full Form Support with Name-Value Pattern **Implementation:** - Allow button, input, select, textarea, label, form +- All form elements use `name` attribute +- Button returns single name-value pair +- Forms return array of name-value pairs via FormData - Strict attribute whitelisting (no action/method on form) -- Optional context handlers for both actions and form submission -- Comprehensive documentation on security model +- Optional context handler using same pattern + +**Example:** +```javascript +// Template with form +{ + form: { + $children: [ + { input: { type: "text", name: "username", value: "{{user}}" } }, + { button: { type: "submit", name: "action", value: "save", $children: ["Save"] } } + ] + } +} + +// Handler +onAction: (name, value, event) => { + if (name === 'action' && value === 'save') { + const form = event.target.closest('form'); + const formData = Array.from(new FormData(form).entries()); + // formData = [["username", "alice"], ["action", "save"]] + } +} +``` + +**Pros:** +- Powerful and consistent pattern +- Works for simple and complex forms +- Standard HTML FormData integration -**Pros:** Powerful for content-driven apps with forms -**Cons:** Large API surface, security concerns, complexity +**Cons:** +- Larger API surface +- Need to carefully consider security +- More complexity ### Path 4: No Changes