An 85 Lines of Code Static Site Generator with Bun and JSX 2024-01-18
In this post we'll take a brief look on how to create an SSG (static site generator) with Bun. It will take your components written in JSX and turn them into HTML pages.
This will be a three-step process.
- collect all the pages that we conventionally put in a single directory. Nesting directories will be preserved as well,
- transpile JSX inside the pages into JS files with functions that produce HTML,
- loop over JS files, running them and saving HTML into
.html
files
Let's get to it.
TS config
First off, we need to drop the X in JSX. To do so, we instruct Bun to use our custom function for transpiling JSX. We'll go with a react-jsx
preset. We'll also define a gutsby path, which is going to be our JSX handler. With this path in place, we set gutsby as our jsxImportSource
.
./tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"gutsby/*": ["./gutsby/*"]
},
"jsx": "react-jsx",
"jsxImportSource": "gutsby",
"lib": ["ESNext", "DOM"],
"allowJs": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"isolatedModules": true,
"target": "ESNext",
"module": "CommonJS",
"types": ["bun-types"]
}
}
Next we need to bun init
in the root directory since TSConfig will blame us for bun-types. You need Bun installed on your machine, of course.
It also makes sense to define a few types for our convenience. I'll go global but you can export them if you like.
./src/types.d.ts
declare global {
module JSX {
interface IntrinsicElements {
[key: string]: any
}
}
/**
* A customisable props type for our components.
*/
export type PropsWithChildren<
CustomProps extends Record<string, unknown> = Record<string, unknown>
> = CustomProps & {
children?: (Promise<string | string[]> | undefined)[]
}
}
export {}
Now we're ready to make our dirty little gutsby.
Dealing with JSX in Bun
With a JSX preset we've picked in our tsconfig (react-jsx
), Bun will wrap every JSX element into ajsxDEV
function call. Since we've also instructed it that the import source is the gutsby
directory, let's create our JSX handler there. To avoid errors, we need two files: jsx-runtime.ts and jsx-dev-runtime.ts but the content may be completely the same since Bun will refer to the dev in our scenario.
./gutsby/jsx-runtime.ts
/**
* This function is called recursively, so, whether it's a component or an HTML
* tag, it will eventually become an HTML string with all children nested as
* HTML as well.
*/
export async function jsx(
type: string | ((props: PropsWithChildren) => string),
props: PropsWithChildren
): Promise<string> {
// 1. Handling components
// This is a component. Run it to get the contents.
if (typeof type === "function") return type(props)
// 2. Handling tags
// If children is not an array then it must be.
if (!Array.isArray(props.children)) props.children = [props.children]
// Start opening tag composition.
let line = `<${type}`
// Get all the props that are not children.
const notChildren = Object.keys(props).filter(key => key !== "children")
// Loop over the props and put them as attributes in our HTML.
// Yes, class, not className.
for (const prop of notChildren) line += ` ${prop}="${props[prop]}"`
// Finish opening tag composition.
line += ">"
// Loop over the children.
for (const child of props.children) {
let nested = await child
// If children is not an array then it must be.
if (!Array.isArray(nested)) nested = [nested as string]
// Loop over children and put them as inner HTML.
for (const item of nested as string[]) line += (await item) ?? ""
}
// Close the tag and return whatever HTML we got.
return line.concat(`</${type}>`)
}
./gutsby/jsx-dev-runtime.ts
/**
* This function is called recursively, so, whether it's a component or an HTML
* tag, it will eventually become an HTML string with all children nested as
* HTML as well.
*/
export async function jsxDEV(
type: string | ((props: PropsWithChildren) => string),
props: PropsWithChildren
): Promise<string> {
// 1. Handling components
// This is a component. Run it to get the contents.
if (typeof type === "function") return type(props)
// 2. Handling tags
// If children is not an array then it must be.
if (!Array.isArray(props.children)) props.children = [props.children]
// Start opening tag composition.
let line = `<${type}`
// Get all the props that are not children.
const notChildren = Object.keys(props).filter(key => key !== "children")
// Loop over the props and put them as attributes in our HTML.
// Yes, class, not className.
for (const prop of notChildren) line += ` ${prop}="${props[prop]}"`
// Finish opening tag composition.
line += ">"
// Loop over the children.
for (const child of props.children) {
let nested = await child
// If children is not an array then it must be.
if (!Array.isArray(nested)) nested = [nested as string]
// Loop over children and put them as inner HTML.
for (const item of nested as string[]) line += (await item) ?? ""
}
// Close the tag and return whatever HTML we got.
return line.concat(`</${type}>`)
}
That is it, actually. Now all your elements will become strings of HTML. Our next step is to create a script that will run turn our top-level components into elements and save them as HTML. We're basically making a ReactDOM.render
here, but it renders to HTML files directly.
Building pages
We need two things: a build script, and a page we'll render as a proof of concept. Let's start with the page first. I put mine in a src/pages
directory to keep it separate from the build script and other stuff. A bit of Nextiness.
./src/pages/index.tsx
const valueOutsideComponent = "Value outside"
const asyncValueOutsideComponentP = Promise.resolve("Async value outside")
/**
* This is our index page.
*
* NOTE: It must be a default export as per our build script.
*
* And yes, it does support
* - extracting components
* - children provision
* - async behavior
* - values in closure
*/
export default async function Index() {
const valueViaChildren = "Value via children"
return (
<html lang="en">
<head>
<title>Bun, JSX and Orlowdev</title>
</head>
<body>
<main>
<h1>My values</h1>
<MyValues valueViaProps="Value via props">{valueViaChildren}</MyValues>
</main>
</body>
</html>
)
}
type P = PropsWithChildren<{ valueViaProps: string }>
const MyValues = async ({ valueViaProps, children }: P) => {
const valueInsideComponent = "Value inside"
const asyncValueInsideComponent = await Promise.resolve("Async value inside")
const asyncValueOutsideComponent = await asyncValueOutsideComponentP
return (
<ul>
<li>{valueViaProps}</li>
<li>{valueInsideComponent}</li>
<li>{valueOutsideComponent}</li>
<li>{asyncValueInsideComponent}</li>
<li>{asyncValueOutsideComponent}</li>
<li>{children}</li>
</ul>
)
}
Now the last part of our tour is to get HTML. We only cover build process here but you can add all sorts of things here, including CSS processing, copying assets, minifying images, and what. The script will run bun build
on the files inside the pages directory and then grab the compiled files and execute them, saving to ./dist/*.html
.
./build.ts
import { promises, existsSync, mkdirSync } from "node:fs"
import { execSync } from "node:child_process"
// Create all the directories if they do not exist.
if (!existsSync("dist")) mkdirSync("dist")
if (!existsSync("dist/js")) mkdirSync("dist/js")
if (!existsSync("dist/www")) mkdirSync("dist/www")
/**
* Build JSX, loop over pages and save their content as HTML files with the same name.
*/
const compileHTML = async () => {
// Bun build with a target of Bun since we are going to run those scripts later with Bun.
execSync("bun build src/pages/* --outdir dist/js --target=bun")
// Get all available pages.
const pages = await promises.readdir("./dist/js")
// Loop over pages and generate HTML files for each of them.
for (const page of pages) {
// Skip if a page is somehow not a JS file.
if (!page.endsWith(".js")) continue
// Get name of the file without file extension.
const name = page.substring(0, page.lastIndexOf("."))
// Import default function from the page.
const f = await import(`dist/js/${name}`).then(p => p.default)
// Run the function and write whatever it returns to an HTML file with the name of the page.
Bun.write(`./dist/www/${name}.html`, await f())
}
}
// Go!
compileHTML()
And that's it! If you now run bun run build.ts
it should create you a dist/www/index.html that will look something like this:
And this wraps up our short lesson. Your optional home assignment is to add:
- serving HTML files (check out Bun.serve)
- watching for changes and rerunning compilation (fs.watch)
- postprocessing for CSS (TailwindCSS, for example)
- copying static assets for your pages
- optimizing images - this is a harder one
If you have any questions, you can reach me out on X.
A fun fact: the website you're reading this article at (orlow.dev) is built with this Bun+JSX thing. So, if you need ideas or inspiration, check my GitHub for this website. See you soon!