sjabloon 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  A tiny, CSP-safe template engine for JavaScript. **~0.8KB min+gzip (~2KB with [xprsn](https://www.npmjs.com/package/xprsn)), one dependency.**
4
4
 
5
+ [![NPM version](https://img.shields.io/npm/v/sjabloon.svg)](https://www.npmjs.com/package/sjabloon)
6
+ [![Build Status](https://github.com/robinvdvleuten/sjabloon/actions/workflows/test.yml/badge.svg)](https://github.com/robinvdvleuten/sjabloon/actions/workflows/test.yml)
7
+ [![NPM downloads](https://img.shields.io/npm/dm/sjabloon.svg)](https://www.npmjs.com/package/sjabloon)
8
+ [![MIT license](https://img.shields.io/github/license/robinvdvleuten/sjabloon.svg)](https://github.com/robinvdvleuten/sjabloon/blob/main/LICENSE)
9
+
10
+ <a href="https://webstronauts.com?utm_source=github&utm_medium=readme&utm_campaign=sjabloon">
11
+ <picture>
12
+ <img src="https://webstronauts.com/images/sponsored-by.svg" alt="Sponsored by The Webstronauts" width="200" height="65">
13
+ </picture>
14
+ </a>
15
+
5
16
  *Sjabloon* is Dutch for "template". It renders text templates with full [xprsn](https://github.com/robinvdvleuten/xprsn) expressions inside every tag, without turning template text into JavaScript. There is no `eval` and no `new Function`, so it runs under a strict Content Security Policy where engines that compile templates to code cannot.
6
17
 
7
18
  ```js
@@ -26,13 +37,22 @@ render(
26
37
 
27
38
  ### `template(str, functions?)`
28
39
 
29
- Compiles the template and returns a renderer `(values?) => string`. Malformed tags, unclosed blocks, and invalid expressions throw a `SyntaxError` at compile time.
40
+ Compiles the template and returns a renderer `(values?, scope?) => string`. Malformed tags, unclosed blocks, and invalid expressions throw a `SyntaxError` at compile time.
30
41
 
31
- The renderer also carries `names`: every variable the template reads from your values, deduplicated, with loop variables excluded. Use it to validate a stored template against your data model, or to fetch only the fields a template actually needs before rendering.
42
+ The anchors `$` (root) and `@` (current `{{#each}}` item) work as [described below](#syntax) with no extra arguments — at the root, before any loop, both point at `values`. If you're embedding sjabloon under an engine with its own scope model, pass `{ root, item }` as the second argument to seed the two root anchors from distinct objects: `$` becomes `root` and `@` becomes `item`. Omit `item` and `@` stays unbound at the root, so reading `@.x` throws where there is no current item. Either way, `{{#each}}` still re-points `@` to the current item inside its body.
32
43
 
33
44
  ```js
34
- const tpl = template('{{ title }}{{#each items as it}}{{ it.name }}{{/each}}');
35
- tpl.names; // => ['title', 'items']
45
+ const tpl = template('{{ $.report }} {{ @.row }}');
46
+ tpl(base, { root: reportRoot, item: currentRow }); // $ = reportRoot, @ = currentRow
47
+ tpl(base, { root: reportRoot }); // no item → @.x throws
48
+ ```
49
+
50
+ The renderer also carries `names` (every variable the template reads from your values, loop variables excluded) and `functions` (the registry functions it calls, methods excluded), both deduplicated. Check a stored template against your data model and its allowed functions before you render it, or fetch only the fields it needs.
51
+
52
+ ```js
53
+ const tpl = template('{{ fmt(title) }}{{#each items as it}}{{ it.name }}{{/each}}', { fmt: s => s });
54
+ tpl.names; // => ['title', 'items']
55
+ tpl.functions; // => ['fmt']
36
56
  ```
37
57
 
38
58
  ### `render(str, values?, functions?)`
@@ -49,12 +69,32 @@ Shorthand for `template(str, functions)(values)`.
49
69
  | `{{#each expr as item}} … {{/each}}` | Loop over an array or an object's values |
50
70
  | `{{#each expr as item, key}} … {{/each}}` | Second name binds the index (arrays) or the key (objects) |
51
71
  | `{{#each expr as item}} … {{#else}} … {{/each}}` | The `{{#else}}` branch renders when the collection is empty or missing |
72
+ | `{{ loop.last }}` (inside `{{#each}}`) | Iteration metadata: `index` (1-based), `index0`, `first`, `last`, `length` |
52
73
  | `{{! anything }}` | Comment, removed from output |
53
74
  | `{{- expr -}}` | A dash hugging either brace trims the whitespace on that side, newlines included; works on every tag form |
54
75
 
55
- Every `expr` is an [xprsn expression](https://github.com/robinvdvleuten/xprsn#syntax): literals, arithmetic, comparisons, `and`/`or`/`not`/`in`, ternaries, property and method access, and functions from the registry you pass in. `null` and `undefined` render as empty strings.
76
+ Every `expr` is an [xprsn expression](https://github.com/robinvdvleuten/xprsn#syntax): literals, arithmetic, string concatenation with `~` (`{{ first ~ " " ~ last }}`), comparisons, `and`/`or`/`not`/`in`, ternaries, property and method access, and functions from the registry you pass in. `null` and `undefined` render as empty strings.
77
+
78
+ A loop body sees its loop variable plus the outer scope; reusing an outer name shadows it only inside that body. The engine keeps loop variables on a child scope, so the values you pass are never mutated.
79
+
80
+ Inside `{{#each}}`, a `loop` object holds the iteration state: `index` (1-based), `index0`, `first`, `last`, and `length`. Use `loop.last` for separators and trailing borders, or `loop.index` with `loop.length` for "row X of Y". Each nested loop gets its own.
81
+
82
+ ```js
83
+ render('{{#each xs as x}}{{ x }}{{#if not loop.last}}, {{/if}}{{/each}}', { xs: ['a', 'b', 'c'] });
84
+ // => 'a, b, c'
85
+ ```
86
+
87
+ Two anchors are always in scope: `$` is the root values and `@` is the current `{{#each}}` item (the root outside a loop). They let a nested body name the level it means instead of leaning on shadowing: `$.company` reaches the top, and `@.total` is whatever the innermost loop sits on.
88
+
89
+ ```js
90
+ render(
91
+ '{{#each regions as company}}{{ company }} of {{ $.company }}: {{#each rows as r}}{{ @.n }} {{/each}}{{/each}}',
92
+ { company: 'ACME', regions: ['North', 'South'], rows: [{ n: 1 }, { n: 2 }] }
93
+ );
94
+ // => 'North of ACME: 1 2 South of ACME: 1 2 '
95
+ ```
56
96
 
57
- Loop bodies see the loop variable plus everything from the outer scope. A nested loop can reuse an outer name and shadow it for its own body. The engine sets loop variables on a child scope, so your values object comes back exactly as you passed it in.
97
+ Here the loop variable `company` shadows the root's for a bare name, but `$.company` still returns `'ACME'`. Anchors never count as `names`, and a blocked key through one (`$.constructor`) throws like anywhere else.
58
98
 
59
99
  ## Content Security Policy
60
100
 
package/dist/index.cjs CHANGED
@@ -1 +1 @@
1
- var t=require("xprsn");function r(t,r){(null==r||r>t.length)&&(r=t.length);for(var e=0,n=Array(r);e<r;e++)n[e]=t[e];return n}var e,n,a,u,i,o,l={"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"},s=/\{\{\{(-)?\s*([\s\S]*?)\s*(-)?\}\}\}|\{\{(-)?\s*([\s\S]*?)\s*(-)?\}\}/,c=function(t){throw SyntaxError(t)},f=function(e){for(var n,u=t.compile(e,a),l=function(t){var e="undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(e)return(e=e.call(t)).next.bind(e);if(Array.isArray(t)||(e=function(t,e){if(t){if("string"==typeof t)return r(t,e);var n={}.toString.call(t).slice(8,-1);return"Object"===n&&t.constructor&&(n=t.constructor.name),"Map"===n||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?r(t,e):void 0}}(t))){e&&(t=e);var n=0;return function(){return n>=t.length?{done:!0}:{done:!1,value:t[n++]}}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}(u.names);!(n=l()).done;){var s=n.value;i.has(s)||o.add(s)}return u},p=function(t){var r=g(["#elif","#else","/if"]),e=u.startsWith("#elif ")?[p(f(u.slice(6)))]:"#else"===u?g(["/if"]):[];return function(n){return(t(n)?r:e).map(function(t){return t(n)}).join("")}},g=function(t){for(var r,a,o=[],s=function(){if(null!=a.text)o.push((y=a.text,function(){return y}));else if(null!=a.raw)o.push((m=f(a.raw),function(t){var r;return String(null!=(r=m(t))?r:"")}));else{if(t.includes(a.tag.split(" ")[0]))return u=a.tag,{v:o};if("!"===a.tag[0]);else if(a.tag.startsWith("#if "))o.push(p(f(a.tag.slice(4))));else if(a.tag.startsWith("#each ")){var r=/^#each ([\s\S]+) as (\w+)(?:\s*,\s*(\w+))?$/.exec(a.tag)||c("Bad {{"+a.tag+"}}"),e=f(r[1]),n=r[2],s=r[3],h=[i.has(n),s&&i.has(s)];i.add(n),s&&i.add(s);var v=g(["#else","/each"]);h[0]||i.delete(n),s&&!h[1]&&i.delete(s);var d="#else"===u?g(["/each"]):[];o.push(function(t){var r,a=(r=e(t),Array.isArray(r)?r.map(function(t,r){return[t,r]}):r&&"object"==typeof r?Object.keys(r).map(function(t){return[r[t],t]}):[]);return a.length?a.map(function(r){var e=r[0],a=r[1],u=Object.create(t);return u[n]=e,s&&(u[s]=a),v.map(function(t){return t(u)}).join("")}).join(""):d.map(function(r){return r(t)}).join("")})}else"#"===a.tag[0]||"/"===a.tag[0]?c("Unexpected {{"+a.tag+"}}"):o.push(function(t){return function(r){var e;return function(t){return String(t).replace(/[&<>"']/g,function(t){return l[t]})}(null!=(e=t(r))?e:"")}}(f(a.tag)))}var m,y};a=e[n++];)if(r=s())return r.v;return t.length&&c("Missing {{"+t[t.length-1]+"}}"),o};function h(t,r){a=r,i=new Set,o=new Set,e=[];for(var u=String(t).split(s),l=0;l<u.length&&(u[l]&&e.push({text:u[l]}),!(l+1>=u.length));l+=7){var c=null!=u[l+2],f=c?{raw:u[l+2]}:{tag:u[l+5]};f.l="-"===u[l+(c?1:4)],f.r="-"===u[l+(c?3:6)],e.push(f)}e.forEach(function(t,r){var n,a;t.l&&null!=(n=e[r-1])&&n.text&&(e[r-1].text=e[r-1].text.replace(/\s+$/,"")),t.r&&null!=(a=e[r+1])&&a.text&&(e[r+1].text=e[r+1].text.replace(/^\s+/,""))}),n=0;var p=g([]),h=function(t){return p.map(function(r){return r(t||{})}).join("")};return h.names=Array.from(o),h}exports.render=function(t,r,e){return h(t,e)(r)},exports.template=h;
1
+ var t=require("xprsn");function r(t,r){(null==r||r>t.length)&&(r=t.length);for(var e=0,n=Array(r);e<r;e++)n[e]=t[e];return n}function e(t,e){var n="undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(n)return(n=n.call(t)).next.bind(n);if(Array.isArray(t)||(n=function(t,e){if(t){if("string"==typeof t)return r(t,e);var n={}.toString.call(t).slice(8,-1);return"Object"===n&&t.constructor&&(n=t.constructor.name),"Map"===n||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?r(t,e):void 0}}(t))||e&&t&&"number"==typeof t.length){n&&(t=n);var a=0;return function(){return a>=t.length?{done:!0}:{done:!1,value:t[a++]}}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var n,a,i,u,o,l,c,f={"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"},s=function(t){return String(t).replace(/[&<>"']/g,function(t){return f[t]})},h=/\{\{\{(-)?\s*([\s\S]*?)\s*(-)?\}\}\}|\{\{(-)?\s*([\s\S]*?)\s*(-)?\}\}/,g=function(t){throw SyntaxError(t)},p=function(t,r){return t.map(function(t){return t(r)}).join("")},v=function(t,r){return e=d(t),function(t){var n;return r(null!=(n=e(t))?n:"")};var e},d=function(r){for(var n,a=t.compile(r,i),u=e(a.names);!(n=u()).done;){var f=n.value;o.has(f)||l.add(f)}for(var s,h=e(a.functions);!(s=h()).done;)c.add(s.value);return a},m=function(t){var r=y(["#elif","#else","/if"]),e=u.startsWith("#elif ")?[m(d(u.slice(6)))]:"#else"===u?y(["/if"]):[];return function(n){return p(t(n)?r:e,n)}},y=function(t){for(var r,e,i=[],l=function(){if(null!=e.text)i.push((x=e.text,function(){return x}));else if(null!=e.raw)i.push(v(e.raw,String));else{if(t.includes(e.tag.split(" ")[0]))return u=e.tag,{v:i};if("!"===e.tag[0]);else if(e.tag.startsWith("#if "))i.push(m(d(e.tag.slice(4))));else if(e.tag.startsWith("#each ")){var r=/^#each ([\s\S]+) as (\w+)(?:\s*,\s*(\w+))?$/.exec(e.tag)||g("Bad {{"+e.tag+"}}"),n=d(r[1]),a=r[2],l=r[3],c=function(){var t=[].slice.call(arguments).filter(function(t){return t&&!o.has(t)});return t.forEach(function(t){return o.add(t)}),function(){return t.forEach(function(t){return o.delete(t)})}}(a,l,"loop"),f=y(["#else","/each"]);c();var h="#else"===u?y(["/each"]):[];i.push(function(t){var r,e=(r=n(t),Array.isArray(r)?r.map(function(t,r){return[t,r]}):r&&"object"==typeof r?Object.keys(r).map(function(t){return[r[t],t]}):[]);return e.length?e.map(function(r,n){var i=r[0],u=r[1],o=Object.create(t);return o[a]=i,l&&(o[l]=u),o["@"]=i,o.loop={index:n+1,index0:n,first:!n,last:n===e.length-1,length:e.length},p(f,o)}).join(""):p(h,t)})}else"#"===e.tag[0]||"/"===e.tag[0]?g("Unexpected {{"+e.tag+"}}"):i.push(v(e.tag,s))}var x};e=n[a++];)if(r=l())return r.v;return t.length&&g("Missing {{"+t[t.length-1]+"}}"),i};function x(t,r){i=r,o=new Set(["$","@"]),l=new Set,c=new Set,n=[];for(var e=String(t).split(h),u=0;u<e.length&&(e[u]&&n.push({text:e[u]}),!(u+1>=e.length));u+=7){var f=null!=e[u+2],s=f?{raw:e[u+2]}:{tag:e[u+5]};s.l="-"===e[u+(f?1:4)],s.r="-"===e[u+(f?3:6)],n.push(s)}n.forEach(function(t,r){var e,a;t.l&&null!=(e=n[r-1])&&e.text&&(n[r-1].text=n[r-1].text.replace(/\s+$/,"")),t.r&&null!=(a=n[r+1])&&a.text&&(n[r+1].text=n[r+1].text.replace(/^\s+/,""))}),a=0;var g=y([]),v=function(t,r){t=t||{};var e=Object.create(t);return e.$=r?r.root:t,r?"item"in r&&(e["@"]=r.item):e["@"]=t,p(g,e)};return v.names=Array.from(l),v.functions=Array.from(c),v}exports.render=function(t,r,e){return x(t,e)(r)},exports.template=x;
@@ -1 +1 @@
1
- import{compile as t}from"xprsn";function r(t,r){(null==r||r>t.length)&&(r=t.length);for(var n=0,e=Array(r);n<r;n++)e[n]=t[n];return e}var n,e,a,u,i,o,l={"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"},s=/\{\{\{(-)?\s*([\s\S]*?)\s*(-)?\}\}\}|\{\{(-)?\s*([\s\S]*?)\s*(-)?\}\}/,f=function(t){throw SyntaxError(t)},c=function(n){for(var e,u=t(n,a),l=function(t){var n="undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(n)return(n=n.call(t)).next.bind(n);if(Array.isArray(t)||(n=function(t,n){if(t){if("string"==typeof t)return r(t,n);var e={}.toString.call(t).slice(8,-1);return"Object"===e&&t.constructor&&(e=t.constructor.name),"Map"===e||"Set"===e?Array.from(t):"Arguments"===e||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(e)?r(t,n):void 0}}(t))){n&&(t=n);var e=0;return function(){return e>=t.length?{done:!0}:{done:!1,value:t[e++]}}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}(u.names);!(e=l()).done;){var s=e.value;i.has(s)||o.add(s)}return u},p=function(t){var r=g(["#elif","#else","/if"]),n=u.startsWith("#elif ")?[p(c(u.slice(6)))]:"#else"===u?g(["/if"]):[];return function(e){return(t(e)?r:n).map(function(t){return t(e)}).join("")}},g=function(t){for(var r,a,o=[],s=function(){if(null!=a.text)o.push((y=a.text,function(){return y}));else if(null!=a.raw)o.push((d=c(a.raw),function(t){var r;return String(null!=(r=d(t))?r:"")}));else{if(t.includes(a.tag.split(" ")[0]))return u=a.tag,{v:o};if("!"===a.tag[0]);else if(a.tag.startsWith("#if "))o.push(p(c(a.tag.slice(4))));else if(a.tag.startsWith("#each ")){var r=/^#each ([\s\S]+) as (\w+)(?:\s*,\s*(\w+))?$/.exec(a.tag)||f("Bad {{"+a.tag+"}}"),n=c(r[1]),e=r[2],s=r[3],h=[i.has(e),s&&i.has(s)];i.add(e),s&&i.add(s);var v=g(["#else","/each"]);h[0]||i.delete(e),s&&!h[1]&&i.delete(s);var m="#else"===u?g(["/each"]):[];o.push(function(t){var r,a=(r=n(t),Array.isArray(r)?r.map(function(t,r){return[t,r]}):r&&"object"==typeof r?Object.keys(r).map(function(t){return[r[t],t]}):[]);return a.length?a.map(function(r){var n=r[0],a=r[1],u=Object.create(t);return u[e]=n,s&&(u[s]=a),v.map(function(t){return t(u)}).join("")}).join(""):m.map(function(r){return r(t)}).join("")})}else"#"===a.tag[0]||"/"===a.tag[0]?f("Unexpected {{"+a.tag+"}}"):o.push(function(t){return function(r){var n;return function(t){return String(t).replace(/[&<>"']/g,function(t){return l[t]})}(null!=(n=t(r))?n:"")}}(c(a.tag)))}var d,y};a=n[e++];)if(r=s())return r.v;return t.length&&f("Missing {{"+t[t.length-1]+"}}"),o};function h(t,r){a=r,i=new Set,o=new Set,n=[];for(var u=String(t).split(s),l=0;l<u.length&&(u[l]&&n.push({text:u[l]}),!(l+1>=u.length));l+=7){var f=null!=u[l+2],c=f?{raw:u[l+2]}:{tag:u[l+5]};c.l="-"===u[l+(f?1:4)],c.r="-"===u[l+(f?3:6)],n.push(c)}n.forEach(function(t,r){var e,a;t.l&&null!=(e=n[r-1])&&e.text&&(n[r-1].text=n[r-1].text.replace(/\s+$/,"")),t.r&&null!=(a=n[r+1])&&a.text&&(n[r+1].text=n[r+1].text.replace(/^\s+/,""))}),e=0;var p=g([]),h=function(t){return p.map(function(r){return r(t||{})}).join("")};return h.names=Array.from(o),h}function v(t,r,n){return h(t,n)(r)}export{v as render,h as template};
1
+ import{compile as t}from"xprsn";function r(t,r){(null==r||r>t.length)&&(r=t.length);for(var n=0,e=Array(r);n<r;n++)e[n]=t[n];return e}function n(t,n){var e="undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(e)return(e=e.call(t)).next.bind(e);if(Array.isArray(t)||(e=function(t,n){if(t){if("string"==typeof t)return r(t,n);var e={}.toString.call(t).slice(8,-1);return"Object"===e&&t.constructor&&(e=t.constructor.name),"Map"===e||"Set"===e?Array.from(t):"Arguments"===e||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(e)?r(t,n):void 0}}(t))||n&&t&&"number"==typeof t.length){e&&(t=e);var a=0;return function(){return a>=t.length?{done:!0}:{done:!1,value:t[a++]}}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var e,a,i,u,o,l,f,c={"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"},s=function(t){return String(t).replace(/[&<>"']/g,function(t){return c[t]})},h=/\{\{\{(-)?\s*([\s\S]*?)\s*(-)?\}\}\}|\{\{(-)?\s*([\s\S]*?)\s*(-)?\}\}/,g=function(t){throw SyntaxError(t)},p=function(t,r){return t.map(function(t){return t(r)}).join("")},v=function(t,r){return n=d(t),function(t){var e;return r(null!=(e=n(t))?e:"")};var n},d=function(r){for(var e,a=t(r,i),u=n(a.names);!(e=u()).done;){var c=e.value;o.has(c)||l.add(c)}for(var s,h=n(a.functions);!(s=h()).done;)f.add(s.value);return a},m=function(t){var r=y(["#elif","#else","/if"]),n=u.startsWith("#elif ")?[m(d(u.slice(6)))]:"#else"===u?y(["/if"]):[];return function(e){return p(t(e)?r:n,e)}},y=function(t){for(var r,n,i=[],l=function(){if(null!=n.text)i.push((x=n.text,function(){return x}));else if(null!=n.raw)i.push(v(n.raw,String));else{if(t.includes(n.tag.split(" ")[0]))return u=n.tag,{v:i};if("!"===n.tag[0]);else if(n.tag.startsWith("#if "))i.push(m(d(n.tag.slice(4))));else if(n.tag.startsWith("#each ")){var r=/^#each ([\s\S]+) as (\w+)(?:\s*,\s*(\w+))?$/.exec(n.tag)||g("Bad {{"+n.tag+"}}"),e=d(r[1]),a=r[2],l=r[3],f=function(){var t=[].slice.call(arguments).filter(function(t){return t&&!o.has(t)});return t.forEach(function(t){return o.add(t)}),function(){return t.forEach(function(t){return o.delete(t)})}}(a,l,"loop"),c=y(["#else","/each"]);f();var h="#else"===u?y(["/each"]):[];i.push(function(t){var r,n=(r=e(t),Array.isArray(r)?r.map(function(t,r){return[t,r]}):r&&"object"==typeof r?Object.keys(r).map(function(t){return[r[t],t]}):[]);return n.length?n.map(function(r,e){var i=r[0],u=r[1],o=Object.create(t);return o[a]=i,l&&(o[l]=u),o["@"]=i,o.loop={index:e+1,index0:e,first:!e,last:e===n.length-1,length:n.length},p(c,o)}).join(""):p(h,t)})}else"#"===n.tag[0]||"/"===n.tag[0]?g("Unexpected {{"+n.tag+"}}"):i.push(v(n.tag,s))}var x};n=e[a++];)if(r=l())return r.v;return t.length&&g("Missing {{"+t[t.length-1]+"}}"),i};function x(t,r){i=r,o=new Set(["$","@"]),l=new Set,f=new Set,e=[];for(var n=String(t).split(h),u=0;u<n.length&&(n[u]&&e.push({text:n[u]}),!(u+1>=n.length));u+=7){var c=null!=n[u+2],s=c?{raw:n[u+2]}:{tag:n[u+5]};s.l="-"===n[u+(c?1:4)],s.r="-"===n[u+(c?3:6)],e.push(s)}e.forEach(function(t,r){var n,a;t.l&&null!=(n=e[r-1])&&n.text&&(e[r-1].text=e[r-1].text.replace(/\s+$/,"")),t.r&&null!=(a=e[r+1])&&a.text&&(e[r+1].text=e[r+1].text.replace(/^\s+/,""))}),a=0;var g=y([]),v=function(t,r){t=t||{};var n=Object.create(t);return n.$=r?r.root:t,r?"item"in r&&(n["@"]=r.item):n["@"]=t,p(g,n)};return v.names=Array.from(l),v.functions=Array.from(f),v}function S(t,r,n){return x(t,n)(r)}export{S as render,x as template};
package/dist/index.umd.js CHANGED
@@ -1 +1 @@
1
- !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports,require("xprsn")):"function"==typeof define&&define.amd?define(["exports","xprsn"],e):e((t||self).sjabloon={},t.xprsn)}(this,function(t,e){function n(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,r=Array(e);n<e;n++)r[n]=t[n];return r}var r,a,i,o,u,l,s={"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"},f=/\{\{\{(-)?\s*([\s\S]*?)\s*(-)?\}\}\}|\{\{(-)?\s*([\s\S]*?)\s*(-)?\}\}/,c=function(t){throw SyntaxError(t)},p=function(t){for(var r,a=e.compile(t,i),o=function(t){var e="undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(e)return(e=e.call(t)).next.bind(e);if(Array.isArray(t)||(e=function(t,e){if(t){if("string"==typeof t)return n(t,e);var r={}.toString.call(t).slice(8,-1);return"Object"===r&&t.constructor&&(r=t.constructor.name),"Map"===r||"Set"===r?Array.from(t):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?n(t,e):void 0}}(t))){e&&(t=e);var r=0;return function(){return r>=t.length?{done:!0}:{done:!1,value:t[r++]}}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}(a.names);!(r=o()).done;){var s=r.value;u.has(s)||l.add(s)}return a},h=function(t){var e=d(["#elif","#else","/if"]),n=o.startsWith("#elif ")?[h(p(o.slice(6)))]:"#else"===o?d(["/if"]):[];return function(r){return(t(r)?e:n).map(function(t){return t(r)}).join("")}},d=function(t){for(var e,n,i=[],l=function(){if(null!=n.text)i.push((y=n.text,function(){return y}));else if(null!=n.raw)i.push((m=p(n.raw),function(t){var e;return String(null!=(e=m(t))?e:"")}));else{if(t.includes(n.tag.split(" ")[0]))return o=n.tag,{v:i};if("!"===n.tag[0]);else if(n.tag.startsWith("#if "))i.push(h(p(n.tag.slice(4))));else if(n.tag.startsWith("#each ")){var e=/^#each ([\s\S]+) as (\w+)(?:\s*,\s*(\w+))?$/.exec(n.tag)||c("Bad {{"+n.tag+"}}"),r=p(e[1]),a=e[2],l=e[3],f=[u.has(a),l&&u.has(l)];u.add(a),l&&u.add(l);var g=d(["#else","/each"]);f[0]||u.delete(a),l&&!f[1]&&u.delete(l);var v="#else"===o?d(["/each"]):[];i.push(function(t){var e,n=(e=r(t),Array.isArray(e)?e.map(function(t,e){return[t,e]}):e&&"object"==typeof e?Object.keys(e).map(function(t){return[e[t],t]}):[]);return n.length?n.map(function(e){var n=e[0],r=e[1],i=Object.create(t);return i[a]=n,l&&(i[l]=r),g.map(function(t){return t(i)}).join("")}).join(""):v.map(function(e){return e(t)}).join("")})}else"#"===n.tag[0]||"/"===n.tag[0]?c("Unexpected {{"+n.tag+"}}"):i.push(function(t){return function(e){var n;return function(t){return String(t).replace(/[&<>"']/g,function(t){return s[t]})}(null!=(n=t(e))?n:"")}}(p(n.tag)))}var m,y};n=r[a++];)if(e=l())return e.v;return t.length&&c("Missing {{"+t[t.length-1]+"}}"),i};function g(t,e){i=e,u=new Set,l=new Set,r=[];for(var n=String(t).split(f),o=0;o<n.length&&(n[o]&&r.push({text:n[o]}),!(o+1>=n.length));o+=7){var s=null!=n[o+2],c=s?{raw:n[o+2]}:{tag:n[o+5]};c.l="-"===n[o+(s?1:4)],c.r="-"===n[o+(s?3:6)],r.push(c)}r.forEach(function(t,e){var n,a;t.l&&null!=(n=r[e-1])&&n.text&&(r[e-1].text=r[e-1].text.replace(/\s+$/,"")),t.r&&null!=(a=r[e+1])&&a.text&&(r[e+1].text=r[e+1].text.replace(/^\s+/,""))}),a=0;var p=d([]),h=function(t){return p.map(function(e){return e(t||{})}).join("")};return h.names=Array.from(l),h}t.render=function(t,e,n){return g(t,n)(e)},t.template=g});
1
+ !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports,require("xprsn")):"function"==typeof define&&define.amd?define(["exports","xprsn"],e):e((t||self).sjabloon={},t.xprsn)}(this,function(t,e){function n(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,r=Array(e);n<e;n++)r[n]=t[n];return r}function r(t,e){var r="undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(r)return(r=r.call(t)).next.bind(r);if(Array.isArray(t)||(r=function(t,e){if(t){if("string"==typeof t)return n(t,e);var r={}.toString.call(t).slice(8,-1);return"Object"===r&&t.constructor&&(r=t.constructor.name),"Map"===r||"Set"===r?Array.from(t):"Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r)?n(t,e):void 0}}(t))||e&&t&&"number"==typeof t.length){r&&(t=r);var a=0;return function(){return a>=t.length?{done:!0}:{done:!1,value:t[a++]}}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var a,i,o,u,l,f,s,c={"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"},p=function(t){return String(t).replace(/[&<>"']/g,function(t){return c[t]})},h=/\{\{\{(-)?\s*([\s\S]*?)\s*(-)?\}\}\}|\{\{(-)?\s*([\s\S]*?)\s*(-)?\}\}/,g=function(t){throw SyntaxError(t)},d=function(t,e){return t.map(function(t){return t(e)}).join("")},v=function(t,e){return n=m(t),function(t){var r;return e(null!=(r=n(t))?r:"")};var n},m=function(t){for(var n,a=e.compile(t,o),i=r(a.names);!(n=i()).done;){var u=n.value;l.has(u)||f.add(u)}for(var c,p=r(a.functions);!(c=p()).done;)s.add(c.value);return a},y=function(t){var e=x(["#elif","#else","/if"]),n=u.startsWith("#elif ")?[y(m(u.slice(6)))]:"#else"===u?x(["/if"]):[];return function(r){return d(t(r)?e:n,r)}},x=function(t){for(var e,n,r=[],o=function(){if(null!=n.text)r.push((h=n.text,function(){return h}));else if(null!=n.raw)r.push(v(n.raw,String));else{if(t.includes(n.tag.split(" ")[0]))return u=n.tag,{v:r};if("!"===n.tag[0]);else if(n.tag.startsWith("#if "))r.push(y(m(n.tag.slice(4))));else if(n.tag.startsWith("#each ")){var e=/^#each ([\s\S]+) as (\w+)(?:\s*,\s*(\w+))?$/.exec(n.tag)||g("Bad {{"+n.tag+"}}"),a=m(e[1]),i=e[2],o=e[3],f=function(){var t=[].slice.call(arguments).filter(function(t){return t&&!l.has(t)});return t.forEach(function(t){return l.add(t)}),function(){return t.forEach(function(t){return l.delete(t)})}}(i,o,"loop"),s=x(["#else","/each"]);f();var c="#else"===u?x(["/each"]):[];r.push(function(t){var e,n=(e=a(t),Array.isArray(e)?e.map(function(t,e){return[t,e]}):e&&"object"==typeof e?Object.keys(e).map(function(t){return[e[t],t]}):[]);return n.length?n.map(function(e,r){var a=e[0],u=e[1],l=Object.create(t);return l[i]=a,o&&(l[o]=u),l["@"]=a,l.loop={index:r+1,index0:r,first:!r,last:r===n.length-1,length:n.length},d(s,l)}).join(""):d(c,t)})}else"#"===n.tag[0]||"/"===n.tag[0]?g("Unexpected {{"+n.tag+"}}"):r.push(v(n.tag,p))}var h};n=a[i++];)if(e=o())return e.v;return t.length&&g("Missing {{"+t[t.length-1]+"}}"),r};function b(t,e){o=e,l=new Set(["$","@"]),f=new Set,s=new Set,a=[];for(var n=String(t).split(h),r=0;r<n.length&&(n[r]&&a.push({text:n[r]}),!(r+1>=n.length));r+=7){var u=null!=n[r+2],c=u?{raw:n[r+2]}:{tag:n[r+5]};c.l="-"===n[r+(u?1:4)],c.r="-"===n[r+(u?3:6)],a.push(c)}a.forEach(function(t,e){var n,r;t.l&&null!=(n=a[e-1])&&n.text&&(a[e-1].text=a[e-1].text.replace(/\s+$/,"")),t.r&&null!=(r=a[e+1])&&r.text&&(a[e+1].text=a[e+1].text.replace(/^\s+/,""))}),i=0;var p=x([]),g=function(t,e){t=t||{};var n=Object.create(t);return n.$=e?e.root:t,e?"item"in e&&(n["@"]=e.item):n["@"]=t,d(p,n)};return g.names=Array.from(f),g.functions=Array.from(s),g}t.render=function(t,e,n){return b(t,n)(e)},t.template=b});
package/index.d.ts CHANGED
@@ -2,17 +2,32 @@
2
2
  * Compile a template once, render it many times.
3
3
  *
4
4
  * The returned renderer exposes `names`: the variables the template reads
5
- * from your values, deduplicated. Loop variables the template introduces
6
- * are not included.
5
+ * from your values, deduplicated. Loop variables the template introduces are
6
+ * not included. It also exposes `functions`: the registry functions the
7
+ * template calls, deduplicated.
8
+ *
9
+ * Two anchors are always in scope: `$` is the root values, and `@` is the
10
+ * current `#each` item (the root outside any loop). They let a nested loop
11
+ * reach the root (`$.company`) or the current item (`@.total`) explicitly,
12
+ * past any shadowing. Neither counts as a `name`.
13
+ *
14
+ * An embedder with its own scope model can override the anchors per render by
15
+ * passing `{ root, item }` as the renderer's second argument: `$` becomes
16
+ * `root` and `@` becomes `item` (two distinct objects). Omit `item` to leave
17
+ * `@` unbound, so reading `@.x` throws through xprsn's guard.
7
18
  *
8
19
  * @param {string} str The template, e.g. `'Hello {{ user.name }}!'`.
9
20
  * @param {Record<string, Function>} [funcs] Functions callable inside expressions.
10
- * @returns {{(values?: Record<string, any>): string, names: string[]}} Renderer for the compiled template.
21
+ * @returns {{(values?: Record<string, any>, scope?: { root?: any, item?: any }): string, names: string[], functions: string[]}} Renderer for the compiled template.
11
22
  * @throws {SyntaxError} On malformed tags, unclosed blocks, or bad expressions.
12
23
  */
13
24
  export function template(str: string, funcs?: Record<string, Function>): {
14
- (values?: Record<string, any>): string;
25
+ (values?: Record<string, any>, scope?: {
26
+ root?: any;
27
+ item?: any;
28
+ }): string;
15
29
  names: string[];
30
+ functions: string[];
16
31
  };
17
32
  /**
18
33
  * Compile and render a template in one go.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sjabloon",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Tiny, CSP-safe template engine for JavaScript, powered by xprsn expressions. No eval, no new Function.",
5
5
  "repository": "robinvdvleuten/sjabloon",
6
6
  "license": "MIT",
@@ -31,7 +31,8 @@
31
31
  "scripts": {
32
32
  "build": "microbundle --no-sourcemap",
33
33
  "prepublishOnly": "npm run build",
34
- "test": "node --disallow-code-generation-from-strings node_modules/tape/bin/tape test/*.test.js"
34
+ "test": "node --disallow-code-generation-from-strings node_modules/tape/bin/tape test/*.test.js",
35
+ "bench": "node bench/index.js"
35
36
  },
36
37
  "keywords": [
37
38
  "template",
@@ -41,7 +42,7 @@
41
42
  "handlebars"
42
43
  ],
43
44
  "dependencies": {
44
- "xprsn": "^0.2.0"
45
+ "xprsn": "^0.4.0"
45
46
  },
46
47
  "devDependencies": {
47
48
  "microbundle": "^0.15.1",
package/src/index.js CHANGED
@@ -19,15 +19,33 @@ const pairs = lv => Array.isArray(lv) ? lv.map((x, j) => [x, j])
19
19
  const TAGS = /\{\{\{(-)?\s*([\s\S]*?)\s*(-)?\}\}\}|\{\{(-)?\s*([\s\S]*?)\s*(-)?\}\}/;
20
20
 
21
21
  // Shared parser state; parsing is synchronous so this is safe.
22
- let toks, i, fns, last, bound, nms;
22
+ // `nms` collects free variables, `fnms` the registry functions called.
23
+ let toks, i, fns, last, bound, nms, fnms;
23
24
 
24
25
  let err = msg => { throw SyntaxError(msg) };
25
26
 
26
- // Compile one expression and collect its free variables, minus the loop
27
- // variables currently in scope those belong to the template, not the caller.
27
+ // Render a list of nodes against a scope.
28
+ let run = (nodes, v) => nodes.map(n => n(v)).join('');
29
+
30
+ // A leaf interpolation node: compile `src`, render nullish as '', apply `wrap`
31
+ // (`esc` for `{{ }}`, `String` for the raw `{{{ }}}` form).
32
+ let interp = (src, wrap) => (e => v => wrap(e(v) ?? ''))(cp(src));
33
+
34
+ // Bind `names` for a block body; returns a restore that unbinds only the names
35
+ // this block introduced, leaving an outer scope's bindings in place.
36
+ let scope = (...names) => {
37
+ const fresh = names.filter(n => n && !bound.has(n));
38
+ fresh.forEach(n => bound.add(n));
39
+ return () => fresh.forEach(n => bound.delete(n));
40
+ };
41
+
42
+ // Compile one expression and collect its free variables (minus the loop
43
+ // variables currently in scope, which belong to the template) and the registry
44
+ // functions it calls.
28
45
  let cp = s => {
29
46
  const e = compile(s, fns);
30
47
  for (const n of e.names) bound.has(n) || nms.add(n);
48
+ for (const fn of e.functions) fnms.add(fn);
31
49
  return e;
32
50
  };
33
51
 
@@ -37,7 +55,7 @@ let branch = cond => {
37
55
  const els = last.startsWith('#elif ') ? [branch(cp(last.slice(6)))]
38
56
  : last === '#else' ? parse(['/if'])
39
57
  : [];
40
- return v => (cond(v) ? then : els).map(n => n(v)).join('');
58
+ return v => run(cond(v) ? then : els, v);
41
59
  };
42
60
 
43
61
  let parse = stops => {
@@ -46,7 +64,7 @@ let parse = stops => {
46
64
  if (t.text != null) {
47
65
  nodes.push((s => () => s)(t.text));
48
66
  } else if (t.raw != null) {
49
- nodes.push((e => v => String(e(v) ?? ''))(cp(t.raw)));
67
+ nodes.push(interp(t.raw, String));
50
68
  } else if (stops.includes(t.tag.split(' ')[0])) {
51
69
  last = t.tag;
52
70
  return nodes;
@@ -57,29 +75,32 @@ let parse = stops => {
57
75
  } else if (t.tag.startsWith('#each ')) {
58
76
  const m = /^#each ([\s\S]+) as (\w+)(?:\s*,\s*(\w+))?$/.exec(t.tag) || err('Bad {{' + t.tag + '}}');
59
77
  const list = cp(m[1]), name = m[2], idx = m[3];
60
- const had = [bound.has(name), idx && bound.has(idx)];
61
- bound.add(name);
62
- idx && bound.add(idx);
78
+ // `name`, `idx`, and `loop` are engine-bound inside the body, so
79
+ // exclude them from names there and restore outer bindings after.
80
+ const restore = scope(name, idx, 'loop');
63
81
  const body = parse(['#else', '/each']);
64
- had[0] || bound.delete(name);
65
- idx && !had[1] && bound.delete(idx);
82
+ restore();
66
83
  const empty = last === '#else' ? parse(['/each']) : [];
67
- // Child scopes inherit the parent via the prototype chain, so
68
- // outer variables stay visible inside the loop body.
84
+ // Child scopes inherit the parent via the prototype chain, so outer
85
+ // variables stay visible inside the loop body. `@` re-points to the
86
+ // current item at each level, `$` (root) rides the chain, and `loop`
87
+ // carries the iteration metadata (index/first/last/length).
69
88
  nodes.push(v => {
70
89
  const ps = pairs(list(v));
71
- if (!ps.length) return empty.map(n => n(v)).join('');
72
- return ps.map(([item, key]) => {
90
+ if (!ps.length) return run(empty, v);
91
+ return ps.map(([item, key], j) => {
73
92
  const s = Object.create(v);
74
93
  s[name] = item;
75
94
  if (idx) s[idx] = key;
76
- return body.map(n => n(s)).join('');
95
+ s['@'] = item;
96
+ s.loop = { index: j + 1, index0: j, first: !j, last: j === ps.length - 1, length: ps.length };
97
+ return run(body, s);
77
98
  }).join('');
78
99
  });
79
100
  } else if (t.tag[0] === '#' || t.tag[0] === '/') {
80
101
  err('Unexpected {{' + t.tag + '}}');
81
102
  } else {
82
- nodes.push((e => v => esc(e(v) ?? ''))(cp(t.tag)));
103
+ nodes.push(interp(t.tag, esc));
83
104
  }
84
105
  }
85
106
  stops.length && err('Missing {{' + stops[stops.length - 1] + '}}');
@@ -90,18 +111,32 @@ let parse = stops => {
90
111
  * Compile a template once, render it many times.
91
112
  *
92
113
  * The returned renderer exposes `names`: the variables the template reads
93
- * from your values, deduplicated. Loop variables the template introduces
94
- * are not included.
114
+ * from your values, deduplicated. Loop variables the template introduces are
115
+ * not included. It also exposes `functions`: the registry functions the
116
+ * template calls, deduplicated.
117
+ *
118
+ * Two anchors are always in scope: `$` is the root values, and `@` is the
119
+ * current `#each` item (the root outside any loop). They let a nested loop
120
+ * reach the root (`$.company`) or the current item (`@.total`) explicitly,
121
+ * past any shadowing. Neither counts as a `name`.
122
+ *
123
+ * An embedder with its own scope model can override the anchors per render by
124
+ * passing `{ root, item }` as the renderer's second argument: `$` becomes
125
+ * `root` and `@` becomes `item` (two distinct objects). Omit `item` to leave
126
+ * `@` unbound, so reading `@.x` throws through xprsn's guard.
95
127
  *
96
128
  * @param {string} str The template, e.g. `'Hello {{ user.name }}!'`.
97
129
  * @param {Record<string, Function>} [funcs] Functions callable inside expressions.
98
- * @returns {{(values?: Record<string, any>): string, names: string[]}} Renderer for the compiled template.
130
+ * @returns {{(values?: Record<string, any>, scope?: { root?: any, item?: any }): string, names: string[], functions: string[]}} Renderer for the compiled template.
99
131
  * @throws {SyntaxError} On malformed tags, unclosed blocks, or bad expressions.
100
132
  */
101
133
  export function template(str, funcs) {
102
134
  fns = funcs;
103
- bound = new Set();
135
+ // `$` (root) and `@` (current item) are engine-bound anchors, always in
136
+ // scope, so they never count as caller-supplied `names`.
137
+ bound = new Set(['$', '@']);
104
138
  nms = new Set();
139
+ fnms = new Set();
105
140
  toks = [];
106
141
  const parts = String(str).split(TAGS);
107
142
  for (let j = 0; j < parts.length; j += 7) {
@@ -120,10 +155,24 @@ export function template(str, funcs) {
120
155
  });
121
156
  i = 0;
122
157
  const nodes = parse([]);
123
- const f = v => nodes.map(n => n(v || {})).join('');
158
+ // Wrap the values in a root scope carrying the anchors, without mutating
159
+ // what the caller passed: by default `$` and `@` both point at the root.
160
+ // An embedder can override the anchors with a `{ root, item }` second arg:
161
+ // `$` = root, `@` = item (distinct objects). Omitting `item` leaves `@`
162
+ // unbound, so `@.x` throws through xprsn's guard — a group-header band that
163
+ // has no current row wants exactly that.
164
+ const f = (v, o) => {
165
+ v = v || {};
166
+ const r = Object.create(v);
167
+ r['$'] = o ? o.root : v;
168
+ if (!o) r['@'] = v;
169
+ else if ('item' in o) r['@'] = o.item;
170
+ return run(nodes, r);
171
+ };
124
172
  // Array.from, not a spread: the bundler's transpile turns `[...set]` into
125
173
  // `[].concat(set)`, which wraps the Set instead of unpacking it.
126
174
  f.names = Array.from(nms);
175
+ f.functions = Array.from(fnms);
127
176
  return f;
128
177
  }
129
178