From e4f3b857093c90f5658912fb0b083c926c73f289 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Dec 2025 23:11:40 +0000 Subject: [PATCH 01/24] Initial plan From 5436af978a686498b13b56c3cc44b0a93f4dacf7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Dec 2025 23:26:11 +0000 Subject: [PATCH 02/24] Implement $filter feature on databinding with conditional logic Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- docs/assets/markdown-it-treebark-browser.js | 22 +- .../markdown-it-treebark-browser.min.js | 16 +- docs/assets/treebark-browser.js | 22 +- docs/assets/treebark-browser.min.js | 14 +- nodejs/packages/test/package.json | 22 ++ nodejs/packages/test/src/filter.test.ts | 335 ++++++++++++++++++ nodejs/packages/treebark/src/common.ts | 40 ++- nodejs/packages/treebark/src/dom.ts | 18 +- nodejs/packages/treebark/src/string.ts | 18 +- nodejs/packages/treebark/src/types.ts | 17 + 10 files changed, 496 insertions(+), 28 deletions(-) create mode 100644 nodejs/packages/test/src/filter.test.ts diff --git a/docs/assets/markdown-it-treebark-browser.js b/docs/assets/markdown-it-treebark-browser.js index af9b891..26cc380 100644 --- a/docs/assets/markdown-it-treebark-browser.js +++ b/docs/assets/markdown-it-treebark-browser.js @@ -54,6 +54,7 @@ }; const OPERATORS = /* @__PURE__ */ new Set(["$<", "$>", "$<=", "$>=", "$=", "$in"]); const CONDITIONALKEYS = /* @__PURE__ */ new Set(["$check", "$then", "$else", "$not", "$join", ...OPERATORS]); + /* @__PURE__ */ new Set(["$check", "$not", "$join", ...OPERATORS]); const BLOCKED_CSS_PROPERTIES = /* @__PURE__ */ new Set([ "behavior", // IE behavior property - can execute code @@ -252,6 +253,16 @@ return value.$else !== void 0 ? value.$else : ""; } } + function isFilterCondition(value) { + return value !== null && typeof value === "object" && !Array.isArray(value) && "$check" in value && typeof value.$check === "string"; + } + function evaluateFilterCondition(item, filter, parents = [], logger, getOuterProperty) { + if (!validatePathExpression(filter.$check, "$check", logger)) { + return false; + } + const checkValue = getProperty(item, filter.$check, parents, logger, getOuterProperty); + return evaluateCondition(checkValue, filter); + } function parseTemplateObject(templateObj, logger) { if (!templateObj || typeof templateObj !== "object") { logger.error("Template object cannot be null, undefined, or non-object"); @@ -395,7 +406,7 @@ return ""; } const bound = getProperty(data, rest.$bind, [], logger, getOuterProperty); - const { $bind, $children = [], ...bindAttrs } = rest; + const { $bind, $filter, $children = [], ...bindAttrs } = rest; if (!Array.isArray(bound)) { if (bound !== null && bound !== void 0 && typeof bound !== "object") { logger.error(`$bind resolved to primitive value of type "${typeof bound}", cannot render children`); @@ -405,9 +416,16 @@ const newParents = [...parents, data]; return render({ [tag]: { ...bindAttrs, $children } }, boundData, { ...context, parents: newParents }); } + let itemsToRender = bound; + if ($filter && isFilterCondition($filter)) { + itemsToRender = bound.filter((item) => { + const newParents = [...parents, data]; + return evaluateFilterCondition(item, $filter, newParents, logger, getOuterProperty); + }); + } childrenOutput = []; if (!VOID_TAGS.has(tag)) { - for (const item of bound) { + for (const item of itemsToRender) { const newParents = [...parents, data]; for (const child of $children) { const content = render(child, item, { ...childContext, parents: newParents }); diff --git a/docs/assets/markdown-it-treebark-browser.min.js b/docs/assets/markdown-it-treebark-browser.min.js index 18024fc..9493b6f 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,b){typeof exports=="object"&&typeof module<"u"?module.exports=b():typeof define=="function"&&define.amd?define(b):(y=typeof globalThis<"u"?globalThis:y||self,y.MarkdownItTreebark=b())})(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"]),b=new Set(["$comment","$if"]),A=new Set(["img","br","hr"]),_=new Set([...y,...b,...A]),C=new Set(["id","class","style","title","role","data-","aria-"]),D={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"])},k=new Set(["$<","$>","$<=","$>=","$=","$in"]),O=new Set(["$check","$then","$else","$not","$join",...k]),N=new Set(["behavior","-moz-binding"]);function S(e,n,o=[],i,c){if(n===".")return e;let t=e,r=n;for(;r.startsWith("..");){let a=0,s=r;for(;s.startsWith("..");)a++,s=s.substring(2),s.startsWith("/")&&(s=s.substring(1));if(a<=o.length)t=o[o.length-a],r=s.startsWith(".")?s.substring(1):s;else return c?c(n,e,o):void 0}if(r){if(i&&typeof t!="object"&&t!==null&&t!==void 0){i.error(`Cannot access property "${r}" on primitive value of type "${typeof t}"`);return}const a=r.split(".").reduce((s,f)=>s&&typeof s=="object"&&s!==null?s[f]:void 0,t);return a===void 0&&c?c(n,e,o):a}return t}function R(e){return e.replace(/[&<>"']/g,n=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[n]||n)}function j(e,n,o=!0,i=[],c,t){return e.replace(/\{\{\{([^{]*?)\}\}\}|\{\{([^{]*?)\}\}/g,(r,a,s)=>{if(a!==void 0)return`{{${a.trim()}}}`;const f=s.trim(),u=S(n,f,i,c,t);return u==null?"":o?R(String(u)):String(u)})}function I(e,n){const o=[];for(const[i,c]of Object.entries(e)){const t=i;if(!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(t)){n.warn(`CSS property "${i}" has invalid format (must be kebab-case)`);continue}if(N.has(t)){n.warn(`CSS property "${i}" is blocked for security reasons`);continue}if(c==null)continue;let r=String(c).trim();if(r.includes(";")){const f=r;r=r.split(";")[0].trim(),r&&r!==f.trim()&&n.warn(`CSS value for "${i}" contained semicolon - using only first part: "${r}"`)}if(!r)continue;const a=/url\s*\(/i.test(r),s=/url\s*\(\s*['"]?data:/i.test(r);if(a&&!s||/expression\s*\(/i.test(r)||/javascript:/i.test(r)||/@import/i.test(r)){n.warn(`CSS value for "${i}" contains potentially dangerous pattern: "${r}"`);continue}o.push(`${t}: ${r}`)}return o.join("; ").trim()}function G(e,n,o,i,c){if(e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"){const t=e;if(!v(t.$check,"$check",i))return"";const r=S(n,t.$check,o,i,c),s=T(r,t)?t.$then:t.$else;return s===void 0?"":typeof s=="object"&&s!==null&&!Array.isArray(s)?I(s,i):""}return typeof e=="object"&&e!==null&&!Array.isArray(e)?I(e,i):(i.error(`Style attribute must be an object with CSS properties, not ${typeof e}. Example: style: { "color": "red", "font-size": "14px" }`),"")}function q(e,n,o){const i=C.has(e)||[...C].some(r=>r.endsWith("-")&&e.startsWith(r)),c=D[n],t=c&&c.has(e);return!i&&!t?(o.warn(`Attribute "${e}" is not allowed on tag "${n}"`),!1):!0}function z(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 T(e,n){const o=[];for(const r of k)r in n&&o.push({key:r,value:n[r]});if(o.length===0){const r=!!e;return n.$not?!r:r}const i=o.map(r=>{switch(r.key){case"$<":return typeof e=="number"&&typeof r.value=="number"&&e":return typeof e=="number"&&typeof r.value=="number"&&e>r.value;case"$<=":return typeof e=="number"&&typeof r.value=="number"&&e<=r.value;case"$>=":return typeof e=="number"&&typeof r.value=="number"&&e>=r.value;case"$=":return e===r.value;case"$in":return Array.isArray(r.value)&&r.value.includes(e);default:return!1}}),c=n.$join==="OR";let t;return c?t=i.some(r=>r):t=i.every(r=>r),n.$not?!t:t}function B(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function F(e,n,o=[],i,c){if(!v(e.$check,"$check",i))return"";const t=S(n,e.$check,o,i,c);return T(t,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 i=o[0];if(!i){n.error("Template object must have at least one tag");return}const[c,t]=i,r=typeof t=="string"?[t]:Array.isArray(t)?t:t?.$children||[],a=t&&typeof t=="object"&&!Array.isArray(t)?Object.fromEntries(Object.entries(t).filter(([s])=>s!=="$children")):{};return{tag:c,rest:t,children:r,attrs:a}}function K(e,n,o=[],i,c){const t=e;if(!t.$check)return i.error('"$if" tag requires $check attribute to specify the condition'),{valueToRender:void 0};if(!v(t.$check,"$check",i))return{valueToRender:void 0};const r=S(n,t.$check,o,i,c);typeof e=="object"&&e!==null&&!Array.isArray(e)&&"$children"in e&&i.warn('"$if" tag does not support $children, use $then and $else instead');const{$then:a,$else:s}=t;if(a!==void 0&&Array.isArray(a))return i.error('"$if" tag $then must be a string or single element object, not an array'),{valueToRender:void 0};if(s!==void 0&&Array.isArray(s))return i.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($=>!O.has($));return u.length>0&&i.warn(`"$if" tag does not support attributes: ${u.join(", ")}. Allowed: ${[...O].join(", ")}`),{valueToRender:T(r,t)?a:s}}const Y=(e,n)=>{if(!n)return e.length<=1?e[0]?.[1]??"":e.reduce((i,[,c])=>i+c,"");if(e.length===0)return"";if(e.length===1&&!e[0][1].includes("<"))return e[0][1];let o=` -`;for(let i=0;i","$<=","$>=","$=","$in"]),R=new Set(["$check","$then","$else","$not","$join",...k]);[...k];const G=new Set(["behavior","-moz-binding"]);function p(e,t,o=[],i,c){if(t===".")return e;let n=e,r=t;for(;r.startsWith("..");){let a=0,s=r;for(;s.startsWith("..");)a++,s=s.substring(2),s.startsWith("/")&&(s=s.substring(1));if(a<=o.length)n=o[o.length-a],r=s.startsWith(".")?s.substring(1):s;else return c?c(t,e,o):void 0}if(r){if(i&&typeof n!="object"&&n!==null&&n!==void 0){i.error(`Cannot access property "${r}" on primitive value of type "${typeof n}"`);return}const a=r.split(".").reduce((s,f)=>s&&typeof s=="object"&&s!==null?s[f]:void 0,n);return a===void 0&&c?c(t,e,o):a}return n}function I(e){return e.replace(/[&<>"']/g,t=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[t]||t)}function C(e,t,o=!0,i=[],c,n){return e.replace(/\{\{\{([^{]*?)\}\}\}|\{\{([^{]*?)\}\}/g,(r,a,s)=>{if(a!==void 0)return`{{${a.trim()}}}`;const f=s.trim(),u=p(t,f,i,c,n);return u==null?"":o?I(String(u)):String(u)})}function L(e,t){const o=[];for(const[i,c]of Object.entries(e)){const n=i;if(!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(n)){t.warn(`CSS property "${i}" has invalid format (must be kebab-case)`);continue}if(G.has(n)){t.warn(`CSS property "${i}" is blocked for security reasons`);continue}if(c==null)continue;let r=String(c).trim();if(r.includes(";")){const f=r;r=r.split(";")[0].trim(),r&&r!==f.trim()&&t.warn(`CSS value for "${i}" contained semicolon - using only first part: "${r}"`)}if(!r)continue;const a=/url\s*\(/i.test(r),s=/url\s*\(\s*['"]?data:/i.test(r);if(a&&!s||/expression\s*\(/i.test(r)||/javascript:/i.test(r)||/@import/i.test(r)){t.warn(`CSS value for "${i}" contains potentially dangerous pattern: "${r}"`);continue}o.push(`${n}: ${r}`)}return o.join("; ").trim()}function q(e,t,o,i,c){if(e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"){const n=e;if(!w(n.$check,"$check",i))return"";const r=p(t,n.$check,o,i,c),s=T(r,n)?n.$then:n.$else;return s===void 0?"":typeof s=="object"&&s!==null&&!Array.isArray(s)?L(s,i):""}return typeof e=="object"&&e!==null&&!Array.isArray(e)?L(e,i):(i.error(`Style attribute must be an object with CSS properties, not ${typeof e}. Example: style: { "color": "red", "font-size": "14px" }`),"")}function z(e,t,o){const i=O.has(e)||[...O].some(r=>r.endsWith("-")&&e.startsWith(r)),c=F[t],n=c&&c.has(e);return!i&&!n?(o.warn(`Attribute "${e}" is not allowed on tag "${t}"`),!1):!0}function B(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$bind"in e}function w(e,t,o){return e==="."?!0:e.includes("..")?(o.error(`${t} does not support parent context access (..) - use interpolation {{..prop}} in content/attributes instead. Invalid: ${t}: "${e}"`),!1):e.includes("{{")?(o.error(`${t} does not support interpolation {{...}} - use literal property paths only. Invalid: ${t}: "${e}"`),!1):!0}function T(e,t){const o=[];for(const r of k)r in t&&o.push({key:r,value:t[r]});if(o.length===0){const r=!!e;return t.$not?!r:r}const i=o.map(r=>{switch(r.key){case"$<":return typeof e=="number"&&typeof r.value=="number"&&e":return typeof e=="number"&&typeof r.value=="number"&&e>r.value;case"$<=":return typeof e=="number"&&typeof r.value=="number"&&e<=r.value;case"$>=":return typeof e=="number"&&typeof r.value=="number"&&e>=r.value;case"$=":return e===r.value;case"$in":return Array.isArray(r.value)&&r.value.includes(e);default:return!1}}),c=t.$join==="OR";let n;return c?n=i.some(r=>r):n=i.every(r=>r),t.$not?!n:n}function M(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function K(e,t,o=[],i,c){if(!w(e.$check,"$check",i))return"";const n=p(t,e.$check,o,i,c);return T(n,e)?e.$then!==void 0?e.$then:"":e.$else!==void 0?e.$else:""}function Y(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function J(e,t,o=[],i,c){if(!w(t.$check,"$check",i))return!1;const n=p(e,t.$check,o,i,c);return T(n,t)}function U(e,t){if(!e||typeof e!="object"){t.error("Template object cannot be null, undefined, or non-object");return}const o=Object.entries(e);if(o.length===0){t.error("Template object must have at least one tag");return}const i=o[0];if(!i){t.error("Template object must have at least one tag");return}const[c,n]=i,r=typeof n=="string"?[n]:Array.isArray(n)?n:n?.$children||[],a=n&&typeof n=="object"&&!Array.isArray(n)?Object.fromEntries(Object.entries(n).filter(([s])=>s!=="$children")):{};return{tag:c,rest:n,children:r,attrs:a}}function V(e,t,o=[],i,c){const n=e;if(!n.$check)return i.error('"$if" tag requires $check attribute to specify the condition'),{valueToRender:void 0};if(!w(n.$check,"$check",i))return{valueToRender:void 0};const r=p(t,n.$check,o,i,c);typeof e=="object"&&e!==null&&!Array.isArray(e)&&"$children"in e&&i.warn('"$if" tag does not support $children, use $then and $else instead');const{$then:a,$else:s}=n;if(a!==void 0&&Array.isArray(a))return i.error('"$if" tag $then must be a string or single element object, not an array'),{valueToRender:void 0};if(s!==void 0&&Array.isArray(s))return i.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($=>!R.has($));return u.length>0&&i.warn(`"$if" tag does not support attributes: ${u.join(", ")}. Allowed: ${[...R].join(", ")}`),{valueToRender:T(r,n)?a:s}}const H=(e,t)=>{if(!t)return e.length<=1?e[0]?.[1]??"":e.reduce((i,[,c])=>i+c,"");if(e.length===0)return"";if(e.length===1&&!e[0][1].includes("<"))return e[0][1];let o=` +`;for(let i=0;i`;const d=`<${e}${U(n,o,e,a,c,s)}>`;return A.has(e)?d:`${d}${f}${u}`}function p(e,n,o){const i=o.parents||[],c=o.logger,t=o.getOuterProperty;if(typeof e=="string")return j(e,n,!0,i,c,t);if(Array.isArray(e))return e.map(l=>p(l,n,o)).join(o.indentStr?` -`:"");const r=M(e,c);if(!r)return"";const{tag:a,rest:s,children:f,attrs:u}=r;if(!_.has(a))return c.error(`Tag "${a}" is not allowed`),"";if(a==="$comment"&&o.insideComment)return c.error("Nested comments are not allowed"),"";if(a==="$if"){const{valueToRender:l}=K(s,n,i,c,t);return l===void 0?"":p(l,n,o)}A.has(a)&&f.length>0&&c.warn(`Tag "${a}" is a void element and cannot have children`);const d={...o,insideComment:a==="$comment"||o.insideComment,level:(o.level||0)+1},h=l=>l===""?[]:o.indentStr&&l.includes(` +`,o};function P(e,t={}){const o=e.data,i=t.logger||console,c=t.propertyFallback,n=t.indent?{indentStr:typeof t.indent=="number"?" ".repeat(t.indent):typeof t.indent=="string"?t.indent:" ",level:0,logger:i,getOuterProperty:c}:{logger:i,getOuterProperty:c};return y(e.template,o,n)}function Q(e,t,o,i,c,n,r,a=[],s){const f=H(i,n),u=f.startsWith(` +`)&&n?n.repeat(r||0):"";if(e==="$comment")return``;const d=`<${e}${X(t,o,e,a,c,s)}>`;return S.has(e)?d:`${d}${f}${u}`}function y(e,t,o){const i=o.parents||[],c=o.logger,n=o.getOuterProperty;if(typeof e=="string")return C(e,t,!0,i,c,n);if(Array.isArray(e))return e.map(l=>y(l,t,o)).join(o.indentStr?` +`:"");const r=U(e,c);if(!r)return"";const{tag:a,rest:s,children:f,attrs:u}=r;if(!N.has(a))return c.error(`Tag "${a}" is not allowed`),"";if(a==="$comment"&&o.insideComment)return c.error("Nested comments are not allowed"),"";if(a==="$if"){const{valueToRender:l}=V(s,t,i,c,n);return l===void 0?"":y(l,t,o)}S.has(a)&&f.length>0&&c.warn(`Tag "${a}" is a void element and cannot have children`);const d={...o,insideComment:a==="$comment"||o.insideComment,level:(o.level||0)+1},h=l=>l===""?[]:o.indentStr&&l.includes(` `)&&!l.includes("<")?l.split(` -`).map(w=>[d.level,w]):[[d.level,l]];let $,m;if(z(s)){if(!v(s.$bind,"$bind",c))return"";const l=S(n,s.$bind,[],c,t),{$bind:w,$children:P=[],...W}=s;if(!Array.isArray(l)){if(l!=null&&typeof l!="object")return c.error(`$bind resolved to primitive value of type "${typeof l}", cannot render children`),"";const g=l&&typeof l=="object"&&l!==null?l:{},E=[...i,n];return p({[a]:{...W,$children:P}},g,{...o,parents:E})}if($=[],!A.has(a))for(const g of l){const E=[...i,n];for(const X of P){const Z=p(X,g,{...d,parents:E});$.push(...h(Z))}}m=W}else{if($=[],!A.has(a))for(const l of f){const w=p(l,n,{...d,parents:i});$.push(...h(w))}m=u}return J(a,m,n,$,c,o.indentStr,o.level,i,t)}function U(e,n,o,i=[],c,t){const r=Object.entries(e).filter(([a])=>q(a,o,c)).map(([a,s])=>{let f;if(a==="style"){if(f=G(s,n,i,c,t),!f)return null}else if(B(s)){const u=F(s,n,i,c,t);f=j(String(u),n,!1,i,c,t)}else f=j(String(s),n,!1,i,c,t);return`${a}="${R(f)}"`}).filter(a=>a!==null).join(" ");return r?" "+r:""}function H(e,n={}){const{data:o={},yaml:i,indent:c,logger:t}=n,r=e.renderer.rules.fence;e.renderer.rules.fence=function(a,s,f,u,d){const h=a[s],$=h.info?h.info.trim():"";if($==="treebark"||$.startsWith("treebark "))try{return V(h.content,o,i,c,t)+` -`}catch(m){const l=m instanceof Error?m.message:"Unknown error";return`
Treebark Error: ${Q(l)}
-`}return r?r(a,s,f,u,d):""}}function V(e,n,o,i,c){let t,r=null;if(!e.trim())throw new Error("Empty or invalid template");if(o)try{t=o.load(e)}catch(s){r=s instanceof Error?s:new Error("YAML parsing failed")}if(!t)try{t=JSON.parse(e)}catch(s){throw o&&r?new Error(`Failed to parse as YAML or JSON. YAML error: ${r.message}`):new Error(`Failed to parse as JSON: ${s instanceof Error?s.message:"Invalid format"}`)}if(!t)throw new Error("Empty or invalid template");const a={indent:i,logger:c};if(t&&typeof t=="object"&&"template"in t){const s={...n,...t.data};return L({template:t.template,data:s},a)}else return L({template:t,data:n},a)}function Q(e){const n={"&":"&","<":"<",">":">",'"':""","'":"'"};return e.replace(/[&<>"']/g,o=>n[o])}return H})); +`).map(g=>[d.level,g]):[[d.level,l]];let $,m;if(B(s)){if(!w(s.$bind,"$bind",c))return"";const l=p(t,s.$bind,[],c,n),{$bind:g,$filter:E,$children:W=[],..._}=s;if(!Array.isArray(l)){if(l!=null&&typeof l!="object")return c.error(`$bind resolved to primitive value of type "${typeof l}", cannot render children`),"";const v=l&&typeof l=="object"&&l!==null?l:{},j=[...i,t];return y({[a]:{..._,$children:W}},v,{...o,parents:j})}let D=l;if(E&&Y(E)&&(D=l.filter(v=>{const j=[...i,t];return J(v,E,j,c,n)})),$=[],!S.has(a))for(const v of D){const j=[...i,t];for(const te of W){const ne=y(te,v,{...d,parents:j});$.push(...h(ne))}}m=_}else{if($=[],!S.has(a))for(const l of f){const g=y(l,t,{...d,parents:i});$.push(...h(g))}m=u}return Q(a,m,t,$,c,o.indentStr,o.level,i,n)}function X(e,t,o,i=[],c,n){const r=Object.entries(e).filter(([a])=>z(a,o,c)).map(([a,s])=>{let f;if(a==="style"){if(f=q(s,t,i,c,n),!f)return null}else if(M(s)){const u=K(s,t,i,c,n);f=C(String(u),t,!1,i,c,n)}else f=C(String(s),t,!1,i,c,n);return`${a}="${I(f)}"`}).filter(a=>a!==null).join(" ");return r?" "+r:""}function Z(e,t={}){const{data:o={},yaml:i,indent:c,logger:n}=t,r=e.renderer.rules.fence;e.renderer.rules.fence=function(a,s,f,u,d){const h=a[s],$=h.info?h.info.trim():"";if($==="treebark"||$.startsWith("treebark "))try{return x(h.content,o,i,c,n)+` +`}catch(m){const l=m instanceof Error?m.message:"Unknown error";return`
Treebark Error: ${ee(l)}
+`}return r?r(a,s,f,u,d):""}}function x(e,t,o,i,c){let n,r=null;if(!e.trim())throw new Error("Empty or invalid template");if(o)try{n=o.load(e)}catch(s){r=s instanceof Error?s:new Error("YAML parsing failed")}if(!n)try{n=JSON.parse(e)}catch(s){throw o&&r?new Error(`Failed to parse as YAML or JSON. YAML error: ${r.message}`):new Error(`Failed to parse as JSON: ${s instanceof Error?s.message:"Invalid format"}`)}if(!n)throw new Error("Empty or invalid template");const a={indent:i,logger:c};if(n&&typeof n=="object"&&"template"in n){const s={...t,...n.data};return P({template:n.template,data:s},a)}else return P({template:n,data:t},a)}function ee(e){const t={"&":"&","<":"<",">":">",'"':""","'":"'"};return e.replace(/[&<>"']/g,o=>t[o])}return Z})); //# sourceMappingURL=markdown-it-treebark-browser.min.js.map diff --git a/docs/assets/treebark-browser.js b/docs/assets/treebark-browser.js index 210dd7c..6814250 100644 --- a/docs/assets/treebark-browser.js +++ b/docs/assets/treebark-browser.js @@ -54,6 +54,7 @@ }; const OPERATORS = /* @__PURE__ */ new Set(["$<", "$>", "$<=", "$>=", "$=", "$in"]); const CONDITIONALKEYS = /* @__PURE__ */ new Set(["$check", "$then", "$else", "$not", "$join", ...OPERATORS]); + /* @__PURE__ */ new Set(["$check", "$not", "$join", ...OPERATORS]); const BLOCKED_CSS_PROPERTIES = /* @__PURE__ */ new Set([ "behavior", // IE behavior property - can execute code @@ -252,6 +253,16 @@ return value.$else !== void 0 ? value.$else : ""; } } + function isFilterCondition(value) { + return value !== null && typeof value === "object" && !Array.isArray(value) && "$check" in value && typeof value.$check === "string"; + } + function evaluateFilterCondition(item, filter, parents = [], logger, getOuterProperty) { + if (!validatePathExpression(filter.$check, "$check", logger)) { + return false; + } + const checkValue = getProperty(item, filter.$check, parents, logger, getOuterProperty); + return evaluateCondition(checkValue, filter); + } function parseTemplateObject(templateObj, logger) { if (!templateObj || typeof templateObj !== "object") { logger.error("Template object cannot be null, undefined, or non-object"); @@ -395,7 +406,7 @@ return ""; } const bound = getProperty(data, rest.$bind, [], logger, getOuterProperty); - const { $bind, $children = [], ...bindAttrs } = rest; + const { $bind, $filter, $children = [], ...bindAttrs } = rest; if (!Array.isArray(bound)) { if (bound !== null && bound !== void 0 && typeof bound !== "object") { logger.error(`$bind resolved to primitive value of type "${typeof bound}", cannot render children`); @@ -405,9 +416,16 @@ const newParents = [...parents, data]; return render({ [tag]: { ...bindAttrs, $children } }, boundData, { ...context, parents: newParents }); } + let itemsToRender = bound; + if ($filter && isFilterCondition($filter)) { + itemsToRender = bound.filter((item) => { + const newParents = [...parents, data]; + return evaluateFilterCondition(item, $filter, newParents, logger, getOuterProperty); + }); + } childrenOutput = []; if (!VOID_TAGS.has(tag)) { - for (const item of bound) { + for (const item of itemsToRender) { const newParents = [...parents, data]; for (const child of $children) { const content = render(child, item, { ...childContext, parents: newParents }); diff --git a/docs/assets/treebark-browser.min.js b/docs/assets/treebark-browser.min.js index f53f682..68f851c 100644 --- a/docs/assets/treebark-browser.min.js +++ b/docs/assets/treebark-browser.min.js @@ -1,9 +1,9 @@ -(function(h,p){typeof exports=="object"&&typeof module<"u"?p(exports):typeof define=="function"&&define.amd?define(["exports"],p):(h=typeof globalThis<"u"?globalThis:h||self,p(h.Treebark={}))})(this,(function(h){"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"]),D=new Set(["$comment","$if"]),m=new Set(["img","br","hr"]),L=new Set([...p,...D,...m]),O=new Set(["id","class","style","title","role","data-","aria-"]),W={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"])},R=new Set(["$<","$>","$<=","$>=","$=","$in"]),k=new Set(["$check","$then","$else","$not","$join",...R]),G=new Set(["behavior","-moz-binding"]);function b(e,t,o=[],i,s){if(t===".")return e;let r=e,n=t;for(;n.startsWith("..");){let a=0,c=n;for(;c.startsWith("..");)a++,c=c.substring(2),c.startsWith("/")&&(c=c.substring(1));if(a<=o.length)r=o[o.length-a],n=c.startsWith(".")?c.substring(1):c;else return s?s(t,e,o):void 0}if(n){if(i&&typeof r!="object"&&r!==null&&r!==void 0){i.error(`Cannot access property "${n}" on primitive value of type "${typeof r}"`);return}const a=n.split(".").reduce((c,u)=>c&&typeof c=="object"&&c!==null?c[u]:void 0,r);return a===void 0&&s?s(t,e,o):a}return r}function E(e){return e.replace(/[&<>"']/g,t=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[t]||t)}function v(e,t,o=!0,i=[],s,r){return e.replace(/\{\{\{([^{]*?)\}\}\}|\{\{([^{]*?)\}\}/g,(n,a,c)=>{if(a!==void 0)return`{{${a.trim()}}}`;const u=c.trim(),f=b(t,u,i,s,r);return f==null?"":o?E(String(f)):String(f)})}function I(e,t){const o=[];for(const[i,s]of Object.entries(e)){const r=i;if(!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(r)){t.warn(`CSS property "${i}" has invalid format (must be kebab-case)`);continue}if(G.has(r)){t.warn(`CSS property "${i}" is blocked for security reasons`);continue}if(s==null)continue;let n=String(s).trim();if(n.includes(";")){const u=n;n=n.split(";")[0].trim(),n&&n!==u.trim()&&t.warn(`CSS value for "${i}" contained semicolon - using only first part: "${n}"`)}if(!n)continue;const a=/url\s*\(/i.test(n),c=/url\s*\(\s*['"]?data:/i.test(n);if(a&&!c||/expression\s*\(/i.test(n)||/javascript:/i.test(n)||/@import/i.test(n)){t.warn(`CSS value for "${i}" contains potentially dangerous pattern: "${n}"`);continue}o.push(`${r}: ${n}`)}return o.join("; ").trim()}function z(e,t,o,i,s){if(e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"){const r=e;if(!S(r.$check,"$check",i))return"";const n=b(t,r.$check,o,i,s),c=j(n,r)?r.$then:r.$else;return c===void 0?"":typeof c=="object"&&c!==null&&!Array.isArray(c)?I(c,i):""}return typeof e=="object"&&e!==null&&!Array.isArray(e)?I(e,i):(i.error(`Style attribute must be an object with CSS properties, not ${typeof e}. Example: style: { "color": "red", "font-size": "14px" }`),"")}function N(e,t,o){const i=O.has(e)||[...O].some(n=>n.endsWith("-")&&e.startsWith(n)),s=W[t],r=s&&s.has(e);return!i&&!r?(o.warn(`Attribute "${e}" is not allowed on tag "${t}"`),!1):!0}function q(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$bind"in e}function S(e,t,o){return e==="."?!0:e.includes("..")?(o.error(`${t} does not support parent context access (..) - use interpolation {{..prop}} in content/attributes instead. Invalid: ${t}: "${e}"`),!1):e.includes("{{")?(o.error(`${t} does not support interpolation {{...}} - use literal property paths only. Invalid: ${t}: "${e}"`),!1):!0}function j(e,t){const o=[];for(const n of R)n in t&&o.push({key:n,value:t[n]});if(o.length===0){const n=!!e;return t.$not?!n:n}const i=o.map(n=>{switch(n.key){case"$<":return typeof e=="number"&&typeof n.value=="number"&&e":return typeof e=="number"&&typeof n.value=="number"&&e>n.value;case"$<=":return typeof e=="number"&&typeof n.value=="number"&&e<=n.value;case"$>=":return typeof e=="number"&&typeof n.value=="number"&&e>=n.value;case"$=":return e===n.value;case"$in":return Array.isArray(n.value)&&n.value.includes(e);default:return!1}}),s=t.$join==="OR";let r;return s?r=i.some(n=>n):r=i.every(n=>n),t.$not?!r:r}function B(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function K(e,t,o=[],i,s){if(!S(e.$check,"$check",i))return"";const r=b(t,e.$check,o,i,s);return j(r,e)?e.$then!==void 0?e.$then:"":e.$else!==void 0?e.$else:""}function F(e,t){if(!e||typeof e!="object"){t.error("Template object cannot be null, undefined, or non-object");return}const o=Object.entries(e);if(o.length===0){t.error("Template object must have at least one tag");return}const i=o[0];if(!i){t.error("Template object must have at least one tag");return}const[s,r]=i,n=typeof r=="string"?[r]:Array.isArray(r)?r:r?.$children||[],a=r&&typeof r=="object"&&!Array.isArray(r)?Object.fromEntries(Object.entries(r).filter(([c])=>c!=="$children")):{};return{tag:s,rest:r,children:n,attrs:a}}function U(e,t,o=[],i,s){const r=e;if(!r.$check)return i.error('"$if" tag requires $check attribute to specify the condition'),{valueToRender:void 0};if(!S(r.$check,"$check",i))return{valueToRender:void 0};const n=b(t,r.$check,o,i,s);typeof e=="object"&&e!==null&&!Array.isArray(e)&&"$children"in e&&i.warn('"$if" tag does not support $children, use $then and $else instead');const{$then:a,$else:c}=r;if(a!==void 0&&Array.isArray(a))return i.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 i.error('"$if" tag $else must be a string or single element object, not an array'),{valueToRender:void 0};const f=(typeof e=="object"&&e!==null&&!Array.isArray(e)?Object.keys(e):[]).filter($=>!k.has($));return f.length>0&&i.warn(`"$if" tag does not support attributes: ${f.join(", ")}. Allowed: ${[...k].join(", ")}`),{valueToRender:j(n,r)?a:c}}const M=(e,t)=>{if(!t)return e.length<=1?e[0]?.[1]??"":e.reduce((i,[,s])=>i+s,"");if(e.length===0)return"";if(e.length===1&&!e[0][1].includes("<"))return e[0][1];let o=` -`;for(let i=0;i","$<=","$>=","$=","$in"]),E=new Set(["$check","$then","$else","$not","$join",...T]);[...T];const N=new Set(["behavior","-moz-binding"]);function y(e,n,o=[],r,s){if(n===".")return e;let i=e,t=n;for(;t.startsWith("..");){let l=0,c=t;for(;c.startsWith("..");)l++,c=c.substring(2),c.startsWith("/")&&(c=c.substring(1));if(l<=o.length)i=o[o.length-l],t=c.startsWith(".")?c.substring(1):c;else return s?s(n,e,o):void 0}if(t){if(r&&typeof i!="object"&&i!==null&&i!==void 0){r.error(`Cannot access property "${t}" on primitive value of type "${typeof i}"`);return}const l=t.split(".").reduce((c,u)=>c&&typeof c=="object"&&c!==null?c[u]:void 0,i);return l===void 0&&s?s(n,e,o):l}return i}function I(e){return e.replace(/[&<>"']/g,n=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[n]||n)}function C(e,n,o=!0,r=[],s,i){return e.replace(/\{\{\{([^{]*?)\}\}\}|\{\{([^{]*?)\}\}/g,(t,l,c)=>{if(l!==void 0)return`{{${l.trim()}}}`;const u=c.trim(),f=y(n,u,r,s,i);return f==null?"":o?I(String(f)):String(f)})}function P(e,n){const o=[];for(const[r,s]of Object.entries(e)){const i=r;if(!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(i)){n.warn(`CSS property "${r}" has invalid format (must be kebab-case)`);continue}if(N.has(i)){n.warn(`CSS property "${r}" is blocked for security reasons`);continue}if(s==null)continue;let t=String(s).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 l=/url\s*\(/i.test(t),c=/url\s*\(\s*['"]?data:/i.test(t);if(l&&!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(`${i}: ${t}`)}return o.join("; ").trim()}function q(e,n,o,r,s){if(e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"){const i=e;if(!A(i.$check,"$check",r))return"";const t=y(n,i.$check,o,r,s),c=j(t,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 B(e,n,o){const r=R.has(e)||[...R].some(t=>t.endsWith("-")&&e.startsWith(t)),s=z[n],i=s&&s.has(e);return!r&&!i?(o.warn(`Attribute "${e}" is not allowed on tag "${n}"`),!1):!0}function F(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$bind"in e}function A(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 j(e,n){const o=[];for(const t of T)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}}),s=n.$join==="OR";let i;return s?i=r.some(t=>t):i=r.every(t=>t),n.$not?!i:i}function K(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function U(e,n,o=[],r,s){if(!A(e.$check,"$check",r))return"";const i=y(n,e.$check,o,r,s);return j(i,e)?e.$then!==void 0?e.$then:"":e.$else!==void 0?e.$else:""}function V(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function M(e,n,o=[],r,s){if(!A(n.$check,"$check",r))return!1;const i=y(e,n.$check,o,r,s);return j(i,n)}function Y(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[s,i]=r,t=typeof i=="string"?[i]:Array.isArray(i)?i:i?.$children||[],l=i&&typeof i=="object"&&!Array.isArray(i)?Object.fromEntries(Object.entries(i).filter(([c])=>c!=="$children")):{};return{tag:s,rest:i,children:t,attrs:l}}function H(e,n,o=[],r,s){const i=e;if(!i.$check)return r.error('"$if" tag requires $check attribute to specify the condition'),{valueToRender:void 0};if(!A(i.$check,"$check",r))return{valueToRender:void 0};const t=y(n,i.$check,o,r,s);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:l,$else:c}=i;if(l!==void 0&&Array.isArray(l))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 f=(typeof e=="object"&&e!==null&&!Array.isArray(e)?Object.keys(e):[]).filter($=>!E.has($));return f.length>0&&r.warn(`"$if" tag does not support attributes: ${f.join(", ")}. Allowed: ${[...E].join(", ")}`),{valueToRender:j(t,i)?l:c}}const J=(e,n)=>{if(!n)return e.length<=1?e[0]?.[1]??"":e.reduce((r,[,s])=>r+s,"");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 d=`<${e}${H(t,o,e,a,s,c)}>`;return m.has(e)?d:`${d}${u}${f}`}function y(e,t,o){const i=o.parents||[],s=o.logger,r=o.getOuterProperty;if(typeof e=="string")return v(e,t,!0,i,s,r);if(Array.isArray(e))return e.map(l=>y(l,t,o)).join(o.indentStr?` -`:"");const n=F(e,s);if(!n)return"";const{tag:a,rest:c,children:u,attrs:f}=n;if(!L.has(a))return s.error(`Tag "${a}" is not allowed`),"";if(a==="$comment"&&o.insideComment)return s.error("Nested comments are not allowed"),"";if(a==="$if"){const{valueToRender:l}=U(c,t,i,s,r);return l===void 0?"":y(l,t,o)}m.has(a)&&u.length>0&&s.warn(`Tag "${a}" is a void element and cannot have children`);const d={...o,insideComment:a==="$comment"||o.insideComment,level:(o.level||0)+1},T=l=>l===""?[]:o.indentStr&&l.includes(` -`)&&!l.includes("<")?l.split(` -`).map(A=>[d.level,A]):[[d.level,l]];let $,w;if(q(c)){if(!S(c.$bind,"$bind",s))return"";const l=b(t,c.$bind,[],s,r),{$bind:A,$children:P=[],..._}=c;if(!Array.isArray(l)){if(l!=null&&typeof l!="object")return s.error(`$bind resolved to primitive value of type "${typeof l}", cannot render children`),"";const C=l&&typeof l=="object"&&l!==null?l:{},g=[...i,t];return y({[a]:{..._,$children:P}},C,{...o,parents:g})}if($=[],!m.has(a))for(const C of l){const g=[...i,t];for(const J of P){const Q=y(J,C,{...d,parents:g});$.push(...T(Q))}}w=_}else{if($=[],!m.has(a))for(const l of u){const A=y(l,t,{...d,parents:i});$.push(...T(A))}w=f}return Y(a,w,t,$,s,o.indentStr,o.level,i,r)}function H(e,t,o,i=[],s,r){const n=Object.entries(e).filter(([a])=>N(a,o,s)).map(([a,c])=>{let u;if(a==="style"){if(u=z(c,t,i,s,r),!u)return null}else if(B(c)){const f=K(c,t,i,s,r);u=v(String(f),t,!1,i,s,r)}else u=v(String(c),t,!1,i,s,r);return`${a}="${E(u)}"`}).filter(a=>a!==null).join(" ");return n?" "+n:""}h.renderToString=V,Object.defineProperty(h,Symbol.toStringTag,{value:"Module"})})); +`,o};function Q(e,n={}){const o=e.data,r=n.logger||console,s=n.propertyFallback,i=n.indent?{indentStr:typeof n.indent=="number"?" ".repeat(n.indent):typeof n.indent=="string"?n.indent:" ",level:0,logger:r,getOuterProperty:s}:{logger:r,getOuterProperty:s};return p(e.template,o,i)}function X(e,n,o,r,s,i,t,l=[],c){const u=J(r,i),f=u.startsWith(` +`)&&i?i.repeat(t||0):"";if(e==="$comment")return``;const d=`<${e}${Z(n,o,e,l,s,c)}>`;return b.has(e)?d:`${d}${u}${f}`}function p(e,n,o){const r=o.parents||[],s=o.logger,i=o.getOuterProperty;if(typeof e=="string")return C(e,n,!0,r,s,i);if(Array.isArray(e))return e.map(a=>p(a,n,o)).join(o.indentStr?` +`:"");const t=Y(e,s);if(!t)return"";const{tag:l,rest:c,children:u,attrs:f}=t;if(!G.has(l))return s.error(`Tag "${l}" is not allowed`),"";if(l==="$comment"&&o.insideComment)return s.error("Nested comments are not allowed"),"";if(l==="$if"){const{valueToRender:a}=H(c,n,r,s,i);return a===void 0?"":p(a,n,o)}b.has(l)&&u.length>0&&s.warn(`Tag "${l}" is a void element and cannot have children`);const d={...o,insideComment:l==="$comment"||o.insideComment,level:(o.level||0)+1},k=a=>a===""?[]:o.indentStr&&a.includes(` +`)&&!a.includes("<")?a.split(` +`).map(w=>[d.level,w]):[[d.level,a]];let $,g;if(F(c)){if(!A(c.$bind,"$bind",s))return"";const a=y(n,c.$bind,[],s,i),{$bind:w,$filter:O,$children:_=[],...D}=c;if(!Array.isArray(a)){if(a!=null&&typeof a!="object")return s.error(`$bind resolved to primitive value of type "${typeof a}", cannot render children`),"";const S=a&&typeof a=="object"&&a!==null?a:{},v=[...r,n];return p({[l]:{...D,$children:_}},S,{...o,parents:v})}let L=a;if(O&&V(O)&&(L=a.filter(S=>{const v=[...r,n];return M(S,O,v,s,i)})),$=[],!b.has(l))for(const S of L){const v=[...r,n];for(const x of _){const ee=p(x,S,{...d,parents:v});$.push(...k(ee))}}g=D}else{if($=[],!b.has(l))for(const a of u){const w=p(a,n,{...d,parents:r});$.push(...k(w))}g=f}return X(l,g,n,$,s,o.indentStr,o.level,r,i)}function Z(e,n,o,r=[],s,i){const t=Object.entries(e).filter(([l])=>B(l,o,s)).map(([l,c])=>{let u;if(l==="style"){if(u=q(c,n,r,s,i),!u)return null}else if(K(c)){const f=U(c,n,r,s,i);u=C(String(f),n,!1,r,s,i)}else u=C(String(c),n,!1,r,s,i);return`${l}="${I(u)}"`}).filter(l=>l!==null).join(" ");return t?" "+t:""}h.renderToString=Q,Object.defineProperty(h,Symbol.toStringTag,{value:"Module"})})); //# sourceMappingURL=treebark-browser.min.js.map diff --git a/nodejs/packages/test/package.json b/nodejs/packages/test/package.json index 5d7e816..8c5fefc 100644 --- a/nodejs/packages/test/package.json +++ b/nodejs/packages/test/package.json @@ -170,6 +170,28 @@ } ] } + }, + { + "displayName": "filter", + "testMatch": ["/src/filter.test.ts"], + "preset": "ts-jest/presets/default-esm", + "testEnvironment": "jsdom", + "extensionsToTreatAsEsm": [".ts"], + "moduleNameMapper": { + "^(\\.{1,2}/.*)\\.js$": "$1" + }, + "transform": { + "^.+\\.tsx?$": [ + "ts-jest", + { + "useESM": true, + "tsconfig": { + "module": "ES2020", + "outDir": "dist" + } + } + ] + } } ] } diff --git a/nodejs/packages/test/src/filter.test.ts b/nodejs/packages/test/src/filter.test.ts new file mode 100644 index 0000000..cf702f9 --- /dev/null +++ b/nodejs/packages/test/src/filter.test.ts @@ -0,0 +1,335 @@ +import { renderToString, renderToDOM } from 'treebark'; + +describe('$filter on databinding', () => { + describe('String Renderer', () => { + it('should filter array items based on simple truthiness', () => { + const input = { + template: { + ul: { + $bind: 'items', + $filter: { + $check: 'active' + }, + $children: [ + { li: '{{name}}' } + ] + } + }, + data: { + items: [ + { name: 'Item 1', active: true }, + { name: 'Item 2', active: false }, + { name: 'Item 3', active: true } + ] + } + }; + + const result = renderToString(input); + expect(result).toBe('
  • Item 1
  • Item 3
'); + }); + + it('should filter array items with comparison operators', () => { + const input = { + template: { + ul: { + $bind: 'products', + $filter: { + $check: 'price', + '$<': 500 + }, + $children: [ + { li: '{{name}} - ${{price}}' } + ] + } + }, + data: { + products: [ + { name: 'Laptop', price: 999 }, + { name: 'Mouse', price: 25 }, + { name: 'Keyboard', price: 75 }, + { name: 'Monitor', price: 699 } + ] + } + }; + + const result = renderToString(input); + expect(result).toBe('
  • Mouse - $25
  • Keyboard - $75
'); + }); + + it('should filter array items with greater than operator', () => { + const input = { + template: { + ul: { + $bind: 'products', + $filter: { + $check: 'price', + '$>': 500 + }, + $children: [ + { li: '{{name}}' } + ] + } + }, + data: { + products: [ + { name: 'Laptop', price: 999 }, + { name: 'Mouse', price: 25 }, + { name: 'Monitor', price: 699 } + ] + } + }; + + const result = renderToString(input); + expect(result).toBe('
  • Laptop
  • Monitor
'); + }); + + it('should filter array items with $in operator', () => { + const input = { + template: { + ul: { + $bind: 'users', + $filter: { + $check: 'role', + $in: ['admin', 'moderator'] + }, + $children: [ + { li: '{{name}} ({{role}})' } + ] + } + }, + data: { + users: [ + { name: 'Alice', role: 'admin' }, + { name: 'Bob', role: 'user' }, + { name: 'Charlie', role: 'moderator' }, + { name: 'Dave', role: 'user' } + ] + } + }; + + const result = renderToString(input); + expect(result).toBe('
  • Alice (admin)
  • Charlie (moderator)
'); + }); + + it('should filter array items with equality operator', () => { + const input = { + template: { + ul: { + $bind: 'items', + $filter: { + $check: 'status', + '$=': 'published' + }, + $children: [ + { li: '{{title}}' } + ] + } + }, + data: { + items: [ + { title: 'Post 1', status: 'published' }, + { title: 'Post 2', status: 'draft' }, + { title: 'Post 3', status: 'published' } + ] + } + }; + + const result = renderToString(input); + expect(result).toBe('
  • Post 1
  • Post 3
'); + }); + + it('should filter with range using multiple operators', () => { + const input = { + template: { + ul: { + $bind: 'people', + $filter: { + $check: 'age', + '$>=': 18, + '$<=': 65 + }, + $children: [ + { li: '{{name}} ({{age}})' } + ] + } + }, + data: { + people: [ + { name: 'Alice', age: 15 }, + { name: 'Bob', age: 25 }, + { name: 'Charlie', age: 70 }, + { name: 'Dave', age: 40 } + ] + } + }; + + const result = renderToString(input); + expect(result).toBe('
  • Bob (25)
  • Dave (40)
'); + }); + + it('should filter with OR logic', () => { + const input = { + template: { + ul: { + $bind: 'people', + $filter: { + $check: 'age', + '$<': 18, + '$>': 65, + $join: 'OR' as const + }, + $children: [ + { li: '{{name}}' } + ] + } + }, + data: { + people: [ + { name: 'Alice', age: 15 }, + { name: 'Bob', age: 25 }, + { name: 'Charlie', age: 70 } + ] + } + }; + + const result = renderToString(input); + expect(result).toBe('
  • Alice
  • Charlie
'); + }); + + it('should filter with $not modifier', () => { + const input = { + template: { + ul: { + $bind: 'items', + $filter: { + $check: 'hidden', + $not: true + }, + $children: [ + { li: '{{name}}' } + ] + } + }, + data: { + items: [ + { name: 'Item 1', hidden: false }, + { name: 'Item 2', hidden: true }, + { name: 'Item 3', hidden: false } + ] + } + }; + + const result = renderToString(input); + expect(result).toBe('
  • Item 1
  • Item 3
'); + }); + + it('should return empty when all items are filtered out', () => { + const input = { + template: { + ul: { + $bind: 'products', + $filter: { + $check: 'price', + '$<': 10 + }, + $children: [ + { li: '{{name}}' } + ] + } + }, + data: { + products: [ + { name: 'Laptop', price: 999 }, + { name: 'Mouse', price: 25 } + ] + } + }; + + const result = renderToString(input); + expect(result).toBe('
    '); + }); + + it('should work without $filter (no filtering)', () => { + const input = { + template: { + ul: { + $bind: 'items', + $children: [ + { li: '{{name}}' } + ] + } + }, + data: { + items: [ + { name: 'Item 1' }, + { name: 'Item 2' } + ] + } + }; + + const result = renderToString(input); + expect(result).toBe('
    • Item 1
    • Item 2
    '); + }); + }); + + describe('DOM Renderer', () => { + it('should filter array items in DOM', () => { + const input = { + template: { + ul: { + $bind: 'items', + $filter: { + $check: 'active' + }, + $children: [ + { li: '{{name}}' } + ] + } + }, + data: { + items: [ + { name: 'Item 1', active: true }, + { name: 'Item 2', active: false }, + { name: 'Item 3', active: true } + ] + } + }; + + const fragment = renderToDOM(input); + const div = document.createElement('div'); + div.appendChild(fragment); + + expect(div.innerHTML).toBe('
    • Item 1
    • Item 3
    '); + }); + + it('should filter with comparison operators in DOM', () => { + const input = { + template: { + ul: { + $bind: 'products', + $filter: { + $check: 'price', + '$<': 100 + }, + $children: [ + { li: '{{name}}' } + ] + } + }, + data: { + products: [ + { name: 'Laptop', price: 999 }, + { name: 'Mouse', price: 25 }, + { name: 'Keyboard', price: 75 } + ] + } + }; + + const fragment = renderToDOM(input); + const div = document.createElement('div'); + div.appendChild(fragment); + + expect(div.innerHTML).toBe('
    • Mouse
    • Keyboard
    '); + }); + }); +}); diff --git a/nodejs/packages/treebark/src/common.ts b/nodejs/packages/treebark/src/common.ts index 867546c..06c79d0 100644 --- a/nodejs/packages/treebark/src/common.ts +++ b/nodejs/packages/treebark/src/common.ts @@ -16,6 +16,7 @@ import type { OuterPropertyResolver, BindPath, InterpolatedString, + FilterCondition, } from './types.js'; // Container tags that can have children and require closing tags @@ -59,6 +60,8 @@ export const OPERATORS = new Set(['$<', '$>', '$<=', '$>=', '$=', '$in']); export const CONDITIONALKEYS = new Set(['$check', '$then', '$else', '$not', '$join', ...OPERATORS]); +export const FILTERKEYS = new Set(['$check', '$not', '$join', ...OPERATORS]); + // Blocked CSS properties that are known to be dangerous const BLOCKED_CSS_PROPERTIES = new Set([ 'behavior', // IE behavior property - can execute code @@ -345,14 +348,14 @@ export function validatePathExpression(value: BindPath, label: string, logger: L } /** - * Evaluate conditional logic for $if tag and conditional attributes + * Evaluate conditional logic for $if tag, conditional attributes, and $filter * Supports operators: $<, $>, $<=, $>=, $=, $in * Supports modifiers: $not, $join * Default behavior: truthy check when no operators */ -export function evaluateCondition( +export function evaluateCondition( checkValue: unknown, - attrs: ConditionalBase + attrs: ConditionalBase | FilterCondition ): boolean { const operators: { key: string; value: unknown }[] = []; @@ -441,6 +444,37 @@ export function evaluateConditionalValue( } } +/** + * Check if a value is a filter condition object + */ +export function isFilterCondition(value: unknown): value is FilterCondition { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + '$check' in value && + typeof (value as FilterCondition).$check === 'string' + ); +} + +/** + * Evaluate a filter condition for a single item + * Returns true if the item passes the filter, false otherwise + */ +export function evaluateFilterCondition( + item: Data, + filter: FilterCondition, + parents: Data[] = [], + logger: Logger, + getOuterProperty?: OuterPropertyResolver +): boolean { + if (!validatePathExpression(filter.$check, '$check', logger)) { + return false; + } + const checkValue = getProperty(item, filter.$check, parents, logger, getOuterProperty); + return evaluateCondition(checkValue, filter); +} + /** * Check if a template has $bind: "." which means bind to current data object */ diff --git a/nodejs/packages/treebark/src/dom.ts b/nodejs/packages/treebark/src/dom.ts index db2ed99..6f84140 100644 --- a/nodejs/packages/treebark/src/dom.ts +++ b/nodejs/packages/treebark/src/dom.ts @@ -11,7 +11,9 @@ import { isConditionalValue, evaluateConditionalValue, parseTemplateObject, - processConditional + processConditional, + isFilterCondition, + evaluateFilterCondition } from './common.js'; export function renderToDOM( @@ -123,7 +125,7 @@ function render(template: TemplateElement | TemplateElement[], data: Data, conte // $bind uses literal property paths only - no parent context access const bound = getProperty(data, rest.$bind, [], logger, getOuterProperty); - const { $bind, $children = [], ...bindAttrs } = rest; + const { $bind, $filter, $children = [], ...bindAttrs } = rest; setAttrs(element, bindAttrs, data, tag, parents, logger, getOuterProperty); // Validate children for bound elements @@ -133,7 +135,17 @@ function render(template: TemplateElement | TemplateElement[], data: Data, conte } if (Array.isArray(bound)) { - for (const item of bound) { + // Apply $filter if present + let itemsToRender = bound; + + if ($filter && isFilterCondition($filter)) { + itemsToRender = bound.filter(item => { + const newParents = [...parents, data]; + return evaluateFilterCondition(item as Data, $filter, newParents, logger, getOuterProperty); + }); + } + + for (const item of itemsToRender) { // For array items, add current data context to parents const newParents = [...parents, data]; // Skip children for void tags diff --git a/nodejs/packages/treebark/src/string.ts b/nodejs/packages/treebark/src/string.ts index a94b9c0..6aacf12 100644 --- a/nodejs/packages/treebark/src/string.ts +++ b/nodejs/packages/treebark/src/string.ts @@ -12,7 +12,9 @@ import { isConditionalValue, evaluateConditionalValue, parseTemplateObject, - processConditional + processConditional, + isFilterCondition, + evaluateFilterCondition } from './common.js'; // Type for indented output: [indentLevel, htmlContent] @@ -161,7 +163,7 @@ function render(template: TemplateElement | TemplateElement[], data: Data, conte } const bound = getProperty(data, rest.$bind, [], logger, getOuterProperty); - const { $bind, $children = [], ...bindAttrs } = rest; + const { $bind, $filter, $children = [], ...bindAttrs } = rest; if (!Array.isArray(bound)) { // Check if bound is a primitive and we're trying to access children @@ -175,10 +177,20 @@ function render(template: TemplateElement | TemplateElement[], data: Data, conte } // Array binding case + let itemsToRender = bound; + + // Apply $filter if present + if ($filter && isFilterCondition($filter)) { + itemsToRender = bound.filter(item => { + const newParents = [...parents, data]; + return evaluateFilterCondition(item as Data, $filter, newParents, logger, getOuterProperty); + }); + } + childrenOutput = []; // Skip children for void tags if (!VOID_TAGS.has(tag)) { - for (const item of bound) { + for (const item of itemsToRender) { const newParents = [...parents, data]; for (const child of $children) { const content = render(child, item as Data, { ...childContext, parents: newParents }); diff --git a/nodejs/packages/treebark/src/types.ts b/nodejs/packages/treebark/src/types.ts index 19475da..6328b89 100644 --- a/nodejs/packages/treebark/src/types.ts +++ b/nodejs/packages/treebark/src/types.ts @@ -43,6 +43,22 @@ export type ConditionalValueOrTemplate = ConditionalBase; +// Filter condition type for $filter - similar to ConditionalBase but without $then/$else +export type FilterCondition = { + $check: BindPath; + // Comparison operators (require numbers) + '$<'?: number; + '$>'?: number; + '$<='?: number; + '$>='?: number; + // Equality operators (can compare any value) + '$='?: PrimitiveValue; + $in?: PrimitiveValue[]; + // Modifiers + $not?: boolean; + $join?: 'AND' | 'OR'; +}; + // CSS Style properties as an object with kebab-case property names // Accepts any valid CSS property name (kebab-case format) export type CSSProperties = { @@ -84,6 +100,7 @@ type GlobalAttrs = { // Base attributes for container tags (can have children) type BaseContainerAttrs = GlobalAttrs & { $bind?: BindPath; + $filter?: FilterCondition; $children?: (InterpolatedString | TemplateObject)[]; }; From 13cc0cbfddd3a7f97faf452b1c1405d486472c8c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Dec 2025 23:30:48 +0000 Subject: [PATCH 03/24] Add documentation and playground examples for $filter feature Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- README.md | 107 +++++++++++- docs/js/playground.js | 164 +++++++++++++++++- .../src/examples/filter-age-range.ts | 59 +++++++ .../src/examples/filter-by-price.ts | 45 +++++ .../playground/src/examples/filter-by-role.ts | 61 +++++++ .../packages/playground/src/examples/index.ts | 8 +- spec.md | 150 +++++++++++++++- 7 files changed, 586 insertions(+), 8 deletions(-) create mode 100644 nodejs/packages/playground/src/examples/filter-age-range.ts create mode 100644 nodejs/packages/playground/src/examples/filter-by-price.ts create mode 100644 nodejs/packages/playground/src/examples/filter-by-role.ts diff --git a/README.md b/README.md index fd299c7..03e2363 100644 --- a/README.md +++ b/README.md @@ -108,11 +108,12 @@ This means the implementation is featherweight. **Data binding:** - `$children`: Array or string. Defines child nodes or mixed content for an element. - `$bind`: String. Binds the current node to a property or array in the data context. If it resolves to an array, the element's children are repeated for each item. +- `$filter`: Object. Filters array items when used with `$bind`. Uses the same conditional operators as `$if` tag. -**Conditional keys (used in `$if` tag and conditional attribute values):** +**Conditional keys (used in `$if` tag, `$filter`, and conditional attribute values):** - `$check`: String. Property path to check. -- `$then`: Single template object or string. Content/value when condition is true. -- `$else`: Single template object or string. Content/value when condition is false. +- `$then`: Single template object or string. Content/value when condition is true (not used in `$filter`). +- `$else`: Single template object or string. Content/value when condition is false (not used in `$filter`). - `$<`: Less than comparison. - `$>`: Greater than comparison. - `$<=`: Less than or equal comparison. @@ -714,6 +715,106 @@ Output: **Note:** Numeric indices work because JavaScript allows both `array[0]` and `array["0"]` syntax. The dot notation path is split and each segment (including numeric strings) is used as a property key. +### Filtering Arrays + +You can filter array items before rendering them by using `$filter` with `$bind`. The `$filter` key uses the same conditional operators as the `$if` tag. + +**Filter by price:** +```json +{ + "ul": { + "$bind": "products", + "$filter": { + "$check": "price", + "$<": 500 + }, + "$children": [ + { "li": "{{name}} — {{price}}" } + ] + } +} +``` + +Data: +```json +{ + "products": [ + { "name": "Laptop", "price": "$999" }, + { "name": "Mouse", "price": "$25" }, + { "name": "Keyboard", "price": "$75" } + ] +} +``` + +Output: +```html +
      +
    • Mouse — $25
    • +
    • Keyboard — $75
    • +
    +``` + +**Filter by role:** +```json +{ + "ul": { + "$bind": "users", + "$filter": { + "$check": "role", + "$in": ["admin", "moderator"] + }, + "$children": [ + { "li": "{{name}} ({{role}})" } + ] + } +} +``` + +Data: +```json +{ + "users": [ + { "name": "Alice", "role": "admin" }, + { "name": "Bob", "role": "user" }, + { "name": "Charlie", "role": "moderator" } + ] +} +``` + +Output: +```html +
      +
    • Alice (admin)
    • +
    • Charlie (moderator)
    • +
    +``` + +**Filter with range:** +```json +{ + "ul": { + "$bind": "people", + "$filter": { + "$check": "age", + "$>=": 18, + "$<=": 65 + }, + "$children": [ + { "li": "{{name}} ({{age}})" } + ] + } +} +``` + +This filters for working-age adults (18-65 inclusive). + +**Available filter operators:** +- `$<`, `$>`, `$<=`, `$>=`: Numeric comparisons +- `$=`: Strict equality +- `$in`: Array membership check +- `$not`: Invert the condition +- `$join`: Combine operators with "AND" (default) or "OR" logic + ### Comments HTML comments can be created using the `comment` tag: diff --git a/docs/js/playground.js b/docs/js/playground.js index a09151e..1e8bc49 100644 --- a/docs/js/playground.js +++ b/docs/js/playground.js @@ -849,6 +849,165 @@ { rowId: 5, sun: 27, mon: 28, tue: 29, wed: 30, thu: 31, fri: "", sat: "" } ] }; + const filterByPrice = { + template: { + div: { + class: "product-showcase", + $children: [ + { h2: "Products Under $500" }, + { + ul: { + class: "product-list", + $bind: "products", + $filter: { + $check: "price", + "$<": 500 + }, + $children: [ + { li: "{{name}} - ${{price}}" } + ] + } + }, + { h2: "All Products" }, + { + ul: { + class: "product-list", + $bind: "products", + $children: [ + { li: "{{name}} - ${{price}}" } + ] + } + } + ] + } + }, + data: { + products: [ + { name: "Laptop", price: 999 }, + { name: "Mouse", price: 25 }, + { name: "Keyboard", price: 75 }, + { name: "Monitor", price: 699 }, + { name: "Webcam", price: 89 }, + { name: "Headset", price: 149 } + ] + } + }; + const filterByRole = { + template: { + div: { + class: "user-dashboard", + $children: [ + { h2: "Admins and Moderators" }, + { + ul: { + class: "user-list privileged", + $bind: "users", + $filter: { + $check: "role", + $in: ["admin", "moderator"] + }, + $children: [ + { + li: { + $children: [ + { strong: "{{name}}" }, + " - ", + { span: { class: "role", $children: ["{{role}}"] } } + ] + } + } + ] + } + }, + { h2: "All Users" }, + { + ul: { + class: "user-list", + $bind: "users", + $children: [ + { + li: { + $children: [ + { strong: "{{name}}" }, + " - ", + { span: { class: "role", $children: ["{{role}}"] } } + ] + } + } + ] + } + } + ] + } + }, + data: { + users: [ + { name: "Alice", role: "admin" }, + { name: "Bob", role: "user" }, + { name: "Charlie", role: "moderator" }, + { name: "Dave", role: "user" }, + { name: "Eve", role: "editor" }, + { name: "Frank", role: "admin" } + ] + } + }; + const filterAgeRange = { + template: { + div: { + class: "age-groups", + $children: [ + { h2: "Working Age (18-65)" }, + { + ul: { + $bind: "people", + $filter: { + $check: "age", + "$>=": 18, + "$<=": 65 + }, + $children: [ + { li: "{{name}} ({{age}} years old)" } + ] + } + }, + { h2: "Non-Working Age" }, + { + ul: { + $bind: "people", + $filter: { + $check: "age", + "$<": 18, + "$>": 65, + $join: "OR" + }, + $children: [ + { li: "{{name}} ({{age}} years old)" } + ] + } + }, + { h2: "Everyone" }, + { + ul: { + $bind: "people", + $children: [ + { li: "{{name}} ({{age}} years old)" } + ] + } + } + ] + } + }, + data: { + people: [ + { name: "Alice", age: 15 }, + { name: "Bob", age: 25 }, + { name: "Charlie", age: 70 }, + { name: "Dave", age: 40 }, + { name: "Eve", age: 12 }, + { name: "Frank", age: 55 } + ] + } + }; const examples = { "Hello World": helloWorld, "Card Layout": cardLayout, @@ -869,7 +1028,10 @@ "Conditional Join Or": conditionalJoinOr, "Conditional Attribute Values": conditionalAttributeValues, "Style Objects": styleObjects, - "Calendar": calendar + "Calendar": calendar, + "Filter By Price": filterByPrice, + "Filter By Role": filterByRole, + "Filter Age Range": filterAgeRange }; let currentTemplateFormat = "json"; const templateEditor = document.getElementById("template-editor"); diff --git a/nodejs/packages/playground/src/examples/filter-age-range.ts b/nodejs/packages/playground/src/examples/filter-age-range.ts new file mode 100644 index 0000000..fe4358e --- /dev/null +++ b/nodejs/packages/playground/src/examples/filter-age-range.ts @@ -0,0 +1,59 @@ +import type { Example } from './types.js'; + +export const filterAgeRange: Example = { + template: { + div: { + class: "age-groups", + $children: [ + { h2: "Working Age (18-65)" }, + { + ul: { + $bind: "people", + $filter: { + $check: "age", + "$>=": 18, + "$<=": 65 + }, + $children: [ + { li: "{{name}} ({{age}} years old)" } + ] + } + }, + { h2: "Non-Working Age" }, + { + ul: { + $bind: "people", + $filter: { + $check: "age", + "$<": 18, + "$>": 65, + $join: "OR" as const + }, + $children: [ + { li: "{{name}} ({{age}} years old)" } + ] + } + }, + { h2: "Everyone" }, + { + ul: { + $bind: "people", + $children: [ + { li: "{{name}} ({{age}} years old)" } + ] + } + } + ] + } + }, + data: { + people: [ + { name: "Alice", age: 15 }, + { name: "Bob", age: 25 }, + { name: "Charlie", age: 70 }, + { name: "Dave", age: 40 }, + { name: "Eve", age: 12 }, + { name: "Frank", age: 55 } + ] + } +}; diff --git a/nodejs/packages/playground/src/examples/filter-by-price.ts b/nodejs/packages/playground/src/examples/filter-by-price.ts new file mode 100644 index 0000000..5be8168 --- /dev/null +++ b/nodejs/packages/playground/src/examples/filter-by-price.ts @@ -0,0 +1,45 @@ +import type { Example } from './types.js'; + +export const filterByPrice: Example = { + template: { + div: { + class: "product-showcase", + $children: [ + { h2: "Products Under $500" }, + { + ul: { + class: "product-list", + $bind: "products", + $filter: { + $check: "price", + "$<": 500 + }, + $children: [ + { li: "{{name}} - ${{price}}" } + ] + } + }, + { h2: "All Products" }, + { + ul: { + class: "product-list", + $bind: "products", + $children: [ + { li: "{{name}} - ${{price}}" } + ] + } + } + ] + } + }, + data: { + products: [ + { name: "Laptop", price: 999 }, + { name: "Mouse", price: 25 }, + { name: "Keyboard", price: 75 }, + { name: "Monitor", price: 699 }, + { name: "Webcam", price: 89 }, + { name: "Headset", price: 149 } + ] + } +}; diff --git a/nodejs/packages/playground/src/examples/filter-by-role.ts b/nodejs/packages/playground/src/examples/filter-by-role.ts new file mode 100644 index 0000000..f383047 --- /dev/null +++ b/nodejs/packages/playground/src/examples/filter-by-role.ts @@ -0,0 +1,61 @@ +import type { Example } from './types.js'; + +export const filterByRole: Example = { + template: { + div: { + class: "user-dashboard", + $children: [ + { h2: "Admins and Moderators" }, + { + ul: { + class: "user-list privileged", + $bind: "users", + $filter: { + $check: "role", + $in: ["admin", "moderator"] + }, + $children: [ + { + li: { + $children: [ + { strong: "{{name}}" }, + " - ", + { span: { class: "role", $children: ["{{role}}"] } } + ] + } + } + ] + } + }, + { h2: "All Users" }, + { + ul: { + class: "user-list", + $bind: "users", + $children: [ + { + li: { + $children: [ + { strong: "{{name}}" }, + " - ", + { span: { class: "role", $children: ["{{role}}"] } } + ] + } + } + ] + } + } + ] + } + }, + data: { + users: [ + { name: "Alice", role: "admin" }, + { name: "Bob", role: "user" }, + { name: "Charlie", role: "moderator" }, + { name: "Dave", role: "user" }, + { name: "Eve", role: "editor" }, + { name: "Frank", role: "admin" } + ] + } +}; diff --git a/nodejs/packages/playground/src/examples/index.ts b/nodejs/packages/playground/src/examples/index.ts index fbe5933..77959d5 100644 --- a/nodejs/packages/playground/src/examples/index.ts +++ b/nodejs/packages/playground/src/examples/index.ts @@ -19,6 +19,9 @@ import { conditionalJoinOr } from './conditional-join-or.js'; import { conditionalAttributeValues } from './conditional-attribute-values.js'; import { styleObjects } from './style-objects.js'; import { calendar } from './calendar.js'; +import { filterByPrice } from './filter-by-price.js'; +import { filterByRole } from './filter-by-role.js'; +import { filterAgeRange } from './filter-age-range.js'; export type Examples = Record; @@ -42,5 +45,8 @@ export const examples: Examples = { 'Conditional Join Or': conditionalJoinOr, 'Conditional Attribute Values': conditionalAttributeValues, 'Style Objects': styleObjects, - 'Calendar': calendar + 'Calendar': calendar, + 'Filter By Price': filterByPrice, + 'Filter By Role': filterByRole, + 'Filter Age Range': filterAgeRange }; diff --git a/spec.md b/spec.md index 51e60f6..1a56cdb 100644 --- a/spec.md +++ b/spec.md @@ -79,6 +79,7 @@ Treebark renders as much valid content as possible, only skipping problematic el - **`$children`** → array of child nodes (strings, nodes, or arrays) - **`$bind`** → bind current node to an array or object property in data +- **`$filter`** → filter array items when used with `$bind` (uses conditional operators) --- @@ -327,7 +328,150 @@ JavaScript allows both `array[0]` and `array["0"]` syntax. Since the path is spl --- -## 11. Tag Whitelist +## 11. Filtering Arrays with $filter + +The `$filter` key works with `$bind` to filter array items before rendering them. It uses the same conditional operators as the `$if` tag. + +**Syntax:** +```javascript +{ + tag: { + $bind: "arrayProperty", + $filter: { + $check: "propertyToCheck", + // ... conditional operators ... + }, + $children: [ /* template for each filtered item */ ] + } +} +``` + +**Example - Filter by price:** +```javascript +{ + template: { + ul: { + $bind: "products", + $filter: { + $check: "price", + "$<": 500 + }, + $children: [ + { li: "{{name}} - ${{price}}" } + ] + } + }, + data: { + products: [ + { name: "Laptop", price: 999 }, + { name: "Mouse", price: 25 }, + { name: "Keyboard", price: 75 } + ] + } +} +``` +Output: `
    • Mouse - $25
    • Keyboard - $75
    ` + +**Example - Filter by role:** +```javascript +{ + template: { + ul: { + $bind: "users", + $filter: { + $check: "role", + $in: ["admin", "moderator"] + }, + $children: [ + { li: "{{name}}" } + ] + } + }, + data: { + users: [ + { name: "Alice", role: "admin" }, + { name: "Bob", role: "user" }, + { name: "Charlie", role: "moderator" } + ] + } +} +``` +Output: `
    • Alice
    • Charlie
    ` + +**Example - Filter with range (AND logic):** +```javascript +{ + template: { + ul: { + $bind: "people", + $filter: { + $check: "age", + "$>=": 18, + "$<=": 65 + }, + $children: [ + { li: "{{name}}" } + ] + } + }, + data: { + people: [ + { name: "Alice", age: 15 }, + { name: "Bob", age: 30 }, + { name: "Charlie", age: 70 } + ] + } +} +``` +Output: `
    • Bob
    ` + +**Example - Filter with OR logic:** +```javascript +{ + template: { + ul: { + $bind: "people", + $filter: { + $check: "age", + "$<": 18, + "$>": 65, + $join: "OR" + }, + $children: [ + { li: "{{name}}" } + ] + } + }, + data: { + people: [ + { name: "Alice", age: 15 }, + { name: "Bob", age: 30 }, + { name: "Charlie", age: 70 } + ] + } +} +``` +Output: `
    • Alice
    • Charlie
    ` + +**Supported operators:** +- `$<`: Less than +- `$>`: Greater than +- `$<=`: Less than or equal +- `$>=`: Greater than or equal +- `$=`: Strict equality (===) +- `$in`: Array membership check +- `$not`: Invert the condition result +- `$join`: "AND" | "OR" - Combine multiple operators (default: "AND") + +**Key differences from `$if` tag:** +- `$filter` is used with `$bind` to filter arrays +- `$filter` does not use `$then` or `$else` (it only evaluates true/false) +- Items that evaluate to true are included in the rendered output +- Items that evaluate to false are excluded + +--- + +## 12. Tag Whitelist **Standard HTML tags:** `div`, `span`, `p`, `header`, `footer`, `main`, `section`, `article`, @@ -347,7 +491,7 @@ Blocked tags: --- -## 12. Comments +## 13. Comments HTML comments are generated using the `$comment` tag: @@ -385,7 +529,7 @@ $comment: --- -## 13. Conditional Rendering with "$if" Tag +## 14. Conditional Rendering with "$if" Tag The `$if` tag provides advanced conditional rendering based on data properties. It acts as a transparent container that renders its children only when specified conditions are met. From 349241e3afa1e4b4d2722a5088caf360013964e0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Dec 2025 23:35:39 +0000 Subject: [PATCH 04/24] Address code review feedback and add filter comparison examples Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- README.md | 65 +++++++- docs/assets/markdown-it-treebark-browser.js | 1 - .../markdown-it-treebark-browser.min.js | 6 +- docs/assets/treebark-browser.js | 1 - docs/assets/treebark-browser.min.js | 12 +- docs/js/playground.js | 148 +++++++++++++++++- .../src/examples/filter-comparison.ts | 107 +++++++++++++ .../src/examples/filter-in-stock.ts | 44 ++++++ .../packages/playground/src/examples/index.ts | 6 +- nodejs/packages/treebark/src/common.ts | 2 - 10 files changed, 373 insertions(+), 19 deletions(-) create mode 100644 nodejs/packages/playground/src/examples/filter-comparison.ts create mode 100644 nodejs/packages/playground/src/examples/filter-in-stock.ts diff --git a/README.md b/README.md index 03e2363..bb20c57 100644 --- a/README.md +++ b/README.md @@ -729,7 +729,7 @@ You can filter array items before rendering them by using `$filter` with `$bind` "$<": 500 }, "$children": [ - { "li": "{{name}} — {{price}}" } + { "li": "{{name}} — ${{price}}" } ] } } @@ -739,9 +739,9 @@ Data: ```json { "products": [ - { "name": "Laptop", "price": "$999" }, - { "name": "Mouse", "price": "$25" }, - { "name": "Keyboard", "price": "$75" } + { "name": "Laptop", "price": 999 }, + { "name": "Mouse", "price": 25 }, + { "name": "Keyboard", "price": 75 } ] } ``` @@ -815,6 +815,63 @@ This filters for working-age adults (18-65 inclusive). - `$not`: Invert the condition - `$join`: Combine operators with "AND" (default) or "OR" logic +**Practical example - Filter vs If:** + +Instead of rendering all items with conditional status messages: +```json +{ + "div": { + "$bind": "products", + "$children": [ + { + "div": { + "$children": [ + { "h3": "{{name}}" }, + { + "$if": { + "$check": "inStock", + "$then": { "p": "✓ In Stock" } + } + }, + { + "$if": { + "$check": "inStock", + "$not": true, + "$then": { "p": "✗ Out of Stock" } + } + } + ] + } + } + ] + } +} +``` + +Simply filter to show only in-stock items: +```json +{ + "div": { + "$bind": "products", + "$filter": { + "$check": "inStock" + }, + "$children": [ + { + "div": { + "$children": [ + { "h3": "{{name}}" }, + { "p": "✓ In Stock ({{quantity}} available)" } + ] + } + } + ] + } +} +``` + +This is cleaner, simpler, and more efficient! + ### Comments HTML comments can be created using the `comment` tag: diff --git a/docs/assets/markdown-it-treebark-browser.js b/docs/assets/markdown-it-treebark-browser.js index 26cc380..1f82d70 100644 --- a/docs/assets/markdown-it-treebark-browser.js +++ b/docs/assets/markdown-it-treebark-browser.js @@ -54,7 +54,6 @@ }; const OPERATORS = /* @__PURE__ */ new Set(["$<", "$>", "$<=", "$>=", "$=", "$in"]); const CONDITIONALKEYS = /* @__PURE__ */ new Set(["$check", "$then", "$else", "$not", "$join", ...OPERATORS]); - /* @__PURE__ */ new Set(["$check", "$not", "$join", ...OPERATORS]); const BLOCKED_CSS_PROPERTIES = /* @__PURE__ */ new Set([ "behavior", // IE behavior property - can execute code diff --git a/docs/assets/markdown-it-treebark-browser.min.js b/docs/assets/markdown-it-treebark-browser.min.js index 9493b6f..4cebd58 100644 --- a/docs/assets/markdown-it-treebark-browser.min.js +++ b/docs/assets/markdown-it-treebark-browser.min.js @@ -1,11 +1,11 @@ -(function(b,A){typeof exports=="object"&&typeof module<"u"?module.exports=A():typeof define=="function"&&define.amd?define(A):(b=typeof globalThis<"u"?globalThis:b||self,b.MarkdownItTreebark=A())})(this,(function(){"use strict";const b=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"]),A=new Set(["$comment","$if"]),S=new Set(["img","br","hr"]),N=new Set([...b,...A,...S]),O=new Set(["id","class","style","title","role","data-","aria-"]),F={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"])},k=new Set(["$<","$>","$<=","$>=","$=","$in"]),R=new Set(["$check","$then","$else","$not","$join",...k]);[...k];const G=new Set(["behavior","-moz-binding"]);function p(e,t,o=[],i,c){if(t===".")return e;let n=e,r=t;for(;r.startsWith("..");){let a=0,s=r;for(;s.startsWith("..");)a++,s=s.substring(2),s.startsWith("/")&&(s=s.substring(1));if(a<=o.length)n=o[o.length-a],r=s.startsWith(".")?s.substring(1):s;else return c?c(t,e,o):void 0}if(r){if(i&&typeof n!="object"&&n!==null&&n!==void 0){i.error(`Cannot access property "${r}" on primitive value of type "${typeof n}"`);return}const a=r.split(".").reduce((s,f)=>s&&typeof s=="object"&&s!==null?s[f]:void 0,n);return a===void 0&&c?c(t,e,o):a}return n}function I(e){return e.replace(/[&<>"']/g,t=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[t]||t)}function C(e,t,o=!0,i=[],c,n){return e.replace(/\{\{\{([^{]*?)\}\}\}|\{\{([^{]*?)\}\}/g,(r,a,s)=>{if(a!==void 0)return`{{${a.trim()}}}`;const f=s.trim(),u=p(t,f,i,c,n);return u==null?"":o?I(String(u)):String(u)})}function L(e,t){const o=[];for(const[i,c]of Object.entries(e)){const n=i;if(!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(n)){t.warn(`CSS property "${i}" has invalid format (must be kebab-case)`);continue}if(G.has(n)){t.warn(`CSS property "${i}" is blocked for security reasons`);continue}if(c==null)continue;let r=String(c).trim();if(r.includes(";")){const f=r;r=r.split(";")[0].trim(),r&&r!==f.trim()&&t.warn(`CSS value for "${i}" contained semicolon - using only first part: "${r}"`)}if(!r)continue;const a=/url\s*\(/i.test(r),s=/url\s*\(\s*['"]?data:/i.test(r);if(a&&!s||/expression\s*\(/i.test(r)||/javascript:/i.test(r)||/@import/i.test(r)){t.warn(`CSS value for "${i}" contains potentially dangerous pattern: "${r}"`);continue}o.push(`${n}: ${r}`)}return o.join("; ").trim()}function q(e,t,o,i,c){if(e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"){const n=e;if(!w(n.$check,"$check",i))return"";const r=p(t,n.$check,o,i,c),s=T(r,n)?n.$then:n.$else;return s===void 0?"":typeof s=="object"&&s!==null&&!Array.isArray(s)?L(s,i):""}return typeof e=="object"&&e!==null&&!Array.isArray(e)?L(e,i):(i.error(`Style attribute must be an object with CSS properties, not ${typeof e}. Example: style: { "color": "red", "font-size": "14px" }`),"")}function z(e,t,o){const i=O.has(e)||[...O].some(r=>r.endsWith("-")&&e.startsWith(r)),c=F[t],n=c&&c.has(e);return!i&&!n?(o.warn(`Attribute "${e}" is not allowed on tag "${t}"`),!1):!0}function B(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$bind"in e}function w(e,t,o){return e==="."?!0:e.includes("..")?(o.error(`${t} does not support parent context access (..) - use interpolation {{..prop}} in content/attributes instead. Invalid: ${t}: "${e}"`),!1):e.includes("{{")?(o.error(`${t} does not support interpolation {{...}} - use literal property paths only. Invalid: ${t}: "${e}"`),!1):!0}function T(e,t){const o=[];for(const r of k)r in t&&o.push({key:r,value:t[r]});if(o.length===0){const r=!!e;return t.$not?!r:r}const i=o.map(r=>{switch(r.key){case"$<":return typeof e=="number"&&typeof r.value=="number"&&e":return typeof e=="number"&&typeof r.value=="number"&&e>r.value;case"$<=":return typeof e=="number"&&typeof r.value=="number"&&e<=r.value;case"$>=":return typeof e=="number"&&typeof r.value=="number"&&e>=r.value;case"$=":return e===r.value;case"$in":return Array.isArray(r.value)&&r.value.includes(e);default:return!1}}),c=t.$join==="OR";let n;return c?n=i.some(r=>r):n=i.every(r=>r),t.$not?!n:n}function M(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function K(e,t,o=[],i,c){if(!w(e.$check,"$check",i))return"";const n=p(t,e.$check,o,i,c);return T(n,e)?e.$then!==void 0?e.$then:"":e.$else!==void 0?e.$else:""}function Y(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function J(e,t,o=[],i,c){if(!w(t.$check,"$check",i))return!1;const n=p(e,t.$check,o,i,c);return T(n,t)}function U(e,t){if(!e||typeof e!="object"){t.error("Template object cannot be null, undefined, or non-object");return}const o=Object.entries(e);if(o.length===0){t.error("Template object must have at least one tag");return}const i=o[0];if(!i){t.error("Template object must have at least one tag");return}const[c,n]=i,r=typeof n=="string"?[n]:Array.isArray(n)?n:n?.$children||[],a=n&&typeof n=="object"&&!Array.isArray(n)?Object.fromEntries(Object.entries(n).filter(([s])=>s!=="$children")):{};return{tag:c,rest:n,children:r,attrs:a}}function V(e,t,o=[],i,c){const n=e;if(!n.$check)return i.error('"$if" tag requires $check attribute to specify the condition'),{valueToRender:void 0};if(!w(n.$check,"$check",i))return{valueToRender:void 0};const r=p(t,n.$check,o,i,c);typeof e=="object"&&e!==null&&!Array.isArray(e)&&"$children"in e&&i.warn('"$if" tag does not support $children, use $then and $else instead');const{$then:a,$else:s}=n;if(a!==void 0&&Array.isArray(a))return i.error('"$if" tag $then must be a string or single element object, not an array'),{valueToRender:void 0};if(s!==void 0&&Array.isArray(s))return i.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($=>!R.has($));return u.length>0&&i.warn(`"$if" tag does not support attributes: ${u.join(", ")}. Allowed: ${[...R].join(", ")}`),{valueToRender:T(r,n)?a:s}}const H=(e,t)=>{if(!t)return e.length<=1?e[0]?.[1]??"":e.reduce((i,[,c])=>i+c,"");if(e.length===0)return"";if(e.length===1&&!e[0][1].includes("<"))return e[0][1];let o=` +(function(b,A){typeof exports=="object"&&typeof module<"u"?module.exports=A():typeof define=="function"&&define.amd?define(A):(b=typeof globalThis<"u"?globalThis:b||self,b.MarkdownItTreebark=A())})(this,(function(){"use strict";const b=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"]),A=new Set(["$comment","$if"]),S=new Set(["img","br","hr"]),N=new Set([...b,...A,...S]),E=new Set(["id","class","style","title","role","data-","aria-"]),F={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]),G=new Set(["behavior","-moz-binding"]);function p(e,t,o=[],i,c){if(t===".")return e;let n=e,r=t;for(;r.startsWith("..");){let a=0,s=r;for(;s.startsWith("..");)a++,s=s.substring(2),s.startsWith("/")&&(s=s.substring(1));if(a<=o.length)n=o[o.length-a],r=s.startsWith(".")?s.substring(1):s;else return c?c(t,e,o):void 0}if(r){if(i&&typeof n!="object"&&n!==null&&n!==void 0){i.error(`Cannot access property "${r}" on primitive value of type "${typeof n}"`);return}const a=r.split(".").reduce((s,f)=>s&&typeof s=="object"&&s!==null?s[f]:void 0,n);return a===void 0&&c?c(t,e,o):a}return n}function I(e){return e.replace(/[&<>"']/g,t=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[t]||t)}function k(e,t,o=!0,i=[],c,n){return e.replace(/\{\{\{([^{]*?)\}\}\}|\{\{([^{]*?)\}\}/g,(r,a,s)=>{if(a!==void 0)return`{{${a.trim()}}}`;const f=s.trim(),u=p(t,f,i,c,n);return u==null?"":o?I(String(u)):String(u)})}function L(e,t){const o=[];for(const[i,c]of Object.entries(e)){const n=i;if(!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(n)){t.warn(`CSS property "${i}" has invalid format (must be kebab-case)`);continue}if(G.has(n)){t.warn(`CSS property "${i}" is blocked for security reasons`);continue}if(c==null)continue;let r=String(c).trim();if(r.includes(";")){const f=r;r=r.split(";")[0].trim(),r&&r!==f.trim()&&t.warn(`CSS value for "${i}" contained semicolon - using only first part: "${r}"`)}if(!r)continue;const a=/url\s*\(/i.test(r),s=/url\s*\(\s*['"]?data:/i.test(r);if(a&&!s||/expression\s*\(/i.test(r)||/javascript:/i.test(r)||/@import/i.test(r)){t.warn(`CSS value for "${i}" contains potentially dangerous pattern: "${r}"`);continue}o.push(`${n}: ${r}`)}return o.join("; ").trim()}function q(e,t,o,i,c){if(e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"){const n=e;if(!w(n.$check,"$check",i))return"";const r=p(t,n.$check,o,i,c),s=T(r,n)?n.$then:n.$else;return s===void 0?"":typeof s=="object"&&s!==null&&!Array.isArray(s)?L(s,i):""}return typeof e=="object"&&e!==null&&!Array.isArray(e)?L(e,i):(i.error(`Style attribute must be an object with CSS properties, not ${typeof e}. Example: style: { "color": "red", "font-size": "14px" }`),"")}function z(e,t,o){const i=E.has(e)||[...E].some(r=>r.endsWith("-")&&e.startsWith(r)),c=F[t],n=c&&c.has(e);return!i&&!n?(o.warn(`Attribute "${e}" is not allowed on tag "${t}"`),!1):!0}function B(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$bind"in e}function w(e,t,o){return e==="."?!0:e.includes("..")?(o.error(`${t} does not support parent context access (..) - use interpolation {{..prop}} in content/attributes instead. Invalid: ${t}: "${e}"`),!1):e.includes("{{")?(o.error(`${t} does not support interpolation {{...}} - use literal property paths only. Invalid: ${t}: "${e}"`),!1):!0}function T(e,t){const o=[];for(const r of O)r in t&&o.push({key:r,value:t[r]});if(o.length===0){const r=!!e;return t.$not?!r:r}const i=o.map(r=>{switch(r.key){case"$<":return typeof e=="number"&&typeof r.value=="number"&&e":return typeof e=="number"&&typeof r.value=="number"&&e>r.value;case"$<=":return typeof e=="number"&&typeof r.value=="number"&&e<=r.value;case"$>=":return typeof e=="number"&&typeof r.value=="number"&&e>=r.value;case"$=":return e===r.value;case"$in":return Array.isArray(r.value)&&r.value.includes(e);default:return!1}}),c=t.$join==="OR";let n;return c?n=i.some(r=>r):n=i.every(r=>r),t.$not?!n:n}function M(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function K(e,t,o=[],i,c){if(!w(e.$check,"$check",i))return"";const n=p(t,e.$check,o,i,c);return T(n,e)?e.$then!==void 0?e.$then:"":e.$else!==void 0?e.$else:""}function Y(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function J(e,t,o=[],i,c){if(!w(t.$check,"$check",i))return!1;const n=p(e,t.$check,o,i,c);return T(n,t)}function U(e,t){if(!e||typeof e!="object"){t.error("Template object cannot be null, undefined, or non-object");return}const o=Object.entries(e);if(o.length===0){t.error("Template object must have at least one tag");return}const i=o[0];if(!i){t.error("Template object must have at least one tag");return}const[c,n]=i,r=typeof n=="string"?[n]:Array.isArray(n)?n:n?.$children||[],a=n&&typeof n=="object"&&!Array.isArray(n)?Object.fromEntries(Object.entries(n).filter(([s])=>s!=="$children")):{};return{tag:c,rest:n,children:r,attrs:a}}function V(e,t,o=[],i,c){const n=e;if(!n.$check)return i.error('"$if" tag requires $check attribute to specify the condition'),{valueToRender:void 0};if(!w(n.$check,"$check",i))return{valueToRender:void 0};const r=p(t,n.$check,o,i,c);typeof e=="object"&&e!==null&&!Array.isArray(e)&&"$children"in e&&i.warn('"$if" tag does not support $children, use $then and $else instead');const{$then:a,$else:s}=n;if(a!==void 0&&Array.isArray(a))return i.error('"$if" tag $then must be a string or single element object, not an array'),{valueToRender:void 0};if(s!==void 0&&Array.isArray(s))return i.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($=>!R.has($));return u.length>0&&i.warn(`"$if" tag does not support attributes: ${u.join(", ")}. Allowed: ${[...R].join(", ")}`),{valueToRender:T(r,n)?a:s}}const H=(e,t)=>{if(!t)return e.length<=1?e[0]?.[1]??"":e.reduce((i,[,c])=>i+c,"");if(e.length===0)return"";if(e.length===1&&!e[0][1].includes("<"))return e[0][1];let o=` `;for(let i=0;i`;const d=`<${e}${X(t,o,e,a,c,s)}>`;return S.has(e)?d:`${d}${f}${u}`}function y(e,t,o){const i=o.parents||[],c=o.logger,n=o.getOuterProperty;if(typeof e=="string")return C(e,t,!0,i,c,n);if(Array.isArray(e))return e.map(l=>y(l,t,o)).join(o.indentStr?` +`)&&n?n.repeat(r||0):"";if(e==="$comment")return``;const d=`<${e}${X(t,o,e,a,c,s)}>`;return S.has(e)?d:`${d}${f}${u}`}function y(e,t,o){const i=o.parents||[],c=o.logger,n=o.getOuterProperty;if(typeof e=="string")return k(e,t,!0,i,c,n);if(Array.isArray(e))return e.map(l=>y(l,t,o)).join(o.indentStr?` `:"");const r=U(e,c);if(!r)return"";const{tag:a,rest:s,children:f,attrs:u}=r;if(!N.has(a))return c.error(`Tag "${a}" is not allowed`),"";if(a==="$comment"&&o.insideComment)return c.error("Nested comments are not allowed"),"";if(a==="$if"){const{valueToRender:l}=V(s,t,i,c,n);return l===void 0?"":y(l,t,o)}S.has(a)&&f.length>0&&c.warn(`Tag "${a}" is a void element and cannot have children`);const d={...o,insideComment:a==="$comment"||o.insideComment,level:(o.level||0)+1},h=l=>l===""?[]:o.indentStr&&l.includes(` `)&&!l.includes("<")?l.split(` -`).map(g=>[d.level,g]):[[d.level,l]];let $,m;if(B(s)){if(!w(s.$bind,"$bind",c))return"";const l=p(t,s.$bind,[],c,n),{$bind:g,$filter:E,$children:W=[],..._}=s;if(!Array.isArray(l)){if(l!=null&&typeof l!="object")return c.error(`$bind resolved to primitive value of type "${typeof l}", cannot render children`),"";const v=l&&typeof l=="object"&&l!==null?l:{},j=[...i,t];return y({[a]:{..._,$children:W}},v,{...o,parents:j})}let D=l;if(E&&Y(E)&&(D=l.filter(v=>{const j=[...i,t];return J(v,E,j,c,n)})),$=[],!S.has(a))for(const v of D){const j=[...i,t];for(const te of W){const ne=y(te,v,{...d,parents:j});$.push(...h(ne))}}m=_}else{if($=[],!S.has(a))for(const l of f){const g=y(l,t,{...d,parents:i});$.push(...h(g))}m=u}return Q(a,m,t,$,c,o.indentStr,o.level,i,n)}function X(e,t,o,i=[],c,n){const r=Object.entries(e).filter(([a])=>z(a,o,c)).map(([a,s])=>{let f;if(a==="style"){if(f=q(s,t,i,c,n),!f)return null}else if(M(s)){const u=K(s,t,i,c,n);f=C(String(u),t,!1,i,c,n)}else f=C(String(s),t,!1,i,c,n);return`${a}="${I(f)}"`}).filter(a=>a!==null).join(" ");return r?" "+r:""}function Z(e,t={}){const{data:o={},yaml:i,indent:c,logger:n}=t,r=e.renderer.rules.fence;e.renderer.rules.fence=function(a,s,f,u,d){const h=a[s],$=h.info?h.info.trim():"";if($==="treebark"||$.startsWith("treebark "))try{return x(h.content,o,i,c,n)+` +`).map(g=>[d.level,g]):[[d.level,l]];let $,m;if(B(s)){if(!w(s.$bind,"$bind",c))return"";const l=p(t,s.$bind,[],c,n),{$bind:g,$filter:C,$children:W=[],..._}=s;if(!Array.isArray(l)){if(l!=null&&typeof l!="object")return c.error(`$bind resolved to primitive value of type "${typeof l}", cannot render children`),"";const v=l&&typeof l=="object"&&l!==null?l:{},j=[...i,t];return y({[a]:{..._,$children:W}},v,{...o,parents:j})}let D=l;if(C&&Y(C)&&(D=l.filter(v=>{const j=[...i,t];return J(v,C,j,c,n)})),$=[],!S.has(a))for(const v of D){const j=[...i,t];for(const te of W){const ne=y(te,v,{...d,parents:j});$.push(...h(ne))}}m=_}else{if($=[],!S.has(a))for(const l of f){const g=y(l,t,{...d,parents:i});$.push(...h(g))}m=u}return Q(a,m,t,$,c,o.indentStr,o.level,i,n)}function X(e,t,o,i=[],c,n){const r=Object.entries(e).filter(([a])=>z(a,o,c)).map(([a,s])=>{let f;if(a==="style"){if(f=q(s,t,i,c,n),!f)return null}else if(M(s)){const u=K(s,t,i,c,n);f=k(String(u),t,!1,i,c,n)}else f=k(String(s),t,!1,i,c,n);return`${a}="${I(f)}"`}).filter(a=>a!==null).join(" ");return r?" "+r:""}function Z(e,t={}){const{data:o={},yaml:i,indent:c,logger:n}=t,r=e.renderer.rules.fence;e.renderer.rules.fence=function(a,s,f,u,d){const h=a[s],$=h.info?h.info.trim():"";if($==="treebark"||$.startsWith("treebark "))try{return x(h.content,o,i,c,n)+` `}catch(m){const l=m instanceof Error?m.message:"Unknown error";return`
    Treebark Error: ${ee(l)}
    `}return r?r(a,s,f,u,d):""}}function x(e,t,o,i,c){let n,r=null;if(!e.trim())throw new Error("Empty or invalid template");if(o)try{n=o.load(e)}catch(s){r=s instanceof Error?s:new Error("YAML parsing failed")}if(!n)try{n=JSON.parse(e)}catch(s){throw o&&r?new Error(`Failed to parse as YAML or JSON. YAML error: ${r.message}`):new Error(`Failed to parse as JSON: ${s instanceof Error?s.message:"Invalid format"}`)}if(!n)throw new Error("Empty or invalid template");const a={indent:i,logger:c};if(n&&typeof n=="object"&&"template"in n){const s={...t,...n.data};return P({template:n.template,data:s},a)}else return P({template:n,data:t},a)}function ee(e){const t={"&":"&","<":"<",">":">",'"':""","'":"'"};return e.replace(/[&<>"']/g,o=>t[o])}return Z})); //# sourceMappingURL=markdown-it-treebark-browser.min.js.map diff --git a/docs/assets/treebark-browser.js b/docs/assets/treebark-browser.js index 6814250..e642e8b 100644 --- a/docs/assets/treebark-browser.js +++ b/docs/assets/treebark-browser.js @@ -54,7 +54,6 @@ }; const OPERATORS = /* @__PURE__ */ new Set(["$<", "$>", "$<=", "$>=", "$=", "$in"]); const CONDITIONALKEYS = /* @__PURE__ */ new Set(["$check", "$then", "$else", "$not", "$join", ...OPERATORS]); - /* @__PURE__ */ new Set(["$check", "$not", "$join", ...OPERATORS]); const BLOCKED_CSS_PROPERTIES = /* @__PURE__ */ new Set([ "behavior", // IE behavior property - can execute code diff --git a/docs/assets/treebark-browser.min.js b/docs/assets/treebark-browser.min.js index 68f851c..928dfc6 100644 --- a/docs/assets/treebark-browser.min.js +++ b/docs/assets/treebark-browser.min.js @@ -1,9 +1,9 @@ -(function(h,m){typeof exports=="object"&&typeof module<"u"?m(exports):typeof define=="function"&&define.amd?define(["exports"],m):(h=typeof globalThis<"u"?globalThis:h||self,m(h.Treebark={}))})(this,(function(h){"use strict";const m=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"]),W=new Set(["$comment","$if"]),b=new Set(["img","br","hr"]),G=new Set([...m,...W,...b]),R=new Set(["id","class","style","title","role","data-","aria-"]),z={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"])},T=new Set(["$<","$>","$<=","$>=","$=","$in"]),E=new Set(["$check","$then","$else","$not","$join",...T]);[...T];const N=new Set(["behavior","-moz-binding"]);function y(e,n,o=[],r,s){if(n===".")return e;let i=e,t=n;for(;t.startsWith("..");){let l=0,c=t;for(;c.startsWith("..");)l++,c=c.substring(2),c.startsWith("/")&&(c=c.substring(1));if(l<=o.length)i=o[o.length-l],t=c.startsWith(".")?c.substring(1):c;else return s?s(n,e,o):void 0}if(t){if(r&&typeof i!="object"&&i!==null&&i!==void 0){r.error(`Cannot access property "${t}" on primitive value of type "${typeof i}"`);return}const l=t.split(".").reduce((c,u)=>c&&typeof c=="object"&&c!==null?c[u]:void 0,i);return l===void 0&&s?s(n,e,o):l}return i}function I(e){return e.replace(/[&<>"']/g,n=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[n]||n)}function C(e,n,o=!0,r=[],s,i){return e.replace(/\{\{\{([^{]*?)\}\}\}|\{\{([^{]*?)\}\}/g,(t,l,c)=>{if(l!==void 0)return`{{${l.trim()}}}`;const u=c.trim(),f=y(n,u,r,s,i);return f==null?"":o?I(String(f)):String(f)})}function P(e,n){const o=[];for(const[r,s]of Object.entries(e)){const i=r;if(!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(i)){n.warn(`CSS property "${r}" has invalid format (must be kebab-case)`);continue}if(N.has(i)){n.warn(`CSS property "${r}" is blocked for security reasons`);continue}if(s==null)continue;let t=String(s).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 l=/url\s*\(/i.test(t),c=/url\s*\(\s*['"]?data:/i.test(t);if(l&&!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(`${i}: ${t}`)}return o.join("; ").trim()}function q(e,n,o,r,s){if(e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"){const i=e;if(!A(i.$check,"$check",r))return"";const t=y(n,i.$check,o,r,s),c=j(t,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 B(e,n,o){const r=R.has(e)||[...R].some(t=>t.endsWith("-")&&e.startsWith(t)),s=z[n],i=s&&s.has(e);return!r&&!i?(o.warn(`Attribute "${e}" is not allowed on tag "${n}"`),!1):!0}function F(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$bind"in e}function A(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 j(e,n){const o=[];for(const t of T)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}}),s=n.$join==="OR";let i;return s?i=r.some(t=>t):i=r.every(t=>t),n.$not?!i:i}function K(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function U(e,n,o=[],r,s){if(!A(e.$check,"$check",r))return"";const i=y(n,e.$check,o,r,s);return j(i,e)?e.$then!==void 0?e.$then:"":e.$else!==void 0?e.$else:""}function V(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function M(e,n,o=[],r,s){if(!A(n.$check,"$check",r))return!1;const i=y(e,n.$check,o,r,s);return j(i,n)}function Y(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[s,i]=r,t=typeof i=="string"?[i]:Array.isArray(i)?i:i?.$children||[],l=i&&typeof i=="object"&&!Array.isArray(i)?Object.fromEntries(Object.entries(i).filter(([c])=>c!=="$children")):{};return{tag:s,rest:i,children:t,attrs:l}}function H(e,n,o=[],r,s){const i=e;if(!i.$check)return r.error('"$if" tag requires $check attribute to specify the condition'),{valueToRender:void 0};if(!A(i.$check,"$check",r))return{valueToRender:void 0};const t=y(n,i.$check,o,r,s);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:l,$else:c}=i;if(l!==void 0&&Array.isArray(l))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 f=(typeof e=="object"&&e!==null&&!Array.isArray(e)?Object.keys(e):[]).filter($=>!E.has($));return f.length>0&&r.warn(`"$if" tag does not support attributes: ${f.join(", ")}. Allowed: ${[...E].join(", ")}`),{valueToRender:j(t,i)?l:c}}const J=(e,n)=>{if(!n)return e.length<=1?e[0]?.[1]??"":e.reduce((r,[,s])=>r+s,"");if(e.length===0)return"";if(e.length===1&&!e[0][1].includes("<"))return e[0][1];let o=` -`;for(let r=0;r","$<=","$>=","$=","$in"]),E=new Set(["$check","$then","$else","$not","$join",...R]),N=new Set(["behavior","-moz-binding"]);function y(e,t,o=[],r,s){if(t===".")return e;let i=e,n=t;for(;n.startsWith("..");){let l=0,c=n;for(;c.startsWith("..");)l++,c=c.substring(2),c.startsWith("/")&&(c=c.substring(1));if(l<=o.length)i=o[o.length-l],n=c.startsWith(".")?c.substring(1):c;else return s?s(t,e,o):void 0}if(n){if(r&&typeof i!="object"&&i!==null&&i!==void 0){r.error(`Cannot access property "${n}" on primitive value of type "${typeof i}"`);return}const l=n.split(".").reduce((c,u)=>c&&typeof c=="object"&&c!==null?c[u]:void 0,i);return l===void 0&&s?s(t,e,o):l}return i}function I(e){return e.replace(/[&<>"']/g,t=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[t]||t)}function w(e,t,o=!0,r=[],s,i){return e.replace(/\{\{\{([^{]*?)\}\}\}|\{\{([^{]*?)\}\}/g,(n,l,c)=>{if(l!==void 0)return`{{${l.trim()}}}`;const u=c.trim(),f=y(t,u,r,s,i);return f==null?"":o?I(String(f)):String(f)})}function P(e,t){const o=[];for(const[r,s]of Object.entries(e)){const i=r;if(!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(i)){t.warn(`CSS property "${r}" has invalid format (must be kebab-case)`);continue}if(N.has(i)){t.warn(`CSS property "${r}" is blocked for security reasons`);continue}if(s==null)continue;let n=String(s).trim();if(n.includes(";")){const u=n;n=n.split(";")[0].trim(),n&&n!==u.trim()&&t.warn(`CSS value for "${r}" contained semicolon - using only first part: "${n}"`)}if(!n)continue;const l=/url\s*\(/i.test(n),c=/url\s*\(\s*['"]?data:/i.test(n);if(l&&!c||/expression\s*\(/i.test(n)||/javascript:/i.test(n)||/@import/i.test(n)){t.warn(`CSS value for "${r}" contains potentially dangerous pattern: "${n}"`);continue}o.push(`${i}: ${n}`)}return o.join("; ").trim()}function q(e,t,o,r,s){if(e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"){const i=e;if(!A(i.$check,"$check",r))return"";const n=y(t,i.$check,o,r,s),c=j(n,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 B(e,t,o){const r=O.has(e)||[...O].some(n=>n.endsWith("-")&&e.startsWith(n)),s=z[t],i=s&&s.has(e);return!r&&!i?(o.warn(`Attribute "${e}" is not allowed on tag "${t}"`),!1):!0}function F(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$bind"in e}function A(e,t,o){return e==="."?!0:e.includes("..")?(o.error(`${t} does not support parent context access (..) - use interpolation {{..prop}} in content/attributes instead. Invalid: ${t}: "${e}"`),!1):e.includes("{{")?(o.error(`${t} does not support interpolation {{...}} - use literal property paths only. Invalid: ${t}: "${e}"`),!1):!0}function j(e,t){const o=[];for(const n of R)n in t&&o.push({key:n,value:t[n]});if(o.length===0){const n=!!e;return t.$not?!n:n}const r=o.map(n=>{switch(n.key){case"$<":return typeof e=="number"&&typeof n.value=="number"&&e":return typeof e=="number"&&typeof n.value=="number"&&e>n.value;case"$<=":return typeof e=="number"&&typeof n.value=="number"&&e<=n.value;case"$>=":return typeof e=="number"&&typeof n.value=="number"&&e>=n.value;case"$=":return e===n.value;case"$in":return Array.isArray(n.value)&&n.value.includes(e);default:return!1}}),s=t.$join==="OR";let i;return s?i=r.some(n=>n):i=r.every(n=>n),t.$not?!i:i}function K(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function U(e,t,o=[],r,s){if(!A(e.$check,"$check",r))return"";const i=y(t,e.$check,o,r,s);return j(i,e)?e.$then!==void 0?e.$then:"":e.$else!==void 0?e.$else:""}function V(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function M(e,t,o=[],r,s){if(!A(t.$check,"$check",r))return!1;const i=y(e,t.$check,o,r,s);return j(i,t)}function Y(e,t){if(!e||typeof e!="object"){t.error("Template object cannot be null, undefined, or non-object");return}const o=Object.entries(e);if(o.length===0){t.error("Template object must have at least one tag");return}const r=o[0];if(!r){t.error("Template object must have at least one tag");return}const[s,i]=r,n=typeof i=="string"?[i]:Array.isArray(i)?i:i?.$children||[],l=i&&typeof i=="object"&&!Array.isArray(i)?Object.fromEntries(Object.entries(i).filter(([c])=>c!=="$children")):{};return{tag:s,rest:i,children:n,attrs:l}}function H(e,t,o=[],r,s){const i=e;if(!i.$check)return r.error('"$if" tag requires $check attribute to specify the condition'),{valueToRender:void 0};if(!A(i.$check,"$check",r))return{valueToRender:void 0};const n=y(t,i.$check,o,r,s);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:l,$else:c}=i;if(l!==void 0&&Array.isArray(l))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 f=(typeof e=="object"&&e!==null&&!Array.isArray(e)?Object.keys(e):[]).filter($=>!E.has($));return f.length>0&&r.warn(`"$if" tag does not support attributes: ${f.join(", ")}. Allowed: ${[...E].join(", ")}`),{valueToRender:j(n,i)?l:c}}const J=(e,t)=>{if(!t)return e.length<=1?e[0]?.[1]??"":e.reduce((r,[,s])=>r+s,"");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 d=`<${e}${Z(n,o,e,l,s,c)}>`;return b.has(e)?d:`${d}${u}${f}`}function p(e,n,o){const r=o.parents||[],s=o.logger,i=o.getOuterProperty;if(typeof e=="string")return C(e,n,!0,r,s,i);if(Array.isArray(e))return e.map(a=>p(a,n,o)).join(o.indentStr?` -`:"");const t=Y(e,s);if(!t)return"";const{tag:l,rest:c,children:u,attrs:f}=t;if(!G.has(l))return s.error(`Tag "${l}" is not allowed`),"";if(l==="$comment"&&o.insideComment)return s.error("Nested comments are not allowed"),"";if(l==="$if"){const{valueToRender:a}=H(c,n,r,s,i);return a===void 0?"":p(a,n,o)}b.has(l)&&u.length>0&&s.warn(`Tag "${l}" is a void element and cannot have children`);const d={...o,insideComment:l==="$comment"||o.insideComment,level:(o.level||0)+1},k=a=>a===""?[]:o.indentStr&&a.includes(` +`,o};function Q(e,t={}){const o=e.data,r=t.logger||console,s=t.propertyFallback,i=t.indent?{indentStr:typeof t.indent=="number"?" ".repeat(t.indent):typeof t.indent=="string"?t.indent:" ",level:0,logger:r,getOuterProperty:s}:{logger:r,getOuterProperty:s};return p(e.template,o,i)}function X(e,t,o,r,s,i,n,l=[],c){const u=J(r,i),f=u.startsWith(` +`)&&i?i.repeat(n||0):"";if(e==="$comment")return``;const d=`<${e}${Z(t,o,e,l,s,c)}>`;return b.has(e)?d:`${d}${u}${f}`}function p(e,t,o){const r=o.parents||[],s=o.logger,i=o.getOuterProperty;if(typeof e=="string")return w(e,t,!0,r,s,i);if(Array.isArray(e))return e.map(a=>p(a,t,o)).join(o.indentStr?` +`:"");const n=Y(e,s);if(!n)return"";const{tag:l,rest:c,children:u,attrs:f}=n;if(!G.has(l))return s.error(`Tag "${l}" is not allowed`),"";if(l==="$comment"&&o.insideComment)return s.error("Nested comments are not allowed"),"";if(l==="$if"){const{valueToRender:a}=H(c,t,r,s,i);return a===void 0?"":p(a,t,o)}b.has(l)&&u.length>0&&s.warn(`Tag "${l}" is a void element and cannot have children`);const d={...o,insideComment:l==="$comment"||o.insideComment,level:(o.level||0)+1},C=a=>a===""?[]:o.indentStr&&a.includes(` `)&&!a.includes("<")?a.split(` -`).map(w=>[d.level,w]):[[d.level,a]];let $,g;if(F(c)){if(!A(c.$bind,"$bind",s))return"";const a=y(n,c.$bind,[],s,i),{$bind:w,$filter:O,$children:_=[],...D}=c;if(!Array.isArray(a)){if(a!=null&&typeof a!="object")return s.error(`$bind resolved to primitive value of type "${typeof a}", cannot render children`),"";const S=a&&typeof a=="object"&&a!==null?a:{},v=[...r,n];return p({[l]:{...D,$children:_}},S,{...o,parents:v})}let L=a;if(O&&V(O)&&(L=a.filter(S=>{const v=[...r,n];return M(S,O,v,s,i)})),$=[],!b.has(l))for(const S of L){const v=[...r,n];for(const x of _){const ee=p(x,S,{...d,parents:v});$.push(...k(ee))}}g=D}else{if($=[],!b.has(l))for(const a of u){const w=p(a,n,{...d,parents:r});$.push(...k(w))}g=f}return X(l,g,n,$,s,o.indentStr,o.level,r,i)}function Z(e,n,o,r=[],s,i){const t=Object.entries(e).filter(([l])=>B(l,o,s)).map(([l,c])=>{let u;if(l==="style"){if(u=q(c,n,r,s,i),!u)return null}else if(K(c)){const f=U(c,n,r,s,i);u=C(String(f),n,!1,r,s,i)}else u=C(String(c),n,!1,r,s,i);return`${l}="${I(u)}"`}).filter(l=>l!==null).join(" ");return t?" "+t:""}h.renderToString=Q,Object.defineProperty(h,Symbol.toStringTag,{value:"Module"})})); +`).map(T=>[d.level,T]):[[d.level,a]];let $,k;if(F(c)){if(!A(c.$bind,"$bind",s))return"";const a=y(t,c.$bind,[],s,i),{$bind:T,$filter:g,$children:_=[],...D}=c;if(!Array.isArray(a)){if(a!=null&&typeof a!="object")return s.error(`$bind resolved to primitive value of type "${typeof a}", cannot render children`),"";const S=a&&typeof a=="object"&&a!==null?a:{},v=[...r,t];return p({[l]:{...D,$children:_}},S,{...o,parents:v})}let L=a;if(g&&V(g)&&(L=a.filter(S=>{const v=[...r,t];return M(S,g,v,s,i)})),$=[],!b.has(l))for(const S of L){const v=[...r,t];for(const x of _){const ee=p(x,S,{...d,parents:v});$.push(...C(ee))}}k=D}else{if($=[],!b.has(l))for(const a of u){const T=p(a,t,{...d,parents:r});$.push(...C(T))}k=f}return X(l,k,t,$,s,o.indentStr,o.level,r,i)}function Z(e,t,o,r=[],s,i){const n=Object.entries(e).filter(([l])=>B(l,o,s)).map(([l,c])=>{let u;if(l==="style"){if(u=q(c,t,r,s,i),!u)return null}else if(K(c)){const f=U(c,t,r,s,i);u=w(String(f),t,!1,r,s,i)}else u=w(String(c),t,!1,r,s,i);return`${l}="${I(u)}"`}).filter(l=>l!==null).join(" ");return n?" "+n:""}h.renderToString=Q,Object.defineProperty(h,Symbol.toStringTag,{value:"Module"})})); //# sourceMappingURL=treebark-browser.min.js.map diff --git a/docs/js/playground.js b/docs/js/playground.js index 1e8bc49..cafe1db 100644 --- a/docs/js/playground.js +++ b/docs/js/playground.js @@ -1008,6 +1008,150 @@ ] } }; + const filterInStock = { + template: { + div: { + class: "product-inventory", + $children: [ + { h2: "Available Products (In Stock Only)" }, + { + div: { + $bind: "products", + $filter: { + $check: "inStock" + }, + $children: [ + { + div: { + class: "product-item", + $children: [ + { h3: "{{name}}" }, + { p: "Price: {{price}}" }, + { + p: { + style: { color: "green" }, + $children: ["✓ In Stock ({{quantity}} available)"] + } + } + ] + } + } + ] + } + } + ] + } + }, + data: { + products: [ + { name: "Laptop", price: "$999", inStock: true, quantity: 15 }, + { name: "Phone", price: "$499", inStock: false, quantity: 0 }, + { name: "Tablet", price: "$299", inStock: true, quantity: 8 } + ] + } + }; + const filterComparison = { + template: { + div: { + class: "comparison-demo", + $children: [ + { h1: "Filter vs If: Comparison" }, + // Old way: Using $if tags to conditionally display status + { + div: { + class: "method old-way", + $children: [ + { h2: "❌ Old Way: Using $if tags" }, + { p: "Shows all products with conditional status messages" }, + { + div: { + $bind: "products", + $children: [ + { + div: { + class: "product-item", + $children: [ + { h3: "{{name}}" }, + { p: "Price: {{price}}" }, + { + $if: { + $check: "inStock", + $then: { + p: { + style: { color: "green" }, + $children: ["✓ In Stock ({{quantity}} available)"] + } + } + } + }, + { + $if: { + $check: "inStock", + $not: true, + $then: { + p: { + style: { color: "red" }, + $children: ["✗ Out of Stock"] + } + } + } + } + ] + } + } + ] + } + } + ] + } + }, + { hr: {} }, + // New way: Using $filter to show only in-stock items + { + div: { + class: "method new-way", + $children: [ + { h2: "✅ New Way: Using $filter" }, + { p: "Shows only in-stock products (cleaner, simpler)" }, + { + div: { + $bind: "products", + $filter: { + $check: "inStock" + }, + $children: [ + { + div: { + class: "product-item", + $children: [ + { h3: "{{name}}" }, + { p: "Price: {{price}}" }, + { + p: { + style: { color: "green" }, + $children: ["✓ In Stock ({{quantity}} available)"] + } + } + ] + } + } + ] + } + } + ] + } + } + ] + } + }, + data: { + products: [ + { name: "Laptop", price: "$999", inStock: true, quantity: 15 }, + { name: "Phone", price: "$499", inStock: false, quantity: 0 }, + { name: "Tablet", price: "$299", inStock: true, quantity: 8 } + ] + } + }; const examples = { "Hello World": helloWorld, "Card Layout": cardLayout, @@ -1031,7 +1175,9 @@ "Calendar": calendar, "Filter By Price": filterByPrice, "Filter By Role": filterByRole, - "Filter Age Range": filterAgeRange + "Filter Age Range": filterAgeRange, + "Filter In Stock": filterInStock, + "Filter vs If Comparison": filterComparison }; let currentTemplateFormat = "json"; const templateEditor = document.getElementById("template-editor"); diff --git a/nodejs/packages/playground/src/examples/filter-comparison.ts b/nodejs/packages/playground/src/examples/filter-comparison.ts new file mode 100644 index 0000000..2b0cfdb --- /dev/null +++ b/nodejs/packages/playground/src/examples/filter-comparison.ts @@ -0,0 +1,107 @@ +import type { Example } from './types.js'; + +export const filterComparison: Example = { + template: { + div: { + class: "comparison-demo", + $children: [ + { h1: "Filter vs If: Comparison" }, + + // Old way: Using $if tags to conditionally display status + { + div: { + class: "method old-way", + $children: [ + { h2: "❌ Old Way: Using $if tags" }, + { p: "Shows all products with conditional status messages" }, + { + div: { + $bind: "products", + $children: [ + { + div: { + class: "product-item", + $children: [ + { h3: "{{name}}" }, + { p: "Price: {{price}}" }, + { + $if: { + $check: "inStock", + $then: { + p: { + style: { color: "green" }, + $children: ["✓ In Stock ({{quantity}} available)"] + } + } + } + }, + { + $if: { + $check: "inStock", + $not: true, + $then: { + p: { + style: { color: "red" }, + $children: ["✗ Out of Stock"] + } + } + } + } + ] + } + } + ] + } + } + ] + } + }, + + { hr: {} }, + + // New way: Using $filter to show only in-stock items + { + div: { + class: "method new-way", + $children: [ + { h2: "✅ New Way: Using $filter" }, + { p: "Shows only in-stock products (cleaner, simpler)" }, + { + div: { + $bind: "products", + $filter: { + $check: "inStock" + }, + $children: [ + { + div: { + class: "product-item", + $children: [ + { h3: "{{name}}" }, + { p: "Price: {{price}}" }, + { + p: { + style: { color: "green" }, + $children: ["✓ In Stock ({{quantity}} available)"] + } + } + ] + } + } + ] + } + } + ] + } + } + ] + } + }, + data: { + products: [ + { name: "Laptop", price: "$999", inStock: true, quantity: 15 }, + { name: "Phone", price: "$499", inStock: false, quantity: 0 }, + { name: "Tablet", price: "$299", inStock: true, quantity: 8 } + ] + } +}; diff --git a/nodejs/packages/playground/src/examples/filter-in-stock.ts b/nodejs/packages/playground/src/examples/filter-in-stock.ts new file mode 100644 index 0000000..a63203a --- /dev/null +++ b/nodejs/packages/playground/src/examples/filter-in-stock.ts @@ -0,0 +1,44 @@ +import type { Example } from './types.js'; + +export const filterInStock: Example = { + template: { + div: { + class: "product-inventory", + $children: [ + { h2: "Available Products (In Stock Only)" }, + { + div: { + $bind: "products", + $filter: { + $check: "inStock" + }, + $children: [ + { + div: { + class: "product-item", + $children: [ + { h3: "{{name}}" }, + { p: "Price: {{price}}" }, + { + p: { + style: { color: "green" }, + $children: ["✓ In Stock ({{quantity}} available)"] + } + } + ] + } + } + ] + } + } + ] + } + }, + data: { + products: [ + { name: "Laptop", price: "$999", inStock: true, quantity: 15 }, + { name: "Phone", price: "$499", inStock: false, quantity: 0 }, + { name: "Tablet", price: "$299", inStock: true, quantity: 8 } + ] + } +}; diff --git a/nodejs/packages/playground/src/examples/index.ts b/nodejs/packages/playground/src/examples/index.ts index 77959d5..d853e1d 100644 --- a/nodejs/packages/playground/src/examples/index.ts +++ b/nodejs/packages/playground/src/examples/index.ts @@ -22,6 +22,8 @@ import { calendar } from './calendar.js'; import { filterByPrice } from './filter-by-price.js'; import { filterByRole } from './filter-by-role.js'; import { filterAgeRange } from './filter-age-range.js'; +import { filterInStock } from './filter-in-stock.js'; +import { filterComparison } from './filter-comparison.js'; export type Examples = Record; @@ -48,5 +50,7 @@ export const examples: Examples = { 'Calendar': calendar, 'Filter By Price': filterByPrice, 'Filter By Role': filterByRole, - 'Filter Age Range': filterAgeRange + 'Filter Age Range': filterAgeRange, + 'Filter In Stock': filterInStock, + 'Filter vs If Comparison': filterComparison }; diff --git a/nodejs/packages/treebark/src/common.ts b/nodejs/packages/treebark/src/common.ts index 06c79d0..79aff2b 100644 --- a/nodejs/packages/treebark/src/common.ts +++ b/nodejs/packages/treebark/src/common.ts @@ -60,8 +60,6 @@ export const OPERATORS = new Set(['$<', '$>', '$<=', '$>=', '$=', '$in']); export const CONDITIONALKEYS = new Set(['$check', '$then', '$else', '$not', '$join', ...OPERATORS]); -export const FILTERKEYS = new Set(['$check', '$not', '$join', ...OPERATORS]); - // Blocked CSS properties that are known to be dangerous const BLOCKED_CSS_PROPERTIES = new Set([ 'behavior', // IE behavior property - can execute code From c4e287c6c35a3736c9c438f6fd9523e41502cbfe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 11 Dec 2025 23:44:15 +0000 Subject: [PATCH 05/24] Add $filter on databinding to filter arrays using conditional operators Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- README.md | 29 ++++++++++---------------- docs/assets/treebark-browser.js | 15 +++++++------ docs/assets/treebark-browser.min.js | 14 ++++++------- nodejs/packages/treebark/src/dom.ts | 18 +++++++--------- nodejs/packages/treebark/src/string.ts | 20 ++++++++---------- 5 files changed, 42 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index bb20c57..cb3a3d4 100644 --- a/README.md +++ b/README.md @@ -817,30 +817,23 @@ This filters for working-age adults (18-65 inclusive). **Practical example - Filter vs If:** -Instead of rendering all items with conditional status messages: +Instead of wrapping each item in an $if to conditionally render it: ```json { "div": { "$bind": "products", "$children": [ { - "div": { - "$children": [ - { "h3": "{{name}}" }, - { - "$if": { - "$check": "inStock", - "$then": { "p": "✓ In Stock" } - } - }, - { - "$if": { - "$check": "inStock", - "$not": true, - "$then": { "p": "✗ Out of Stock" } - } + "$if": { + "$check": "inStock", + "$then": { + "div": { + "$children": [ + { "h3": "{{name}}" }, + { "p": "✓ In Stock ({{quantity}} available)" } + ] } - ] + } } } ] @@ -870,7 +863,7 @@ Simply filter to show only in-stock items: } ``` -This is cleaner, simpler, and more efficient! +This is cleaner and more explicit about intent! ### Comments diff --git a/docs/assets/treebark-browser.js b/docs/assets/treebark-browser.js index e642e8b..4445a6b 100644 --- a/docs/assets/treebark-browser.js +++ b/docs/assets/treebark-browser.js @@ -415,16 +415,15 @@ const newParents = [...parents, data]; return render({ [tag]: { ...bindAttrs, $children } }, boundData, { ...context, parents: newParents }); } - let itemsToRender = bound; - if ($filter && isFilterCondition($filter)) { - itemsToRender = bound.filter((item) => { - const newParents = [...parents, data]; - return evaluateFilterCondition(item, $filter, newParents, logger, getOuterProperty); - }); - } childrenOutput = []; if (!VOID_TAGS.has(tag)) { - for (const item of itemsToRender) { + for (const item of bound) { + if ($filter && isFilterCondition($filter)) { + const newParents2 = [...parents, data]; + if (!evaluateFilterCondition(item, $filter, newParents2, logger, getOuterProperty)) { + continue; + } + } const newParents = [...parents, data]; for (const child of $children) { const content = render(child, item, { ...childContext, parents: newParents }); diff --git a/docs/assets/treebark-browser.min.js b/docs/assets/treebark-browser.min.js index 928dfc6..28c22fb 100644 --- a/docs/assets/treebark-browser.min.js +++ b/docs/assets/treebark-browser.min.js @@ -1,9 +1,9 @@ -(function(h,m){typeof exports=="object"&&typeof module<"u"?m(exports):typeof define=="function"&&define.amd?define(["exports"],m):(h=typeof globalThis<"u"?globalThis:h||self,m(h.Treebark={}))})(this,(function(h){"use strict";const m=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"]),W=new Set(["$comment","$if"]),b=new Set(["img","br","hr"]),G=new Set([...m,...W,...b]),O=new Set(["id","class","style","title","role","data-","aria-"]),z={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"])},R=new Set(["$<","$>","$<=","$>=","$=","$in"]),E=new Set(["$check","$then","$else","$not","$join",...R]),N=new Set(["behavior","-moz-binding"]);function y(e,t,o=[],r,s){if(t===".")return e;let i=e,n=t;for(;n.startsWith("..");){let l=0,c=n;for(;c.startsWith("..");)l++,c=c.substring(2),c.startsWith("/")&&(c=c.substring(1));if(l<=o.length)i=o[o.length-l],n=c.startsWith(".")?c.substring(1):c;else return s?s(t,e,o):void 0}if(n){if(r&&typeof i!="object"&&i!==null&&i!==void 0){r.error(`Cannot access property "${n}" on primitive value of type "${typeof i}"`);return}const l=n.split(".").reduce((c,u)=>c&&typeof c=="object"&&c!==null?c[u]:void 0,i);return l===void 0&&s?s(t,e,o):l}return i}function I(e){return e.replace(/[&<>"']/g,t=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[t]||t)}function w(e,t,o=!0,r=[],s,i){return e.replace(/\{\{\{([^{]*?)\}\}\}|\{\{([^{]*?)\}\}/g,(n,l,c)=>{if(l!==void 0)return`{{${l.trim()}}}`;const u=c.trim(),f=y(t,u,r,s,i);return f==null?"":o?I(String(f)):String(f)})}function P(e,t){const o=[];for(const[r,s]of Object.entries(e)){const i=r;if(!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(i)){t.warn(`CSS property "${r}" has invalid format (must be kebab-case)`);continue}if(N.has(i)){t.warn(`CSS property "${r}" is blocked for security reasons`);continue}if(s==null)continue;let n=String(s).trim();if(n.includes(";")){const u=n;n=n.split(";")[0].trim(),n&&n!==u.trim()&&t.warn(`CSS value for "${r}" contained semicolon - using only first part: "${n}"`)}if(!n)continue;const l=/url\s*\(/i.test(n),c=/url\s*\(\s*['"]?data:/i.test(n);if(l&&!c||/expression\s*\(/i.test(n)||/javascript:/i.test(n)||/@import/i.test(n)){t.warn(`CSS value for "${r}" contains potentially dangerous pattern: "${n}"`);continue}o.push(`${i}: ${n}`)}return o.join("; ").trim()}function q(e,t,o,r,s){if(e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"){const i=e;if(!A(i.$check,"$check",r))return"";const n=y(t,i.$check,o,r,s),c=j(n,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 B(e,t,o){const r=O.has(e)||[...O].some(n=>n.endsWith("-")&&e.startsWith(n)),s=z[t],i=s&&s.has(e);return!r&&!i?(o.warn(`Attribute "${e}" is not allowed on tag "${t}"`),!1):!0}function F(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$bind"in e}function A(e,t,o){return e==="."?!0:e.includes("..")?(o.error(`${t} does not support parent context access (..) - use interpolation {{..prop}} in content/attributes instead. Invalid: ${t}: "${e}"`),!1):e.includes("{{")?(o.error(`${t} does not support interpolation {{...}} - use literal property paths only. Invalid: ${t}: "${e}"`),!1):!0}function j(e,t){const o=[];for(const n of R)n in t&&o.push({key:n,value:t[n]});if(o.length===0){const n=!!e;return t.$not?!n:n}const r=o.map(n=>{switch(n.key){case"$<":return typeof e=="number"&&typeof n.value=="number"&&e":return typeof e=="number"&&typeof n.value=="number"&&e>n.value;case"$<=":return typeof e=="number"&&typeof n.value=="number"&&e<=n.value;case"$>=":return typeof e=="number"&&typeof n.value=="number"&&e>=n.value;case"$=":return e===n.value;case"$in":return Array.isArray(n.value)&&n.value.includes(e);default:return!1}}),s=t.$join==="OR";let i;return s?i=r.some(n=>n):i=r.every(n=>n),t.$not?!i:i}function K(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function U(e,t,o=[],r,s){if(!A(e.$check,"$check",r))return"";const i=y(t,e.$check,o,r,s);return j(i,e)?e.$then!==void 0?e.$then:"":e.$else!==void 0?e.$else:""}function V(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function M(e,t,o=[],r,s){if(!A(t.$check,"$check",r))return!1;const i=y(e,t.$check,o,r,s);return j(i,t)}function Y(e,t){if(!e||typeof e!="object"){t.error("Template object cannot be null, undefined, or non-object");return}const o=Object.entries(e);if(o.length===0){t.error("Template object must have at least one tag");return}const r=o[0];if(!r){t.error("Template object must have at least one tag");return}const[s,i]=r,n=typeof i=="string"?[i]:Array.isArray(i)?i:i?.$children||[],l=i&&typeof i=="object"&&!Array.isArray(i)?Object.fromEntries(Object.entries(i).filter(([c])=>c!=="$children")):{};return{tag:s,rest:i,children:n,attrs:l}}function H(e,t,o=[],r,s){const i=e;if(!i.$check)return r.error('"$if" tag requires $check attribute to specify the condition'),{valueToRender:void 0};if(!A(i.$check,"$check",r))return{valueToRender:void 0};const n=y(t,i.$check,o,r,s);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:l,$else:c}=i;if(l!==void 0&&Array.isArray(l))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 f=(typeof e=="object"&&e!==null&&!Array.isArray(e)?Object.keys(e):[]).filter($=>!E.has($));return f.length>0&&r.warn(`"$if" tag does not support attributes: ${f.join(", ")}. Allowed: ${[...E].join(", ")}`),{valueToRender:j(n,i)?l:c}}const J=(e,t)=>{if(!t)return e.length<=1?e[0]?.[1]??"":e.reduce((r,[,s])=>r+s,"");if(e.length===0)return"";if(e.length===1&&!e[0][1].includes("<"))return e[0][1];let o=` -`;for(let r=0;r","$<=","$>=","$=","$in"]),I=new Set(["$check","$then","$else","$not","$join",...E]),N=new Set(["behavior","-moz-binding"]);function y(e,n,o=[],r,s){if(n===".")return e;let i=e,t=n;for(;t.startsWith("..");){let a=0,c=t;for(;c.startsWith("..");)a++,c=c.substring(2),c.startsWith("/")&&(c=c.substring(1));if(a<=o.length)i=o[o.length-a],t=c.startsWith(".")?c.substring(1):c;else return s?s(n,e,o):void 0}if(t){if(r&&typeof i!="object"&&i!==null&&i!==void 0){r.error(`Cannot access property "${t}" on primitive value of type "${typeof i}"`);return}const a=t.split(".").reduce((c,u)=>c&&typeof c=="object"&&c!==null?c[u]:void 0,i);return a===void 0&&s?s(n,e,o):a}return i}function P(e){return e.replace(/[&<>"']/g,n=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[n]||n)}function w(e,n,o=!0,r=[],s,i){return e.replace(/\{\{\{([^{]*?)\}\}\}|\{\{([^{]*?)\}\}/g,(t,a,c)=>{if(a!==void 0)return`{{${a.trim()}}}`;const u=c.trim(),f=y(n,u,r,s,i);return f==null?"":o?P(String(f)):String(f)})}function _(e,n){const o=[];for(const[r,s]of Object.entries(e)){const i=r;if(!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(i)){n.warn(`CSS property "${r}" has invalid format (must be kebab-case)`);continue}if(N.has(i)){n.warn(`CSS property "${r}" is blocked for security reasons`);continue}if(s==null)continue;let t=String(s).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(`${i}: ${t}`)}return o.join("; ").trim()}function q(e,n,o,r,s){if(e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"){const i=e;if(!A(i.$check,"$check",r))return"";const t=y(n,i.$check,o,r,s),c=S(t,i)?i.$then:i.$else;return c===void 0?"":typeof c=="object"&&c!==null&&!Array.isArray(c)?_(c,r):""}return typeof e=="object"&&e!==null&&!Array.isArray(e)?_(e,r):(r.error(`Style attribute must be an object with CSS properties, not ${typeof e}. Example: style: { "color": "red", "font-size": "14px" }`),"")}function B(e,n,o){const r=R.has(e)||[...R].some(t=>t.endsWith("-")&&e.startsWith(t)),s=z[n],i=s&&s.has(e);return!r&&!i?(o.warn(`Attribute "${e}" is not allowed on tag "${n}"`),!1):!0}function F(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$bind"in e}function A(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 S(e,n){const o=[];for(const t of E)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}}),s=n.$join==="OR";let i;return s?i=r.some(t=>t):i=r.every(t=>t),n.$not?!i:i}function K(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function U(e,n,o=[],r,s){if(!A(e.$check,"$check",r))return"";const i=y(n,e.$check,o,r,s);return S(i,e)?e.$then!==void 0?e.$then:"":e.$else!==void 0?e.$else:""}function V(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function M(e,n,o=[],r,s){if(!A(n.$check,"$check",r))return!1;const i=y(e,n.$check,o,r,s);return S(i,n)}function Y(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[s,i]=r,t=typeof i=="string"?[i]:Array.isArray(i)?i:i?.$children||[],a=i&&typeof i=="object"&&!Array.isArray(i)?Object.fromEntries(Object.entries(i).filter(([c])=>c!=="$children")):{};return{tag:s,rest:i,children:t,attrs:a}}function H(e,n,o=[],r,s){const i=e;if(!i.$check)return r.error('"$if" tag requires $check attribute to specify the condition'),{valueToRender:void 0};if(!A(i.$check,"$check",r))return{valueToRender:void 0};const t=y(n,i.$check,o,r,s);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:a,$else:c}=i;if(a!==void 0&&Array.isArray(a))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 f=(typeof e=="object"&&e!==null&&!Array.isArray(e)?Object.keys(e):[]).filter($=>!I.has($));return f.length>0&&r.warn(`"$if" tag does not support attributes: ${f.join(", ")}. Allowed: ${[...I].join(", ")}`),{valueToRender:S(t,i)?a:c}}const J=(e,n)=>{if(!n)return e.length<=1?e[0]?.[1]??"":e.reduce((r,[,s])=>r+s,"");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 d=`<${e}${Z(t,o,e,l,s,c)}>`;return b.has(e)?d:`${d}${u}${f}`}function p(e,t,o){const r=o.parents||[],s=o.logger,i=o.getOuterProperty;if(typeof e=="string")return w(e,t,!0,r,s,i);if(Array.isArray(e))return e.map(a=>p(a,t,o)).join(o.indentStr?` -`:"");const n=Y(e,s);if(!n)return"";const{tag:l,rest:c,children:u,attrs:f}=n;if(!G.has(l))return s.error(`Tag "${l}" is not allowed`),"";if(l==="$comment"&&o.insideComment)return s.error("Nested comments are not allowed"),"";if(l==="$if"){const{valueToRender:a}=H(c,t,r,s,i);return a===void 0?"":p(a,t,o)}b.has(l)&&u.length>0&&s.warn(`Tag "${l}" is a void element and cannot have children`);const d={...o,insideComment:l==="$comment"||o.insideComment,level:(o.level||0)+1},C=a=>a===""?[]:o.indentStr&&a.includes(` -`)&&!a.includes("<")?a.split(` -`).map(T=>[d.level,T]):[[d.level,a]];let $,k;if(F(c)){if(!A(c.$bind,"$bind",s))return"";const a=y(t,c.$bind,[],s,i),{$bind:T,$filter:g,$children:_=[],...D}=c;if(!Array.isArray(a)){if(a!=null&&typeof a!="object")return s.error(`$bind resolved to primitive value of type "${typeof a}", cannot render children`),"";const S=a&&typeof a=="object"&&a!==null?a:{},v=[...r,t];return p({[l]:{...D,$children:_}},S,{...o,parents:v})}let L=a;if(g&&V(g)&&(L=a.filter(S=>{const v=[...r,t];return M(S,g,v,s,i)})),$=[],!b.has(l))for(const S of L){const v=[...r,t];for(const x of _){const ee=p(x,S,{...d,parents:v});$.push(...C(ee))}}k=D}else{if($=[],!b.has(l))for(const a of u){const T=p(a,t,{...d,parents:r});$.push(...C(T))}k=f}return X(l,k,t,$,s,o.indentStr,o.level,r,i)}function Z(e,t,o,r=[],s,i){const n=Object.entries(e).filter(([l])=>B(l,o,s)).map(([l,c])=>{let u;if(l==="style"){if(u=q(c,t,r,s,i),!u)return null}else if(K(c)){const f=U(c,t,r,s,i);u=w(String(f),t,!1,r,s,i)}else u=w(String(c),t,!1,r,s,i);return`${l}="${I(u)}"`}).filter(l=>l!==null).join(" ");return n?" "+n:""}h.renderToString=Q,Object.defineProperty(h,Symbol.toStringTag,{value:"Module"})})); +`,o};function Q(e,n={}){const o=e.data,r=n.logger||console,s=n.propertyFallback,i=n.indent?{indentStr:typeof n.indent=="number"?" ".repeat(n.indent):typeof n.indent=="string"?n.indent:" ",level:0,logger:r,getOuterProperty:s}:{logger:r,getOuterProperty:s};return p(e.template,o,i)}function X(e,n,o,r,s,i,t,a=[],c){const u=J(r,i),f=u.startsWith(` +`)&&i?i.repeat(t||0):"";if(e==="$comment")return``;const d=`<${e}${Z(n,o,e,a,s,c)}>`;return m.has(e)?d:`${d}${u}${f}`}function p(e,n,o){const r=o.parents||[],s=o.logger,i=o.getOuterProperty;if(typeof e=="string")return w(e,n,!0,r,s,i);if(Array.isArray(e))return e.map(l=>p(l,n,o)).join(o.indentStr?` +`:"");const t=Y(e,s);if(!t)return"";const{tag:a,rest:c,children:u,attrs:f}=t;if(!G.has(a))return s.error(`Tag "${a}" is not allowed`),"";if(a==="$comment"&&o.insideComment)return s.error("Nested comments are not allowed"),"";if(a==="$if"){const{valueToRender:l}=H(c,n,r,s,i);return l===void 0?"":p(l,n,o)}m.has(a)&&u.length>0&&s.warn(`Tag "${a}" is a void element and cannot have children`);const d={...o,insideComment:a==="$comment"||o.insideComment,level:(o.level||0)+1},T=l=>l===""?[]:o.indentStr&&l.includes(` +`)&&!l.includes("<")?l.split(` +`).map(v=>[d.level,v]):[[d.level,l]];let $,C;if(F(c)){if(!A(c.$bind,"$bind",s))return"";const l=y(n,c.$bind,[],s,i),{$bind:v,$filter:k,$children:D=[],...L}=c;if(!Array.isArray(l)){if(l!=null&&typeof l!="object")return s.error(`$bind resolved to primitive value of type "${typeof l}", cannot render children`),"";const j=l&&typeof l=="object"&&l!==null?l:{},g=[...r,n];return p({[a]:{...L,$children:D}},j,{...o,parents:g})}if($=[],!m.has(a))for(const j of l){if(k&&V(k)){const O=[...r,n];if(!M(j,k,O,s,i))continue}const g=[...r,n];for(const O of D){const x=p(O,j,{...d,parents:g});$.push(...T(x))}}C=L}else{if($=[],!m.has(a))for(const l of u){const v=p(l,n,{...d,parents:r});$.push(...T(v))}C=f}return X(a,C,n,$,s,o.indentStr,o.level,r,i)}function Z(e,n,o,r=[],s,i){const t=Object.entries(e).filter(([a])=>B(a,o,s)).map(([a,c])=>{let u;if(a==="style"){if(u=q(c,n,r,s,i),!u)return null}else if(K(c)){const f=U(c,n,r,s,i);u=w(String(f),n,!1,r,s,i)}else u=w(String(c),n,!1,r,s,i);return`${a}="${P(u)}"`}).filter(a=>a!==null).join(" ");return t?" "+t:""}h.renderToString=Q,Object.defineProperty(h,Symbol.toStringTag,{value:"Module"})})); //# sourceMappingURL=treebark-browser.min.js.map diff --git a/nodejs/packages/treebark/src/dom.ts b/nodejs/packages/treebark/src/dom.ts index 6f84140..2489584 100644 --- a/nodejs/packages/treebark/src/dom.ts +++ b/nodejs/packages/treebark/src/dom.ts @@ -135,17 +135,15 @@ function render(template: TemplateElement | TemplateElement[], data: Data, conte } if (Array.isArray(bound)) { - // Apply $filter if present - let itemsToRender = bound; - - if ($filter && isFilterCondition($filter)) { - itemsToRender = bound.filter(item => { + for (const item of bound) { + // Apply $filter if present - skip items that don't match + if ($filter && isFilterCondition($filter)) { const newParents = [...parents, data]; - return evaluateFilterCondition(item as Data, $filter, newParents, logger, getOuterProperty); - }); - } - - for (const item of itemsToRender) { + if (!evaluateFilterCondition(item as Data, $filter, newParents, logger, getOuterProperty)) { + continue; + } + } + // For array items, add current data context to parents const newParents = [...parents, data]; // Skip children for void tags diff --git a/nodejs/packages/treebark/src/string.ts b/nodejs/packages/treebark/src/string.ts index 6aacf12..f9c36c8 100644 --- a/nodejs/packages/treebark/src/string.ts +++ b/nodejs/packages/treebark/src/string.ts @@ -177,20 +177,18 @@ function render(template: TemplateElement | TemplateElement[], data: Data, conte } // Array binding case - let itemsToRender = bound; - - // Apply $filter if present - if ($filter && isFilterCondition($filter)) { - itemsToRender = bound.filter(item => { - const newParents = [...parents, data]; - return evaluateFilterCondition(item as Data, $filter, newParents, logger, getOuterProperty); - }); - } - childrenOutput = []; // Skip children for void tags if (!VOID_TAGS.has(tag)) { - for (const item of itemsToRender) { + for (const item of bound) { + // Apply $filter if present - skip items that don't match + if ($filter && isFilterCondition($filter)) { + const newParents = [...parents, data]; + if (!evaluateFilterCondition(item as Data, $filter, newParents, logger, getOuterProperty)) { + continue; + } + } + const newParents = [...parents, data]; for (const child of $children) { const content = render(child, item as Data, { ...childContext, parents: newParents }); From 346786cdd9e2d48ef0f4c0794c3e5b095342c2f5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 00:01:13 +0000 Subject: [PATCH 06/24] Refactor: use FilterCondition as base type, fix newParents scope, move filter tests to common-tests Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- docs/assets/treebark-browser.js | 5 +- docs/assets/treebark-browser.min.js | 12 +- nodejs/packages/test/package.json | 22 -- nodejs/packages/test/src/common-tests.ts | 242 ++++++++++++++++ nodejs/packages/test/src/dom.test.ts | 46 ++++ nodejs/packages/test/src/filter.test.ts | 335 ----------------------- nodejs/packages/test/src/string.test.ts | 43 +++ nodejs/packages/treebark/src/dom.ts | 6 +- nodejs/packages/treebark/src/string.ts | 4 +- nodejs/packages/treebark/src/types.ts | 30 +- 10 files changed, 354 insertions(+), 391 deletions(-) delete mode 100644 nodejs/packages/test/src/filter.test.ts diff --git a/docs/assets/treebark-browser.js b/docs/assets/treebark-browser.js index 4445a6b..2bd2238 100644 --- a/docs/assets/treebark-browser.js +++ b/docs/assets/treebark-browser.js @@ -418,13 +418,12 @@ childrenOutput = []; if (!VOID_TAGS.has(tag)) { for (const item of bound) { + const newParents = [...parents, data]; if ($filter && isFilterCondition($filter)) { - const newParents2 = [...parents, data]; - if (!evaluateFilterCondition(item, $filter, newParents2, logger, getOuterProperty)) { + if (!evaluateFilterCondition(item, $filter, newParents, logger, getOuterProperty)) { continue; } } - const newParents = [...parents, data]; for (const child of $children) { const content = render(child, item, { ...childContext, parents: newParents }); childrenOutput.push(...processContent(content)); diff --git a/docs/assets/treebark-browser.min.js b/docs/assets/treebark-browser.min.js index 28c22fb..fd51458 100644 --- a/docs/assets/treebark-browser.min.js +++ b/docs/assets/treebark-browser.min.js @@ -1,9 +1,9 @@ -(function(h,b){typeof exports=="object"&&typeof module<"u"?b(exports):typeof define=="function"&&define.amd?define(["exports"],b):(h=typeof globalThis<"u"?globalThis:h||self,b(h.Treebark={}))})(this,(function(h){"use strict";const b=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"]),W=new Set(["$comment","$if"]),m=new Set(["img","br","hr"]),G=new Set([...b,...W,...m]),R=new Set(["id","class","style","title","role","data-","aria-"]),z={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"])},E=new Set(["$<","$>","$<=","$>=","$=","$in"]),I=new Set(["$check","$then","$else","$not","$join",...E]),N=new Set(["behavior","-moz-binding"]);function y(e,n,o=[],r,s){if(n===".")return e;let i=e,t=n;for(;t.startsWith("..");){let a=0,c=t;for(;c.startsWith("..");)a++,c=c.substring(2),c.startsWith("/")&&(c=c.substring(1));if(a<=o.length)i=o[o.length-a],t=c.startsWith(".")?c.substring(1):c;else return s?s(n,e,o):void 0}if(t){if(r&&typeof i!="object"&&i!==null&&i!==void 0){r.error(`Cannot access property "${t}" on primitive value of type "${typeof i}"`);return}const a=t.split(".").reduce((c,u)=>c&&typeof c=="object"&&c!==null?c[u]:void 0,i);return a===void 0&&s?s(n,e,o):a}return i}function P(e){return e.replace(/[&<>"']/g,n=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[n]||n)}function w(e,n,o=!0,r=[],s,i){return e.replace(/\{\{\{([^{]*?)\}\}\}|\{\{([^{]*?)\}\}/g,(t,a,c)=>{if(a!==void 0)return`{{${a.trim()}}}`;const u=c.trim(),f=y(n,u,r,s,i);return f==null?"":o?P(String(f)):String(f)})}function _(e,n){const o=[];for(const[r,s]of Object.entries(e)){const i=r;if(!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(i)){n.warn(`CSS property "${r}" has invalid format (must be kebab-case)`);continue}if(N.has(i)){n.warn(`CSS property "${r}" is blocked for security reasons`);continue}if(s==null)continue;let t=String(s).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(`${i}: ${t}`)}return o.join("; ").trim()}function q(e,n,o,r,s){if(e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"){const i=e;if(!A(i.$check,"$check",r))return"";const t=y(n,i.$check,o,r,s),c=S(t,i)?i.$then:i.$else;return c===void 0?"":typeof c=="object"&&c!==null&&!Array.isArray(c)?_(c,r):""}return typeof e=="object"&&e!==null&&!Array.isArray(e)?_(e,r):(r.error(`Style attribute must be an object with CSS properties, not ${typeof e}. Example: style: { "color": "red", "font-size": "14px" }`),"")}function B(e,n,o){const r=R.has(e)||[...R].some(t=>t.endsWith("-")&&e.startsWith(t)),s=z[n],i=s&&s.has(e);return!r&&!i?(o.warn(`Attribute "${e}" is not allowed on tag "${n}"`),!1):!0}function F(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$bind"in e}function A(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 S(e,n){const o=[];for(const t of E)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}}),s=n.$join==="OR";let i;return s?i=r.some(t=>t):i=r.every(t=>t),n.$not?!i:i}function K(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function U(e,n,o=[],r,s){if(!A(e.$check,"$check",r))return"";const i=y(n,e.$check,o,r,s);return S(i,e)?e.$then!==void 0?e.$then:"":e.$else!==void 0?e.$else:""}function V(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function M(e,n,o=[],r,s){if(!A(n.$check,"$check",r))return!1;const i=y(e,n.$check,o,r,s);return S(i,n)}function Y(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[s,i]=r,t=typeof i=="string"?[i]:Array.isArray(i)?i:i?.$children||[],a=i&&typeof i=="object"&&!Array.isArray(i)?Object.fromEntries(Object.entries(i).filter(([c])=>c!=="$children")):{};return{tag:s,rest:i,children:t,attrs:a}}function H(e,n,o=[],r,s){const i=e;if(!i.$check)return r.error('"$if" tag requires $check attribute to specify the condition'),{valueToRender:void 0};if(!A(i.$check,"$check",r))return{valueToRender:void 0};const t=y(n,i.$check,o,r,s);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:a,$else:c}=i;if(a!==void 0&&Array.isArray(a))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 f=(typeof e=="object"&&e!==null&&!Array.isArray(e)?Object.keys(e):[]).filter($=>!I.has($));return f.length>0&&r.warn(`"$if" tag does not support attributes: ${f.join(", ")}. Allowed: ${[...I].join(", ")}`),{valueToRender:S(t,i)?a:c}}const J=(e,n)=>{if(!n)return e.length<=1?e[0]?.[1]??"":e.reduce((r,[,s])=>r+s,"");if(e.length===0)return"";if(e.length===1&&!e[0][1].includes("<"))return e[0][1];let o=` -`;for(let r=0;r","$<=","$>=","$=","$in"]),E=new Set(["$check","$then","$else","$not","$join",...R]),z=new Set(["behavior","-moz-binding"]);function y(e,n,o=[],i,s){if(n===".")return e;let r=e,t=n;for(;t.startsWith("..");){let a=0,c=t;for(;c.startsWith("..");)a++,c=c.substring(2),c.startsWith("/")&&(c=c.substring(1));if(a<=o.length)r=o[o.length-a],t=c.startsWith(".")?c.substring(1):c;else return s?s(n,e,o):void 0}if(t){if(i&&typeof r!="object"&&r!==null&&r!==void 0){i.error(`Cannot access property "${t}" on primitive value of type "${typeof r}"`);return}const a=t.split(".").reduce((c,u)=>c&&typeof c=="object"&&c!==null?c[u]:void 0,r);return a===void 0&&s?s(n,e,o):a}return r}function I(e){return e.replace(/[&<>"']/g,n=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[n]||n)}function w(e,n,o=!0,i=[],s,r){return e.replace(/\{\{\{([^{]*?)\}\}\}|\{\{([^{]*?)\}\}/g,(t,a,c)=>{if(a!==void 0)return`{{${a.trim()}}}`;const u=c.trim(),f=y(n,u,i,s,r);return f==null?"":o?I(String(f)):String(f)})}function P(e,n){const o=[];for(const[i,s]of Object.entries(e)){const r=i;if(!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(r)){n.warn(`CSS property "${i}" has invalid format (must be kebab-case)`);continue}if(z.has(r)){n.warn(`CSS property "${i}" is blocked for security reasons`);continue}if(s==null)continue;let t=String(s).trim();if(t.includes(";")){const u=t;t=t.split(";")[0].trim(),t&&t!==u.trim()&&n.warn(`CSS value for "${i}" 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 "${i}" contains potentially dangerous pattern: "${t}"`);continue}o.push(`${r}: ${t}`)}return o.join("; ").trim()}function N(e,n,o,i,s){if(e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"){const r=e;if(!A(r.$check,"$check",i))return"";const t=y(n,r.$check,o,i,s),c=S(t,r)?r.$then:r.$else;return c===void 0?"":typeof c=="object"&&c!==null&&!Array.isArray(c)?P(c,i):""}return typeof e=="object"&&e!==null&&!Array.isArray(e)?P(e,i):(i.error(`Style attribute must be an object with CSS properties, not ${typeof e}. Example: style: { "color": "red", "font-size": "14px" }`),"")}function q(e,n,o){const i=O.has(e)||[...O].some(t=>t.endsWith("-")&&e.startsWith(t)),s=G[n],r=s&&s.has(e);return!i&&!r?(o.warn(`Attribute "${e}" is not allowed on tag "${n}"`),!1):!0}function B(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$bind"in e}function A(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 S(e,n){const o=[];for(const t of R)t in n&&o.push({key:t,value:n[t]});if(o.length===0){const t=!!e;return n.$not?!t:t}const i=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}}),s=n.$join==="OR";let r;return s?r=i.some(t=>t):r=i.every(t=>t),n.$not?!r:r}function F(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function K(e,n,o=[],i,s){if(!A(e.$check,"$check",i))return"";const r=y(n,e.$check,o,i,s);return S(r,e)?e.$then!==void 0?e.$then:"":e.$else!==void 0?e.$else:""}function U(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function V(e,n,o=[],i,s){if(!A(n.$check,"$check",i))return!1;const r=y(e,n.$check,o,i,s);return S(r,n)}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 i=o[0];if(!i){n.error("Template object must have at least one tag");return}const[s,r]=i,t=typeof r=="string"?[r]:Array.isArray(r)?r:r?.$children||[],a=r&&typeof r=="object"&&!Array.isArray(r)?Object.fromEntries(Object.entries(r).filter(([c])=>c!=="$children")):{};return{tag:s,rest:r,children:t,attrs:a}}function Y(e,n,o=[],i,s){const r=e;if(!r.$check)return i.error('"$if" tag requires $check attribute to specify the condition'),{valueToRender:void 0};if(!A(r.$check,"$check",i))return{valueToRender:void 0};const t=y(n,r.$check,o,i,s);typeof e=="object"&&e!==null&&!Array.isArray(e)&&"$children"in e&&i.warn('"$if" tag does not support $children, use $then and $else instead');const{$then:a,$else:c}=r;if(a!==void 0&&Array.isArray(a))return i.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 i.error('"$if" tag $else must be a string or single element object, not an array'),{valueToRender:void 0};const f=(typeof e=="object"&&e!==null&&!Array.isArray(e)?Object.keys(e):[]).filter($=>!E.has($));return f.length>0&&i.warn(`"$if" tag does not support attributes: ${f.join(", ")}. Allowed: ${[...E].join(", ")}`),{valueToRender:S(t,r)?a:c}}const H=(e,n)=>{if(!n)return e.length<=1?e[0]?.[1]??"":e.reduce((i,[,s])=>i+s,"");if(e.length===0)return"";if(e.length===1&&!e[0][1].includes("<"))return e[0][1];let o=` +`;for(let i=0;i`;const d=`<${e}${Z(n,o,e,a,s,c)}>`;return m.has(e)?d:`${d}${u}${f}`}function p(e,n,o){const r=o.parents||[],s=o.logger,i=o.getOuterProperty;if(typeof e=="string")return w(e,n,!0,r,s,i);if(Array.isArray(e))return e.map(l=>p(l,n,o)).join(o.indentStr?` -`:"");const t=Y(e,s);if(!t)return"";const{tag:a,rest:c,children:u,attrs:f}=t;if(!G.has(a))return s.error(`Tag "${a}" is not allowed`),"";if(a==="$comment"&&o.insideComment)return s.error("Nested comments are not allowed"),"";if(a==="$if"){const{valueToRender:l}=H(c,n,r,s,i);return l===void 0?"":p(l,n,o)}m.has(a)&&u.length>0&&s.warn(`Tag "${a}" is a void element and cannot have children`);const d={...o,insideComment:a==="$comment"||o.insideComment,level:(o.level||0)+1},T=l=>l===""?[]:o.indentStr&&l.includes(` +`,o};function J(e,n={}){const o=e.data,i=n.logger||console,s=n.propertyFallback,r=n.indent?{indentStr:typeof n.indent=="number"?" ".repeat(n.indent):typeof n.indent=="string"?n.indent:" ",level:0,logger:i,getOuterProperty:s}:{logger:i,getOuterProperty:s};return p(e.template,o,r)}function Q(e,n,o,i,s,r,t,a=[],c){const u=H(i,r),f=u.startsWith(` +`)&&r?r.repeat(t||0):"";if(e==="$comment")return``;const d=`<${e}${X(n,o,e,a,s,c)}>`;return m.has(e)?d:`${d}${u}${f}`}function p(e,n,o){const i=o.parents||[],s=o.logger,r=o.getOuterProperty;if(typeof e=="string")return w(e,n,!0,i,s,r);if(Array.isArray(e))return e.map(l=>p(l,n,o)).join(o.indentStr?` +`:"");const t=M(e,s);if(!t)return"";const{tag:a,rest:c,children:u,attrs:f}=t;if(!W.has(a))return s.error(`Tag "${a}" is not allowed`),"";if(a==="$comment"&&o.insideComment)return s.error("Nested comments are not allowed"),"";if(a==="$if"){const{valueToRender:l}=Y(c,n,i,s,r);return l===void 0?"":p(l,n,o)}m.has(a)&&u.length>0&&s.warn(`Tag "${a}" is a void element and cannot have children`);const d={...o,insideComment:a==="$comment"||o.insideComment,level:(o.level||0)+1},C=l=>l===""?[]:o.indentStr&&l.includes(` `)&&!l.includes("<")?l.split(` -`).map(v=>[d.level,v]):[[d.level,l]];let $,C;if(F(c)){if(!A(c.$bind,"$bind",s))return"";const l=y(n,c.$bind,[],s,i),{$bind:v,$filter:k,$children:D=[],...L}=c;if(!Array.isArray(l)){if(l!=null&&typeof l!="object")return s.error(`$bind resolved to primitive value of type "${typeof l}", cannot render children`),"";const j=l&&typeof l=="object"&&l!==null?l:{},g=[...r,n];return p({[a]:{...L,$children:D}},j,{...o,parents:g})}if($=[],!m.has(a))for(const j of l){if(k&&V(k)){const O=[...r,n];if(!M(j,k,O,s,i))continue}const g=[...r,n];for(const O of D){const x=p(O,j,{...d,parents:g});$.push(...T(x))}}C=L}else{if($=[],!m.has(a))for(const l of u){const v=p(l,n,{...d,parents:r});$.push(...T(v))}C=f}return X(a,C,n,$,s,o.indentStr,o.level,r,i)}function Z(e,n,o,r=[],s,i){const t=Object.entries(e).filter(([a])=>B(a,o,s)).map(([a,c])=>{let u;if(a==="style"){if(u=q(c,n,r,s,i),!u)return null}else if(K(c)){const f=U(c,n,r,s,i);u=w(String(f),n,!1,r,s,i)}else u=w(String(c),n,!1,r,s,i);return`${a}="${P(u)}"`}).filter(a=>a!==null).join(" ");return t?" "+t:""}h.renderToString=Q,Object.defineProperty(h,Symbol.toStringTag,{value:"Module"})})); +`).map(v=>[d.level,v]):[[d.level,l]];let $,k;if(B(c)){if(!A(c.$bind,"$bind",s))return"";const l=y(n,c.$bind,[],s,r),{$bind:v,$filter:g,$children:_=[],...D}=c;if(!Array.isArray(l)){if(l!=null&&typeof l!="object")return s.error(`$bind resolved to primitive value of type "${typeof l}", cannot render children`),"";const j=l&&typeof l=="object"&&l!==null?l:{},T=[...i,n];return p({[a]:{...D,$children:_}},j,{...o,parents:T})}if($=[],!m.has(a))for(const j of l){const T=[...i,n];if(!(g&&U(g)&&!V(j,g,T,s,r)))for(const Z of _){const x=p(Z,j,{...d,parents:T});$.push(...C(x))}}k=D}else{if($=[],!m.has(a))for(const l of u){const v=p(l,n,{...d,parents:i});$.push(...C(v))}k=f}return Q(a,k,n,$,s,o.indentStr,o.level,i,r)}function X(e,n,o,i=[],s,r){const t=Object.entries(e).filter(([a])=>q(a,o,s)).map(([a,c])=>{let u;if(a==="style"){if(u=N(c,n,i,s,r),!u)return null}else if(F(c)){const f=K(c,n,i,s,r);u=w(String(f),n,!1,i,s,r)}else u=w(String(c),n,!1,i,s,r);return`${a}="${I(u)}"`}).filter(a=>a!==null).join(" ");return t?" "+t:""}h.renderToString=J,Object.defineProperty(h,Symbol.toStringTag,{value:"Module"})})); //# sourceMappingURL=treebark-browser.min.js.map diff --git a/nodejs/packages/test/package.json b/nodejs/packages/test/package.json index 8c5fefc..5d7e816 100644 --- a/nodejs/packages/test/package.json +++ b/nodejs/packages/test/package.json @@ -170,28 +170,6 @@ } ] } - }, - { - "displayName": "filter", - "testMatch": ["/src/filter.test.ts"], - "preset": "ts-jest/presets/default-esm", - "testEnvironment": "jsdom", - "extensionsToTreatAsEsm": [".ts"], - "moduleNameMapper": { - "^(\\.{1,2}/.*)\\.js$": "$1" - }, - "transform": { - "^.+\\.tsx?$": [ - "ts-jest", - { - "useESM": true, - "tsconfig": { - "module": "ES2020", - "outDir": "dist" - } - } - ] - } } ] } diff --git a/nodejs/packages/test/src/common-tests.ts b/nodejs/packages/test/src/common-tests.ts index b5c1b8d..a2843fb 100644 --- a/nodejs/packages/test/src/common-tests.ts +++ b/nodejs/packages/test/src/common-tests.ts @@ -2268,6 +2268,248 @@ export const ifTagErrorTests: ErrorTestCase[] = [ } ]; +export const filterTests: TestCase[] = [ + { + name: 'filters array items based on simple truthiness', + input: { + template: { + ul: { + $bind: 'items', + $filter: { + $check: 'active' + }, + $children: [ + { li: '{{name}}' } + ] + } + }, + data: { + items: [ + { name: 'Item 1', active: true }, + { name: 'Item 2', active: false }, + { name: 'Item 3', active: true } + ] + } + } + }, + { + name: 'filters array items with less than operator', + input: { + template: { + ul: { + $bind: 'products', + $filter: { + $check: 'price', + '$<': 500 + }, + $children: [ + { li: '{{name}} - ${{price}}' } + ] + } + }, + data: { + products: [ + { name: 'Laptop', price: 999 }, + { name: 'Mouse', price: 25 }, + { name: 'Keyboard', price: 75 }, + { name: 'Monitor', price: 699 } + ] + } + } + }, + { + name: 'filters array items with greater than operator', + input: { + template: { + ul: { + $bind: 'products', + $filter: { + $check: 'price', + '$>': 500 + }, + $children: [ + { li: '{{name}}' } + ] + } + }, + data: { + products: [ + { name: 'Laptop', price: 999 }, + { name: 'Mouse', price: 25 }, + { name: 'Monitor', price: 699 } + ] + } + } + }, + { + name: 'filters array items with $in operator', + input: { + template: { + ul: { + $bind: 'users', + $filter: { + $check: 'role', + $in: ['admin', 'moderator'] + }, + $children: [ + { li: '{{name}} ({{role}})' } + ] + } + }, + data: { + users: [ + { name: 'Alice', role: 'admin' }, + { name: 'Bob', role: 'user' }, + { name: 'Charlie', role: 'moderator' }, + { name: 'Dave', role: 'user' } + ] + } + } + }, + { + name: 'filters array items with equality operator', + input: { + template: { + ul: { + $bind: 'items', + $filter: { + $check: 'status', + '$=': 'published' + }, + $children: [ + { li: '{{title}}' } + ] + } + }, + data: { + items: [ + { title: 'Post 1', status: 'published' }, + { title: 'Post 2', status: 'draft' }, + { title: 'Post 3', status: 'published' } + ] + } + } + }, + { + name: 'filters with range using multiple operators', + input: { + template: { + ul: { + $bind: 'people', + $filter: { + $check: 'age', + '$>=': 18, + '$<=': 65 + }, + $children: [ + { li: '{{name}} ({{age}})' } + ] + } + }, + data: { + people: [ + { name: 'Alice', age: 15 }, + { name: 'Bob', age: 25 }, + { name: 'Charlie', age: 70 }, + { name: 'Dave', age: 40 } + ] + } + } + }, + { + name: 'filters with OR logic', + input: { + template: { + ul: { + $bind: 'people', + $filter: { + $check: 'age', + '$<': 18, + '$>': 65, + $join: 'OR' + }, + $children: [ + { li: '{{name}}' } + ] + } + }, + data: { + people: [ + { name: 'Alice', age: 15 }, + { name: 'Bob', age: 25 }, + { name: 'Charlie', age: 70 } + ] + } + } + }, + { + name: 'filters with $not modifier', + input: { + template: { + ul: { + $bind: 'items', + $filter: { + $check: 'hidden', + $not: true + }, + $children: [ + { li: '{{name}}' } + ] + } + }, + data: { + items: [ + { name: 'Item 1', hidden: false }, + { name: 'Item 2', hidden: true }, + { name: 'Item 3', hidden: false } + ] + } + } + }, + { + name: 'returns empty when all items are filtered out', + input: { + template: { + ul: { + $bind: 'products', + $filter: { + $check: 'price', + '$<': 10 + }, + $children: [ + { li: '{{name}}' } + ] + } + }, + data: { + products: [ + { name: 'Laptop', price: 999 }, + { name: 'Mouse', price: 25 } + ] + } + } + }, + { + name: 'works without $filter (no filtering)', + input: { + template: { + ul: { + $bind: 'items', + $children: [ + { li: '{{name}}' } + ] + } + }, + data: { + items: [ + { name: 'Item 1' }, + { name: 'Item 2' } + ] + } + } + } +]; + // Utility function to create test from test case data export function createTest(testCase: TestCase, renderFunction: (input: any, options?: any) => any, assertFunction: (result: any, testCase: TestCase) => void) { diff --git a/nodejs/packages/test/src/dom.test.ts b/nodejs/packages/test/src/dom.test.ts index 99a423a..04b4809 100644 --- a/nodejs/packages/test/src/dom.test.ts +++ b/nodejs/packages/test/src/dom.test.ts @@ -23,6 +23,7 @@ import { ifTagThenElseTests, conditionalAttributeTests, ifTagErrorTests, + filterTests, styleObjectTests, styleObjectWarningTests, styleObjectErrorTests, @@ -852,6 +853,51 @@ describe('DOM Renderer', () => { }); }); + // Filter tests + describe('$filter on databinding', () => { + filterTests.forEach(testCase => { + createTest(testCase, renderToDOM, (fragment, tc) => { + const div = document.createElement('div'); + div.appendChild(fragment); + + switch (tc.name) { + case 'filters array items based on simple truthiness': + expect(div.innerHTML).toBe('
    • Item 1
    • Item 3
    '); + break; + case 'filters array items with less than operator': + expect(div.innerHTML).toBe('
    • Mouse - $25
    • Keyboard - $75
    '); + break; + case 'filters array items with greater than operator': + expect(div.innerHTML).toBe('
    • Laptop
    • Monitor
    '); + break; + case 'filters array items with $in operator': + expect(div.innerHTML).toBe('
    • Alice (admin)
    • Charlie (moderator)
    '); + break; + case 'filters array items with equality operator': + expect(div.innerHTML).toBe('
    • Post 1
    • Post 3
    '); + break; + case 'filters with range using multiple operators': + expect(div.innerHTML).toBe('
    • Bob (25)
    • Dave (40)
    '); + break; + case 'filters with OR logic': + expect(div.innerHTML).toBe('
    • Alice
    • Charlie
    '); + break; + case 'filters with $not modifier': + expect(div.innerHTML).toBe('
    • Item 1
    • Item 3
    '); + break; + case 'returns empty when all items are filtered out': + expect(div.innerHTML).toBe('
      '); + break; + case 'works without $filter (no filtering)': + expect(div.innerHTML).toBe('
      • Item 1
      • Item 2
      '); + break; + default: + throw new Error(`Unknown test case: ${tc.name}`); + } + }); + }); + }); + // Style object tests describe('Style Objects', () => { styleObjectTests.forEach(testCase => { diff --git a/nodejs/packages/test/src/filter.test.ts b/nodejs/packages/test/src/filter.test.ts deleted file mode 100644 index cf702f9..0000000 --- a/nodejs/packages/test/src/filter.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { renderToString, renderToDOM } from 'treebark'; - -describe('$filter on databinding', () => { - describe('String Renderer', () => { - it('should filter array items based on simple truthiness', () => { - const input = { - template: { - ul: { - $bind: 'items', - $filter: { - $check: 'active' - }, - $children: [ - { li: '{{name}}' } - ] - } - }, - data: { - items: [ - { name: 'Item 1', active: true }, - { name: 'Item 2', active: false }, - { name: 'Item 3', active: true } - ] - } - }; - - const result = renderToString(input); - expect(result).toBe('
      • Item 1
      • Item 3
      '); - }); - - it('should filter array items with comparison operators', () => { - const input = { - template: { - ul: { - $bind: 'products', - $filter: { - $check: 'price', - '$<': 500 - }, - $children: [ - { li: '{{name}} - ${{price}}' } - ] - } - }, - data: { - products: [ - { name: 'Laptop', price: 999 }, - { name: 'Mouse', price: 25 }, - { name: 'Keyboard', price: 75 }, - { name: 'Monitor', price: 699 } - ] - } - }; - - const result = renderToString(input); - expect(result).toBe('
      • Mouse - $25
      • Keyboard - $75
      '); - }); - - it('should filter array items with greater than operator', () => { - const input = { - template: { - ul: { - $bind: 'products', - $filter: { - $check: 'price', - '$>': 500 - }, - $children: [ - { li: '{{name}}' } - ] - } - }, - data: { - products: [ - { name: 'Laptop', price: 999 }, - { name: 'Mouse', price: 25 }, - { name: 'Monitor', price: 699 } - ] - } - }; - - const result = renderToString(input); - expect(result).toBe('
      • Laptop
      • Monitor
      '); - }); - - it('should filter array items with $in operator', () => { - const input = { - template: { - ul: { - $bind: 'users', - $filter: { - $check: 'role', - $in: ['admin', 'moderator'] - }, - $children: [ - { li: '{{name}} ({{role}})' } - ] - } - }, - data: { - users: [ - { name: 'Alice', role: 'admin' }, - { name: 'Bob', role: 'user' }, - { name: 'Charlie', role: 'moderator' }, - { name: 'Dave', role: 'user' } - ] - } - }; - - const result = renderToString(input); - expect(result).toBe('
      • Alice (admin)
      • Charlie (moderator)
      '); - }); - - it('should filter array items with equality operator', () => { - const input = { - template: { - ul: { - $bind: 'items', - $filter: { - $check: 'status', - '$=': 'published' - }, - $children: [ - { li: '{{title}}' } - ] - } - }, - data: { - items: [ - { title: 'Post 1', status: 'published' }, - { title: 'Post 2', status: 'draft' }, - { title: 'Post 3', status: 'published' } - ] - } - }; - - const result = renderToString(input); - expect(result).toBe('
      • Post 1
      • Post 3
      '); - }); - - it('should filter with range using multiple operators', () => { - const input = { - template: { - ul: { - $bind: 'people', - $filter: { - $check: 'age', - '$>=': 18, - '$<=': 65 - }, - $children: [ - { li: '{{name}} ({{age}})' } - ] - } - }, - data: { - people: [ - { name: 'Alice', age: 15 }, - { name: 'Bob', age: 25 }, - { name: 'Charlie', age: 70 }, - { name: 'Dave', age: 40 } - ] - } - }; - - const result = renderToString(input); - expect(result).toBe('
      • Bob (25)
      • Dave (40)
      '); - }); - - it('should filter with OR logic', () => { - const input = { - template: { - ul: { - $bind: 'people', - $filter: { - $check: 'age', - '$<': 18, - '$>': 65, - $join: 'OR' as const - }, - $children: [ - { li: '{{name}}' } - ] - } - }, - data: { - people: [ - { name: 'Alice', age: 15 }, - { name: 'Bob', age: 25 }, - { name: 'Charlie', age: 70 } - ] - } - }; - - const result = renderToString(input); - expect(result).toBe('
      • Alice
      • Charlie
      '); - }); - - it('should filter with $not modifier', () => { - const input = { - template: { - ul: { - $bind: 'items', - $filter: { - $check: 'hidden', - $not: true - }, - $children: [ - { li: '{{name}}' } - ] - } - }, - data: { - items: [ - { name: 'Item 1', hidden: false }, - { name: 'Item 2', hidden: true }, - { name: 'Item 3', hidden: false } - ] - } - }; - - const result = renderToString(input); - expect(result).toBe('
      • Item 1
      • Item 3
      '); - }); - - it('should return empty when all items are filtered out', () => { - const input = { - template: { - ul: { - $bind: 'products', - $filter: { - $check: 'price', - '$<': 10 - }, - $children: [ - { li: '{{name}}' } - ] - } - }, - data: { - products: [ - { name: 'Laptop', price: 999 }, - { name: 'Mouse', price: 25 } - ] - } - }; - - const result = renderToString(input); - expect(result).toBe('
        '); - }); - - it('should work without $filter (no filtering)', () => { - const input = { - template: { - ul: { - $bind: 'items', - $children: [ - { li: '{{name}}' } - ] - } - }, - data: { - items: [ - { name: 'Item 1' }, - { name: 'Item 2' } - ] - } - }; - - const result = renderToString(input); - expect(result).toBe('
        • Item 1
        • Item 2
        '); - }); - }); - - describe('DOM Renderer', () => { - it('should filter array items in DOM', () => { - const input = { - template: { - ul: { - $bind: 'items', - $filter: { - $check: 'active' - }, - $children: [ - { li: '{{name}}' } - ] - } - }, - data: { - items: [ - { name: 'Item 1', active: true }, - { name: 'Item 2', active: false }, - { name: 'Item 3', active: true } - ] - } - }; - - const fragment = renderToDOM(input); - const div = document.createElement('div'); - div.appendChild(fragment); - - expect(div.innerHTML).toBe('
        • Item 1
        • Item 3
        '); - }); - - it('should filter with comparison operators in DOM', () => { - const input = { - template: { - ul: { - $bind: 'products', - $filter: { - $check: 'price', - '$<': 100 - }, - $children: [ - { li: '{{name}}' } - ] - } - }, - data: { - products: [ - { name: 'Laptop', price: 999 }, - { name: 'Mouse', price: 25 }, - { name: 'Keyboard', price: 75 } - ] - } - }; - - const fragment = renderToDOM(input); - const div = document.createElement('div'); - div.appendChild(fragment); - - expect(div.innerHTML).toBe('
        • Mouse
        • Keyboard
        '); - }); - }); -}); diff --git a/nodejs/packages/test/src/string.test.ts b/nodejs/packages/test/src/string.test.ts index 02a70b8..5dd478e 100644 --- a/nodejs/packages/test/src/string.test.ts +++ b/nodejs/packages/test/src/string.test.ts @@ -20,6 +20,7 @@ import { ifTagThenElseTests, conditionalAttributeTests, ifTagErrorTests, + filterTests, styleObjectTests, styleObjectWarningTests, styleObjectErrorTests, @@ -973,6 +974,48 @@ describe('String Renderer', () => { }); }); + // Filter tests + describe('$filter on databinding', () => { + filterTests.forEach(testCase => { + createTest(testCase, renderToString, (result, tc) => { + switch (tc.name) { + case 'filters array items based on simple truthiness': + expect(result).toBe('
        • Item 1
        • Item 3
        '); + break; + case 'filters array items with less than operator': + expect(result).toBe('
        • Mouse - $25
        • Keyboard - $75
        '); + break; + case 'filters array items with greater than operator': + expect(result).toBe('
        • Laptop
        • Monitor
        '); + break; + case 'filters array items with $in operator': + expect(result).toBe('
        • Alice (admin)
        • Charlie (moderator)
        '); + break; + case 'filters array items with equality operator': + expect(result).toBe('
        • Post 1
        • Post 3
        '); + break; + case 'filters with range using multiple operators': + expect(result).toBe('
        • Bob (25)
        • Dave (40)
        '); + break; + case 'filters with OR logic': + expect(result).toBe('
        • Alice
        • Charlie
        '); + break; + case 'filters with $not modifier': + expect(result).toBe('
        • Item 1
        • Item 3
        '); + break; + case 'returns empty when all items are filtered out': + expect(result).toBe('
          '); + break; + case 'works without $filter (no filtering)': + expect(result).toBe('
          • Item 1
          • Item 2
          '); + break; + default: + throw new Error(`Unknown test case: ${tc.name}`); + } + }); + }); + }); + // Style object tests describe('Style Objects', () => { styleObjectTests.forEach(testCase => { diff --git a/nodejs/packages/treebark/src/dom.ts b/nodejs/packages/treebark/src/dom.ts index 2489584..79df2ff 100644 --- a/nodejs/packages/treebark/src/dom.ts +++ b/nodejs/packages/treebark/src/dom.ts @@ -136,16 +136,16 @@ function render(template: TemplateElement | TemplateElement[], data: Data, conte if (Array.isArray(bound)) { for (const item of bound) { + // For array items, add current data context to parents + const newParents = [...parents, data]; + // Apply $filter if present - skip items that don't match if ($filter && isFilterCondition($filter)) { - const newParents = [...parents, data]; if (!evaluateFilterCondition(item as Data, $filter, newParents, logger, getOuterProperty)) { continue; } } - // For array items, add current data context to parents - const newParents = [...parents, data]; // Skip children for void tags if (!isVoid) { for (const c of $children) { diff --git a/nodejs/packages/treebark/src/string.ts b/nodejs/packages/treebark/src/string.ts index f9c36c8..4d9ad13 100644 --- a/nodejs/packages/treebark/src/string.ts +++ b/nodejs/packages/treebark/src/string.ts @@ -181,15 +181,15 @@ function render(template: TemplateElement | TemplateElement[], data: Data, conte // Skip children for void tags if (!VOID_TAGS.has(tag)) { for (const item of bound) { + const newParents = [...parents, data]; + // Apply $filter if present - skip items that don't match if ($filter && isFilterCondition($filter)) { - const newParents = [...parents, data]; if (!evaluateFilterCondition(item as Data, $filter, newParents, logger, getOuterProperty)) { continue; } } - const newParents = [...parents, data]; for (const child of $children) { const content = render(child, item as Data, { ...childContext, parents: newParents }); childrenOutput.push(...processContent(content)); diff --git a/nodejs/packages/treebark/src/types.ts b/nodejs/packages/treebark/src/types.ts index 6328b89..548d318 100644 --- a/nodejs/packages/treebark/src/types.ts +++ b/nodejs/packages/treebark/src/types.ts @@ -19,11 +19,10 @@ export type InterpolatedString = string; export type TemplateObject = IfTag | RegularTags; export type TemplateElement = InterpolatedString | TemplateObject; -// Generic conditional type shared by $if tag and conditional attribute values -export type ConditionalBase = { +// Filter condition type - base for all conditional logic +// Contains operators and modifiers but no $then/$else +export type FilterCondition = { $check: BindPath; - $then: T; - $else?: T; // Comparison operators (require numbers) '$<'?: number; '$>'?: number; @@ -37,28 +36,19 @@ export type ConditionalBase = { $join?: 'AND' | 'OR'; }; +// Conditional type extends FilterCondition with $then/$else for branching +// Used by $if tag and conditional attribute values +export type ConditionalBase = FilterCondition & { + $then: T; + $else?: T; +}; + // Conditional type for $if tag - T can be string or TemplateObject export type ConditionalValueOrTemplate = ConditionalBase; // Conditional value type for attribute values - T is restricted to primitives export type ConditionalValue = ConditionalBase; -// Filter condition type for $filter - similar to ConditionalBase but without $then/$else -export type FilterCondition = { - $check: BindPath; - // Comparison operators (require numbers) - '$<'?: number; - '$>'?: number; - '$<='?: number; - '$>='?: number; - // Equality operators (can compare any value) - '$='?: PrimitiveValue; - $in?: PrimitiveValue[]; - // Modifiers - $not?: boolean; - $join?: 'AND' | 'OR'; -}; - // CSS Style properties as an object with kebab-case property names // Accepts any valid CSS property name (kebab-case format) export type CSSProperties = { From a61d25bce11d27b7fb35b4b4e6ba24bfe0ef0ee2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 00:39:20 +0000 Subject: [PATCH 07/24] Simplify README example: remove unnecessary array wrapper around single $if in $children Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- README.md | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index cb3a3d4..a582891 100644 --- a/README.md +++ b/README.md @@ -822,21 +822,19 @@ Instead of wrapping each item in an $if to conditionally render it: { "div": { "$bind": "products", - "$children": [ - { - "$if": { - "$check": "inStock", - "$then": { - "div": { - "$children": [ - { "h3": "{{name}}" }, - { "p": "✓ In Stock ({{quantity}} available)" } - ] - } + "$children": { + "$if": { + "$check": "inStock", + "$then": { + "div": { + "$children": [ + { "h3": "{{name}}" }, + { "p": "✓ In Stock ({{quantity}} available)" } + ] } } } - ] + } } } ``` From c99570d76c8a0bea0c13ae19bc17047b4a5eb9c8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 00:45:13 +0000 Subject: [PATCH 08/24] Remove evaluateFilterCondition helper function to reduce code duplication Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- docs/assets/treebark-browser.js | 13 +++++-------- docs/assets/treebark-browser.min.js | 14 +++++++------- nodejs/packages/treebark/src/common.ts | 18 ------------------ nodejs/packages/treebark/src/dom.ts | 8 ++++++-- nodejs/packages/treebark/src/string.ts | 8 ++++++-- 5 files changed, 24 insertions(+), 37 deletions(-) diff --git a/docs/assets/treebark-browser.js b/docs/assets/treebark-browser.js index 2bd2238..60b9fa4 100644 --- a/docs/assets/treebark-browser.js +++ b/docs/assets/treebark-browser.js @@ -255,13 +255,6 @@ function isFilterCondition(value) { return value !== null && typeof value === "object" && !Array.isArray(value) && "$check" in value && typeof value.$check === "string"; } - function evaluateFilterCondition(item, filter, parents = [], logger, getOuterProperty) { - if (!validatePathExpression(filter.$check, "$check", logger)) { - return false; - } - const checkValue = getProperty(item, filter.$check, parents, logger, getOuterProperty); - return evaluateCondition(checkValue, filter); - } function parseTemplateObject(templateObj, logger) { if (!templateObj || typeof templateObj !== "object") { logger.error("Template object cannot be null, undefined, or non-object"); @@ -420,7 +413,11 @@ for (const item of bound) { const newParents = [...parents, data]; if ($filter && isFilterCondition($filter)) { - if (!evaluateFilterCondition(item, $filter, newParents, logger, getOuterProperty)) { + if (!validatePathExpression($filter.$check, "$check", logger)) { + continue; + } + const checkValue = getProperty(item, $filter.$check, newParents, logger, getOuterProperty); + if (!evaluateCondition(checkValue, $filter)) { continue; } } diff --git a/docs/assets/treebark-browser.min.js b/docs/assets/treebark-browser.min.js index fd51458..44a80ce 100644 --- a/docs/assets/treebark-browser.min.js +++ b/docs/assets/treebark-browser.min.js @@ -1,9 +1,9 @@ -(function(h,b){typeof exports=="object"&&typeof module<"u"?b(exports):typeof define=="function"&&define.amd?define(["exports"],b):(h=typeof globalThis<"u"?globalThis:h||self,b(h.Treebark={}))})(this,(function(h){"use strict";const b=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"]),L=new Set(["$comment","$if"]),m=new Set(["img","br","hr"]),W=new Set([...b,...L,...m]),O=new Set(["id","class","style","title","role","data-","aria-"]),G={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"])},R=new Set(["$<","$>","$<=","$>=","$=","$in"]),E=new Set(["$check","$then","$else","$not","$join",...R]),z=new Set(["behavior","-moz-binding"]);function y(e,n,o=[],i,s){if(n===".")return e;let r=e,t=n;for(;t.startsWith("..");){let a=0,c=t;for(;c.startsWith("..");)a++,c=c.substring(2),c.startsWith("/")&&(c=c.substring(1));if(a<=o.length)r=o[o.length-a],t=c.startsWith(".")?c.substring(1):c;else return s?s(n,e,o):void 0}if(t){if(i&&typeof r!="object"&&r!==null&&r!==void 0){i.error(`Cannot access property "${t}" on primitive value of type "${typeof r}"`);return}const a=t.split(".").reduce((c,u)=>c&&typeof c=="object"&&c!==null?c[u]:void 0,r);return a===void 0&&s?s(n,e,o):a}return r}function I(e){return e.replace(/[&<>"']/g,n=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[n]||n)}function w(e,n,o=!0,i=[],s,r){return e.replace(/\{\{\{([^{]*?)\}\}\}|\{\{([^{]*?)\}\}/g,(t,a,c)=>{if(a!==void 0)return`{{${a.trim()}}}`;const u=c.trim(),f=y(n,u,i,s,r);return f==null?"":o?I(String(f)):String(f)})}function P(e,n){const o=[];for(const[i,s]of Object.entries(e)){const r=i;if(!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(r)){n.warn(`CSS property "${i}" has invalid format (must be kebab-case)`);continue}if(z.has(r)){n.warn(`CSS property "${i}" is blocked for security reasons`);continue}if(s==null)continue;let t=String(s).trim();if(t.includes(";")){const u=t;t=t.split(";")[0].trim(),t&&t!==u.trim()&&n.warn(`CSS value for "${i}" 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 "${i}" contains potentially dangerous pattern: "${t}"`);continue}o.push(`${r}: ${t}`)}return o.join("; ").trim()}function N(e,n,o,i,s){if(e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"){const r=e;if(!A(r.$check,"$check",i))return"";const t=y(n,r.$check,o,i,s),c=S(t,r)?r.$then:r.$else;return c===void 0?"":typeof c=="object"&&c!==null&&!Array.isArray(c)?P(c,i):""}return typeof e=="object"&&e!==null&&!Array.isArray(e)?P(e,i):(i.error(`Style attribute must be an object with CSS properties, not ${typeof e}. Example: style: { "color": "red", "font-size": "14px" }`),"")}function q(e,n,o){const i=O.has(e)||[...O].some(t=>t.endsWith("-")&&e.startsWith(t)),s=G[n],r=s&&s.has(e);return!i&&!r?(o.warn(`Attribute "${e}" is not allowed on tag "${n}"`),!1):!0}function B(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$bind"in e}function A(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 S(e,n){const o=[];for(const t of R)t in n&&o.push({key:t,value:n[t]});if(o.length===0){const t=!!e;return n.$not?!t:t}const i=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}}),s=n.$join==="OR";let r;return s?r=i.some(t=>t):r=i.every(t=>t),n.$not?!r:r}function F(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function K(e,n,o=[],i,s){if(!A(e.$check,"$check",i))return"";const r=y(n,e.$check,o,i,s);return S(r,e)?e.$then!==void 0?e.$then:"":e.$else!==void 0?e.$else:""}function U(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function V(e,n,o=[],i,s){if(!A(n.$check,"$check",i))return!1;const r=y(e,n.$check,o,i,s);return S(r,n)}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 i=o[0];if(!i){n.error("Template object must have at least one tag");return}const[s,r]=i,t=typeof r=="string"?[r]:Array.isArray(r)?r:r?.$children||[],a=r&&typeof r=="object"&&!Array.isArray(r)?Object.fromEntries(Object.entries(r).filter(([c])=>c!=="$children")):{};return{tag:s,rest:r,children:t,attrs:a}}function Y(e,n,o=[],i,s){const r=e;if(!r.$check)return i.error('"$if" tag requires $check attribute to specify the condition'),{valueToRender:void 0};if(!A(r.$check,"$check",i))return{valueToRender:void 0};const t=y(n,r.$check,o,i,s);typeof e=="object"&&e!==null&&!Array.isArray(e)&&"$children"in e&&i.warn('"$if" tag does not support $children, use $then and $else instead');const{$then:a,$else:c}=r;if(a!==void 0&&Array.isArray(a))return i.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 i.error('"$if" tag $else must be a string or single element object, not an array'),{valueToRender:void 0};const f=(typeof e=="object"&&e!==null&&!Array.isArray(e)?Object.keys(e):[]).filter($=>!E.has($));return f.length>0&&i.warn(`"$if" tag does not support attributes: ${f.join(", ")}. Allowed: ${[...E].join(", ")}`),{valueToRender:S(t,r)?a:c}}const H=(e,n)=>{if(!n)return e.length<=1?e[0]?.[1]??"":e.reduce((i,[,s])=>i+s,"");if(e.length===0)return"";if(e.length===1&&!e[0][1].includes("<"))return e[0][1];let o=` -`;for(let i=0;i","$<=","$>=","$=","$in"]),I=new Set(["$check","$then","$else","$not","$join",...E]),N=new Set(["behavior","-moz-binding"]);function y(e,t,o=[],i,s){if(t===".")return e;let r=e,n=t;for(;n.startsWith("..");){let l=0,c=n;for(;c.startsWith("..");)l++,c=c.substring(2),c.startsWith("/")&&(c=c.substring(1));if(l<=o.length)r=o[o.length-l],n=c.startsWith(".")?c.substring(1):c;else return s?s(t,e,o):void 0}if(n){if(i&&typeof r!="object"&&r!==null&&r!==void 0){i.error(`Cannot access property "${n}" on primitive value of type "${typeof r}"`);return}const l=n.split(".").reduce((c,u)=>c&&typeof c=="object"&&c!==null?c[u]:void 0,r);return l===void 0&&s?s(t,e,o):l}return r}function P(e){return e.replace(/[&<>"']/g,t=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[t]||t)}function C(e,t,o=!0,i=[],s,r){return e.replace(/\{\{\{([^{]*?)\}\}\}|\{\{([^{]*?)\}\}/g,(n,l,c)=>{if(l!==void 0)return`{{${l.trim()}}}`;const u=c.trim(),f=y(t,u,i,s,r);return f==null?"":o?P(String(f)):String(f)})}function _(e,t){const o=[];for(const[i,s]of Object.entries(e)){const r=i;if(!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(r)){t.warn(`CSS property "${i}" has invalid format (must be kebab-case)`);continue}if(N.has(r)){t.warn(`CSS property "${i}" is blocked for security reasons`);continue}if(s==null)continue;let n=String(s).trim();if(n.includes(";")){const u=n;n=n.split(";")[0].trim(),n&&n!==u.trim()&&t.warn(`CSS value for "${i}" contained semicolon - using only first part: "${n}"`)}if(!n)continue;const l=/url\s*\(/i.test(n),c=/url\s*\(\s*['"]?data:/i.test(n);if(l&&!c||/expression\s*\(/i.test(n)||/javascript:/i.test(n)||/@import/i.test(n)){t.warn(`CSS value for "${i}" contains potentially dangerous pattern: "${n}"`);continue}o.push(`${r}: ${n}`)}return o.join("; ").trim()}function q(e,t,o,i,s){if(e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"){const r=e;if(!A(r.$check,"$check",i))return"";const n=y(t,r.$check,o,i,s),c=v(n,r)?r.$then:r.$else;return c===void 0?"":typeof c=="object"&&c!==null&&!Array.isArray(c)?_(c,i):""}return typeof e=="object"&&e!==null&&!Array.isArray(e)?_(e,i):(i.error(`Style attribute must be an object with CSS properties, not ${typeof e}. Example: style: { "color": "red", "font-size": "14px" }`),"")}function B(e,t,o){const i=R.has(e)||[...R].some(n=>n.endsWith("-")&&e.startsWith(n)),s=z[t],r=s&&s.has(e);return!i&&!r?(o.warn(`Attribute "${e}" is not allowed on tag "${t}"`),!1):!0}function K(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$bind"in e}function A(e,t,o){return e==="."?!0:e.includes("..")?(o.error(`${t} does not support parent context access (..) - use interpolation {{..prop}} in content/attributes instead. Invalid: ${t}: "${e}"`),!1):e.includes("{{")?(o.error(`${t} does not support interpolation {{...}} - use literal property paths only. Invalid: ${t}: "${e}"`),!1):!0}function v(e,t){const o=[];for(const n of E)n in t&&o.push({key:n,value:t[n]});if(o.length===0){const n=!!e;return t.$not?!n:n}const i=o.map(n=>{switch(n.key){case"$<":return typeof e=="number"&&typeof n.value=="number"&&e":return typeof e=="number"&&typeof n.value=="number"&&e>n.value;case"$<=":return typeof e=="number"&&typeof n.value=="number"&&e<=n.value;case"$>=":return typeof e=="number"&&typeof n.value=="number"&&e>=n.value;case"$=":return e===n.value;case"$in":return Array.isArray(n.value)&&n.value.includes(e);default:return!1}}),s=t.$join==="OR";let r;return s?r=i.some(n=>n):r=i.every(n=>n),t.$not?!r:r}function F(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function U(e,t,o=[],i,s){if(!A(e.$check,"$check",i))return"";const r=y(t,e.$check,o,i,s);return v(r,e)?e.$then!==void 0?e.$then:"":e.$else!==void 0?e.$else:""}function V(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function M(e,t){if(!e||typeof e!="object"){t.error("Template object cannot be null, undefined, or non-object");return}const o=Object.entries(e);if(o.length===0){t.error("Template object must have at least one tag");return}const i=o[0];if(!i){t.error("Template object must have at least one tag");return}const[s,r]=i,n=typeof r=="string"?[r]:Array.isArray(r)?r:r?.$children||[],l=r&&typeof r=="object"&&!Array.isArray(r)?Object.fromEntries(Object.entries(r).filter(([c])=>c!=="$children")):{};return{tag:s,rest:r,children:n,attrs:l}}function Y(e,t,o=[],i,s){const r=e;if(!r.$check)return i.error('"$if" tag requires $check attribute to specify the condition'),{valueToRender:void 0};if(!A(r.$check,"$check",i))return{valueToRender:void 0};const n=y(t,r.$check,o,i,s);typeof e=="object"&&e!==null&&!Array.isArray(e)&&"$children"in e&&i.warn('"$if" tag does not support $children, use $then and $else instead');const{$then:l,$else:c}=r;if(l!==void 0&&Array.isArray(l))return i.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 i.error('"$if" tag $else must be a string or single element object, not an array'),{valueToRender:void 0};const f=(typeof e=="object"&&e!==null&&!Array.isArray(e)?Object.keys(e):[]).filter($=>!I.has($));return f.length>0&&i.warn(`"$if" tag does not support attributes: ${f.join(", ")}. Allowed: ${[...I].join(", ")}`),{valueToRender:v(n,r)?l:c}}const H=(e,t)=>{if(!t)return e.length<=1?e[0]?.[1]??"":e.reduce((i,[,s])=>i+s,"");if(e.length===0)return"";if(e.length===1&&!e[0][1].includes("<"))return e[0][1];let o=` +`;for(let i=0;i`;const d=`<${e}${X(n,o,e,a,s,c)}>`;return m.has(e)?d:`${d}${u}${f}`}function p(e,n,o){const i=o.parents||[],s=o.logger,r=o.getOuterProperty;if(typeof e=="string")return w(e,n,!0,i,s,r);if(Array.isArray(e))return e.map(l=>p(l,n,o)).join(o.indentStr?` -`:"");const t=M(e,s);if(!t)return"";const{tag:a,rest:c,children:u,attrs:f}=t;if(!W.has(a))return s.error(`Tag "${a}" is not allowed`),"";if(a==="$comment"&&o.insideComment)return s.error("Nested comments are not allowed"),"";if(a==="$if"){const{valueToRender:l}=Y(c,n,i,s,r);return l===void 0?"":p(l,n,o)}m.has(a)&&u.length>0&&s.warn(`Tag "${a}" is a void element and cannot have children`);const d={...o,insideComment:a==="$comment"||o.insideComment,level:(o.level||0)+1},C=l=>l===""?[]:o.indentStr&&l.includes(` -`)&&!l.includes("<")?l.split(` -`).map(v=>[d.level,v]):[[d.level,l]];let $,k;if(B(c)){if(!A(c.$bind,"$bind",s))return"";const l=y(n,c.$bind,[],s,r),{$bind:v,$filter:g,$children:_=[],...D}=c;if(!Array.isArray(l)){if(l!=null&&typeof l!="object")return s.error(`$bind resolved to primitive value of type "${typeof l}", cannot render children`),"";const j=l&&typeof l=="object"&&l!==null?l:{},T=[...i,n];return p({[a]:{...D,$children:_}},j,{...o,parents:T})}if($=[],!m.has(a))for(const j of l){const T=[...i,n];if(!(g&&U(g)&&!V(j,g,T,s,r)))for(const Z of _){const x=p(Z,j,{...d,parents:T});$.push(...C(x))}}k=D}else{if($=[],!m.has(a))for(const l of u){const v=p(l,n,{...d,parents:i});$.push(...C(v))}k=f}return Q(a,k,n,$,s,o.indentStr,o.level,i,r)}function X(e,n,o,i=[],s,r){const t=Object.entries(e).filter(([a])=>q(a,o,s)).map(([a,c])=>{let u;if(a==="style"){if(u=N(c,n,i,s,r),!u)return null}else if(F(c)){const f=K(c,n,i,s,r);u=w(String(f),n,!1,i,s,r)}else u=w(String(c),n,!1,i,s,r);return`${a}="${I(u)}"`}).filter(a=>a!==null).join(" ");return t?" "+t:""}h.renderToString=J,Object.defineProperty(h,Symbol.toStringTag,{value:"Module"})})); +`,o};function J(e,t={}){const o=e.data,i=t.logger||console,s=t.propertyFallback,r=t.indent?{indentStr:typeof t.indent=="number"?" ".repeat(t.indent):typeof t.indent=="string"?t.indent:" ",level:0,logger:i,getOuterProperty:s}:{logger:i,getOuterProperty:s};return p(e.template,o,r)}function Q(e,t,o,i,s,r,n,l=[],c){const u=H(i,r),f=u.startsWith(` +`)&&r?r.repeat(n||0):"";if(e==="$comment")return``;const d=`<${e}${X(t,o,e,l,s,c)}>`;return m.has(e)?d:`${d}${u}${f}`}function p(e,t,o){const i=o.parents||[],s=o.logger,r=o.getOuterProperty;if(typeof e=="string")return C(e,t,!0,i,s,r);if(Array.isArray(e))return e.map(a=>p(a,t,o)).join(o.indentStr?` +`:"");const n=M(e,s);if(!n)return"";const{tag:l,rest:c,children:u,attrs:f}=n;if(!G.has(l))return s.error(`Tag "${l}" is not allowed`),"";if(l==="$comment"&&o.insideComment)return s.error("Nested comments are not allowed"),"";if(l==="$if"){const{valueToRender:a}=Y(c,t,i,s,r);return a===void 0?"":p(a,t,o)}m.has(l)&&u.length>0&&s.warn(`Tag "${l}" is a void element and cannot have children`);const d={...o,insideComment:l==="$comment"||o.insideComment,level:(o.level||0)+1},g=a=>a===""?[]:o.indentStr&&a.includes(` +`)&&!a.includes("<")?a.split(` +`).map(j=>[d.level,j]):[[d.level,a]];let $,k;if(K(c)){if(!A(c.$bind,"$bind",s))return"";const a=y(t,c.$bind,[],s,r),{$bind:j,$filter:S,$children:D=[],...L}=c;if(!Array.isArray(a)){if(a!=null&&typeof a!="object")return s.error(`$bind resolved to primitive value of type "${typeof a}", cannot render children`),"";const T=a&&typeof a=="object"&&a!==null?a:{},w=[...i,t];return p({[l]:{...L,$children:D}},T,{...o,parents:w})}if($=[],!m.has(l))for(const T of a){const w=[...i,t];if(S&&V(S)){if(!A(S.$check,"$check",s))continue;const O=y(T,S.$check,w,s,r);if(!v(O,S))continue}for(const O of D){const Z=p(O,T,{...d,parents:w});$.push(...g(Z))}}k=L}else{if($=[],!m.has(l))for(const a of u){const j=p(a,t,{...d,parents:i});$.push(...g(j))}k=f}return Q(l,k,t,$,s,o.indentStr,o.level,i,r)}function X(e,t,o,i=[],s,r){const n=Object.entries(e).filter(([l])=>B(l,o,s)).map(([l,c])=>{let u;if(l==="style"){if(u=q(c,t,i,s,r),!u)return null}else if(F(c)){const f=U(c,t,i,s,r);u=C(String(f),t,!1,i,s,r)}else u=C(String(c),t,!1,i,s,r);return`${l}="${P(u)}"`}).filter(l=>l!==null).join(" ");return n?" "+n:""}h.renderToString=J,Object.defineProperty(h,Symbol.toStringTag,{value:"Module"})})); //# sourceMappingURL=treebark-browser.min.js.map diff --git a/nodejs/packages/treebark/src/common.ts b/nodejs/packages/treebark/src/common.ts index 79aff2b..9fd91e9 100644 --- a/nodejs/packages/treebark/src/common.ts +++ b/nodejs/packages/treebark/src/common.ts @@ -455,24 +455,6 @@ export function isFilterCondition(value: unknown): value is FilterCondition { ); } -/** - * Evaluate a filter condition for a single item - * Returns true if the item passes the filter, false otherwise - */ -export function evaluateFilterCondition( - item: Data, - filter: FilterCondition, - parents: Data[] = [], - logger: Logger, - getOuterProperty?: OuterPropertyResolver -): boolean { - if (!validatePathExpression(filter.$check, '$check', logger)) { - return false; - } - const checkValue = getProperty(item, filter.$check, parents, logger, getOuterProperty); - return evaluateCondition(checkValue, filter); -} - /** * Check if a template has $bind: "." which means bind to current data object */ diff --git a/nodejs/packages/treebark/src/dom.ts b/nodejs/packages/treebark/src/dom.ts index 79df2ff..22dcac3 100644 --- a/nodejs/packages/treebark/src/dom.ts +++ b/nodejs/packages/treebark/src/dom.ts @@ -13,7 +13,7 @@ import { parseTemplateObject, processConditional, isFilterCondition, - evaluateFilterCondition + evaluateCondition } from './common.js'; export function renderToDOM( @@ -141,7 +141,11 @@ function render(template: TemplateElement | TemplateElement[], data: Data, conte // Apply $filter if present - skip items that don't match if ($filter && isFilterCondition($filter)) { - if (!evaluateFilterCondition(item as Data, $filter, newParents, logger, getOuterProperty)) { + if (!validatePathExpression($filter.$check, '$check', logger)) { + continue; + } + const checkValue = getProperty(item as Data, $filter.$check, newParents, logger, getOuterProperty); + if (!evaluateCondition(checkValue, $filter)) { continue; } } diff --git a/nodejs/packages/treebark/src/string.ts b/nodejs/packages/treebark/src/string.ts index 4d9ad13..b9bae47 100644 --- a/nodejs/packages/treebark/src/string.ts +++ b/nodejs/packages/treebark/src/string.ts @@ -14,7 +14,7 @@ import { parseTemplateObject, processConditional, isFilterCondition, - evaluateFilterCondition + evaluateCondition } from './common.js'; // Type for indented output: [indentLevel, htmlContent] @@ -185,7 +185,11 @@ function render(template: TemplateElement | TemplateElement[], data: Data, conte // Apply $filter if present - skip items that don't match if ($filter && isFilterCondition($filter)) { - if (!evaluateFilterCondition(item as Data, $filter, newParents, logger, getOuterProperty)) { + if (!validatePathExpression($filter.$check, '$check', logger)) { + continue; + } + const checkValue = getProperty(item as Data, $filter.$check, newParents, logger, getOuterProperty); + if (!evaluateCondition(checkValue, $filter)) { continue; } } From 700fccea5325e758833a52bb52a11f501640bdcc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 00:50:58 +0000 Subject: [PATCH 09/24] Reorganize documentation: move $filter section before $if in spec.md, separate filter and conditional keys in README Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- README.md | 11 +++-- spec.md | 118 +++++++++++++++++++++++++++--------------------------- 2 files changed, 66 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index a582891..a643602 100644 --- a/README.md +++ b/README.md @@ -108,12 +108,10 @@ This means the implementation is featherweight. **Data binding:** - `$children`: Array or string. Defines child nodes or mixed content for an element. - `$bind`: String. Binds the current node to a property or array in the data context. If it resolves to an array, the element's children are repeated for each item. -- `$filter`: Object. Filters array items when used with `$bind`. Uses the same conditional operators as `$if` tag. -**Conditional keys (used in `$if` tag, `$filter`, and conditional attribute values):** +**Filter keys (used with `$bind` to filter array items):** +- `$filter`: Object containing the filter condition. - `$check`: String. Property path to check. -- `$then`: Single template object or string. Content/value when condition is true (not used in `$filter`). -- `$else`: Single template object or string. Content/value when condition is false (not used in `$filter`). - `$<`: Less than comparison. - `$>`: Greater than comparison. - `$<=`: Less than or equal comparison. @@ -123,6 +121,11 @@ This means the implementation is featherweight. - `$not`: Boolean. Inverts the entire condition result. - `$join`: "AND" | "OR". Combines multiple operators (default: "AND"). +**Conditional keys (used in `$if` tag and conditional attribute values):** +- All filter keys above (`$check`, `$<`, `$>`, `$<=`, `$>=`, `$=`, `$in`, `$not`, `$join`), plus: +- `$then`: Single template object or string. Content/value when condition is true. +- `$else`: Single template object or string. Content/value when condition is false. + ## Examples ### Nested Elements diff --git a/spec.md b/spec.md index 1a56cdb..0a9944b 100644 --- a/spec.md +++ b/spec.md @@ -328,7 +328,65 @@ JavaScript allows both `array[0]` and `array["0"]` syntax. Since the path is spl --- -## 11. Filtering Arrays with $filter +## 11. Tag Whitelist + +**Standard HTML tags:** +`div`, `span`, `p`, `header`, `footer`, `main`, `section`, `article`, +`h1`–`h6`, `strong`, `em`, `blockquote`, `code`, `pre`, +`ul`, `ol`, `li`, +`table`, `thead`, `tbody`, `tr`, `th`, `td`, +`a`, `img` + +**Special tags:** +`comment`, `if` + +Blocked tags: +`script`, `iframe`, `embed`, `object`, `applet`, +`form`, `input`, `button`, `select`, +`video`, `audio`, +`style`, `link`, `meta`, `base` + +--- + +## 12. Comments + +HTML comments are generated using the `$comment` tag: + +```yaml +$comment: "This is a comment" +``` + +→ `` + +**Features:** +- Support interpolation: `$comment: "Generated on {{date}}"` +- Support mixed content with `$children` +- Cannot be nested (attempting to place a `$comment` inside another `$comment` logs an error and skips rendering the nested comment) + +**Examples:** + +Basic comment: +```yaml +$comment: "This is a comment" +``` + +Comment with interpolation: +```yaml +$comment: "User: {{name}}" +``` + +Comment with mixed content: +```yaml +$comment: + $children: + - "Start: " + - span: "highlighted text" + - " :End" +``` + +--- + +## 13. Filtering Arrays with $filter The `$filter` key works with `$bind` to filter array items before rendering them. It uses the same conditional operators as the `$if` tag. @@ -471,64 +529,6 @@ Output: `
          • Alice
          • Charlie
          ` --- -## 12. Tag Whitelist - -**Standard HTML tags:** -`div`, `span`, `p`, `header`, `footer`, `main`, `section`, `article`, -`h1`–`h6`, `strong`, `em`, `blockquote`, `code`, `pre`, -`ul`, `ol`, `li`, -`table`, `thead`, `tbody`, `tr`, `th`, `td`, -`a`, `img` - -**Special tags:** -`comment`, `if` - -Blocked tags: -`script`, `iframe`, `embed`, `object`, `applet`, -`form`, `input`, `button`, `select`, -`video`, `audio`, -`style`, `link`, `meta`, `base` - ---- - -## 13. Comments - -HTML comments are generated using the `$comment` tag: - -```yaml -$comment: "This is a comment" -``` - -→ `` - -**Features:** -- Support interpolation: `$comment: "Generated on {{date}}"` -- Support mixed content with `$children` -- Cannot be nested (attempting to place a `$comment` inside another `$comment` logs an error and skips rendering the nested comment) - -**Examples:** - -Basic comment: -```yaml -$comment: "This is a comment" -``` - -Comment with interpolation: -```yaml -$comment: "User: {{name}}" -``` - -Comment with mixed content: -```yaml -$comment: - $children: - - "Start: " - - span: "highlighted text" - - " :End" -``` - ---- - ## 14. Conditional Rendering with "$if" Tag The `$if` tag provides advanced conditional rendering based on data properties. It acts as a transparent container that renders its children only when specified conditions are met. From 930938d8e74179af95f49a11b318accfa31f0e44 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 00:55:18 +0000 Subject: [PATCH 10/24] Optimize filter validation by moving checks outside the loop Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- docs/assets/treebark-browser.js | 10 ++++++---- docs/assets/treebark-browser.min.js | 10 +++++----- nodejs/packages/treebark/src/dom.ts | 12 ++++++++---- nodejs/packages/treebark/src/string.ts | 13 +++++++++---- 4 files changed, 28 insertions(+), 17 deletions(-) diff --git a/docs/assets/treebark-browser.js b/docs/assets/treebark-browser.js index 60b9fa4..32c7bde 100644 --- a/docs/assets/treebark-browser.js +++ b/docs/assets/treebark-browser.js @@ -410,12 +410,14 @@ } childrenOutput = []; if (!VOID_TAGS.has(tag)) { + const hasFilter = $filter && isFilterCondition($filter); + if (hasFilter && !validatePathExpression($filter.$check, "$check", logger)) { + contentAttrs = bindAttrs; + return ""; + } for (const item of bound) { const newParents = [...parents, data]; - if ($filter && isFilterCondition($filter)) { - if (!validatePathExpression($filter.$check, "$check", logger)) { - continue; - } + if (hasFilter) { const checkValue = getProperty(item, $filter.$check, newParents, logger, getOuterProperty); if (!evaluateCondition(checkValue, $filter)) { continue; diff --git a/docs/assets/treebark-browser.min.js b/docs/assets/treebark-browser.min.js index 44a80ce..cf17402 100644 --- a/docs/assets/treebark-browser.min.js +++ b/docs/assets/treebark-browser.min.js @@ -1,9 +1,9 @@ -(function(h,b){typeof exports=="object"&&typeof module<"u"?b(exports):typeof define=="function"&&define.amd?define(["exports"],b):(h=typeof globalThis<"u"?globalThis:h||self,b(h.Treebark={}))})(this,(function(h){"use strict";const b=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"]),W=new Set(["$comment","$if"]),m=new Set(["img","br","hr"]),G=new Set([...b,...W,...m]),R=new Set(["id","class","style","title","role","data-","aria-"]),z={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"])},E=new Set(["$<","$>","$<=","$>=","$=","$in"]),I=new Set(["$check","$then","$else","$not","$join",...E]),N=new Set(["behavior","-moz-binding"]);function y(e,t,o=[],i,s){if(t===".")return e;let r=e,n=t;for(;n.startsWith("..");){let l=0,c=n;for(;c.startsWith("..");)l++,c=c.substring(2),c.startsWith("/")&&(c=c.substring(1));if(l<=o.length)r=o[o.length-l],n=c.startsWith(".")?c.substring(1):c;else return s?s(t,e,o):void 0}if(n){if(i&&typeof r!="object"&&r!==null&&r!==void 0){i.error(`Cannot access property "${n}" on primitive value of type "${typeof r}"`);return}const l=n.split(".").reduce((c,u)=>c&&typeof c=="object"&&c!==null?c[u]:void 0,r);return l===void 0&&s?s(t,e,o):l}return r}function P(e){return e.replace(/[&<>"']/g,t=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[t]||t)}function C(e,t,o=!0,i=[],s,r){return e.replace(/\{\{\{([^{]*?)\}\}\}|\{\{([^{]*?)\}\}/g,(n,l,c)=>{if(l!==void 0)return`{{${l.trim()}}}`;const u=c.trim(),f=y(t,u,i,s,r);return f==null?"":o?P(String(f)):String(f)})}function _(e,t){const o=[];for(const[i,s]of Object.entries(e)){const r=i;if(!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(r)){t.warn(`CSS property "${i}" has invalid format (must be kebab-case)`);continue}if(N.has(r)){t.warn(`CSS property "${i}" is blocked for security reasons`);continue}if(s==null)continue;let n=String(s).trim();if(n.includes(";")){const u=n;n=n.split(";")[0].trim(),n&&n!==u.trim()&&t.warn(`CSS value for "${i}" contained semicolon - using only first part: "${n}"`)}if(!n)continue;const l=/url\s*\(/i.test(n),c=/url\s*\(\s*['"]?data:/i.test(n);if(l&&!c||/expression\s*\(/i.test(n)||/javascript:/i.test(n)||/@import/i.test(n)){t.warn(`CSS value for "${i}" contains potentially dangerous pattern: "${n}"`);continue}o.push(`${r}: ${n}`)}return o.join("; ").trim()}function q(e,t,o,i,s){if(e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"){const r=e;if(!A(r.$check,"$check",i))return"";const n=y(t,r.$check,o,i,s),c=v(n,r)?r.$then:r.$else;return c===void 0?"":typeof c=="object"&&c!==null&&!Array.isArray(c)?_(c,i):""}return typeof e=="object"&&e!==null&&!Array.isArray(e)?_(e,i):(i.error(`Style attribute must be an object with CSS properties, not ${typeof e}. Example: style: { "color": "red", "font-size": "14px" }`),"")}function B(e,t,o){const i=R.has(e)||[...R].some(n=>n.endsWith("-")&&e.startsWith(n)),s=z[t],r=s&&s.has(e);return!i&&!r?(o.warn(`Attribute "${e}" is not allowed on tag "${t}"`),!1):!0}function K(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$bind"in e}function A(e,t,o){return e==="."?!0:e.includes("..")?(o.error(`${t} does not support parent context access (..) - use interpolation {{..prop}} in content/attributes instead. Invalid: ${t}: "${e}"`),!1):e.includes("{{")?(o.error(`${t} does not support interpolation {{...}} - use literal property paths only. Invalid: ${t}: "${e}"`),!1):!0}function v(e,t){const o=[];for(const n of E)n in t&&o.push({key:n,value:t[n]});if(o.length===0){const n=!!e;return t.$not?!n:n}const i=o.map(n=>{switch(n.key){case"$<":return typeof e=="number"&&typeof n.value=="number"&&e":return typeof e=="number"&&typeof n.value=="number"&&e>n.value;case"$<=":return typeof e=="number"&&typeof n.value=="number"&&e<=n.value;case"$>=":return typeof e=="number"&&typeof n.value=="number"&&e>=n.value;case"$=":return e===n.value;case"$in":return Array.isArray(n.value)&&n.value.includes(e);default:return!1}}),s=t.$join==="OR";let r;return s?r=i.some(n=>n):r=i.every(n=>n),t.$not?!r:r}function F(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function U(e,t,o=[],i,s){if(!A(e.$check,"$check",i))return"";const r=y(t,e.$check,o,i,s);return v(r,e)?e.$then!==void 0?e.$then:"":e.$else!==void 0?e.$else:""}function V(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function M(e,t){if(!e||typeof e!="object"){t.error("Template object cannot be null, undefined, or non-object");return}const o=Object.entries(e);if(o.length===0){t.error("Template object must have at least one tag");return}const i=o[0];if(!i){t.error("Template object must have at least one tag");return}const[s,r]=i,n=typeof r=="string"?[r]:Array.isArray(r)?r:r?.$children||[],l=r&&typeof r=="object"&&!Array.isArray(r)?Object.fromEntries(Object.entries(r).filter(([c])=>c!=="$children")):{};return{tag:s,rest:r,children:n,attrs:l}}function Y(e,t,o=[],i,s){const r=e;if(!r.$check)return i.error('"$if" tag requires $check attribute to specify the condition'),{valueToRender:void 0};if(!A(r.$check,"$check",i))return{valueToRender:void 0};const n=y(t,r.$check,o,i,s);typeof e=="object"&&e!==null&&!Array.isArray(e)&&"$children"in e&&i.warn('"$if" tag does not support $children, use $then and $else instead');const{$then:l,$else:c}=r;if(l!==void 0&&Array.isArray(l))return i.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 i.error('"$if" tag $else must be a string or single element object, not an array'),{valueToRender:void 0};const f=(typeof e=="object"&&e!==null&&!Array.isArray(e)?Object.keys(e):[]).filter($=>!I.has($));return f.length>0&&i.warn(`"$if" tag does not support attributes: ${f.join(", ")}. Allowed: ${[...I].join(", ")}`),{valueToRender:v(n,r)?l:c}}const H=(e,t)=>{if(!t)return e.length<=1?e[0]?.[1]??"":e.reduce((i,[,s])=>i+s,"");if(e.length===0)return"";if(e.length===1&&!e[0][1].includes("<"))return e[0][1];let o=` +(function(h,b){typeof exports=="object"&&typeof module<"u"?b(exports):typeof define=="function"&&define.amd?define(["exports"],b):(h=typeof globalThis<"u"?globalThis:h||self,b(h.Treebark={}))})(this,(function(h){"use strict";const b=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"]),G=new Set(["$comment","$if"]),m=new Set(["img","br","hr"]),z=new Set([...b,...G,...m]),E=new Set(["id","class","style","title","role","data-","aria-"]),N={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"])},I=new Set(["$<","$>","$<=","$>=","$=","$in"]),P=new Set(["$check","$then","$else","$not","$join",...I]),q=new Set(["behavior","-moz-binding"]);function y(e,t,o=[],i,s){if(t===".")return e;let r=e,n=t;for(;n.startsWith("..");){let l=0,c=n;for(;c.startsWith("..");)l++,c=c.substring(2),c.startsWith("/")&&(c=c.substring(1));if(l<=o.length)r=o[o.length-l],n=c.startsWith(".")?c.substring(1):c;else return s?s(t,e,o):void 0}if(n){if(i&&typeof r!="object"&&r!==null&&r!==void 0){i.error(`Cannot access property "${n}" on primitive value of type "${typeof r}"`);return}const l=n.split(".").reduce((c,u)=>c&&typeof c=="object"&&c!==null?c[u]:void 0,r);return l===void 0&&s?s(t,e,o):l}return r}function _(e){return e.replace(/[&<>"']/g,t=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[t]||t)}function g(e,t,o=!0,i=[],s,r){return e.replace(/\{\{\{([^{]*?)\}\}\}|\{\{([^{]*?)\}\}/g,(n,l,c)=>{if(l!==void 0)return`{{${l.trim()}}}`;const u=c.trim(),f=y(t,u,i,s,r);return f==null?"":o?_(String(f)):String(f)})}function D(e,t){const o=[];for(const[i,s]of Object.entries(e)){const r=i;if(!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(r)){t.warn(`CSS property "${i}" has invalid format (must be kebab-case)`);continue}if(q.has(r)){t.warn(`CSS property "${i}" is blocked for security reasons`);continue}if(s==null)continue;let n=String(s).trim();if(n.includes(";")){const u=n;n=n.split(";")[0].trim(),n&&n!==u.trim()&&t.warn(`CSS value for "${i}" contained semicolon - using only first part: "${n}"`)}if(!n)continue;const l=/url\s*\(/i.test(n),c=/url\s*\(\s*['"]?data:/i.test(n);if(l&&!c||/expression\s*\(/i.test(n)||/javascript:/i.test(n)||/@import/i.test(n)){t.warn(`CSS value for "${i}" contains potentially dangerous pattern: "${n}"`);continue}o.push(`${r}: ${n}`)}return o.join("; ").trim()}function B(e,t,o,i,s){if(e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"){const r=e;if(!A(r.$check,"$check",i))return"";const n=y(t,r.$check,o,i,s),c=v(n,r)?r.$then:r.$else;return c===void 0?"":typeof c=="object"&&c!==null&&!Array.isArray(c)?D(c,i):""}return typeof e=="object"&&e!==null&&!Array.isArray(e)?D(e,i):(i.error(`Style attribute must be an object with CSS properties, not ${typeof e}. Example: style: { "color": "red", "font-size": "14px" }`),"")}function F(e,t,o){const i=E.has(e)||[...E].some(n=>n.endsWith("-")&&e.startsWith(n)),s=N[t],r=s&&s.has(e);return!i&&!r?(o.warn(`Attribute "${e}" is not allowed on tag "${t}"`),!1):!0}function K(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$bind"in e}function A(e,t,o){return e==="."?!0:e.includes("..")?(o.error(`${t} does not support parent context access (..) - use interpolation {{..prop}} in content/attributes instead. Invalid: ${t}: "${e}"`),!1):e.includes("{{")?(o.error(`${t} does not support interpolation {{...}} - use literal property paths only. Invalid: ${t}: "${e}"`),!1):!0}function v(e,t){const o=[];for(const n of I)n in t&&o.push({key:n,value:t[n]});if(o.length===0){const n=!!e;return t.$not?!n:n}const i=o.map(n=>{switch(n.key){case"$<":return typeof e=="number"&&typeof n.value=="number"&&e":return typeof e=="number"&&typeof n.value=="number"&&e>n.value;case"$<=":return typeof e=="number"&&typeof n.value=="number"&&e<=n.value;case"$>=":return typeof e=="number"&&typeof n.value=="number"&&e>=n.value;case"$=":return e===n.value;case"$in":return Array.isArray(n.value)&&n.value.includes(e);default:return!1}}),s=t.$join==="OR";let r;return s?r=i.some(n=>n):r=i.every(n=>n),t.$not?!r:r}function U(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function V(e,t,o=[],i,s){if(!A(e.$check,"$check",i))return"";const r=y(t,e.$check,o,i,s);return v(r,e)?e.$then!==void 0?e.$then:"":e.$else!==void 0?e.$else:""}function M(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function Y(e,t){if(!e||typeof e!="object"){t.error("Template object cannot be null, undefined, or non-object");return}const o=Object.entries(e);if(o.length===0){t.error("Template object must have at least one tag");return}const i=o[0];if(!i){t.error("Template object must have at least one tag");return}const[s,r]=i,n=typeof r=="string"?[r]:Array.isArray(r)?r:r?.$children||[],l=r&&typeof r=="object"&&!Array.isArray(r)?Object.fromEntries(Object.entries(r).filter(([c])=>c!=="$children")):{};return{tag:s,rest:r,children:n,attrs:l}}function H(e,t,o=[],i,s){const r=e;if(!r.$check)return i.error('"$if" tag requires $check attribute to specify the condition'),{valueToRender:void 0};if(!A(r.$check,"$check",i))return{valueToRender:void 0};const n=y(t,r.$check,o,i,s);typeof e=="object"&&e!==null&&!Array.isArray(e)&&"$children"in e&&i.warn('"$if" tag does not support $children, use $then and $else instead');const{$then:l,$else:c}=r;if(l!==void 0&&Array.isArray(l))return i.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 i.error('"$if" tag $else must be a string or single element object, not an array'),{valueToRender:void 0};const f=(typeof e=="object"&&e!==null&&!Array.isArray(e)?Object.keys(e):[]).filter($=>!P.has($));return f.length>0&&i.warn(`"$if" tag does not support attributes: ${f.join(", ")}. Allowed: ${[...P].join(", ")}`),{valueToRender:v(n,r)?l:c}}const J=(e,t)=>{if(!t)return e.length<=1?e[0]?.[1]??"":e.reduce((i,[,s])=>i+s,"");if(e.length===0)return"";if(e.length===1&&!e[0][1].includes("<"))return e[0][1];let o=` `;for(let i=0;i`;const d=`<${e}${X(t,o,e,l,s,c)}>`;return m.has(e)?d:`${d}${u}${f}`}function p(e,t,o){const i=o.parents||[],s=o.logger,r=o.getOuterProperty;if(typeof e=="string")return C(e,t,!0,i,s,r);if(Array.isArray(e))return e.map(a=>p(a,t,o)).join(o.indentStr?` -`:"");const n=M(e,s);if(!n)return"";const{tag:l,rest:c,children:u,attrs:f}=n;if(!G.has(l))return s.error(`Tag "${l}" is not allowed`),"";if(l==="$comment"&&o.insideComment)return s.error("Nested comments are not allowed"),"";if(l==="$if"){const{valueToRender:a}=Y(c,t,i,s,r);return a===void 0?"":p(a,t,o)}m.has(l)&&u.length>0&&s.warn(`Tag "${l}" is a void element and cannot have children`);const d={...o,insideComment:l==="$comment"||o.insideComment,level:(o.level||0)+1},g=a=>a===""?[]:o.indentStr&&a.includes(` +`,o};function Q(e,t={}){const o=e.data,i=t.logger||console,s=t.propertyFallback,r=t.indent?{indentStr:typeof t.indent=="number"?" ".repeat(t.indent):typeof t.indent=="string"?t.indent:" ",level:0,logger:i,getOuterProperty:s}:{logger:i,getOuterProperty:s};return p(e.template,o,r)}function X(e,t,o,i,s,r,n,l=[],c){const u=J(i,r),f=u.startsWith(` +`)&&r?r.repeat(n||0):"";if(e==="$comment")return``;const d=`<${e}${Z(t,o,e,l,s,c)}>`;return m.has(e)?d:`${d}${u}${f}`}function p(e,t,o){const i=o.parents||[],s=o.logger,r=o.getOuterProperty;if(typeof e=="string")return g(e,t,!0,i,s,r);if(Array.isArray(e))return e.map(a=>p(a,t,o)).join(o.indentStr?` +`:"");const n=Y(e,s);if(!n)return"";const{tag:l,rest:c,children:u,attrs:f}=n;if(!z.has(l))return s.error(`Tag "${l}" is not allowed`),"";if(l==="$comment"&&o.insideComment)return s.error("Nested comments are not allowed"),"";if(l==="$if"){const{valueToRender:a}=H(c,t,i,s,r);return a===void 0?"":p(a,t,o)}m.has(l)&&u.length>0&&s.warn(`Tag "${l}" is a void element and cannot have children`);const d={...o,insideComment:l==="$comment"||o.insideComment,level:(o.level||0)+1},k=a=>a===""?[]:o.indentStr&&a.includes(` `)&&!a.includes("<")?a.split(` -`).map(j=>[d.level,j]):[[d.level,a]];let $,k;if(K(c)){if(!A(c.$bind,"$bind",s))return"";const a=y(t,c.$bind,[],s,r),{$bind:j,$filter:S,$children:D=[],...L}=c;if(!Array.isArray(a)){if(a!=null&&typeof a!="object")return s.error(`$bind resolved to primitive value of type "${typeof a}", cannot render children`),"";const T=a&&typeof a=="object"&&a!==null?a:{},w=[...i,t];return p({[l]:{...L,$children:D}},T,{...o,parents:w})}if($=[],!m.has(l))for(const T of a){const w=[...i,t];if(S&&V(S)){if(!A(S.$check,"$check",s))continue;const O=y(T,S.$check,w,s,r);if(!v(O,S))continue}for(const O of D){const Z=p(O,T,{...d,parents:w});$.push(...g(Z))}}k=L}else{if($=[],!m.has(l))for(const a of u){const j=p(a,t,{...d,parents:i});$.push(...g(j))}k=f}return Q(l,k,t,$,s,o.indentStr,o.level,i,r)}function X(e,t,o,i=[],s,r){const n=Object.entries(e).filter(([l])=>B(l,o,s)).map(([l,c])=>{let u;if(l==="style"){if(u=q(c,t,i,s,r),!u)return null}else if(F(c)){const f=U(c,t,i,s,r);u=C(String(f),t,!1,i,s,r)}else u=C(String(c),t,!1,i,s,r);return`${l}="${P(u)}"`}).filter(l=>l!==null).join(" ");return n?" "+n:""}h.renderToString=J,Object.defineProperty(h,Symbol.toStringTag,{value:"Module"})})); +`).map(T=>[d.level,T]):[[d.level,a]];let $,j;if(K(c)){if(!A(c.$bind,"$bind",s))return"";const a=y(t,c.$bind,[],s,r),{$bind:T,$filter:S,$children:L=[],...O}=c;if(!Array.isArray(a)){if(a!=null&&typeof a!="object")return s.error(`$bind resolved to primitive value of type "${typeof a}", cannot render children`),"";const w=a&&typeof a=="object"&&a!==null?a:{},C=[...i,t];return p({[l]:{...O,$children:L}},w,{...o,parents:C})}if($=[],!m.has(l)){const w=S&&M(S);if(w&&!A(S.$check,"$check",s))return j=O,"";for(const C of a){const W=[...i,t];if(w){const R=y(C,S.$check,W,s,r);if(!v(R,S))continue}for(const R of L){const x=p(R,C,{...d,parents:W});$.push(...k(x))}}}j=O}else{if($=[],!m.has(l))for(const a of u){const T=p(a,t,{...d,parents:i});$.push(...k(T))}j=f}return X(l,j,t,$,s,o.indentStr,o.level,i,r)}function Z(e,t,o,i=[],s,r){const n=Object.entries(e).filter(([l])=>F(l,o,s)).map(([l,c])=>{let u;if(l==="style"){if(u=B(c,t,i,s,r),!u)return null}else if(U(c)){const f=V(c,t,i,s,r);u=g(String(f),t,!1,i,s,r)}else u=g(String(c),t,!1,i,s,r);return`${l}="${_(u)}"`}).filter(l=>l!==null).join(" ");return n?" "+n:""}h.renderToString=Q,Object.defineProperty(h,Symbol.toStringTag,{value:"Module"})})); //# sourceMappingURL=treebark-browser.min.js.map diff --git a/nodejs/packages/treebark/src/dom.ts b/nodejs/packages/treebark/src/dom.ts index 22dcac3..c3c809f 100644 --- a/nodejs/packages/treebark/src/dom.ts +++ b/nodejs/packages/treebark/src/dom.ts @@ -135,15 +135,19 @@ function render(template: TemplateElement | TemplateElement[], data: Data, conte } if (Array.isArray(bound)) { + // Validate filter once before the loop + const hasFilter = $filter && isFilterCondition($filter); + if (hasFilter && !validatePathExpression($filter.$check, '$check', logger)) { + // Invalid filter path - skip rendering all items + return element; + } + for (const item of bound) { // For array items, add current data context to parents const newParents = [...parents, data]; // Apply $filter if present - skip items that don't match - if ($filter && isFilterCondition($filter)) { - if (!validatePathExpression($filter.$check, '$check', logger)) { - continue; - } + if (hasFilter) { const checkValue = getProperty(item as Data, $filter.$check, newParents, logger, getOuterProperty); if (!evaluateCondition(checkValue, $filter)) { continue; diff --git a/nodejs/packages/treebark/src/string.ts b/nodejs/packages/treebark/src/string.ts index b9bae47..598789a 100644 --- a/nodejs/packages/treebark/src/string.ts +++ b/nodejs/packages/treebark/src/string.ts @@ -180,14 +180,19 @@ function render(template: TemplateElement | TemplateElement[], data: Data, conte childrenOutput = []; // Skip children for void tags if (!VOID_TAGS.has(tag)) { + // Validate filter once before the loop + const hasFilter = $filter && isFilterCondition($filter); + if (hasFilter && !validatePathExpression($filter.$check, '$check', logger)) { + // Invalid filter path - skip rendering all items + contentAttrs = bindAttrs; + return ''; + } + for (const item of bound) { const newParents = [...parents, data]; // Apply $filter if present - skip items that don't match - if ($filter && isFilterCondition($filter)) { - if (!validatePathExpression($filter.$check, '$check', logger)) { - continue; - } + if (hasFilter) { const checkValue = getProperty(item as Data, $filter.$check, newParents, logger, getOuterProperty); if (!evaluateCondition(checkValue, $filter)) { continue; From 57c379cf5c4657e2c2f941aa87f3e1924328d27d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 01:02:21 +0000 Subject: [PATCH 11/24] Move Filtering Arrays section to just before Conditional Rendering in README Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- README.md | 76 +++++++++++++++++++++++++++---------------------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index a643602..763d6c9 100644 --- a/README.md +++ b/README.md @@ -718,6 +718,44 @@ Output: **Note:** Numeric indices work because JavaScript allows both `array[0]` and `array["0"]` syntax. The dot notation path is split and each segment (including numeric strings) is used as a property key. +### Comments + +HTML comments can be created using the `comment` tag: + +```json +{ "$comment": "This is a comment" } +``` + +Output: +```html + +``` + +Comments support interpolation and mixed content like other tags: + +```json +{ + "$comment": { + "$children": [ + "Generated by {{generator}} on ", + { "span": "{{date}}" } + ] + } +} +``` + +Data: +```json +{ "generator": "Treebark", "date": "2024-01-01" } +``` + +Output: +```html + +``` + +**Note:** Comments cannot be nested - attempting to place a `$comment` tag inside another `$comment` logs an error and skips the nested comment. + ### Filtering Arrays You can filter array items before rendering them by using `$filter` with `$bind`. The `$filter` key uses the same conditional operators as the `$if` tag. @@ -866,44 +904,6 @@ Simply filter to show only in-stock items: This is cleaner and more explicit about intent! -### Comments - -HTML comments can be created using the `comment` tag: - -```json -{ "$comment": "This is a comment" } -``` - -Output: -```html - -``` - -Comments support interpolation and mixed content like other tags: - -```json -{ - "$comment": { - "$children": [ - "Generated by {{generator}} on ", - { "span": "{{date}}" } - ] - } -} -``` - -Data: -```json -{ "generator": "Treebark", "date": "2024-01-01" } -``` - -Output: -```html - -``` - -**Note:** Comments cannot be nested - attempting to place a `$comment` tag inside another `$comment` logs an error and skips the nested comment. - ### Conditional Rendering The `$if` tag provides powerful conditional rendering with comparison operators and if/else branching. It doesn't render itself as an HTML element—it conditionally outputs a single element based on the condition. From b4b679f8e24691fa5ef1432bd3fee891aeda83c4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 01:23:54 +0000 Subject: [PATCH 12/24] Remove Filter vs If comparison example from Filtering section Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- README.md | 48 ------------------------------------------------ 1 file changed, 48 deletions(-) diff --git a/README.md b/README.md index 763d6c9..fbb0238 100644 --- a/README.md +++ b/README.md @@ -856,54 +856,6 @@ This filters for working-age adults (18-65 inclusive). - `$not`: Invert the condition - `$join`: Combine operators with "AND" (default) or "OR" logic -**Practical example - Filter vs If:** - -Instead of wrapping each item in an $if to conditionally render it: -```json -{ - "div": { - "$bind": "products", - "$children": { - "$if": { - "$check": "inStock", - "$then": { - "div": { - "$children": [ - { "h3": "{{name}}" }, - { "p": "✓ In Stock ({{quantity}} available)" } - ] - } - } - } - } - } -} -``` - -Simply filter to show only in-stock items: -```json -{ - "div": { - "$bind": "products", - "$filter": { - "$check": "inStock" - }, - "$children": [ - { - "div": { - "$children": [ - { "h3": "{{name}}" }, - { "p": "✓ In Stock ({{quantity}} available)" } - ] - } - } - ] - } -} -``` - -This is cleaner and more explicit about intent! - ### Conditional Rendering The `$if` tag provides powerful conditional rendering with comparison operators and if/else branching. It doesn't render itself as an HTML element—it conditionally outputs a single element based on the condition. From 5f1fa1c2c928cb1a080b8b3566673101c2205a2c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 03:04:50 +0000 Subject: [PATCH 13/24] Fix filter-comparison playground example to use $else instead of second $if with $not Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- docs/js/playground.js | 10 ++-------- .../playground/src/examples/filter-comparison.ts | 10 ++-------- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/docs/js/playground.js b/docs/js/playground.js index cafe1db..8910edc 100644 --- a/docs/js/playground.js +++ b/docs/js/playground.js @@ -1081,14 +1081,8 @@ style: { color: "green" }, $children: ["✓ In Stock ({{quantity}} available)"] } - } - } - }, - { - $if: { - $check: "inStock", - $not: true, - $then: { + }, + $else: { p: { style: { color: "red" }, $children: ["✗ Out of Stock"] diff --git a/nodejs/packages/playground/src/examples/filter-comparison.ts b/nodejs/packages/playground/src/examples/filter-comparison.ts index 2b0cfdb..eb44ae0 100644 --- a/nodejs/packages/playground/src/examples/filter-comparison.ts +++ b/nodejs/packages/playground/src/examples/filter-comparison.ts @@ -32,14 +32,8 @@ export const filterComparison: Example = { style: { color: "green" }, $children: ["✓ In Stock ({{quantity}} available)"] } - } - } - }, - { - $if: { - $check: "inStock", - $not: true, - $then: { + }, + $else: { p: { style: { color: "red" }, $children: ["✗ Out of Stock"] From 9ad6eba5d9dbbe724dee9d5960fa07a482ab5b55 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 03:30:29 +0000 Subject: [PATCH 14/24] Document type-safety for numeric comparisons, reorganize operator sections, add 5 type-safety tests Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- README.md | 41 +++---- nodejs/packages/test/src/common-tests.ts | 131 +++++++++++++++++++++++ nodejs/packages/test/src/dom.test.ts | 15 +++ nodejs/packages/test/src/string.test.ts | 15 +++ spec.md | 40 ++++--- 5 files changed, 211 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index fbb0238..76055d1 100644 --- a/README.md +++ b/README.md @@ -758,7 +758,16 @@ Output: ### Filtering Arrays -You can filter array items before rendering them by using `$filter` with `$bind`. The `$filter` key uses the same conditional operators as the `$if` tag. +You can filter array items before rendering them by using `$filter` with `$bind`. + +**Available filter operators:** +- `$<`, `$>`, `$<=`, `$>=`: Numeric comparisons (both values must be numbers) +- `$=`: Strict equality +- `$in`: Array membership check +- `$not`: Invert the condition +- `$join`: Combine operators with "AND" (default) or "OR" logic + +**Note:** Numeric comparison operators (`$<`, `$>`, `$<=`, `$>=`) require both the checked value and comparison value to be numbers. String values like `"110"` will not match numeric comparisons even though JavaScript would coerce them. This type-safety prevents unpredictable filtering behavior. **Filter by price:** ```json @@ -849,17 +858,21 @@ Output: This filters for working-age adults (18-65 inclusive). -**Available filter operators:** -- `$<`, `$>`, `$<=`, `$>=`: Numeric comparisons -- `$=`: Strict equality -- `$in`: Array membership check -- `$not`: Invert the condition -- `$join`: Combine operators with "AND" (default) or "OR" logic - ### Conditional Rendering The `$if` tag provides powerful conditional rendering with comparison operators and if/else branching. It doesn't render itself as an HTML element—it conditionally outputs a single element based on the condition. +**Available conditional operators:** +- `$<`, `$>`, `$<=`, `$>=`: Numeric comparisons (both values must be numbers) +- `$=`: Strict equality (===) +- `$in`: Array membership +- `$not`: Invert the condition +- `$join`: Combine operators with "AND" (default) or "OR" logic +- `$then`: Element to render when condition is true +- `$else`: Element to render when condition is false + +**Note:** Numeric comparison operators require both values to be numbers for type-safety. + **Basic truthiness check:** ```json { @@ -920,9 +933,9 @@ With `data: { isLoggedIn: false }`:

          Please log in

          ``` -**Comparison operators:** +**Stacking comparison operators:** -The `$if` tag supports powerful comparison operators that can be stacked: +Multiple comparison operators can be combined for range checks: ```json { @@ -942,14 +955,6 @@ The `$if` tag supports powerful comparison operators that can be stacked: } ``` -Available operators: -- `$<`: Less than -- `$>`: Greater than -- `$<=`: Less than or equal -- `$>=`: Greater than or equal -- `$=`: Strict equality (===) -- `$in`: Array membership - **Using `$>=` and `$<=` for inclusive ranges:** ```json diff --git a/nodejs/packages/test/src/common-tests.ts b/nodejs/packages/test/src/common-tests.ts index a2843fb..3f03f41 100644 --- a/nodejs/packages/test/src/common-tests.ts +++ b/nodejs/packages/test/src/common-tests.ts @@ -2507,6 +2507,137 @@ export const filterTests: TestCase[] = [ ] } } + }, + { + name: 'type safety: filters out string values when comparing with numbers using $>', + input: { + template: { + ul: { + $bind: 'items', + $filter: { + $check: 'value', + '$>': 10 + }, + $children: [ + { li: '{{name}}: {{value}}' } + ] + } + }, + data: { + items: [ + { name: 'Number 15', value: 15 }, + { name: 'Number 5', value: 5 }, + { name: 'String 110', value: '110' }, + { name: 'String 2', value: '2' } + ] + } + } + }, + { + name: 'type safety: filters out string values when comparing with numbers using $<', + input: { + template: { + ul: { + $bind: 'items', + $filter: { + $check: 'value', + '$<': 100 + }, + $children: [ + { li: '{{name}}: {{value}}' } + ] + } + }, + data: { + items: [ + { name: 'Number 50', value: 50 }, + { name: 'Number 150', value: 150 }, + { name: 'String 75', value: '75' }, + { name: 'String 200', value: '200' } + ] + } + } + }, + { + name: 'type safety: filters out string values when comparing with numbers using $>=', + input: { + template: { + ul: { + $bind: 'items', + $filter: { + $check: 'value', + '$>=': 18 + }, + $children: [ + { li: '{{name}}: {{value}}' } + ] + } + }, + data: { + items: [ + { name: 'Number 18', value: 18 }, + { name: 'Number 25', value: 25 }, + { name: 'Number 10', value: 10 }, + { name: 'String 20', value: '20' }, + { name: 'String 15', value: '15' } + ] + } + } + }, + { + name: 'type safety: filters out string values when comparing with numbers using $<=', + input: { + template: { + ul: { + $bind: 'items', + $filter: { + $check: 'value', + '$<=': 65 + }, + $children: [ + { li: '{{name}}: {{value}}' } + ] + } + }, + data: { + items: [ + { name: 'Number 40', value: 40 }, + { name: 'Number 65', value: 65 }, + { name: 'Number 70', value: 70 }, + { name: 'String 50', value: '50' }, + { name: 'String 80', value: '80' } + ] + } + } + }, + { + name: 'type safety: allows all numbers in range with numeric operators', + input: { + template: { + ul: { + $bind: 'items', + $filter: { + $check: 'value', + '$>=': 10, + '$<=': 100 + }, + $children: [ + { li: '{{name}}: {{value}}' } + ] + } + }, + data: { + items: [ + { name: 'Number 10', value: 10 }, + { name: 'Number 50', value: 50 }, + { name: 'Number 100', value: 100 }, + { name: 'Number 5', value: 5 }, + { name: 'Number 150', value: 150 }, + { name: 'String 50', value: '50' }, + { name: 'String 75', value: '75' } + ] + } + } } ]; diff --git a/nodejs/packages/test/src/dom.test.ts b/nodejs/packages/test/src/dom.test.ts index 04b4809..f4971c8 100644 --- a/nodejs/packages/test/src/dom.test.ts +++ b/nodejs/packages/test/src/dom.test.ts @@ -891,6 +891,21 @@ describe('DOM Renderer', () => { case 'works without $filter (no filtering)': expect(div.innerHTML).toBe('
          • Item 1
          • Item 2
          '); break; + case 'type safety: filters out string values when comparing with numbers using $>': + expect(div.innerHTML).toBe('
          • Number 15: 15
          '); + break; + case 'type safety: filters out string values when comparing with numbers using $<': + expect(div.innerHTML).toBe('
          • Number 50: 50
          '); + break; + case 'type safety: filters out string values when comparing with numbers using $>=': + expect(div.innerHTML).toBe('
          • Number 18: 18
          • Number 25: 25
          '); + break; + case 'type safety: filters out string values when comparing with numbers using $<=': + expect(div.innerHTML).toBe('
          • Number 40: 40
          • Number 65: 65
          '); + break; + case 'type safety: allows all numbers in range with numeric operators': + expect(div.innerHTML).toBe('
          • Number 10: 10
          • Number 50: 50
          • Number 100: 100
          '); + break; default: throw new Error(`Unknown test case: ${tc.name}`); } diff --git a/nodejs/packages/test/src/string.test.ts b/nodejs/packages/test/src/string.test.ts index 5dd478e..d4c3304 100644 --- a/nodejs/packages/test/src/string.test.ts +++ b/nodejs/packages/test/src/string.test.ts @@ -1009,6 +1009,21 @@ describe('String Renderer', () => { case 'works without $filter (no filtering)': expect(result).toBe('
          • Item 1
          • Item 2
          '); break; + case 'type safety: filters out string values when comparing with numbers using $>': + expect(result).toBe('
          • Number 15: 15
          '); + break; + case 'type safety: filters out string values when comparing with numbers using $<': + expect(result).toBe('
          • Number 50: 50
          '); + break; + case 'type safety: filters out string values when comparing with numbers using $>=': + expect(result).toBe('
          • Number 18: 18
          • Number 25: 25
          '); + break; + case 'type safety: filters out string values when comparing with numbers using $<=': + expect(result).toBe('
          • Number 40: 40
          • Number 65: 65
          '); + break; + case 'type safety: allows all numbers in range with numeric operators': + expect(result).toBe('
          • Number 10: 10
          • Number 50: 50
          • Number 100: 100
          '); + break; default: throw new Error(`Unknown test case: ${tc.name}`); } diff --git a/spec.md b/spec.md index 0a9944b..8d8a8c7 100644 --- a/spec.md +++ b/spec.md @@ -388,7 +388,19 @@ $comment: ## 13. Filtering Arrays with $filter -The `$filter` key works with `$bind` to filter array items before rendering them. It uses the same conditional operators as the `$if` tag. +The `$filter` key works with `$bind` to filter array items before rendering them. + +**Supported operators:** +- `$<`: Less than (numeric comparison, both values must be numbers) +- `$>`: Greater than (numeric comparison, both values must be numbers) +- `$<=`: Less than or equal (numeric comparison, both values must be numbers) +- `$>=`: Greater than or equal (numeric comparison, both values must be numbers) +- `$=`: Strict equality (===) +- `$in`: Array membership check +- `$not`: Invert the condition result +- `$join`: "AND" | "OR" - Combine multiple operators (default: "AND") + +**Type Safety:** Numeric comparison operators (`$<`, `$>`, `$<=`, `$>=`) require both the checked value and comparison value to be numbers. String values will not match numeric comparisons, even though JavaScript would coerce them. This prevents unpredictable filtering behavior. **Syntax:** ```javascript @@ -511,16 +523,6 @@ Output: `
          • Bob
          ` ``` Output: `
          • Alice
          • Charlie
          ` -**Supported operators:** -- `$<`: Less than -- `$>`: Greater than -- `$<=`: Less than or equal -- `$>=`: Greater than or equal -- `$=`: Strict equality (===) -- `$in`: Array membership check -- `$not`: Invert the condition result -- `$join`: "AND" | "OR" - Combine multiple operators (default: "AND") - **Key differences from `$if` tag:** - `$filter` is used with `$bind` to filter arrays - `$filter` does not use `$then` or `$else` (it only evaluates true/false) @@ -533,11 +535,23 @@ Output: `
          • Alice
          • Charlie
          ` The `$if` tag provides advanced conditional rendering based on data properties. It acts as a transparent container that renders its children only when specified conditions are met. +**Supported operators:** +- `$<`: Less than (numeric comparison, both values must be numbers) +- `$>`: Greater than (numeric comparison, both values must be numbers) +- `$<=`: Less than or equal (numeric comparison, both values must be numbers) +- `$>=`: Greater than or equal (numeric comparison, both values must be numbers) +- `$=`: Strict equality (===) +- `$in`: Array membership check +- `$not`: Invert the final result +- `$join`: "AND" | "OR" - Combine multiple operators (default: "AND") +- `$then` (or `$thenChildren`): Element(s) to render when condition is true +- `$else` (or `$elseChildren`): Element(s) to render when condition is false + +**Type Safety:** Numeric comparison operators require both values to be numbers. This prevents unpredictable behavior from JavaScript type coercion. + **Key Features:** - Uses `$check` to specify the property to check -- Supports comparison operators: `$<`, `$>`, `$<=`, `$>=`, `$=`, `$in` - Operators can be stacked (multiple operators) -- Supports `$not` to invert the final result - Uses AND logic by default, can switch to OR logic with `$join: "OR"` - Supports `$thenChildren` and `$elseChildren` for explicit if/else branching - Does not render itself as an HTML element From f7d04dbca9bd94b8bd693d64936f8c3604087073 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 03:41:01 +0000 Subject: [PATCH 15/24] Alphabetize $in, $join, $not operators in README operator lists Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 76055d1..e31afbe 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ This means the implementation is featherweight. - `$join`: "AND" | "OR". Combines multiple operators (default: "AND"). **Conditional keys (used in `$if` tag and conditional attribute values):** -- All filter keys above (`$check`, `$<`, `$>`, `$<=`, `$>=`, `$=`, `$in`, `$not`, `$join`), plus: +- All filter keys above (`$check`, `$<`, `$>`, `$<=`, `$>=`, `$=`, `$in`, `$join`, `$not`), plus: - `$then`: Single template object or string. Content/value when condition is true. - `$else`: Single template object or string. Content/value when condition is false. @@ -764,8 +764,8 @@ You can filter array items before rendering them by using `$filter` with `$bind` - `$<`, `$>`, `$<=`, `$>=`: Numeric comparisons (both values must be numbers) - `$=`: Strict equality - `$in`: Array membership check -- `$not`: Invert the condition - `$join`: Combine operators with "AND" (default) or "OR" logic +- `$not`: Invert the condition **Note:** Numeric comparison operators (`$<`, `$>`, `$<=`, `$>=`) require both the checked value and comparison value to be numbers. String values like `"110"` will not match numeric comparisons even though JavaScript would coerce them. This type-safety prevents unpredictable filtering behavior. @@ -866,8 +866,8 @@ The `$if` tag provides powerful conditional rendering with comparison operators - `$<`, `$>`, `$<=`, `$>=`: Numeric comparisons (both values must be numbers) - `$=`: Strict equality (===) - `$in`: Array membership -- `$not`: Invert the condition - `$join`: Combine operators with "AND" (default) or "OR" logic +- `$not`: Invert the condition - `$then`: Element to render when condition is true - `$else`: Element to render when condition is false From ae2172576d6bc6e588044438b27e3513160a4b83 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 03:57:22 +0000 Subject: [PATCH 16/24] Reorganize README: add Filtering Arrays to TOC, move Comments after Conditional Rendering Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- README.md | 79 ++++++++++++++++++++++++++++--------------------------- 1 file changed, 40 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index e31afbe..5c5cc74 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,9 @@ Output: - [Parent Property Access](#parent-property-access) - [Working with Arrays](#working-with-arrays) - [Array Element Access](#array-element-access) - - [Comments](#comments) + - [Filtering Arrays](#filtering-arrays) - [Conditional Rendering](#conditional-rendering) + - [Comments](#comments) - [Error Handling](#error-handling) - [Format Notes](#format-notes) - [Available Libraries](#available-libraries) @@ -718,44 +719,6 @@ Output: **Note:** Numeric indices work because JavaScript allows both `array[0]` and `array["0"]` syntax. The dot notation path is split and each segment (including numeric strings) is used as a property key. -### Comments - -HTML comments can be created using the `comment` tag: - -```json -{ "$comment": "This is a comment" } -``` - -Output: -```html - -``` - -Comments support interpolation and mixed content like other tags: - -```json -{ - "$comment": { - "$children": [ - "Generated by {{generator}} on ", - { "span": "{{date}}" } - ] - } -} -``` - -Data: -```json -{ "generator": "Treebark", "date": "2024-01-01" } -``` - -Output: -```html - -``` - -**Note:** Comments cannot be nested - attempting to place a `$comment` tag inside another `$comment` logs an error and skips the nested comment. - ### Filtering Arrays You can filter array items before rendering them by using `$filter` with `$bind`. @@ -1112,6 +1075,44 @@ 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` +### Comments + +HTML comments can be created using the `comment` tag: + +```json +{ "$comment": "This is a comment" } +``` + +Output: +```html + +``` + +Comments support interpolation and mixed content like other tags: + +```json +{ + "$comment": { + "$children": [ + "Generated by {{generator}} on ", + { "span": "{{date}}" } + ] + } +} +``` + +Data: +```json +{ "generator": "Treebark", "date": "2024-01-01" } +``` + +Output: +```html + +``` + +**Note:** Comments cannot be nested - attempting to place a `$comment` tag inside another `$comment` logs an error and skips the nested comment. + ## 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. From fbd5e8d941a983e6c377fd048cd13c694dd2c96f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 05:37:53 +0000 Subject: [PATCH 17/24] Move Styling with Style Objects section after Conditional Rendering in README Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- README.md | 204 +++++++++--------- docs/assets/markdown-it-treebark-browser.js | 27 ++- .../markdown-it-treebark-browser.min.js | 16 +- 3 files changed, 122 insertions(+), 125 deletions(-) diff --git a/README.md b/README.md index 5c5cc74..9d545ac 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,6 @@ Output: - [Examples](#examples) - [Nested Elements](#nested-elements) - [Attributes](#attributes) - - [Styling with Style Objects](#styling-with-style-objects) - [Mixed Content](#mixed-content) - [With Data Binding](#with-data-binding) - [Binding with $bind](#binding-with-bind) @@ -41,6 +40,7 @@ Output: - [Array Element Access](#array-element-access) - [Filtering Arrays](#filtering-arrays) - [Conditional Rendering](#conditional-rendering) + - [Styling with Style Objects](#styling-with-style-objects) - [Comments](#comments) - [Error Handling](#error-handling) - [Format Notes](#format-notes) @@ -210,107 +210,6 @@ Output: Visit our site ``` -### Styling with Style Objects - -For security, Treebark uses a **structured object format** for the `style` attribute. This prevents CSS injection attacks while maintaining flexibility. - -**Basic styling:** -```json -{ - "div": { - "style": { - "color": "red", - "font-size": "16px", - "padding": "10px" - }, - "$children": ["Styled content"] - } -} -``` - -Output: -```html -
          Styled content
          -``` - -**Key features:** -- **Kebab-case property names**: Use standard CSS property names like `font-size`, `background-color`, etc. -- **Dangerous patterns blocked**: `url()` (except data: URIs), `expression()`, `javascript:`, `@import` -- **Blocked properties**: `behavior`, `-moz-binding` (known dangerous properties) -- **Type safety**: Values are strings - -**Flexbox example:** -```json -{ - "div": { - "style": { - "display": "flex", - "flex-direction": "column", - "justify-content": "center", - "align-items": "center", - "gap": "20px" - }, - "$children": ["Flexbox layout"] - } -} -``` - -**Grid example:** -```json -{ - "div": { - "style": { - "display": "grid", - "grid-template-columns": "repeat(3, 1fr)", - "gap": "10px" - }, - "$children": ["Grid layout"] - } -} -``` - -**Conditional styles:** -```json -{ - "div": { - "style": { - "$check": "isActive", - "$then": { "color": "green", "font-weight": "bold" }, - "$else": { "color": "gray" } - }, - "$children": ["Status"] - } -} -``` - -#### Tags without attributes -For `br` & `hr` tags, use an empty object: - -```json -{ - "div": { - "$children": [ - "Line one", - { "br": {} }, - "Line two", - { "hr": {} }, - "Footer text" - ] - } -} -``` - -Output: -```html -
          - Line one -
          - Line two -
          - Footer text -
          -``` - ### Mixed Content ```json @@ -1075,6 +974,107 @@ 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` +### Styling with Style Objects + +For security, Treebark uses a **structured object format** for the `style` attribute. This prevents CSS injection attacks while maintaining flexibility. + +**Basic styling:** +```json +{ + "div": { + "style": { + "color": "red", + "font-size": "16px", + "padding": "10px" + }, + "$children": ["Styled content"] + } +} +``` + +Output: +```html +
          Styled content
          +``` + +**Key features:** +- **Kebab-case property names**: Use standard CSS property names like `font-size`, `background-color`, etc. +- **Dangerous patterns blocked**: `url()` (except data: URIs), `expression()`, `javascript:`, `@import` +- **Blocked properties**: `behavior`, `-moz-binding` (known dangerous properties) +- **Type safety**: Values are strings + +**Flexbox example:** +```json +{ + "div": { + "style": { + "display": "flex", + "flex-direction": "column", + "justify-content": "center", + "align-items": "center", + "gap": "20px" + }, + "$children": ["Flexbox layout"] + } +} +``` + +**Grid example:** +```json +{ + "div": { + "style": { + "display": "grid", + "grid-template-columns": "repeat(3, 1fr)", + "gap": "10px" + }, + "$children": ["Grid layout"] + } +} +``` + +**Conditional styles:** +```json +{ + "div": { + "style": { + "$check": "isActive", + "$then": { "color": "green", "font-weight": "bold" }, + "$else": { "color": "gray" } + }, + "$children": ["Status"] + } +} +``` + +#### Tags without attributes +For `br` & `hr` tags, use an empty object: + +```json +{ + "div": { + "$children": [ + "Line one", + { "br": {} }, + "Line two", + { "hr": {} }, + "Footer text" + ] + } +} +``` + +Output: +```html +
          + Line one +
          + Line two +
          + Footer text +
          +``` + ### Comments HTML comments can be created using the `comment` tag: diff --git a/docs/assets/markdown-it-treebark-browser.js b/docs/assets/markdown-it-treebark-browser.js index 1f82d70..4ab7848 100644 --- a/docs/assets/markdown-it-treebark-browser.js +++ b/docs/assets/markdown-it-treebark-browser.js @@ -255,13 +255,6 @@ function isFilterCondition(value) { return value !== null && typeof value === "object" && !Array.isArray(value) && "$check" in value && typeof value.$check === "string"; } - function evaluateFilterCondition(item, filter, parents = [], logger, getOuterProperty) { - if (!validatePathExpression(filter.$check, "$check", logger)) { - return false; - } - const checkValue = getProperty(item, filter.$check, parents, logger, getOuterProperty); - return evaluateCondition(checkValue, filter); - } function parseTemplateObject(templateObj, logger) { if (!templateObj || typeof templateObj !== "object") { logger.error("Template object cannot be null, undefined, or non-object"); @@ -415,17 +408,21 @@ const newParents = [...parents, data]; return render({ [tag]: { ...bindAttrs, $children } }, boundData, { ...context, parents: newParents }); } - let itemsToRender = bound; - if ($filter && isFilterCondition($filter)) { - itemsToRender = bound.filter((item) => { - const newParents = [...parents, data]; - return evaluateFilterCondition(item, $filter, newParents, logger, getOuterProperty); - }); - } childrenOutput = []; if (!VOID_TAGS.has(tag)) { - for (const item of itemsToRender) { + const hasFilter = $filter && isFilterCondition($filter); + if (hasFilter && !validatePathExpression($filter.$check, "$check", logger)) { + contentAttrs = bindAttrs; + return ""; + } + for (const item of bound) { const newParents = [...parents, data]; + if (hasFilter) { + const checkValue = getProperty(item, $filter.$check, newParents, logger, getOuterProperty); + if (!evaluateCondition(checkValue, $filter)) { + continue; + } + } for (const child of $children) { const content = render(child, item, { ...childContext, parents: newParents }); childrenOutput.push(...processContent(content)); diff --git a/docs/assets/markdown-it-treebark-browser.min.js b/docs/assets/markdown-it-treebark-browser.min.js index 4cebd58..ea3bd20 100644 --- a/docs/assets/markdown-it-treebark-browser.min.js +++ b/docs/assets/markdown-it-treebark-browser.min.js @@ -1,11 +1,11 @@ -(function(b,A){typeof exports=="object"&&typeof module<"u"?module.exports=A():typeof define=="function"&&define.amd?define(A):(b=typeof globalThis<"u"?globalThis:b||self,b.MarkdownItTreebark=A())})(this,(function(){"use strict";const b=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"]),A=new Set(["$comment","$if"]),S=new Set(["img","br","hr"]),N=new Set([...b,...A,...S]),E=new Set(["id","class","style","title","role","data-","aria-"]),F={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]),G=new Set(["behavior","-moz-binding"]);function p(e,t,o=[],i,c){if(t===".")return e;let n=e,r=t;for(;r.startsWith("..");){let a=0,s=r;for(;s.startsWith("..");)a++,s=s.substring(2),s.startsWith("/")&&(s=s.substring(1));if(a<=o.length)n=o[o.length-a],r=s.startsWith(".")?s.substring(1):s;else return c?c(t,e,o):void 0}if(r){if(i&&typeof n!="object"&&n!==null&&n!==void 0){i.error(`Cannot access property "${r}" on primitive value of type "${typeof n}"`);return}const a=r.split(".").reduce((s,f)=>s&&typeof s=="object"&&s!==null?s[f]:void 0,n);return a===void 0&&c?c(t,e,o):a}return n}function I(e){return e.replace(/[&<>"']/g,t=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[t]||t)}function k(e,t,o=!0,i=[],c,n){return e.replace(/\{\{\{([^{]*?)\}\}\}|\{\{([^{]*?)\}\}/g,(r,a,s)=>{if(a!==void 0)return`{{${a.trim()}}}`;const f=s.trim(),u=p(t,f,i,c,n);return u==null?"":o?I(String(u)):String(u)})}function L(e,t){const o=[];for(const[i,c]of Object.entries(e)){const n=i;if(!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(n)){t.warn(`CSS property "${i}" has invalid format (must be kebab-case)`);continue}if(G.has(n)){t.warn(`CSS property "${i}" is blocked for security reasons`);continue}if(c==null)continue;let r=String(c).trim();if(r.includes(";")){const f=r;r=r.split(";")[0].trim(),r&&r!==f.trim()&&t.warn(`CSS value for "${i}" contained semicolon - using only first part: "${r}"`)}if(!r)continue;const a=/url\s*\(/i.test(r),s=/url\s*\(\s*['"]?data:/i.test(r);if(a&&!s||/expression\s*\(/i.test(r)||/javascript:/i.test(r)||/@import/i.test(r)){t.warn(`CSS value for "${i}" contains potentially dangerous pattern: "${r}"`);continue}o.push(`${n}: ${r}`)}return o.join("; ").trim()}function q(e,t,o,i,c){if(e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"){const n=e;if(!w(n.$check,"$check",i))return"";const r=p(t,n.$check,o,i,c),s=T(r,n)?n.$then:n.$else;return s===void 0?"":typeof s=="object"&&s!==null&&!Array.isArray(s)?L(s,i):""}return typeof e=="object"&&e!==null&&!Array.isArray(e)?L(e,i):(i.error(`Style attribute must be an object with CSS properties, not ${typeof e}. Example: style: { "color": "red", "font-size": "14px" }`),"")}function z(e,t,o){const i=E.has(e)||[...E].some(r=>r.endsWith("-")&&e.startsWith(r)),c=F[t],n=c&&c.has(e);return!i&&!n?(o.warn(`Attribute "${e}" is not allowed on tag "${t}"`),!1):!0}function B(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$bind"in e}function w(e,t,o){return e==="."?!0:e.includes("..")?(o.error(`${t} does not support parent context access (..) - use interpolation {{..prop}} in content/attributes instead. Invalid: ${t}: "${e}"`),!1):e.includes("{{")?(o.error(`${t} does not support interpolation {{...}} - use literal property paths only. Invalid: ${t}: "${e}"`),!1):!0}function T(e,t){const o=[];for(const r of O)r in t&&o.push({key:r,value:t[r]});if(o.length===0){const r=!!e;return t.$not?!r:r}const i=o.map(r=>{switch(r.key){case"$<":return typeof e=="number"&&typeof r.value=="number"&&e":return typeof e=="number"&&typeof r.value=="number"&&e>r.value;case"$<=":return typeof e=="number"&&typeof r.value=="number"&&e<=r.value;case"$>=":return typeof e=="number"&&typeof r.value=="number"&&e>=r.value;case"$=":return e===r.value;case"$in":return Array.isArray(r.value)&&r.value.includes(e);default:return!1}}),c=t.$join==="OR";let n;return c?n=i.some(r=>r):n=i.every(r=>r),t.$not?!n:n}function M(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function K(e,t,o=[],i,c){if(!w(e.$check,"$check",i))return"";const n=p(t,e.$check,o,i,c);return T(n,e)?e.$then!==void 0?e.$then:"":e.$else!==void 0?e.$else:""}function Y(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function J(e,t,o=[],i,c){if(!w(t.$check,"$check",i))return!1;const n=p(e,t.$check,o,i,c);return T(n,t)}function U(e,t){if(!e||typeof e!="object"){t.error("Template object cannot be null, undefined, or non-object");return}const o=Object.entries(e);if(o.length===0){t.error("Template object must have at least one tag");return}const i=o[0];if(!i){t.error("Template object must have at least one tag");return}const[c,n]=i,r=typeof n=="string"?[n]:Array.isArray(n)?n:n?.$children||[],a=n&&typeof n=="object"&&!Array.isArray(n)?Object.fromEntries(Object.entries(n).filter(([s])=>s!=="$children")):{};return{tag:c,rest:n,children:r,attrs:a}}function V(e,t,o=[],i,c){const n=e;if(!n.$check)return i.error('"$if" tag requires $check attribute to specify the condition'),{valueToRender:void 0};if(!w(n.$check,"$check",i))return{valueToRender:void 0};const r=p(t,n.$check,o,i,c);typeof e=="object"&&e!==null&&!Array.isArray(e)&&"$children"in e&&i.warn('"$if" tag does not support $children, use $then and $else instead');const{$then:a,$else:s}=n;if(a!==void 0&&Array.isArray(a))return i.error('"$if" tag $then must be a string or single element object, not an array'),{valueToRender:void 0};if(s!==void 0&&Array.isArray(s))return i.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($=>!R.has($));return u.length>0&&i.warn(`"$if" tag does not support attributes: ${u.join(", ")}. Allowed: ${[...R].join(", ")}`),{valueToRender:T(r,n)?a:s}}const H=(e,t)=>{if(!t)return e.length<=1?e[0]?.[1]??"":e.reduce((i,[,c])=>i+c,"");if(e.length===0)return"";if(e.length===1&&!e[0][1].includes("<"))return e[0][1];let o=` -`;for(let i=0;i","$<=","$>=","$=","$in"]),L=new Set(["$check","$then","$else","$not","$join",...I]),q=new Set(["behavior","-moz-binding"]);function y(e,n,o=[],i,c){if(n===".")return e;let t=e,r=n;for(;r.startsWith("..");){let a=0,s=r;for(;s.startsWith("..");)a++,s=s.substring(2),s.startsWith("/")&&(s=s.substring(1));if(a<=o.length)t=o[o.length-a],r=s.startsWith(".")?s.substring(1):s;else return c?c(n,e,o):void 0}if(r){if(i&&typeof t!="object"&&t!==null&&t!==void 0){i.error(`Cannot access property "${r}" on primitive value of type "${typeof t}"`);return}const a=r.split(".").reduce((s,f)=>s&&typeof s=="object"&&s!==null?s[f]:void 0,t);return a===void 0&&c?c(n,e,o):a}return t}function P(e){return e.replace(/[&<>"']/g,n=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[n]||n)}function C(e,n,o=!0,i=[],c,t){return e.replace(/\{\{\{([^{]*?)\}\}\}|\{\{([^{]*?)\}\}/g,(r,a,s)=>{if(a!==void 0)return`{{${a.trim()}}}`;const f=s.trim(),u=y(n,f,i,c,t);return u==null?"":o?P(String(u)):String(u)})}function W(e,n){const o=[];for(const[i,c]of Object.entries(e)){const t=i;if(!/^[a-z]([a-z0-9-]*[a-z0-9])?$/.test(t)){n.warn(`CSS property "${i}" has invalid format (must be kebab-case)`);continue}if(q.has(t)){n.warn(`CSS property "${i}" is blocked for security reasons`);continue}if(c==null)continue;let r=String(c).trim();if(r.includes(";")){const f=r;r=r.split(";")[0].trim(),r&&r!==f.trim()&&n.warn(`CSS value for "${i}" contained semicolon - using only first part: "${r}"`)}if(!r)continue;const a=/url\s*\(/i.test(r),s=/url\s*\(\s*['"]?data:/i.test(r);if(a&&!s||/expression\s*\(/i.test(r)||/javascript:/i.test(r)||/@import/i.test(r)){n.warn(`CSS value for "${i}" contains potentially dangerous pattern: "${r}"`);continue}o.push(`${t}: ${r}`)}return o.join("; ").trim()}function z(e,n,o,i,c){if(e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"){const t=e;if(!w(t.$check,"$check",i))return"";const r=y(n,t.$check,o,i,c),s=j(r,t)?t.$then:t.$else;return s===void 0?"":typeof s=="object"&&s!==null&&!Array.isArray(s)?W(s,i):""}return typeof e=="object"&&e!==null&&!Array.isArray(e)?W(e,i):(i.error(`Style attribute must be an object with CSS properties, not ${typeof e}. Example: style: { "color": "red", "font-size": "14px" }`),"")}function B(e,n,o){const i=R.has(e)||[...R].some(r=>r.endsWith("-")&&e.startsWith(r)),c=G[n],t=c&&c.has(e);return!i&&!t?(o.warn(`Attribute "${e}" is not allowed on tag "${n}"`),!1):!0}function M(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$bind"in e}function w(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 j(e,n){const o=[];for(const r of I)r in n&&o.push({key:r,value:n[r]});if(o.length===0){const r=!!e;return n.$not?!r:r}const i=o.map(r=>{switch(r.key){case"$<":return typeof e=="number"&&typeof r.value=="number"&&e":return typeof e=="number"&&typeof r.value=="number"&&e>r.value;case"$<=":return typeof e=="number"&&typeof r.value=="number"&&e<=r.value;case"$>=":return typeof e=="number"&&typeof r.value=="number"&&e>=r.value;case"$=":return e===r.value;case"$in":return Array.isArray(r.value)&&r.value.includes(e);default:return!1}}),c=n.$join==="OR";let t;return c?t=i.some(r=>r):t=i.every(r=>r),n.$not?!t:t}function K(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function Y(e,n,o=[],i,c){if(!w(e.$check,"$check",i))return"";const t=y(n,e.$check,o,i,c);return j(t,e)?e.$then!==void 0?e.$then:"":e.$else!==void 0?e.$else:""}function J(e){return e!==null&&typeof e=="object"&&!Array.isArray(e)&&"$check"in e&&typeof e.$check=="string"}function U(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 i=o[0];if(!i){n.error("Template object must have at least one tag");return}const[c,t]=i,r=typeof t=="string"?[t]:Array.isArray(t)?t:t?.$children||[],a=t&&typeof t=="object"&&!Array.isArray(t)?Object.fromEntries(Object.entries(t).filter(([s])=>s!=="$children")):{};return{tag:c,rest:t,children:r,attrs:a}}function V(e,n,o=[],i,c){const t=e;if(!t.$check)return i.error('"$if" tag requires $check attribute to specify the condition'),{valueToRender:void 0};if(!w(t.$check,"$check",i))return{valueToRender:void 0};const r=y(n,t.$check,o,i,c);typeof e=="object"&&e!==null&&!Array.isArray(e)&&"$children"in e&&i.warn('"$if" tag does not support $children, use $then and $else instead');const{$then:a,$else:s}=t;if(a!==void 0&&Array.isArray(a))return i.error('"$if" tag $then must be a string or single element object, not an array'),{valueToRender:void 0};if(s!==void 0&&Array.isArray(s))return i.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($=>!L.has($));return u.length>0&&i.warn(`"$if" tag does not support attributes: ${u.join(", ")}. Allowed: ${[...L].join(", ")}`),{valueToRender:j(r,t)?a:s}}const H=(e,n)=>{if(!n)return e.length<=1?e[0]?.[1]??"":e.reduce((i,[,c])=>i+c,"");if(e.length===0)return"";if(e.length===1&&!e[0][1].includes("<"))return e[0][1];let o=` +`;for(let i=0;i`;const d=`<${e}${X(t,o,e,a,c,s)}>`;return S.has(e)?d:`${d}${f}${u}`}function y(e,t,o){const i=o.parents||[],c=o.logger,n=o.getOuterProperty;if(typeof e=="string")return k(e,t,!0,i,c,n);if(Array.isArray(e))return e.map(l=>y(l,t,o)).join(o.indentStr?` -`:"");const r=U(e,c);if(!r)return"";const{tag:a,rest:s,children:f,attrs:u}=r;if(!N.has(a))return c.error(`Tag "${a}" is not allowed`),"";if(a==="$comment"&&o.insideComment)return c.error("Nested comments are not allowed"),"";if(a==="$if"){const{valueToRender:l}=V(s,t,i,c,n);return l===void 0?"":y(l,t,o)}S.has(a)&&f.length>0&&c.warn(`Tag "${a}" is a void element and cannot have children`);const d={...o,insideComment:a==="$comment"||o.insideComment,level:(o.level||0)+1},h=l=>l===""?[]:o.indentStr&&l.includes(` +`,o};function _(e,n={}){const o=e.data,i=n.logger||console,c=n.propertyFallback,t=n.indent?{indentStr:typeof n.indent=="number"?" ".repeat(n.indent):typeof n.indent=="string"?n.indent:" ",level:0,logger:i,getOuterProperty:c}:{logger:i,getOuterProperty:c};return m(e.template,o,t)}function Q(e,n,o,i,c,t,r,a=[],s){const f=H(i,t),u=f.startsWith(` +`)&&t?t.repeat(r||0):"";if(e==="$comment")return``;const d=`<${e}${X(n,o,e,a,c,s)}>`;return S.has(e)?d:`${d}${f}${u}`}function m(e,n,o){const i=o.parents||[],c=o.logger,t=o.getOuterProperty;if(typeof e=="string")return C(e,n,!0,i,c,t);if(Array.isArray(e))return e.map(l=>m(l,n,o)).join(o.indentStr?` +`:"");const r=U(e,c);if(!r)return"";const{tag:a,rest:s,children:f,attrs:u}=r;if(!F.has(a))return c.error(`Tag "${a}" is not allowed`),"";if(a==="$comment"&&o.insideComment)return c.error("Nested comments are not allowed"),"";if(a==="$if"){const{valueToRender:l}=V(s,n,i,c,t);return l===void 0?"":m(l,n,o)}S.has(a)&&f.length>0&&c.warn(`Tag "${a}" is a void element and cannot have children`);const d={...o,insideComment:a==="$comment"||o.insideComment,level:(o.level||0)+1},h=l=>l===""?[]:o.indentStr&&l.includes(` `)&&!l.includes("<")?l.split(` -`).map(g=>[d.level,g]):[[d.level,l]];let $,m;if(B(s)){if(!w(s.$bind,"$bind",c))return"";const l=p(t,s.$bind,[],c,n),{$bind:g,$filter:C,$children:W=[],..._}=s;if(!Array.isArray(l)){if(l!=null&&typeof l!="object")return c.error(`$bind resolved to primitive value of type "${typeof l}", cannot render children`),"";const v=l&&typeof l=="object"&&l!==null?l:{},j=[...i,t];return y({[a]:{..._,$children:W}},v,{...o,parents:j})}let D=l;if(C&&Y(C)&&(D=l.filter(v=>{const j=[...i,t];return J(v,C,j,c,n)})),$=[],!S.has(a))for(const v of D){const j=[...i,t];for(const te of W){const ne=y(te,v,{...d,parents:j});$.push(...h(ne))}}m=_}else{if($=[],!S.has(a))for(const l of f){const g=y(l,t,{...d,parents:i});$.push(...h(g))}m=u}return Q(a,m,t,$,c,o.indentStr,o.level,i,n)}function X(e,t,o,i=[],c,n){const r=Object.entries(e).filter(([a])=>z(a,o,c)).map(([a,s])=>{let f;if(a==="style"){if(f=q(s,t,i,c,n),!f)return null}else if(M(s)){const u=K(s,t,i,c,n);f=k(String(u),t,!1,i,c,n)}else f=k(String(s),t,!1,i,c,n);return`${a}="${I(f)}"`}).filter(a=>a!==null).join(" ");return r?" "+r:""}function Z(e,t={}){const{data:o={},yaml:i,indent:c,logger:n}=t,r=e.renderer.rules.fence;e.renderer.rules.fence=function(a,s,f,u,d){const h=a[s],$=h.info?h.info.trim():"";if($==="treebark"||$.startsWith("treebark "))try{return x(h.content,o,i,c,n)+` -`}catch(m){const l=m instanceof Error?m.message:"Unknown error";return`
          Treebark Error: ${ee(l)}
          -`}return r?r(a,s,f,u,d):""}}function x(e,t,o,i,c){let n,r=null;if(!e.trim())throw new Error("Empty or invalid template");if(o)try{n=o.load(e)}catch(s){r=s instanceof Error?s:new Error("YAML parsing failed")}if(!n)try{n=JSON.parse(e)}catch(s){throw o&&r?new Error(`Failed to parse as YAML or JSON. YAML error: ${r.message}`):new Error(`Failed to parse as JSON: ${s instanceof Error?s.message:"Invalid format"}`)}if(!n)throw new Error("Empty or invalid template");const a={indent:i,logger:c};if(n&&typeof n=="object"&&"template"in n){const s={...t,...n.data};return P({template:n.template,data:s},a)}else return P({template:n,data:t},a)}function ee(e){const t={"&":"&","<":"<",">":">",'"':""","'":"'"};return e.replace(/[&<>"']/g,o=>t[o])}return Z})); +`).map(T=>[d.level,T]):[[d.level,l]];let $,p;if(M(s)){if(!w(s.$bind,"$bind",c))return"";const l=y(n,s.$bind,[],c,t),{$bind:T,$filter:v,$children:D=[],...E}=s;if(!Array.isArray(l)){if(l!=null&&typeof l!="object")return c.error(`$bind resolved to primitive value of type "${typeof l}", cannot render children`),"";const g=l&&typeof l=="object"&&l!==null?l:{},k=[...i,n];return m({[a]:{...E,$children:D}},g,{...o,parents:k})}if($=[],!S.has(a)){const g=v&&J(v);if(g&&!w(v.$check,"$check",c))return p=E,"";for(const k of l){const N=[...i,n];if(g){const O=y(k,v.$check,N,c,t);if(!j(O,v))continue}for(const O of D){const te=m(O,k,{...d,parents:N});$.push(...h(te))}}}p=E}else{if($=[],!S.has(a))for(const l of f){const T=m(l,n,{...d,parents:i});$.push(...h(T))}p=u}return Q(a,p,n,$,c,o.indentStr,o.level,i,t)}function X(e,n,o,i=[],c,t){const r=Object.entries(e).filter(([a])=>B(a,o,c)).map(([a,s])=>{let f;if(a==="style"){if(f=z(s,n,i,c,t),!f)return null}else if(K(s)){const u=Y(s,n,i,c,t);f=C(String(u),n,!1,i,c,t)}else f=C(String(s),n,!1,i,c,t);return`${a}="${P(f)}"`}).filter(a=>a!==null).join(" ");return r?" "+r:""}function Z(e,n={}){const{data:o={},yaml:i,indent:c,logger:t}=n,r=e.renderer.rules.fence;e.renderer.rules.fence=function(a,s,f,u,d){const h=a[s],$=h.info?h.info.trim():"";if($==="treebark"||$.startsWith("treebark "))try{return x(h.content,o,i,c,t)+` +`}catch(p){const l=p instanceof Error?p.message:"Unknown error";return`
          Treebark Error: ${ee(l)}
          +`}return r?r(a,s,f,u,d):""}}function x(e,n,o,i,c){let t,r=null;if(!e.trim())throw new Error("Empty or invalid template");if(o)try{t=o.load(e)}catch(s){r=s instanceof Error?s:new Error("YAML parsing failed")}if(!t)try{t=JSON.parse(e)}catch(s){throw o&&r?new Error(`Failed to parse as YAML or JSON. YAML error: ${r.message}`):new Error(`Failed to parse as JSON: ${s instanceof Error?s.message:"Invalid format"}`)}if(!t)throw new Error("Empty or invalid template");const a={indent:i,logger:c};if(t&&typeof t=="object"&&"template"in t){const s={...n,...t.data};return _({template:t.template,data:s},a)}else return _({template:t,data:n},a)}function ee(e){const n={"&":"&","<":"<",">":">",'"':""","'":"'"};return e.replace(/[&<>"']/g,o=>n[o])}return Z})); //# sourceMappingURL=markdown-it-treebark-browser.min.js.map From 349afed118725cf3749e3162fd026934a2714095 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 05:50:00 +0000 Subject: [PATCH 18/24] Add explanation of $check, $then, $else operators to Conditional styles section Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 9d545ac..f050256 100644 --- a/README.md +++ b/README.md @@ -1034,6 +1034,9 @@ Output: ``` **Conditional styles:** + +You can apply conditional logic to styles using the same operators from filtering and conditionals (`$check`, `$then`, `$else`, etc.): + ```json { "div": { @@ -1047,6 +1050,8 @@ Output: } ``` +This checks if `isActive` is truthy. If true, applies green color and bold font. Otherwise, applies gray color. + #### Tags without attributes For `br` & `hr` tags, use an empty object: From c6af03ef1e27673b5eae8ee6305514236c21dd9e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 12 Dec 2025 06:23:05 +0000 Subject: [PATCH 19/24] List all available conditional operators explicitly in Conditional styles section Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f050256..df56e4f 100644 --- a/README.md +++ b/README.md @@ -1035,7 +1035,7 @@ Output: **Conditional styles:** -You can apply conditional logic to styles using the same operators from filtering and conditionals (`$check`, `$then`, `$else`, etc.): +You can apply conditional logic to styles using these operators: `$check`, `$<`, `$>`, `$<=`, `$>=`, `$=`, `$in`, `$join`, `$not`, `$then`, `$else`: ```json { From 99372afa510582d01594f84dd0cd0d056121b85a Mon Sep 17 00:00:00 2001 From: Dan Marshall Date: Fri, 12 Dec 2025 20:52:54 -0800 Subject: [PATCH 20/24] regen homepage from readme --- docs/index.md | 371 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 243 insertions(+), 128 deletions(-) diff --git a/docs/index.md b/docs/index.md index 07281fd..573e740 100644 --- a/docs/index.md +++ b/docs/index.md @@ -38,15 +38,16 @@ Output: - [Examples](#examples) - [Nested Elements](#nested-elements) - [Attributes](#attributes) - - [Styling with Style Objects](#styling-with-style-objects) - [Mixed Content](#mixed-content) - [With Data Binding](#with-data-binding) - [Binding with $bind](#binding-with-bind) - [Parent Property Access](#parent-property-access) - [Working with Arrays](#working-with-arrays) - [Array Element Access](#array-element-access) - - [Comments](#comments) + - [Filtering Arrays](#filtering-arrays) - [Conditional Rendering](#conditional-rendering) + - [Styling with Style Objects](#styling-with-style-objects) + - [Comments](#comments) - [Error Handling](#error-handling) - [Format Notes](#format-notes) - [Available Libraries](#available-libraries) @@ -115,10 +116,9 @@ This means the implementation is featherweight. - `$children`: Array or string. Defines child nodes or mixed content for an element. - `$bind`: String. Binds the current node to a property or array in the data context. If it resolves to an array, the element's children are repeated for each item. -**Conditional keys (used in `$if` tag and conditional attribute values):** +**Filter keys (used with `$bind` to filter array items):** +- `$filter`: Object containing the filter condition. - `$check`: String. Property path to check. -- `$then`: Single template object or string. Content/value when condition is true. -- `$else`: Single template object or string. Content/value when condition is false. - `$<`: Less than comparison. - `$>`: Greater than comparison. - `$<=`: Less than or equal comparison. @@ -128,6 +128,11 @@ This means the implementation is featherweight. - `$not`: Boolean. Inverts the entire condition result. - `$join`: "AND" | "OR". Combines multiple operators (default: "AND"). +**Conditional keys (used in `$if` tag and conditional attribute values):** +- All filter keys above (`$check`, `$<`, `$>`, `$<=`, `$>=`, `$=`, `$in`, `$join`, `$not`), plus: +- `$then`: Single template object or string. Content/value when condition is true. +- `$else`: Single template object or string. Content/value when condition is false. + ## Examples ### Nested Elements @@ -211,107 +216,6 @@ Output: Visit our site ``` -### Styling with Style Objects - -For security, Treebark uses a **structured object format** for the `style` attribute. This prevents CSS injection attacks while maintaining flexibility. - -**Basic styling:** -```json -{ - "div": { - "style": { - "color": "red", - "font-size": "16px", - "padding": "10px" - }, - "$children": ["Styled content"] - } -} -``` - -Output: -```html -
          Styled content
          -``` - -**Key features:** -- **Kebab-case property names**: Use standard CSS property names like `font-size`, `background-color`, etc. -- **Dangerous patterns blocked**: `url()` (except data: URIs), `expression()`, `javascript:`, `@import` -- **Blocked properties**: `behavior`, `-moz-binding` (known dangerous properties) -- **Type safety**: Values are strings - -**Flexbox example:** -```json -{ - "div": { - "style": { - "display": "flex", - "flex-direction": "column", - "justify-content": "center", - "align-items": "center", - "gap": "20px" - }, - "$children": ["Flexbox layout"] - } -} -``` - -**Grid example:** -```json -{ - "div": { - "style": { - "display": "grid", - "grid-template-columns": "repeat(3, 1fr)", - "gap": "10px" - }, - "$children": ["Grid layout"] - } -} -``` - -**Conditional styles:** -```json -{ - "div": { - "style": { - "$check": "isActive", - "$then": { "color": "green", "font-weight": "bold" }, - "$else": { "color": "gray" } - }, - "$children": ["Status"] - } -} -``` - -#### Tags without attributes -For `br` & `hr` tags, use an empty object: - -```json -{ - "div": { - "$children": [ - "Line one", - { "br": {} }, - "Line two", - { "hr": {} }, - "Footer text" - ] - } -} -``` - -Output: -```html -
          - Line one -
          - Line two -
          - Footer text -
          -``` - ### Mixed Content ```json @@ -720,27 +624,65 @@ Output: **Note:** Numeric indices work because JavaScript allows both `array[0]` and `array["0"]` syntax. The dot notation path is split and each segment (including numeric strings) is used as a property key. -### Comments +### Filtering Arrays -HTML comments can be created using the `comment` tag: +You can filter array items before rendering them by using `$filter` with `$bind`. + +**Available filter operators:** +- `$<`, `$>`, `$<=`, `$>=`: Numeric comparisons (both values must be numbers) +- `$=`: Strict equality +- `$in`: Array membership check +- `$join`: Combine operators with "AND" (default) or "OR" logic +- `$not`: Invert the condition +**Note:** Numeric comparison operators (`$<`, `$>`, `$<=`, `$>=`) require both the checked value and comparison value to be numbers. String values like `"110"` will not match numeric comparisons even though JavaScript would coerce them. This type-safety prevents unpredictable filtering behavior. + +**Filter by price:** ```json -{ "$comment": "This is a comment" } +{ + "ul": { + "$bind": "products", + "$filter": { + "$check": "price", + "$<": 500 + }, + "$children": [ + { "li": "{% raw %}{{name}}{% endraw %} — ${% raw %}{{price}}{% endraw %}" } + ] + } +} +``` + +Data: +```json +{ + "products": [ + { "name": "Laptop", "price": 999 }, + { "name": "Mouse", "price": 25 }, + { "name": "Keyboard", "price": 75 } + ] +} ``` Output: ```html - +
            +
          • Mouse — $25
          • +
          • Keyboard — $75
          • +
          ``` -Comments support interpolation and mixed content like other tags: - +**Filter by role:** ```json { - "$comment": { + "ul": { + "$bind": "users", + "$filter": { + "$check": "role", + "$in": ["admin", "moderator"] + }, "$children": [ - "Generated by {% raw %}{{generator}}{% endraw %} on ", - { "span": "{% raw %}{{date}}{% endraw %}" } + { "li": "{% raw %}{{name}}{% endraw %} ({% raw %}{{role}}{% endraw %})" } ] } } @@ -748,20 +690,57 @@ Comments support interpolation and mixed content like other tags: Data: ```json -{ "generator": "Treebark", "date": "2024-01-01" } +{ + "users": [ + { "name": "Alice", "role": "admin" }, + { "name": "Bob", "role": "user" }, + { "name": "Charlie", "role": "moderator" } + ] +} ``` Output: ```html - +
            +
          • Alice (admin)
          • +
          • Charlie (moderator)
          • +
          ``` -**Note:** Comments cannot be nested - attempting to place a `$comment` tag inside another `$comment` logs an error and skips the nested comment. +**Filter with range:** +```json +{ + "ul": { + "$bind": "people", + "$filter": { + "$check": "age", + "$>=": 18, + "$<=": 65 + }, + "$children": [ + { "li": "{% raw %}{{name}}{% endraw %} ({% raw %}{{age}}{% endraw %})" } + ] + } +} +``` + +This filters for working-age adults (18-65 inclusive). ### Conditional Rendering The `$if` tag provides powerful conditional rendering with comparison operators and if/else branching. It doesn't render itself as an HTML element—it conditionally outputs a single element based on the condition. +**Available conditional operators:** +- `$<`, `$>`, `$<=`, `$>=`: Numeric comparisons (both values must be numbers) +- `$=`: Strict equality (===) +- `$in`: Array membership +- `$join`: Combine operators with "AND" (default) or "OR" logic +- `$not`: Invert the condition +- `$then`: Element to render when condition is true +- `$else`: Element to render when condition is false + +**Note:** Numeric comparison operators require both values to be numbers for type-safety. + **Basic truthiness check:** ```json { @@ -822,9 +801,9 @@ With `data: { isLoggedIn: false }`:

          Please log in

          ``` -**Comparison operators:** +**Stacking comparison operators:** -The `$if` tag supports powerful comparison operators that can be stacked: +Multiple comparison operators can be combined for range checks: ```json { @@ -844,14 +823,6 @@ The `$if` tag supports powerful comparison operators that can be stacked: } ``` -Available operators: -- `$<`: Less than -- `$>`: Greater than -- `$<=`: Less than or equal -- `$>=`: Greater than or equal -- `$=`: Strict equality (===) -- `$in`: Array membership - **Using `$>=` and `$<=` for inclusive ranges:** ```json @@ -1009,6 +980,150 @@ 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` +### Styling with Style Objects + +For security, Treebark uses a **structured object format** for the `style` attribute. This prevents CSS injection attacks while maintaining flexibility. + +**Basic styling:** +```json +{ + "div": { + "style": { + "color": "red", + "font-size": "16px", + "padding": "10px" + }, + "$children": ["Styled content"] + } +} +``` + +Output: +```html +
          Styled content
          +``` + +**Key features:** +- **Kebab-case property names**: Use standard CSS property names like `font-size`, `background-color`, etc. +- **Dangerous patterns blocked**: `url()` (except data: URIs), `expression()`, `javascript:`, `@import` +- **Blocked properties**: `behavior`, `-moz-binding` (known dangerous properties) +- **Type safety**: Values are strings + +**Flexbox example:** +```json +{ + "div": { + "style": { + "display": "flex", + "flex-direction": "column", + "justify-content": "center", + "align-items": "center", + "gap": "20px" + }, + "$children": ["Flexbox layout"] + } +} +``` + +**Grid example:** +```json +{ + "div": { + "style": { + "display": "grid", + "grid-template-columns": "repeat(3, 1fr)", + "gap": "10px" + }, + "$children": ["Grid layout"] + } +} +``` + +**Conditional styles:** + +You can apply conditional logic to styles using these operators: `$check`, `$<`, `$>`, `$<=`, `$>=`, `$=`, `$in`, `$join`, `$not`, `$then`, `$else`: + +```json +{ + "div": { + "style": { + "$check": "isActive", + "$then": { "color": "green", "font-weight": "bold" }, + "$else": { "color": "gray" } + }, + "$children": ["Status"] + } +} +``` + +This checks if `isActive` is truthy. If true, applies green color and bold font. Otherwise, applies gray color. + +#### Tags without attributes +For `br` & `hr` tags, use an empty object: + +```json +{ + "div": { + "$children": [ + "Line one", + { "br": {} }, + "Line two", + { "hr": {} }, + "Footer text" + ] + } +} +``` + +Output: +```html +
          + Line one +
          + Line two +
          + Footer text +
          +``` + +### Comments + +HTML comments can be created using the `comment` tag: + +```json +{ "$comment": "This is a comment" } +``` + +Output: +```html + +``` + +Comments support interpolation and mixed content like other tags: + +```json +{ + "$comment": { + "$children": [ + "Generated by {% raw %}{{generator}}{% endraw %} on ", + { "span": "{% raw %}{{date}}{% endraw %}" } + ] + } +} +``` + +Data: +```json +{ "generator": "Treebark", "date": "2024-01-01" } +``` + +Output: +```html + +``` + +**Note:** Comments cannot be nested - attempting to place a `$comment` tag inside another `$comment` logs an error and skips the nested comment. + ## 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. From 6857f1f784c969781783d6f84dae413b33ba1236 Mon Sep 17 00:00:00 2001 From: Dan Marshall Date: Fri, 12 Dec 2025 20:54:24 -0800 Subject: [PATCH 21/24] bump version to 2.1.0 --- nodejs/package-lock.json | 2 +- nodejs/packages/treebark/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index fca9f19..ec591bb 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -7915,7 +7915,7 @@ } }, "packages/treebark": { - "version": "2.0.10", + "version": "2.1.0", "license": "MIT", "devDependencies": {}, "engines": { diff --git a/nodejs/packages/treebark/package.json b/nodejs/packages/treebark/package.json index ed2a1d5..2386a89 100644 --- a/nodejs/packages/treebark/package.json +++ b/nodejs/packages/treebark/package.json @@ -1,6 +1,6 @@ { "name": "treebark", - "version": "2.0.10", + "version": "2.1.0", "description": "Safe HTML tree structures for Markdown and content-driven apps", "type": "module", "files": [ From 185957d54a84e992e06200cad199af465566fb1d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 13 Dec 2025 05:01:38 +0000 Subject: [PATCH 22/24] Fix filter-comparison playground to make apples-to-apples comparison with $if at $bind level Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- docs/js/playground.js | 36 ++++++++----------- .../src/examples/filter-comparison.ts | 36 ++++++++----------- 2 files changed, 30 insertions(+), 42 deletions(-) diff --git a/docs/js/playground.js b/docs/js/playground.js index 8910edc..c3616dd 100644 --- a/docs/js/playground.js +++ b/docs/js/playground.js @@ -1056,41 +1056,35 @@ class: "comparison-demo", $children: [ { h1: "Filter vs If: Comparison" }, - // Old way: Using $if tags to conditionally display status + // Old way: Using $if tag to conditionally render each item { div: { class: "method old-way", $children: [ - { h2: "❌ Old Way: Using $if tags" }, - { p: "Shows all products with conditional status messages" }, + { h2: "❌ Old Way: Using $if tag" }, + { p: "Shows only in-stock products using conditional rendering" }, { div: { $bind: "products", $children: [ { - div: { - class: "product-item", - $children: [ - { h3: "{{name}}" }, - { p: "Price: {{price}}" }, - { - $if: { - $check: "inStock", - $then: { + $if: { + $check: "inStock", + $then: { + div: { + class: "product-item", + $children: [ + { h3: "{{name}}" }, + { p: "Price: {{price}}" }, + { p: { style: { color: "green" }, $children: ["✓ In Stock ({{quantity}} available)"] } - }, - $else: { - p: { - style: { color: "red" }, - $children: ["✗ Out of Stock"] - } } - } + ] } - ] + } } } ] @@ -1106,7 +1100,7 @@ class: "method new-way", $children: [ { h2: "✅ New Way: Using $filter" }, - { p: "Shows only in-stock products (cleaner, simpler)" }, + { p: "Shows only in-stock products (cleaner, more declarative)" }, { div: { $bind: "products", diff --git a/nodejs/packages/playground/src/examples/filter-comparison.ts b/nodejs/packages/playground/src/examples/filter-comparison.ts index eb44ae0..7f05d40 100644 --- a/nodejs/packages/playground/src/examples/filter-comparison.ts +++ b/nodejs/packages/playground/src/examples/filter-comparison.ts @@ -7,41 +7,35 @@ export const filterComparison: Example = { $children: [ { h1: "Filter vs If: Comparison" }, - // Old way: Using $if tags to conditionally display status + // Old way: Using $if tag to conditionally render each item { div: { class: "method old-way", $children: [ - { h2: "❌ Old Way: Using $if tags" }, - { p: "Shows all products with conditional status messages" }, + { h2: "❌ Old Way: Using $if tag" }, + { p: "Shows only in-stock products using conditional rendering" }, { div: { $bind: "products", $children: [ { - div: { - class: "product-item", - $children: [ - { h3: "{{name}}" }, - { p: "Price: {{price}}" }, - { - $if: { - $check: "inStock", - $then: { + $if: { + $check: "inStock", + $then: { + div: { + class: "product-item", + $children: [ + { h3: "{{name}}" }, + { p: "Price: {{price}}" }, + { p: { style: { color: "green" }, $children: ["✓ In Stock ({{quantity}} available)"] } - }, - $else: { - p: { - style: { color: "red" }, - $children: ["✗ Out of Stock"] - } } - } + ] } - ] + } } } ] @@ -59,7 +53,7 @@ export const filterComparison: Example = { class: "method new-way", $children: [ { h2: "✅ New Way: Using $filter" }, - { p: "Shows only in-stock products (cleaner, simpler)" }, + { p: "Shows only in-stock products (cleaner, more declarative)" }, { div: { $bind: "products", From 0a091474e6488949b890397f86eabbe09047d8cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 13 Dec 2025 05:41:18 +0000 Subject: [PATCH 23/24] Simplify filter-comparison playground to use ul/li with minimal nesting Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- docs/js/playground.js | 42 ++++--------------- .../src/examples/filter-comparison.ts | 42 ++++--------------- 2 files changed, 14 insertions(+), 70 deletions(-) diff --git a/docs/js/playground.js b/docs/js/playground.js index c3616dd..a1008f4 100644 --- a/docs/js/playground.js +++ b/docs/js/playground.js @@ -1059,31 +1059,17 @@ // Old way: Using $if tag to conditionally render each item { div: { - class: "method old-way", $children: [ { h2: "❌ Old Way: Using $if tag" }, - { p: "Shows only in-stock products using conditional rendering" }, { - div: { + ul: { $bind: "products", $children: [ { $if: { $check: "inStock", $then: { - div: { - class: "product-item", - $children: [ - { h3: "{{name}}" }, - { p: "Price: {{price}}" }, - { - p: { - style: { color: "green" }, - $children: ["✓ In Stock ({{quantity}} available)"] - } - } - ] - } + li: "{{name}}" } } } @@ -1097,31 +1083,17 @@ // New way: Using $filter to show only in-stock items { div: { - class: "method new-way", $children: [ { h2: "✅ New Way: Using $filter" }, - { p: "Shows only in-stock products (cleaner, more declarative)" }, { - div: { + ul: { $bind: "products", $filter: { $check: "inStock" }, $children: [ { - div: { - class: "product-item", - $children: [ - { h3: "{{name}}" }, - { p: "Price: {{price}}" }, - { - p: { - style: { color: "green" }, - $children: ["✓ In Stock ({{quantity}} available)"] - } - } - ] - } + li: "{{name}}" } ] } @@ -1134,9 +1106,9 @@ }, data: { products: [ - { name: "Laptop", price: "$999", inStock: true, quantity: 15 }, - { name: "Phone", price: "$499", inStock: false, quantity: 0 }, - { name: "Tablet", price: "$299", inStock: true, quantity: 8 } + { name: "Laptop", inStock: true }, + { name: "Phone", inStock: false }, + { name: "Tablet", inStock: true } ] } }; diff --git a/nodejs/packages/playground/src/examples/filter-comparison.ts b/nodejs/packages/playground/src/examples/filter-comparison.ts index 7f05d40..5fe6139 100644 --- a/nodejs/packages/playground/src/examples/filter-comparison.ts +++ b/nodejs/packages/playground/src/examples/filter-comparison.ts @@ -10,31 +10,17 @@ export const filterComparison: Example = { // Old way: Using $if tag to conditionally render each item { div: { - class: "method old-way", $children: [ { h2: "❌ Old Way: Using $if tag" }, - { p: "Shows only in-stock products using conditional rendering" }, { - div: { + ul: { $bind: "products", $children: [ { $if: { $check: "inStock", $then: { - div: { - class: "product-item", - $children: [ - { h3: "{{name}}" }, - { p: "Price: {{price}}" }, - { - p: { - style: { color: "green" }, - $children: ["✓ In Stock ({{quantity}} available)"] - } - } - ] - } + li: "{{name}}" } } } @@ -50,31 +36,17 @@ export const filterComparison: Example = { // New way: Using $filter to show only in-stock items { div: { - class: "method new-way", $children: [ { h2: "✅ New Way: Using $filter" }, - { p: "Shows only in-stock products (cleaner, more declarative)" }, { - div: { + ul: { $bind: "products", $filter: { $check: "inStock" }, $children: [ { - div: { - class: "product-item", - $children: [ - { h3: "{{name}}" }, - { p: "Price: {{price}}" }, - { - p: { - style: { color: "green" }, - $children: ["✓ In Stock ({{quantity}} available)"] - } - } - ] - } + li: "{{name}}" } ] } @@ -87,9 +59,9 @@ export const filterComparison: Example = { }, data: { products: [ - { name: "Laptop", price: "$999", inStock: true, quantity: 15 }, - { name: "Phone", price: "$499", inStock: false, quantity: 0 }, - { name: "Tablet", price: "$299", inStock: true, quantity: 8 } + { name: "Laptop", inStock: true }, + { name: "Phone", inStock: false }, + { name: "Tablet", inStock: true } ] } }; From 6ae3d053d585248c6665cb6454d9c35127a99f64 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 15 Dec 2025 20:16:16 +0000 Subject: [PATCH 24/24] Add links to Filtering Arrays and Conditional Rendering sections in Special Keys Co-authored-by: danmarshall <11507384+danmarshall@users.noreply.github.com> --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index df56e4f..4a2b6ec 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ This means the implementation is featherweight. **Special tags:** - `$comment` — Emits HTML comments. Cannot be nested inside another `$comment`. -- `$if` — Conditional rendering based on data properties with comparison operators. See [Conditional Rendering](#conditional-rendering) below. +- `$if` — Conditional rendering based on data properties with comparison operators. See [Conditional Rendering](#conditional-rendering). ### Allowed Attributes @@ -110,7 +110,7 @@ This means the implementation is featherweight. - `$children`: Array or string. Defines child nodes or mixed content for an element. - `$bind`: String. Binds the current node to a property or array in the data context. If it resolves to an array, the element's children are repeated for each item. -**Filter keys (used with `$bind` to filter array items):** +**Filter keys (used with `$bind` to filter array items — see [Filtering Arrays](#filtering-arrays)):** - `$filter`: Object containing the filter condition. - `$check`: String. Property path to check. - `$<`: Less than comparison. @@ -122,7 +122,7 @@ This means the implementation is featherweight. - `$not`: Boolean. Inverts the entire condition result. - `$join`: "AND" | "OR". Combines multiple operators (default: "AND"). -**Conditional keys (used in `$if` tag and conditional attribute values):** +**Conditional keys (used in `$if` tag and conditional attribute values — see [Conditional Rendering](#conditional-rendering)):** - All filter keys above (`$check`, `$<`, `$>`, `$<=`, `$>=`, `$=`, `$in`, `$join`, `$not`), plus: - `$then`: Single template object or string. Content/value when condition is true. - `$else`: Single template object or string. Content/value when condition is false.