Making Eleventy Data Traceable with TSX and Zod
Tags: eleventy, 11ty, typescript, zod
Published: 2024-05-14 19:04:34

[Eleventy](https://11ty.dev/) 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](https://www.11ty.dev/docs/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](https://www.youtube.com/watch?v=iLxJ6PtuF9M&t=5640s) and [related tutorial series](https://www.jetbrains.com/guide/javascript/tutorials/eleventy-tsx/) 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:

- [Get 11ty 3.0 and TSM](https://www.jetbrains.com/guide/javascript/tutorials/eleventy-tsx/setup/)
- [Switch to TypeScript](https://www.jetbrains.com/guide/javascript/tutorials/eleventy-tsx/switch-to-ts/)
- [Templating in TSX](https://www.jetbrains.com/guide/javascript/tutorials/eleventy-tsx/tsx-templates/)

At this point, you will have a setup which relies on [`tsx`](https://github.com/privatenumber/tsx) to understand TypeScript, and [`jsx-async-runtime`](https://github.com/privatenumber/tsx) 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](https://www.11ty.dev/docs/data-eleventy-supplied/) that could be useful:

```ts
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 by `jsx-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](https://zod.dev) 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)

```ts
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:

```tsx
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](https://www.11ty.dev/docs/data-frontmatter/) 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](https://zod.dev/?id=coercion-for-primitives) and [transforms](https://zod.dev/?id=transform) in the Zod schema.

Coercion can be particularly useful in the event you are [computing template strings in front matter](https://www.11ty.dev/docs/data-computed/#using-a-template-string). As a contrived example, let's modify the schema from the previous example to include a number field:

```ts
  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](https://fosstodon.org/@eleventy/112445266077627523) that there is an `eleventyDataSchema` feature since 3.0.0-alpha.7 that can effectively support data validation (Zach even includes a [Zod example](https://github.com/11ty/eleventy/issues/879#issuecomment-2062585031)). 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.