sjabloon 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # sjabloon
2
2
 
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.**
3
+ A tiny, CSP-safe, target-neutral template engine for JavaScript. **~1.9KB min+brotli (~3.7KB 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/getquario/sjabloon/actions/workflows/test.yml/badge.svg)](https://github.com/getquario/sjabloon/actions/workflows/test.yml)
@@ -13,16 +13,24 @@ A tiny, CSP-safe template engine for JavaScript. **~1.8KB min+gzip (~3.6KB with
13
13
  </picture>
14
14
  </a>
15
15
 
16
- *Sjabloon* is Dutch for "template". It renders text templates with full [xprsn](https://github.com/getquario/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.
16
+ *Sjabloon* is Dutch for "template". It renders templates with full [xprsn](https://github.com/getquario/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.
17
+
18
+ The engine emits **tokens**, not text. A render gives you the literal runs and the interpolated values, interleaved in render order — because different targets need different things from the same template. HTML wants escaped text; a spreadsheet wants the number `1000` and a cell format; a PDF wants styled runs. Escaping belongs at the output edge, not in the engine, so a string is just one way to consume the stream.
17
19
 
18
20
  ```js
19
- import { template, render } from 'sjabloon';
21
+ import { template, text } from 'sjabloon';
22
+
23
+ const cell = template('{{ total * 1.21 }}');
20
24
 
21
- // Compile once, render many times:
22
- const greet = template('Hello {{ user.name.toUpperCase() }}!');
23
- greet({ user: { name: 'Robin' } }); // => 'Hello ROBIN!'
25
+ cell({ total: 1000 }); // => [{ value: 1210 }] — still a number
26
+ text(cell({ total: 1000 })); // => '1210' — when you want the string
27
+ ```
28
+
29
+ If you only want a string, import the edition that produces one directly:
30
+
31
+ ```js
32
+ import { render } from 'sjabloon/html'; // {{ }} HTML-escapes, {{{ }}} is raw
24
33
 
25
- // Blocks, expressions, and custom functions:
26
34
  render(
27
35
  `<ul>{{#each items as it, i}}
28
36
  <li>{{ i + 1 }}. {{ it.name }}: {{ fmt(it.price * it.qty) }}</li>
@@ -33,11 +41,25 @@ render(
33
41
  );
34
42
  ```
35
43
 
44
+ ## Editions
45
+
46
+ Three entry points, one engine. They share a parser, a syntax, and a diagnostics contract, and differ only in what a render produces.
47
+
48
+ | Import | `template(str, funcs?)` returns | `{{ expr }}` | `{{{ expr }}}` |
49
+ | --- | --- | --- | --- |
50
+ | `sjabloon` | `(values?, scope?) => Token[]` | value token | `SyntaxError` |
51
+ | `sjabloon/text` | `(values?, scope?) => string` | unescaped | `SyntaxError` |
52
+ | `sjabloon/html` | `(values?, scope?) => string` | HTML-escaped | raw |
53
+
54
+ `{{{ }}}` exists only in the HTML edition, where "raw" means something. Everywhere else `{{ }}` is already raw, so the triple form is a compile-time `SJABLOON_RAW_TAG` error rather than a silent synonym.
55
+
56
+ Every edition exports `template`, `render`, and `isDiagnostic`. They all resolve to one shared core, so a diagnostic thrown through any of them authenticates through all of them.
57
+
36
58
  ## API
37
59
 
38
60
  ### `template(str, functions?)`
39
61
 
40
- Compiles the template and returns a renderer `(values?, scope?) => string`. Malformed tags, unclosed blocks, and invalid expressions throw a `SyntaxError` at compile time.
62
+ Compiles the template and returns a renderer. What it renders to depends on the edition you imported from (see [Editions](#editions)); everything else on this page is identical across all three. Malformed tags, unclosed blocks, and invalid expressions throw a `SyntaxError` at compile time.
41
63
 
42
64
  The anchors `$` (root) and `@` (current `{{#each}}` item) work as [described below](#syntax) with no extra arguments — at the root, before any loop, both point at `values`. If you're embedding sjabloon under an engine with its own scope model, pass `{ root, item }` as the second argument to seed the two root anchors from distinct objects: `$` becomes `root` and `@` becomes `item`. Omit `item` and `@` stays unbound at the root, so reading `@.x` throws where there is no current item. Either way, `{{#each}}` still re-points `@` to the current item inside its body.
43
65
 
@@ -47,14 +69,7 @@ tpl(base, { root: reportRoot, item: currentRow }); // $ = reportRoot, @ = curren
47
69
  tpl(base, { root: reportRoot }); // no item → @.x throws
48
70
  ```
49
71
 
50
- The renderer's `withRaw(values?, scope?)` method renders once and returns both channels: `{ text, raws }` — the rendered string plus each interpolation's pre-escape, pre-stringify value, whatever the expression evaluated to before it became output text. A `{{ total }}` holding `1000` yields the number `1000`, not the string `"1000"` the whole point for typed consumers like spreadsheet cells. Both `{{ }}` and `{{{ }}}` are captured, nullish included, in render order (loop bodies once per iteration, untaken branches never). Block expressions `#if` conditions, `#each` collections — are not interpolations and are never captured.
51
-
52
- ```js
53
- const cell = template('{{ total * 1.21 }}');
54
- cell.withRaw({ total: 1000 }); // => { text: '1210', raws: [1210] } — still a number
55
- ```
56
-
57
- 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.
72
+ The renderer 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.
58
73
 
59
74
  ```js
60
75
  const tpl = template('{{ fmt(title) }}{{#each items as it}}{{ it.name }}{{/each}}', { fmt: s => s });
@@ -64,7 +79,28 @@ tpl.functions; // => ['fmt']
64
79
 
65
80
  ### `render(str, values?, functions?)`
66
81
 
67
- Shorthand for `template(str, functions)(values)`.
82
+ Shorthand for `template(str, functions)(values)`, returning whatever its edition renders.
83
+
84
+ ### `text(tokens)` — root entry only
85
+
86
+ Joins a token stream the way `sjabloon/text` would have rendered it: literals verbatim, values as `String(value ?? '')`. `text(template(str)(values))` and `sjabloon/text`'s `template(str)(values)` are equal for every template and every set of values — a property the test suite and the fuzzer both check.
87
+
88
+ ```js
89
+ import { template, text } from 'sjabloon';
90
+
91
+ const tokens = template('{{ qty }} × {{ name }}')({ qty: 2, name: 'Koffie' });
92
+ // => [{ value: 2 }, { literal: ' × ' }, { value: 'Koffie' }]
93
+
94
+ text(tokens); // => '2 × Koffie'
95
+ ```
96
+
97
+ A `Token` is either `{ literal: string }` or `{ value: unknown }`:
98
+
99
+ - **Values are pre-stringify.** `{{ total }}` holding `1000` yields the number `1000`, not `"1000"`, and nullish stays nullish. Stringification is deferred to `text()` — so a value with no primitive conversion reaches the stream intact and only fails when something asks for text.
100
+ - **Order is render order.** Loop bodies append once per iteration, untaken branches append nothing, and block expressions (`#if` conditions, `#each` collections) never appear — they steer the render rather than being part of it.
101
+ - **Literals are the template's static runs**, one token each, never merged and never empty. So the interleaving tells you the shape: a bare `{{ amount }}` is exactly one value token, while `Total: {{ amount }}` is a literal followed by a value. That distinction is why the engine emits tokens instead of a string plus a list of values — a spreadsheet cell that is *only* a number is a different thing from one that happens to contain one.
102
+
103
+ Literal tokens are frozen and shared across loop iterations; value tokens are fresh per emit.
68
104
 
69
105
  ### Error diagnostics
70
106
 
@@ -75,18 +111,18 @@ Sjabloon errors keep their native `SyntaxError` or `TypeError` class and expose:
75
111
  - `end`: the exclusive template offset;
76
112
  - `blocks`: a frozen, outermost-first array of `{ type, start, end }` opener spans.
77
113
 
78
- 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.
114
+ Parser codes are `SJABLOON_EACH_SYNTAX`, `SJABLOON_BLOCKED_BINDING`, `SJABLOON_UNEXPECTED_TAG`, `SJABLOON_UNKNOWN_BLOCK`, `SJABLOON_UNCLOSED_BLOCK`, `SJABLOON_RAW_TAG` (a `{{{ }}}` tag outside the HTML edition, located at the whole tag), 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.
79
115
 
80
116
  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.
81
117
 
82
- 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`.
118
+ 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 returns `false`. All three editions share one core, so mixing them in a single process is safe: an error thrown through `sjabloon/html` authenticates through `sjabloon`.
83
119
 
84
120
  ## Syntax
85
121
 
86
122
  | Tag | Meaning |
87
123
  | --- | --- |
88
- | `{{ expr }}` | Interpolate an expression, HTML-escaped |
89
- | `{{{ expr }}}` | Interpolate without escaping |
124
+ | `{{ expr }}` | Interpolate an expression — a value token, or text escaped per edition |
125
+ | `{{{ expr }}}` | Interpolate raw. **`sjabloon/html` only**; a `SyntaxError` elsewhere |
90
126
  | `{{#if expr}} … {{#elif expr}} … {{#else}} … {{/if}}` | Conditional block, with as many `{{#elif}}` links as you need |
91
127
  | `{{#each expr as item}} … {{/each}}` | Loop over an array or an object's values |
92
128
  | `{{#each expr as item, key}} … {{/each}}` | Second name binds the index (arrays) or the key (objects) |
@@ -126,14 +162,16 @@ That runtime CSP support costs some render speed. Handlebars and tempura generat
126
162
 
127
163
  ## Safety
128
164
 
129
- - `{{ expr }}` escapes `& < > " '` by default; unescaped output requires the explicit `{{{ }}}` form.
165
+ - `sjabloon/html` escapes `& < > " '` in `{{ }}`; unescaped output requires the explicit `{{{ }}}` form. **If you are rendering HTML, import that edition.** The other two are output-neutral by design and escape nothing, on the assumption that you escape at your own output edge.
130
166
  - Expressions inherit all of xprsn's guards: no `__proto__`/`constructor`/`prototype` access, null-prototype hash literals, and functions resolved only from your registry.
131
167
  - Templates read your values; they cannot assign to them.
132
168
  - 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.
133
169
 
134
170
  ## Environments
135
171
 
136
- 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.
172
+ Node.js 22.12 and newer, ESM only. Browser use is supported through a standards-based ESM bundler in environments supporting ES2024. Direct `<script>` globals, UMD, and CommonJS builds are not provided.
173
+
174
+ Shipping CommonJS alongside ESM would put two copies of the core in any process that mixed `require` and `import`, and therefore two diagnostic identities — `isDiagnostic` would silently return `false` across the seam. One format removes that failure mode instead of documenting it.
137
175
 
138
176
  ## License
139
177
 
package/dist/core.js ADDED
@@ -0,0 +1 @@
1
+ import{compile as e,isDiagnostic as t}from"xprsn";const n=/^(?:__proto__|constructor|prototype)$/,r=new WeakSet,i=r.add.bind(r),a=r.has.bind(r),o=e=>a(e);let s=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=0,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},c,l,u,d,f,p,m,h,g,_,v,y,b=()=>Object.freeze(g.slice()),x=(e,t)=>(g.length<256||C(`Template too deeply nested`,`SJABLOON_TOO_DEEP`,t),Object.freeze({type:e,start:t[2],end:t[3]})),S=(e,t)=>(Object.defineProperty(e,"blocks",{value:t,enumerable:!0}),i(e),e);const C=(e,t,n,r=n?.[2]??h.length,i=n?.[3]??h.length)=>{let a=SyntaxError(e);throw a.code=t,a.start=r,a.end=i,S(a,b())};let w=(e,n,r,i=t)=>{throw i(e)?(e.start+=n,e.end+=n,S(e,r)):e},T=e=>C(`Unexpected {{`+e[1]+`}}`,`SJABLOON_UNEXPECTED_TAG`,e),E=(e,t,n)=>{for(let r of e)r(t,n)},D=(e,t)=>t(O(e[1],e[4],b())),O=(t,n,r)=>{let i;try{i=e(t,u)}catch(e){w(e,n,r)}for(let e of i.names)f.includes(e)||p.add(e);for(let e of i.functions)m.add(e);return e=>{try{return i(e)}catch(e){w(e,n,r,i.isDiagnostic)}}},k=e=>{let t=A([`#elif`,`#else`,`/if`]),n=d[1],r=[];return n.startsWith(`#elif `)?r=[k(O(n.slice(6),d[4]+6,b()))]:n===`#else`?(r=A([`/if`]),d[1]===`/if`||T(d)):n!==`/if`&&T(d),(n,i)=>E(e(n)?t:r,n,i)},A=e=>{let t=[];for(let r;r=c[l++];){let i=r[1];if(!r[0])i&&t.push(_(i));else if(r[0]===1)y||C(`Raw {{{`+i+`}}} is not available here; {{ `+i+` }} is already raw`,`SJABLOON_RAW_TAG`,r),t.push(D(r,y));else if(e.includes(i.split(` `)[0]))return d=r,t;else if(i[0]!==`!`)if(i.startsWith(`#if `))g.push(x(`if`,r)),t.push(k(O(i.slice(4),r[4]+4,b()))),g.pop();else if(/^#each(?:\s|$)/.test(i)){g.push(x(`each`,r));let e=/^#each ([\s\S]+) as ((\w+)(?:\s*,\s*(\w+))?)$/.exec(i)||C(`Bad {{`+i+`}}`,`SJABLOON_EACH_SYNTAX`,r),a=e[3],o=e[4],s=r[4]+i.length-e[2].length;if(n.test(a)&&C(`Bad {{`+i+`}}`,`SJABLOON_BLOCKED_BINDING`,r,s,s+a.length),o&&n.test(o)){let e=r[4]+i.length-o.length;C(`Bad {{`+i+`}}`,`SJABLOON_BLOCKED_BINDING`,r,e,e+o.length)}let c=O(e[1],r[4]+6,b()),l=f.length;f.push(a),o&&f.push(o),f.push(`loop`);let u=A([`#else`,`/each`]);f.length=l;let p=[];d[1]===`#else`?(p=A([`/each`]),d[1]===`/each`||T(d)):d[1]!==`/each`&&T(d),g.pop(),t.push((e,t)=>{let n=c(e),r=Array.isArray(n),i=r?n.slice():n&&typeof n==`object`?Object.keys(n).map(e=>[n[e],e]):[];if(!i.length)return E(p,e,t);i.forEach((n,s)=>{let c=r?n:n[0],l=r?s:n[1],d=Object.create(e);d[a]=c,o&&(d[o]=l),d[`@`]=c,d.loop={index:s+1,index0:s,first:!s,last:s===i.length-1,length:i.length},E(u,d,t)})})}else/^#(?:if|elif|else)(?:\s|$)/.test(i)||i[0]===`/`?T(r):i[0]===`#`?C(`Unknown {{`+i+`}}`,`SJABLOON_UNKNOWN_BLOCK`,r):t.push(D(r,v))}return e.length&&C(`Missing {{`+e[e.length-1]+`}}`,`SJABLOON_UNCLOSED_BLOCK`),t},j=([e,t,n,r,i])=>{function a(a,o){_=e,v=t,y=n,u=o,f=[`$`,`@`],p=new Set,m=new Set,h=String(a),g=[],c=s(h),l=0;let d;try{d=A([])}catch(e){throw e instanceof RangeError&&C(`Template too deeply nested`,`SJABLOON_TOO_DEEP`),e}let b=(e,t)=>{e||={};let n=Object.create(e);n.$=t?t.root:e,t?`item`in t&&(n[`@`]=t.item):n[`@`]=e;let a=r();return E(d,n,a),i(a)};return b.names=Array.from(p),b.functions=Array.from(m),b}return{template:a,render:(e,t,n)=>a(e,n)(t)}};export{j as n,o as t};
package/dist/html.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ export * from './types.js';
2
+ import type { SjabloonFunctions, SjabloonRenderer, SjabloonValues } from './types.js';
3
+
4
+ /**
5
+ * Compile a template once, render it many times to an HTML string.
6
+ *
7
+ * `{{ expr }}` HTML-escapes (`& < > " '`) and `{{{ expr }}}` interpolates raw.
8
+ * This is the only edition that knows what HTML is; the rest of sjabloon leaves
9
+ * escaping to the output edge.
10
+ *
11
+ * @see SjabloonRenderer for `names`/`functions`, SjabloonScope for `$` and `@`.
12
+ * @throws {SyntaxError} On malformed tags, unclosed blocks, or bad expressions.
13
+ */
14
+ export function template(str: string, funcs?: SjabloonFunctions): SjabloonRenderer<string>;
15
+
16
+ /** Compile and render in one go. Shorthand for `template(str, funcs)(values)`. */
17
+ export function render(str: string, values?: SjabloonValues, funcs?: SjabloonFunctions): string;
package/dist/html.js ADDED
@@ -0,0 +1 @@
1
+ import{n as e,t}from"./core.js";const n={"&":`&amp;`,"<":`&lt;`,">":`&gt;`,'"':`&quot;`,"'":`&#39;`},r=e=>String(e).replace(/[&<>"']/g,e=>n[e]),{template:i,render:a}=e([e=>(t,n)=>{n.s+=e},e=>(t,n,i)=>(i=e(t),n.s+=r(i??``)),e=>(t,n,r)=>(r=e(t),n.s+=String(r??``)),()=>({s:``}),e=>e.s]);export{t as isDiagnostic,a as render,i as template};
@@ -0,0 +1,23 @@
1
+ export * from './types.js';
2
+ import type { SjabloonFunctions, SjabloonRenderer, SjabloonValues, Token } from './types.js';
3
+
4
+ /**
5
+ * Compile a template once, render it many times to a token stream.
6
+ *
7
+ * `{{ expr }}` emits a value token; escaping belongs to whoever consumes the
8
+ * stream, so `{{{ expr }}}` has no meaning here and is a compile-time
9
+ * `SJABLOON_RAW_TAG` error.
10
+ *
11
+ * @see SjabloonRenderer for `names`/`functions`, SjabloonScope for `$` and `@`.
12
+ * @throws {SyntaxError} On malformed tags, unclosed blocks, or bad expressions.
13
+ */
14
+ export function template(str: string, funcs?: SjabloonFunctions): SjabloonRenderer<Token[]>;
15
+
16
+ /** Compile and render in one go. Shorthand for `template(str, funcs)(values)`. */
17
+ export function render(str: string, values?: SjabloonValues, funcs?: SjabloonFunctions): Token[];
18
+
19
+ /**
20
+ * Join a token stream into the string `sjabloon/text` would have produced:
21
+ * literals verbatim, values as `String(value ?? '')`.
22
+ */
23
+ export function text(tokens: readonly Token[]): string;
package/dist/index.js CHANGED
@@ -1 +1 @@
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;const y=Symbol();let b=()=>Object.freeze(v.slice()),x=(e,t)=>(v.length<256||C(`Template too deeply nested`,`SJABLOON_TOO_DEEP`,t),Object.freeze({type:e,start:t[2],end:t[3]})),S=(e,t)=>(Object.defineProperty(e,"blocks",{value:t,enumerable:!0}),o(e),e),C=(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,S(a,b())},w=(e,n,r,i=t)=>{throw i(e)?(e.start+=n,e.end+=n,S(e,r)):e},T=e=>C(`Unexpected {{`+e[1]+`}}`,`SJABLOON_UNEXPECTED_TAG`,e),E=(e,t)=>e.map(e=>e(t)).join(``),D=(e,t)=>(e=>(n,r)=>(r=e(n),n[y]?.push(r),t(r??``)))(O(e[1],e[4],b())),O=(t,n,r)=>{let i;try{i=e(t,f)}catch(e){w(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){w(e,n,r,i.isDiagnostic)}}},k=e=>{let t=A([`#elif`,`#else`,`/if`]),n=p[1],r=[];return n.startsWith(`#elif `)?r=[k(O(n.slice(6),p[4]+6,b()))]:n===`#else`?(r=A([`/if`]),p[1]===`/if`||T(p)):n!==`/if`&&T(p),n=>E(e(n)?t:r,n)},A=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(D(n,String));else if(e.includes(a.split(` `)[0]))return p=n,t;else if(a[0]!==`!`)if(a.startsWith(`#if `))v.push(x(`if`,n)),t.push(k(O(a.slice(4),n[4]+4,b()))),v.pop();else if(/^#each(?:\s|$)/.test(a)){v.push(x(`each`,n));let e=/^#each ([\s\S]+) as ((\w+)(?:\s*,\s*(\w+))?)$/.exec(a);e||C(`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)&&C(`Bad {{`+a+`}}`,`SJABLOON_BLOCKED_BINDING`,n,s,s+r.length),o&&i.test(o)){let e=n[4]+a.length-o.length;C(`Bad {{`+a+`}}`,`SJABLOON_BLOCKED_BINDING`,n,e,e+o.length)}let c=O(e[1],n[4]+6,b()),l=m.length;m.push(r),o&&m.push(o),m.push(`loop`);let u=A([`#else`,`/each`]);m.length=l;let d=[];p[1]===`#else`?(d=A([`/each`]),p[1]===`/each`||T(p)):p[1]!==`/each`&&T(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},E(u,l)}).join(``):E(d,e)})}else/^#(?:if|elif|else)(?:\s|$)/.test(a)||a[0]===`/`?T(n):a[0]===`#`?C(`Unknown {{`+a+`}}`,`SJABLOON_UNKNOWN_BLOCK`,n):t.push(D(n,r))}return e.length&&C(`Missing {{`+e[e.length-1]+`}}`,`SJABLOON_UNCLOSED_BLOCK`),t};function j(e,t){f=t,m=[`$`,`@`],h=new Set,g=new Set,_=String(e),v=[],u=l(_),d=0;let n;try{n=A([])}catch(e){throw e instanceof RangeError&&C(`Template too deeply nested`,`SJABLOON_TOO_DEEP`),e}let r=(e,t,r)=>{e||={};let i=Object.create(e);return i.$=t?t.root:e,t?`item`in t&&(i[`@`]=t.item):i[`@`]=e,i[y]=r,E(n,i)},i=(e,t)=>r(e,t);return i.withRaw=(e,t,n=[])=>({text:r(e,t,n),raws:n}),i.names=Array.from(h),i.functions=Array.from(g),i}function M(e,t,n){return j(e,n)(t)}export{c as isDiagnostic,M as render,j as template};
1
+ import{n as e,t}from"./core.js";const{template:n,render:r}=e([e=>(e=>(t,n)=>{n.push(e)})(Object.freeze({literal:e})),e=>(t,n)=>{n.push({value:e(t)})},0,()=>[],e=>e]),i=e=>{let t=``;for(let n of e)t+=n.literal??String(n.value??``);return t};export{t as isDiagnostic,r as render,n as template,i as text};
package/dist/text.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ export * from './types.js';
2
+ import type { SjabloonFunctions, SjabloonRenderer, SjabloonValues } from './types.js';
3
+
4
+ /**
5
+ * Compile a template once, render it many times to a plain string.
6
+ *
7
+ * `{{ expr }}` interpolates unescaped — escaping belongs at the output edge —
8
+ * so `{{{ expr }}}` has no meaning here and is a compile-time
9
+ * `SJABLOON_RAW_TAG` error.
10
+ *
11
+ * @see SjabloonRenderer for `names`/`functions`, SjabloonScope for `$` and `@`.
12
+ * @throws {SyntaxError} On malformed tags, unclosed blocks, or bad expressions.
13
+ */
14
+ export function template(str: string, funcs?: SjabloonFunctions): SjabloonRenderer<string>;
15
+
16
+ /** Compile and render in one go. Shorthand for `template(str, funcs)(values)`. */
17
+ export function render(str: string, values?: SjabloonValues, funcs?: SjabloonFunctions): string;
package/dist/text.js ADDED
@@ -0,0 +1 @@
1
+ import{n as e,t}from"./core.js";const{template:n,render:r}=e([e=>(t,n)=>{n.s+=e},e=>(t,n,r)=>(r=e(t),n.s+=String(r??``)),0,()=>({s:``}),e=>e.s]);export{t as isDiagnostic,r as render,n as template};
@@ -0,0 +1,83 @@
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
+ | 'SJABLOON_TOO_DEEP'
11
+ | 'SJABLOON_RAW_TAG';
12
+
13
+ export interface SjabloonBlock {
14
+ readonly type: 'if' | 'each';
15
+ readonly start: number;
16
+ readonly end: number;
17
+ }
18
+
19
+ export interface SjabloonDiagnostic extends Error {
20
+ readonly code: SjabloonErrorCode;
21
+ readonly start: number;
22
+ readonly end: number;
23
+ readonly blocks: readonly SjabloonBlock[];
24
+ }
25
+
26
+ export type SjabloonValues = Record<string, any>;
27
+ export type SjabloonFunctions = Record<string, Function>;
28
+
29
+ /**
30
+ * Per-render override of the scope anchors, for embedders with their own scope
31
+ * model.
32
+ *
33
+ * Two anchors are always in scope: `$` is the root values, and `@` is the
34
+ * current `#each` item (the root outside any loop). They let a nested loop
35
+ * reach the root (`$.company`) or the current item (`@.total`) explicitly,
36
+ * past any shadowing. Neither counts as a `name`.
37
+ *
38
+ * Passing this object as the renderer's second argument makes `$` become
39
+ * `root` and `@` become `item` (two distinct objects). Omit `item` to leave
40
+ * `@` unbound, so reading `@.x` throws through xprsn's guard — a group-header
41
+ * band that has no current row wants exactly that.
42
+ */
43
+ export interface SjabloonScope {
44
+ root?: any;
45
+ item?: any;
46
+ }
47
+
48
+ /**
49
+ * A compiled template: render it many times.
50
+ *
51
+ * `names` are the variables the template reads from your values, deduplicated;
52
+ * loop variables the template introduces are not included. `functions` are the
53
+ * registry functions the template calls, deduplicated.
54
+ */
55
+ export interface SjabloonRenderer<T> {
56
+ (values?: SjabloonValues, scope?: SjabloonScope): T;
57
+ names: string[];
58
+ functions: string[];
59
+ }
60
+
61
+ /** One static text run of the template, verbatim. */
62
+ export interface LiteralToken {
63
+ literal: string;
64
+ }
65
+
66
+ /** One `{{ }}` interpolation, pre-stringify. Nullish values are preserved. */
67
+ export interface ValueToken {
68
+ value: unknown;
69
+ }
70
+
71
+ /**
72
+ * A render's output in the token edition, in render order: loop bodies append
73
+ * once per iteration, untaken branches append nothing, and block expressions
74
+ * (`#if` conditions, `#each` collections) never appear.
75
+ */
76
+ export type Token = LiteralToken | ValueToken;
77
+
78
+ /**
79
+ * Check whether an error was produced or translated by sjabloon. Every entry
80
+ * shares one core, so a diagnostic thrown through any of them authenticates
81
+ * through all of them.
82
+ */
83
+ export function isDiagnostic(error: unknown): error is SjabloonDiagnostic;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sjabloon",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Tiny, CSP-safe template engine for JavaScript, powered by xprsn expressions. No eval, no new Function.",
5
5
  "repository": "getquario/sjabloon",
6
6
  "license": "Apache-2.0",
@@ -10,33 +10,58 @@
10
10
  "url": "https://robinvdvleuten.nl"
11
11
  },
12
12
  "type": "module",
13
- "source": "src/index.js",
14
- "main": "dist/index.cjs",
15
13
  "module": "dist/index.js",
16
- "types": "index.d.ts",
14
+ "types": "dist/index.d.ts",
17
15
  "exports": {
18
16
  ".": {
19
- "types": "./index.d.ts",
20
- "import": "./dist/index.js",
21
- "require": "./dist/index.cjs"
22
- }
17
+ "types": "./dist/index.d.ts",
18
+ "default": "./dist/index.js"
19
+ },
20
+ "./text": {
21
+ "types": "./dist/text.d.ts",
22
+ "default": "./dist/text.js"
23
+ },
24
+ "./html": {
25
+ "types": "./dist/html.d.ts",
26
+ "default": "./dist/html.js"
27
+ },
28
+ "./package.json": "./package.json"
23
29
  },
24
30
  "engines": {
25
- "node": ">=22.0.0"
31
+ "node": ">=22.12.0"
26
32
  },
27
33
  "files": [
28
- "dist",
29
- "src",
30
- "index.d.ts"
34
+ "dist"
31
35
  ],
32
36
  "size-limit": [
33
37
  {
34
- "path": "dist/index.js",
35
- "limit": "1.76 kB"
38
+ "name": "sjabloon",
39
+ "path": [
40
+ "dist/index.js",
41
+ "dist/core.js"
42
+ ],
43
+ "limit": "1.95 kB"
44
+ },
45
+ {
46
+ "name": "sjabloon/text",
47
+ "path": [
48
+ "dist/text.js",
49
+ "dist/core.js"
50
+ ],
51
+ "limit": "1.9 kB"
52
+ },
53
+ {
54
+ "name": "sjabloon/html",
55
+ "path": [
56
+ "dist/html.js",
57
+ "dist/core.js"
58
+ ],
59
+ "limit": "1.97 kB"
36
60
  },
37
61
  {
38
- "path": "dist/index.cjs",
39
- "limit": "1.78 kB"
62
+ "name": "core chunk (informational)",
63
+ "path": "dist/core.js",
64
+ "limit": "1.75 kB"
40
65
  }
41
66
  ],
42
67
  "scripts": {
package/dist/index.cjs DELETED
@@ -1 +0,0 @@
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,_;const v=Symbol();let y=()=>Object.freeze(_.slice()),b=(e,t)=>(_.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}),a(e),e),S=(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,x(a,y())},C=(t,n,r,i=e.isDiagnostic)=>{throw i(t)?(t.start+=n,t.end+=n,x(t,r)):t},w=e=>S(`Unexpected {{`+e[1]+`}}`,`SJABLOON_UNEXPECTED_TAG`,e),T=(e,t)=>e.map(e=>e(t)).join(``),E=(e,t)=>(e=>(n,r)=>(r=e(n),n[v]?.push(r),t(r??``)))(D(e[1],e[4],y())),D=(t,n,r)=>{let i;try{i=(0,e.compile)(t,d)}catch(e){C(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){C(e,n,r,i.isDiagnostic)}}},O=e=>{let t=k([`#elif`,`#else`,`/if`]),n=f[1],r=[];return n.startsWith(`#elif `)?r=[O(D(n.slice(6),f[4]+6,y()))]:n===`#else`?(r=k([`/if`]),f[1]===`/if`||w(f)):n!==`/if`&&w(f),n=>T(e(n)?t:r,n)},k=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(E(i,String));else if(e.includes(a.split(` `)[0]))return f=i,t;else if(a[0]!==`!`)if(a.startsWith(`#if `))_.push(b(`if`,i)),t.push(O(D(a.slice(4),i[4]+4,y()))),_.pop();else if(/^#each(?:\s|$)/.test(a)){_.push(b(`each`,i));let e=/^#each ([\s\S]+) as ((\w+)(?:\s*,\s*(\w+))?)$/.exec(a);e||S(`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)&&S(`Bad {{`+a+`}}`,`SJABLOON_BLOCKED_BINDING`,i,s,s+n.length),o&&r.test(o)){let e=i[4]+a.length-o.length;S(`Bad {{`+a+`}}`,`SJABLOON_BLOCKED_BINDING`,i,e,e+o.length)}let c=D(e[1],i[4]+6,y()),l=p.length;p.push(n),o&&p.push(o),p.push(`loop`);let u=k([`#else`,`/each`]);p.length=l;let d=[];f[1]===`#else`?(d=k([`/each`]),f[1]===`/each`||w(f)):f[1]!==`/each`&&w(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},T(u,l)}).join(``):T(d,e)})}else/^#(?:if|elif|else)(?:\s|$)/.test(a)||a[0]===`/`?w(i):a[0]===`#`?S(`Unknown {{`+a+`}}`,`SJABLOON_UNKNOWN_BLOCK`,i):t.push(E(i,n))}return e.length&&S(`Missing {{`+e[e.length-1]+`}}`,`SJABLOON_UNCLOSED_BLOCK`),t};function A(e,t){d=t,p=[`$`,`@`],m=new Set,h=new Set,g=String(e),_=[],l=c(g),u=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,r)=>{e||={};let i=Object.create(e);return i.$=t?t.root:e,t?`item`in t&&(i[`@`]=t.item):i[`@`]=e,i[v]=r,T(n,i)},i=(e,t)=>r(e,t);return i.withRaw=(e,t,n=[])=>({text:r(e,t,n),raws:n}),i.names=Array.from(m),i.functions=Array.from(h),i}function j(e,t,n){return A(e,n)(t)}exports.isDiagnostic=s,exports.render=j,exports.template=A;
package/index.d.ts DELETED
@@ -1,82 +0,0 @@
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
- * The renderer also exposes `withRaw(values, scope)`: one render, both channels.
48
- * It returns `{ text, raws }` — the rendered string plus each interpolation's
49
- * pre-escape, pre-stringify value (`{{ }}` and `{{{ }}}` alike, nullish
50
- * included), in render order: loop bodies push once per iteration, untaken
51
- * branches push nothing. Block expressions (`#if` conditions, `#each`
52
- * collections) are never captured.
53
- *
54
- * @param {string} str The template, e.g. `'Hello {{ user.name }}!'`.
55
- * @param {Record<string, Function>} [funcs] Functions callable inside expressions.
56
- * @returns {{(values?: Record<string, any>, scope?: { root?: any, item?: any }): string, withRaw: (values?: Record<string, any>, scope?: { root?: any, item?: any }) => { text: string, raws: unknown[] }, names: string[], functions: string[]}} Renderer for the compiled template.
57
- * @throws {SyntaxError} On malformed tags, unclosed blocks, or bad expressions.
58
- */
59
- export function template(str: string, funcs?: Record<string, Function>): {
60
- (values?: Record<string, any>, scope?: {
61
- root?: any;
62
- item?: any;
63
- }): string;
64
- withRaw(values?: Record<string, any>, scope?: {
65
- root?: any;
66
- item?: any;
67
- }): {
68
- text: string;
69
- raws: unknown[];
70
- };
71
- names: string[];
72
- functions: string[];
73
- };
74
- /**
75
- * Compile and render a template in one go.
76
- *
77
- * @param {string} str The template to render.
78
- * @param {Record<string, any>} [values] Variables available to the template.
79
- * @param {Record<string, Function>} [funcs] Functions callable inside expressions.
80
- * @returns {string} The rendered output.
81
- */
82
- export function render(str: string, values?: Record<string, any>, funcs?: Record<string, Function>): string;
package/src/index.js DELETED
@@ -1,291 +0,0 @@
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, isDiagnostic as isXprsnDiagnostic } from 'xprsn';
7
-
8
- const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
9
- const esc = s => String(s).replace(/[&<>"']/g, c => ESC[c]);
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);
14
-
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);
22
-
23
- // Linear scan into text/tag/raw tokens. Dashes hug braces (`{{- x -}}` trims;
24
- // `{{ -x }}` stays unary minus). Prefer {{{ }}} over {{ }}. `triple` latches
25
- // off once }}} is gone so {{{...}}×N does not rescan to EOF (stays O(n)).
26
- let lex = s => {
27
- const out = [];
28
- for (let i = 0, triple = 1; i < s.length; ) {
29
- const a = s.indexOf('{{', i);
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;
33
- if (l) p++;
34
- if (raw && triple) { b = s.indexOf('}}}', p); if (b < 0) triple = 0; }
35
- if (b < 0) {
36
- if (raw) { raw = !1; p = a + 2; l = s[p] === '-'; if (l) p++; }
37
- b = s.indexOf('}}', p);
38
- }
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();
46
- out.push(t);
47
- i = end;
48
- if (r) while (/\s/.test(s[i])) i++;
49
- }
50
- return out;
51
- };
52
-
53
- // Shared parser state; parsing is synchronous so this is safe.
54
- // `nms` collects free variables, `fnms` the registry functions called.
55
- let toks, i, fns, last, bound, nms, fnms, src, blocks;
56
-
57
- // The opt-in raw-value collector rides the render's root scope under a
58
- // symbol: invisible to expressions, inherited by `#each` child scopes, and
59
- // naturally per render, so re-entrant renders each collect into their own.
60
- const RAWS = Symbol();
61
-
62
- let snap = () => Object.freeze(blocks.slice());
63
- // Block nesting is capped so a pathological template fails as a deterministic
64
- // SyntaxError at the offending opener, far below the native stack limit.
65
- const DEPTH = 256;
66
- let opener = (type, t) => {
67
- blocks.length < DEPTH || fault('Template too deeply nested', 'SJABLOON_TOO_DEEP', t);
68
- return Object.freeze({ type, start: t[2], end: t[3] });
69
- };
70
- let attach = (e, context) => {
71
- Object.defineProperty(e, 'blocks', { value: context, enumerable: true });
72
- mark(e);
73
- return e;
74
- };
75
- let fault = (msg, code, t, start = t?.[2] ?? src.length, end = t?.[3] ?? src.length) => {
76
- const e = SyntaxError(msg);
77
- e.code = code;
78
- e.start = start;
79
- e.end = end;
80
- throw attach(e, snap());
81
- };
82
- let translated = (e, start, context, owns = isXprsnDiagnostic) => {
83
- if (!owns(e)) throw e;
84
- e.start += start;
85
- e.end += start;
86
- throw attach(e, context);
87
- };
88
- let unexpected = t => fault('Unexpected {{' + t[1] + '}}', 'SJABLOON_UNEXPECTED_TAG', t);
89
-
90
- // Render a list of nodes against a scope.
91
- let run = (nodes, v) => nodes.map(n => n(v)).join('');
92
-
93
- // A leaf interpolation node: compile `src`, render nullish as '', apply `wrap`
94
- // (`esc` for `{{ }}`, `String` for the raw `{{{ }}}` form). The pre-stringify
95
- // result feeds the opt-in `raws` collector; block expressions never do.
96
- let interp = (t, wrap) => (e => (v, x) => (x = e(v), v[RAWS]?.push(x), wrap(x ?? '')))(cp(t[1], t[4], snap()));
97
-
98
- // Compile one expression and collect its free variables (minus the loop
99
- // variables currently in scope, which belong to the template) and the registry
100
- // functions it calls.
101
- let cp = (s, start, context) => {
102
- let e;
103
- try {
104
- e = compile(s, fns);
105
- } catch (x) {
106
- translated(x, start, context);
107
- }
108
- for (const n of e.names) bound.includes(n) || nms.add(n);
109
- for (const fn of e.functions) fnms.add(fn);
110
- return v => {
111
- try {
112
- return e(v);
113
- } catch (x) {
114
- translated(x, start, context, e.isDiagnostic);
115
- }
116
- };
117
- };
118
-
119
- // One `#if`/`#elif` link: parse its branch, then recurse on the chain tail.
120
- let branch = cond => {
121
- const then = parse(['#elif', '#else', '/if']);
122
- const tag = last[1];
123
- let els = [];
124
- if (tag.startsWith('#elif ')) els = [branch(cp(tag.slice(6), last[4] + 6, snap()))];
125
- else if (tag === '#else') {
126
- els = parse(['/if']);
127
- last[1] === '/if' || unexpected(last);
128
- } else if (tag !== '/if') unexpected(last);
129
- return v => run(cond(v) ? then : els, v);
130
- };
131
-
132
- let parse = stops => {
133
- const nodes = [];
134
- for (let t; (t = toks[i++]); ) {
135
- const tag = t[1];
136
- if (!t[0]) {
137
- nodes.push((s => () => s)(tag));
138
- } else if (t[0] === 1) {
139
- nodes.push(interp(t, String));
140
- } else if (stops.includes(tag.split(' ')[0])) {
141
- last = t;
142
- return nodes;
143
- } else if (tag[0] === '!') {
144
- // comment
145
- } else if (tag.startsWith('#if ')) {
146
- blocks.push(opener('if', t));
147
- nodes.push(branch(cp(tag.slice(4), t[4] + 4, snap())));
148
- blocks.pop();
149
- } else if (/^#each(?:\s|$)/.test(tag)) {
150
- blocks.push(opener('each', t));
151
- const m = /^#each ([\s\S]+) as ((\w+)(?:\s*,\s*(\w+))?)$/.exec(tag);
152
- m || fault('Bad {{' + tag + '}}', 'SJABLOON_EACH_SYNTAX', t);
153
- const name = m[3], idx = m[4], at = t[4] + tag.length - m[2].length;
154
- if (BLOCKED.test(name)) fault('Bad {{' + tag + '}}', 'SJABLOON_BLOCKED_BINDING', t, at, at + name.length);
155
- if (idx && BLOCKED.test(idx)) {
156
- const p = t[4] + tag.length - idx.length;
157
- fault('Bad {{' + tag + '}}', 'SJABLOON_BLOCKED_BINDING', t, p, p + idx.length);
158
- }
159
- const list = cp(m[1], t[4] + 6, snap());
160
- // `name`, `idx`, and `loop` are engine-bound inside the body, so
161
- // exclude them from names there and restore outer bindings after.
162
- const mark = bound.length;
163
- bound.push(name);
164
- if (idx) bound.push(idx);
165
- bound.push('loop');
166
- const body = parse(['#else', '/each']);
167
- bound.length = mark;
168
- let empty = [];
169
- if (last[1] === '#else') {
170
- empty = parse(['/each']);
171
- last[1] === '/each' || unexpected(last);
172
- } else if (last[1] !== '/each') unexpected(last);
173
- blocks.pop();
174
- // Child scopes inherit the parent via the prototype chain, so outer
175
- // variables stay visible inside the loop body. `@` re-points to the
176
- // current item at each level, `$` (root) rides the chain, and `loop`
177
- // carries the iteration metadata (index/first/last/length).
178
- nodes.push(v => {
179
- const lv = list(v), arr = Array.isArray(lv);
180
- const ps = arr ? lv.slice() : lv && typeof lv === 'object' ? Object.keys(lv).map(k => [lv[k], k]) : [];
181
- if (!ps.length) return run(empty, v);
182
- return ps.map((x, j) => {
183
- const item = arr ? x : x[0], key = arr ? j : x[1];
184
- const s = Object.create(v);
185
- s[name] = item;
186
- if (idx) s[idx] = key;
187
- s['@'] = item;
188
- s.loop = { index: j + 1, index0: j, first: !j, last: j === ps.length - 1, length: ps.length };
189
- return run(body, s);
190
- }).join('');
191
- });
192
- } else if (/^#(?:if|elif|else)(?:\s|$)/.test(tag) || tag[0] === '/') {
193
- unexpected(t);
194
- } else if (tag[0] === '#') {
195
- fault('Unknown {{' + tag + '}}', 'SJABLOON_UNKNOWN_BLOCK', t);
196
- } else {
197
- nodes.push(interp(t, esc));
198
- }
199
- }
200
- stops.length && fault('Missing {{' + stops[stops.length - 1] + '}}', 'SJABLOON_UNCLOSED_BLOCK');
201
- return nodes;
202
- };
203
-
204
- /**
205
- * Compile a template once, render it many times.
206
- *
207
- * The returned renderer exposes `names`: the variables the template reads
208
- * from your values, deduplicated. Loop variables the template introduces are
209
- * not included. It also exposes `functions`: the registry functions the
210
- * template calls, deduplicated.
211
- *
212
- * Two anchors are always in scope: `$` is the root values, and `@` is the
213
- * current `#each` item (the root outside any loop). They let a nested loop
214
- * reach the root (`$.company`) or the current item (`@.total`) explicitly,
215
- * past any shadowing. Neither counts as a `name`.
216
- *
217
- * An embedder with its own scope model can override the anchors per render by
218
- * passing `{ root, item }` as the renderer's second argument: `$` becomes
219
- * `root` and `@` becomes `item` (two distinct objects). Omit `item` to leave
220
- * `@` unbound, so reading `@.x` throws through xprsn's guard.
221
- *
222
- * The renderer also exposes `withRaw(values, scope)`: one render, both channels.
223
- * It returns `{ text, raws }` — the rendered string plus each interpolation's
224
- * pre-escape, pre-stringify value (`{{ }}` and `{{{ }}}` alike, nullish
225
- * included), in render order: loop bodies push once per iteration, untaken
226
- * branches push nothing. Block expressions (`#if` conditions, `#each`
227
- * collections) are never captured.
228
- *
229
- * @param {string} str The template, e.g. `'Hello {{ user.name }}!'`.
230
- * @param {Record<string, Function>} [funcs] Functions callable inside expressions.
231
- * @returns {{(values?: Record<string, any>, scope?: { root?: any, item?: any }): string, withRaw: (values?: Record<string, any>, scope?: { root?: any, item?: any }) => { text: string, raws: unknown[] }, names: string[], functions: string[]}} Renderer for the compiled template.
232
- * @throws {SyntaxError} On malformed tags, unclosed blocks, or bad expressions.
233
- */
234
- export function template(str, funcs) {
235
- fns = funcs;
236
- // `$` (root) and `@` (current item) are engine-bound anchors, always in
237
- // scope, so they never count as caller-supplied `names`.
238
- bound = ['$', '@'];
239
- nms = new Set();
240
- fnms = new Set();
241
- src = String(str);
242
- blocks = [];
243
- toks = lex(src);
244
- i = 0;
245
- // Deeply nested blocks overflow the recursive-descent parser; surface that
246
- // as a SyntaxError so malformed input keeps its documented compile-time
247
- // contract (mirroring xprsn's XPRSN_TOO_DEEP for expressions).
248
- let nodes;
249
- try {
250
- nodes = parse([]);
251
- } catch (x) {
252
- // An empty span at the end, like an unclosed block.
253
- if (x instanceof RangeError) fault('Template too deeply nested', 'SJABLOON_TOO_DEEP');
254
- throw x;
255
- }
256
- // Wrap the values in a root scope carrying the anchors, without mutating
257
- // what the caller passed: by default `$` and `@` both point at the root.
258
- // An embedder can override the anchors with a `{ root, item }` second arg:
259
- // `$` = root, `@` = item (distinct objects). Omitting `item` leaves `@`
260
- // unbound, so `@.x` throws through xprsn's guard — a group-header band that
261
- // has no current row wants exactly that.
262
- const g = (v, o, w) => {
263
- v = v || {};
264
- const r = Object.create(v);
265
- r['$'] = o ? o.root : v;
266
- if (!o) r['@'] = v;
267
- else if ('item' in o) r['@'] = o.item;
268
- r[RAWS] = w;
269
- return run(nodes, r);
270
- };
271
- const f = (v, o) => g(v, o);
272
- // One render, both channels: the rendered string plus the ordered raws.
273
- f.withRaw = (v, o, w = []) => ({ text: g(v, o, w), raws: w });
274
- // Array.from, not a spread: the bundler's transpile turns `[...set]` into
275
- // `[].concat(set)`, which wraps the Set instead of unpacking it.
276
- f.names = Array.from(nms);
277
- f.functions = Array.from(fnms);
278
- return f;
279
- }
280
-
281
- /**
282
- * Compile and render a template in one go.
283
- *
284
- * @param {string} str The template to render.
285
- * @param {Record<string, any>} [values] Variables available to the template.
286
- * @param {Record<string, Function>} [funcs] Functions callable inside expressions.
287
- * @returns {string} The rendered output.
288
- */
289
- export function render(str, values, funcs) {
290
- return template(str, funcs)(values);
291
- }