Making Eleventy Data Traceable with TSX and Zod
Kenneth G. Franqueiro
Posted on May 14, 2024
Eleventy is a fast and versatile static site generator (SSG). Out of the box, it is most likely to appeal to developers used to earlier Python- or Ruby-based web frameworks and SSGs (e.g. Django, Flask, or Jekyll).
I've used Eleventy for a personal project in the past, but eventually switched to Astro. One of the reasons I switched was because I find having typings and explicit imports to be far more maintainable. In Eleventy, there is no paper trail for IDEs to follow between templates/includes and the data cascade, so determining where a variable name seen in an include is actually defined can involve digging through half a dozen different files. Changing data is also a risky and onerous process, as it's far too easy for something to slip through the cracks.
When I saw Paul Everitt's 11tyConf talk and related tutorial series which incorporate TypeScript and TSX into an Eleventy 3.x project, I was immediately excited at the prospect of being able to make Eleventy projects more maintainable. One thing Paul teased but didn't cover in detail was input data validation. My thoughts immediately turned to Astro, and its ability to hook up Zod schemas to content collections to validate front matter. Maybe I could accomplish something similar on top of Paul's TSX setup for Eleventy layouts?
Starting Point
This post will assume you've followed the first 3 parts of the JetBrains tutorial:
At this point, you will have a setup which relies on tsx
to understand TypeScript, and jsx-async-runtime
to understand JSX/TSX templates.
Adding Types for Eleventy-Supplied Data
As far as I could tell, neither Eleventy itself nor DefinitelyTyped have typings for Eleventy. Let's start with some typings for data that Eleventy exposes that could be useful:
interface EleventyPage {
date: Date;
filePathStem: string;
fileSlug: string;
inputPath: string;
outputFileExtension: string;
outputPath: string;
rawInput: string;
templateSyntax: string;
url: string;
}
interface EleventyMeta {
directories: {
data: string;
includes: string;
input: string;
layouts?: string;
output: string;
};
env: {
config: string;
root: string;
runMode: "build" | "serve" | "watch";
source: "cli" | "script";
};
generator: string;
version: string;
}
export interface EleventyProps {
content: JSX.Children | string;
eleventy: EleventyMeta;
page: EleventyPage;
}
The content
property is how an Eleventy layout accesses each child template's output. There are a few things worth noting about this property in the context of TSX:
- The
JSX
namespace referenced above is declared globally byjsx-async-runtime
; no import is required - When the child template is Markdown (and presumably others as well),
content
is a string - When the child template is also JSX/TSX,
content
itself is JSX
As we'll see below, both the string and JSX content
cases work easily with jsx-async-runtime
, because it does not escape HTML in JavaScript values by default. This is opposite to more common JSX runtimes; keep this in mind depending on where your data originates.
Incorporating Zod
Zod is a validation library which will easily produce TypeScript typings for defined schemas.
Let's create a centralized helper to take care of most of the work, so that we won't have much boilerplate to repeat in TSX layouts. The helper does the following:
- Receives a Zod schema and a functional component
- Returns a new functional component that does the following:
- Attempts to parse the props it receives using the schema
- Spreads the parsed output on top of the original data (in order to include all properties from the cascade, even ones not included in the schema, e.g. Eleventy's own provided data)
import type { EleventyProps } from "types";
import type { input, output, ZodTypeAny } from "zod";
export function createEleventyComponent<T extends ZodTypeAny>(
schema: T,
FC: (props: EleventyProps & output<T>) => JSX.Element
) {
return (props: EleventyProps & input<T>) =>
FC({
...props,
...schema.parse(props),
});
}
Using this function in a .11ty.tsx
layout would look something like this:
export const render = createEleventyComponent(
z.object({
title: z.string().min(1), // require non-empty
}),
({ content, title }) => (
<html>
<head>
<title>{title}</title>
</head>
<body>{content}</body>
</html>
)
);
In this example, title
comes from the Zod schema, and content
comes from Eleventy-supplied data (see notes in the previous section). We get IntelliSense for both, thanks to the typings in createEleventyComponent
.
Meanwhile, schema.parse
will fail loudly if someone forgets to provide title
in the front matter data of any template (TSX or otherwise) that uses this layout:
[11ty] 1. Having trouble writing to "./_site/index.html" from "./index.md" (via EleventyTemplateError)
[11ty] 2. Transform `tsx` encountered an error when transforming ./index.md. (via EleventyTransformError)
[11ty] 3. [
[11ty] {
[11ty] "code": "invalid_type",
[11ty] "expected": "string",
[11ty] "received": "undefined",
[11ty] "path": [
[11ty] "title"
[11ty] ],
[11ty] "message": "Required"
[11ty] }
[11ty] ] (via ZodError)
Thus, we get the benefits of TypeScript on one side, along with the assurance that we will receive the data we expect from the other.
Further Advantages
The createEleventyComponent
function spreads the parsed output over the original props, so we can benefit from coercion and transforms in the Zod schema.
Coercion can be particularly useful in the event you are computing template strings in front matter. As a contrived example, let's modify the schema from the previous example to include a number field:
z.object({
n: z.coerce.number(),
title: z.string().min(1), // require non-empty
}),
The use of z.coerce
means you will always get a number value, even if it is populated through a computed template string (which ordinarily results in a string):
---
eleventyComputed:
n: "{{ 42 }}"
title: "Hello world"
---
Conclusion
The approach explained in this post is primarily useful for defining TSX layouts. You're free to componentize however you like from that point onward, e.g. defining a component that helps lay out meta tags, or reusable header/footer components that are instantiated differently across distinct layouts. Since you now have fully typed and validated props at the layout level, passing them to child components works as it would in any other TSX codebase, making it far easier to see what data each layout and component expects at a glance.
I'm experimenting with another idea to take this approach one step further; I'm hoping to have another post about that coming up soon.
Comments from Mastodon
The Eleventy team informed me that there is an eleventyDataSchema
feature since 3.0.0-alpha.7 that can effectively support data validation (Zach even includes a Zod example). This is great if validation is all you're after, and you can obtain typings from your schema using Zod's infer
, output
, or TypeOf
(they're all synonymous). However, there's no way to benefit from coercion or transforms as discussed above, since the validation pass can't override the data.
Posted on May 14, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.