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`
`,
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 | 
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`
`);
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 | [](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`
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`
${['foo', 'bar'].map(str => html`
${str}
`)}
`
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 |
--------------------------------------------------------------------------------