diff --git a/README.md b/README.md index fd299c7..4a2b6ec 100644 --- a/README.md +++ b/README.md @@ -32,15 +32,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) @@ -90,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 @@ -109,10 +110,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 — see [Filtering Arrays](#filtering-arrays)):** +- `$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. @@ -122,6 +122,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 — 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. + ## Examples ### Nested Elements @@ -205,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 @@ -714,27 +618,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": "{{name}} — ${{price}}" } + ] + } +} +``` + +Data: +```json +{ + "products": [ + { "name": "Laptop", "price": 999 }, + { "name": "Mouse", "price": 25 }, + { "name": "Keyboard", "price": 75 } + ] +} ``` Output: ```html - + ``` -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 {{generator}} on ", - { "span": "{{date}}" } + { "li": "{{name}} ({{role}})" } ] } } @@ -742,20 +684,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 - + ``` -**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": "{{name}} ({{age}})" } + ] + } +} +``` + +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 { @@ -816,9 +795,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 { @@ -838,14 +817,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 @@ -1003,6 +974,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 {{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. diff --git a/docs/assets/markdown-it-treebark-browser.js b/docs/assets/markdown-it-treebark-browser.js index af9b891..4ab7848 100644 --- a/docs/assets/markdown-it-treebark-browser.js +++ b/docs/assets/markdown-it-treebark-browser.js @@ -252,6 +252,9 @@ 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 parseTemplateObject(templateObj, logger) { if (!templateObj || typeof templateObj !== "object") { logger.error("Template object cannot be null, undefined, or non-object"); @@ -395,7 +398,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`); @@ -407,8 +410,19 @@ } 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 (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 18024fc..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(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=` +(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"]),F=new Set([...b,...A,...S]),R=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"])},I=new Set(["$<","$>","$<=","$>=","$=","$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}${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 _(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(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(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 diff --git a/docs/assets/treebark-browser.js b/docs/assets/treebark-browser.js index 210dd7c..32c7bde 100644 --- a/docs/assets/treebark-browser.js +++ b/docs/assets/treebark-browser.js @@ -252,6 +252,9 @@ 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 parseTemplateObject(templateObj, logger) { if (!templateObj || typeof templateObj !== "object") { logger.error("Template object cannot be null, undefined, or non-object"); @@ -395,7 +398,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`); @@ -407,8 +410,19 @@ } 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 (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/treebark-browser.min.js b/docs/assets/treebark-browser.min.js index f53f682..cf17402 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=` +(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}${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,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(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/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. diff --git a/docs/js/playground.js b/docs/js/playground.js index a09151e..a1008f4 100644 --- a/docs/js/playground.js +++ b/docs/js/playground.js @@ -849,6 +849,269 @@ { 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 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 tag to conditionally render each item + { + div: { + $children: [ + { h2: "❌ Old Way: Using $if tag" }, + { + ul: { + $bind: "products", + $children: [ + { + $if: { + $check: "inStock", + $then: { + li: "{{name}}" + } + } + } + ] + } + } + ] + } + }, + { hr: {} }, + // New way: Using $filter to show only in-stock items + { + div: { + $children: [ + { h2: "✅ New Way: Using $filter" }, + { + ul: { + $bind: "products", + $filter: { + $check: "inStock" + }, + $children: [ + { + li: "{{name}}" + } + ] + } + } + ] + } + } + ] + } + }, + data: { + products: [ + { name: "Laptop", inStock: true }, + { name: "Phone", inStock: false }, + { name: "Tablet", inStock: true } + ] + } + }; const examples = { "Hello World": helloWorld, "Card Layout": cardLayout, @@ -869,7 +1132,12 @@ "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, + "Filter In Stock": filterInStock, + "Filter vs If Comparison": filterComparison }; let currentTemplateFormat = "json"; const templateEditor = document.getElementById("template-editor"); 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/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/filter-comparison.ts b/nodejs/packages/playground/src/examples/filter-comparison.ts new file mode 100644 index 0000000..5fe6139 --- /dev/null +++ b/nodejs/packages/playground/src/examples/filter-comparison.ts @@ -0,0 +1,67 @@ +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 tag to conditionally render each item + { + div: { + $children: [ + { h2: "❌ Old Way: Using $if tag" }, + { + ul: { + $bind: "products", + $children: [ + { + $if: { + $check: "inStock", + $then: { + li: "{{name}}" + } + } + } + ] + } + } + ] + } + }, + + { hr: {} }, + + // New way: Using $filter to show only in-stock items + { + div: { + $children: [ + { h2: "✅ New Way: Using $filter" }, + { + ul: { + $bind: "products", + $filter: { + $check: "inStock" + }, + $children: [ + { + li: "{{name}}" + } + ] + } + } + ] + } + } + ] + } + }, + data: { + products: [ + { name: "Laptop", inStock: true }, + { name: "Phone", inStock: false }, + { name: "Tablet", inStock: true } + ] + } +}; 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 fbe5933..d853e1d 100644 --- a/nodejs/packages/playground/src/examples/index.ts +++ b/nodejs/packages/playground/src/examples/index.ts @@ -19,6 +19,11 @@ 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'; +import { filterInStock } from './filter-in-stock.js'; +import { filterComparison } from './filter-comparison.js'; export type Examples = Record; @@ -42,5 +47,10 @@ 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, + 'Filter In Stock': filterInStock, + 'Filter vs If Comparison': filterComparison }; diff --git a/nodejs/packages/test/src/common-tests.ts b/nodejs/packages/test/src/common-tests.ts index b5c1b8d..3f03f41 100644 --- a/nodejs/packages/test/src/common-tests.ts +++ b/nodejs/packages/test/src/common-tests.ts @@ -2268,6 +2268,379 @@ 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' } + ] + } + } + }, + { + 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' } + ] + } + } + } +]; + // 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..f4971c8 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,66 @@ 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; + 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}`); + } + }); + }); + }); + // Style object tests describe('Style Objects', () => { styleObjectTests.forEach(testCase => { diff --git a/nodejs/packages/test/src/string.test.ts b/nodejs/packages/test/src/string.test.ts index 02a70b8..d4c3304 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,63 @@ 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; + 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}`); + } + }); + }); + }); + // Style object tests describe('Style Objects', () => { styleObjectTests.forEach(testCase => { 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": [ diff --git a/nodejs/packages/treebark/src/common.ts b/nodejs/packages/treebark/src/common.ts index 867546c..9fd91e9 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 @@ -345,14 +346,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 +442,19 @@ 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' + ); +} + /** * 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..c3c809f 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, + evaluateCondition } 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,9 +135,25 @@ 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 (hasFilter) { + const checkValue = getProperty(item as Data, $filter.$check, newParents, logger, getOuterProperty); + if (!evaluateCondition(checkValue, $filter)) { + continue; + } + } + // 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 a94b9c0..598789a 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, + evaluateCondition } 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 @@ -178,8 +180,25 @@ 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 (hasFilter) { + const checkValue = getProperty(item as Data, $filter.$check, newParents, logger, getOuterProperty); + if (!evaluateCondition(checkValue, $filter)) { + continue; + } + } + 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 19475da..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,6 +36,13 @@ 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; @@ -84,6 +90,7 @@ type GlobalAttrs = { // Base attributes for container tags (can have children) type BaseContainerAttrs = GlobalAttrs & { $bind?: BindPath; + $filter?: FilterCondition; $children?: (InterpolatedString | TemplateObject)[]; }; diff --git a/spec.md b/spec.md index 51e60f6..8d8a8c7 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) --- @@ -385,15 +386,172 @@ $comment: --- -## 13. Conditional Rendering with "$if" Tag +## 13. Filtering Arrays with $filter + +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 +{ + 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
      ` + +**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 + +--- + +## 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. +**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