Esbuild as a static site generator for MDX
This post is part of a series that originated with the question "can the Contentful-Gatsby-Netlify trio be simplified?".
- Jacco Meijer
- |
- Mar 19, 2024
Esbuild as a static site generator for MDX
Static site generators gain popularity. This blog is about using Esbuild as a static site generator for MDX.
Using 11ty is part of the positive answer. The last post concludes with the need for a Render JSX plugin for Esbuild.
This post starts with the code for such a plugin and describes how it works.
The code
Below is the full plugin. It's taken from jaccomeijer/render-jsx-plugin-poc, a small repo that can be used to see the plugin working.
import { writeFile, mkdir } from 'fs/promises'
import path from 'path'
import crypto from 'crypto'
import { render } from 'preact-render-to-string'
const pluginName = 'renderJsxPlugin'
export const renderJsxPlugin = ({ outdir, initialProps }) => {
const setup = build => {
build.onEnd(async result => {
const metafileOutputs = Object.keys(result.metafile.outputs)
for (const bundlePath of metafileOutputs) {
const entryPoint = result.metafile.outputs[bundlePath].entryPoint
if (!entryPoint) {
continue
}
const { dir, name } = path.parse(entryPoint)
const strippedDir = dir.replace('src/pages', '')
const outputDir = path.join(outdir, strippedDir)
const outputPath = path.join(outdir, strippedDir, `${name}.html`)
const cacheBust = crypto.randomBytes(6).toString('hex')
const modulePath = path.resolve(`./${bundlePath}?v=${cacheBust}`)
const module = await import(modulePath)
console.log(`${pluginName}: ${outputPath}`)
// Check if the default export is a function
if (typeof module?.default !== 'function') {
continue
}
const props = Object.assign(initialProps || {})
const html = render(module.default(props))
await mkdir(path.resolve(outputDir), { recursive: true })
await writeFile(path.resolve(outputPath), `<!DOCTYPE html>${html}`)
}
})
}
return { name: pluginName, setup }
}
More capable version
A more capable version of the plugin is used in the jaccomeijer/green-build repository. That repo is used to build this site (repository here) and adds functionality like:
- image handling
- CSS bundling
- HTML minifying
- code highlighting
- page prop with Front Matter support
- pages prop for menu building
- cleanup of the javascript bundle
- custom urls
- index.html for all pages
- uses React JSX runtime instead or Preact
The breakdown
The plugin is simple and contains a few distinct steps.
Metadata
Esbuild provides onEnd
plugins with build
metadata. The plugin starts with using result.metafile
to get all bundle
paths.
const metafileOutputs = Object.keys(result.metafile.outputs)
OutputPath
The plugin then loops through every bundlePath
and derives an output dir and
path.
const outputPath = path.join(outdir, strippedDir, `${name}.html`)
Import bundle
Next, the the JSX bundle created by Esbuild is imported. Adding
?v=${cacheBust}
to the module path prevents Node from caching the module.
const modulePath = path.resolve(`./${bundlePath}?v=${cacheBust}`)
const module = await import(modulePath)
Render to HTML
The last part of the plugin does the actual rendering. The plugin imports the
render
function from Preact.
import { render } from 'preact-render-to-string'
This means that the plugin works with bundles that have the Preact JSX Factory. This Factory is the function that is called for every JSX element. For Esbuild, these are the build options to build with the Preact Factory:
{
jsx: 'automatic',
jsxImportSource: 'preact',
...
}
The imported bundle contains a JSX component that accepts props like any other
JSX function. This demo does not do much with the initialProps
, but this is
how e.g. global metadata can be passed to all pages. The package
jaccomeijer/green-build, mentioned
earlier, uses this concept.
const props = Object.assign(initialProps || {})
const html = render(module.default(props))
Write to file
With the static HTML, all that's left is writing the HTML to a file for the Esbuild webserver to render.
All HTML documents must start with a <!DOCTYPE>
declaration. This is not a
tag, but an id to let the browser know what to expect. This declaration is added
here.
await mkdir(path.resolve(outputDir), { recursive: true })
await writeFile(path.resolve(outputPath), `<!DOCTYPE html>${html}`)
JSX
The plugin above uses Preact to transform JSX into static HTML. Theoretically, the JSX framework should not affect the final result because Factory functions are added when bundling and removed again when rendering. In practice, there's some differences in consistency, speed and syntax conventions.
Jeasx
The jsx-async-runtime package
contains a very simple and lightweight JSX runtime. It also contains the
renderToString
function.
This is a very simple runtime which makes it very fast. Drawback is that spaces
in pre
and code
elements are not maintained properly which makes the render
unusable for code blocks.
Preact
The
preact-render-to-string
package provides a render
function that renders consistently to static HTML.
The package does not warn about React conventions like the need for key
attributes and is a lot faster than React's renderToStaticMarkup
.
React
The first version of the jaccomeijer/green-build package created bundles with Preact settings.
Version 2 of the build package switched to React because of compatibility. Creating e.g. a UI library makes the library compatible with frameworks like Astro.
Gaining React compatibility came with the cost of speed. The React Factory is
noticeably slower than the other two. Also, creating a React bundle shows all
the warnings that come with React's conventions. CamelCase attributes and key
attribute requirements for example.
The react-dom/server package contains two functions:
renderToString
→ renders to a string that can be hydratedrenderToStaticMarkup
→ renders a non-interactive string
Of which the Static variant is the most useful here.
Speed and other JSX Factories
As noted above, React is the slowest of the JSX Factories. For this plugin, three JSX Factories have been tried, but many other frameworks with JSX-alike syntax exist. To mention a few, Million, SolidJs and Svelte. All of them possibly expose separate static render functionality.
Adding Javascript
JSX is used as a simple component solution. By wrapping static HTML in a function, this function can be reused. Simply by importing the function. Function arguments, or 'props', make this concept dynamic.
When JSX is rendered, only the output of the JSX functions remains. This results in static HTML.
When client side Javascript is needed, it can be added the same way that
Javascript is added to any static HTML. E.g. using a <script>
tag.
Shadow DOM without Javascript
Since Feb 20, 2024 all major browsers support 'Declarative Shadow Dom'. The declarative part means that 'Shadow Dom' can be enabled without Javascript:
<template shadowrootmode="open">
Server Side Rendering
This plugin does exactly what Server Side Rendering (SSR) does. The major difference with e.g. React SSR is that the build this plugin provides does not support a Javascript runtime. It provides a pure static build instead.
This means that the React API in this code doesn't work:
const [value, setValue] = useState(0);
To use the React API, the Javascript runtime library of React must be shipped with the static code. Besides that, once the code is loaded by the client browser, event handlers need to be attached to the static elements. This process is known as 'Hydration'.
This does not mean that client side Javascript cannot be used with a static render. The UI library in the next post defines two Custom Elements that depend on client side Javascript.
Conclusion
A simple extension of Esbuild lays the foundation for a fast static site generator.
JSX being just functions, they are easily added to NPM modules for distribution and reuse. MDX is an efficient way to write documentation and stories. Combine the two and creating a reusable and documented UI library is simple. The next post zooms in on this.
- Jacco Meijer
- |
- Mar 21, 2024
UI Library with MDX documentation
Using the simple Render JSX plugin for Esbuild this post shows how to setup a simple UI library.