├── .editorconfig
├── .eslintignore
├── .eslintrc
├── .github
├── dependabot.yml
└── workflows
│ └── main.yml
├── .gitignore
├── .husky
└── pre-commit
├── .npmignore
├── .prettierignore
├── .vscode
└── settings.json
├── LICENSE
├── README.md
├── package.json
├── pnpm-lock.yaml
├── scripts
└── build.js
├── src
├── browser-buffer.js
├── browser-stream-template-renderer.js
├── browser-stream.js
├── default-template-processor.js
├── default-template-result-processor.js
├── directives
│ ├── async-append.d.ts
│ ├── async-append.js
│ ├── async-replace.d.ts
│ ├── async-replace.js
│ ├── cache.d.ts
│ ├── cache.js
│ ├── class-map.d.ts
│ ├── class-map.js
│ ├── guard.d.ts
│ ├── guard.js
│ ├── if-defined.d.ts
│ ├── if-defined.js
│ ├── repeat.d.ts
│ ├── repeat.js
│ ├── style-map.d.ts
│ ├── style-map.js
│ ├── unsafe-html.d.ts
│ ├── unsafe-html.js
│ ├── until.d.ts
│ └── until.js
├── escape.js
├── index.js
├── is.js
├── node-stream-template-renderer.js
├── parts.js
├── promise-template-renderer.js
├── shared.js
├── template-result.js
├── template.js
└── types.d.ts
├── test
├── browser
│ ├── assets
│ │ ├── chai.js
│ │ ├── mocha.css
│ │ └── mocha.js
│ ├── cli.mjs
│ ├── index.html
│ ├── test-runner.js
│ └── tests.js
├── directives-test.js
├── parts-test.js
├── perf.js
├── renderer-test.js
├── server.js
├── template-result-test.js
├── template-test.js
└── utils.js
└── tsconfig.json
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | indent_style = space
5 | indent_size = 2
6 | end_of_line = lf
7 | charset = utf-8
8 | insert_final_newline = true
9 |
10 | [*.md]
11 | trim_trailing_whitespace = false
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | *.d.ts
2 | test/browser
3 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "root": true,
3 | "env": {
4 | "es6": true,
5 | "mocha": true,
6 | "node": true
7 | },
8 | "extends": ["eslint:recommended", "prettier"],
9 | "parserOptions": {
10 | "ecmaVersion": 2020,
11 | "sourceType": "module"
12 | },
13 | "plugins": ["prettier"],
14 | "rules": {
15 | "no-console": "off",
16 | "no-unused-vars": ["error", { "args": "none", "ignoreRestSiblings": true }],
17 | "sort-imports": [
18 | "warn",
19 | { "ignoreCase": true, "memberSyntaxSortOrder": ["none", "all", "multiple", "single"] }
20 | ]
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: npm
4 | directory: "/"
5 | schedule:
6 | interval: daily
7 | time: "04:00"
8 | open-pull-requests-limit: 10
9 | allow:
10 | - dependency-type: production
11 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: test
2 |
3 | on: push
4 |
5 | env:
6 | CI: true
7 | PNPM_CACHE_FOLDER: .pnpm-store
8 | HUSKY: 0 # Bypass husky commit hook for CI
9 |
10 | jobs:
11 | build_deploy:
12 | runs-on: ubuntu-latest
13 | strategy:
14 | matrix:
15 | node: ['14', '16', '17']
16 | name: Install, build, and test (Node ${{ matrix.node }})
17 | steps:
18 | - name: Checkout
19 | uses: actions/checkout@v2
20 |
21 | - name: 'Cache pnpm modules'
22 | uses: actions/cache@v2
23 | with:
24 | path: ${{ env.PNPM_CACHE_FOLDER }}
25 | key: ${{ runner.os }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
26 | restore-keys: |
27 | ${{ runner.os }}-pnpm-
28 |
29 | - name: 'Install pnpm 6'
30 | uses: pnpm/action-setup@v2.0.1
31 | with:
32 | version: 6.x
33 |
34 | - name: Setup Node.js
35 | uses: actions/setup-node@v1
36 | with:
37 | node-version: ${{ matrix.node }}
38 |
39 | - name: 'Configure pnpm'
40 | run: pnpm config set store-dir $PNPM_CACHE_FOLDER
41 |
42 | - name: Install
43 | run: pnpm --frozen-lockfile install
44 |
45 | - name: Test
46 | run: pnpm run test
47 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .dvlp
2 | .npm
3 | node_modules
4 | .DS_Store
5 | /index.d.ts
6 | /index.js
7 | /index.mjs
8 | /browser.mjs
9 | /shared.js
10 | /shared.mjs
11 | /directives/
12 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npx --no-install lint-staged
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | scripts
2 | test
3 | src
4 | .*
5 | pnpm-lock.yaml
6 | tsconfig.json
7 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | package.json
2 | test/**/assets
3 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "javascript.implicitProjectConfig.checkJs": true,
3 | "typescript.tsdk": "node_modules/typescript/lib"
4 | }
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018-2020 Alexander Pope
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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | > **Warning**
2 | > Moved to [`@popeindustries/lit`](https://github.com/popeindustries/lit)
3 |
4 | # lit-html-server
5 |
6 | Render [**lit-html**](https://github.com/polymer/lit-html) templates on the server as strings or streams (and in the browser too!). Supports all **lit-html** types, special attribute expressions, and many of the standard directives.
7 |
8 | > Although based on **lit-html** semantics, **lit-html-server** is a great general purpose HTML template streaming library. Tagged template literals are a native JavaScript feature, and the HTML rendered is 100% standard markup, with no special syntax or runtime required!
9 |
10 | ## Usage
11 |
12 | Install with `npm/yarn`:
13 |
14 | ```bash
15 | $ npm install --save @popeindustries/lit-html-server
16 | ```
17 |
18 | ...write your **lit-html** template:
19 |
20 | ```js
21 | const { html } = require('@popeindustries/lit-html-server');
22 | const { classMap } = require('@popeindustries/lit-html-server/directives/class-map.js');
23 | const { until } = require('@popeindustries/lit-html-server/directives/until.js');
24 |
25 | function Layout(data) {
26 | return html`
27 |
28 |
29 |
30 |
31 | ${data.title}
32 |
33 |
34 | ${until(Body(data.api))}
35 |
36 |
37 | `;
38 | }
39 |
40 | async function Body(api) {
41 | // Some Promise-based request method
42 | const data = await fetchRemoteData(api);
43 |
44 | return html`
45 | ${data.title}
46 |
47 | ${data.text}
48 | `;
49 | }
50 | ```
51 |
52 | ...and render (plain HTTP server example, though similar for Express/Fastify/etc):
53 |
54 | ```js
55 | const http = require('http');
56 | const { renderToStream } = require('@popeindustries/lit-html-server');
57 |
58 | http.createServer((request, response) => {
59 | const data = { title: 'Home', api: '/api/home' };
60 |
61 | response.writeHead(200);
62 | // Returns a Node.js Readable stream which can be piped to "response"
63 | renderToStream(Layout(data)).pipe(response);
64 | });
65 | ```
66 |
67 | ## Universal Templates
68 |
69 | With **lit-html-server** and **lit-html** it's possible to write a single template and render it on the server, in a ServiceWorker, and in the browser. In order to be able to render the same template in three different runtime environments, it's necessary to change the version of `html` and `directives` used to process the template. It would certainly be possible to alias imports using a build process (so that `import { html } from 'lit-html'` points to `@popeindustries/lit-html-server` for bundles run in server/ServiceWorker), but a more flexible approach would be to pass references directly to the templates (dependency injection):
70 |
71 | ```js
72 | /**
73 | * layout.js
74 | */
75 | import Body from './body.js';
76 |
77 | export function Layout(context, data) {
78 | const {
79 | html,
80 | directives: { until }
81 | } = context;
82 |
83 | return html`
84 |
85 |
86 |
87 |
88 | ${data.title}
89 |
90 |
91 | ${until(Body(context, data.api))}
92 |
93 |
94 | `;
95 | }
96 |
97 | /**
98 | * server.js
99 | * (transpiler or experimental modules required)
100 | */
101 | import { html, renderToStream } from '@popeindustries/lit-html-server';
102 | import { Layout } from './layout.js';
103 | import { until } from '@popeindustries/lit-html-server/directives/until.js';
104 |
105 | const context = {
106 | html,
107 | directives: {
108 | until
109 | }
110 | };
111 |
112 | http
113 | .createServer((request, response) => {
114 | response.writeHead(200);
115 | renderToStream(Layout(context, data)).pipe(response);
116 | }
117 |
118 | /**
119 | * service-worker.js
120 | * (bundler required)
121 | */
122 | import { html, renderToStream } from '@popeindustries/lit-html-server/browser/index.js';
123 | import { Layout } from './layout.js';
124 | import { until } from '@popeindustries/lit-html-server/browser/directives/until.js';
125 |
126 | const context = {
127 | html,
128 | directives: {
129 | until
130 | }
131 | };
132 |
133 | self.addEventListener('fetch', (event) => {
134 | const stream = renderToStream(Layout(context, data));
135 | const response = new Response(stream, {
136 | headers: {
137 | 'content-type': 'text/html'
138 | }
139 | });
140 |
141 | event.respondWith(response);
142 | });
143 |
144 | /**
145 | * browser.js
146 | */
147 | import { html, render } from 'lit-html';
148 | import { Layout } from './layout.js';
149 | import { until } from 'lit-html/directives/until.js';
150 |
151 | const context = {
152 | html,
153 | directives: {
154 | until
155 | }
156 | };
157 |
158 | render(Layout(context, data), document.body);
159 | ```
160 |
161 | ## API (Node.js)
162 |
163 | ### `html`
164 |
165 | The tag function to apply to HTML template literals (also aliased as `svg`):
166 |
167 | ```js
168 | const { html } = require('@popeindustries/lit-html-server');
169 |
170 | const name = 'Bob';
171 | html`
172 | Hello ${name}!
173 | `;
174 | ```
175 |
176 | All template expressions (values interpolated with `${value}`) are escaped for securely including in HTML by default. An `unsafe-html` [directive](#directives) is available to disable escaping:
177 |
178 | ```js
179 | const { html } = require('@popeindustries/lit-html-server');
180 | const { unsafeHTML } = require('@popeindustries/lit-html-server/directives/unsafe-html.js');
181 |
182 | html`
183 | ${unsafeHTML('danger! ')}
184 | `;
185 | ```
186 |
187 | > The following render methods accept an `options` object with the following properties:
188 | >
189 | > - **`serializePropertyAttributes: boolean`** - enable `JSON.stringify` of property attribute values (default: `false`)
190 |
191 | ### `renderToStream(result: TemplateResult, options: RenderOptions): Readable`
192 |
193 | Returns the result of the template tagged by `html` as a Node.js `Readable` stream of markup:
194 |
195 | ```js
196 | const { html, renderToStream } = require('@popeindustries/lit-html-server');
197 |
198 | const name = 'Bob';
199 | renderToStream(
200 | html`
201 | Hello ${name}!
202 | `
203 | ).pipe(response);
204 | ```
205 |
206 | ### `renderToString(result: TemplateResult, options: RenderOptions): Promise`
207 |
208 | Returns the result of the template tagged by `html` as a Promise which resolves to a string of markup:
209 |
210 | ```js
211 | const { html, renderToString } = require('@popeindustries/lit-html-server');
212 |
213 | const name = 'Bob';
214 | const markup = await renderToString(
215 | html`
216 | Hello ${name}!
217 | `
218 | );
219 | response.end(markup);
220 | ```
221 |
222 | ### `renderToBuffer(result: TemplateResult, options: RenderOptions): Promise`
223 |
224 | Returns the result of the template tagged by `html` as a Promise which resolves to a Buffer of markup:
225 |
226 | ```js
227 | const { html, renderToBuffer } = require('@popeindustries/lit-html-server');
228 |
229 | const name = 'Bob';
230 | const markup = await renderToBuffer(
231 | html`
232 | Hello ${name}!
233 | `
234 | );
235 | response.end(markup);
236 | ```
237 |
238 | ## API (Browser)
239 |
240 | **lit-html-server** may also be used in the browser to render strings of markup, or in a Service Worker script to render streams of markup.
241 |
242 | ### `html`
243 |
244 | The tag function to apply to HTML template literals (also aliased as `svg`):
245 |
246 | ```js
247 | import { html } from '@popeindustries/lit-html-server/browser.mjs';
248 |
249 | const name = 'Bob';
250 | html`
251 | Hello ${name}!
252 | `;
253 | ```
254 |
255 | ### `renderToStream(TemplateResult): ReadableStream`
256 |
257 | Returns the result of the template tagged by `html` as a `ReadableStream` stream of markup. This may be used in a Service Worker script to stream an html response to the browser:
258 |
259 | ```js
260 | import { html, renderToStream } from '@popeindustries/lit-html-server/browser.mjs';
261 |
262 | self.addEventListener('fetch', (event) => {
263 | const name = 'Bob';
264 | const stream = renderToStream(
265 | html`
266 | Hello ${name}!
267 | `
268 | );
269 | const response = new Response(stream, {
270 | headers: {
271 | 'content-type': 'text/html'
272 | }
273 | });
274 |
275 | event.respondWith(response);
276 | });
277 | ```
278 |
279 | > _NOTE: a bundler is required to package modules for use in a Service Worker_
280 |
281 | ### `renderToString(TemplateResult): Promise`
282 |
283 | Returns the result of the template tagged by `html` as a Promise which resolves to a string of markup:
284 |
285 | ```js
286 | import { html, renderToString } from '@popeindustries/lit-html-server/browser.mjs';
287 | const name = 'Bob';
288 | const markup = await renderToString(
289 | html`
290 | Hello ${name}!
291 | `
292 | );
293 | document.body.innerHtml = markup;
294 | ```
295 |
296 | ## Writing templates
297 |
298 | In general, all of the standard **lit-html** rules and semantics apply when rendering templates on the server with **lit-html-server** (read more about [**lit-html**](https://polymer.github.io/lit-html/guide/) and writing templates [here](https://polymer.github.io/lit-html/guide/writing-templates.html)).
299 |
300 | ### Template structure
301 |
302 | Although there are no technical restrictions for doing so, if you plan on writing templates for use on both the server and client, you should abide by the same rules:
303 |
304 | - templates should be well-formed when all expressions are replaced with empty values
305 | - expressions should only occur in attribute-value and text-content positions
306 | - expressions should not appear where tag or attribute names would appear
307 | - templates can have multiple top-level elements and text
308 | - templates should not contain unclosed elements
309 |
310 | ### Expressions
311 |
312 | All of the **lit-html** [expression syntax](https://polymer.github.io/lit-html/guide/writing-templates.html#binding-types) is supported:
313 |
314 | - text:
315 |
316 | ```js
317 | html`
318 | Hello ${name}
319 | `;
320 | //=> Hello Bob
321 | ```
322 |
323 | - attribute:
324 |
325 | ```js
326 | html`
327 |
328 | `;
329 | //=>
330 | ```
331 |
332 | - boolean attribute (attribute markup removed with falsey expression values):
333 |
334 | ```js
335 | html`
336 |
337 | `;
338 | //=> if truthy
339 | //=> if falsey
340 | ```
341 |
342 | - property (attribute markup removed unless `RenderOptions.serializePropertyAttributes = true` ):
343 |
344 | ```js
345 | const value = { some: 'text' };
346 | html`
347 |
348 | `;
349 | //=>
350 | html`
351 |
352 | `;
353 | //=>
354 | // (when render options.serializePropertyAttributes = true)
355 | ```
356 |
357 | - event handler (attribute markup removed):
358 |
359 | ```js
360 | const fn = (e) => console.log('clicked');
361 | html`
362 | Click Me
363 | `;
364 | //=> Click Me
365 | ```
366 |
367 | ### Types
368 |
369 | Most of the **lit-html** [value types](https://polymer.github.io/lit-html/guide/writing-templates.html#supported-types) are supported:
370 |
371 | - primitives: `String`, `Number`, `Boolean`, `null`, and `undefined`
372 |
373 | > Note that `undefined` handling is the same as in **lit-html**: stringified when used as an attribute value, and ignored when used as a node value
374 |
375 | - nested templates:
376 |
377 | ```js
378 | const header = html`
379 | Header
380 | `;
381 | const page = html`
382 | ${header}
383 | This is some text
384 | `;
385 | ```
386 |
387 | - Arrays / iterables (sync):
388 |
389 | ```js
390 | const items = [1, 2, 3];
391 | html`
392 |
393 | ${items.map(
394 | (item) =>
395 | html`
396 | ${item}
397 | `
398 | )}
399 |
400 | `;
401 | html`
402 | total = ${new Set(items)}
403 | `;
404 | ```
405 |
406 | - Promises:
407 |
408 | ```js
409 | const promise = fetch('sample.txt').then((r) => r.text());
410 | html`
411 | The response is ${promise}.
412 | `;
413 | ```
414 |
415 | > Note that **lit-html** no longer supports Promise values. Though **lit-html-server** does, it's recommended to use the `until` directive instead when authoring templates to be used in both environments.
416 |
417 | ### Directives
418 |
419 | Most of the built-in **lit-html** [directives](https://lit-html.polymer-project.org/guide/template-reference#built-in-directives) are also included for compatibility when using templates on the server and in the browser (even though some directives are no-ops in a server rendered context):
420 |
421 | - `asyncAppend(value)`: Renders the items of an AsyncIterable, appending new values after previous values:
422 |
423 | ```js
424 | const { asyncAppend } = require('@popeindustries/lit-html-server/directives/async-append.js');
425 |
426 | html`
427 |
428 | ${asyncAppend(someListIterator)}
429 |
430 | `;
431 | ```
432 |
433 | - `cache(value)`: Enables fast switching between multiple templates by caching previous results. Since it's generally not desireable to cache between requests, this is a no-op:
434 |
435 | ```js
436 | const { cache } = require('@popeindustries/lit-html-server/directives/cache.js');
437 |
438 | cache(
439 | loggedIn
440 | ? html`
441 | You are logged in
442 | `
443 | : html`
444 | Please log in
445 | `
446 | );
447 | ```
448 |
449 | - `classMap(classInfo)`: applies css classes to the `class` attribute. 'classInfo' keys are added as class names if values are truthy:
450 |
451 | ```js
452 | const { classMap } = require('@popeindustries/lit-html-server/directives/class-map.js');
453 |
454 | html`
455 |
456 | `;
457 | ```
458 |
459 | - `guard(value, fn)`: no-op since re-rendering does not apply (renders result of `fn`):
460 |
461 | ```js
462 | const { guard } = require('@popeindustries/lit-html-server/directives/guard.js');
463 |
464 | html`
465 |
466 | ${guard(items, () =>
467 | items.map(
468 | (item) =>
469 | html`
470 | ${item}
471 | `
472 | )
473 | )}
474 |
475 | `;
476 | ```
477 |
478 | - `ifDefined(value)`: sets the attribute if the value is defined and removes the attribute if the value is undefined:
479 |
480 | ```js
481 | const { ifDefined } = require('@popeindustries/lit-html-server/directives/if-defined.js');
482 |
483 | html`
484 |
485 | `;
486 | ```
487 |
488 | - `repeat(items, keyfnOrTemplate, template))`: no-op since re-rendering does not apply (maps `items` over `template`)
489 |
490 | ```js
491 | const { repeat } = require('@popeindustries/lit-html-server/directives/repeat.js');
492 |
493 | html`
494 |
495 | ${repeat(
496 | items,
497 | (i) => i.id,
498 | (i, index) =>
499 | html`
500 | ${index}: ${i.name}
501 | `
502 | )}
503 |
504 | `;
505 | ```
506 |
507 | - `styleMap(styleInfo)`: applies css properties to the `style` attribute. 'styleInfo' keys and values are added as style properties:
508 |
509 | ```js
510 | const { styleMap } = require('@popeindustries/lit-html-server/directives/style-map.js');
511 |
512 | html`
513 |
514 | `;
515 | ```
516 |
517 | - `unsafeHTML(value)`: render `value` without HTML escaping:
518 |
519 | ```js
520 | const { unsafeHTML } = require('@popeindustries/lit-html-server/directives/unsafe-html.js');
521 |
522 | html`
523 | ${unsafeHTML("hey! it's dangerous! ")}
524 | `;
525 | ```
526 |
527 | - `until(...args)`: renders one of a series of values, including Promises, in priority order. Since it's not possible to render more than once in a server context, primitive synchronous values are prioritized over asynchronous Promises. If no synchronous values are passed, the last value is rendered regardless of type:
528 |
529 | ```js
530 | const { until } = require('@popeindustries/lit-html-server/directives/until.js');
531 |
532 | html`
533 |
534 | ${until(
535 | fetch('content.json').then((r) => r.json()),
536 | html`
537 | Loading...
538 | `
539 | )}
540 |
541 | `;
542 | // => renders Loading...
543 |
544 | html`
545 |
546 | ${until(
547 | fetch('content.json').then((r) => r.json()),
548 | isBrowser
549 | ? html`
550 | Loading...
551 | `
552 | : undefined
553 | )}
554 |
555 | `;
556 | // => renders fetch result
557 | ```
558 |
559 | ## Thanks!
560 |
561 | Thanks to [Thomas Parslow](https://github.com/almost) for the [stream-template](https://github.com/almost/stream-template) library that was the inspiration for this streaming implementation, and thanks to [Justin Fagnani](https://github.com/justinfagnani) and the [team](https://github.com/Polymer/lit-html/graphs/contributors) behind the **lit-html** project!
562 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@popeindustries/lit-html-server",
3 | "version": "4.0.2",
4 | "description": "Render lit-html templates on the server",
5 | "author": "Alexander Pope ",
6 | "keywords": [
7 | "html template",
8 | "lit-html",
9 | "ssr",
10 | "stream",
11 | "streaming",
12 | "tagged template literal",
13 | "template",
14 | "template render"
15 | ],
16 | "main": "index.js",
17 | "types": "index.d.ts",
18 | "imports": {
19 | "#buffer": {
20 | "browser": "./src/browser-buffer.js",
21 | "default": "node:buffer"
22 | },
23 | "#stream": {
24 | "browser": "./src/browser-stream.js",
25 | "default": "node:stream"
26 | }
27 | },
28 | "exports": {
29 | ".": {
30 | "browser": "./browser.mjs",
31 | "import": "./index.mjs",
32 | "require": "./index.js",
33 | "types": "./index.d.ts"
34 | },
35 | "./directives/async-append.js": {
36 | "browser": "./directives/async-append.mjs",
37 | "import": "./directives/async-append.mjs",
38 | "require": "./directives/async-append.js",
39 | "types": "./directives/async-append.d.ts"
40 | },
41 | "./directives/async-replace.js": {
42 | "browser": "./directives/async-replace.mjs",
43 | "import": "./directives/async-replace.mjs",
44 | "require": "./directives/async-append.js",
45 | "types": "./directives/async-append.d.ts"
46 | },
47 | "./directives/cache.js": {
48 | "browser": "./directives/cache.mjs",
49 | "import": "./directives/cache.mjs",
50 | "require": "./directives/cache.js",
51 | "types": "./directives/cache.d.ts"
52 | },
53 | "./directives/class-map.js": {
54 | "browser": "./directives/class-map.mjs",
55 | "import": "./directives/class-map.mjs",
56 | "require": "./directives/class-map.js",
57 | "types": "./directives/class-map.d.ts"
58 | },
59 | "./directives/guard.js": {
60 | "browser": "./directives/guard.mjs",
61 | "import": "./directives/guard.mjs",
62 | "require": "./directives/guard.js",
63 | "types": "./directives/guard.d.ts"
64 | },
65 | "./directives/if-defined.js": {
66 | "browser": "./directives/if-defined.mjs",
67 | "import": "./directives/if-defined.mjs",
68 | "require": "./directives/if-defined.js",
69 | "types": "./directives/if-defined.d.ts"
70 | },
71 | "./directives/repeat.js": {
72 | "browser": "./directives/repeat.mjs",
73 | "import": "./directives/repeat.mjs",
74 | "require": "./directives/repeat.js",
75 | "types": "./directives/repeat.d.ts"
76 | },
77 | "./directives/style-map.js": {
78 | "browser": "./directives/style-map.mjs",
79 | "import": "./directives/style-map.mjs",
80 | "require": "./directives/style-map.js",
81 | "types": "./directives/style-map.d.ts"
82 | },
83 | "./directives/unsafe-html.js": {
84 | "browser": "./directives/unsafe-html.mjs",
85 | "import": "./directives/unsafe-html.mjs",
86 | "require": "./directives/unsafe-html.js",
87 | "types": "./directives/unsafe-html.d.ts"
88 | },
89 | "./directives/until.js": {
90 | "browser": "./directives/until.mjs",
91 | "import": "./directives/until.mjs",
92 | "require": "./directives/until.js",
93 | "types": "./directives/until.d.ts"
94 | }
95 | },
96 | "repository": "https://github.com/popeindustries/lit-html-server.git",
97 | "license": "MIT",
98 | "engines": {
99 | "node": ">=14"
100 | },
101 | "devDependencies": {
102 | "@rollup/plugin-commonjs": "^22.0.0",
103 | "@rollup/plugin-node-resolve": "^13.3.0",
104 | "@types/node": "^18.0.0",
105 | "autocannon": "^7.9.0",
106 | "chai": "^4.3.6",
107 | "chalk": "^5.0.1",
108 | "eslint": "^8.17.0",
109 | "eslint-config-prettier": "^8.5.0",
110 | "eslint-plugin-prettier": "^4.0.0",
111 | "get-stream": "^6.0.1",
112 | "husky": "^8.0.1",
113 | "lint-staged": "^13.0.1",
114 | "lit-html": "^1.4.1",
115 | "mocha": "^10.0.0",
116 | "prettier": "^2.7.1",
117 | "puppeteer": "^14.4.0",
118 | "rollup": "^2.75.6",
119 | "send": "^0.18.0",
120 | "typescript": "^4.7.3"
121 | },
122 | "scripts": {
123 | "build": "node ./scripts/build.js",
124 | "format": "prettier --write './{src,test}/**/*.{js,json}'",
125 | "lint": "eslint './{src,test}/**/*.js'",
126 | "perf": "node test/perf.js",
127 | "prepublishOnly": "pnpm run build",
128 | "test": "pnpm run build && pnpm run lint && pnpm run test:unit && pnpm run test:browser",
129 | "test:unit": "NODE_ENV=test mocha \"test/*-test.js\" --reporter spec --bail --timeout 2000",
130 | "test:browser": "node ./test/browser/cli.mjs",
131 | "typecheck": "tsc --noEmit"
132 | },
133 | "prettier": {
134 | "arrowParens": "always",
135 | "htmlWhitespaceSensitivity": "ignore",
136 | "printWidth": 100,
137 | "singleQuote": true
138 | },
139 | "lint-staged": {
140 | "*.js": [
141 | "eslint"
142 | ],
143 | "*.{js,json,md,html}": [
144 | "prettier --write"
145 | ]
146 | }
147 | }
148 |
--------------------------------------------------------------------------------
/scripts/build.js:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 |
3 | const commonjs = require('@rollup/plugin-commonjs');
4 | const fs = require('fs');
5 | const path = require('path');
6 | const { nodeResolve } = require('@rollup/plugin-node-resolve');
7 | const { rollup } = require('rollup');
8 |
9 | if (!fs.existsSync(path.resolve('directives'))) {
10 | fs.mkdirSync(path.resolve('directives'));
11 | }
12 |
13 | const plugins = [
14 | commonjs(),
15 | nodeResolve({
16 | exportConditions: ['browser', 'import', 'require', 'default'],
17 | preferBuiltins: true,
18 | }),
19 | ];
20 | const input = {
21 | external: (id) => id === '#buffer' || id === '#stream' || path.basename(id) === 'shared.js',
22 | input: 'src/index.js',
23 | plugins,
24 | };
25 | const tasks = [
26 | [
27 | input,
28 | {
29 | file: 'index.js',
30 | format: 'cjs',
31 | },
32 | (content) => content.replace(/(#)(buffer|stream)/g, '$2'),
33 | ],
34 | [
35 | input,
36 | {
37 | file: 'index.mjs',
38 | format: 'esm',
39 | },
40 | (content) =>
41 | content.replace(/\.\/shared\.js/g, './shared.mjs').replace(/(#)(buffer|stream)/g, '$2'),
42 | ],
43 | [
44 | {
45 | external: (id) => path.basename(id) === 'shared.js',
46 | input: 'src/index.js',
47 | plugins: [
48 | commonjs(),
49 | nodeResolve({ exportConditions: ['browser', 'import', 'require', 'default'] }),
50 | ],
51 | },
52 | {
53 | file: 'browser.mjs',
54 | format: 'esm',
55 | },
56 | (content) => content.replace(/\.\/shared\.js/g, './shared.mjs'),
57 | ],
58 | [
59 | {
60 | input: 'src/shared.js',
61 | plugins,
62 | },
63 | {
64 | file: 'shared.mjs',
65 | format: 'esm',
66 | },
67 | ],
68 | [
69 | {
70 | input: 'src/shared.js',
71 | plugins,
72 | },
73 | {
74 | file: 'shared.js',
75 | format: 'cjs',
76 | },
77 | ],
78 | ...configDirectives('cjs', '.js', true),
79 | ...configDirectives('esm', '.mjs'),
80 | ];
81 |
82 | (async function () {
83 | for (const [inputOptions, outputOptions, preWrite] of tasks) {
84 | const bundle = await rollup(inputOptions);
85 | const { output } = await bundle.generate(outputOptions);
86 | for (const chunkOrAsset of output) {
87 | let content = chunkOrAsset.isAsset ? chunkOrAsset.source : chunkOrAsset.code;
88 | if (preWrite) {
89 | content = preWrite(content);
90 | }
91 | write(path.resolve(outputOptions.file), content);
92 | }
93 | }
94 | write(
95 | path.resolve('index.d.ts'),
96 | fs.readFileSync(path.resolve('src/types.d.ts'), 'utf8').replace(/\/\* export \*\//g, 'export')
97 | );
98 | })();
99 |
100 | function configDirectives(format, extension, moveTypes) {
101 | const config = [];
102 | const dir = path.resolve('src/directives');
103 | const directives = fs.readdirSync(dir);
104 | const preWrite = (content) => content.replace('../shared.js', '../shared.mjs');
105 |
106 | for (const directive of directives) {
107 | const input = path.join(dir, directive);
108 | let filename = path.join('directives', directive);
109 |
110 | if (path.extname(directive) === '.js') {
111 | if (extension === '.mjs') {
112 | filename = filename.replace('.js', '.mjs');
113 | }
114 |
115 | config.push([
116 | {
117 | external: (id) => id !== input,
118 | input,
119 | plugins,
120 | },
121 | {
122 | file: filename,
123 | format,
124 | },
125 | extension === '.mjs' ? preWrite : undefined,
126 | ]);
127 | } else if (path.extname(directive) === '.ts' && moveTypes) {
128 | fs.copyFileSync(input, path.resolve(filename));
129 | }
130 | }
131 |
132 | return config;
133 | }
134 |
135 | function write(filepath, content) {
136 | const dir = path.dirname(filepath);
137 | if (!fs.existsSync(dir)) {
138 | fs.mkdirSync(dir, { recursive: true });
139 | }
140 | fs.writeFileSync(filepath, content);
141 | }
142 |
--------------------------------------------------------------------------------
/src/browser-buffer.js:
--------------------------------------------------------------------------------
1 | /**
2 | * A minimal Buffer class that implements a partial brower Buffer API around strings.
3 | * This module should be provided as an alias for Node's built-in 'buffer' module
4 | * when run in the browser.
5 | * @see package.json#browser
6 | */
7 | export class Buffer {
8 | /**
9 | * Determine if 'buffer' is a buffer
10 | *
11 | * @param { any } buffer
12 | * @returns { boolean }
13 | */
14 | static isBuffer(buffer) {
15 | return buffer != null && typeof buffer === 'object' && buffer.string !== undefined;
16 | }
17 |
18 | /**
19 | * Create buffer from 'string'
20 | *
21 | * @param { string } string
22 | * @returns { Buffer }
23 | */
24 | static from(string) {
25 | string = typeof string === 'string' ? string : String(string);
26 | return new Buffer(string);
27 | }
28 |
29 | /**
30 | * Join 'buffers' into a single string
31 | *
32 | * @param { Array } buffers
33 | * @param { number } [length]
34 | * @returns { Buffer }
35 | */
36 | static concat(buffers, length) {
37 | let string = '';
38 |
39 | for (let i = 0, n = buffers.length; i < n; i++) {
40 | const buffer = buffers[i];
41 |
42 | string += typeof buffer === 'string' ? buffer : String(buffer);
43 | }
44 |
45 | if (length !== undefined && string.length > length) {
46 | string = string.slice(0, length);
47 | }
48 |
49 | return new Buffer(string);
50 | }
51 |
52 | /**
53 | * Construct Buffer instance
54 | *
55 | * @param { string } string
56 | */
57 | constructor(string) {
58 | this.string = string;
59 | }
60 |
61 | /**
62 | * Stringify
63 | *
64 | * @returns { string }
65 | */
66 | toString() {
67 | return this.string;
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/browser-stream-template-renderer.js:
--------------------------------------------------------------------------------
1 | /* global ReadableStream */
2 | /**
3 | * A custom Readable stream factory for rendering a template result to a stream
4 | *
5 | * @param { TemplateResult } result - a template result returned from call to "html`...`"
6 | * @param { TemplateResultProcessor } processor
7 | * @param { RenderOptions } [options]
8 | * @returns { ReadableStream }
9 | */
10 | export function browserStreamTemplateRenderer(result, processor, options) {
11 | if (typeof ReadableStream === 'undefined') {
12 | throw Error('ReadableStream not supported on this platform');
13 | }
14 | if (typeof TextEncoder === 'undefined') {
15 | throw Error('TextEncoder not supported on this platform');
16 | }
17 |
18 | return new ReadableStream({
19 | // @ts-ignore
20 | process: null,
21 | start(controller) {
22 | const encoder = new TextEncoder();
23 | const underlyingSource = this;
24 | let stack = [result];
25 |
26 | this.process = processor.getProcessor(
27 | {
28 | push(chunk) {
29 | if (chunk === null) {
30 | controller.close();
31 | return false;
32 | }
33 |
34 | controller.enqueue(encoder.encode(chunk.toString()));
35 | // Pause processing (return "false") if stream is full
36 | return controller.desiredSize != null ? controller.desiredSize > 0 : true;
37 | },
38 | destroy(err) {
39 | controller.error(err);
40 | underlyingSource.process = undefined;
41 | // @ts-ignore
42 | stack = undefined;
43 | },
44 | },
45 | stack,
46 | 16384,
47 | options
48 | );
49 | },
50 | pull() {
51 | this.process();
52 | },
53 | });
54 | }
55 |
--------------------------------------------------------------------------------
/src/browser-stream.js:
--------------------------------------------------------------------------------
1 | /**
2 | * A minimal Readable stream class to prevent browser parse errors resulting from
3 | * the static importing of Node-only code.
4 | * This module should be provided as an alias for Node's built-in 'stream' module
5 | * when run in the browser.
6 | * @see package.json#browser
7 | */
8 | export class Readable {}
9 |
--------------------------------------------------------------------------------
/src/default-template-processor.js:
--------------------------------------------------------------------------------
1 | import {
2 | AttributePart,
3 | BooleanAttributePart,
4 | EventAttributePart,
5 | NodePart,
6 | PropertyAttributePart,
7 | } from './parts.js';
8 |
9 | /**
10 | * Class representing the default Template processor.
11 | * Exposes factory functions for generating Part instances to use for
12 | * resolving a template's dynamic values.
13 | */
14 | export class DefaultTemplateProcessor {
15 | /**
16 | * Create part instance for dynamic attribute values
17 | *
18 | * @param { string } name
19 | * @param { Array } strings
20 | * @param { string } tagName
21 | * @returns { AttributePart }
22 | */
23 | handleAttributeExpressions(name, strings = [], tagName) {
24 | const prefix = name[0];
25 |
26 | if (prefix === '.') {
27 | return new PropertyAttributePart(name.slice(1), strings, tagName);
28 | } else if (prefix === '@') {
29 | return new EventAttributePart(name.slice(1), strings, tagName);
30 | } else if (prefix === '?') {
31 | return new BooleanAttributePart(name.slice(1), strings, tagName);
32 | }
33 |
34 | return new AttributePart(name, strings, tagName);
35 | }
36 |
37 | /**
38 | * Create part instance for dynamic text values
39 | *
40 | * @param { string } tagName
41 | * @returns { NodePart }
42 | */
43 | handleTextExpression(tagName) {
44 | return new NodePart(tagName);
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/default-template-result-processor.js:
--------------------------------------------------------------------------------
1 | /* eslint no-constant-condition:0 */
2 | import {
3 | isArray,
4 | isAsyncIterator,
5 | isBuffer,
6 | isIteratorResult,
7 | isPromise,
8 | isTemplateResult,
9 | } from './is.js';
10 | import { Buffer } from '#buffer';
11 |
12 | /**
13 | * Class for the default TemplateResult processor
14 | * used by Promise/Stream TemplateRenderers.
15 | *
16 | * @implements TemplateResultProcessor
17 | */
18 | export class DefaultTemplateResultProcessor {
19 | /**
20 | * Process "stack" and push chunks to "renderer"
21 | *
22 | * @param { TemplateResultRenderer } renderer
23 | * @param { Array } stack
24 | * @param { number } [highWaterMark] - byte length to buffer before pushing data
25 | * @param { RenderOptions } [options]
26 | * @returns { () => void }
27 | */
28 | getProcessor(renderer, stack, highWaterMark = 0, options) {
29 | /** @type { Array } */
30 | const buffer = [];
31 | let bufferLength = 0;
32 | let processing = false;
33 |
34 | function flushBuffer() {
35 | if (buffer.length > 0) {
36 | const keepPushing = renderer.push(Buffer.concat(buffer, bufferLength));
37 |
38 | bufferLength = buffer.length = 0;
39 | return keepPushing;
40 | }
41 | }
42 |
43 | return function process() {
44 | if (processing) {
45 | return;
46 | }
47 |
48 | while (true) {
49 | processing = true;
50 | let chunk = stack[0];
51 | let breakLoop = false;
52 | let popStack = true;
53 |
54 | // Done
55 | if (chunk === undefined) {
56 | flushBuffer();
57 | return renderer.push(null);
58 | }
59 |
60 | if (isTemplateResult(chunk)) {
61 | popStack = false;
62 | chunk = getTemplateResultChunk(chunk, stack, options);
63 | }
64 |
65 | // Skip if finished reading TemplateResult (null)
66 | if (chunk !== null) {
67 | if (isBuffer(chunk)) {
68 | buffer.push(chunk);
69 | bufferLength += chunk.length;
70 | // Flush buffered data if over highWaterMark
71 | if (bufferLength > highWaterMark) {
72 | // Break if backpressure triggered
73 | breakLoop = !flushBuffer();
74 | processing = !breakLoop;
75 | }
76 | } else if (isPromise(chunk)) {
77 | // Flush buffered data before waiting for Promise
78 | flushBuffer();
79 | // "processing" is still true, so prevented from restarting until Promise resolved
80 | breakLoop = true;
81 | // Add pending Promise for value to stack
82 | stack.unshift(chunk);
83 | chunk
84 | .then((chunk) => {
85 | // Handle IteratorResults from AsyncIterator
86 | if (isIteratorResult(chunk)) {
87 | if (chunk.done) {
88 | // Clear resolved Promise
89 | stack.shift();
90 | // Clear AsyncIterator
91 | stack.shift();
92 | } else {
93 | // Replace resolved Promise with IteratorResult value
94 | stack[0] = chunk.value;
95 | }
96 | } else {
97 | // Replace resolved Promise with value
98 | stack[0] = chunk;
99 | }
100 | processing = false;
101 | process();
102 | })
103 | .catch((err) => {
104 | stack.length = 0;
105 | renderer.destroy(err);
106 | });
107 | } else if (isArray(chunk)) {
108 | // First remove existing Array if at top of stack (not added by pending TemplateResult)
109 | if (stack[0] === chunk) {
110 | popStack = false;
111 | stack.shift();
112 | }
113 | stack.unshift(...chunk);
114 | } else if (isAsyncIterator(chunk)) {
115 | popStack = false;
116 | // Add AsyncIterator back to stack (will be cleared when done iterating)
117 | if (stack[0] !== chunk) {
118 | stack.unshift(chunk);
119 | }
120 | // Add pending Promise for IteratorResult to stack
121 | stack.unshift(chunk[Symbol.asyncIterator]().next());
122 | } else {
123 | stack.length = 0;
124 | return renderer.destroy(Error(`unknown chunk type: ${chunk}`));
125 | }
126 | }
127 |
128 | if (popStack) {
129 | stack.shift();
130 | }
131 |
132 | if (breakLoop) {
133 | break;
134 | }
135 | }
136 | };
137 | }
138 | }
139 |
140 | /**
141 | * Retrieve next chunk from "result".
142 | * Adds nested TemplateResults to the stack if necessary.
143 | *
144 | * @param { TemplateResult } result
145 | * @param { Array } stack
146 | * @param { RenderOptions } [options]
147 | */
148 | function getTemplateResultChunk(result, stack, options) {
149 | let chunk = result.readChunk(options);
150 |
151 | // Skip empty strings
152 | if (isBuffer(chunk) && chunk.length === 0) {
153 | chunk = result.readChunk(options);
154 | }
155 |
156 | // Finished reading, dispose
157 | if (chunk === null) {
158 | stack.shift();
159 | } else if (isTemplateResult(chunk)) {
160 | // Add to top of stack
161 | stack.unshift(chunk);
162 | chunk = getTemplateResultChunk(chunk, stack, options);
163 | }
164 |
165 | return chunk;
166 | }
167 |
--------------------------------------------------------------------------------
/src/directives/async-append.d.ts:
--------------------------------------------------------------------------------
1 | import { Part } from '../index.js';
2 |
3 | export declare const asyncAppend: (
4 | value: AsyncIterable,
5 | mapper?: ((v: unknown, index?: number | undefined) => unknown) | undefined
6 | ) => (part: Part) => void;
7 |
--------------------------------------------------------------------------------
/src/directives/async-append.js:
--------------------------------------------------------------------------------
1 | import { directive, isNodePart } from '../shared.js';
2 |
3 | /**
4 | * Render items of an AsyncIterable
5 | */
6 | export const asyncAppend = directive((value, mapper) => (part) => {
7 | if (!isNodePart(part)) {
8 | throw Error('The `asyncAppend` directive can only be used in text nodes');
9 | }
10 |
11 | const val = /** @type { AsyncIterableIterator } */ (value);
12 | const map = /** @type { (value: unknown, index: number) => unknown } */ (mapper);
13 |
14 | if (mapper !== undefined) {
15 | value = createMappedAsyncIterable(val, map);
16 | }
17 |
18 | part.setValue(value);
19 | });
20 |
21 | /**
22 | * Create new asyncIterator from "asuncIterable" that maps results with "mapper"
23 | *
24 | * @param { AsyncIterableIterator } asyncIterable
25 | * @param { (value: unknown, index: number) => unknown } mapper
26 | */
27 | async function* createMappedAsyncIterable(asyncIterable, mapper) {
28 | let i = 0;
29 |
30 | for await (const item of asyncIterable) {
31 | yield mapper(item, i++);
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/directives/async-replace.d.ts:
--------------------------------------------------------------------------------
1 | import { Part } from '../index.js';
2 |
3 | export declare const asyncReplace: (
4 | value: AsyncIterable,
5 | mapper?: ((v: unknown, index?: number | undefined) => unknown) | undefined
6 | ) => (part: Part) => void;
7 |
--------------------------------------------------------------------------------
/src/directives/async-replace.js:
--------------------------------------------------------------------------------
1 | import { directive, isNodePart } from '../shared.js';
2 |
3 | /**
4 | * Render items of an AsyncIterable, replacing previous items as they are resolved.
5 | * Not possible to render more than once in a server context, so only the first item is rendered.
6 | */
7 | export const asyncReplace = directive((value, mapper) => (part) => {
8 | if (!isNodePart(part)) {
9 | throw Error('The `asyncReplace` directive can only be used in text nodes');
10 | }
11 |
12 | const val = /** @type { AsyncIterableIterator } */ (value);
13 | const map = /** @type { (value: unknown, index: number) => unknown } */ (mapper);
14 |
15 | part.setValue(
16 | val.next().then(({ value }) => {
17 | if (mapper !== undefined) {
18 | value = map(value, 0);
19 | }
20 |
21 | return value;
22 | })
23 | );
24 | });
25 |
--------------------------------------------------------------------------------
/src/directives/cache.d.ts:
--------------------------------------------------------------------------------
1 | import { Part } from '../index.js';
2 |
3 | export declare const cache: (value: unknown) => (part: Part) => void;
4 |
--------------------------------------------------------------------------------
/src/directives/cache.js:
--------------------------------------------------------------------------------
1 | import { directive, isNodePart } from '../shared.js';
2 |
3 | /**
4 | * Enables fast switching between multiple templates by caching previous results.
5 | * Not possible/desireable to cache between server-side requests, so this is a no-op.
6 | */
7 | export const cache = directive((value) => (part) => {
8 | if (!isNodePart(part)) {
9 | throw Error('The `cache` directive can only be used in text nodes');
10 | }
11 | part.setValue(value);
12 | });
13 |
--------------------------------------------------------------------------------
/src/directives/class-map.d.ts:
--------------------------------------------------------------------------------
1 | import { Part } from '../index.js';
2 |
3 | export const classMap: (classInfo: {
4 | [name: string]: string | boolean | number;
5 | }) => (part: Part) => void;
6 |
--------------------------------------------------------------------------------
/src/directives/class-map.js:
--------------------------------------------------------------------------------
1 | import { directive, isAttributePart } from '../shared.js';
2 |
3 | /**
4 | * Applies CSS classes, where'classInfo' keys are added as class names if values are truthy.
5 | * Only applies to 'class' attribute.
6 | *
7 | * @type { (classInfo: { [name: string]: string | boolean | number }) => (part: Part) => void }
8 | */
9 | export const classMap = directive((classInfo) => (part) => {
10 | if (!isAttributePart(part) || part.name !== 'class') {
11 | throw Error('The `classMap` directive can only be used in the `class` attribute');
12 | }
13 |
14 | const classes = /** @type { { [name: string]: string } } */ (classInfo);
15 | let value = '';
16 |
17 | for (const key in classes) {
18 | if (classes[key]) {
19 | value += `${value.length ? ' ' : ''}${key}`;
20 | }
21 | }
22 |
23 | part.setValue(value);
24 | });
25 |
--------------------------------------------------------------------------------
/src/directives/guard.d.ts:
--------------------------------------------------------------------------------
1 | import { Part } from '../index.js';
2 |
3 | export const guard: (value: unknown, fn: () => unknown) => (part: Part) => void;
4 |
--------------------------------------------------------------------------------
/src/directives/guard.js:
--------------------------------------------------------------------------------
1 | import { directive } from '../shared.js';
2 |
3 | /**
4 | * Guard against re-render.
5 | * Not possible to compare against previous render in a server context,
6 | * so this is a no-op.
7 | */
8 | export const guard = directive((value, fn) => (part) => {
9 | const f = /** @type { () => unknown } */ (fn);
10 |
11 | part.setValue(f());
12 | });
13 |
--------------------------------------------------------------------------------
/src/directives/if-defined.d.ts:
--------------------------------------------------------------------------------
1 | import { Part } from '../index.js';
2 |
3 | export const ifDefined: (value: unknown) => (part: Part) => void;
4 |
--------------------------------------------------------------------------------
/src/directives/if-defined.js:
--------------------------------------------------------------------------------
1 | import { directive, isAttributePart, nothing } from '../shared.js';
2 |
3 | /**
4 | * Sets the attribute if 'value' is defined,
5 | * removes the attribute if undefined.
6 | */
7 | export const ifDefined = directive((value) => (part) => {
8 | if (value === undefined && isAttributePart(part)) {
9 | return part.setValue(nothing);
10 | }
11 | part.setValue(value);
12 | });
13 |
--------------------------------------------------------------------------------
/src/directives/repeat.d.ts:
--------------------------------------------------------------------------------
1 | import { Part } from '../index.js';
2 |
3 | export const repeat: (
4 | items: Array,
5 | keyFnOrTemplate: (item: unknown, index: number) => unknown,
6 | template?: (item: unknown, index: number) => unknown
7 | ) => (part: Part) => void;
8 |
--------------------------------------------------------------------------------
/src/directives/repeat.js:
--------------------------------------------------------------------------------
1 | import { directive } from '../shared.js';
2 |
3 | /**
4 | * Loop through 'items' and call 'template'.
5 | * No concept of efficient re-ordering possible in server context,
6 | * so this is a simple no-op map operation.
7 | */
8 | export const repeat = directive((items, keyFnOrTemplate, template) => {
9 | if (template === undefined) {
10 | template = keyFnOrTemplate;
11 | }
12 |
13 | const arrayItems = /** @type { Array } */ (items);
14 | const tmpl = /** @type { (item: unknown, index: number) => unknown } */ (template);
15 |
16 | return (part) => {
17 | part.setValue(arrayItems.map((item, index) => tmpl(item, index)));
18 | };
19 | });
20 |
--------------------------------------------------------------------------------
/src/directives/style-map.d.ts:
--------------------------------------------------------------------------------
1 | import { Part } from '../index.js';
2 |
3 | export const styleMap: (styleInfo: { [name: string]: string }) => (part: Part) => void;
4 |
--------------------------------------------------------------------------------
/src/directives/style-map.js:
--------------------------------------------------------------------------------
1 | import { directive, isAttributePart } from '../shared.js';
2 |
3 | /**
4 | * Apply CSS properties, where 'styleInfo' keys and values are added as CSS properties.
5 | * Only applies to 'style' attribute.
6 | */
7 | export const styleMap = directive((styleInfo) => (part) => {
8 | if (!isAttributePart(part) || part.name !== 'style') {
9 | throw Error('The `styleMap` directive can only be used in the `style` attribute');
10 | }
11 |
12 | const styles = /** @type { { [name: string]: string } } */ (styleInfo);
13 | let value = '';
14 |
15 | for (const key in styles) {
16 | value += `${value.length ? '; ' : ''}${key}: ${styles[key]}`;
17 | }
18 |
19 | part.setValue(value);
20 | });
21 |
--------------------------------------------------------------------------------
/src/directives/unsafe-html.d.ts:
--------------------------------------------------------------------------------
1 | import { Part } from '../index.js';
2 |
3 | export const unsafeHTML: (value: unknown) => (part: Part) => void;
4 |
--------------------------------------------------------------------------------
/src/directives/unsafe-html.js:
--------------------------------------------------------------------------------
1 | import { directive, isNodePart, unsafePrefixString } from '../shared.js';
2 |
3 | /**
4 | * Render "value" without HTML escaping
5 | */
6 | export const unsafeHTML = directive((value) => (part) => {
7 | if (!isNodePart(part)) {
8 | throw Error('The `unsafeHTML` directive can only be used in text nodes');
9 | }
10 | part.setValue(`${unsafePrefixString}${value}`);
11 | });
12 |
--------------------------------------------------------------------------------
/src/directives/until.d.ts:
--------------------------------------------------------------------------------
1 | import { Part } from '../index.js';
2 |
3 | export const until: (...args: Array) => (part: Part) => void;
4 |
--------------------------------------------------------------------------------
/src/directives/until.js:
--------------------------------------------------------------------------------
1 | import { directive, isPrimitive } from '../shared.js';
2 |
3 | /**
4 | * Renders one of a series of values, including Promises, in priority order.
5 | * Not possible to render more than once in a server context, so primitive
6 | * sync values are prioritised over async, unless there are no more pending
7 | * values, in which case the last value is always rendered regardless.
8 | */
9 | export const until = directive((...args) => (part) => {
10 | for (let i = 0, n = args.length; i < n; i++) {
11 | const value = args[i];
12 |
13 | // Render sync values immediately,
14 | // or last value (async included) if no more values pending
15 | if (isPrimitive(value) || i === n - 1) {
16 | part.setValue(value);
17 | }
18 | }
19 | });
20 |
--------------------------------------------------------------------------------
/src/escape.js:
--------------------------------------------------------------------------------
1 | // https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html#output-encoding-rules-summary
2 | // https://github.com/mathiasbynens/jsesc/blob/master/jsesc.js
3 |
4 | /** @type { { [name: string]: string } } */
5 | const HTML_ESCAPES = {
6 | '"': '"',
7 | "'": ''',
8 | '&': '&',
9 | '<': '<',
10 | '>': '>',
11 | };
12 | const RE_HTML = /["'&<>]/g;
13 | const RE_SCRIPT_STYLE_TAG = /<\/(script|style)/gi;
14 |
15 | /**
16 | * Safely escape "string" for inlining
17 | *
18 | * @param { string } string
19 | * @param { string } context - one of text|attribute|script|style
20 | * @returns { string }
21 | */
22 | export function escape(string, context = 'text') {
23 | switch (context) {
24 | case 'script':
25 | case 'style':
26 | return string.replace(RE_SCRIPT_STYLE_TAG, '<\\/$1').replace(/