Skip to content

HTML JSX Plugin

A plugin for staticbolt that renders JSX components into the page at build time, through the framework's own render-to-string. The tag is replaced by the markup, and neither the component nor the framework reaches the browser.

Installation

The plugin ships as part of @staticbolt/core. The framework does not. Install the one your components use.

Terminal window
npm install react react-dom

Usage

In your .staticbolt.ts configuration file:

import { defineConfig } from "@staticbolt/core";
import { htmlJsxPlugin } from "@staticbolt/core/plugins";
export default defineConfig({
plugins: [htmlJsxPlugin({ framework: "react" })],
});

The tag is named after the framework:

<react src="@components/card.tsx" export="Card" title="Hello" canRun:js="1 + 1 === 2" tags:js='["a", "b"]' isOpen>
<p>
Markup as
<strong>children</strong>
.
</p>
</react>
export function Card({ title, canRun, isOpen, tags, children }) {
return (
<article data-open={String(isOpen)}>
<h2>{title}</h2>
<p>{canRun ? "can run" : "cannot run"}</p>
<div>{children}</div>
</article>
);
}

Outputs:

<article data-open="true">
<h2>Hello</h2>
<p>can run</p>
<div>
<p>
Markup as
<strong>children</strong>
.
</p>
</div>
</article>

Props

Every attribute other than src and export becomes a prop. Its case is kept as written, canRun reaches the component as canRun.

Attribute Prop
title="Hello" the string "Hello"
count:js="2 + 1" the value of the expression, 3
tags:js='["a"]' the array ["a"], JSON is an expression too
isOpen true, an attribute written without a value

A :js expression has no variables in scope. One that does not parse is reported against the element, and the element stays in the page.

Children

The element's content becomes the children prop, as markup rather than text. A component that renders {children} keeps the tags it was given instead of escaping them. The markup lands wherever the component puts children.

Elements nest. An inner element renders first, and its markup becomes part of its parent's children:

<react src="@components/card.tsx" export="Card" title="Outer">
<react src="@components/badge.tsx">inner</react>
</react>

Hydration

An element with hydrate also mounts in the browser. Its markup is wrapped in a <div data-react="…" style="display: contents">, and the page gets one module script that imports the component and hydrates the wrapper. That script is a page script like any other: the build compiles the component for the browser, and bundlePackages bundles the framework.

<react src="@components/counter.tsx" export="Counter" start:js="1" hydrate></react>

With hydrate: true every element hydrates, and no-hydrate keeps one static:

htmlJsxPlugin({ framework: "react", hydrate: true });
<react src="@components/card.tsx" export="Card" title="Static" no-hydrate>
<p>content</p>
</react>

An element that hydrates cannot have content: the browser would hydrate children as text, not as the markup it was rendered to. Its props are evaluated a second time in the browser. A :js expression must give the same value there, and a component that throws while hydrating loses its markup, the way the framework handles an unhandled render error. react, preact and vue hydrate. hono and native do not.

A package and its internals have to end up in one output file, or hydration runs on two copies of its state. Give bundlePackages a chunk for it:

bundlePackagesPlugin({ chunks: { signals: { include: ["@preact/signals"] } } });

More than one framework

Use the plugin once per framework, each with its own tag:

plugins: [htmlJsxPlugin({ framework: "react" }), htmlJsxPlugin({ framework: "preact" })];
<react src="@components/card.tsx" export="Card" title="React" />
<preact src="@components/chip.tsx" export="Chip" label="Preact" />

A JSX component carries the pragma of its framework, as it always would:

/** @jsxImportSource preact */

Nesting an element of one framework inside an element of another is not supported.

How it runs

For each page, the plugin writes one module that imports every component the page uses and renders each element. esbuild bundles that module together with the components and whatever they import from the project, and Node runs the bundle. None of this joins the build. The components and the framework never reach an output file, and the project's Babel setup does not apply to them. esbuild compiles them with the automatic JSX runtime of the plugin's framework and the path aliases from tsconfig.json.

Packages stay out of the bundle. Node's own require loads them from node_modules, resolved for Node rather than the browser, and loads them once for the whole build. That gives every page the same single copy of the framework, which its hooks require.

Failures are per element. The error is reported against the element, the element stays in the page, and the rest of the page renders. The plugins that bundle, preload or convert what a page holds do not walk the markup that a component produced.

In development, the plugin tracks the files that each page rendered. Editing a component, or anything it imports, recompiles the pages that used it.

Plugin Options

framework

  • Type: "react" | "preact" | "hono" | "vue" | "native"
  • Required

The framework that the components use. It has to be installed in the project. The value names the tag and picks the JSX runtime and the render-to-string:

framework Renders through Children reach the component as Hydrates
"react" react-dom/server renderToStaticMarkup the children prop yes
"preact" preact-render-to-string render the children prop yes
"hono" the element's toString(), async included the children prop no
"vue" vue/server-renderer renderToString the default slot yes
"native" the runtime's own string renderer the children prop no

Solid and Svelte are not supported: their components need their own compilers.

tagName

  • Type: string
  • Default: the framework's name

The tag name that marks a component to render.

htmlJsxPlugin({ framework: "react", tagName: "component" });

sourceAttribute

  • Type: string
  • Default: "src"

The name of the attribute that points at the component module, as a path relative to the page or through a path alias.

exportAttribute

  • Type: string
  • Default: "export"

The name of the attribute that names the export to render. Without it, the plugin renders the default export.