From 0e1efc07917b102852ecacba5a3d66b5e6c03b56 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Sat, 18 Apr 2026 11:32:55 -0700 Subject: [PATCH 1/4] document async-htm-to-string as a Preact-free rendering option --- README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/README.md b/README.md index 008f8fb..af04948 100644 --- a/README.md +++ b/README.md @@ -1284,6 +1284,40 @@ const layout: LayoutFunction<{site: string}, VDOMNode, string> = ({ children }) } ``` +### Using `async-htm-to-string` for string-based rendering + +DomStack's default layout pattern uses `htm/preact` and `preact-render-to-string` for HTML generation. If you only need server-side rendering and have no client-side Preact components, [`async-htm-to-string`](https://github.com/nicferrier/async-htm-to-string) is a lighter alternative. It uses the same `htm` tagged template syntax but renders directly to a string without a virtual DOM layer. + +```js +import { html, rawHtml } from 'async-htm-to-string' + +export default async function layout ({ children, vars }) { + return await html` + + + ${vars.title} + ${rawHtml(children)} + + ` +} +``` + +Key differences from `htm/preact`: + +- **Attribute names are standard HTML.** Use `class`, `for`, `tabindex` rather than `className`, `htmlFor`, `tabIndex`. Using React-style attribute names with `async-htm-to-string` outputs them literally as invalid attributes with no warning. +- **Always `await` the `html` tag.** It returns a thenable object, not a plain string. Returning without `await` produces `[object Object]` in the rendered output with no error thrown. +- **`rawHtml()` bypasses escaping.** Use it only for content you trust, such as the output of `page.renderInnerPage()` or your own markdown renderer. Passing user-controlled input through `rawHtml()` is a direct XSS risk. + +```js +import { html, rawHtml } from 'async-htm-to-string' + +// safe: vars.title is escaped automatically +// trusted: children is already-rendered HTML from the page builder +export default async function layout ({ children, vars }) { + return await html`

${vars.title}

${rawHtml(children)}
` +} +``` + ## Design Goals - Convention over configuration. All configuration should be optional, and at most it should be minimal. From 42c79ea421521d00ad2443d6d2ec9d5ef0f66d77 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Sat, 18 Apr 2026 12:04:17 -0700 Subject: [PATCH 2/4] Improve async-htm-to-string docs: install snippet, await clarification, rawHtml nuance - Add npm install snippet to make the section actionable - Clarify the await behavior: the tag returns an object that resolves asynchronously; describe the actual failure mode rather than just saying 'thenable' - Soften the 'trusted' claim for children: note that children can be any type from a page function and may contain unsanitized content depending on the page source --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index af04948..4ab51a9 100644 --- a/README.md +++ b/README.md @@ -1288,6 +1288,10 @@ const layout: LayoutFunction<{site: string}, VDOMNode, string> = ({ children }) DomStack's default layout pattern uses `htm/preact` and `preact-render-to-string` for HTML generation. If you only need server-side rendering and have no client-side Preact components, [`async-htm-to-string`](https://github.com/nicferrier/async-htm-to-string) is a lighter alternative. It uses the same `htm` tagged template syntax but renders directly to a string without a virtual DOM layer. +```console +npm install async-htm-to-string +``` + ```js import { html, rawHtml } from 'async-htm-to-string' @@ -1305,14 +1309,14 @@ export default async function layout ({ children, vars }) { Key differences from `htm/preact`: - **Attribute names are standard HTML.** Use `class`, `for`, `tabindex` rather than `className`, `htmlFor`, `tabIndex`. Using React-style attribute names with `async-htm-to-string` outputs them literally as invalid attributes with no warning. -- **Always `await` the `html` tag.** It returns a thenable object, not a plain string. Returning without `await` produces `[object Object]` in the rendered output with no error thrown. -- **`rawHtml()` bypasses escaping.** Use it only for content you trust, such as the output of `page.renderInnerPage()` or your own markdown renderer. Passing user-controlled input through `rawHtml()` is a direct XSS risk. +- **Always `await` the `html` tag.** The tag returns an object that resolves to a string asynchronously. If you return it without `await` from a non-async function (or assign it where a string is expected), you will get `[object Object]` in the output with no error thrown. Use `async function` and `await` the result. +- **`rawHtml()` bypasses escaping.** It is equivalent to setting `innerHTML` directly. Use it only for HTML you generated yourself (such as the output of `page.renderInnerPage()` or a trusted markdown renderer). `children` passed to a layout can be any type returned by a page function, and may contain unsanitized content depending on the page. Always verify the source before passing it through `rawHtml()`. ```js import { html, rawHtml } from 'async-htm-to-string' // safe: vars.title is escaped automatically -// trusted: children is already-rendered HTML from the page builder +// children here is the HTML string produced by page.renderInnerPage() — trusted export default async function layout ({ children, vars }) { return await html`

