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 +37 -5
- package/dist/index.cjs +1 -1
- package/dist/index.module.js +1 -1
- package/dist/index.umd.js +1 -1
- package/index.d.ts +10 -3
- package/package.json +4 -3
- package/src/index.js +59 -21
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
|
+
[](https://www.npmjs.com/package/sjabloon)
|
|
6
|
+
[](https://github.com/robinvdvleuten/sjabloon/actions/workflows/test.yml)
|
|
7
|
+
[](https://www.npmjs.com/package/sjabloon)
|
|
8
|
+
[](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
|
|
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;
|
|
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
|
-
|
|
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}
|
|
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={"&":"&","<":"<",">":">",'"':""","'":"'"},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;
|
package/dist/index.module.js
CHANGED
|
@@ -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}
|
|
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={"&":"&","<":"<",">":">",'"':""","'":"'"},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}
|
|
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={"&":"&","<":"<",">":">",'"':""","'":"'"},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
|
-
*
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
27
|
-
|
|
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
|
|
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((
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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((
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|