DEV Community

Kenneth G. Franqueiro
Kenneth G. Franqueiro

Posted on

Making Eleventy Data Traceable with TSX and Zod

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;
}
Enter fullscreen mode Exit fullscreen mode

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 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),
    });
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
);
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
  }),
Enter fullscreen mode Exit fullscreen mode

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"
---
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (1)

Collapse
 
hfreitas profile image
Hebert Freitas

Nice article!