sjabloon 0.2.0 → 0.3.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
@@ -28,11 +39,12 @@ render(
28
39
 
29
40
  Compiles the template and returns a renderer `(values?) => 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 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.
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('{{ fmt(title) }}{{#each items as it}}{{ it.name }}{{/each}}', { fmt: s => s });
46
+ tpl.names; // => ['title', 'items']
47
+ tpl.functions; // => ['fmt']
36
48
  ```
37
49
 
38
50
  ### `render(str, values?, functions?)`
@@ -49,12 +61,32 @@ Shorthand for `template(str, functions)(values)`.
49
61
  | `{{#each expr as item}} … {{/each}}` | Loop over an array or an object's values |
50
62
  | `{{#each expr as item, key}} … {{/each}}` | Second name binds the index (arrays) or the key (objects) |
51
63
  | `{{#each expr as item}} … {{#else}} … {{/each}}` | The `{{#else}}` branch renders when the collection is empty or missing |
64
+ | `{{ loop.last }}` (inside `{{#each}}`) | Iteration metadata: `index` (1-based), `index0`, `first`, `last`, `length` |
52
65
  | `{{! anything }}` | Comment, removed from output |
53
66
  | `{{- expr -}}` | A dash hugging either brace trims the whitespace on that side, newlines included; works on every tag form |
54
67
 
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.
68
+ 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.
69
+
70
+ 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.
71
+
72
+ 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.
73
+
74
+ ```js
75
+ render('{{#each xs as x}}{{ x }}{{#if not loop.last}}, {{/if}}{{/each}}', { xs: ['a', 'b', 'c'] });
76
+ // => 'a, b, c'
77
+ ```
78
+
79
+ 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.
80
+
81
+ ```js
82
+ render(
83
+ '{{#each regions as company}}{{ company }} of {{ $.company }}: {{#each rows as r}}{{ @.n }} {{/each}}{{/each}}',
84
+ { company: 'ACME', regions: ['North', 'South'], rows: [{ n: 1 }, { n: 2 }] }
85
+ );
86
+ // => 'North of ACME: 1 2 South of ACME: 1 2 '
87
+ ```
56
88
 
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.
89
+ 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
90
 
59
91
  ## Content Security Policy
60
92
 
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,u,i,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,u),i=e(a.names);!(n=i()).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=i.startsWith("#elif ")?[m(d(i.slice(6)))]:"#else"===i?y(["/if"]):[];return function(n){return p(t(n)?r:e,n)}},y=function(t){for(var r,e,u=[],l=function(){if(null!=e.text)u.push((x=e.text,function(){return x}));else if(null!=e.raw)u.push(v(e.raw,String));else{if(t.includes(e.tag.split(" ")[0]))return i=e.tag,{v:u};if("!"===e.tag[0]);else if(e.tag.startsWith("#if "))u.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"===i?y(["/each"]):[];u.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 u=r[0],i=r[1],o=Object.create(t);return o[a]=u,l&&(o[l]=i),o["@"]=u,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+"}}"):u.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]+"}}"),u};function x(t,r){u=r,o=new Set(["$","@"]),l=new Set,c=new Set,n=[];for(var e=String(t).split(h),i=0;i<e.length&&(e[i]&&n.push({text:e[i]}),!(i+1>=e.length));i+=7){var f=null!=e[i+2],s=f?{raw:e[i+2]}:{tag:e[i+5]};s.l="-"===e[i+(f?1:4)],s.r="-"===e[i+(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){t=t||{};var r=Object.create(t);return r.$=r["@"]=t,p(g,r)};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,u,i,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,u),i=n(a.names);!(e=i()).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=i.startsWith("#elif ")?[m(d(i.slice(6)))]:"#else"===i?y(["/if"]):[];return function(e){return p(t(e)?r:n,e)}},y=function(t){for(var r,n,u=[],l=function(){if(null!=n.text)u.push((x=n.text,function(){return x}));else if(null!=n.raw)u.push(v(n.raw,String));else{if(t.includes(n.tag.split(" ")[0]))return i=n.tag,{v:u};if("!"===n.tag[0]);else if(n.tag.startsWith("#if "))u.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"===i?y(["/each"]):[];u.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 u=r[0],i=r[1],o=Object.create(t);return o[a]=u,l&&(o[l]=i),o["@"]=u,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+"}}"):u.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]+"}}"),u};function x(t,r){u=r,o=new Set(["$","@"]),l=new Set,f=new Set,e=[];for(var n=String(t).split(h),i=0;i<n.length&&(n[i]&&e.push({text:n[i]}),!(i+1>=n.length));i+=7){var c=null!=n[i+2],s=c?{raw:n[i+2]}:{tag:n[i+5]};s.l="-"===n[i+(c?1:4)],s.r="-"===n[i+(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){t=t||{};var r=Object.create(t);return r.$=r["@"]=t,p(g,r)};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,o,i,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,i),o=r(a.names);!(n=o()).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=[],i=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]),o=e[2],i=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)})}}(o,i,"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[o]=a,i&&(l[i]=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[o++];)if(e=i())return e.v;return t.length&&g("Missing {{"+t[t.length-1]+"}}"),r};function b(t,e){i=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+/,""))}),o=0;var p=x([]),g=function(t){t=t||{};var e=Object.create(t);return e.$=e["@"]=t,d(p,e)};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,24 @@
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`.
7
13
  *
8
14
  * @param {string} str The template, e.g. `'Hello {{ user.name }}!'`.
9
15
  * @param {Record<string, Function>} [funcs] Functions callable inside expressions.
10
- * @returns {{(values?: Record<string, any>): string, names: string[]}} Renderer for the compiled template.
16
+ * @returns {{(values?: Record<string, any>): string, names: string[], functions: string[]}} Renderer for the compiled template.
11
17
  * @throws {SyntaxError} On malformed tags, unclosed blocks, or bad expressions.
12
18
  */
13
19
  export function template(str: string, funcs?: Record<string, Function>): {
14
20
  (values?: Record<string, any>): string;
15
21
  names: string[];
22
+ functions: string[];
16
23
  };
17
24
  /**
18
25
  * 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.3.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.3.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,27 @@ 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`.
