Eleventy (11ty) 3.0 supports ESM. Yay! Along the way, Zach is unbundling some template languages, which is a good thing.
In this repo, we'll set up 11ty to use: ESM (duh), TypeScript (wuh?), and TSX (WAT?). For full tooling pleasure, we'll
throw in Vitest. Impatient for the "how"? We're using the tsx package, which
uses esbuild
. (We're not using Vite.)
11ty prides itself on being a simple, powerful SSG for JavaScript. No build tooling, no magic. At first glance, TypeScript and JSX/TSX seem to be a bad fit. If you're from that world, there are lots of galactic-framework-VC-backed choices that will delight your inner architecture astronaut.
That's not you. But while you might not want the full footgun experience, perhaps using TypeScript is your jam. When paired with a good IDE, it makes for a good DX, for those that like tooling.
Same for JSX/TSX. Especially for TSX. Having a template language with good squigglies, autocomplete, and debugging is...chef's kiss.
Again -- we know this isn't the 11ty way. But it shows that 11ty's goodness can extend into a next level of tooling.
In a mostly-empty package.json
, we'll add the 11ty dependency and -- crucially -- indicate that this is an ESM
project. As Zach explains, this
flips the switch:
"scripts": {
"build": "npx @11ty/eleventy"
},
"type": "module",
"dependencies": {
"@11ty/eleventy": "3.0.0-alpha.4"
},
We add a minimal eleventy.config.js
file at that top. See: this is already ESM, no more module.exports
!
export default function (eleventyConfig) {
return {
dir: {
input: "src",
output: "dist",
},
};
}
We have two .js
files. Maybe you like TypeScript? Let's switch this project to .ts
. We'll leave TSX for the next
step.
First, of course, add the magical bag of mystery known as the tsconfig.json
file. I hope this is right. I always just
cut, paste, and pray.
{
"compilerOptions": {
"module": "ESNext",
"target": "ESNext",
"moduleResolution": "Node",
"outDir": "dist",
"strict": false,
"jsx": "react-jsx",
"jsxImportSource": "jsx-async-runtime"
},
"exclude": ["node_modules", "dist"]
}
Next, let's install the tsx package which makes a nice wrapper around esbuild TypeScript, the real star of the show.
We'll also change our build
script to use tsx
"
"scripts": {
"build": "tsx node_modules/@11ty/eleventy/cmd.cjs --config=eleventy.config.ts"
},
"devDependencies": {
"tsx": "^4.7.0"
}
Yes, your eyes didn't deceive you -- let's rename our config file to eleventy.config.ts
and sprinkle in some
TypeScript syntax. 4 characters of syntax (: any
) to be precise.
export default function (eleventyConfig: any) {
return {
dir: {
input: "src",
output: "dist",
},
};
}
We also rename our template to src/index.11ty.tsx
and a return type:
export function render(): string {
return "<h1>Hello ESM</h1>";
}
We run our build and...wump wump:
[11ty] Wrote 0 files in 0.01 seconds (v3.0.0-alpha.4)
We need to return to eleventy.config.ts
and teach it about .ts
files. We'll go ahead and teach about .tsx
as well.
export default function (eleventyConfig: any) {
eleventyConfig.addExtension(["11ty.jsx", "11ty.ts", "11ty.tsx"], {
key: "11ty.js",
});
return {
dir: {
input: "src",
output: "dist",
},
};
}
This time when we build -- success!
[11ty] Writing dist/index.html from ./src/index.11ty.tsx
[11ty] Wrote 1 file in 0.02 seconds (v3.0.0-alpha.4)
You know, JSX takes a lot of grief. I get it. But you can't beat the tooling support. Using TSX as a template language for 11ty is really sweet.
The first thing to understand: esbuild
has JSX support. But it doesn't actually do JSX processing. It expects to be
pointed at a JSX renderer. Most people associate that with React, Preact, or other build-tool-colossus (negative)
experiences.
But there are actually standalone JSX processors that can run in Node during build. (Or even in the browser, post-load.) We're going to use jsx-async-runtime. (We previously used vhtml.)
- Support TS typing
- Supported, active
- Lets us eliminate
import h
via tsconfigjsImportSource
- It's async (unlike Preact's renderer) to allow 11ty Image
First, we install the package as a dependency:
"dependencies": {
"@11ty/eleventy": "3.0.0-alpha.4",
"jsx-async-runtime": "^0.1.3"
},
We now need to tell our tsx
package (really: esbuild
) how to handle TSX. You can pass command line arguments. Or,
you can infer from tsconfig.json
, which we'll do:
{
"compilerOptions": {
"module": "ESNext",
"target": "ESNext",
"moduleResolution": "Node",
"outDir": "dist",
"strict": false,
"jsx": "react-jsx",
"jsxImportSource": "jsx-async-runtime"
},
"exclude": ["node_modules", "dist"]
}
These two new compiler options are important:
"jsx": "react-jsx"
enables theesbuild
"automatic" mode. It actually has nothing to do with React."jsxImportSource": "jsx-async-runtime"
lets you avoid importing anh
function at the top of all your files.
These are confusing and brittle, especially the second part.
Let's rename our file to src/index.11ty.tsx
and return TSX instead of a string:
export function render(): JSX.Element {
return <h1>Hello TSX</h1>;
}
Several interesting points. Obviously, the return
hands back JSX
, which indicate in the JSX.Element
return type.
But notice what's missing. In our vhtml
setup, we had to preface each line with this:
import h, { JSX } from "vhtml";
This was annoying. Not the least of which: h
wasn't even used in the file and showed up as an unused import.
If we try to build now, it will...fail. 11ty templates are supposed to return a string, not a JSX element. Let's fix
that in elevent.config.ts
:
import { renderToString } from "jsx-async-runtime";
export default function (eleventyConfig: any) {
eleventyConfig.addExtension(["11ty.jsx", "11ty.ts", "11ty.tsx"], {
key: "11ty.js",
});
eleventyConfig.addTransform("tsx", async (content: any) => {
const result = await renderToString(content);
return `<!doctype html>\n${result}`;
});
return {
dir: {
input: "src",
output: "dist",
},
};
}
Now when you build, you'll get this in dist/index.html
:
<!doctype html>
<h1>Hello TSX</h1>
To recap what we did here:
- Add JSX handling to tsconfig.json
- Changed our one page/template to TSX
- Taught 11ty to render
.tsx
templates fromJSX.Element
to a string
We now have TypeScript for Eleventy with TSX as a template language. This lets us use component-driven development in 11ty.
For example, we can work on small chunks -- in isolation -- and work happily in tests, using Vitest. We'll start by adding a dependency and a script:
"scripts": {
"test": "vitest run"
},
"devDependencies": {
"vitest": "^1.1.3",
}
We need to wire up Vitest in a vitest.config.js
file at the root:
import { defineConfig } from "vitest/config";
export default defineConfig({
esbuild: {
jsx: "transform",
jsxInject: "import { jsx } from 'jsx-async-runtime/jsx-runtime'",
jsxFactory: "jsx",
jsxImportSource: "jsx-async-runtime",
},
test: {
include: ["./site/**/*.test.tsx"],
},
});
This overrides the same settings used by tsx
for running Eleventy builds. Vitest uses esbuild
(as does tsx
) but
for whatever reason, didn't respect the tsconfig.json
settings without help. Big shoutout
to JoaquĂn Sánchez from Vite/Vitest fame
for figuring this out for me.
Next, let's rewrite index.11ty.tsx
to have a named-export component, which we then re-export for Eleventy's render
protocol for templates. This is for convenience, so you don't have all of your components named render
:
export function Index(): JSX.Element {
return <h1>Hello TSX</h1>;
}
export const render = Index;
Now we can write a test of the Index
component, using Vitest:
import {expect, test} from "vitest";
import {renderToString} from "jsx-async-runtime";
import {Index} from "./index.11ty";
test("render index", async () => {
const result = <Index/>;
const rendered = await renderToString(result);
expect(rendered).toEqual("<h1>Hello TSX</h1>");
});
This test passes when we run npm test
.
We're in great shape. We now 11ty development using tooling-friendly TS and TSX, for those that prefer such things. We also have testing with the super-cool Vitest.
Our test right now asserts a string. We're going to want richer testing. Let's hook up Happy DOM as a fake web browser and Testing Library for role-based assertions.
First, over to package.json
to add some dependencies:
"devDependencies": {
"happy-dom": "^13.0.0",
"@testing-library/dom": "^9.3.4",
},
Our vitest.config.js
file needs to be told to use Happy DOM as the global document
:
export default defineConfig({
test: {
environment: "happy-dom",
},
});
Our index.test.tsx
file can now do a real DOM with the Testing Library approach to assertions:
import {expect, test} from "vitest";
import {renderToString} from "jsx-async-runtime";
import {Index} from "./index.11ty";
import {screen} from "@testing-library/dom";
test("render index", async () => {
const result = <Index/>;
document.body.innerHTML = await renderToString(result);
expect(screen.getByText("Hello TSX")).to.exist;
});
Our site is currently one "component". And that component is more like a "view." Let's start that split by making
a Heading
component that can get passed a prop for who to say hello to.
Let's say our site content will be in site
and our software components in components
:
$ mv src site
Our vitest.config.js
file needs to be pointed at our two directories:
include: ["./site/**/*.test.tsx", "./components/**/*.test.tsx"],
We should do something similar in tsconfig.json
:
"include": ["site", "components"],
"exclude": ["node_modules", "dist"]
We also need 11ty to find its content in site
instead of src
:
return {
dir: {
input: "site",
output: "dist",
},
};
With this in place, let's make components/Heading.tsx
(note that it didn't need the .11ty
in the filename):
export type HeadingProps = {
name?: string;
};
export function Heading({name = "TSX"}: HeadingProps): JSX.Element {
return <h1>Hello {name}</h1>;
}
We can test the default and passed-value cases in components/Heading.test.tsx
:
import {expect, test} from "vitest";
import {renderToString} from "jsx-async-runtime";
import {screen} from "@testing-library/dom";
import {Heading} from "./Heading";
test("render heading with default name", async () => {
const result = <Heading/>;
document.body.innerHTML = await renderToString(result);
expect(screen.getByText("Hello TSX")).to.exist;
});
test("render heading with custom name", async () => {
const result = <Heading name={`World`}/>;
document.body.innerHTML = await renderToString(result);
expect(screen.getByText("Hello World")).to.exist;
});
Now, let's go back to our 11ty template and point it at a component:
import {Heading} from "../components/Heading";
export function Index(): JSX.Element {
return <Heading/>;
}
export const render = Index;
Tests all pass, the page still builds the same. All good.
We're doing TSX for our index.11ty.tsx
11ty template. But it doesn't really feel like a component, per-se. It feels
more like a view. It mediates between incoming 11ty data and the logic for that "page".
Let's change index.11ty.tsx
to be a "view":
- The
render
function grabs the 11ty-specific stuff - A component for that page receives that stuff and renders
Why this split? Testing! It's not that fun mediating with the (untyped) 11ty
We'll start with some pseudo-typing for 11ty. Make a file eleventy.ts
at the root:
export type ViewProps = {
page: {
filePathStem: string;
};
};
11ty has a lot more than this. Your site might have even more (custom collections, etc.) We'll keep it simple for now.
We'll change our index page at site/index.11ty.tsx
to have a render
function which does the mediation:
import {Heading} from "../components/Heading";
import {ViewProps} from "../eleventy";
export type IndexProps = {
filePathStem: string;
};
export function Index({filePathStem}: IndexProps): JSX.Element {
return <Heading name={filePathStem}/>;
}
export function render({page}: ViewProps): JSX.Element {
return <Index filePathStem={page.filePathStem}/>;
}
As you can see, the render
function does little more than pass the massaged-data from 11ty into the component you're
really interested in: this page. That is, the Index
component.
Our tests in index.test.tsx
can then model this. You first focus on writing something that gets passed in specific
data, using tests to be more productive. Then, when done, write a test for the render function, which needs the 11ty
data. We can see that split here:
import {expect, test} from "vitest";
import {renderToString} from "jsx-async-runtime";
import {render, Index} from "./index.11ty";
import {screen} from "@testing-library/dom";
import {ViewProps} from "../eleventy";
test("renders Index component", async () => {
const result = renderToString(<Index filePathStem="/index"/>);
document.body.innerHTML = await renderToString(result);
expect(screen.getByText(`Hello /index`)).to.exist;
});
test("render index view", async () => {
const viewProps: ViewProps = {
page: {filePathStem: "/index"},
};
// Let's do this as a call, rather than TSX, to remind
// ourselves that this is a view, not a "component".
const result = render(viewProps);
document.body.innerHTML = await renderToString(result);
expect(screen.getByText(`Hello /index`)).to.exist;
});
When your view needs lots of data from across parts of the 11ty surface area, this split becomes more convenient.
Moreso, when you have a chain of layouts to format Markdown data, this mediation is more important.
Right now our 11ty "page" is a JS render function. But most people use site/index.md
with frontmatter that points to
a "layout". Let's do that in this step. Since this is something 11ty will "see" (in the frontmatter), let's use the 11ty
convention of _layouts
for the directory:
$ mkdir _layouts
Let's rename index.11ty.tsx
to _layouts/MainLayout.11ty.tsx
. Then, change it to render a title from frontmatter and
the Markdown contents, while keeping the use of our <Heading>
component:
import {Heading} from "../components/Heading";
import {ViewProps} from "../eleventy";
export function render({content, title}: ViewProps): JSX.Element {
return (
<html lang="en">
<head>
<title>{title}</title>
</head>
<body>
<Heading name={title}/>
{content}
</body>
</html>
);
}
This will control the layout of all Markdown files in our site.
We made a slight change to eleventy.ts
to collect the title:
export type ViewProps = {
content: string;
title: string;
};
Our tsconfig.json
needs a slight addition, to look in this new _layouts
directory:
"include": ["site", "components", "_layouts"],
Same for vitest.config.js
:
include: [
"./site/**/*.test.tsx",
"./components/**/*.test.tsx",
"./_layouts/**/*.test.tsx",
],
Let's now move over to our test. We need to update the test in MainLayout.test.tsx
:
import { expect, test } from "vitest";
import { renderToString } from "jsx-async-runtime";
import { render } from "./MainLayout.11ty";
import { screen } from "@testing-library/dom";
import { ViewProps } from "../eleventy";
test("render MainLayout", async () => {
const viewProps: ViewProps = {
content: "<p>This is <em>the body</em></p>",
title: "My Site",
};
const result = render(viewProps);
document.body.innerHTML = await renderToString(result);
expect(screen.getByText(`Hello My Site`)).to.exist;
expect(screen.getByText(`the body`)).to.exist;
});
As you can see, we did a test for the <em>
content.
One last configuration change. Let's update eleventy.config.ts
to point at _layouts
:
return {
dir: {
input: "site",
layouts: "../_layouts",
output: "dist",
},
};
With that in place, we can use Markdown in our site...pointed at a layout...which points at a component.
Here's site/index.md
:
---
title: My Site
layout: MainLayout.11ty.tsx
---
This is a _very_ nice site.
When we re-run our build, we get the output we expect:
<!doctype html>
<html lang="en">
<head>
<title>My Site</title>
</head>
<body>
<h1>Hello My Site</h1>
<p>This is a <em>very</em> nice site.</p>
</body>
</html>
Our site builds, our tests pass. We have component-driven development and the tooling that TS/TSX/testing brings. What more could we want?
I luvs me this style of development. Others though -- just want to see 11ty build. Faking out 11ty doesn't fit their brain. They want the equivalent of E2E tests.
People usually reach for Playwright or Cyprus in these cases. But that severs us from some of this Node-based, IDE-friendly tooling. Any chance we can actually run 11ty, but in a way that feels like a unit test?