├── .gitignore ├── benchmark ├── public │ ├── logo.webp │ ├── favicon.ico │ └── main.css ├── ejs │ ├── blog.ejs │ ├── navigation-link.ejs │ ├── post.ejs │ ├── navigation.ejs │ ├── index.js │ └── layout.ejs ├── pug │ ├── blog.pug │ ├── index.js │ └── layout.pug ├── package.json ├── tpl-stream │ ├── index.js │ ├── blog.js │ └── layout.js ├── index.js ├── readme.md └── blog-posts.js ├── src ├── index.js ├── cache.js ├── render.js └── template.js ├── .github └── workflows │ └── test.yaml ├── test ├── streams.js ├── composition.js ├── objects.js ├── simple.js ├── arrays.js └── async.js ├── package.json ├── LICENSE └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | package-lock.json 4 | -------------------------------------------------------------------------------- /benchmark/public/logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lorenzofox3/tpl-stream/main/benchmark/public/logo.webp -------------------------------------------------------------------------------- /benchmark/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lorenzofox3/tpl-stream/main/benchmark/public/favicon.ico -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { html } from './template.js'; 2 | export { render, renderAsString } from './render.js'; 3 | export { templateCache } from './cache.js'; 4 | -------------------------------------------------------------------------------- /benchmark/ejs/blog.ejs: -------------------------------------------------------------------------------- 1 |
2 |

Latest articles