${vars.title}

${rawHtml(children)}
` } From 6d73a9e01c260c6ed04d74aa9b96a0bbaa71161c Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Sat, 18 Apr 2026 12:54:34 -0700 Subject: [PATCH 3/4] Fix tabIndex wording and add rawHtml trust note --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4ab51a9..0e7b9d3 100644 --- a/README.md +++ b/README.md @@ -1295,6 +1295,8 @@ npm install async-htm-to-string ```js import { html, rawHtml } from 'async-htm-to-string' +// Note: rawHtml(children) assumes children is a pre-rendered HTML string from a +// trusted source such as page.renderInnerPage(). Do not pass unsanitized user input. export default async function layout ({ children, vars }) { return await html` @@ -1308,7 +1310,7 @@ export default async function layout ({ children, vars }) { Key differences from `htm/preact`: -- **Attribute names are standard HTML.** Use `class`, `for`, `tabindex` rather than `className`, `htmlFor`, `tabIndex`. Using React-style attribute names with `async-htm-to-string` outputs them literally as invalid attributes with no warning. +- **Attribute names are standard HTML.** Use `class` and `for` rather than React aliases like `className` and `htmlFor`, which `async-htm-to-string` will output literally with no warning. For attributes like `tabindex`, `tabIndex` is only a casing preference in HTML, but using standard lowercase keeps templates consistent. - **Always `await` the `html` tag.** The tag returns an object that resolves to a string asynchronously. If you return it without `await` from a non-async function (or assign it where a string is expected), you will get `[object Object]` in the output with no error thrown. Use `async function` and `await` the result. - **`rawHtml()` bypasses escaping.** It is equivalent to setting `innerHTML` directly. Use it only for HTML you generated yourself (such as the output of `page.renderInnerPage()` or a trusted markdown renderer). `children` passed to a layout can be any type returned by a page function, and may contain unsanitized content depending on the page. Always verify the source before passing it through `rawHtml()`. From 9308a930ec5cd3cb7f7b5a28ca57f8f3c51c3f19 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Sat, 18 Apr 2026 19:50:02 -0700 Subject: [PATCH 4/4] Fix renderInnerPage call signature in async-htm-to-string docs (add await and { pages }) Co-Authored-By: Claude Sonnet 4.6 --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0e7b9d3..f783185 100644 --- a/README.md +++ b/README.md @@ -1296,7 +1296,7 @@ npm install async-htm-to-string import { html, rawHtml } from 'async-htm-to-string' // Note: rawHtml(children) assumes children is a pre-rendered HTML string from a -// trusted source such as page.renderInnerPage(). Do not pass unsanitized user input. +// trusted source such as await page.renderInnerPage({ pages }). Do not pass unsanitized user input. export default async function layout ({ children, vars }) { return await html` @@ -1312,13 +1312,13 @@ Key differences from `htm/preact`: - **Attribute names are standard HTML.** Use `class` and `for` rather than React aliases like `className` and `htmlFor`, which `async-htm-to-string` will output literally with no warning. For attributes like `tabindex`, `tabIndex` is only a casing preference in HTML, but using standard lowercase keeps templates consistent. - **Always `await` the `html` tag.** The tag returns an object that resolves to a string asynchronously. If you return it without `await` from a non-async function (or assign it where a string is expected), you will get `[object Object]` in the output with no error thrown. Use `async function` and `await` the result. -- **`rawHtml()` bypasses escaping.** It is equivalent to setting `innerHTML` directly. Use it only for HTML you generated yourself (such as the output of `page.renderInnerPage()` or a trusted markdown renderer). `children` passed to a layout can be any type returned by a page function, and may contain unsanitized content depending on the page. Always verify the source before passing it through `rawHtml()`. +- **`rawHtml()` bypasses escaping.** It is equivalent to setting `innerHTML` directly. Use it only for HTML you generated yourself (such as the output of `await page.renderInnerPage({ pages })` or a trusted markdown renderer). `children` passed to a layout can be any type returned by a page function, and may contain unsanitized content depending on the page. Always verify the source before passing it through `rawHtml()`. ```js import { html, rawHtml } from 'async-htm-to-string' // safe: vars.title is escaped automatically -// children here is the HTML string produced by page.renderInnerPage() — trusted +// children here is the HTML string produced by await page.renderInnerPage({ pages }) — trusted export default async function layout ({ children, vars }) { return await html`

${vars.title}

${rawHtml(children)}
` }