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
-
+
+ - 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 {{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
-
+
+ - 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": "{{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 }`:
```
-**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}${e}>`}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}${e}>`}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}${e}>`}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}${e}>`}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 }`:
```
-**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('');
+ 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('');
+ 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('');
+ break;
+ case 'filters with range using multiple operators':
+ expect(div.innerHTML).toBe('');
+ break;
+ case 'filters with OR logic':
+ expect(div.innerHTML).toBe('');
+ break;
+ case 'filters with $not modifier':
+ expect(div.innerHTML).toBe('');
+ 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('');
+ break;
+ case 'type safety: filters out string values when comparing with numbers using $>':
+ expect(div.innerHTML).toBe('');
+ break;
+ case 'type safety: filters out string values when comparing with numbers using $<':
+ expect(div.innerHTML).toBe('');
+ 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('');
+ 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('');
+ 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('');
+ break;
+ case 'filters with range using multiple operators':
+ expect(result).toBe('');
+ break;
+ case 'filters with OR logic':
+ expect(result).toBe('');
+ break;
+ case 'filters with $not modifier':
+ expect(result).toBe('');
+ break;
+ case 'returns empty when all items are filtered out':
+ expect(result).toBe('');
+ break;
+ case 'works without $filter (no filtering)':
+ expect(result).toBe('');
+ break;
+ case 'type safety: filters out string values when comparing with numbers using $>':
+ expect(result).toBe('');
+ break;
+ case 'type safety: filters out string values when comparing with numbers using $<':
+ expect(result).toBe('');
+ 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: ``
+
+**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: ``
+
+**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: ``
+
+**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