sjabloon 0.1.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 +46 -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 +16 -2
- package/package.json +4 -3
- package/src/index.js +113 -27
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,6 +39,14 @@ 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
|
|
|
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.
|
|
43
|
+
|
|
44
|
+
```js
|
|
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']
|
|
48
|
+
```
|
|
49
|
+
|
|
31
50
|
### `render(str, values?, functions?)`
|
|
32
51
|
|
|
33
52
|
Shorthand for `template(str, functions)(values)`.
|
|
@@ -38,14 +57,36 @@ Shorthand for `template(str, functions)(values)`.
|
|
|
38
57
|
| --- | --- |
|
|
39
58
|
| `{{ expr }}` | Interpolate an expression, HTML-escaped |
|
|
40
59
|
| `{{{ expr }}}` | Interpolate without escaping |
|
|
41
|
-
| `{{#if expr}} … {{#else}} … {{/if}}` | Conditional block |
|
|
42
|
-
| `{{#each expr as item}} … {{/each}}` | Loop
|
|
43
|
-
| `{{#each expr as item,
|
|
60
|
+
| `{{#if expr}} … {{#elif expr}} … {{#else}} … {{/if}}` | Conditional block, with as many `{{#elif}}` links as you need |
|
|
61
|
+
| `{{#each expr as item}} … {{/each}}` | Loop over an array or an object's values |
|
|
62
|
+
| `{{#each expr as item, key}} … {{/each}}` | Second name binds the index (arrays) or the key (objects) |
|
|
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` |
|
|
44
65
|
| `{{! anything }}` | Comment, removed from output |
|
|
66
|
+
| `{{- expr -}}` | A dash hugging either brace trims the whitespace on that side, newlines included; works on every tag form |
|
|
67
|
+
|
|
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.
|
|
45
73
|
|
|
46
|
-
|
|
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
|
+
```
|
|
47
88
|
|
|
48
|
-
|
|
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.
|
|
49
90
|
|
|
50
91
|
## Content Security Policy
|
|
51
92
|
|
package/dist/index.cjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var t,n,r,e,
|
|
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";var n,r,e,u,i={"&":"&","<":"<",">":">",'"':""","'":"'"},
|
|
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(
|
|
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
|
@@ -1,12 +1,26 @@
|
|
|
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 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
|
+
*
|
|
4
14
|
* @param {string} str The template, e.g. `'Hello {{ user.name }}!'`.
|
|
5
15
|
* @param {Record<string, Function>} [funcs] Functions callable inside expressions.
|
|
6
|
-
* @returns {(values?: Record<string, any>)
|
|
16
|
+
* @returns {{(values?: Record<string, any>): string, names: string[], functions: string[]}} Renderer for the compiled template.
|
|
7
17
|
* @throws {SyntaxError} On malformed tags, unclosed blocks, or bad expressions.
|
|
8
18
|
*/
|
|
9
|
-
export function template(str: string, funcs?: Record<string, Function>):
|
|
19
|
+
export function template(str: string, funcs?: Record<string, Function>): {
|
|
20
|
+
(values?: Record<string, any>): string;
|
|
21
|
+
names: string[];
|
|
22
|
+
functions: string[];
|
|
23
|
+
};
|
|
10
24
|
/**
|
|
11
25
|
* Compile and render a template in one go.
|
|
12
26
|
*
|
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
|
@@ -8,47 +8,99 @@ import { compile } from 'xprsn';
|
|
|
8
8
|
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
9
9
|
const esc = s => String(s).replace(/[&<>"']/g, c => ESC[c]);
|
|
10
10
|
|
|
11
|
-
//
|
|
12
|
-
const
|
|
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
|
-
|
|
22
|
+
// `nms` collects free variables, `fnms` the registry functions called.
|
|
23
|
+
let toks, i, fns, last, bound, nms, fnms;
|
|
16
24
|
|
|
17
25
|
let err = msg => { throw SyntaxError(msg) };
|
|
18
26
|
|
|
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.
|
|
45
|
+
let cp = s => {
|
|
46
|
+
const e = compile(s, fns);
|
|
47
|
+
for (const n of e.names) bound.has(n) || nms.add(n);
|
|
48
|
+
for (const fn of e.functions) fnms.add(fn);
|
|
49
|
+
return e;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// One `#if`/`#elif` link: parse its branch, then recurse on the chain tail.
|
|
53
|
+
let branch = cond => {
|
|
54
|
+
const then = parse(['#elif', '#else', '/if']);
|
|
55
|
+
const els = last.startsWith('#elif ') ? [branch(cp(last.slice(6)))]
|
|
56
|
+
: last === '#else' ? parse(['/if'])
|
|
57
|
+
: [];
|
|
58
|
+
return v => run(cond(v) ? then : els, v);
|
|
59
|
+
};
|
|
60
|
+
|
|
19
61
|
let parse = stops => {
|
|
20
62
|
const nodes = [];
|
|
21
63
|
for (let t; (t = toks[i++]); ) {
|
|
22
64
|
if (t.text != null) {
|
|
23
65
|
nodes.push((s => () => s)(t.text));
|
|
24
66
|
} else if (t.raw != null) {
|
|
25
|
-
nodes.push((
|
|
67
|
+
nodes.push(interp(t.raw, String));
|
|
26
68
|
} else if (stops.includes(t.tag.split(' ')[0])) {
|
|
27
69
|
last = t.tag;
|
|
28
70
|
return nodes;
|
|
29
71
|
} else if (t.tag[0] === '!') {
|
|
30
72
|
// comment
|
|
31
73
|
} else if (t.tag.startsWith('#if ')) {
|
|
32
|
-
|
|
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(''));
|
|
74
|
+
nodes.push(branch(cp(t.tag.slice(4))));
|
|
36
75
|
} else if (t.tag.startsWith('#each ')) {
|
|
37
76
|
const m = /^#each ([\s\S]+) as (\w+)(?:\s*,\s*(\w+))?$/.exec(t.tag) || err('Bad {{' + t.tag + '}}');
|
|
38
|
-
const list =
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
77
|
+
const list = cp(m[1]), name = m[2], idx = m[3];
|
|
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');
|
|
81
|
+
const body = parse(['#else', '/each']);
|
|
82
|
+
restore();
|
|
83
|
+
const empty = last === '#else' ? parse(['/each']) : [];
|
|
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).
|
|
88
|
+
nodes.push(v => {
|
|
89
|
+
const ps = pairs(list(v));
|
|
90
|
+
if (!ps.length) return run(empty, v);
|
|
91
|
+
return ps.map(([item, key], j) => {
|
|
92
|
+
const s = Object.create(v);
|
|
93
|
+
s[name] = item;
|
|
94
|
+
if (idx) s[idx] = key;
|
|
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);
|
|
98
|
+
}).join('');
|
|
99
|
+
});
|
|
48
100
|
} else if (t.tag[0] === '#' || t.tag[0] === '/') {
|
|
49
101
|
err('Unexpected {{' + t.tag + '}}');
|
|
50
102
|
} else {
|
|
51
|
-
nodes.push((
|
|
103
|
+
nodes.push(interp(t.tag, esc));
|
|
52
104
|
}
|
|
53
105
|
}
|
|
54
106
|
stops.length && err('Missing {{' + stops[stops.length - 1] + '}}');
|
|
@@ -58,25 +110,59 @@ let parse = stops => {
|
|
|
58
110
|
/**
|
|
59
111
|
* Compile a template once, render it many times.
|
|
60
112
|
*
|
|
113
|
+
* The returned renderer exposes `names`: the variables the template reads
|
|
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
|
+
*
|
|
61
123
|
* @param {string} str The template, e.g. `'Hello {{ user.name }}!'`.
|
|
62
124
|
* @param {Record<string, Function>} [funcs] Functions callable inside expressions.
|
|
63
|
-
* @returns {(values?: Record<string, any>)
|
|
125
|
+
* @returns {{(values?: Record<string, any>): string, names: string[], functions: string[]}} Renderer for the compiled template.
|
|
64
126
|
* @throws {SyntaxError} On malformed tags, unclosed blocks, or bad expressions.
|
|
65
127
|
*/
|
|
66
128
|
export function template(str, funcs) {
|
|
67
129
|
fns = funcs;
|
|
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(['$', '@']);
|
|
133
|
+
nms = new Set();
|
|
134
|
+
fnms = new Set();
|
|
68
135
|
toks = [];
|
|
69
136
|
const parts = String(str).split(TAGS);
|
|
70
|
-
for (let j = 0; j < parts.length; j
|
|
71
|
-
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
137
|
+
for (let j = 0; j < parts.length; j += 7) {
|
|
138
|
+
if (parts[j]) toks.push({ text: parts[j] });
|
|
139
|
+
if (j + 1 >= parts.length) break;
|
|
140
|
+
const raw = parts[j + 2] != null;
|
|
141
|
+
const t = raw ? { raw: parts[j + 2] } : { tag: parts[j + 5] };
|
|
142
|
+
t.l = parts[j + (raw ? 1 : 4)] === '-';
|
|
143
|
+
t.r = parts[j + (raw ? 3 : 6)] === '-';
|
|
144
|
+
toks.push(t);
|
|
76
145
|
}
|
|
146
|
+
// `{{-` / `-}}` eat the whitespace touching that side of the tag.
|
|
147
|
+
toks.forEach((t, k) => {
|
|
148
|
+
if (t.l && toks[k - 1]?.text) toks[k - 1].text = toks[k - 1].text.replace(/\s+$/, '');
|
|
149
|
+
if (t.r && toks[k + 1]?.text) toks[k + 1].text = toks[k + 1].text.replace(/^\s+/, '');
|
|
150
|
+
});
|
|
77
151
|
i = 0;
|
|
78
152
|
const nodes = parse([]);
|
|
79
|
-
|
|
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
|
+
};
|
|
161
|
+
// Array.from, not a spread: the bundler's transpile turns `[...set]` into
|
|
162
|
+
// `[].concat(set)`, which wraps the Set instead of unpacking it.
|
|
163
|
+
f.names = Array.from(nms);
|
|
164
|
+
f.functions = Array.from(fnms);
|
|
165
|
+
return f;
|
|
80
166
|
}
|
|
81
167
|
|
|
82
168
|
/**
|