0004. Render every node through one `<tapestry-node>` wrapper
- Status: Accepted
- Date: 2026-10-02
Context
Section titled “Context”StudioCMS renderers return HTML strings. StudioCMS then runs the HTML through
ultrahtml: first sanitize, then swap, which replaces registered custom
elements with Astro components, passing attributes as props and inner HTML
as the default slot.
The first implementation mapped each Tapestry component to its own custom
element (<tapestry-hero heading="…">) and registered every component in
componentRegistry. Testing against the real ultrahtml pipeline showed:
- Attribute values aren’t entity-decoded.
heading="Say "hi""reaches the component as the literal stringSay "hi", and Astro escapes it again, so visitors would see"on the page. - All props become strings. Numbers and booleans would need parsing in every component.
- Users would need to list every component in
componentRegistry, duplicating the Tapestry config.
Decision
Section titled “Decision”Register a single wrapper element, tapestry-node, mapped to Tapestry’s own
Node.astro. Each node renders as:
<tapestry-node type="hero" props="%7B%22heading%22%3A%22Say%20%5C%22hi%5C%22%22%7D">…children…</tapestry-node>propsisencodeURIComponent(JSON.stringify(props)). The output contains no",&,<or>, so it needs no HTML escaping, and it survives the pipeline byte-for-byte.Node.astrodecodes the props, looks up the real component in thevirtual:tapestry/componentsmodule (static imports generated at build time), and renders it with children in the default slot.
Alternatives considered
Section titled “Alternatives considered”- Per-component elements + entity decoding in each component: pushes a footgun onto every component author.
- Base64 props: equivalent safety, but opaque when debugging; URL encoding stays human-readable.
- Bypass the registry by rendering with Astro’s Container API inside the
renderer: the renderer has no access to the request’s
SSRResult; it would render out of context (no shared head/CSS propagation) and is experimental. - Augment plugins (
ComponentRenderAugment): enabled per page viadata.augments, so not suitable for always-on rendering.
Consequences
Section titled “Consequences”- Props keep JSON types (number, boolean, string).
- Users add exactly one registry entry:
...tapestryComponentRegistry(). - The sanitizer allowlist is a single element, which is easy to reason about.
- Each node costs one extra component call on the server (negligible).
- The live-preview phase can add
data-tapestry-idin one place (Node.astro).
Source: docs/decisions/0004-single-wrapper-element.md (view on GitHub)