95
122
  *
96
123
  * @param {string} str The template, e.g. `'Hello {{ user.name }}!'`.
97
124
  * @param {Record<string, Function>} [funcs] Functions callable inside expressions.
98
- * @returns {{(values?: Record<string, any>): string, names: string[]}} Renderer for the compiled template.
125
+ * @returns {{(values?: Record<string, any>): string, names: string[], functions: string[]}} Renderer for the compiled template.
99
126
  * @throws {SyntaxError} On malformed tags, unclosed blocks, or bad expressions.
100
127
  */
101
128
  export function template(str, funcs) {
102
129
  fns = funcs;
103
- bound = new Set();
130
+ // `$` (root) and `@` (current item) are engine-bound anchors, always in
131
+ // scope, so they never count as caller-supplied `names`.
132
+ bound = new Set(['$', '@']);
104
133
  nms = new Set();
134
+ fnms = new Set();
105
135
  toks = [];
106
136
  const parts = String(str).split(TAGS);
107
137
  for (let j = 0; j < parts.length; j += 7) {
@@ -120,10 +150,18 @@ export function template(str, funcs) {
120
150
  });
121
151
  i = 0;
122
152
  const nodes = parse([]);
123
- const f = v => nodes.map(n => n(v || {})).join('');
153
+ // Wrap the values in a root scope carrying the anchors, without mutating
154
+ // what the caller passed: `$` and `@` both point at the root here.
155
+ const f = v => {
156
+ v = v || {};
157
+ const r = Object.create(v);
158
+ r['$'] = r['@'] = v;
159
+ return run(nodes, r);
160
+ };
124
161
  // Array.from, not a spread: the bundler's transpile turns `[...set]` into
125
162
  // `[].concat(set)`, which wraps the Set instead of unpacking it.
126
163
  f.names = Array.from(nms);
164
+ f.functions = Array.from(fnms);
127
165
  return f;
128
166
  }
129
167