sjabloon 0.1.0 → 0.2.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
@@ -28,6 +28,13 @@ render(
28
28
 
29
29
  Compiles the template and returns a renderer `(values?) => string`. Malformed tags, unclosed blocks, and invalid expressions throw a `SyntaxError` at compile time.
30
30
 
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.
32
+
33
+ ```js
34
+ const tpl = template('{{ title }}{{#each items as it}}{{ it.name }}{{/each}}');
35
+ tpl.names; // => ['title', 'items']
36
+ ```
37
+
31
38
  ### `render(str, values?, functions?)`
32
39
 
33
40
  Shorthand for `template(str, functions)(values)`.
@@ -38,10 +45,12 @@ Shorthand for `template(str, functions)(values)`.
38
45
  | --- | --- |
39
46
  | `{{ expr }}` | Interpolate an expression, HTML-escaped |
40
47
  | `{{{ expr }}}` | Interpolate without escaping |
41
- | `{{#if expr}} … {{#else}} … {{/if}}` | Conditional block |
42
- | `{{#each expr as item}} … {{/each}}` | Loop block |
43
- | `{{#each expr as item, i}} … {{/each}}` | Loop block with index |
48
+ | `{{#if expr}} … {{#elif expr}} … {{#else}} … {{/if}}` | Conditional block, with as many `{{#elif}}` links as you need |
49
+ | `{{#each expr as item}} … {{/each}}` | Loop over an array or an object's values |
50
+ | `{{#each expr as item, key}} … {{/each}}` | Second name binds the index (arrays) or the key (objects) |
51
+ | `{{#each expr as item}} … {{#else}} … {{/each}}` | The `{{#else}}` branch renders when the collection is empty or missing |
44
52
  | `{{! anything }}` | Comment, removed from output |
53
+ | `{{- expr -}}` | A dash hugging either brace trims the whitespace on that side, newlines included; works on every tag form |
45
54
 
46
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.
47
56
 
package/dist/index.cjs CHANGED
@@ -1 +1 @@
1
- var t,n,r,e,u=require("xprsn"),i={"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"},a=/\{\{\{\s*([\s\S]*?)\s*\}\}\}|\{\{\s*([\s\S]*?)\s*\}\}/,s=function(t){throw SyntaxError(t)},o=function(a){for(var c,l,f=[],p=function(){if(null!=l.text)f.push((S=l.text,function(){return S}));else if(null!=l.raw)f.push((x=u.compile(l.raw,r),function(t){var n;return String(null!=(n=x(t))?n:"")}));else{if(a.includes(l.tag.split(" ")[0]))return e=l.tag,{v:f};if("!"===l.tag[0]);else if(l.tag.startsWith("#if ")){var t=u.compile(l.tag.slice(4),r),n=o(["#else","/if"]),c="#else"===e?o(["/if"]):[];f.push(function(r){return(t(r)?n:c).map(function(t){return t(r)}).join("")})}else if(l.tag.startsWith("#each ")){var p=/^#each ([\s\S]+) as (\w+)(?:\s*,\s*(\w+))?$/.exec(l.tag)||s("Bad {{"+l.tag+"}}"),g=u.compile(p[1],r),h=p[2],v=p[3],m=o(["/each"]);f.push(function(t){return g(t).map(function(n,r){var e=Object.create(t);return e[h]=n,v&&(e[v]=r),m.map(function(t){return t(e)}).join("")}).join("")})}else"#"===l.tag[0]||"/"===l.tag[0]?s("Unexpected {{"+l.tag+"}}"):f.push(function(t){return function(n){var r;return function(t){return String(t).replace(/[&<>"']/g,function(t){return i[t]})}(null!=(r=t(n))?r:"")}}(u.compile(l.tag,r)))}var x,S};l=t[n++];)if(c=p())return c.v;return a.length&&s("Missing {{"+a[a.length-1]+"}}"),f};function c(e,u){r=u,t=[];for(var i=String(e).split(a),s=0;s<i.length;s++){var c=i[s];null!=c&&(s%3==0?c&&t.push({text:c}):t.push(s%3==1?{raw:c}:{tag:c}))}n=0;var l=o([]);return function(t){return l.map(function(n){return n(t||{})}).join("")}}exports.render=function(t,n,r){return c(t,r)(n)},exports.template=c;
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 +1 @@
1
- import{compile as t}from"xprsn";var n,r,e,u,i={"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"},a=/\{\{\{\s*([\s\S]*?)\s*\}\}\}|\{\{\s*([\s\S]*?)\s*\}\}/,s=function(t){throw SyntaxError(t)},f=function(a){for(var o,c,l=[],g=function(){if(null!=c.text)l.push((S=c.text,function(){return S}));else if(null!=c.raw)l.push((m=t(c.raw,e),function(t){var n;return String(null!=(n=m(t))?n:"")}));else{if(a.includes(c.tag.split(" ")[0]))return u=c.tag,{v:l};if("!"===c.tag[0]);else if(c.tag.startsWith("#if ")){var n=t(c.tag.slice(4),e),r=f(["#else","/if"]),o="#else"===u?f(["/if"]):[];l.push(function(t){return(n(t)?r:o).map(function(n){return n(t)}).join("")})}else if(c.tag.startsWith("#each ")){var g=/^#each ([\s\S]+) as (\w+)(?:\s*,\s*(\w+))?$/.exec(c.tag)||s("Bad {{"+c.tag+"}}"),p=t(g[1],e),h=g[2],v=g[3],x=f(["/each"]);l.push(function(t){return p(t).map(function(n,r){var e=Object.create(t);return e[h]=n,v&&(e[v]=r),x.map(function(t){return t(e)}).join("")}).join("")})}else"#"===c.tag[0]||"/"===c.tag[0]?s("Unexpected {{"+c.tag+"}}"):l.push(function(t){return function(n){var r;return function(t){return String(t).replace(/[&<>"']/g,function(t){return i[t]})}(null!=(r=t(n))?r:"")}}(t(c.tag,e)))}var m,S};c=n[r++];)if(o=g())return o.v;return a.length&&s("Missing {{"+a[a.length-1]+"}}"),l};function o(t,u){e=u,n=[];for(var i=String(t).split(a),s=0;s<i.length;s++){var o=i[s];null!=o&&(s%3==0?o&&n.push({text:o}):n.push(s%3==1?{raw:o}:{tag:o}))}r=0;var c=f([]);return function(t){return c.map(function(n){return n(t||{})}).join("")}}function c(t,n,r){return o(t,r)(n)}export{c as render,o 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}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};
package/dist/index.umd.js CHANGED
@@ -1 +1 @@
1
- !function(n,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports,require("xprsn")):"function"==typeof define&&define.amd?define(["exports","xprsn"],t):t((n||self).sjabloon={},n.xprsn)}(this,function(n,t){var e,r,i,u,a={"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"},o=/\{\{\{\s*([\s\S]*?)\s*\}\}\}|\{\{\s*([\s\S]*?)\s*\}\}/,s=function(n){throw SyntaxError(n)},f=function(n){for(var o,l,c=[],p=function(){if(null!=l.text)c.push((x=l.text,function(){return x}));else if(null!=l.raw)c.push((m=t.compile(l.raw,i),function(n){var t;return String(null!=(t=m(n))?t:"")}));else{if(n.includes(l.tag.split(" ")[0]))return u=l.tag,{v:c};if("!"===l.tag[0]);else if(l.tag.startsWith("#if ")){var e=t.compile(l.tag.slice(4),i),r=f(["#else","/if"]),o="#else"===u?f(["/if"]):[];c.push(function(n){return(e(n)?r:o).map(function(t){return t(n)}).join("")})}else if(l.tag.startsWith("#each ")){var p=/^#each ([\s\S]+) as (\w+)(?:\s*,\s*(\w+))?$/.exec(l.tag)||s("Bad {{"+l.tag+"}}"),g=t.compile(p[1],i),h=p[2],d=p[3],v=f(["/each"]);c.push(function(n){return g(n).map(function(t,e){var r=Object.create(n);return r[h]=t,d&&(r[d]=e),v.map(function(n){return n(r)}).join("")}).join("")})}else"#"===l.tag[0]||"/"===l.tag[0]?s("Unexpected {{"+l.tag+"}}"):c.push(function(n){return function(t){var e;return function(n){return String(n).replace(/[&<>"']/g,function(n){return a[n]})}(null!=(e=n(t))?e:"")}}(t.compile(l.tag,i)))}var m,x};l=e[r++];)if(o=p())return o.v;return n.length&&s("Missing {{"+n[n.length-1]+"}}"),c};function l(n,t){i=t,e=[];for(var u=String(n).split(o),a=0;a<u.length;a++){var s=u[a];null!=s&&(a%3==0?s&&e.push({text:s}):e.push(a%3==1?{raw:s}:{tag:s}))}r=0;var l=f([]);return function(n){return l.map(function(t){return t(n||{})}).join("")}}n.render=function(n,t,e){return l(n,e)(t)},n.template=l});
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});
package/index.d.ts CHANGED
@@ -1,12 +1,19 @@
1
1
  /**
2
2
  * Compile a template once, render it many times.
3
3
  *
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.
7
+ *
4
8
  * @param {string} str The template, e.g. `'Hello {{ user.name }}!'`.
5
9
  * @param {Record<string, Function>} [funcs] Functions callable inside expressions.
6
- * @returns {(values?: Record<string, any>) => string} Renderer for the compiled template.
10
+ * @returns {{(values?: Record<string, any>): string, names: string[]}} Renderer for the compiled template.
7
11
  * @throws {SyntaxError} On malformed tags, unclosed blocks, or bad expressions.
8
12
  */
9
- export function template(str: string, funcs?: Record<string, Function>): (values?: Record<string, any>) => string;
13
+ export function template(str: string, funcs?: Record<string, Function>): {
14
+ (values?: Record<string, any>): string;
15
+ names: string[];
16
+ };
10
17
  /**
11
18
  * Compile and render a template in one go.
12
19
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sjabloon",
3
- "version": "0.1.0",
3
+ "version": "0.2.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",
@@ -41,7 +41,7 @@
41
41
  "handlebars"
42
42
  ],
43
43
  "dependencies": {
44
- "xprsn": "^0.1.0"
44
+ "xprsn": "^0.2.0"
45
45
  },
46
46
  "devDependencies": {
47
47
  "microbundle": "^0.15.1",
package/src/index.js CHANGED
@@ -8,47 +8,78 @@ import { compile } from 'xprsn';
8
8
  const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
9
9
  const esc = s => String(s).replace(/[&<>"']/g, c => ESC[c]);
10
10
 
11
- // Split into [text, raw-tag, tag, text, raw-tag, tag, ...] triplets.
12
- const TAGS = /\{\{\{\s*([\s\S]*?)\s*\}\}\}|\{\{\s*([\s\S]*?)\s*\}\}/;
11
+ // What `#each` walks: [value, key] pairs array indexes or own object keys.
12
+ const pairs = lv => Array.isArray(lv) ? lv.map((x, j) => [x, j])
13
+ : lv && typeof lv === 'object' ? Object.keys(lv).map(k => [lv[k], k])
14
+ : [];
15
+
16
+ // Split into [text, rawL, raw, rawR, tagL, tag, tagR, ...] strides of 7.
17
+ // The dash captures hug the braces, so `{{ -price }}` stays a unary minus
18
+ // while `{{- price -}}` trims the whitespace touching the tag.
19
+ const TAGS = /\{\{\{(-)?\s*([\s\S]*?)\s*(-)?\}\}\}|\{\{(-)?\s*([\s\S]*?)\s*(-)?\}\}/;
13
20
 
14
21
  // Shared parser state; parsing is synchronous so this is safe.
15
- let toks, i, fns, last;
22
+ let toks, i, fns, last, bound, nms;
16
23
 
17
24
  let err = msg => { throw SyntaxError(msg) };
18
25
 
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.
28
+ let cp = s => {
29
+ const e = compile(s, fns);
30
+ for (const n of e.names) bound.has(n) || nms.add(n);
31
+ return e;
32
+ };
33
+
34
+ // One `#if`/`#elif` link: parse its branch, then recurse on the chain tail.
35
+ let branch = cond => {
36
+ const then = parse(['#elif', '#else', '/if']);
37
+ const els = last.startsWith('#elif ') ? [branch(cp(last.slice(6)))]
38
+ : last === '#else' ? parse(['/if'])
39
+ : [];
40
+ return v => (cond(v) ? then : els).map(n => n(v)).join('');
41
+ };
42
+
19
43
  let parse = stops => {
20
44
  const nodes = [];
21
45
  for (let t; (t = toks[i++]); ) {
22
46
  if (t.text != null) {
23
47
  nodes.push((s => () => s)(t.text));
24
48
  } else if (t.raw != null) {
25
- nodes.push((e => v => String(e(v) ?? ''))(compile(t.raw, fns)));
49
+ nodes.push((e => v => String(e(v) ?? ''))(cp(t.raw)));
26
50
  } else if (stops.includes(t.tag.split(' ')[0])) {
27
51
  last = t.tag;
28
52
  return nodes;
29
53
  } else if (t.tag[0] === '!') {
30
54
  // comment
31
55
  } else if (t.tag.startsWith('#if ')) {
32
- const cond = compile(t.tag.slice(4), fns);
33
- const then = parse(['#else', '/if']);
34
- const els = last === '#else' ? parse(['/if']) : [];
35
- nodes.push(v => (cond(v) ? then : els).map(n => n(v)).join(''));
56
+ nodes.push(branch(cp(t.tag.slice(4))));
36
57
  } else if (t.tag.startsWith('#each ')) {
37
58
  const m = /^#each ([\s\S]+) as (\w+)(?:\s*,\s*(\w+))?$/.exec(t.tag) || err('Bad {{' + t.tag + '}}');
38
- const list = compile(m[1], fns), name = m[2], idx = m[3];
39
- const body = parse(['/each']);
59
+ 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);
63
+ const body = parse(['#else', '/each']);
64
+ had[0] || bound.delete(name);
65
+ idx && !had[1] && bound.delete(idx);
66
+ const empty = last === '#else' ? parse(['/each']) : [];
40
67
  // Child scopes inherit the parent via the prototype chain, so
41
68
  // outer variables stay visible inside the loop body.
42
- nodes.push(v => list(v).map((item, j) => {
43
- const s = Object.create(v);
44
- s[name] = item;
45
- if (idx) s[idx] = j;
46
- return body.map(n => n(s)).join('');
47
- }).join(''));
69
+ nodes.push(v => {
70
+ const ps = pairs(list(v));
71
+ if (!ps.length) return empty.map(n => n(v)).join('');
72
+ return ps.map(([item, key]) => {
73
+ const s = Object.create(v);
74
+ s[name] = item;
75
+ if (idx) s[idx] = key;
76
+ return body.map(n => n(s)).join('');
77
+ }).join('');
78
+ });
48
79
  } else if (t.tag[0] === '#' || t.tag[0] === '/') {
49
80
  err('Unexpected {{' + t.tag + '}}');
50
81
  } else {
51
- nodes.push((e => v => esc(e(v) ?? ''))(compile(t.tag, fns)));
82
+ nodes.push((e => v => esc(e(v) ?? ''))(cp(t.tag)));
52
83
  }
53
84
  }
54
85
  stops.length && err('Missing {{' + stops[stops.length - 1] + '}}');
@@ -58,25 +89,42 @@ let parse = stops => {
58
89
  /**
59
90
  * Compile a template once, render it many times.
60
91
  *
92
+ * The returned renderer exposes `names`: the variables the template reads
93
+ * from your values, deduplicated. Loop variables the template introduces
94
+ * are not included.
95
+ *
61
96
  * @param {string} str The template, e.g. `'Hello {{ user.name }}!'`.
62
97
  * @param {Record<string, Function>} [funcs] Functions callable inside expressions.
63
- * @returns {(values?: Record<string, any>) => string} Renderer for the compiled template.
98
+ * @returns {{(values?: Record<string, any>): string, names: string[]}} Renderer for the compiled template.
64
99
  * @throws {SyntaxError} On malformed tags, unclosed blocks, or bad expressions.
65
100
  */
66
101
  export function template(str, funcs) {
67
102
  fns = funcs;
103
+ bound = new Set();
104
+ nms = new Set();
68
105
  toks = [];
69
106
  const parts = String(str).split(TAGS);
70
- for (let j = 0; j < parts.length; j++) {
71
- const s = parts[j];
72
- if (s == null) continue;
73
- if (j % 3 === 0) s && toks.push({ text: s });
74
- else if (j % 3 === 1) toks.push({ raw: s });
75
- else toks.push({ tag: s });
107
+ for (let j = 0; j < parts.length; j += 7) {
108
+ if (parts[j]) toks.push({ text: parts[j] });
109
+ if (j + 1 >= parts.length) break;
110
+ const raw = parts[j + 2] != null;
111
+ const t = raw ? { raw: parts[j + 2] } : { tag: parts[j + 5] };
112
+ t.l = parts[j + (raw ? 1 : 4)] === '-';
113
+ t.r = parts[j + (raw ? 3 : 6)] === '-';
114
+ toks.push(t);
76
115
  }
116
+ // `{{-` / `-}}` eat the whitespace touching that side of the tag.
117
+ toks.forEach((t, k) => {
118
+ if (t.l && toks[k - 1]?.text) toks[k - 1].text = toks[k - 1].text.replace(/\s+$/, '');
119
+ if (t.r && toks[k + 1]?.text) toks[k + 1].text = toks[k + 1].text.replace(/^\s+/, '');
120
+ });
77
121
  i = 0;
78
122
  const nodes = parse([]);
79
- return v => nodes.map(n => n(v || {})).join('');
123
+ const f = v => nodes.map(n => n(v || {})).join('');
124
+ // Array.from, not a spread: the bundler's transpile turns `[...set]` into
125
+ // `[].concat(set)`, which wraps the Set instead of unpacking it.
126
+ f.names = Array.from(nms);
127
+ return f;
80
128
  }
81
129
 
82
130
  /**