sjabloon 0.1.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/LICENSE +21 -0
- package/README.md +64 -0
- package/dist/index.cjs +1 -0
- package/dist/index.module.js +1 -0
- package/dist/index.umd.js +1 -0
- package/index.d.ts +18 -0
- package/package.json +50 -0
- package/src/index.js +92 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Robin van der Vleuten <robin@webstronauts.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# sjabloon
|
|
2
|
+
|
|
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
|
+
|
|
5
|
+
*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
|
+
|
|
7
|
+
```js
|
|
8
|
+
import { template, render } from 'sjabloon';
|
|
9
|
+
|
|
10
|
+
// Compile once, render many times:
|
|
11
|
+
const greet = template('Hello {{ user.name.toUpperCase() }}!');
|
|
12
|
+
greet({ user: { name: 'Robin' } }); // => 'Hello ROBIN!'
|
|
13
|
+
|
|
14
|
+
// Blocks, expressions, and custom functions:
|
|
15
|
+
render(
|
|
16
|
+
`<ul>{{#each items as it, i}}
|
|
17
|
+
<li>{{ i + 1 }}. {{ it.name }}: {{ fmt(it.price * it.qty) }}</li>
|
|
18
|
+
{{/each}}</ul>
|
|
19
|
+
{{#if total >= 100 and "vip" in user.roles}}Free shipping!{{#else}}Shipping: {{ fmt(5) }}{{/if}}`,
|
|
20
|
+
{ items: [{ name: 'Koffie', price: 8, qty: 2 }], total: 120, user: { roles: ['vip'] } },
|
|
21
|
+
{ fmt: n => '€' + n.toFixed(2) }
|
|
22
|
+
);
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## API
|
|
26
|
+
|
|
27
|
+
### `template(str, functions?)`
|
|
28
|
+
|
|
29
|
+
Compiles the template and returns a renderer `(values?) => string`. Malformed tags, unclosed blocks, and invalid expressions throw a `SyntaxError` at compile time.
|
|
30
|
+
|
|
31
|
+
### `render(str, values?, functions?)`
|
|
32
|
+
|
|
33
|
+
Shorthand for `template(str, functions)(values)`.
|
|
34
|
+
|
|
35
|
+
## Syntax
|
|
36
|
+
|
|
37
|
+
| Tag | Meaning |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| `{{ expr }}` | Interpolate an expression, HTML-escaped |
|
|
40
|
+
| `{{{ 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 |
|
|
44
|
+
| `{{! anything }}` | Comment, removed from output |
|
|
45
|
+
|
|
46
|
+
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
|
+
|
|
48
|
+
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.
|
|
49
|
+
|
|
50
|
+
## Content Security Policy
|
|
51
|
+
|
|
52
|
+
sjabloon works under `script-src 'self'` with no `unsafe-eval`. Templates parse into a tree of closures that call other closures; xprsn compiles the expressions the same way. The test suite runs under `node --disallow-code-generation-from-strings`, which throws on any string-to-code construct exactly like a strict CSP does.
|
|
53
|
+
|
|
54
|
+
This is the practical difference from engines like Handlebars (without precompilation) or tempura, which generate a JavaScript function per template and therefore need `unsafe-eval` at runtime. If you can precompile templates at build time, those engines are great and fast. If templates arrive at runtime (user-edited templates, CMS content, email templates) and your CSP is strict, sjabloon fits.
|
|
55
|
+
|
|
56
|
+
## Safety
|
|
57
|
+
|
|
58
|
+
- `{{ expr }}` escapes `& < > " '` by default; unescaped output requires the explicit `{{{ }}}` form.
|
|
59
|
+
- Expressions inherit all of xprsn's guards: no `__proto__`/`constructor`/`prototype` access, null-prototype hash literals, and functions resolved only from your registry.
|
|
60
|
+
- Templates read your values; they cannot assign to them.
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
MIT © [Robin van der Vleuten](https://robinvdvleuten.nl)
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
var t,n,r,e,u=require("xprsn"),i={"&":"&","<":"<",">":">",'"':""","'":"'"},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;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{compile as t}from"xprsn";var n,r,e,u,i={"&":"&","<":"<",">":">",'"':""","'":"'"},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};
|
|
@@ -0,0 +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={"&":"&","<":"<",">":">",'"':""","'":"'"},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});
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compile a template once, render it many times.
|
|
3
|
+
*
|
|
4
|
+
* @param {string} str The template, e.g. `'Hello {{ user.name }}!'`.
|
|
5
|
+
* @param {Record<string, Function>} [funcs] Functions callable inside expressions.
|
|
6
|
+
* @returns {(values?: Record<string, any>) => string} Renderer for the compiled template.
|
|
7
|
+
* @throws {SyntaxError} On malformed tags, unclosed blocks, or bad expressions.
|
|
8
|
+
*/
|
|
9
|
+
export function template(str: string, funcs?: Record<string, Function>): (values?: Record<string, any>) => string;
|
|
10
|
+
/**
|
|
11
|
+
* Compile and render a template in one go.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} str The template to render.
|
|
14
|
+
* @param {Record<string, any>} [values] Variables available to the template.
|
|
15
|
+
* @param {Record<string, Function>} [funcs] Functions callable inside expressions.
|
|
16
|
+
* @returns {string} The rendered output.
|
|
17
|
+
*/
|
|
18
|
+
export function render(str: string, values?: Record<string, any>, funcs?: Record<string, Function>): string;
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sjabloon",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Tiny, CSP-safe template engine for JavaScript, powered by xprsn expressions. No eval, no new Function.",
|
|
5
|
+
"repository": "robinvdvleuten/sjabloon",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": {
|
|
8
|
+
"name": "Robin van der Vleuten",
|
|
9
|
+
"email": "robin@webstronauts.com",
|
|
10
|
+
"url": "https://robinvdvleuten.nl"
|
|
11
|
+
},
|
|
12
|
+
"type": "module",
|
|
13
|
+
"source": "src/index.js",
|
|
14
|
+
"main": "./dist/index.cjs",
|
|
15
|
+
"module": "./dist/index.module.js",
|
|
16
|
+
"unpkg": "./dist/index.umd.js",
|
|
17
|
+
"types": "./index.d.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./index.d.ts",
|
|
21
|
+
"import": "./dist/index.module.js",
|
|
22
|
+
"require": "./dist/index.cjs",
|
|
23
|
+
"default": "./dist/index.module.js"
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"src",
|
|
29
|
+
"index.d.ts"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "microbundle --no-sourcemap",
|
|
33
|
+
"prepublishOnly": "npm run build",
|
|
34
|
+
"test": "node --disallow-code-generation-from-strings node_modules/tape/bin/tape test/*.test.js"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"template",
|
|
38
|
+
"template-engine",
|
|
39
|
+
"csp",
|
|
40
|
+
"render",
|
|
41
|
+
"handlebars"
|
|
42
|
+
],
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"xprsn": "^0.1.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"microbundle": "^0.15.1",
|
|
48
|
+
"tape": "^5.9.0"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny, CSP-safe template engine powered by xprsn expressions.
|
|
3
|
+
* Templates compile to a composition of closures; template text is never
|
|
4
|
+
* turned into JavaScript, so strict CSP is satisfied.
|
|
5
|
+
*/
|
|
6
|
+
import { compile } from 'xprsn';
|
|
7
|
+
|
|
8
|
+
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
9
|
+
const esc = s => String(s).replace(/[&<>"']/g, c => ESC[c]);
|
|
10
|
+
|
|
11
|
+
// Split into [text, raw-tag, tag, text, raw-tag, tag, ...] triplets.
|
|
12
|
+
const TAGS = /\{\{\{\s*([\s\S]*?)\s*\}\}\}|\{\{\s*([\s\S]*?)\s*\}\}/;
|
|
13
|
+
|
|
14
|
+
// Shared parser state; parsing is synchronous so this is safe.
|
|
15
|
+
let toks, i, fns, last;
|
|
16
|
+
|
|
17
|
+
let err = msg => { throw SyntaxError(msg) };
|
|
18
|
+
|
|
19
|
+
let parse = stops => {
|
|
20
|
+
const nodes = [];
|
|
21
|
+
for (let t; (t = toks[i++]); ) {
|
|
22
|
+
if (t.text != null) {
|
|
23
|
+
nodes.push((s => () => s)(t.text));
|
|
24
|
+
} else if (t.raw != null) {
|
|
25
|
+
nodes.push((e => v => String(e(v) ?? ''))(compile(t.raw, fns)));
|
|
26
|
+
} else if (stops.includes(t.tag.split(' ')[0])) {
|
|
27
|
+
last = t.tag;
|
|
28
|
+
return nodes;
|
|
29
|
+
} else if (t.tag[0] === '!') {
|
|
30
|
+
// comment
|
|
31
|
+
} 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(''));
|
|
36
|
+
} else if (t.tag.startsWith('#each ')) {
|
|
37
|
+
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']);
|
|
40
|
+
// Child scopes inherit the parent via the prototype chain, so
|
|
41
|
+
// 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(''));
|
|
48
|
+
} else if (t.tag[0] === '#' || t.tag[0] === '/') {
|
|
49
|
+
err('Unexpected {{' + t.tag + '}}');
|
|
50
|
+
} else {
|
|
51
|
+
nodes.push((e => v => esc(e(v) ?? ''))(compile(t.tag, fns)));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
stops.length && err('Missing {{' + stops[stops.length - 1] + '}}');
|
|
55
|
+
return nodes;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Compile a template once, render it many times.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} str The template, e.g. `'Hello {{ user.name }}!'`.
|
|
62
|
+
* @param {Record<string, Function>} [funcs] Functions callable inside expressions.
|
|
63
|
+
* @returns {(values?: Record<string, any>) => string} Renderer for the compiled template.
|
|
64
|
+
* @throws {SyntaxError} On malformed tags, unclosed blocks, or bad expressions.
|
|
65
|
+
*/
|
|
66
|
+
export function template(str, funcs) {
|
|
67
|
+
fns = funcs;
|
|
68
|
+
toks = [];
|
|
69
|
+
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 });
|
|
76
|
+
}
|
|
77
|
+
i = 0;
|
|
78
|
+
const nodes = parse([]);
|
|
79
|
+
return v => nodes.map(n => n(v || {})).join('');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Compile and render a template in one go.
|
|
84
|
+
*
|
|
85
|
+
* @param {string} str The template to render.
|
|
86
|
+
* @param {Record<string, any>} [values] Variables available to the template.
|
|
87
|
+
* @param {Record<string, Function>} [funcs] Functions callable inside expressions.
|
|
88
|
+
* @returns {string} The rendered output.
|
|
89
|
+
*/
|
|
90
|
+
export function render(str, values, funcs) {
|
|
91
|
+
return template(str, funcs)(values);
|
|
92
|
+
}
|