3 | <% posts.forEach(function (post) { -%> 4 | <%- include('post', {post}) %> 5 | <% }); -%> 6 |
7 | -------------------------------------------------------------------------------- /benchmark/ejs/navigation-link.ejs: -------------------------------------------------------------------------------- 1 |
  • 2 | <% if(isCurrentPage) { %> 3 | 4 | <%= link.name %> 5 | 6 | <% } else { %> 7 | 8 | <%= link.name %> 9 | 10 | <% } %> 11 |
  • 12 | -------------------------------------------------------------------------------- /benchmark/ejs/post.ejs: -------------------------------------------------------------------------------- 1 |
    2 |

    <%= post.title%>

    3 |

    4 | Published by <%= post.author %> on 5 |

    6 |

    <%= post.description %>

    7 | Read full article 8 |
    9 | -------------------------------------------------------------------------------- /benchmark/ejs/navigation.ejs: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /src/cache.js: -------------------------------------------------------------------------------- 1 | let cache = new WeakMap(); 2 | 3 | export const templateCache = { 4 | has(templateParts) { 5 | return cache.has(templateParts); 6 | }, 7 | 8 | set(templateParts, values) { 9 | return cache.set(templateParts, values); 10 | }, 11 | 12 | get(templateParts) { 13 | return cache.get(templateParts); 14 | }, 15 | 16 | clear() { 17 | cache = new WeakMap(); 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /benchmark/pug/blog.pug: -------------------------------------------------------------------------------- 1 | extends layout.pug 2 | 3 | mixin Post(post) 4 | article.post-preview 5 | h3 #{post.title} 6 | p.meta Published by #{post.author} on #[time #{post.publicationDate}] 7 | p #{post.description} 8 | a(rel='bookmark', href=post.permalink) Read full article 9 | 10 | mixin Blog(posts) 11 | section 12 | h2 Latest articles 13 | each post in posts 14 | +Post(post) 15 | 16 | block content 17 | +Blog(posts) 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | defaults: 12 | run: 13 | shell: bash 14 | 15 | jobs: 16 | all: 17 | name: Test 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v3 21 | - uses: actions/setup-node@v3 22 | with: 23 | node-version: 20 24 | - name: Install dependencies 25 | run: npm i 26 | - name: Test 27 | run: npm t 28 | -------------------------------------------------------------------------------- /benchmark/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "scripts": { 4 | "dev": "node --watch index.js", 5 | "start": "NODE_ENV=production node index.js", 6 | "bench:tpl-stream": "autocannon http://localhost:3000/tpl-stream", 7 | "bench:pug": "autocannon http://localhost:3000/pug", 8 | "bench:ejs": "autocannon http://localhost:3000/ejs" 9 | }, 10 | "devDependencies": { 11 | "@fastify/static": "^8.1.1", 12 | "@fastify/view": "^11.0.0", 13 | "autocannon": "^8.0.0", 14 | "ejs": "^3.1.10", 15 | "fastify": "^5.3.2", 16 | "pug": "^3.0.3" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /benchmark/tpl-stream/index.js: -------------------------------------------------------------------------------- 1 | import { render } from '../../src/index.js'; 2 | import { Page } from './layout.js'; 3 | import { getPosts } from '../blog-posts.js'; 4 | import { Blog } from './blog.js'; 5 | 6 | export const tplStreamPlugin = async (instance) => { 7 | instance.route({ 8 | method: 'GET', 9 | url: '/', 10 | async handler(req, reply) { 11 | reply.type('text/html'); 12 | return render( 13 | Page({ 14 | title: 'Blog', 15 | content: getPosts().then((posts) => Blog({ posts })), 16 | }), 17 | ); 18 | }, 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /benchmark/tpl-stream/blog.js: -------------------------------------------------------------------------------- 1 | import { html } from '../../src/index.js'; 2 | 3 | const BlogPost = ({ 4 | title, 5 | author, 6 | description, 7 | publicationDate, 8 | permalink, 9 | }) => html` 10 |
    11 |

    ${title}

    12 |

    13 | Published by ${author} on 14 |

    15 |

    ${description}

    16 | Read full article 17 |
    18 | `; 19 | 20 | export const Blog = ({ posts }) => 21 | html`
    22 |

    Latest articles

    23 | ${posts.map(BlogPost)} 24 |
    `; 25 | -------------------------------------------------------------------------------- /benchmark/pug/index.js: -------------------------------------------------------------------------------- 1 | import pug from 'pug'; 2 | import { dirname, join } from 'node:path'; 3 | import { fileURLToPath } from 'node:url'; 4 | import { getPosts } from '../blog-posts.js'; 5 | 6 | const __dirname = dirname(fileURLToPath(import.meta.url)) + '/'; 7 | export const pugPlugin = async (instance) => { 8 | const render = pug.compileFile(join(__dirname, 'blog.pug'), { 9 | basedir: __dirname, 10 | }); 11 | 12 | instance.route({ 13 | method: 'GET', 14 | url: '/', 15 | async handler(req, reply) { 16 | reply.type('text/html'); 17 | const posts = await getPosts(); 18 | return render({ 19 | title: 'Blog', 20 | currentPage: '/blog', 21 | posts, 22 | }); 23 | }, 24 | }); 25 | }; 26 | -------------------------------------------------------------------------------- /test/streams.js: -------------------------------------------------------------------------------- 1 | import { test } from 'zora'; 2 | import { render, html } from '../src/index.js'; 3 | 4 | const getChunks = async (stream) => { 5 | const chunks = []; 6 | for await (const chunk of stream) { 7 | chunks.push(chunk); 8 | } 9 | return chunks; 10 | }; 11 | 12 | test(`content with no Promise is not chunked`, async ({ eq }) => { 13 | const chunks = await getChunks( 14 | render(html`

    ${html`${42}`}

    `), 15 | ); 16 | eq(chunks, ['

    42

    ']); 17 | }); 18 | 19 | test(`A chunk is created when there is a pending Promise`, async ({ eq }) => { 20 | const chunks = await getChunks( 21 | render(html`

    ${Promise.resolve(html`${42}`)}

    `), 22 | ); 23 | eq(chunks, ['

    ', '42

    ']); 24 | }); 25 | -------------------------------------------------------------------------------- /benchmark/index.js: -------------------------------------------------------------------------------- 1 | import fastify from 'fastify'; 2 | import fastifyStatic from '@fastify/static'; 3 | import { dirname, join } from 'node:path'; 4 | import { fileURLToPath } from 'node:url'; 5 | import { tplStreamPlugin } from './tpl-stream/index.js'; 6 | import { pugPlugin } from './pug/index.js'; 7 | import { ejsPlugin } from './ejs/index.js'; 8 | 9 | const __dirname = dirname(fileURLToPath(import.meta.url)); 10 | 11 | const app = fastify({ 12 | logger: true, 13 | }); 14 | 15 | app.register(fastifyStatic, { 16 | root: join(__dirname, 'public'), 17 | prefix: '/public/', 18 | }); 19 | 20 | app.register(tplStreamPlugin, { prefix: '/tpl-stream' }); 21 | app.register(pugPlugin, { prefix: '/pug' }); 22 | app.register(ejsPlugin, { prefix: '/ejs' }); 23 | 24 | app.listen({ port: 3000 }); 25 | -------------------------------------------------------------------------------- /benchmark/ejs/index.js: -------------------------------------------------------------------------------- 1 | import { dirname } from 'node:path'; 2 | import { fileURLToPath } from 'node:url'; 3 | import view from '@fastify/view'; 4 | import ejs from 'ejs'; 5 | import { getPosts } from '../blog-posts.js'; 6 | 7 | const __dirname = dirname(fileURLToPath(import.meta.url)); 8 | export const ejsPlugin = async (instance) => { 9 | instance.register(view, { 10 | engine: { 11 | ejs, 12 | }, 13 | root: __dirname, 14 | }); 15 | 16 | instance.route({ 17 | method: 'GET', 18 | url: '/', 19 | async handler(req, reply) { 20 | reply.type('text/html'); 21 | const posts = await getPosts(); 22 | return reply.view('./layout', { 23 | title: 'Blog', 24 | posts, 25 | currentPage: '/blog', 26 | }); 27 | }, 28 | }); 29 | }; 30 | -------------------------------------------------------------------------------- /test/composition.js: -------------------------------------------------------------------------------- 1 | import { test } from 'zora'; 2 | import { renderAsString, html } from '../src/index.js'; 3 | 4 | test(`templates can be composed together`, async ({ eq }) => { 5 | eq( 6 | await renderAsString(html`

    foo ${html`${42}`}

    `), 7 | '

    foo 42

    ', 8 | ); 9 | }); 10 | 11 | test(`composition can be done by functions`, async ({ eq }) => { 12 | const Tpl1 = ({ title, content }) => 13 | // prettier-ignore 14 | html`

    ${title}

    ${content}
    `; 15 | 16 | const Tpl2 = ({ name }) => html`

    ${name}

    `; 17 | 18 | const htmlString = await renderAsString( 19 | Tpl1({ 20 | title: 'some title', 21 | content: Tpl2({ name: 'Lorenzofox' }), 22 | }), 23 | ); 24 | 25 | eq(htmlString, '

    some title

    Lorenzofox

    '); 26 | }); 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tpl-stream", 3 | "version": "0.0.3", 4 | "description": "html template library that supports streaming for javascript runtimes", 5 | "main": "src/index.js", 6 | "type": "module", 7 | "scripts": { 8 | "test": "pta", 9 | "format": "prettier", 10 | "dev": "node --watch ./node_modules/.bin/pta" 11 | }, 12 | "files": [ 13 | "src/" 14 | ], 15 | "prettier": { 16 | "singleQuote": true 17 | }, 18 | "exports": { 19 | ".": { 20 | "default": "./src/index.js" 21 | }, 22 | "./pacakge.json": "./package.json" 23 | }, 24 | "keywords": [ 25 | "html", 26 | "stream", 27 | "template", 28 | "pug", 29 | "ejs", 30 | "handlebar", 31 | "ssr" 32 | ], 33 | "author": "Laurent RENARD", 34 | "license": "MIT", 35 | "devDependencies": { 36 | "prettier": "^3.5.3", 37 | "pta": "^1.3.0", 38 | "zora": "^6.0.0" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /benchmark/ejs/layout.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= title %> 9 | 10 | 11 |
    12 | 13 |

    <%= title %>

    14 | <%- include('./navigation', { currentPage}) %> 15 |
    16 |
    17 |

    18 | Hi! I am Laurent and this is my dev blog. This is where I collect what 19 | I learn, what I experiment and what I find interesting. 20 |

    21 | <%- include('blog', {posts}) %> 22 |
    23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /test/objects.js: -------------------------------------------------------------------------------- 1 | import { test } from 'zora'; 2 | import { renderAsString, html } from '../src/index.js'; 3 | 4 | test('object pairs result into html attributes', async ({ eq }) => { 5 | const htmlString = await renderAsString( 6 | html``, 7 | ); 8 | eq(htmlString, ``); 9 | }); 10 | 11 | test('attribute values are escaped', async ({ eq }) => { 12 | const htmlString = await renderAsString( 13 | // prettier-ignore 14 | html``, 15 | ); 16 | eq( 17 | htmlString, 18 | ``, 19 | ); 20 | }); 21 | 22 | test('attributes whose value is false are ignored', async ({ eq }) => { 23 | const htmlString = await renderAsString( 24 | // prettier-ignore 25 | html``, 26 | ); 27 | eq(htmlString, ``); 28 | }); 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 RENARD Laurent 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/simple.js: -------------------------------------------------------------------------------- 1 | import { test } from 'zora'; 2 | import { html, renderAsString } from '../src/index.js'; 3 | 4 | test('html content is interpolated', async ({ eq }) => { 5 | eq(await renderAsString(html`

    ${'Lorenzofox'}

    `), `

    Lorenzofox

    `); 6 | eq( 7 | await renderAsString( 8 | // prettier-ignore 9 | html`${42}${43}

    ${'Lorenzofox'}

    ${true}${'some content'}`, 10 | ), 11 | `4243

    Lorenzofox

    truesome content`, 12 | ); 13 | }); 14 | 15 | test('attributes are interpolated', async ({ eq }) => { 16 | eq( 17 | await renderAsString(html``), 18 | ``, 19 | ); 20 | }); 21 | 22 | test('interpolated html is escaped (HTML contexts)', async ({ eq }) => { 23 | eq( 24 | await renderAsString( 25 | html`

    ${''}

    `, 26 | ), 27 | `

    <script>window.alert("bim")</script>

    `, 28 | ); 29 | }); 30 | 31 | test('interpolated attributes are escaped (HTML attribute Contexts)', async ({ 32 | eq, 33 | }) => { 34 | eq( 35 | await renderAsString(html`

    bim'}">

    `), 36 | `

    `, 37 | ); 38 | }); 39 | -------------------------------------------------------------------------------- /benchmark/pug/layout.pug: -------------------------------------------------------------------------------- 1 | - var links = [{ href: '/', name: 'Home' }, { href: '/blog', name: 'Blog' },{ href: '/about', name: 'About' }] 2 | 3 | mixin NavigationLink(link, currentPage) 4 | - var isCurrentPage = currentPage === link.href 5 | li 6 | a(href=link.href, aria-current=isCurrentPage ? 'page' : false) #{link.name} 7 | 8 | mixin Navigation(currentPage) 9 | nav 10 | ul 11 | each link in links 12 | +NavigationLink(link, currentPage) 13 | 14 | doctype html 15 | html 16 | head 17 | meta(charset='utf-8') 18 | meta(name='utf-8', content='width=device-width,initial-scale=1') 19 | link(href='/public/main.css', rel='stylesheet', type='text/css') 20 | link(rel='icon', href='/public/favicon.ico') 21 | title #{title} 22 | body 23 | header#main-header 24 | img#logo(alt='blog logo', src='/public/logo.webp') 25 | h1 #{title} 26 | +Navigation(currentPage) 27 | main 28 | p Hi! I am Laurent and this is my dev blog. This is where I collect what I learn, what I experiment and what I find interesting. 29 | block content 30 | footer 31 | p © Laurent RENARD. All Rights Reserved. 32 | -------------------------------------------------------------------------------- /test/arrays.js: -------------------------------------------------------------------------------- 1 | import { test } from 'zora'; 2 | import { renderAsString, html } from '../src/index.js'; 3 | 4 | test('arrays of templates are inlined', async ({ eq }) => { 5 | const ListItem = (value) => html`
  • ${value}
  • `; 6 | const items = ['item 1', 'item 2', 'item 3']; 7 | 8 | eq( 9 | await renderAsString( 10 | // prettier-ignore 11 | html``, 12 | ), 13 | ``, 14 | ); 15 | }); 16 | 17 | test('arrays of arrays are inlined', async ({ eq }) => { 18 | const ListItem = (value) => html`
  • ${value}
  • `; 19 | const items = [[html`item 1a`, html`item 1b`], 'item 2', 'item 3']; 20 | 21 | eq( 22 | await renderAsString( 23 | // prettier-ignore 24 | html``, 25 | ), 26 | ``, 27 | ); 28 | }); 29 | 30 | test('arrays of literals are NOT escaped', async ({ eq }) => { 31 | const ListItem = (value) => value; 32 | const items = [ 33 | '', 34 | '
  • item 2
  • ', 35 | '
  • item 3
  • ', 36 | ]; 37 | 38 | eq( 39 | await renderAsString( 40 | // prettier-ignore 41 | html``, 42 | ), 43 | ``, 44 | ); 45 | }); 46 | -------------------------------------------------------------------------------- /benchmark/readme.md: -------------------------------------------------------------------------------- 1 | # benchmark 2 | 3 | This is not really a _benchmark_, but rather a toy application to assess the performances of the library in a _real world_ use case, versus 4 | different baselines: the same [fastify](https://fastify.dev/) application built with the popular template engines [pug](https://pugjs.org/) (the one I would use by default) and [ejs](https://ejs.co/)(a very popular one in the community with 13M weekly downloads). 5 | 6 | ## The application 7 | 8 | ![Screenshot 2024-04-03 at 11 05 47](https://github.com/lorenzofox3/tpl-stream/assets/2402022/cc021ce7-5405-4690-8d9b-43904fb05c45) 9 | 10 | The application is a blog page where posts are loaded from a fake database. To simulate some latency we run the following code: 11 | ```js 12 | const LATENCY = env.DB_LATENCY || 10; 13 | 14 | export async function getPosts() { 15 | const latency = Math.round(Math.random() * LATENCY); 16 | await setTimeout(latency); 17 | return [...postList]; 18 | } 19 | ``` 20 | ## Load simulation 21 | 22 | Then, we run [autocannon](https://github.com/mcollina/autocannon) to see how many requests the server can process. 23 | On my machine, I get the following results (median requests by second): 24 | 25 | | tpl-stream | pug | ejs | 26 | |------------|-----|-----| 27 | | 1550 | 1632| 670 | 28 | 29 | ## conclusion 30 | 31 | ``tpl-stream`` is more than capable and I will use it as my default template engine from now. 32 | -------------------------------------------------------------------------------- /benchmark/tpl-stream/layout.js: -------------------------------------------------------------------------------- 1 | import { html } from '../../src/index.js'; 2 | 3 | const links = [ 4 | { href: '/', name: 'Home' }, 5 | { href: '/blog', name: 'Blog' }, 6 | { href: '/about', name: 'About' }, 7 | ]; 8 | const NavigationLink = ({ name, href, ...rest }) => 9 | html`
  • ${name}
  • `; 10 | 11 | const Navigation = ({ currentPage = '/' }) => 12 | html``; 22 | 23 | export const Page = ({ title, content }) => html` 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ${title} 32 | 33 | 34 |
    35 | 36 |

    ${title}

    37 | ${Navigation({ currentPage: '/blog' })} 38 |
    39 |
    40 |

    41 | Hi! I am Laurent and this is my dev blog. This is where I collect what 42 | I learn, what I experiment and what I find interesting. 43 |

    44 | ${content} 45 |
    46 | 47 | 48 | 49 | `; 50 | -------------------------------------------------------------------------------- /test/async.js: -------------------------------------------------------------------------------- 1 | import { test } from 'zora'; 2 | import { renderAsString, html } from '../src/index.js'; 3 | 4 | test(`Promise that resolves a template is inserted`, async ({ eq }) => { 5 | const htmlString = await renderAsString( 6 | html`
    ${Promise.resolve(html`

    hello world

    `)}
    `, 7 | ); 8 | eq(htmlString, `

    hello world

    `); 9 | }); 10 | 11 | test(`Promise that resolves a string is NOT escaped`, async ({ eq }) => { 12 | const htmlString = await renderAsString( 13 | html`
    ${Promise.resolve(`

    hello world

    `)}
    `, 14 | ); 15 | eq(htmlString, `

    hello world

    `); 16 | }); 17 | 18 | test(`Promise that resolves an array is inlined`, async ({ eq }) => { 19 | const htmlString = await renderAsString( 20 | // prettier-ignore 21 | html`
    ${Promise.resolve([ 22 | html`

    hello world

    `, 23 | html`

    how are you?

    `, 24 | ])}
    `, 25 | ); 26 | eq(htmlString, `

    hello world

    how are you?

    `); 27 | }); 28 | 29 | test('Stream of templates are inserted', async ({ eq }) => { 30 | const stream = async function* () { 31 | yield html`
  • item1
  • `; 32 | yield html`
  • item2
  • `; 33 | yield html`
  • item3
  • `; 34 | }; 35 | 36 | const htmlString = await renderAsString( 37 | // prettier-ignore 38 | html``, 39 | ); 40 | // prettier-ignore 41 | eq(htmlString, ``); 42 | }); 43 | 44 | test('Stream of strings are not escaped', async ({ eq }) => { 45 | const stream = async function* () { 46 | yield `
  • item1
  • `; 47 | yield `
  • item2
  • `; 48 | yield `
  • item3
  • `; 49 | }; 50 | 51 | const htmlString = await renderAsString( 52 | // prettier-ignore 53 | html``, 54 | ); 55 | // prettier-ignore 56 | eq(htmlString, ``); 57 | }); 58 | -------------------------------------------------------------------------------- /src/render.js: -------------------------------------------------------------------------------- 1 | export { render, renderAsString }; 2 | 3 | // we use coroutine instead of async iterable to avoid Promise overhead (for perf) 4 | function* _render(template, controller) { 5 | for (const chunk of template) { 6 | if (typeof chunk === 'string') { 7 | controller.enqueue(chunk); 8 | } else if (chunk?.[Symbol.iterator]) { 9 | yield* _render(chunk, controller); 10 | } else if (chunk?.then) { 11 | const resolved = yield chunk; 12 | yield* _render( 13 | typeof resolved === 'string' ? [resolved] : resolved, 14 | controller, 15 | ); 16 | } else if (chunk?.[Symbol.asyncIterator]) { 17 | while (true) { 18 | const { value: resolved, done } = yield chunk.next(); 19 | if (done === true) { 20 | break; 21 | } 22 | yield* _render( 23 | typeof resolved === 'string' ? [resolved] : resolved, 24 | controller, 25 | ); 26 | } 27 | } else { 28 | throw new Error('Unsupported chunk'); 29 | } 30 | } 31 | } 32 | 33 | function render(template) { 34 | return new ReadableStream({ 35 | start(controller) { 36 | const buffer = []; 37 | const iterable = _render(template, { 38 | enqueue: (val) => buffer.push(val), 39 | }); 40 | 41 | return pump(); 42 | 43 | async function pump(chunk) { 44 | const { value } = iterable.next(chunk); 45 | 46 | if (value?.then) { 47 | if (buffer.length) { 48 | controller.enqueue(buffer.join('')); 49 | } 50 | const asyncChunk = await value; 51 | buffer.length = 0; 52 | return pump(asyncChunk); 53 | } 54 | 55 | if (buffer.length) { 56 | controller.enqueue(buffer.join('')); 57 | } 58 | 59 | controller.close(); 60 | } 61 | }, 62 | }); 63 | } 64 | 65 | async function renderAsString(template) { 66 | const buffer = []; 67 | 68 | for await (const chunk of render(template)) { 69 | buffer.push(chunk); 70 | } 71 | 72 | return buffer.join(''); 73 | } 74 | -------------------------------------------------------------------------------- /src/template.js: -------------------------------------------------------------------------------- 1 | import { templateCache } from './cache.js'; 2 | 3 | export { html }; 4 | 5 | function html(templateParts, ...values) { 6 | if (!templateCache.has(templateParts)) { 7 | templateCache.set(templateParts, compile(templateParts, ...values)); 8 | } 9 | 10 | return templateCache.get(templateParts)(...values); 11 | } 12 | 13 | const { constructor: GeneratorFunction } = function* () {}; 14 | 15 | const zip = (arr1, arr2) => arr1.map((item, index) => [item, arr2[index]]); 16 | 17 | const compile = (templateParts, ...values) => { 18 | const src = buildSource(templateParts, ...values); 19 | const args = [ 20 | 'utils', 21 | ...Array.from({ length: values.length }, (_, i) => 'arg' + i), 22 | ]; 23 | const gen = new GeneratorFunction(...args, src); 24 | return (...values) => gen({ escape, attributesFragment }, ...values); 25 | }; 26 | 27 | const buildSource = (templateParts, ...values) => { 28 | const [first, ...rest] = templateParts; 29 | const tuples = zip(rest, values); 30 | return ( 31 | tuples.reduce((src, [tplPart, value], i) => { 32 | if (value?.[Symbol.iterator] && typeof value !== 'string') { 33 | return src + `;yield *arg${i};yield \`${tplPart}\``; 34 | } 35 | 36 | if (isAsync(value)) { 37 | return src + `;yield arg${i}; yield \`${tplPart}\``; 38 | } 39 | 40 | if (typeof value === 'object') { 41 | return src + `+utils.attributesFragment(arg${i}) + \`${tplPart}\``; 42 | } 43 | 44 | return src + `+utils.escape(String(arg${i})) + \`${tplPart}\``; 45 | }, `yield \`${first}\``) + ';' 46 | ); 47 | }; 48 | 49 | const attributesFragment = (value) => 50 | Object.entries(value) 51 | .filter(([_, value]) => value !== false) 52 | .map(([attr, value]) => `${attr}="${escape(value)}"`) 53 | .join(' '); 54 | 55 | const isAsync = (value) => 56 | value?.then !== undefined || value?.[Symbol.asyncIterator]; 57 | 58 | const escapeMap = { 59 | '&': '&', 60 | '<': '<', 61 | '>': '>', 62 | '"': '"', 63 | "'": ''', 64 | }; 65 | 66 | const htmlEntities = /[&<>"']/g; 67 | const escape = (value) => { 68 | if (/[&<>"']/.test(value)) { 69 | return value.replace(htmlEntities, (char) => escapeMap[char]); 70 | } 71 | 72 | return value; 73 | }; 74 | -------------------------------------------------------------------------------- /benchmark/public/main.css: -------------------------------------------------------------------------------- 1 | *, 2 | *::before, 3 | *::after { 4 | box-sizing: border-box; 5 | } 6 | 7 | * { 8 | margin: 0; 9 | } 10 | 11 | body { 12 | line-height: 1.4; 13 | margin: unset; 14 | -webkit-font-smoothing: antialiased; 15 | } 16 | 17 | button, 18 | input, 19 | textarea, 20 | select { 21 | font: inherit; 22 | } 23 | 24 | p, h1, h2, h3, h4, h5, h6 { 25 | overflow-wrap: break-word; 26 | } 27 | 28 | img, 29 | picture, 30 | svg, 31 | canvas { 32 | display: block; 33 | max-inline-size: 100%; 34 | block-size: auto; 35 | } 36 | 37 | :root { 38 | font-family: system-ui, Avenir, sans-serif; 39 | --font-color: #3b3b3b; 40 | --font-color-lighter: #5e5e5e; 41 | --app-bg: #f1f1f8; 42 | --dark-font-color: #f3f3f3; 43 | --dark-background: #343442; 44 | } 45 | 46 | body { 47 | color: var(--font-color); 48 | min-height: 100svh; 49 | font-size: clamp(1.1rem, 0.3vw + 1rem, 1.25rem); 50 | background: var(--app-bg); 51 | } 52 | 53 | #main-header { 54 | display: flex; 55 | background: var(--dark-background); 56 | color: var(--dark-font-color); 57 | align-items: center; 58 | justify-content: space-between; 59 | padding: 0.5em 2em; 60 | 61 | #logo { 62 | aspect-ratio: 1; 63 | height: 100px; 64 | 65 | } 66 | 67 | a { 68 | color: inherit; 69 | padding: 0.5em; 70 | border-radius: 4px; 71 | 72 | &[aria-current=page] { 73 | background: orangered; 74 | } 75 | } 76 | 77 | nav > ul { 78 | list-style: none; 79 | display: flex; 80 | align-items: center; 81 | gap: 1em; 82 | } 83 | } 84 | 85 | main { 86 | max-width: 65ch; 87 | margin-inline: auto; 88 | 89 | > p:first-child { 90 | margin-block: 2em; 91 | } 92 | } 93 | 94 | .post-preview { 95 | margin-block: 1.6em; 96 | 97 | p { 98 | margin-block:0.75em; 99 | } 100 | 101 | .meta { 102 | font-size: 0.8em; 103 | color: #5e5e5e; 104 | margin: unset; 105 | } 106 | } 107 | 108 | footer { 109 | font-size: 0.75em; 110 | padding: 1em; 111 | color: var(--dark-font-color); 112 | background: var(--dark-background) 113 | } 114 | 115 | -------------------------------------------------------------------------------- /benchmark/blog-posts.js: -------------------------------------------------------------------------------- 1 | import { setTimeout } from 'node:timers/promises'; 2 | import { env } from 'node:process'; 3 | 4 | const author = 'Laurent RENARD'; 5 | 6 | const postList = [ 7 | { 8 | author, 9 | title: "Let's build a UI framework - part 1/2", 10 | publicationDate: '2024-26-03', 11 | description: 12 | 'We have now at our disposal a way to turn coroutines into web components. We also have a set of higher order functions to manage how a component updates. It is great time to put these small bricks together in an expressive yet simple new UI Framework.', 13 | permalink: 'https://lorenzofox.dev/posts/lets-build-a-framework-part-1/', 14 | }, 15 | { 16 | author, 17 | title: 'Controllers on top of coroutine components', 18 | publicationDate: '2024-18-03', 19 | description: 20 | 'We have previously described a way of modelling custom elements as coroutines (generator functions). We then made sure that they could be updated efficiently. In this post, we will look at different patterns for controlling how (and when) the components are updated: these are what I call controllers.', 21 | permalink: 'https://lorenzofox.dev/posts/controllers/', 22 | }, 23 | { 24 | author, 25 | title: 'Batch component updates with micro tasks', 26 | publicationDate: '2024-03-11', 27 | description: 28 | 'In the previous article, we finished by providing a function to convert a generator into a custom element. In this post we will iterate by adding reactive attributes to our component definition, and ensuring that updates are performed in batch, using the hidden gem queueMicrotask.', 29 | permalink: 'https://lorenzofox.dev/posts/reactive-attributes/', 30 | }, 31 | { 32 | author, 33 | title: 'Coroutines and web components', 34 | publicationDate: '2024-03-04', 35 | description: 36 | 'In the previous article we learned what coroutines are and saw some patterns they can help implement. In this article, we will see how coroutines can be used to model web components in a different way, and why you might like it.', 37 | permalink: 'https://lorenzofox.dev/posts/component-as-infinite-loop/', 38 | }, 39 | { 40 | author, 41 | title: 'Coroutines in Javascript', 42 | publicationDate: '2024-02-24', 43 | description: 44 | 'A coroutine is a function whose execution can be suspended and resumed, possibly passing some data. They happen to be useful for implementing various patterns involving cooperation between different tasks/functions such as asynchronous flows for example.', 45 | permalink: 'https://lorenzofox.dev/posts/coroutine/', 46 | }, 47 | ]; 48 | 49 | const LATENCY = env.DB_LATENCY || 10; 50 | 51 | export async function getPosts() { 52 | const latency = Math.round(Math.random() * LATENCY); 53 | await setTimeout(latency); 54 | return [...postList]; 55 | } 56 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # tpl-stream 2 | 3 | [![install size](https://packagephobia.com/badge?p=tpl-stream)](https://packagephobia.com/result?p=tpl-stream) 4 | 5 | ``tpl-stream`` is a Javascript template library that supports streaming. It helps to generate HTML in a server environment, but not only. It runs anywhere, as long as the runtime implements [web streams](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream). 6 | 7 | It is very small compared to the alternatives and does not require a build step, while providing [very good performance](./benchmark). More details can be found in [this blog post](https://lorenzofox.dev/posts/html-streaming-part-2/) 8 | 9 | ## Installation 10 | 11 | The library can be installed from a package manager like [npm](https://www.npmjs.com/) by running the command 12 | 13 | ``npm install --save tpl-stream`` 14 | 15 | Or imported from a CDN: 16 | 17 | ```js 18 | import {render, html} from 'https://unpkg.com/tpl-stream/src/index.js'; 19 | ``` 20 | 21 | ## Usage 22 | 23 | ### Basics 24 | 25 | A template is defined using the ``html`` [tagged template](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#tagged_templates): 26 | 27 | ```js 28 | import {html, renderAsString} from 'tpl-stream'; 29 | 30 | const Greeting = ({name, classname}) => html`

    ${name}

    `; 31 | 32 | const htmlString = await renderAsString(Greeting({name: 'Lorenzofox', classname: 'primary'})) 33 | 34 | ``` 35 | 36 | when rendered, the HTML string will be ``'

    Lorenzofox

    '`` 37 | Interpolated expressions are automatically escaped whether they correspond to a text content or to an attribute. 38 | 39 | Raw HTML can also be inserted by wrapping the string within an array: 40 | 41 | ```js 42 | html`

    ${['42']}

    ` 43 | ``` 44 | 45 | ### Composition 46 | 47 | Multiple templates can be combined together to create more complex templates: 48 | 49 | ```js 50 | const Tpl1 = ({title, content}) => html`

    ${title}

    ${content}
    `; 51 | 52 | const Tpl2 = ({name}) => html`

    ${name}

    `; 53 | 54 | const htmlString = await renderAsString(Tpl1({ 55 | title:'some title', 56 | content: Tpl2({name:'Lorenzofox'}) 57 | })); 58 | 59 | //

    some title

    Lorenzofox

    60 | ``` 61 | 62 | ### Conditionals 63 | 64 | When using conditional, via ternary expression for example, all the branches should be isomorphic (i.e. return the same type): the templates are compiled for optimisation and this is based on the interpretation of the first interpolated value: 65 | 66 | ```js 67 | // don't 68 |

    ${ foo ? html`42` : ''}

    69 | 70 | // do 71 |

    ${ foo ? html`42` : html``}

    72 | ``` 73 | 74 | ### Containers 75 | 76 | Interpolation can work on some _containers_: Promise, Iterable(Array), Streams(anything that implements AsyncIterator) or Objects 77 | These containers must contain a template, a string or another container. 78 | 79 | ```js 80 | html`` 81 | 82 | // or 83 | 84 | html`

    ${Promise.resolve(html`42`)}

    ` 85 | ``` 86 | 87 | An object container is always be interpreted as a map of attributes (there is no parsing context). 88 | key-value pairs whose value is strictly equal to ``false`` are ignored. 89 | 90 | ```js 91 | html`` 92 | 93 | // 94 | ``` 95 | 96 | ### render 97 | 98 | The ``render`` function takes a template as input and returns a ``ReadableStream``. The chunks are split every time there is a pending Promise: 99 | 100 | ```js 101 |

    foo${43}woot${Promise.resolve('woot')}

    102 | 103 | // chunks: ['

    foo43woot', 'woot'

    ] 104 | ``` 105 | 106 | A template can be rendered as a string, by awaiting the Promise returned by ``renderAsString`` function. 107 | 108 | ## Perceived speed 109 | 110 | Streaming may also improve the _perceived_ speed as the browser renders the HTML (and possibly fetching some resources) while the server has not fully responded to the request. 111 | This behaviour can be observed below: the database has an (exaggerated) latency of 1s when the server calls it to fetch the blog posts data. On the left side, the server has already started streaming the first part of the HTML, and the browser can already render the upper part of the document (and fetch the stylesheets and other resources) while the database is still responding. 112 | 113 | This library can be combined with techniques like [Out Of Order streaming](https://lamplightdev.com/blog/2024/01/10/streaming-html-out-of-order-without-javascript/) to improve the user experience even further. 114 | 115 | 116 | 117 | https://github.com/lorenzofox3/tpl-stream/assets/2402022/d0a52057-240f-4ee4-afe7-920acea8a1af 118 | 119 | 120 | 121 | --------------------------------------------------------------------------------