├── .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 | 363 | `; 364 | //=> 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(/