sjabloon 0.4.1 → 0.5.1

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
@@ -1,6 +1,6 @@
1
1
  # sjabloon
2
2
 
3
- A tiny, CSP-safe template engine for JavaScript. **~1.2KB min+gzip (~2.5KB with [xprsn](https://www.npmjs.com/package/xprsn)), one dependency.**
3
+ A tiny, CSP-safe template engine for JavaScript. **~1.8KB min+gzip (~3.6KB with [xprsn](https://www.npmjs.com/package/xprsn)), one dependency.**
4
4
 
5
5
  [![NPM version](https://img.shields.io/npm/v/sjabloon.svg)](https://www.npmjs.com/package/sjabloon)
6
6
  [![Build Status](https://github.com/robinvdvleuten/sjabloon/actions/workflows/test.yml/badge.svg)](https://github.com/robinvdvleuten/sjabloon/actions/workflows/test.yml)
@@ -59,6 +59,21 @@ tpl.functions; // => ['fmt']
59
59
 
60
60
  Shorthand for `template(str, functions)(values)`.
61
61
 
62
+ ### Error diagnostics
63
+
64
+ Sjabloon errors keep their native `SyntaxError` or `TypeError` class and expose:
65
+
66
+ - `code`: a stable `SJABLOON_*` parser category or the original `XPRSN_*` expression category;
67
+ - `start`: a zero-based offset in the original template;
68
+ - `end`: the exclusive template offset;
69
+ - `blocks`: a frozen, outermost-first array of `{ type, start, end }` opener spans.
70
+
71
+ Parser codes are `SJABLOON_EACH_SYNTAX`, `SJABLOON_BLOCKED_BINDING`, `SJABLOON_UNEXPECTED_TAG`, `SJABLOON_UNKNOWN_BLOCK`, `SJABLOON_UNCLOSED_BLOCK`, and `SJABLOON_TOO_DEEP` (block nesting past 256 levels, located at the opener that crossed the cap). A missing closer uses an empty span at the end of the template. Expression offsets refer to the original template, so surrounding braces, whitespace, and trim markers contribute to their absolute position.
72
+
73
+ Unauthenticated errors thrown by registered functions, getters, methods, or value coercion hooks are host errors. Sjabloon passes them through unchanged and does not attach template diagnostic fields.
74
+
75
+ Use `isDiagnostic(error)` when a host needs to distinguish those errors. It returns `true` only for errors produced or translated by the same sjabloon module instance. Copying a documented `code`, `start`, `end`, and `blocks` onto another error does not authenticate it. A diagnostic from another installed copy or module instance also returns `false`.
76
+
62
77
  ## Syntax
63
78
 
64
79
  | Tag | Meaning |
@@ -100,13 +115,18 @@ Here the loop variable `company` shadows the root's for a bare name, but `$.comp
100
115
 
101
116
  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.
102
117
 
103
- 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.
118
+ That runtime CSP support costs some render speed. Handlebars and tempura generate specialized JavaScript, so their compiled renderers are faster but runtime compilation requires `unsafe-eval`. Build-time precompilation avoids that restriction when templates are known in advance. If templates arrive at runtime (user-edited templates, CMS content, email templates) and your CSP is strict, sjabloon fits. See the [comparison benchmarks](bench/comparison/) for cold-compile and hot-render comparisons.
104
119
 
105
120
  ## Safety
106
121
 
107
122
  - `{{ expr }}` escapes `& < > " '` by default; unescaped output requires the explicit `{{{ }}}` form.
108
123
  - Expressions inherit all of xprsn's guards: no `__proto__`/`constructor`/`prototype` access, null-prototype hash literals, and functions resolved only from your registry.
109
124
  - Templates read your values; they cannot assign to them.
125
+ - Registered functions are host-provided capabilities, not a sandbox boundary. Only register helpers that template authors are allowed to invoke; likewise, treat explicit raw output as trusted HTML.
126
+
127
+ ## Environments
128
+
129
+ Node.js 22 and newer are supported through the ESM and CommonJS builds. Browser use is supported through a standards-based ESM bundler in environments supporting ES2024. Direct `<script>` globals and UMD builds are not provided.
110
130
 
111
131
  ## License
112
132
 
package/dist/index.cjs CHANGED
@@ -1 +1 @@
1
- Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});let e=require("xprsn");const t={"&":`&amp;`,"<":`&lt;`,">":`&gt;`,'"':`&quot;`,"'":`&#39;`},n=e=>String(e).replace(/[&<>"']/g,e=>t[e]),r=/^(?:__proto__|constructor|prototype)$/,i=e=>Array.isArray(e)?e.map((e,t)=>[e,t]):e&&typeof e==`object`?Object.keys(e).map(t=>[e[t],t]):[];let a=e=>{let t=[];for(let n=0,r=1;n<e.length;){let i=e.indexOf(`{{`,n);if(i<0){t.push({text:e.slice(n)});break}i>n&&t.push({text:e.slice(n,i)});let a=e.charCodeAt(i+2)===123,o=i+2+a,s=e.charCodeAt(o)===45,c=-1;if(s&&o++,a&&r&&(c=e.indexOf(`}}}`,o),c<0&&(r=0)),c<0&&(a&&(a=!1,o=i+2,s=e.charCodeAt(o)===45,s&&o++),c=e.indexOf(`}}`,o)),c<0){t.push({text:e.slice(i)});break}let l=c>o&&e.charCodeAt(c-1)===45,u=e.slice(o,l?c-1:c).trim(),d=a?{raw:u}:{tag:u};d.l=s,d.r=l,t.push(d),n=c+2+a}return t},o,s,c,l,u,d,f,p=e=>{throw SyntaxError(e)},m=(e,t)=>e.map(e=>e(t)).join(``),h=(e,t)=>(e=>n=>t(e(n)??``))(_(e)),g=(...e)=>{let t=e.filter(e=>e&&!u.has(e));return t.forEach(e=>u.add(e)),()=>t.forEach(e=>u.delete(e))},_=t=>{let n=(0,e.compile)(t,c);for(let e of n.names)u.has(e)||d.add(e);for(let e of n.functions)f.add(e);return n},v=e=>{let t=y([`#elif`,`#else`,`/if`]),n=l.startsWith(`#elif `)?[v(_(l.slice(6)))]:l===`#else`?y([`/if`]):[];return r=>m(e(r)?t:n,r)},y=e=>{let t=[];for(let a;a=o[s++];)if(a.text!=null)t.push((e=>()=>e)(a.text));else if(a.raw!=null)t.push(h(a.raw,String));else if(e.includes(a.tag.split(` `)[0]))return l=a.tag,t;else if(a.tag[0]!==`!`)if(a.tag.startsWith(`#if `))t.push(v(_(a.tag.slice(4))));else if(a.tag.startsWith(`#each `)){let e=/^#each ([\s\S]+) as (\w+)(?:\s*,\s*(\w+))?$/.exec(a.tag)||p(`Bad {{`+a.tag+`}}`),n=_(e[1]),o=e[2],s=e[3];(r.test(o)||s&&r.test(s))&&p(`Bad {{`+a.tag+`}}`);let c=g(o,s,`loop`),u=y([`#else`,`/each`]);c();let d=l===`#else`?y([`/each`]):[];t.push(e=>{let t=i(n(e));return t.length?t.map(([n,r],i)=>{let a=Object.create(e);return a[o]=n,s&&(a[s]=r),a[`@`]=n,a.loop={index:i+1,index0:i,first:!i,last:i===t.length-1,length:t.length},m(u,a)}).join(``):m(d,e)})}else a.tag[0]===`#`||a.tag[0]===`/`?p(`Unexpected {{`+a.tag+`}}`):t.push(h(a.tag,n));return e.length&&p(`Missing {{`+e[e.length-1]+`}}`),t};function b(e,t){c=t,u=new Set([`$`,`@`]),d=new Set,f=new Set,o=a(String(e)),o.forEach((e,t)=>{e.l&&o[t-1]?.text&&(o[t-1].text=o[t-1].text.trimEnd()),e.r&&o[t+1]?.text&&(o[t+1].text=o[t+1].text.trimStart())}),s=0;let n=y([]),r=(e,t)=>{e||={};let r=Object.create(e);return r.$=t?t.root:e,t?`item`in t&&(r[`@`]=t.item):r[`@`]=e,m(n,r)};return r.names=Array.from(d),r.functions=Array.from(f),r}function x(e,t,n){return b(e,n)(t)}exports.render=x,exports.template=b;
1
+ Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});let e=require("xprsn");const t={"&":`&amp;`,"<":`&lt;`,">":`&gt;`,'"':`&quot;`,"'":`&#39;`},n=e=>String(e).replace(/[&<>"']/g,e=>t[e]),r=/^(?:__proto__|constructor|prototype)$/,i=new WeakSet,a=i.add.bind(i),o=i.has.bind(i),s=e=>o(e);let c=e=>{let t=[];for(let n=0,r=1;n<e.length;){let i=e.indexOf(`{{`,n);if(i<0){t.push([0,e.slice(n)]);break}i>n&&t.push([0,e.slice(n,i)]);let a=e[i+2]===`{`,o=i+2+a,s=e[o]===`-`,c=-1;if(s&&o++,a&&r&&(c=e.indexOf(`}}}`,o),c<0&&(r=0)),c<0&&(a&&(a=!1,o=i+2,s=e[o]===`-`,s&&o++),c=e.indexOf(`}}`,o)),c<0){t.push([0,e.slice(i)]);break}let l=c>o&&e[c-1]===`-`,u=l?c-1:c,d=e.slice(o,u),f=d.trim(),p=o+d.length-d.trimStart().length,m=c+2+a,h=[a?1:2,f,i,m,p],g=t.at(-1);if(s&&g?.[0]===0&&g[1]&&(g[1]=g[1].trimEnd()),t.push(h),n=m,l)for(;/\s/.test(e[n]);)n++}return t},l,u,d,f,p,m,h,g,_,v=()=>Object.freeze(_.slice()),y=(e,t)=>(_.length<256||x(`Template too deeply nested`,`SJABLOON_TOO_DEEP`,t),Object.freeze({type:e,start:t[2],end:t[3]})),b=(e,t)=>(Object.defineProperty(e,"blocks",{value:t,enumerable:!0}),a(e),e),x=(e,t,n,r=n?.[2]??g.length,i=n?.[3]??g.length)=>{let a=SyntaxError(e);throw a.code=t,a.start=r,a.end=i,b(a,v())},S=(t,n,r,i=e.isDiagnostic)=>{throw i(t)?(t.start+=n,t.end+=n,b(t,r)):t},C=e=>x(`Unexpected {{`+e[1]+`}}`,`SJABLOON_UNEXPECTED_TAG`,e),w=(e,t)=>e.map(e=>e(t)).join(``),T=(e,t)=>(e=>n=>t(e(n)??``))(E(e[1],e[4],v())),E=(t,n,r)=>{let i;try{i=(0,e.compile)(t,d)}catch(e){S(e,n,r)}for(let e of i.names)p.includes(e)||m.add(e);for(let e of i.functions)h.add(e);return e=>{try{return i(e)}catch(e){S(e,n,r,i.isDiagnostic)}}},D=e=>{let t=O([`#elif`,`#else`,`/if`]),n=f[1],r=[];return n.startsWith(`#elif `)?r=[D(E(n.slice(6),f[4]+6,v()))]:n===`#else`?(r=O([`/if`]),f[1]===`/if`||C(f)):n!==`/if`&&C(f),n=>w(e(n)?t:r,n)},O=e=>{let t=[];for(let i;i=l[u++];){let a=i[1];if(!i[0])t.push((e=>()=>e)(a));else if(i[0]===1)t.push(T(i,String));else if(e.includes(a.split(` `)[0]))return f=i,t;else if(a[0]!==`!`)if(a.startsWith(`#if `))_.push(y(`if`,i)),t.push(D(E(a.slice(4),i[4]+4,v()))),_.pop();else if(/^#each(?:\s|$)/.test(a)){_.push(y(`each`,i));let e=/^#each ([\s\S]+) as ((\w+)(?:\s*,\s*(\w+))?)$/.exec(a);e||x(`Bad {{`+a+`}}`,`SJABLOON_EACH_SYNTAX`,i);let n=e[3],o=e[4],s=i[4]+a.length-e[2].length;if(r.test(n)&&x(`Bad {{`+a+`}}`,`SJABLOON_BLOCKED_BINDING`,i,s,s+n.length),o&&r.test(o)){let e=i[4]+a.length-o.length;x(`Bad {{`+a+`}}`,`SJABLOON_BLOCKED_BINDING`,i,e,e+o.length)}let c=E(e[1],i[4]+6,v()),l=p.length;p.push(n),o&&p.push(o),p.push(`loop`);let u=O([`#else`,`/each`]);p.length=l;let d=[];f[1]===`#else`?(d=O([`/each`]),f[1]===`/each`||C(f)):f[1]!==`/each`&&C(f),_.pop(),t.push(e=>{let t=c(e),r=Array.isArray(t),i=r?t.slice():t&&typeof t==`object`?Object.keys(t).map(e=>[t[e],e]):[];return i.length?i.map((t,a)=>{let s=r?t:t[0],c=r?a:t[1],l=Object.create(e);return l[n]=s,o&&(l[o]=c),l[`@`]=s,l.loop={index:a+1,index0:a,first:!a,last:a===i.length-1,length:i.length},w(u,l)}).join(``):w(d,e)})}else/^#(?:if|elif|else)(?:\s|$)/.test(a)||a[0]===`/`?C(i):a[0]===`#`?x(`Unknown {{`+a+`}}`,`SJABLOON_UNKNOWN_BLOCK`,i):t.push(T(i,n))}return e.length&&x(`Missing {{`+e[e.length-1]+`}}`,`SJABLOON_UNCLOSED_BLOCK`),t};function k(e,t){d=t,p=[`$`,`@`],m=new Set,h=new Set,g=String(e),_=[],l=c(g),u=0;let n;try{n=O([])}catch(e){throw e instanceof RangeError&&x(`Template too deeply nested`,`SJABLOON_TOO_DEEP`),e}let r=(e,t)=>{e||={};let r=Object.create(e);return r.$=t?t.root:e,t?`item`in t&&(r[`@`]=t.item):r[`@`]=e,w(n,r)};return r.names=Array.from(m),r.functions=Array.from(h),r}function A(e,t,n){return k(e,n)(t)}exports.isDiagnostic=s,exports.render=A,exports.template=k;
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- import{compile as e}from"xprsn";const t={"&":`&amp;`,"<":`&lt;`,">":`&gt;`,'"':`&quot;`,"'":`&#39;`},n=e=>String(e).replace(/[&<>"']/g,e=>t[e]),r=/^(?:__proto__|constructor|prototype)$/,i=e=>Array.isArray(e)?e.map((e,t)=>[e,t]):e&&typeof e==`object`?Object.keys(e).map(t=>[e[t],t]):[];let a=e=>{let t=[];for(let n=0,r=1;n<e.length;){let i=e.indexOf(`{{`,n);if(i<0){t.push({text:e.slice(n)});break}i>n&&t.push({text:e.slice(n,i)});let a=e.charCodeAt(i+2)===123,o=i+2+a,s=e.charCodeAt(o)===45,c=-1;if(s&&o++,a&&r&&(c=e.indexOf(`}}}`,o),c<0&&(r=0)),c<0&&(a&&(a=!1,o=i+2,s=e.charCodeAt(o)===45,s&&o++),c=e.indexOf(`}}`,o)),c<0){t.push({text:e.slice(i)});break}let l=c>o&&e.charCodeAt(c-1)===45,u=e.slice(o,l?c-1:c).trim(),d=a?{raw:u}:{tag:u};d.l=s,d.r=l,t.push(d),n=c+2+a}return t},o,s,c,l,u,d,f,p=e=>{throw SyntaxError(e)},m=(e,t)=>e.map(e=>e(t)).join(``),h=(e,t)=>(e=>n=>t(e(n)??``))(_(e)),g=(...e)=>{let t=e.filter(e=>e&&!u.has(e));return t.forEach(e=>u.add(e)),()=>t.forEach(e=>u.delete(e))},_=t=>{let n=e(t,c);for(let e of n.names)u.has(e)||d.add(e);for(let e of n.functions)f.add(e);return n},v=e=>{let t=y([`#elif`,`#else`,`/if`]),n=l.startsWith(`#elif `)?[v(_(l.slice(6)))]:l===`#else`?y([`/if`]):[];return r=>m(e(r)?t:n,r)},y=e=>{let t=[];for(let a;a=o[s++];)if(a.text!=null)t.push((e=>()=>e)(a.text));else if(a.raw!=null)t.push(h(a.raw,String));else if(e.includes(a.tag.split(` `)[0]))return l=a.tag,t;else if(a.tag[0]!==`!`)if(a.tag.startsWith(`#if `))t.push(v(_(a.tag.slice(4))));else if(a.tag.startsWith(`#each `)){let e=/^#each ([\s\S]+) as (\w+)(?:\s*,\s*(\w+))?$/.exec(a.tag)||p(`Bad {{`+a.tag+`}}`),n=_(e[1]),o=e[2],s=e[3];(r.test(o)||s&&r.test(s))&&p(`Bad {{`+a.tag+`}}`);let c=g(o,s,`loop`),u=y([`#else`,`/each`]);c();let d=l===`#else`?y([`/each`]):[];t.push(e=>{let t=i(n(e));return t.length?t.map(([n,r],i)=>{let a=Object.create(e);return a[o]=n,s&&(a[s]=r),a[`@`]=n,a.loop={index:i+1,index0:i,first:!i,last:i===t.length-1,length:t.length},m(u,a)}).join(``):m(d,e)})}else a.tag[0]===`#`||a.tag[0]===`/`?p(`Unexpected {{`+a.tag+`}}`):t.push(h(a.tag,n));return e.length&&p(`Missing {{`+e[e.length-1]+`}}`),t};function b(e,t){c=t,u=new Set([`$`,`@`]),d=new Set,f=new Set,o=a(String(e)),o.forEach((e,t)=>{e.l&&o[t-1]?.text&&(o[t-1].text=o[t-1].text.trimEnd()),e.r&&o[t+1]?.text&&(o[t+1].text=o[t+1].text.trimStart())}),s=0;let n=y([]),r=(e,t)=>{e||={};let r=Object.create(e);return r.$=t?t.root:e,t?`item`in t&&(r[`@`]=t.item):r[`@`]=e,m(n,r)};return r.names=Array.from(d),r.functions=Array.from(f),r}function x(e,t,n){return b(e,n)(t)}export{x as render,b as template};
1
+ import{compile as e,isDiagnostic as t}from"xprsn";const n={"&":`&amp;`,"<":`&lt;`,">":`&gt;`,'"':`&quot;`,"'":`&#39;`},r=e=>String(e).replace(/[&<>"']/g,e=>n[e]),i=/^(?:__proto__|constructor|prototype)$/,a=new WeakSet,o=a.add.bind(a),s=a.has.bind(a),c=e=>s(e);let l=e=>{let t=[];for(let n=0,r=1;n<e.length;){let i=e.indexOf(`{{`,n);if(i<0){t.push([0,e.slice(n)]);break}i>n&&t.push([0,e.slice(n,i)]);let a=e[i+2]===`{`,o=i+2+a,s=e[o]===`-`,c=-1;if(s&&o++,a&&r&&(c=e.indexOf(`}}}`,o),c<0&&(r=0)),c<0&&(a&&(a=!1,o=i+2,s=e[o]===`-`,s&&o++),c=e.indexOf(`}}`,o)),c<0){t.push([0,e.slice(i)]);break}let l=c>o&&e[c-1]===`-`,u=l?c-1:c,d=e.slice(o,u),f=d.trim(),p=o+d.length-d.trimStart().length,m=c+2+a,h=[a?1:2,f,i,m,p],g=t.at(-1);if(s&&g?.[0]===0&&g[1]&&(g[1]=g[1].trimEnd()),t.push(h),n=m,l)for(;/\s/.test(e[n]);)n++}return t},u,d,f,p,m,h,g,_,v,y=()=>Object.freeze(v.slice()),b=(e,t)=>(v.length<256||S(`Template too deeply nested`,`SJABLOON_TOO_DEEP`,t),Object.freeze({type:e,start:t[2],end:t[3]})),x=(e,t)=>(Object.defineProperty(e,"blocks",{value:t,enumerable:!0}),o(e),e),S=(e,t,n,r=n?.[2]??_.length,i=n?.[3]??_.length)=>{let a=SyntaxError(e);throw a.code=t,a.start=r,a.end=i,x(a,y())},C=(e,n,r,i=t)=>{throw i(e)?(e.start+=n,e.end+=n,x(e,r)):e},w=e=>S(`Unexpected {{`+e[1]+`}}`,`SJABLOON_UNEXPECTED_TAG`,e),T=(e,t)=>e.map(e=>e(t)).join(``),E=(e,t)=>(e=>n=>t(e(n)??``))(D(e[1],e[4],y())),D=(t,n,r)=>{let i;try{i=e(t,f)}catch(e){C(e,n,r)}for(let e of i.names)m.includes(e)||h.add(e);for(let e of i.functions)g.add(e);return e=>{try{return i(e)}catch(e){C(e,n,r,i.isDiagnostic)}}},O=e=>{let t=k([`#elif`,`#else`,`/if`]),n=p[1],r=[];return n.startsWith(`#elif `)?r=[O(D(n.slice(6),p[4]+6,y()))]:n===`#else`?(r=k([`/if`]),p[1]===`/if`||w(p)):n!==`/if`&&w(p),n=>T(e(n)?t:r,n)},k=e=>{let t=[];for(let n;n=u[d++];){let a=n[1];if(!n[0])t.push((e=>()=>e)(a));else if(n[0]===1)t.push(E(n,String));else if(e.includes(a.split(` `)[0]))return p=n,t;else if(a[0]!==`!`)if(a.startsWith(`#if `))v.push(b(`if`,n)),t.push(O(D(a.slice(4),n[4]+4,y()))),v.pop();else if(/^#each(?:\s|$)/.test(a)){v.push(b(`each`,n));let e=/^#each ([\s\S]+) as ((\w+)(?:\s*,\s*(\w+))?)$/.exec(a);e||S(`Bad {{`+a+`}}`,`SJABLOON_EACH_SYNTAX`,n);let r=e[3],o=e[4],s=n[4]+a.length-e[2].length;if(i.test(r)&&S(`Bad {{`+a+`}}`,`SJABLOON_BLOCKED_BINDING`,n,s,s+r.length),o&&i.test(o)){let e=n[4]+a.length-o.length;S(`Bad {{`+a+`}}`,`SJABLOON_BLOCKED_BINDING`,n,e,e+o.length)}let c=D(e[1],n[4]+6,y()),l=m.length;m.push(r),o&&m.push(o),m.push(`loop`);let u=k([`#else`,`/each`]);m.length=l;let d=[];p[1]===`#else`?(d=k([`/each`]),p[1]===`/each`||w(p)):p[1]!==`/each`&&w(p),v.pop(),t.push(e=>{let t=c(e),n=Array.isArray(t),i=n?t.slice():t&&typeof t==`object`?Object.keys(t).map(e=>[t[e],e]):[];return i.length?i.map((t,a)=>{let s=n?t:t[0],c=n?a:t[1],l=Object.create(e);return l[r]=s,o&&(l[o]=c),l[`@`]=s,l.loop={index:a+1,index0:a,first:!a,last:a===i.length-1,length:i.length},T(u,l)}).join(``):T(d,e)})}else/^#(?:if|elif|else)(?:\s|$)/.test(a)||a[0]===`/`?w(n):a[0]===`#`?S(`Unknown {{`+a+`}}`,`SJABLOON_UNKNOWN_BLOCK`,n):t.push(E(n,r))}return e.length&&S(`Missing {{`+e[e.length-1]+`}}`,`SJABLOON_UNCLOSED_BLOCK`),t};function A(e,t){f=t,m=[`$`,`@`],h=new Set,g=new Set,_=String(e),v=[],u=l(_),d=0;let n;try{n=k([])}catch(e){throw e instanceof RangeError&&S(`Template too deeply nested`,`SJABLOON_TOO_DEEP`),e}let r=(e,t)=>{e||={};let r=Object.create(e);return r.$=t?t.root:e,t?`item`in t&&(r[`@`]=t.item):r[`@`]=e,T(n,r)};return r.names=Array.from(h),r.functions=Array.from(g),r}function j(e,t,n){return A(e,n)(t)}export{c as isDiagnostic,j as render,A as template};
package/index.d.ts CHANGED
@@ -1,40 +1,68 @@
1
- /**
2
- * Compile a template once, render it many times.
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
- *
14
- * An embedder with its own scope model can override the anchors per render by
15
- * passing `{ root, item }` as the renderer's second argument: `$` becomes
16
- * `root` and `@` becomes `item` (two distinct objects). Omit `item` to leave
17
- * `@` unbound, so reading `@.x` throws through xprsn's guard.
18
- *
19
- * @param {string} str The template, e.g. `'Hello {{ user.name }}!'`.
20
- * @param {Record<string, Function>} [funcs] Functions callable inside expressions.
21
- * @returns {{(values?: Record<string, any>, scope?: { root?: any, item?: any }): string, names: string[], functions: string[]}} Renderer for the compiled template.
22
- * @throws {SyntaxError} On malformed tags, unclosed blocks, or bad expressions.
23
- */
24
- export function template(str: string, funcs?: Record<string, Function>): {
25
- (values?: Record<string, any>, scope?: {
26
- root?: any;
27
- item?: any;
28
- }): string;
29
- names: string[];
30
- functions: string[];
31
- };
32
- /**
33
- * Compile and render a template in one go.
34
- *
35
- * @param {string} str The template to render.
36
- * @param {Record<string, any>} [values] Variables available to the template.
37
- * @param {Record<string, Function>} [funcs] Functions callable inside expressions.
38
- * @returns {string} The rendered output.
39
- */
40
- export function render(str: string, values?: Record<string, any>, funcs?: Record<string, Function>): string;
1
+ import type { XprsnErrorCode } from 'xprsn';
2
+
3
+ export type SjabloonErrorCode =
4
+ | XprsnErrorCode
5
+ | 'SJABLOON_EACH_SYNTAX'
6
+ | 'SJABLOON_BLOCKED_BINDING'
7
+ | 'SJABLOON_UNEXPECTED_TAG'
8
+ | 'SJABLOON_UNKNOWN_BLOCK'
9
+ | 'SJABLOON_UNCLOSED_BLOCK';
10
+
11
+ export interface SjabloonBlock {
12
+ readonly type: 'if' | 'each';
13
+ readonly start: number;
14
+ readonly end: number;
15
+ }
16
+
17
+ export interface SjabloonDiagnostic extends Error {
18
+ readonly code: SjabloonErrorCode;
19
+ readonly start: number;
20
+ readonly end: number;
21
+ readonly blocks: readonly SjabloonBlock[];
22
+ }
23
+
24
+ /**
25
+ * Check whether an error was produced or translated by this sjabloon module instance.
26
+ */
27
+ export function isDiagnostic(error: unknown): error is SjabloonDiagnostic;
28
+
29
+ /**
30
+ * Compile a template once, render it many times.
31
+ *
32
+ * The returned renderer exposes `names`: the variables the template reads
33
+ * from your values, deduplicated. Loop variables the template introduces are
34
+ * not included. It also exposes `functions`: the registry functions the
35
+ * template calls, deduplicated.
36
+ *
37
+ * Two anchors are always in scope: `$` is the root values, and `@` is the
38
+ * current `#each` item (the root outside any loop). They let a nested loop
39
+ * reach the root (`$.company`) or the current item (`@.total`) explicitly,
40
+ * past any shadowing. Neither counts as a `name`.
41
+ *
42
+ * An embedder with its own scope model can override the anchors per render by
43
+ * passing `{ root, item }` as the renderer's second argument: `$` becomes
44
+ * `root` and `@` becomes `item` (two distinct objects). Omit `item` to leave
45
+ * `@` unbound, so reading `@.x` throws through xprsn's guard.
46
+ *
47
+ * @param {string} str The template, e.g. `'Hello {{ user.name }}!'`.
48
+ * @param {Record<string, Function>} [funcs] Functions callable inside expressions.
49
+ * @returns {{(values?: Record<string, any>, scope?: { root?: any, item?: any }): string, names: string[], functions: string[]}} Renderer for the compiled template.
50
+ * @throws {SyntaxError} On malformed tags, unclosed blocks, or bad expressions.
51
+ */
52
+ export function template(str: string, funcs?: Record<string, Function>): {
53
+ (values?: Record<string, any>, scope?: {
54
+ root?: any;
55
+ item?: any;
56
+ }): string;
57
+ names: string[];
58
+ functions: string[];
59
+ };
60
+ /**
61
+ * Compile and render a template in one go.
62
+ *
63
+ * @param {string} str The template to render.
64
+ * @param {Record<string, any>} [values] Variables available to the template.
65
+ * @param {Record<string, Function>} [funcs] Functions callable inside expressions.
66
+ * @returns {string} The rendered output.
67
+ */
68
+ export function render(str: string, values?: Record<string, any>, funcs?: Record<string, Function>): string;
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "sjabloon",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "Tiny, CSP-safe template engine for JavaScript, powered by xprsn expressions. No eval, no new Function.",
5
- "repository": "robinvdvleuten/sjabloon",
5
+ "repository": "getquario/sjabloon",
6
6
  "license": "MIT",
7
7
  "author": {
8
8
  "name": "Robin van der Vleuten",
@@ -21,6 +21,9 @@
21
21
  "require": "./dist/index.cjs"
22
22
  }
23
23
  },
24
+ "engines": {
25
+ "node": ">=22.0.0"
26
+ },
24
27
  "files": [
25
28
  "dist",
26
29
  "src",
@@ -29,16 +32,18 @@
29
32
  "size-limit": [
30
33
  {
31
34
  "path": "dist/index.js",
32
- "limit": "1.25 kB"
35
+ "limit": "1.7 kB"
33
36
  },
34
37
  {
35
38
  "path": "dist/index.cjs",
36
- "limit": "1.25 kB"
39
+ "limit": "1.72 kB"
37
40
  }
38
41
  ],
39
42
  "scripts": {
40
- "bench": "node bench/index.js",
43
+ "bench": "node --disallow-code-generation-from-strings bench/index.js",
44
+ "bench:comparison": "npm run build && npm --prefix bench/comparison run bench",
41
45
  "build": "tsdown",
46
+ "check": "run-s build size test fuzz:regression test:browser",
42
47
  "fuzz": "npm run fuzz:prepare && run-s fuzz:compile fuzz:render fuzz:structured",
43
48
  "fuzz:prepare": "node -e \"for (const x of ['compile','render','structured']) require('fs').mkdirSync('.fuzz-corpus/'+x,{recursive:true})\"",
44
49
  "fuzz:compile": "NODE_OPTIONS=--disallow-code-generation-from-strings jazzer fuzz/compile.fuzz.js -i src/ --customHooks fuzz/hooks.js --disableBugDetectors='command-injection|path-traversal|ssrf' --sync .fuzz-corpus/compile fuzz/corpus/compile -- -max_total_time=60 -use_value_profile=1 -dict=fuzz/sjabloon.dict -artifact_prefix=fuzz/",
@@ -50,7 +55,10 @@
50
55
  "fuzz:regression:structured": "NODE_OPTIONS=--disallow-code-generation-from-strings jazzer fuzz/structured.fuzz.js -i src/ --customHooks fuzz/hooks.js --disableBugDetectors='command-injection|path-traversal|ssrf' --sync --mode=regression fuzz/corpus/structured -- -artifact_prefix=fuzz/",
51
56
  "prepublishOnly": "npm run build",
52
57
  "size": "size-limit",
53
- "test": "node --disallow-code-generation-from-strings node_modules/tape/bin/tape test/*.test.js"
58
+ "test": "run-s test:unit test:types",
59
+ "test:unit": "node --disallow-code-generation-from-strings --test --test-concurrency=1 test/*.test.js",
60
+ "test:browser": "npm run build && playwright install chromium && node test/browser/harness.js",
61
+ "test:types": "tsc"
54
62
  },
55
63
  "keywords": [
56
64
  "template",
@@ -60,15 +68,16 @@
60
68
  "handlebars"
61
69
  ],
62
70
  "dependencies": {
63
- "xprsn": "^0.4.1"
71
+ "xprsn": "^0.8.0"
64
72
  },
65
73
  "devDependencies": {
66
74
  "@jazzer.js/bug-detectors": "^4.0.0",
67
75
  "@jazzer.js/core": "^4.0.0",
68
76
  "@size-limit/file": "^12.1.0",
69
77
  "npm-run-all": "^4.1.5",
78
+ "playwright": "^1.61.1",
70
79
  "size-limit": "^12.1.0",
71
- "tape": "^5.9.0",
72
- "tsdown": "^0.22.12"
80
+ "tsdown": "^0.22.12",
81
+ "typescript": "^7.0.2"
73
82
  }
74
83
  }
package/src/index.js CHANGED
@@ -3,16 +3,22 @@
3
3
  * Templates compile to a composition of closures; template text is never
4
4
  * turned into JavaScript, so strict CSP is satisfied.
5
5
  */
6
- import { compile } from 'xprsn';
6
+ import { compile, isDiagnostic as isXprsnDiagnostic } from 'xprsn';
7
7
 
8
8
  const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
9
9
  const esc = s => String(s).replace(/[&<>"']/g, c => ESC[c]);
10
10
  const BLOCKED = /^(?:__proto__|constructor|prototype)$/;
11
+ const DIAGNOSTICS = new WeakSet();
12
+ const mark = DIAGNOSTICS.add.bind(DIAGNOSTICS);
13
+ const owns = DIAGNOSTICS.has.bind(DIAGNOSTICS);
11
14
 
12
- // What `#each` walks: [value, key] pairs — array indexes or own object keys.
13
- const pairs = lv => Array.isArray(lv) ? lv.map((x, j) => [x, j])
14
- : lv && typeof lv === 'object' ? Object.keys(lv).map(k => [lv[k], k])
15
- : [];
15
+ /**
16
+ * Check whether an error was produced or translated by sjabloon.
17
+ *
18
+ * @param {unknown} error Any thrown value.
19
+ * @returns {boolean} Whether `error` is an authentic sjabloon diagnostic.
20
+ */
21
+ export const isDiagnostic = error => owns(error);
16
22
 
17
23
  // Linear scan into text/tag/raw tokens. Dashes hug braces (`{{- x -}}` trims;
18
24
  // `{{ -x }}` stays unary minus). Prefer {{{ }}} over {{ }}. `triple` latches
@@ -21,98 +27,154 @@ let lex = s => {
21
27
  const out = [];
22
28
  for (let i = 0, triple = 1; i < s.length; ) {
23
29
  const a = s.indexOf('{{', i);
24
- if (a < 0) { out.push({ text: s.slice(i) }); break; }
25
- if (a > i) out.push({ text: s.slice(i, a) });
26
- let raw = s.charCodeAt(a + 2) === 123, p = a + 2 + raw, l = s.charCodeAt(p) === 45, b = -1;
30
+ if (a < 0) { out.push([0, s.slice(i)]); break; }
31
+ if (a > i) out.push([0, s.slice(i, a)]);
32
+ let raw = s[a + 2] === '{', p = a + 2 + raw, l = s[p] === '-', b = -1;
27
33
  if (l) p++;
28
34
  if (raw && triple) { b = s.indexOf('}}}', p); if (b < 0) triple = 0; }
29
35
  if (b < 0) {
30
- if (raw) { raw = !1; p = a + 2; l = s.charCodeAt(p) === 45; if (l) p++; }
36
+ if (raw) { raw = !1; p = a + 2; l = s[p] === '-'; if (l) p++; }
31
37
  b = s.indexOf('}}', p);
32
38
  }
33
- if (b < 0) { out.push({ text: s.slice(a) }); break; }
34
- const r = b > p && s.charCodeAt(b - 1) === 45;
35
- const body = s.slice(p, r ? b - 1 : b).trim(), t = raw ? { raw: body } : { tag: body };
36
- t.l = l;
37
- t.r = r;
39
+ if (b < 0) { out.push([0, s.slice(a)]); break; }
40
+ const r = b > p && s[b - 1] === '-';
41
+ const q = r ? b - 1 : b, whole = s.slice(p, q), body = whole.trim();
42
+ const start = p + whole.length - whole.trimStart().length, end = b + 2 + raw;
43
+ const t = [raw ? 1 : 2, body, a, end, start];
44
+ const prev = out.at(-1);
45
+ if (l && prev?.[0] === 0 && prev[1]) prev[1] = prev[1].trimEnd();
38
46
  out.push(t);
39
- i = b + 2 + raw;
47
+ i = end;
48
+ if (r) while (/\s/.test(s[i])) i++;
40
49
  }
41
50
  return out;
42
51
  };
43
52
 
44
53
  // Shared parser state; parsing is synchronous so this is safe.
45
54
  // `nms` collects free variables, `fnms` the registry functions called.
46
- let toks, i, fns, last, bound, nms, fnms;
55
+ let toks, i, fns, last, bound, nms, fnms, src, blocks;
47
56
 
48
- let err = msg => { throw SyntaxError(msg) };
57
+ let snap = () => Object.freeze(blocks.slice());
58
+ // Block nesting is capped so a pathological template fails as a deterministic
59
+ // SyntaxError at the offending opener, far below the native stack limit.
60
+ const DEPTH = 256;
61
+ let opener = (type, t) => {
62
+ blocks.length < DEPTH || fault('Template too deeply nested', 'SJABLOON_TOO_DEEP', t);
63
+ return Object.freeze({ type, start: t[2], end: t[3] });
64
+ };
65
+ let attach = (e, context) => {
66
+ Object.defineProperty(e, 'blocks', { value: context, enumerable: true });
67
+ mark(e);
68
+ return e;
69
+ };
70
+ let fault = (msg, code, t, start = t?.[2] ?? src.length, end = t?.[3] ?? src.length) => {
71
+ const e = SyntaxError(msg);
72
+ e.code = code;
73
+ e.start = start;
74
+ e.end = end;
75
+ throw attach(e, snap());
76
+ };
77
+ let translated = (e, start, context, owns = isXprsnDiagnostic) => {
78
+ if (!owns(e)) throw e;
79
+ e.start += start;
80
+ e.end += start;
81
+ throw attach(e, context);
82
+ };
83
+ let unexpected = t => fault('Unexpected {{' + t[1] + '}}', 'SJABLOON_UNEXPECTED_TAG', t);
49
84
 
50
85
  // Render a list of nodes against a scope.
51
86
  let run = (nodes, v) => nodes.map(n => n(v)).join('');
52
87
 
53
88
  // A leaf interpolation node: compile `src`, render nullish as '', apply `wrap`
54
89
  // (`esc` for `{{ }}`, `String` for the raw `{{{ }}}` form).
55
- let interp = (src, wrap) => (e => v => wrap(e(v) ?? ''))(cp(src));
56
-
57
- // Bind `names` for a block body; returns a restore that unbinds only the names
58
- // this block introduced, leaving an outer scope's bindings in place.
59
- let scope = (...names) => {
60
- const fresh = names.filter(n => n && !bound.has(n));
61
- fresh.forEach(n => bound.add(n));
62
- return () => fresh.forEach(n => bound.delete(n));
63
- };
90
+ let interp = (t, wrap) => (e => v => wrap(e(v) ?? ''))(cp(t[1], t[4], snap()));
64
91
 
65
92
  // Compile one expression and collect its free variables (minus the loop
66
93
  // variables currently in scope, which belong to the template) and the registry
67
94
  // functions it calls.
68
- let cp = s => {
69
- const e = compile(s, fns);
70
- for (const n of e.names) bound.has(n) || nms.add(n);
95
+ let cp = (s, start, context) => {
96
+ let e;
97
+ try {
98
+ e = compile(s, fns);
99
+ } catch (x) {
100
+ translated(x, start, context);
101
+ }
102
+ for (const n of e.names) bound.includes(n) || nms.add(n);
71
103
  for (const fn of e.functions) fnms.add(fn);
72
- return e;
104
+ return v => {
105
+ try {
106
+ return e(v);
107
+ } catch (x) {
108
+ translated(x, start, context, e.isDiagnostic);
109
+ }
110
+ };
73
111
  };
74
112
 
75
113
  // One `#if`/`#elif` link: parse its branch, then recurse on the chain tail.
76
114
  let branch = cond => {
77
115
  const then = parse(['#elif', '#else', '/if']);
78
- const els = last.startsWith('#elif ') ? [branch(cp(last.slice(6)))]
79
- : last === '#else' ? parse(['/if'])
80
- : [];
116
+ const tag = last[1];
117
+ let els = [];
118
+ if (tag.startsWith('#elif ')) els = [branch(cp(tag.slice(6), last[4] + 6, snap()))];
119
+ else if (tag === '#else') {
120
+ els = parse(['/if']);
121
+ last[1] === '/if' || unexpected(last);
122
+ } else if (tag !== '/if') unexpected(last);
81
123
  return v => run(cond(v) ? then : els, v);
82
124
  };
83
125
 
84
126
  let parse = stops => {
85
127
  const nodes = [];
86
128
  for (let t; (t = toks[i++]); ) {
87
- if (t.text != null) {
88
- nodes.push((s => () => s)(t.text));
89
- } else if (t.raw != null) {
90
- nodes.push(interp(t.raw, String));
91
- } else if (stops.includes(t.tag.split(' ')[0])) {
92
- last = t.tag;
129
+ const tag = t[1];
130
+ if (!t[0]) {
131
+ nodes.push((s => () => s)(tag));
132
+ } else if (t[0] === 1) {
133
+ nodes.push(interp(t, String));
134
+ } else if (stops.includes(tag.split(' ')[0])) {
135
+ last = t;
93
136
  return nodes;
94
- } else if (t.tag[0] === '!') {
137
+ } else if (tag[0] === '!') {
95
138
  // comment
96
- } else if (t.tag.startsWith('#if ')) {
97
- nodes.push(branch(cp(t.tag.slice(4))));
98
- } else if (t.tag.startsWith('#each ')) {
99
- const m = /^#each ([\s\S]+) as (\w+)(?:\s*,\s*(\w+))?$/.exec(t.tag) || err('Bad {{' + t.tag + '}}');
100
- const list = cp(m[1]), name = m[2], idx = m[3];
101
- if (BLOCKED.test(name) || idx && BLOCKED.test(idx)) err('Bad {{' + t.tag + '}}');
139
+ } else if (tag.startsWith('#if ')) {
140
+ blocks.push(opener('if', t));
141
+ nodes.push(branch(cp(tag.slice(4), t[4] + 4, snap())));
142
+ blocks.pop();
143
+ } else if (/^#each(?:\s|$)/.test(tag)) {
144
+ blocks.push(opener('each', t));
145
+ const m = /^#each ([\s\S]+) as ((\w+)(?:\s*,\s*(\w+))?)$/.exec(tag);
146
+ m || fault('Bad {{' + tag + '}}', 'SJABLOON_EACH_SYNTAX', t);
147
+ const name = m[3], idx = m[4], at = t[4] + tag.length - m[2].length;
148
+ if (BLOCKED.test(name)) fault('Bad {{' + tag + '}}', 'SJABLOON_BLOCKED_BINDING', t, at, at + name.length);
149
+ if (idx && BLOCKED.test(idx)) {
150
+ const p = t[4] + tag.length - idx.length;
151
+ fault('Bad {{' + tag + '}}', 'SJABLOON_BLOCKED_BINDING', t, p, p + idx.length);
152
+ }
153
+ const list = cp(m[1], t[4] + 6, snap());
102
154
  // `name`, `idx`, and `loop` are engine-bound inside the body, so
103
155
  // exclude them from names there and restore outer bindings after.
104
- const restore = scope(name, idx, 'loop');
156
+ const mark = bound.length;
157
+ bound.push(name);
158
+ if (idx) bound.push(idx);
159
+ bound.push('loop');
105
160
  const body = parse(['#else', '/each']);
106
- restore();
107
- const empty = last === '#else' ? parse(['/each']) : [];
161
+ bound.length = mark;
162
+ let empty = [];
163
+ if (last[1] === '#else') {
164
+ empty = parse(['/each']);
165
+ last[1] === '/each' || unexpected(last);
166
+ } else if (last[1] !== '/each') unexpected(last);
167
+ blocks.pop();
108
168
  // Child scopes inherit the parent via the prototype chain, so outer
109
169
  // variables stay visible inside the loop body. `@` re-points to the
110
170
  // current item at each level, `$` (root) rides the chain, and `loop`
111
171
  // carries the iteration metadata (index/first/last/length).
112
172
  nodes.push(v => {
113
- const ps = pairs(list(v));
173
+ const lv = list(v), arr = Array.isArray(lv);
174
+ const ps = arr ? lv.slice() : lv && typeof lv === 'object' ? Object.keys(lv).map(k => [lv[k], k]) : [];
114
175
  if (!ps.length) return run(empty, v);
115
- return ps.map(([item, key], j) => {
176
+ return ps.map((x, j) => {
177
+ const item = arr ? x : x[0], key = arr ? j : x[1];
116
178
  const s = Object.create(v);
117
179
  s[name] = item;
118
180
  if (idx) s[idx] = key;
@@ -121,13 +183,15 @@ let parse = stops => {
121
183
  return run(body, s);
122
184
  }).join('');
123
185
  });
124
- } else if (t.tag[0] === '#' || t.tag[0] === '/') {
125
- err('Unexpected {{' + t.tag + '}}');
186
+ } else if (/^#(?:if|elif|else)(?:\s|$)/.test(tag) || tag[0] === '/') {
187
+ unexpected(t);
188
+ } else if (tag[0] === '#') {
189
+ fault('Unknown {{' + tag + '}}', 'SJABLOON_UNKNOWN_BLOCK', t);
126
190
  } else {
127
- nodes.push(interp(t.tag, esc));
191
+ nodes.push(interp(t, esc));
128
192
  }
129
193
  }
130
- stops.length && err('Missing {{' + stops[stops.length - 1] + '}}');
194
+ stops.length && fault('Missing {{' + stops[stops.length - 1] + '}}', 'SJABLOON_UNCLOSED_BLOCK');
131
195
  return nodes;
132
196
  };
133
197
 
@@ -158,17 +222,24 @@ export function template(str, funcs) {
158
222
  fns = funcs;
159
223
  // `$` (root) and `@` (current item) are engine-bound anchors, always in
160
224
  // scope, so they never count as caller-supplied `names`.
161
- bound = new Set(['$', '@']);
225
+ bound = ['$', '@'];
162
226
  nms = new Set();
163
227
  fnms = new Set();
164
- toks = lex(String(str));
165
- // `{{-` / `-}}` eat the whitespace touching that side of the tag.
166
- toks.forEach((t, k) => {
167
- if (t.l && toks[k - 1]?.text) toks[k - 1].text = toks[k - 1].text.trimEnd();
168
- if (t.r && toks[k + 1]?.text) toks[k + 1].text = toks[k + 1].text.trimStart();
169
- });
228
+ src = String(str);
229
+ blocks = [];
230
+ toks = lex(src);
170
231
  i = 0;
171
- const nodes = parse([]);
232
+ // Deeply nested blocks overflow the recursive-descent parser; surface that
233
+ // as a SyntaxError so malformed input keeps its documented compile-time
234
+ // contract (mirroring xprsn's XPRSN_TOO_DEEP for expressions).
235
+ let nodes;
236
+ try {
237
+ nodes = parse([]);
238
+ } catch (x) {
239
+ // An empty span at the end, like an unclosed block.
240
+ if (x instanceof RangeError) fault('Template too deeply nested', 'SJABLOON_TOO_DEEP');
241
+ throw x;
242
+ }
172
243
  // Wrap the values in a root scope carrying the anchors, without mutating
173
244
  // what the caller passed: by default `$` and `@` both point at the root.
174
245
  // An embedder can override the anchors with a `{ root, item }` second arg: