├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ └── ci.yml ├── .gitignore ├── bench ├── escape.js ├── package.json ├── raw.js ├── readme.md └── util.js ├── bin └── index.js ├── docs ├── api.md ├── blocks.md └── syntax.md ├── examples ├── basic.hbs ├── blocks.hbs ├── index.js ├── index.ts └── worker │ ├── .gitignore │ ├── esbuild.js │ ├── package.json │ ├── readme.md │ ├── rollup.config.js │ └── src │ ├── index.js │ └── todos.hbs ├── license ├── logo.png ├── package.json ├── readme.md ├── src ├── $index.js ├── $utils.d.ts ├── $utils.js ├── esbuild.d.ts ├── esbuild.js ├── index.d.ts ├── rollup.d.ts ├── rollup.js └── syntax.hbs └── test ├── $index.js └── $utils.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_size = 2 6 | indent_style = tab 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.{json,yml,md}] 13 | indent_style = space 14 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: lukeed 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - 'bench/**' 7 | - '*.md' 8 | branches: 9 | - '**' 10 | tags-ignore: 11 | - '**' 12 | pull_request: 13 | paths-ignore: 14 | - 'bench/**' 15 | - '*.md' 16 | branches: 17 | - master 18 | 19 | jobs: 20 | test: 21 | name: Node.js v${{ matrix.nodejs }} 22 | runs-on: ubuntu-latest 23 | strategy: 24 | matrix: 25 | nodejs: [10, 12, 14, 16, 18] 26 | steps: 27 | - uses: actions/checkout@v3 28 | - uses: actions/setup-node@v3 29 | with: 30 | node-version: ${{ matrix.nodejs }} 31 | 32 | - name: Install 33 | run: npm install 34 | 35 | - name: Test 36 | if: matrix.nodejs < 18 37 | run: npm test 38 | 39 | - name: Test w/ Coverage 40 | if: matrix.nodejs >= 18 41 | run: | 42 | npm install -g c8 43 | c8 --include=src npm test 44 | 45 | - name: Report 46 | if: matrix.nodejs >= 18 47 | run: | 48 | c8 report --reporter=text-lcov > coverage.lcov 49 | bash <(curl -s https://codecov.io/bash) 50 | env: 51 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | *-lock.* 4 | *.lock 5 | *.log 6 | 7 | # generated 8 | /src/index.js 9 | /index.d.ts 10 | /esbuild 11 | /rollup 12 | /dist 13 | -------------------------------------------------------------------------------- /bench/escape.js: -------------------------------------------------------------------------------- 1 | const assert = require('uvu/assert'); 2 | const { runner } = require('./util'); 3 | 4 | console.log('Loading:\n---'); 5 | 6 | console.time('pug'); 7 | const pug = require('pug'); 8 | console.timeEnd('pug'); 9 | 10 | console.time('ejs'); 11 | const ejs = require('ejs'); 12 | console.timeEnd('ejs'); 13 | 14 | console.time('handlebars'); 15 | const handlebars = require('handlebars'); 16 | console.timeEnd('handlebars'); 17 | 18 | console.time('yeahjs'); 19 | const yeahjs = require('yeahjs'); 20 | console.timeEnd('yeahjs'); 21 | 22 | console.time('dot'); 23 | const dot = require('dot'); 24 | console.timeEnd('dot'); 25 | 26 | console.time('art-template'); 27 | const art = require('art-template'); 28 | console.timeEnd('art-template'); 29 | 30 | console.time('tempura'); 31 | const tempura = require('tempura'); 32 | console.timeEnd('tempura'); 33 | 34 | // --- 35 | 36 | const compilers = { 37 | 'pug': () => pug.compile(`ul\n\t-for (var i = 0, l = list.length; i < l; i ++) {\n\t\tli User: #{list[i].user} / Web Site: #{list[i].site}\n\t-}`), 38 | 39 | 'handlebars': () => handlebars.compile(` 40 | 45 | `), 46 | 47 | 'ejs': () => ejs.compile(` 48 | 53 | `), 54 | 55 | 'yeahjs': () => yeahjs.compile(` 56 | 61 | `, { locals: ['list'] }), 62 | 63 | 'dot': () => dot.template(` 64 | 69 | `), 70 | 71 | 'art-template': () => art.compile(` 72 | 77 | `), 78 | 79 | 'tempura': () => tempura.compile(` 80 | {{#expect list}} 81 | 86 | `, { 87 | format: 'cjs' 88 | }), 89 | }; 90 | 91 | // runner('Compile', compilers, { 92 | // assert(fn) { 93 | // assert.type(fn(), 'function'); 94 | // } 95 | // }); 96 | 97 | const renders = {}; 98 | console.log('\nBenchmark (Compile)'); 99 | 100 | for (let k in compilers) { 101 | let i=0, sum=0, max=5; 102 | while (i++ < max) { 103 | let n = process.hrtime(); 104 | renders[k] = compilers[k](); 105 | let [, ns] = process.hrtime(n); 106 | sum += ns; 107 | } 108 | let avgms = (sum / max / 1e6).toFixed(5); 109 | console.log(' ~>', k.padEnd(18), avgms + 'ms'); 110 | } 111 | 112 | runner('Render', renders, { 113 | setup() { 114 | let list = []; 115 | for (let i=0; i < 1e3; i++) { 116 | list.push({ 117 | user: `user-${i}`, 118 | site: `"https://github.com/user-${i}""`, 119 | }); 120 | } 121 | return { list }; 122 | }, 123 | assert(render) { 124 | let list = [ 125 | { user: 'lukeed', site: '"https://github.com/lukeed"'}, 126 | { user: 'billy', site: '"https://github.com/billy"'}, 127 | ]; 128 | 129 | let output = render({ list }); 130 | assert.type(output, 'string', '~> renders string'); 131 | 132 | let normalize = output.replace(/\n/g, '').replace(/[\t ]+\[\t ]+\<').replace(/\>[\t ]+$/g, '>'); 133 | assert.is.not(normalize, ``); 134 | }, 135 | }); 136 | -------------------------------------------------------------------------------- /bench/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "devDependencies": { 4 | "art-template": "4.13.2", 5 | "benchmark": "2.1.4", 6 | "dot": "1.1.3", 7 | "ejs": "3.1.6", 8 | "handlebars": "4.7.7", 9 | "pug": "3.0.2", 10 | "tempura": "file:../", 11 | "yeahjs": "0.2.0" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /bench/raw.js: -------------------------------------------------------------------------------- 1 | const assert = require('uvu/assert'); 2 | const { runner } = require('./util'); 3 | 4 | console.log('Loading:\n---'); 5 | 6 | console.time('pug'); 7 | const pug = require('pug'); 8 | console.timeEnd('pug'); 9 | 10 | console.time('ejs'); 11 | const ejs = require('ejs'); 12 | console.timeEnd('ejs'); 13 | 14 | console.time('handlebars'); 15 | const handlebars = require('handlebars'); 16 | console.timeEnd('handlebars'); 17 | 18 | console.time('yeahjs'); 19 | const yeahjs = require('yeahjs'); 20 | console.timeEnd('yeahjs'); 21 | 22 | console.time('dot'); 23 | const dot = require('dot'); 24 | console.timeEnd('dot'); 25 | 26 | console.time('art-template'); 27 | const art = require('art-template'); 28 | console.timeEnd('art-template'); 29 | 30 | console.time('tempura'); 31 | const tempura = require('tempura'); 32 | console.timeEnd('tempura'); 33 | 34 | // --- 35 | 36 | const compilers = { 37 | 'pug': () => pug.compile(`ul\n\t-for (var i = 0, l = list.length; i < l; i ++) {\n\t\tli User: !{list[i].user} / Web Site: !{list[i].site}\n\t-}`), 38 | 39 | 'handlebars': () => handlebars.compile(` 40 | 45 | `), 46 | 47 | 'ejs': () => ejs.compile(` 48 | 53 | `), 54 | 55 | 'yeahjs': () => yeahjs.compile(` 56 | 61 | `, { locals: ['list'] }), 62 | 63 | 'dot': () => dot.template(` 64 | 69 | `), 70 | 71 | 'art-template': () => art.compile(` 72 | 77 | `), 78 | 79 | 'tempura': () => tempura.compile(` 80 | {{#expect list}} 81 | 86 | `, { 87 | format: 'cjs' 88 | }), 89 | }; 90 | 91 | // runner('Compile', compilers, { 92 | // assert(fn) { 93 | // assert.type(fn(), 'function'); 94 | // } 95 | // }); 96 | 97 | const renders = {}; 98 | console.log('\nBenchmark (Compile)'); 99 | 100 | for (let k in compilers) { 101 | let i=0, sum=0, max=5; 102 | while (i++ < max) { 103 | let n = process.hrtime(); 104 | renders[k] = compilers[k](); 105 | let [, ns] = process.hrtime(n); 106 | sum += ns; 107 | } 108 | let avgms = (sum / max / 1e6).toFixed(5); 109 | console.log(' ~>', k.padEnd(18), avgms + 'ms'); 110 | } 111 | 112 | runner('Render', renders, { 113 | setup() { 114 | let list = []; 115 | for (let i=0; i < 1e3; i++) { 116 | list.push({ 117 | user: `user-${i}`, 118 | site: `https://github.com/user-${i}`, 119 | }); 120 | } 121 | return { list }; 122 | }, 123 | assert(render) { 124 | let list = [ 125 | { user: 'lukeed', site: 'https://github.com/lukeed'}, 126 | { user: 'billy', site: 'https://github.com/billy'}, 127 | ]; 128 | 129 | let output = render({ list }); 130 | assert.type(output, 'string', '~> renders string'); 131 | 132 | let normalize = output.replace(/\n/g, '').replace(/[\t ]+\[\t ]+\<').replace(/\>[\t ]+$/g, '>'); 133 | assert.is(normalize, ``); 134 | }, 135 | }); 136 | -------------------------------------------------------------------------------- /bench/readme.md: -------------------------------------------------------------------------------- 1 | # Benchmarks 2 | 3 | > All benchmarks run using Node v14.15.3 4 | 5 | ## Load Time 6 | 7 | Measures how long it takes to `require` a library. 8 | 9 | ``` 10 | pug: 127.647ms 11 | ejs: 1.444ms 12 | handlebars: 23.627ms 13 | yeahjs: 0.979ms 14 | dot: 1.363ms 15 | art-template: 8.847ms 16 | tempura: 0.593ms 17 | ``` 18 | 19 | ## Compile Time 20 | 21 | How quickly a template is parsed and generated into a `function` equivalent. 22 | 23 | Results display the average of 5 runs, in milliseconds. 24 | 25 | > **Important:** All candidates use a different template syntax. 26 | 27 | ``` 28 | Benchmark (Compile) 29 | ~> pug 7.20474ms 30 | ~> handlebars 0.02952ms 31 | ~> ejs 0.28859ms 32 | ~> yeahjs 0.12206ms 33 | ~> dot 0.27055ms 34 | ~> art-template 0.76982ms 35 | ~> tempura 0.19813ms 36 | ``` 37 | 38 | ## Render - Raw Values 39 | 40 | Measures how quickly the generated functions render an output string. 41 | 42 | > **Note:** All candidates **do not escape** template values - hence "raw" values. 43 | 44 | All candidates produce the **same** output value, which is used as a validation step. 45 | 46 | ``` 47 | Validation (Render) 48 | ✔ pug 49 | ✔ handlebars 50 | ✔ ejs 51 | ✔ yeahjs 52 | ✔ dot 53 | ✔ art-template 54 | ✔ tempura 55 | 56 | Benchmark (Render) 57 | pug x 34,847 ops/sec ±2.79% (93 runs sampled) 58 | handlebars x 6,700 ops/sec ±1.41% (92 runs sampled) 59 | ejs x 802 ops/sec ±0.54% (94 runs sampled) 60 | yeahjs x 31,508 ops/sec ±0.30% (97 runs sampled) 61 | dot x 40,704 ops/sec ±3.08% (93 runs sampled) 62 | art-template x 39,839 ops/sec ±0.86% (90 runs sampled) 63 | tempura x 44,656 ops/sec ±0.42% (92 runs sampled) 64 | ``` 65 | 66 | ## Render – Escaped Values 67 | 68 | Measures how quickly the generated functions render an output string. All template **values are escaped**, unlike the [Raw Values benchmark]($render-raw-values) above. 69 | 70 | > **Important:** All candidates use a different `escape()` sequence. 71 | 72 | ``` 73 | Benchmark (Render) 74 | pug x 2,800 ops/sec ±0.31% (95 runs sampled) 75 | handlebars x 733 ops/sec ±0.34% (94 runs sampled) 76 | ejs x 376 ops/sec ±0.17% (91 runs sampled) 77 | yeahjs x 873 ops/sec ±0.30% (95 runs sampled) 78 | dot x 707 ops/sec ±0.15% (96 runs sampled) 79 | art-template x 2,707 ops/sec ±0.12% (96 runs sampled) 80 | tempura x 2,922 ops/sec ±0.31% (96 runs sampled) 81 | ``` 82 | -------------------------------------------------------------------------------- /bench/util.js: -------------------------------------------------------------------------------- 1 | const { Suite } = require('benchmark'); 2 | 3 | exports.runner = function (name, contenders, options={}) { 4 | if (options.assert) { 5 | console.log('\nValidation (%s)', name); 6 | Object.keys(contenders).forEach(name => { 7 | try { 8 | options.assert(contenders[name]); 9 | console.log(' ✔', name); 10 | } catch (err) { 11 | console.log(' ✘', name, `(FAILED @ "${err.message}")`); 12 | } 13 | }); 14 | } 15 | 16 | let value; 17 | if (options.setup) { 18 | value = options.setup(); 19 | } 20 | 21 | console.log('\nBenchmark (%s)', name); 22 | const bench = new Suite().on('cycle', e => { 23 | console.log(' ' + e.target); 24 | }); 25 | 26 | Object.keys(contenders).forEach(name => { 27 | bench.add(name + ' '.repeat(18 - name.length), () => { 28 | contenders[name](value); 29 | }) 30 | }); 31 | 32 | bench.run(); 33 | } 34 | -------------------------------------------------------------------------------- /bin/index.js: -------------------------------------------------------------------------------- 1 | const { join } = require('path'); 2 | const fs = require('fs/promises'); 3 | 4 | (async function () { 5 | const src = join(__dirname, '../src'); 6 | 7 | let [$utils, $index] = await Promise.all([ 8 | fs.readFile(join(src, '$utils.js'), 'utf8'), 9 | fs.readFile(join(src, '$index.js'), 'utf8'), 10 | ]); 11 | 12 | $utils = $utils.replace('export function', 'function').replace(/\$/g, '\0'); 13 | $index = $index.replace(/^import.*(?:\n)/m, $utils).replace(/\0/g, '$$'); 14 | 15 | console.log('~> creating "src/index.js" placeholder'); 16 | await fs.writeFile(join(src, 'index.js'), $index); 17 | })().catch(err => { 18 | console.error('Oops', err.stack); 19 | process.exitCode = 1; 20 | }); 21 | -------------------------------------------------------------------------------- /docs/api.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | > **Note:** You may also refer to the [TypeScript definitions](/src/index.d.ts). 4 | 5 | ## Methods 6 | 7 | 8 | ### tempura.esc(value) 9 | Returns: `string` 10 | 11 | #### value 12 | Type: `string` or `unknown` 13 | 14 | The value to be HTML-escaped. The following special characters are escaped: `"`, `&`, and `<`. 15 | 16 | > **Note:** Any non-`string` values are coerced to a strings! 17 | > * `null` and `undefined` become `""` 18 | > * `{ foo: 123 }` becomes `"[object Object]"` 19 | > * `[1, 2, 3]` becomes `"1,2,3"` 20 | > * `123` becomes `"123"` 21 | 22 | 23 | ### tempura.compile(input, options?) 24 | Returns: `Compiler` 25 | 26 | Convert a template into a `function` that can be executed. 27 | 28 | When called with the appropriate, template-specific arguments, the `Compiler` function will return a `string` or a `Promise`, resulting in the desired template output. 29 | 30 | ```js 31 | let render = tempura.compile(` 32 | {{#expect age}} 33 | {{#if age < 100}} 34 | You're not 100+ yet 35 | {{#else}} 36 | What's the secret? 37 | {{/if}} 38 | `); 39 | 40 | console.log(typeof render); 41 | //=> "function" 42 | 43 | render({ age: 31 }); 44 | //=> "You're not 100+ yet" 45 | 46 | render({ age: 102 }); 47 | //=> "What's the secret?" 48 | ``` 49 | 50 | 51 | #### input 52 | Type: `string` 53 | 54 | The template to be converted. 55 | 56 | #### options 57 | 58 | All common [Options](#options-2) are supported, in addition to: 59 | 60 | #### options.escape 61 | Type: `(value: any) => string` 62 | Default: `typeof tempura.esc` 63 | 64 | The escape function to use for `{{{ raw }}}` values. 65 | 66 | When unspecified, [`tempura.esc`](#tempuraescvalue) is used by default; 67 | 68 | 69 | ### tempura.transform(input, options?) 70 | Returns: `string` 71 | 72 | Transforms the `input` source template into a string-representation of the equivalent JavaScript function. 73 | 74 | Unlike [`tempura.compile`](#tempuracompileinput-options), this generates a `string` instead of a `function`, which makes this method suitable for bundler transformations and/or replacing code at build-time. 75 | 76 | ```js 77 | let template = ` 78 | {{#expect age}} 79 | {{#if age < 100}} 80 | You're not 100+ yet 81 | {{#else}} 82 | What's the secret? 83 | {{/if}} 84 | `; 85 | 86 | // produces ESM format by default 87 | let esm = tempura.transform(template); 88 | console.log(typeof esm); //=> "string" 89 | console.log(esm); 90 | //=> 'import{esc as $$1}from"tempura";export default function($$3,$$2){var{age}=$$3,x=``;x+=``;if(age < 100){x+=`You\'re not 100+ yet"`;}else{x+=`What\'s the secret?`;}return x}' 91 | ``` 92 | 93 | #### input 94 | Type: `string` 95 | 96 | The template to be converted. 97 | 98 | #### options 99 | 100 | All common [Options](#options-2) are supported, in addition to: 101 | 102 | #### options.format 103 | Type: `"esm"` or `"cjs"`
104 | Default: `"esm"` 105 | 106 | Modify the generated output to be compliant with the CommonJS or ES Module format. 107 | 108 | > **Note:** Most bundlers and/or upstream consumers can understand (and prefer) the ESM format. 109 | 110 | 111 | ## Options 112 | 113 | ### options.async 114 | Type: `boolean`
115 | Default: `false` 116 | 117 | When true, `tempura.compile` produces an `AsyncFunction` and `tempura.compile` generates an `async` function. Also will include the `await` keyword when a [Custom Block](/docs/blocks.md) is invoked. 118 | 119 | 120 | ### options.props 121 | Type: `string[]` 122 | 123 | The _names_ of variables that will be provided to a view. 124 | 125 | Declaring `props` names means that they don't have to appear within any `{{#expect ...}}` declarations – and vice versa. In other words, `options.props` is a programmatic way to define (or skip) `{{#expect}}` blocks. 126 | 127 | It is recommended that you include global and/or shared variables within `options.props`, which saves you the trouble of writing the same `{{#expect foo, bar}}` statements over and over. This way, each template can `{{#expect}}` any variables that are unique to it. 128 | 129 | > **Note:** Variable names are deduped. For example, defining `{{#expect foo}}` _and_ `options.props: ['foo']` will not have any adverse/duplicative effects. 130 | 131 | 132 | ### options.loose 133 | Type: `boolean`
134 | Default: `false` 135 | 136 | By default, any template variables must be known ahead of time – either through [`options.props`](/docs/api.md#optionsprops) or through [`#expect`](/docs/syntax.md#expect) declarations. However, when enabled, `options.loose` relaxes this constraint. 137 | 138 | > **Note:** Enabling `options.loose` makes for a more Handlebars-like experience. 139 | 140 | With this option activated, removing the `#expect` declaration from the example below will produce the same output: 141 | 142 | ```diff 143 | --{{#expect name}} 144 |

Hello, {{ name }}!

145 | ``` 146 | 147 | 148 | ### options.blocks 149 | Type: `Record` 150 | 151 | Any [custom directives](/docs/blocks.md) that should be made available within the template. 152 | 153 | > **Important:** An error will be thrown while parsing if a custom directive was found but not defined. 154 | 155 | All key names are converted into the directive names. Keys must start with a `A-Z`, `a-z`, or `_` and may include any number of `A-Z`, `a-z`, `_`, or `0-9` characters. 156 | 157 | For example, in order to define and use the `{{#foo}}` and `{{#hello123}}` directives within a template, an `options.blocks` object with `foo` and `hello123` keys must be defined: 158 | 159 | ```js 160 | /** 161 | * @type {tempura.Options} 162 | */ 163 | let options = { 164 | async: true, 165 | blocks: { 166 | foo(args) { 167 | return `foo got ~${args.value}~`; 168 | }, 169 | async hello123(args) { 170 | return `

hello123 got ~${args.name}~

`; 171 | } 172 | } 173 | }; 174 | 175 | let template = ` 176 | {{#foo value=123 }} 177 | {{#hello123 name="world" }} 178 | `; 179 | 180 | // NOTE: Works with `transform` too 181 | await tempura.compile(template, options)(); 182 | //=> "foo got ~123~

hello123 got ~world~

" 183 | ``` 184 | -------------------------------------------------------------------------------- /docs/blocks.md: -------------------------------------------------------------------------------- 1 | # Custom Blocks 2 | 3 | You can include and use your own template helpers! 4 | 5 | In order to do so, custom blocks must be defined via [`options.blocks`](/docs/api.md#optionsblocks) during calls to `tempura.compile` and/or `tempura.transform`. 6 | 7 | > **Important:** A parsing error will be thrown when a template references a custom block (eg, `{{#foo}}`) but _has not defined_ an `options.blocks.foo` function. 8 | 9 | 10 | ## Definition 11 | 12 | All custom blocks are defined as stateless functions. They will receive an `args` object (see [Arguments](#arguments)) and are expected to return a string. 13 | 14 | For example, let's have a custom `script` block that's responsible for producing a valid `'; 26 | 27 | return output; 28 | } 29 | } 30 | }); 31 | ``` 32 | 33 | It can then be used anywhere within a template: 34 | 35 | ```hbs 36 | {{! custom "script" block }} 37 | {{#script src="main.js" defer=true }} 38 | ``` 39 | 40 | You may define `async` custom blocks, too.
However, you must remember to enable [`options.async`](/docs/api.md#optionsasync)! 41 | 42 | ```js 43 | import { send } from 'httpie'; 44 | 45 | let options = { 46 | blocks: { 47 | async nasdaq(args) { 48 | let { symbol } = args; 49 | let output = `
${symbol}
`; 50 | 51 | // DEMO: Send a GET request to some Stock Ticker API 52 | let res = await send('GET', `https://.../symbols/${symbol}`); 53 | output += `
${res.data.price}
`; 54 | 55 | return output; 56 | } 57 | } 58 | }); 59 | ``` 60 | 61 | It can then be used anywhere within a template: 62 | 63 | ```hbs 64 |
65 | {{#nasdaq symbol="AAPL" }} 66 | {{#nasdaq symbol="GOOG" }} 67 | {{#nasdaq symbol="MSFT" }} 68 |
69 | ``` 70 | 71 | 72 | ## Arguments 73 | 74 | Your template code may pass arguments to your custom blocks. 75 | 76 | Arguments are whitespace delimited and every key must be followed by an `=` and some value. For example: 77 | 78 | ```hbs 79 | {{#person name="Luke" age=31 }} 80 | ``` 81 | 82 | Tempura will parse your blocks' arguments into an object before calling your block definition: 83 | 84 | ```js 85 | let render = tempura.compile('...', { 86 | blocks: { 87 | person(args) { 88 | let { name, age } = args; 89 | return `${name} is ${age} years old.`; 90 | } 91 | } 92 | }); 93 | ``` 94 | 95 | Argument values may be any valid JavaScript data type and may also reference other variables: 96 | 97 | ```hbs 98 | {{#expect list}} 99 | 100 | {{#var count = list.length}} 101 | 102 | {{#if count > 0}} 103 | {{#table 104 | items=list total=count 105 | max=25 sticky=true }} 106 | {{/if}} 107 | ``` 108 | 109 | > **Note:** You can write Arrays and objects inline, but it can get messy! 110 | 111 | 112 | ## Invoking Blocks from Blocks 113 | 114 | Sometimes your custom block(s) may need to reference other custom blocks. For example, let's assume a `#foo` block wants to print a `#bar` block under some condition. In other for this to work, both the `foo` and `bar` blocks need to be defined. 115 | 116 | The naiive approach is to hoist helper function(s) and reference them in both block definintions: 117 | 118 | ```js 119 | function helper(value) { 120 | return `${value}`; 121 | } 122 | 123 | let blocks = { 124 | foo(args) { 125 | let output = ''; 126 | if (args.other) output += helper(args.other); 127 | return output + ''; 128 | }, 129 | bar(args) { 130 | return helper(args.name); 131 | } 132 | }; 133 | 134 | let render = tempura.compile('{{#foo other=123}} – {{#bar name="Alice"}}', { blocks }); 135 | render(); //=> "123Alice" 136 | ``` 137 | 138 | While this _does_ work and is a totally valid approach, tempura allows you to skip the helper functions & reference the other custom blocks directly. The above example can be rewritten as: 139 | 140 | ```js 141 | let options = { 142 | blocks: { 143 | // NOTE: `blocks` parameter === `options.blocks` object 144 | foo(args, blocks) { 145 | let output = ''; 146 | if (args.other) { 147 | // Call the `bar` method directly 148 | output += blocks.bar({ name: args.other }); 149 | } 150 | return output + ''; 151 | }, 152 | // NOTE: `blocks` parameter === `options.blocks` object 153 | bar(args, blocks) { 154 | return `${args.name}`; 155 | } 156 | } 157 | }; 158 | 159 | let render = tempura.compile('{{#foo other=123}} – {{#bar name="Alice"}}', options); 160 | render(); //=> "123Alice" 161 | ``` 162 | 163 | > **Important:** Notice that when the `foo` definition invoked `blocks.bar`, it had to construct a `{ name }` object to accommodate the `bar` definition's expected arguments. 164 | 165 | 166 | ## Recursive Blocks 167 | 168 | As shown in the previous section, block definitions can directly reference one another. However, blocks can also invoke _themselves_ recursively. 169 | 170 | > **Important** You must include your own exit condition(s) in order to avoid an infinite loop / `Maximum call stack` error! 171 | 172 | In this example, a `#loop` directive should print its current value until `0`: 173 | 174 | ```js 175 | let blocks = { 176 | loop(args, blocks) { 177 | let value = args.value; 178 | let output = String(value); 179 | if (value--) { 180 | output += " ~> " + blocks.loop({ value }, blocks); 181 | } 182 | return output; 183 | } 184 | } 185 | 186 | let render = tempura.compile('{{#loop value=3 }}', { blocks }); 187 | render(); //=> "3 ~> 2 ~> 1 ~> 0" 188 | ``` 189 | 190 | 191 | ## Compiler Blocks 192 | 193 | If you looked through the [TypeScript definitions](/src/index.d.ts), you may have noticed that custom blocks and the output from `tempura.compile` share the same `Compiler` interface. In other words, they both produce or expect a function with the `(data, blocks?) => Promise | string` signaure. 194 | 195 | This means that you can actually _use and compose_ `tempura.compile` functions within your `options.blocks` definitions! 196 | 197 | Let's rebuild the same example from [Invoking Blocks from Blocks](#invoking-blocks-from-blocks), but using `#foo` and `#bar` statements only: 198 | 199 | ```js 200 | // Declare `blocks` variable upfront, for reference. 201 | // This is needed so that `foo` and can see `bar` block exists. 202 | let blocks = { 203 | bar: tempura.compile(` 204 | {{#expect name}} 205 | {{ name }} 206 | `), 207 | }; 208 | 209 | blocks.foo = tempura.compile(` 210 | {{#expect other}} 211 | 212 | {{#if other}} 213 | {{#bar name=other }} 214 | {{/if}} 215 | 216 | `, { blocks }); 217 | 218 | let render = tempura.compile('{{#foo other=123}} – {{#bar name="Alice"}}', { blocks }); 219 | render(); //=> "123Alice" 220 | ``` 221 | 222 | 223 | ## Recursive Compiler Blocks 224 | 225 | As with normal [recursive blocks](#recursive-blocks), your custom block definition may still reference itself when using `tempura.compile` to produce a `Compiler`. 226 | 227 | The "trick" is to define an `options.blocks` object early so that the same object can be passed into the definition's `tempura.compile` call. Additionally, any directives must already exist as keys in this empty object. 228 | 229 | For example, the `loop` directive (from above) must be defined like so: 230 | 231 | ```js 232 | let blocks = { 233 | // PLACEHOLDER 234 | loop: null, 235 | }; 236 | 237 | // Define `loop`, with `blocks` reference 238 | blocks.loop = tempura.compile(` 239 | {{#expect value }} 240 | 241 | {{ value }} 242 | {{#if value-- }} 243 | ~> {{#loop value=value }} 244 | {{/if}} 245 | `, { blocks }); 246 | 247 | let render = tempura.compile('{{#loop value=3 }}', { blocks }); 248 | render(); //=> "3 ~> 2 ~> 1 ~> 0" 249 | ``` 250 | 251 | This may seem a little odd at first, but this needs to happen because when the `blocks.loop` definition is parsed, it needs to see a `loop` key inside the `options.blocks` object. During generation, the block's _key_ only needs to exist. The functional definition only needs to be set once the top-level `render` is invoked. 252 | -------------------------------------------------------------------------------- /docs/syntax.md: -------------------------------------------------------------------------------- 1 | # Syntax 2 | 3 | > A quick cheatsheet of the tempura template syntax. 4 | 5 | The tempura template syntax is _very similar_ to the [Handlebars](https://handlebarsjs.com/guide/) or [Mustache](https://mustache.github.io/#demo) template syntax.
In fact, they're more similar than they are different! 6 | 7 | > **General Notice** 8 | > Throughout this document, you'll notice that most examples include HTML tags or _produce_ HTML output. This is done only because HTML is a common target. Tempura templates ***do not*** need to use or produce HTML content – it only cares about its own template syntax! 9 | 10 | ## Overview 11 | 12 | Templates are (hopefully!) an easier way to write and maintain view logic. Nearly all template engines, including `tempura`, parse your template files and generate a function (per template) that includes all your view logic and desired output. 13 | 14 | Each of these functions is valid JavaScript and is waiting to accept an `input` object. Together, the input data and the render function produce the expected output. 15 | 16 | ## Expressions 17 | 18 | Values can be printed by wrapping content with the `{{` and `}}` characters. These can appear anywhere within your template, and typically reference a variable name, although they could wrap _any value_. 19 | 20 | When the template is evaluated, these expressions are replaced. For example: 21 | 22 | ```hbs 23 | {{#expect name}} 24 |

Hello, {{ name }}!

25 | ``` 26 | 27 | rendered with the input: 28 | 29 | ```js 30 | { name: 'world' } 31 | ``` 32 | 33 | produces the result: 34 | 35 | ```html 36 |

Hello, world!

37 | ``` 38 | 39 | Expression values can reference any defined variables. In this example, `name` was a simple string, but it could have been an object, an Array, or anything else! 40 | 41 | For example, this template: 42 | 43 | ```hbs 44 |

Items left: {{ items.length }}

45 | ``` 46 | 47 | with this input object: 48 | 49 | ```js 50 | { 51 | items: ["foo", "bar", "baz"] 52 | } 53 | ``` 54 | 55 | produces this output: 56 | 57 | ```html 58 |

Items left: 3

59 | ``` 60 | 61 | ### Escaped Values 62 | 63 | By default, all expressions are HTML-escaped. For example, if the value contains the `"`, `&`, or `<` characters, they'd be encoded into the `"`, `&`, and `<` sequences, respectively. 64 | 65 | > **Note:** The HTML-escape function can be customized via the [`escape`](/docs/api.md#optionsescape) option. 66 | 67 | It's generally recommended to use HTML-escaping unless you're certain that the expression's value does not contain any HTML characters; eg, it's a number or you just constructed it via [`#var`](#var). 68 | 69 | ### Raw Values 70 | 71 | To disable HTML-escaping for a value, you must use the triple-curly syntax; for example `{{{ value }}}`. 72 | 73 | When the triple-curly is used, the _raw value_ is printed as is. 74 | 75 | ```hbs 76 | {{! input }} 77 | escaped: {{ 'a & b " c < d' }} 78 | raw: {{{ 'a & b " c < d' }}} 79 | ``` 80 | 81 | ```html 82 | 83 | escaped: a & b " c < d 84 | raw: a & b " c < d 85 | ``` 86 | 87 | 88 | ## Comments 89 | 90 | It's generally recommended to include comments within your templates, especially as their complexity grows! 91 | 92 | Template comments will never appear in the rendered output. However, HTML comments are kept. 93 | 94 | ```hbs 95 | 96 | {{! template comments are removed }} 97 | {{! 98 | template comments 99 | can also be multi-line 100 | ...and are still removed 101 | !}} 102 |

hello world

103 | ``` 104 | 105 | ## Helpers 106 | 107 | The tempura syntax comes with few (but powerful) template helpers! 108 | 109 | Unlike value expressions, template helpers – or "blocks" – always use double-curly tags. This is because they're _not_ values, so there's no HTML-escape vs raw dilemma. The shorter, double-curly tag is used as a convenience. 110 | 111 | > **Note:** You may define your own helpers! See [Custom Blocks](/docs/blocks.md) for more detail. 112 | 113 | ### `#expect` 114 | 115 | A template needs to declare what variables it _expects_ to receive. In several UI frameworks, this is generally referred to as defining your "props". At the parser level, this prepares the `Compiler` so that it won't throw any `Uncaught ReferenceError`s during execution. 116 | 117 | > **Note:** You should hoist common variables via [`options.props`](/docs/api.md#optionsprops) to avoid repetitive `#expect` declarations. 118 | 119 | You may declare multiple variables within the same `#expect` block. You may also have multiple `#expect` blocks in the same file: 120 | 121 | ```hbs 122 | {{#expect name}} 123 | {{#expect foo, bar}} 124 | {{#expect 125 | hello, 126 | title, todos }} 127 | ``` 128 | 129 | In this snippet, the rest of the template file may safely use the `name`, `foo`, `bar`, `hello`, `title`, and `todos` variables. If any of these variables had been referenced _without_ being declared (either thru `#expect` or `options.props`), then a `ReferenceError` error would have been thrown at runtime. 130 | 131 | This is because tempura prefers to be strict by default. If, however, you would prefer to relax this condition, you may enable the [`loose`](/docs/api.md#optionsloose) option during any `tempura.compile` or `tempura.transform` calls. 132 | 133 | 134 | ### `#var` 135 | 136 | You may define new variables within your template and reference them throughout the file. Normal JavaScript scoping rules apply. 137 | 138 | These inline variables can evaluate any JavaScript and can reference other template variables. 139 | 140 | Only one variable can be defined per `#var` block. You may have any number of `#var` blocks in your template: 141 | 142 | ```hbs 143 | {{#expect todos}} 144 | {{#var numTotal = todos.length}} 145 | {{#var numDone = todos.filter(x => x.done).length}} 146 | {{#var numActive = numTotal = numDone}} 147 | 148 |

You have {{{ numActive }}} item(s) remaining

149 |

You have {{{ numTotal }}} item(s) in total

150 | ``` 151 | 152 | ### `#if`, `#elif`, `#else` 153 | 154 | Your control flow helpers! 155 | 156 | Just like JavaScript, use of `#elif` (short for `else if`) and `#else` are optional, but must always be associated with and follow an `#if` opener. Parenthesis around the `#if` and `#elif` conditions are optional: 157 | 158 | All `#if` blocks must be terminated by an `/if` block. 159 | 160 | ```hbs 161 | {{#expect age, isActive}} 162 | 163 | {{#if isActive}} 164 |

Good for you!

165 | {{/if}} 166 | 167 | {{#if isActive}} 168 |

Good for you!

169 | {{#else}} 170 |

How about 1 hour a week?

171 | {{/if}} 172 | 173 | {{#var senior = age >= 70}} 174 | 175 | {{#if senior && isActive}} 176 |

Wow, that's amazing!

177 | {{#elif isActive}} 178 |

Good for you!

179 | {{#elif senior}} 180 |

How about water aerobics?

181 | {{#else}} 182 |

How about kick boxing?

183 | {{/if}} 184 | ``` 185 | 186 | ### `#each` 187 | 188 | The `#each` block loops over an Array, passing through each value and index to the template expression(s) within the block. 189 | 190 | All `#each` blocks must be terminated by an `/each` block. 191 | 192 | ```hbs 193 | {{#expect items}} 194 | 195 | {{! only use the value }} 196 | {{#each items as item}} 197 |
198 | {{ item.title }} 199 | {{ item.text }} 200 |
201 | {{/each}} 202 | 203 | {{! use the value & index }} 204 | {{#each items as item,index }} 205 |
206 | {{ item.title }} 207 | {{ item.text }} 208 |
209 | {{/each}} 210 | ``` 211 | -------------------------------------------------------------------------------- /examples/basic.hbs: -------------------------------------------------------------------------------- 1 | {{#expect firstname, lastname, avatar, hobbies }} 2 | 3 | {{! compute the fullname value once }} 4 | {{#var fullname = firstname + ' ' + lastname }} 5 | 6 |
7 | {{{ fullname }}} 8 |
{{ fullname }}
9 |
10 | 11 | {{#if hobbies && hobbies.length}} 12 |

{{ fullname }} enjoys:

13 |
    14 | {{#each hobbies as hobby}} 15 |
  • {{ hobby }}
  • 16 | {{/each}} 17 |
18 | {{#else}} 19 |

{{ fullname }} has no hobbies, apparently

20 | {{/if}} 21 | -------------------------------------------------------------------------------- /examples/blocks.hbs: -------------------------------------------------------------------------------- 1 | {{#expect firstname, lastname, avatar }} 2 | 3 | {{#var list = ['oss', 'hiking', 'eating']; }} 4 | 5 |
6 | {{#h1 text="About Me" }} 7 |

Welcome to my profile

8 | {{#include 9 | src="basic.hbs" hobbies=list 10 | firstname=firstname 11 | lastname=lastname 12 | avatar=avatar 13 | }} 14 |

Cya~!

15 |
16 | -------------------------------------------------------------------------------- /examples/index.js: -------------------------------------------------------------------------------- 1 | const { join } = require('path'); 2 | const { readFileSync } = require('fs'); 3 | const { readFile } = require('fs/promises'); 4 | const { compile } = require('../dist'); 5 | 6 | const user = { 7 | firstname: 'Luke', 8 | lastname: 'Edwards', 9 | avatar: 'https://avatars.githubusercontent.com/u/5855893?v=4', 10 | }; 11 | 12 | const examples = [ 13 | 'basic.hbs', 14 | 'blocks.hbs', 15 | ]; 16 | 17 | // Define custom blocks 18 | // ~> Shared w/ all examples 19 | const blocks = { 20 | h1(args) { 21 | return `

${args.text}

`; 22 | }, 23 | include(args) { 24 | let { src, ...rest } = args; 25 | let render = Cache[src] || compile( 26 | readFileSync(file, 'utf8'), 27 | { blocks } 28 | ); 29 | return render(rest); 30 | }, 31 | }; 32 | 33 | const Cache = {}; 34 | 35 | (async function () { 36 | for (let name of examples) { 37 | let render = Cache[name]; 38 | 39 | if (!render) { 40 | let file = join(__dirname, name); 41 | let data = await readFile(file, 'utf8'); 42 | render = Cache[name] = compile(data, { blocks }); 43 | } 44 | console.log('\n\n>>>', name); 45 | console.log(render(user)); 46 | } 47 | })().catch(err => { 48 | console.error('Oops', err.stack); 49 | process.exitCode = 1; 50 | }); 51 | -------------------------------------------------------------------------------- /examples/index.ts: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import * as tempura from '../'; 3 | 4 | interface User { 5 | firstname: string; 6 | lastname: string; 7 | avatar: string; 8 | } 9 | 10 | const user: User = { 11 | firstname: 'Luke', 12 | lastname: 'Edwards', 13 | avatar: 'https://avatars.githubusercontent.com/u/5855893?v=4', 14 | }; 15 | 16 | // partial template #1 17 | const image: tempura.Compiler = tempura.compile(` 18 | {{#expect firstname, lastname, avatar}} 19 | {{ firstname }} {{ lastname }} 20 | `); 21 | 22 | // partial template #2 23 | const greet: tempura.Compiler = tempura.compile(` 24 |

Welcome, {{ firstname }}!

25 | `, { loose: true }); 26 | 27 | // main template / render function 28 | const render: tempura.Compiler = tempura.compile(` 29 | {{#expect firstname, lastname, avatar}} 30 |
31 | {{#image firstname=firstname lastname=lastname avatar=avatar }} 32 |
33 | {{#greet firstname=firstname }} 34 |

You have {{{ firstname.length }}} unread messages

35 | `, { 36 | async: false, 37 | blocks: { greet, image } 38 | }); 39 | 40 | let output = render(user); 41 | console.log(output); 42 | //=>
43 | //=> Luke Edwards 44 | //=>
45 | //=>

Welcome, Luke!

46 | //=>

You have 4 unread messages

47 | -------------------------------------------------------------------------------- /examples/worker/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /output 3 | -------------------------------------------------------------------------------- /examples/worker/esbuild.js: -------------------------------------------------------------------------------- 1 | require('esbuild').build({ 2 | bundle: true, 3 | entryPoints: ['src/index.js'], 4 | outfile: 'output/esbuild.js', 5 | format: 'esm', 6 | plugins: [ 7 | require('tempura/esbuild').transform({ 8 | // Options 9 | }) 10 | ] 11 | }); 12 | -------------------------------------------------------------------------------- /examples/worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "rollup": "rollup -c", 5 | "esbuild": "node esbuild" 6 | }, 7 | "devDependencies": { 8 | "@rollup/plugin-node-resolve": "13.0.0", 9 | "esbuild": "0.12.15", 10 | "rollup": "2.53.1", 11 | "tempura": "0.2.0" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /examples/worker/readme.md: -------------------------------------------------------------------------------- 1 | # Example: Worker + Bundler(s) 2 | 3 | This example demonstrates how to incorporate `tempura` within a Cloudflare Worker. However, this could be _any_ application and/or environemnt so long as a build step is required. 4 | 5 | Put differently, this example illustrates how tempura's esbuild and/or Rollup plugins can be used within a project, transforming your `*.hbs` template files into JavaScript functions that your built output can use. This means that **no `tempura` runtime code** appears in your built code – except the `tempura.esc` (189 bytes), but only if you use HTML-escaped sequences. 6 | 7 | ## Build Commands 8 | 9 | ***Setup*** 10 | 11 | ```sh 12 | $ npm install 13 | ``` 14 | 15 | All build configurations rely on the same `src/index.js` and `src/todos.hbs` source files. 16 | 17 | All builds will send their built output to the `output` directory. You may inspect the files' contents to see _what_ the tempura plugins do to the `todos.hbs` template. If you'd like to deploy to a live Cloudflare Worker, please follow the [Wrangler CLI](https://developers.cloudflare.com/workers/get-started/guide#7-configure-your-project-for-deployment) guide(s). 18 | 19 | 20 | ***Rollup** ([Live Demo](https://cloudflareworkers.com/#b77bd0114ed198f3601bf5650111e9bf:https://tutorial.cloudflareworkers.com))* 21 | 22 | The Rollup build is configured through the `rollup.config.js` file & can be run via: 23 | 24 | ```sh 25 | $ npm run rollup 26 | ``` 27 | 28 | ***esbuild** ([Live Demo](https://cloudflareworkers.com/#0910ad305f1fa9082b2ef8f2d7c50809:https://tutorial.cloudflareworkers.com))* 29 | 30 | An `esbuild` configuration is available in the `esbuild.js` file. In order to attach esbuild plugins, the programmatic approach must be used. It can be run via: 31 | 32 | ```sh 33 | $ npm run esbuild 34 | # or 35 | $ node esbuild.js 36 | ``` 37 | -------------------------------------------------------------------------------- /examples/worker/rollup.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | input: 'src/index.js', 3 | output: { 4 | format: 'esm', 5 | file: 'output/rollup.js', 6 | sourcemap: false, 7 | }, 8 | plugins: [ 9 | require('@rollup/plugin-node-resolve').default(), 10 | require('tempura/rollup').transform({ 11 | format: 'esm', 12 | }) 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /examples/worker/src/index.js: -------------------------------------------------------------------------------- 1 | // NOTE: Must be built! 2 | // $ npm run esbuild 3 | // OR 4 | // $ npm run rollup 5 | import Tasks from './todos.hbs'; 6 | 7 | /** 8 | * @param {Request} request 9 | * @returns {Promise} 10 | */ 11 | async function handle(request) { 12 | let items = []; 13 | 14 | try { 15 | // Make external HTTP request for JSON data. 16 | // NOTE: Data could come from Workers KV, for example. 17 | let res = await fetch('https://jsonplaceholder.typicode.com/todos'); 18 | items = await res.json(); 19 | } catch (err) { 20 | return new Response('Error fetching JSON data', { 21 | status: 500 22 | }); 23 | } 24 | 25 | // Invoke the `Tasks` renderer, which 26 | // is a `function` after build injection. 27 | let html = Tasks({ items }); 28 | 29 | return new Response(html, { 30 | status: 200, 31 | headers: { 32 | 'Content-Type': 'text/html;charset=utf8' 33 | } 34 | }); 35 | } 36 | 37 | // Initialize/Attach Worker 38 | addEventListener('fetch', event => { 39 | event.respondWith( 40 | handle(event.request) 41 | ); 42 | }); 43 | -------------------------------------------------------------------------------- /examples/worker/src/todos.hbs: -------------------------------------------------------------------------------- 1 | {{#expect items}} 2 | 3 | 4 | 5 | tempura | Demo: TODOs 6 | 7 | 16 | 17 | 18 |

TODOs

19 |
    20 | {{#if items.length > 0}} 21 | {{#each items as item}} 22 | 23 | ({{{ item.id }}}) {{ item.title }} 24 | 25 | {{/each}} 26 | {{#else}} 27 |
  • No TODO items! 🎉
  • 28 | {{/if}} 29 |
30 | 31 | 32 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Luke Edwards (lukeed.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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lukeed/tempura/8e67621995910e9e83fae3f7175c8cf5d55f7bc1/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tempura", 3 | "version": "0.4.1", 4 | "repository": "lukeed/tempura", 5 | "description": "A light, crispy, and delicious template engine", 6 | "module": "dist/index.mjs", 7 | "main": "dist/index.js", 8 | "types": "index.d.ts", 9 | "license": "MIT", 10 | "author": { 11 | "name": "Luke Edwards", 12 | "email": "luke.edwards05@gmail.com", 13 | "url": "https://lukeed.com" 14 | }, 15 | "engines": { 16 | "node": ">=10" 17 | }, 18 | "scripts": { 19 | "build": "node bin && bundt", 20 | "test": "uvu -r esm test" 21 | }, 22 | "files": [ 23 | "*.d.ts", 24 | "esbuild", 25 | "rollup", 26 | "dist" 27 | ], 28 | "exports": { 29 | ".": { 30 | "types": "./index.d.ts", 31 | "import": "./dist/index.mjs", 32 | "require": "./dist/index.js" 33 | }, 34 | "./esbuild": { 35 | "types": "./esbuild/index.d.ts", 36 | "import": "./esbuild/index.mjs", 37 | "require": "./esbuild/index.js" 38 | }, 39 | "./rollup": { 40 | "types": "./rollup/index.d.ts", 41 | "import": "./rollup/index.mjs", 42 | "require": "./rollup/index.js" 43 | }, 44 | "./package.json": "./package.json" 45 | }, 46 | "modes": { 47 | "default": "src/index.js", 48 | "esbuild": "src/esbuild.js", 49 | "rollup": "src/rollup.js" 50 | }, 51 | "keywords": [ 52 | "engine", 53 | "mustache", 54 | "handlebars", 55 | "template", 56 | "html", 57 | "hbs" 58 | ], 59 | "devDependencies": { 60 | "bundt": "1.1.5", 61 | "esm": "3.2.25", 62 | "uvu": "0.5.1" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |
2 | tempura 3 |
4 | 5 | 22 | 23 |
A light, crispy, and delicious template engine 🍤
24 | 25 | ## Features 26 | 27 | * **Extremely lightweight**
28 | _Everything is `1.26 kB` (gzip) – even less with tree-shaking!_ 29 | 30 | * **Super Performant**
31 | _Significantly [faster](#benchmarks) than the big names; and the small ones._ 32 | 33 | * **Familiar Syntax**
34 | _Tempura templates look great with Handlebars syntax highlighting._ 35 | 36 | * **Custom Directives**
37 | _Easily define [custom blocks](/docs/blocks.md), via the [API](/docs/api.md), to extend functionality._ 38 | 39 | ## Install 40 | 41 | ``` 42 | $ npm install --save tempura 43 | ``` 44 | 45 | ## Usage 46 | 47 | > Visit the [`/examples`](/examples) and [Syntax Cheatsheet](/docs/syntax.md) for more info! 48 | 49 | ***example.hbs*** 50 | 51 | ```hbs 52 | {{! expected props to receive }} 53 | {{#expect title, items }} 54 | 55 | {{! inline variables }} 56 | {{#var count = items.length }} 57 | {{#var suffix = count === 1 ? 'task' : 'tasks' }} 58 | 59 | {{#if count == 0}} 60 |

You're done! 🎉

61 | {{#else}} 62 |

You have {{{ count }}} {{{ suffix }}} remaining!

63 | 64 | {{#if count == 1}} 65 | Almost there! 66 | {{#elif count > 10}} 67 | ... you must be fried 😔 68 | {{#else}} 69 | You've got this 💪🏼 70 | {{/if}} 71 | 72 |
    73 | {{#each items as todo}} 74 |
  • {{ todo.text }}
  • 75 | {{/each}} 76 |
77 | {{/if}} 78 | ``` 79 | 80 | ***render.js*** 81 | 82 | ```js 83 | import { readFile } from 'fs/promises'; 84 | import { transform, compile } from 'tempura'; 85 | 86 | const template = await readFile('example.hbs', 'utf8'); 87 | 88 | // Transform the template into a function 89 | // NOTE: Produces a string; ideal for build/bundlers 90 | // --- 91 | 92 | let toESM = transform(template); 93 | console.log(typeof toESM); //=> "string" 94 | console.log(toESM); 95 | //=> `import{esc as $$1}from"tempura";export default function($$3,$$2){...}` 96 | 97 | let toCJS = transform(template, { format: 'cjs' }); 98 | console.log(typeof toCJS); //=> "string" 99 | console.log(toCJS); 100 | //=> `var $$1=require("tempura").esc;module.exports=function($$3,$$2){...}` 101 | 102 | 103 | // Convert the template into a live function 104 | // NOTE: Produces a `Function`; ideal for runtime/servers 105 | // --- 106 | 107 | let render = compile(template); 108 | console.log(typeof render); //=> "function" 109 | render({ 110 | title: 'Reminders', 111 | items: [ 112 | { id: 1, text: 'Feed the doggo' }, 113 | { id: 2, text: 'Buy groceries' }, 114 | { id: 3, text: 'Exercise, ok' }, 115 | ] 116 | }); 117 | //=> "

You have 3 tasks remaining!

\n" 118 | //=> + "You've got this 💪🏼\n\n" 119 | //=> + "
    \n" 120 | //=> + "
  • Feed the doggo
  • \n" 121 | //=> + "
  • Buy groceries
  • \n" 122 | //=> + "
  • Exercise, ok
  • \n" 123 | //=> + "
" 124 | ``` 125 | 126 | ## Syntax 127 | 128 | Please refer to the [Syntax Cheatsheet](/docs/syntax.md). 129 | 130 | 131 | ## API 132 | 133 | Visit the [API](/docs/api.md) and [Custom Blocks](/docs/blocks.md) for documentation. 134 | 135 | 136 | ## Benchmarks 137 | 138 | > Running via Node v14.15.13 139 | 140 | Please visit the [`/bench`](/bench) directory for complete, reproducible benchmarks. 141 | 142 | The following is a subset of the full results, presented without context. Again, please visit [`/bench`](/bench) for explanations, comparisons, and/or differences. 143 | 144 | ``` 145 | Benchmark: Render w/ raw values (no escape) 146 | pug x 34,847 ops/sec ±2.79% (93 runs sampled) 147 | handlebars x 6,700 ops/sec ±1.41% (92 runs sampled) 148 | ejs x 802 ops/sec ±0.54% (94 runs sampled) 149 | dot x 40,704 ops/sec ±3.08% (93 runs sampled) 150 | art-template x 39,839 ops/sec ±0.86% (90 runs sampled) 151 | tempura x 44,656 ops/sec ±0.42% (92 runs sampled) 152 | 153 | Benchmark: Render w/ escaped values 154 | pug x 2,800 ops/sec ±0.31% (95 runs sampled) 155 | handlebars x 733 ops/sec ±0.34% (94 runs sampled) 156 | ejs x 376 ops/sec ±0.17% (91 runs sampled) 157 | dot x 707 ops/sec ±0.15% (96 runs sampled) 158 | art-template x 2,707 ops/sec ±0.12% (96 runs sampled) 159 | tempura x 2,922 ops/sec ±0.31% (96 runs sampled) 160 | ``` 161 | 162 | ## License 163 | 164 | MIT © [Luke Edwards](https://lukeed.com) 165 | -------------------------------------------------------------------------------- /src/$index.js: -------------------------------------------------------------------------------- 1 | const ESCAPE = /[&"<]/g, CHARS = { 2 | '"': '"', 3 | '&': '&', 4 | '<': '<', 5 | }; 6 | 7 | import { gen } from './$utils'; 8 | 9 | export function esc(value) { 10 | value = (value == null) ? '' : '' + value; 11 | let last=ESCAPE.lastIndex=0, tmp=0, out=''; 12 | while (ESCAPE.test(value)) { 13 | tmp = ESCAPE.lastIndex - 1; 14 | out += value.substring(last, tmp) + CHARS[value[tmp]]; 15 | last = tmp + 1; 16 | } 17 | return out + value.substring(last); 18 | } 19 | 20 | export function compile(input, options={}) { 21 | return new (options.async ? (async()=>{}).constructor : Function)( 22 | '$$1', '$$2', '$$3', gen(input, options) 23 | ).bind(0, options.escape || esc, options.blocks); 24 | } 25 | 26 | export function transform(input, options={}) { 27 | return ( 28 | options.format === 'cjs' 29 | ? 'var $$1=require("tempura").esc;module.exports=' 30 | : 'import{esc as $$1}from"tempura";export default ' 31 | ) + ( 32 | options.async ? 'async ' : '' 33 | ) + 'function($$3,$$2){'+gen(input, options)+'}'; 34 | } 35 | -------------------------------------------------------------------------------- /src/$utils.d.ts: -------------------------------------------------------------------------------- 1 | import type { Options } from '.'; 2 | 3 | export function gen(input: string, options?: Options): string; 4 | -------------------------------------------------------------------------------- /src/$utils.js: -------------------------------------------------------------------------------- 1 | const ENDLINES = /[\r\n]+$/g; 2 | const CURLY = /{{{?\s*([\s\S]*?)\s*}}}?/g; 3 | const VAR = /(?:^|[-*+^|%/&=\s])([a-zA-Z$_][\w$]*)(?:(?=$|[-*+^|%/&=\s]))/g; 4 | const ARGS = /([a-zA-Z$_][^\s=]*)\s*=\s*((["`'])(?:(?=(\\?))\4.)*?\3|{[^}]*}|\[[^\]]*]|\S+)/g; 5 | 6 | // $$1 = escape() 7 | // $$2 = extra blocks 8 | // $$3 = template values 9 | export function gen(input, options) { 10 | options = options || {}; 11 | 12 | let char, num, action, tmp; 13 | let last = CURLY.lastIndex = 0; 14 | let wip='', txt='', match, inner; 15 | 16 | let extra=options.blocks||{}, stack=[]; 17 | let initials = new Set(options.props||[]); 18 | 19 | function close() { 20 | if (wip.length > 0) { 21 | txt += (txt ? 'x+=' : '=') + '`' + wip + '`;'; 22 | } else if (txt.length === 0) { 23 | txt = '="";' 24 | } 25 | wip = ''; 26 | } 27 | 28 | while (match = CURLY.exec(input)) { 29 | wip += input.substring(last, match.index).replace(ENDLINES, ''); 30 | last = match.index + match[0].length; 31 | 32 | inner = match[1].trim(); 33 | char = inner.charAt(0); 34 | 35 | if (char === '!') { 36 | // comment, continue 37 | } else if (char === '#') { 38 | close(); 39 | [, action, inner] = /^#\s*(\w[\w\d]+)\s*([^]*)/.exec(inner); 40 | 41 | if (action === 'expect') { 42 | inner.split(/[\n\r\s\t]*,[\n\r\s\t]*/g).forEach(key => { 43 | initials.add(key); 44 | }); 45 | } else if (action === 'var') { 46 | num = inner.indexOf('='); 47 | tmp = inner.substring(0, num++).trim(); 48 | inner = inner.substring(num).trim().replace(/[;]$/, ''); 49 | txt += `var ${tmp}=${inner};`; 50 | } else if (action === 'each') { 51 | num = inner.indexOf(' as '); 52 | stack.push(action); 53 | if (!~num) { 54 | txt += `for(var i=0,$$a=${inner};i<$$a.length;i++){`; 55 | } else { 56 | tmp = inner.substring(0, num).trim(); 57 | inner = inner.substring(num + 4).trim(); 58 | let [item, idx='i'] = inner.replace(/[()\s]/g, '').split(','); // (item, idx?) 59 | txt += `for(var ${idx}=0,${item},$$a=${tmp};${idx}<$$a.length;${idx}++){${item}=$$a[${idx}];`; 60 | } 61 | } else if (action === 'if') { 62 | txt += `if(${inner}){`; 63 | stack.push(action); 64 | } else if (action === 'elif') { 65 | txt += `}else if(${inner}){`; 66 | } else if (action === 'else') { 67 | txt += `}else{`; 68 | } else if (action in extra) { 69 | if (inner) { 70 | tmp = []; 71 | // parse arguments, `defer=true` -> `{ defer: true }` 72 | while (match = ARGS.exec(inner)) tmp.push(match[1] + ':' + match[2]); 73 | inner = tmp.length ? '{' + tmp.join() + '}' : ''; 74 | } 75 | inner = inner || '{}'; 76 | tmp = options.async ? 'await ' : ''; 77 | wip += '${' + tmp + '$$2.' + action + '(' + inner + ',$$2)}'; 78 | } else { 79 | throw new Error(`Unknown "${action}" block`); 80 | } 81 | } else if (char === '/') { 82 | action = inner.substring(1); 83 | inner = stack.pop(); 84 | close(); 85 | if (action === inner) txt += '}'; 86 | else throw new Error(`Expected to close "${inner}" block; closed "${action}" instead`); 87 | } else { 88 | if (match[0].charAt(2) === '{') wip += '${' + inner + '}'; // {{{ raw }}} 89 | else wip += '${$$1(' + inner + ')}'; 90 | if (options.loose) { 91 | while (tmp = VAR.exec(inner)) { 92 | initials.add(tmp[1]); 93 | } 94 | } 95 | } 96 | } 97 | 98 | if (stack.length > 0) { 99 | throw new Error(`Unterminated "${stack.pop()}" block`); 100 | } 101 | 102 | if (last < input.length) { 103 | wip += input.substring(last).replace(ENDLINES, ''); 104 | } 105 | 106 | close(); 107 | 108 | tmp = initials.size ? `{${ [...initials].join() }}=$$3,x` : ' x'; 109 | return `var${tmp + txt}return x`; 110 | } 111 | -------------------------------------------------------------------------------- /src/esbuild.d.ts: -------------------------------------------------------------------------------- 1 | import type { Args, Options } from 'tempura'; 2 | import type { Plugin } from 'esbuild'; 3 | 4 | export function transform(options?: Options & { 5 | /** 6 | * Pattern to match 7 | * @default /\.hbs$/ 8 | */ 9 | filter?: RegExp; 10 | /** 11 | * Output format. 12 | * Defaults to esbuild value. 13 | */ 14 | format?: 'esm' | 'cjs'; 15 | }): Plugin; 16 | 17 | export function compile(options: Options & { 18 | /** 19 | * Pattern to match 20 | * @default /\.hbs$/ 21 | */ 22 | filter?: RegExp; 23 | /** 24 | * The input argument(s) to provide each template. 25 | * Function receives the template's file path. 26 | */ 27 | values?: (file: string) => Promise | Args | void, 28 | /** 29 | * Optional minifier function. 30 | * Runs *after* the template has been rendered. 31 | */ 32 | minify?: (result: string) => string, 33 | }): Plugin; 34 | -------------------------------------------------------------------------------- /src/esbuild.js: -------------------------------------------------------------------------------- 1 | import { readFile } from 'fs/promises'; 2 | import * as tempura from 'tempura'; 3 | 4 | function toErrors(err) { 5 | return [{ 6 | detail: err, 7 | text: err.message, 8 | // TODO: parse lines 9 | // location: { file }, 10 | }]; 11 | } 12 | 13 | export function transform(options) { 14 | let { filter, format, ...config } = options || {}; 15 | 16 | filter = filter || /\.hbs$/; 17 | 18 | return { 19 | name: 'tempura', 20 | setup(build) { 21 | // respect `format` or rely on `esbuild` config 22 | config.format = format || build.initialOptions.format; 23 | 24 | build.onLoad({ filter }, async (args) => { 25 | let source = await readFile(args.path, 'utf8'); 26 | let output = { loader: 'js' }; 27 | 28 | try { 29 | output.contents = tempura.transform(source, config); 30 | } catch (err) { 31 | output.errors = toErrors(err); // args.path 32 | } 33 | 34 | return output; 35 | }); 36 | } 37 | } 38 | } 39 | 40 | export function compile(options) { 41 | let { filter, values, minify, ...config } = options || {}; 42 | 43 | filter = filter || /\.hbs$/; 44 | 45 | if (values && typeof values !== 'function') { 46 | throw new Error('Must be a function: `options.values`'); 47 | } 48 | 49 | if (minify && typeof minify !== 'function') { 50 | throw new Error('Must be a function: `options.minify`'); 51 | } 52 | 53 | return { 54 | name: 'tempura', 55 | setup(build) { 56 | build.onLoad({ filter }, async (args) => { 57 | let source = await readFile(args.path, 'utf8'); 58 | let output = { loader: 'text' }; 59 | 60 | try { 61 | let input = values && await values(args.path); 62 | let render = tempura.compile(source, config); 63 | 64 | let result = await render(input || {}); 65 | if (minify) result = minify(result); 66 | 67 | output.contents = result; 68 | } catch (err) { 69 | output.errors = toErrors(err); // args.path 70 | } 71 | 72 | return output; 73 | }); 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | export type Args = Record; 2 | export type Blocks = Record>; 3 | 4 | export type Compiler = 5 | | ((data: T, blocks?: Blocks) => Promise|string) 6 | | ((data?: T) => Promise|string) 7 | 8 | export interface Options { 9 | loose?: boolean; 10 | props?: string[]; 11 | blocks?: Blocks; 12 | async?: boolean; 13 | } 14 | 15 | export function esc(value: string|unknown): string; 16 | export function transform(input: string, options?: Options & { format?: 'esm' | 'cjs' }): string; 17 | 18 | type CompileOptions = Options & { escape?: typeof esc }; 19 | export function compile(input: string, options?: CompileOptions & { async: true }): (data?: T, blocks?: Blocks) => Promise; 20 | export function compile(input: string, options?: CompileOptions & { async?: false }): (data?: T, blocks?: Blocks) => string; 21 | export function compile(input: string, options?: CompileOptions): Compiler; 22 | -------------------------------------------------------------------------------- /src/rollup.d.ts: -------------------------------------------------------------------------------- 1 | import type { Args, Options } from 'tempura'; 2 | import type { Plugin } from 'rollup'; 3 | 4 | export function transform(options?: Options & { 5 | /** 6 | * Pattern to match 7 | * @default /\.hbs$/ 8 | */ 9 | filter?: RegExp; 10 | /** 11 | * Output format. 12 | * Defaults to esbuild value. 13 | */ 14 | format?: 'esm' | 'cjs'; 15 | }): Plugin; 16 | 17 | export function compile(options: Options & { 18 | /** 19 | * Pattern to match 20 | * @default /\.hbs$/ 21 | */ 22 | filter?: RegExp; 23 | /** 24 | * The input argument(s) to provide each template. 25 | * Function receives the template's file path. 26 | */ 27 | values?: (file: string) => Promise | Args | void, 28 | /** 29 | * Optional minifier function. 30 | * Runs *after* the template has been rendered. 31 | */ 32 | minify?: (result: string) => string, 33 | }): Plugin; 34 | -------------------------------------------------------------------------------- /src/rollup.js: -------------------------------------------------------------------------------- 1 | import * as tempura from 'tempura'; 2 | 3 | export function transform(options) { 4 | let { filter, format, ...config } = options || {}; 5 | 6 | filter = filter || /\.hbs$/; 7 | 8 | return { 9 | name: 'tempura', 10 | transform(source, file) { 11 | if (!filter.test(file)) return; 12 | return tempura.transform(source, config); 13 | }, 14 | } 15 | } 16 | 17 | export function compile(options) { 18 | let { filter, values, minify, ...config } = options || {}; 19 | 20 | filter = filter || /\.hbs$/; 21 | 22 | if (values && typeof values !== 'function') { 23 | throw new Error('Must be a function: `options.values`'); 24 | } 25 | 26 | if (minify && typeof minify !== 'function') { 27 | throw new Error('Must be a function: `options.minify`'); 28 | } 29 | 30 | return { 31 | name: 'tempura', 32 | async transform(source, file) { 33 | if (!filter.test(file)) return; 34 | 35 | let input = values && await values(file); 36 | let render = tempura.compile(source, config); 37 | 38 | let result = await render(input || {}); 39 | if (minify) result = minify(result); 40 | 41 | return 'export default ' + JSON.stringify(result); 42 | }, 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/syntax.hbs: -------------------------------------------------------------------------------- 1 | {{#expect title, items, isActive}} 2 | {{#expect 3 | hello, world 4 | }} 5 | 6 |

{{title}}

7 | 8 | {{#var foo = 'world'}} 9 | {{! comment }} 10 |
{{ foo }}
11 | 12 |
    13 | {{#each items as (item, idx) }} 14 |
  • {{ item }} @ {{idx}}
  • 15 | {{/each}} 16 |
17 | 18 | {{#if isActive}} 19 |

active

20 | {{!-- TODO --}} 21 | {{#include src='file.hbs' foo=bar value=123 }} 22 | {{#else}} 23 |

inactive

24 | {{/if}} 25 | 26 | {{#if foo == 1}} 27 | {{#elif foo == 2}} 28 | {{#else}} 29 | {{/if}} 30 | -------------------------------------------------------------------------------- /test/$index.js: -------------------------------------------------------------------------------- 1 | import { suite } from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | import * as tempura from '../src/$index'; 4 | 5 | // --- 6 | 7 | const transform = suite('transform'); 8 | 9 | transform('should be a function', () => { 10 | assert.type(tempura.transform, 'function'); 11 | }); 12 | 13 | transform('should return a string', () => { 14 | let output = tempura.transform(''); 15 | assert.type(output, 'string'); 16 | }); 17 | 18 | transform('should include "esc" import via "tempura" module', () => { 19 | let output = tempura.transform(''); 20 | assert.match(output, /\{esc(\:| as )/); 21 | assert.match(output, '"tempura"'); 22 | }); 23 | 24 | transform('format :: ESM (default)', () => { 25 | let output = tempura.transform(''); 26 | assert.match(output, 'import{esc as $$1}from"tempura";'); 27 | assert.match(output, ';export default function($$3,$$2){'); 28 | assert.ok(output.endsWith('}'), 'close function'); 29 | }); 30 | 31 | transform('format :: ESM :: async', () => { 32 | let output = tempura.transform('', { async: true }); 33 | assert.match(output, ';export default async function($$3,$$2){'); 34 | }); 35 | 36 | transform('format :: CommonJS', () => { 37 | let output = tempura.transform('', { format: 'cjs' }); 38 | assert.match(output, 'var $$1=require("tempura").esc;'); 39 | assert.match(output, ';module.exports=function($$3,$$2){'); 40 | assert.ok(output.endsWith('}'), 'close function'); 41 | }); 42 | 43 | transform('format :: CommonJS :: async', () => { 44 | let output = tempura.transform('', { format: 'cjs', async: true }); 45 | assert.match(output, ';module.exports=async function($$3,$$2){'); 46 | }); 47 | 48 | transform('should bubble parsing errors', () => { 49 | try { 50 | tempura.transform('{{#if foo}}stop'); 51 | assert.unreachable('should have thrown'); 52 | } catch (err) { 53 | assert.instance(err, Error); 54 | assert.is(err.message, 'Unterminated "if" block'); 55 | } 56 | }); 57 | 58 | transform.run(); 59 | 60 | // --- 61 | 62 | const compile = suite('compile'); 63 | 64 | compile('should be a function', () => { 65 | assert.type(tempura.compile, 'function'); 66 | }); 67 | 68 | compile('should return a function', () => { 69 | let output = tempura.compile(''); 70 | assert.type(output, 'function'); 71 | }); 72 | 73 | compile('should produce valid output :: raw', () => { 74 | let output = tempura.compile(` 75 | {{#expect value}} 76 | {{#if value.length > 10}} 77 | "{{{ value }}}" is more than 10 characters 78 | {{#else}} 79 | "{{{ value }}}" is too short 80 | {{/if}} 81 | `); 82 | 83 | assert.is( 84 | output({ value: 'howdy' }).replace(/[\r\n\t]+/g, ''), 85 | '"howdy" is more than 10 characters' 86 | ); 87 | 88 | assert.is( 89 | output({ value: 'aaa' }).replace(/[\r\n\t]+/g, ''), 90 | '"aaa" is too short' 91 | ); 92 | }); 93 | 94 | compile('should produce valid output :: escape', () => { 95 | let output = tempura.compile(` 96 | {{#expect value}} 97 | {{#if value.length > 10}} 98 | "{{ value }}" is more than 10 characters 99 | {{#else}} 100 | "{{ value }}" is too short 101 | {{/if}} 102 | `); 103 | 104 | assert.is( 105 | output({ value: 'howdy' }).replace(/[\r\n\t]+/g, ''), 106 | '"<b>howdy</b>" is more than 10 characters' 107 | ); 108 | 109 | assert.is( 110 | output({ value: 'aaa' }).replace(/[\r\n\t]+/g, ''), 111 | '"<b>aaa</b>" is too short' 112 | ); 113 | }); 114 | 115 | compile('should allow custom `escape` option :: {{value}}', () => { 116 | let output = tempura.compile(` 117 | {{#expect value}} 118 | value is "{{ value }}" 119 | `, { 120 | escape(val) { 121 | return val.replace('foo', 'bar'); 122 | } 123 | }); 124 | 125 | assert.is( 126 | output({ value: 'foobar' }).replace(/[\r\n\t]+/g, ''), 127 | 'value is "barbar"' 128 | ); 129 | }); 130 | 131 | compile('should allow custom `escape` option :: {{{ value }}}', () => { 132 | let output = tempura.compile(` 133 | {{#expect value}} 134 | value is "{{{ value }}}" 135 | `, { 136 | escape(val) { 137 | return val.replace('foo', 'bar'); 138 | } 139 | }); 140 | 141 | assert.is( 142 | output({ value: 'foobar' }).replace(/[\r\n\t]+/g, ''), 143 | 'value is "foobar"' 144 | ); 145 | }); 146 | 147 | compile('should bubble parsing errors', () => { 148 | try { 149 | tempura.compile('{{#if foo}}stop'); 150 | assert.unreachable('should have thrown'); 151 | } catch (err) { 152 | assert.instance(err, Error); 153 | assert.is(err.message, 'Unterminated "if" block'); 154 | } 155 | }); 156 | 157 | compile('should create `async` function', async () => { 158 | let delta; 159 | let sleep = ms => new Promise(r => setTimeout(r, ms)); 160 | let normalize = x => x.replace(/[\r\n\t]+/g, ''); 161 | 162 | async function delay({ wait }) { 163 | let x = Date.now(); 164 | 165 | await sleep(wait); 166 | delta = Date.now() - x; 167 | return `~> waited ${wait}ms!`; 168 | } 169 | 170 | let render = tempura.compile(` 171 | {{#expect ms}} 172 | {{#delay wait=ms }} 173 | `, { 174 | async: true, 175 | blocks: { delay } 176 | }); 177 | 178 | assert.instance(render, Function); 179 | assert.instance(render, delay.constructor); 180 | 181 | assert.is( 182 | Object.prototype.toString.call(render), 183 | '[object AsyncFunction]' 184 | ); 185 | 186 | let foo = await render({ ms: 100 }); 187 | assert.is(normalize(foo), '~> waited 100ms!'); 188 | assert.ok(delta > 99 && delta < 110); 189 | 190 | let bar = await render({ ms: 30 }); 191 | assert.is(normalize(bar), '~> waited 30ms!'); 192 | assert.ok(delta > 29 && delta < 35); 193 | }); 194 | 195 | compile('should allow `blocks` to call other blocks', () => { 196 | let blocks = { 197 | hello(args, blocks) { 198 | let output = `"${args.name}"`; 199 | // Always invoke the `foo` block 200 | output += blocks.foo({ value: 123 }); 201 | // Calls itself; recursive block 202 | if (args.other) output += blocks.hello({ name: args.other }, blocks); 203 | return output; 204 | }, 205 | foo(args) { 206 | return `${args.value}`; 207 | } 208 | }; 209 | 210 | let render = tempura.compile('{{#hello name="world" other="there"}}', { blocks }); 211 | 212 | assert.is( 213 | render(), 214 | `"world"123"there"123` 215 | ); 216 | }); 217 | 218 | compile('should allow `Compiler` output as blocks', () => { 219 | let blocks = { 220 | // initialize foo 221 | // ~> does NOT use custom blocks 222 | foo: tempura.compile(` 223 | {{#expect age}} 224 | {{#if age > 100}} 225 |

centurion

226 | {{#else}} 227 |

youngin

228 | {{/if}} 229 | `), 230 | 231 | // initial hello 232 | // ~> placeholder; because self-references 233 | hello: null, 234 | }; 235 | 236 | blocks.hello = tempura.compile(` 237 | {{#expect name, other}} 238 | 239 | "{{ name }}" 240 | {{#foo age=123}} 241 | 242 | {{#if other}} 243 | {{#hello name=other}} 244 | {{/if}} 245 | `, { blocks }); 246 | 247 | let normalize = x => x.replace(/[\r\n\t]+/g, ''); 248 | let render = tempura.compile('{{#hello name="world" other="there"}}', { blocks }); 249 | 250 | assert.is( 251 | normalize(render()), 252 | `"world"

centurion

"there"

centurion

` 253 | ); 254 | }); 255 | 256 | compile.run(); 257 | 258 | // --- 259 | 260 | const esc = suite('esc'); 261 | 262 | esc('should be a function', () => { 263 | assert.type(tempura.esc, 'function'); 264 | }); 265 | 266 | esc('should convert non-string inputs to string', () => { 267 | assert.is(tempura.esc(), ''); 268 | assert.is(tempura.esc(null), ''); 269 | 270 | assert.is(tempura.esc(false), 'false'); 271 | assert.is(tempura.esc(123), '123'); 272 | assert.is(tempura.esc(0), '0'); 273 | 274 | assert.equal(tempura.esc([1, 2, 3]), '1,2,3'); 275 | assert.equal(tempura.esc({ foo: 1 }), '[object Object]'); 276 | }); 277 | 278 | esc('should prevent xss scripting in array', () => { 279 | let output = tempura.esc(['']); 280 | assert.is(output, '<img src=x onerror="alert(1)" />'); 281 | }); 282 | 283 | esc('should return string from string input', () => { 284 | assert.type(tempura.esc(''), 'string'); 285 | assert.type(tempura.esc('foobar'), 'string'); 286 | }); 287 | 288 | esc('should escape `<` character', () => { 289 | assert.is( 290 | tempura.esc('here: <'), 291 | 'here: <' 292 | ); 293 | }); 294 | 295 | esc('should escape `"` character', () => { 296 | assert.is( 297 | tempura.esc('here: "'), 298 | 'here: "' 299 | ); 300 | }); 301 | 302 | esc('should escape `&` character', () => { 303 | assert.is( 304 | tempura.esc('here: &'), 305 | 'here: &' 306 | ); 307 | }); 308 | 309 | esc('should escape all target characters in a string', () => { 310 | assert.is( 311 | tempura.esc('&&& <<< """'), 312 | '&&& <<< """' 313 | ); 314 | }); 315 | 316 | esc('should reset state on same input string', () => { 317 | let input = '"hello"'; 318 | 319 | assert.is( 320 | tempura.esc(input), 321 | '<foo>"hello"</foo>' 322 | ); 323 | 324 | assert.is( 325 | tempura.esc(input), 326 | '<foo>"hello"</foo>', 327 | '~> repeat' 328 | ); 329 | }); 330 | 331 | esc.run(); 332 | -------------------------------------------------------------------------------- /test/$utils.js: -------------------------------------------------------------------------------- 1 | import { suite } from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | import { gen } from '../src/$utils'; 4 | 5 | const API = suite('API'); 6 | 7 | API('should be a function', () => { 8 | assert.type(gen, 'function'); 9 | }); 10 | 11 | API('should return a string', () => { 12 | assert.type(gen(''), 'string'); 13 | }); 14 | 15 | API('should throw if no input', () => { 16 | assert.throws(gen); 17 | }); 18 | 19 | API.run(); 20 | 21 | // --- 22 | 23 | const values = suite('{{ values }}'); 24 | 25 | values('{{ value }}', () => { 26 | assert.is( 27 | gen('{{ value }}'), 28 | 'var x=`${$$1(value)}`;return x' 29 | ); 30 | 31 | assert.is( 32 | gen('{{value }}'), 33 | 'var x=`${$$1(value)}`;return x' 34 | ); 35 | 36 | assert.is( 37 | gen('{{ value}}'), 38 | 'var x=`${$$1(value)}`;return x' 39 | ); 40 | 41 | assert.is( 42 | gen('{{value}}'), 43 | 'var x=`${$$1(value)}`;return x' 44 | ); 45 | }); 46 | 47 | values('{{ foo.bar }}', () => { 48 | assert.is( 49 | gen('{{ foo.bar }}'), 50 | 'var x=`${$$1(foo.bar)}`;return x' 51 | ); 52 | }); 53 | 54 | values('{{ foo["bar"] }}', () => { 55 | assert.is( 56 | gen('{{ foo["bar"] }}'), 57 | 'var x=`${$$1(foo["bar"])}`;return x' 58 | ); 59 | }); 60 | 61 | values('

{{ foo.bar }} ...

', () => { 62 | assert.is( 63 | gen('

{{ foo.bar }} howdy

'), 64 | 'var x=`

${$$1(foo.bar)} howdy

`;return x' 65 | ); 66 | }); 67 | 68 | values.run(); 69 | 70 | // --- 71 | 72 | const raws = suite('{{{ raw }}}'); 73 | 74 | raws('{{ value }}', () => { 75 | assert.is( 76 | gen('{{{ value }}}'), 77 | 'var x=`${value}`;return x' 78 | ); 79 | 80 | assert.is( 81 | gen('{{{value }}}'), 82 | 'var x=`${value}`;return x' 83 | ); 84 | 85 | assert.is( 86 | gen('{{{ value}}}'), 87 | 'var x=`${value}`;return x' 88 | ); 89 | 90 | assert.is( 91 | gen('{{{value}}}'), 92 | 'var x=`${value}`;return x' 93 | ); 94 | }); 95 | 96 | raws('{{{ foo.bar }}}', () => { 97 | assert.is( 98 | gen('{{{ foo.bar }}}'), 99 | 'var x=`${foo.bar}`;return x' 100 | ); 101 | }); 102 | 103 | raws('{{{ foo["bar"] }}}', () => { 104 | assert.is( 105 | gen('{{{ foo["bar"] }}}'), 106 | 'var x=`${foo["bar"]}`;return x' 107 | ); 108 | }); 109 | 110 | raws('

{{{ foo.bar }}} ...

', () => { 111 | assert.is( 112 | gen('

{{{ foo.bar }}} howdy

'), 113 | 'var x=`

${foo.bar} howdy

`;return x' 114 | ); 115 | }); 116 | 117 | raws.run(); 118 | 119 | // --- 120 | 121 | const expect = suite('#expect'); 122 | 123 | expect('{{#expect foo,bar}}', () => { 124 | assert.is( 125 | gen('{{#expect foo,bar}}'), 126 | 'var{foo,bar}=$$3,x="";return x' 127 | ); 128 | 129 | assert.is( 130 | gen('{{#expect foo , bar}}'), 131 | 'var{foo,bar}=$$3,x="";return x' 132 | ); 133 | 134 | assert.is( 135 | gen('{{#expect\n\tfoo ,bar}}'), 136 | 'var{foo,bar}=$$3,x="";return x' 137 | ); 138 | }); 139 | 140 | expect('{{#expect foobar}}', () => { 141 | assert.is( 142 | gen('{{#expect foobar}}'), 143 | 'var{foobar}=$$3,x="";return x' 144 | ); 145 | 146 | assert.is( 147 | gen('{{#expect \n foobar\n}}'), 148 | 'var{foobar}=$$3,x="";return x' 149 | ); 150 | }); 151 | 152 | expect.run(); 153 | 154 | // --- 155 | 156 | const control = suite('#if'); 157 | 158 | control('{{#if isActive}}...{{/if}}', () => { 159 | assert.is( 160 | gen('{{#if isActive}}

yes

{{/if}}'), 161 | 'var x="";if(isActive){x+=`

yes

`;}return x' 162 | ); 163 | }); 164 | 165 | control('{{#if foo.bar}}...{{#else}}...{{/if}}', () => { 166 | assert.is( 167 | gen('{{#if foo.bar}}

yes

{{#else}}

no {{ way }}

{{/if}}'), 168 | 'var x="";if(foo.bar){x+=`

yes

`;}else{x+=`

no ${$$1(way)}

`;}return x' 169 | ); 170 | }); 171 | 172 | control('{{#if foo == 0}}...{{#else}}...{{/if}}', () => { 173 | assert.is( 174 | gen('{{#if foo == 0}}

zero

{{#else}}

not zero

{{/if}}'), 175 | 'var x="";if(foo == 0){x+=`

zero

`;}else{x+=`

not zero

`;}return x' 176 | ); 177 | }); 178 | 179 | control('{{#if isActive}}...{{#elif isMuted}}...{{#else}}...{{/if}}', () => { 180 | assert.is( 181 | gen('{{#if isActive}}

active

{{#elif isMuted}}

muted

{{#else}}

inactive

{{/if}}'), 182 | 'var x="";if(isActive){x+=`

active

`;}else if(isMuted){x+=`

muted

`;}else{x+=`

inactive

`;}return x' 183 | ); 184 | }); 185 | 186 | control('{{#if isActive}}...{{#elif isMuted}}...{{/if}}', () => { 187 | assert.is( 188 | gen('{{#if isActive }}

active

{{#elif isMuted}}

muted

{{/if}}'), 189 | 'var x="";if(isActive){x+=`

active

`;}else if(isMuted){x+=`

muted

`;}return x' 190 | ); 191 | }); 192 | 193 | control.run(); 194 | 195 | // --- 196 | 197 | const vars = suite('#vars'); 198 | 199 | vars('{{#var foo = "world" }}', () => { 200 | assert.is( 201 | gen('{{#var foo = "world"}}

hello {{ foo }}

'), 202 | 'var x="";var foo="world";x+=`

hello ${$$1(foo)}

`;return x' 203 | ); 204 | 205 | assert.is( 206 | gen('{{#var foo = "world";}}

hello {{ foo }}

'), 207 | 'var x="";var foo="world";x+=`

hello ${$$1(foo)}

`;return x' 208 | ); 209 | }); 210 | 211 | vars('{{#var foo = 1+2 }}', () => { 212 | assert.is( 213 | gen('{{#var foo = 1+2}}

hello {{ foo }}

'), 214 | 'var x="";var foo=1+2;x+=`

hello ${$$1(foo)}

`;return x' 215 | ); 216 | 217 | assert.is( 218 | gen('{{#var foo = 1+2;}}

hello {{ foo }}

'), 219 | 'var x="";var foo=1+2;x+=`

hello ${$$1(foo)}

`;return x' 220 | ); 221 | }); 222 | 223 | vars('{{#var foo = {...} }}', () => { 224 | assert.is( 225 | gen('{{#var name = { first: "luke" } }}

hello {{ name.first }}

'), 226 | 'var x="";var name={ first: "luke" };x+=`

hello ${$$1(name.first)}

`;return x' 227 | ); 228 | 229 | assert.is( 230 | gen('{{#var name = { first:"luke" }; }}

hello {{ name.first }}

'), 231 | 'var x="";var name={ first:"luke" };x+=`

hello ${$$1(name.first)}

`;return x' 232 | ); 233 | }); 234 | 235 | vars('{{#var foo = [...] }}', () => { 236 | assert.is( 237 | gen('{{#var name = ["luke"] }}

hello {{ name[0] }}

'), 238 | 'var x="";var name=["luke"];x+=`

hello ${$$1(name[0])}

`;return x' 239 | ); 240 | 241 | assert.is( 242 | gen('{{#var name = ["luke"]; }}

hello {{ name[0] }}

'), 243 | 'var x="";var name=["luke"];x+=`

hello ${$$1(name[0])}

`;return x' 244 | ); 245 | 246 | assert.is( 247 | gen('{{#var name = ["luke"]; }}

hello {{{ name[0] }}}

'), 248 | 'var x="";var name=["luke"];x+=`

hello ${name[0]}

`;return x' 249 | ); 250 | }); 251 | 252 | vars('{{#var foo = truthy(bar) }}', () => { 253 | assert.is( 254 | gen('{{#var foo = truthy(bar)}}{{#if foo != 0}}

yes

{{/if}}'), 255 | 'var x="";var foo=truthy(bar);if(foo != 0){x+=`

yes

`;}return x' 256 | ); 257 | 258 | assert.is( 259 | gen('{{#var foo = truthy(bar); }}{{#if foo != 0}}

yes

{{ /if }}'), 260 | 'var x="";var foo=truthy(bar);if(foo != 0){x+=`

yes

`;}return x' 261 | ); 262 | }); 263 | 264 | vars.run(); 265 | 266 | // --- 267 | 268 | const comments = suite('!comments'); 269 | 270 | comments('{{! hello }}', () => { 271 | assert.is( 272 | gen('{{! hello }}'), 273 | 'var x="";return x' 274 | ); 275 | 276 | assert.is( 277 | gen('{{!hello}}'), 278 | 'var x="";return x' 279 | ); 280 | }); 281 | 282 | comments('{{! "hello world" }}', () => { 283 | assert.is( 284 | gen('{{! "hello world" }}'), 285 | 'var x="";return x' 286 | ); 287 | 288 | assert.is( 289 | gen('{{!"hello world"}}'), 290 | 'var x="";return x' 291 | ); 292 | }); 293 | 294 | comments.run(); 295 | 296 | // --- 297 | 298 | const each = suite('#each'); 299 | 300 | each('{{#each items}}...{{/each}}', () => { 301 | assert.is( 302 | gen('{{#each items}}

hello

{{/each}}'), 303 | 'var x="";for(var i=0,$$a=items;i<$$a.length;i++){x+=`

hello

`;}return x' 304 | ); 305 | }); 306 | 307 | each('{{#each items as item}}...{{/each}}', () => { 308 | assert.is( 309 | gen('{{#each items as item}}

hello {{item.name}}

{{/each}}'), 310 | 'var x="";for(var i=0,item,$$a=items;i<$$a.length;i++){item=$$a[i];x+=`

hello ${$$1(item.name)}

`;}return x' 311 | ); 312 | 313 | assert.is( 314 | gen('{{#each items as (item) }}

hello {{item.name}}

{{/each}}'), 315 | 'var x="";for(var i=0,item,$$a=items;i<$$a.length;i++){item=$$a[i];x+=`

hello ${$$1(item.name)}

`;}return x' 316 | ); 317 | 318 | assert.is( 319 | gen('{{#each items as (item) }}

hello {{{item.name}}}

{{/each}}'), 320 | 'var x="";for(var i=0,item,$$a=items;i<$$a.length;i++){item=$$a[i];x+=`

hello ${item.name}

`;}return x' 321 | ); 322 | }); 323 | 324 | each('{{#each items as (item,idx)}}...{{/each}}', () => { 325 | assert.is( 326 | gen('
    {{#each items as (item,idx)}}
  • hello {{item.name}} (#{{ idx }})
  • {{/each}}
'), 327 | 'var x=`
    `;for(var idx=0,item,$$a=items;idx<$$a.length;idx++){item=$$a[idx];x+=`
  • hello ${$$1(item.name)} (#${$$1(idx)})
  • `;}x+=`
`;return x' 328 | ); 329 | 330 | assert.is( 331 | gen('
    {{#each items as (item, idx) }}
  • hello {{item.name}} (#{{ idx }})
  • {{/each}}
'), 332 | 'var x=`
    `;for(var idx=0,item,$$a=items;idx<$$a.length;idx++){item=$$a[idx];x+=`
  • hello ${$$1(item.name)} (#${$$1(idx)})
  • `;}x+=`
`;return x' 333 | ); 334 | 335 | assert.is( 336 | gen('
    {{#each items as (item, idx) }}
  • hello {{item.name}} (#{{{ idx }}})
  • {{/each}}
'), 337 | 'var x=`
    `;for(var idx=0,item,$$a=items;idx<$$a.length;idx++){item=$$a[idx];x+=`
  • hello ${$$1(item.name)} (#${idx})
  • `;}x+=`
`;return x' 338 | ); 339 | }); 340 | 341 | each('{{#each items as item, idx}}...{{/each}}', () => { 342 | assert.is( 343 | gen('
    {{#each items as item,idx}}
  • hello {{item.name}} (#{{ idx }})
  • {{/each}}
'), 344 | 'var x=`
    `;for(var idx=0,item,$$a=items;idx<$$a.length;idx++){item=$$a[idx];x+=`
  • hello ${$$1(item.name)} (#${$$1(idx)})
  • `;}x+=`
`;return x' 345 | ); 346 | 347 | assert.is( 348 | gen('
    {{#each items as item, idx }}
  • hello {{item.name}} (#{{ idx }})
  • {{/each}}
'), 349 | 'var x=`
    `;for(var idx=0,item,$$a=items;idx<$$a.length;idx++){item=$$a[idx];x+=`
  • hello ${$$1(item.name)} (#${$$1(idx)})
  • `;}x+=`
`;return x' 350 | ); 351 | 352 | assert.is( 353 | gen('
    {{#each items as item, idx }}
  • hello {{{item.name}}} (#{{{ idx }}})
  • {{/each}}
'), 354 | 'var x=`
    `;for(var idx=0,item,$$a=items;idx<$$a.length;idx++){item=$$a[idx];x+=`
  • hello ${item.name} (#${idx})
  • `;}x+=`
`;return x' 355 | ); 356 | }); 357 | 358 | each.run(); 359 | 360 | // --- 361 | 362 | const loose = suite('options.loose'); 363 | 364 | loose('should not declare unknown vars by default', () => { 365 | let output = gen('{{ hello }}'); 366 | assert.is(output, 'var x=`${$$1(hello)}`;return x'); 367 | }); 368 | 369 | loose('should prepare surprise vars', () => { 370 | let foo = gen('{{ hello }}', { loose: true }); 371 | assert.is(foo, 'var{hello}=$$3,x=`${$$1(hello)}`;return x'); 372 | 373 | let bar = gen('{{{ hello+world }}}', { loose: true }); 374 | assert.is(bar, 'var{hello,world}=$$3,x=`${hello+world}`;return x'); 375 | 376 | let baz = gen('{{{ hello / world }}}', { loose: true }); 377 | assert.is(baz, 'var{hello,world}=$$3,x=`${hello / world}`;return x'); 378 | }); 379 | 380 | loose('should ignore non-identifiers', () => { 381 | let foo = gen('{{{ "123" }}}', { loose: true }); 382 | assert.is(foo, 'var x=`${"123"}`;return x'); 383 | 384 | let bar = gen('{{{ 123 }}}', { loose: true }); 385 | assert.is(bar, 'var x=`${123}`;return x'); 386 | 387 | let baz = gen('{{{ hello == 123 }}}', { loose: true }); 388 | assert.is(baz, 'var{hello}=$$3,x=`${hello == 123}`;return x'); 389 | }); 390 | 391 | loose.run(); 392 | 393 | // --- 394 | 395 | const blocks = suite('options.blocks'); 396 | 397 | blocks('should throw error on unknown block', () => { 398 | try { 399 | gen('{{#hello}}'); 400 | assert.unreachable('should have thrown'); 401 | } catch (err) { 402 | assert.instance(err, Error); 403 | assert.is(err.message, 'Unknown "hello" block'); 404 | } 405 | }); 406 | 407 | blocks('should allow custom directives', () => { 408 | let tmpl = '{{#include x="name" src=true }}'; 409 | 410 | let output = gen(tmpl, { 411 | blocks: { 412 | include() { 413 | assert.unreachable('do not run function on parse'); 414 | } 415 | } 416 | }); 417 | 418 | assert.type(output, 'string'); 419 | assert.is(output, 'var x="";x+=`${$$2.include({x:"name",src:true},$$2)}`;return x'); 420 | }); 421 | 422 | blocks('should allow functional replacement', () => { 423 | let output = gen(`{{#hello "ignored"}}`, { 424 | blocks: { 425 | hello() { 426 | assert.unreachable('do not call functions during parse'); 427 | } 428 | } 429 | }); 430 | 431 | assert.is(output, 'var x="";x+=`${$$2.hello({},$$2)}`;return x'); 432 | }); 433 | 434 | blocks('should allow {{{ raw }}} function callers', () => { 435 | let output = gen(`{{{#hello foo="bar"}}}`, { 436 | blocks: { 437 | hello() { 438 | assert.unreachable('do not call functions during parse'); 439 | } 440 | } 441 | }); 442 | 443 | assert.is(output, 'var x="";x+=`${$$2.hello({foo:"bar"},$$2)}`;return x'); 444 | }); 445 | 446 | blocks('should parse arguments for function callers', () => { 447 | let output = gen(`{{#hello foo="foo" arr=["a b c", 123] bar='bar' o = { foo, bar } hi= howdy}}`, { 448 | blocks: { 449 | hello() { 450 | // 451 | } 452 | } 453 | }); 454 | 455 | let args = `{foo:"foo",arr:["a b c", 123],bar:'bar',o:{ foo, bar },hi:howdy}`; 456 | assert.is(output, 'var x="";x+=`${$$2.hello(' + args + ',$$2)}`;return x'); 457 | }); 458 | 459 | blocks('arguments parsing : strings', () => { 460 | let output = gen(`{{#hello foo="foo" bar = 'bar' baz= \`baz\` hello ="foo 'bar' baz" }}`, { 461 | blocks: { 462 | hello() { 463 | // 464 | } 465 | } 466 | }); 467 | 468 | let args = `{foo:"foo",bar:'bar',baz:\`baz\`,hello:"foo 'bar' baz"}`; 469 | assert.is(output, 'var x="";x+=`${$$2.hello(' + args + ',$$2)}`;return x'); 470 | }); 471 | 472 | blocks('arguments parsing : booleans', () => { 473 | let output = gen(`{{#hello foo=true bar = true baz= false hello =false }}`, { 474 | blocks: { 475 | hello() { 476 | // 477 | } 478 | } 479 | }); 480 | 481 | let args = `{foo:true,bar:true,baz:false,hello:false}`; 482 | assert.is(output, 'var x="";x+=`${$$2.hello(' + args + ',$$2)}`;return x'); 483 | }); 484 | 485 | blocks('arguments parsing : arrays', () => { 486 | let output = gen(`{{#hello foo=[1,2,3] bar = [1, 2, 3] baz= ['foo','baz'] }}`, { 487 | blocks: { 488 | hello() { 489 | // 490 | } 491 | } 492 | }); 493 | 494 | let args = `{foo:[1,2,3],bar:[1, 2, 3],baz:['foo','baz']}`; 495 | assert.is(output, 'var x="";x+=`${$$2.hello(' + args + ',$$2)}`;return x'); 496 | }); 497 | 498 | blocks('arguments parsing : objects', () => { 499 | let output = gen(`{{#hello foo={a,b} bar = { x, y:123 } }}`, { 500 | blocks: { 501 | hello() { 502 | // 503 | } 504 | } 505 | }); 506 | 507 | let args = `{foo:{a,b},bar:{ x, y:123 }}`; 508 | assert.is(output, 'var x="";x+=`${$$2.hello(' + args + ',$$2)}`;return x'); 509 | }); 510 | 511 | blocks('should still throw on unknown block', () => { 512 | try { 513 | gen('{{#var foo = 123}}{{#bar}}{{#howdy}}{{{ foo }}}', { 514 | blocks: { 515 | bar: () => 'bar' 516 | } 517 | }); 518 | assert.unreachable(); 519 | } catch (err) { 520 | assert.instance(err, Error); 521 | assert.is(err.message, 'Unknown "howdy" block'); 522 | } 523 | }); 524 | 525 | blocks('should allow multi-line arguments', () => { 526 | let blocks = { script: 1 }; 527 | 528 | let output = gen( 529 | `{{#script 530 | type="module" 531 | src="foobar.mjs" 532 | async=true 533 | }}`, 534 | { blocks } 535 | ); 536 | 537 | let expects = 'var x="";x+=`${$$2.script({type:"module",src:"foobar.mjs",async:true},$$2)}`;return x'; 538 | assert.is(output, expects); 539 | 540 | output = gen( 541 | `{{#script 542 | type="module" 543 | src="foobar.mjs" 544 | async=true }}`, 545 | { blocks } 546 | ); 547 | 548 | assert.is(output, expects); 549 | }); 550 | 551 | blocks.run(); 552 | 553 | // --- 554 | 555 | const props = suite('options.props'); 556 | 557 | props('should take the place of `#expect` decls', () => { 558 | let foo = gen('{{ url }}', { props: ['url'] }); 559 | assert.is(foo, 'var{url}=$$3,x=`${$$1(url)}`;return x'); 560 | 561 | let bar = gen('{{{ a + b }}}', { props: ['a', 'b'] }); 562 | assert.is(bar, 'var{a,b}=$$3,x=`${a + b}`;return x'); 563 | }); 564 | 565 | props('should dedupe with `#expect` repeats', () => { 566 | let foo = gen('{{#expect foo}}{{{ foo }}}', { props: ['foo'] }); 567 | assert.is(foo, 'var{foo}=$$3,x="";x+=`${foo}`;return x'); 568 | }); 569 | 570 | props('should work with `loose` enabled', () => { 571 | let foo = gen('{{{ foo }}}', { props: ['bar'], loose: true }); 572 | assert.is(foo, 'var{bar,foo}=$$3,x=`${foo}`;return x'); 573 | }); 574 | 575 | props.run(); 576 | 577 | // --- 578 | 579 | const stack = suite('stack'); 580 | 581 | stack('should throw on incorrect block order :: if->each', () => { 582 | try { 583 | gen(` 584 | {{#expect items}} 585 | {{#if items.length > 0}} 586 | {{#each items as item}} 587 |

{{{ item.name }}}

588 | {{/if}} 589 | `); 590 | assert.unreachable(); 591 | } catch (err) { 592 | assert.instance(err, Error); 593 | assert.is(err.message, `Expected to close "each" block; closed "if" instead`); 594 | } 595 | }); 596 | 597 | stack('should throw on incorrect block order :: each->if', () => { 598 | try { 599 | gen(` 600 | {{#each items as item}} 601 | {{#if items.length > 0}} 602 |

{{{ item.name }}}

603 | {{/each}} 604 | `); 605 | assert.unreachable(); 606 | } catch (err) { 607 | assert.instance(err, Error); 608 | assert.is(err.message, `Expected to close "if" block; closed "each" instead`); 609 | } 610 | }); 611 | 612 | stack('unterminated #if block', () => { 613 | try { 614 | gen(` 615 | {{#if items.length > 0}} 616 |

{{{ item.name }}}

617 | `); 618 | assert.unreachable(); 619 | } catch (err) { 620 | assert.instance(err, Error); 621 | assert.is(err.message, `Unterminated "if" block`); 622 | } 623 | }); 624 | 625 | stack('unterminated #if->#elif block', () => { 626 | try { 627 | gen(` 628 | {{#if items.length === 1}} 629 |

{{{ item.name }}}

630 | {{#elif items.length === 2}} 631 |

has two items

632 | `); 633 | assert.unreachable(); 634 | } catch (err) { 635 | assert.instance(err, Error); 636 | assert.is(err.message, `Unterminated "if" block`); 637 | } 638 | }); 639 | 640 | stack('unterminated #each block', () => { 641 | try { 642 | gen(` 643 | {{#each items as item}} 644 |

{{{ item.name }}}

645 | `); 646 | assert.unreachable(); 647 | } catch (err) { 648 | assert.instance(err, Error); 649 | assert.is(err.message, `Unterminated "each" block`); 650 | } 651 | }); 652 | 653 | stack.run(); 654 | 655 | // --- 656 | 657 | const async = suite('async'); 658 | 659 | async('should attach `await` keyword to custom blocks', () => { 660 | let output = gen(`{{#foo x="hello" }} {{#bar y="world" }}`, { 661 | async: true, 662 | blocks: { 663 | foo() { 664 | // return 665 | }, 666 | async bar() { 667 | // 668 | } 669 | } 670 | }); 671 | 672 | let expects = 'var x="";' 673 | expects += 'x+=`${await $$2.foo({x:"hello"},$$2)} `;'; 674 | expects += 'x+=`${await $$2.bar({y:"world"},$$2)}`;return x'; 675 | 676 | assert.is(output, expects); 677 | }); 678 | 679 | async.run(); 680 | --------------------------------------------------------------------------------