Type-Safe Eleventy Data Cascade Access with TSX and Preact Hooks

kgf

Kenneth G. Franqueiro

Posted on May 19, 2024

Type-Safe Eleventy Data Cascade Access with TSX and Preact Hooks

This post continues where Making Eleventy Data Traceable with TSX and Zod leaves off; it will be easier to follow after completing the steps in that post first.

My previous post culminated in having fully-typed (and validated) data available to top-level templates in TSX; from there, it is possible to build an entire component hierarchy, passing both Eleventy-supplied and custom data throughout.

This can turn out to be more cumbersome the more you componentize, as you may end up needing to pass properties down repeatedly, even in the case of Eleventy-supplied data.

What if we could access and validate data needed directly within each component rather than all-at-once at the top level? This would be more in line with how Eleventy templates are typically written, but with the same typing and validation assurances of the previous approach.

Since we've already started down the path of TSX, this sounds like something that context could address...

Replacing jsx-async-runtime with Preact

jsx-async-runtime doesn't know anything about the concept of context, as that is a feature specific to React and Preact. We'll use the latter, since it's far more lightweight. Bear in mind that we're only using this within the Eleventy build process itself; it will not add any client-side JavaScript.

First, let's update dependencies:

npm rm jsx-async-runtime
npm i preact preact-render-to-string
# (or yarn add/remove, etc. as desired in place of npm)
Enter fullscreen mode Exit fullscreen mode

Update tsconfig.json to reference the correct JSX runtime:

"jsxImportSource": "preact",
Enter fullscreen mode Exit fullscreen mode

Then update the tsx transform in eleventy.config.ts:

import type { VNode } from "preact";
import { renderToString } from "preact-render-to-string";

// ...

eleventyConfig.addTransform("tsx", (content: VNode) =>
  renderToString(content)
);
Enter fullscreen mode Exit fullscreen mode

The content property we defined in EleventyProps in the previous post also needs updating:

import type { ComponentChildren } from "preact";

// ...

export interface EleventyProps {
  content: ComponentChildren;
  eleventy: EleventyMeta;
  page: EleventyPage;
}
Enter fullscreen mode Exit fullscreen mode

Handling TSX vs. non-TSX Content

In the previous post, I mentioned that content from both TSX and non-TSX templates would Just Work with jsx-async-runtime because it doesn't escape HTML in JavaScript by default.

Preact does escape by default, which means that while TSX template content (received as JSX children) can be handled as before...

  <body>{content}</body>
Enter fullscreen mode Exit fullscreen mode

...non-TSX template content (received as strings) needs to remain unescaped, since it is expected to contain markup:

<body dangerouslySetInnerHTML={{ __html: content }} />
Enter fullscreen mode Exit fullscreen mode

One layout can support both TSX and non-TSX templates by checking whether content is a string:

typeof content === "string" ? (
  <body dangerouslySetInnerHTML={{ __html: content }} />
) : (
  <body>{content}</body>
)
Enter fullscreen mode Exit fullscreen mode

If you find you need to support both formats across many layouts, you can create a helper function like this:

import { type ComponentChildren } from "preact";

export const resolveContent = (content: ComponentChildren) =>
  typeof content === "string"
    ? { dangerouslySetInnerHTML: { __html: content } }
    : { children: content };

// Example usage:
// <body {...resolveContent(content)} />
Enter fullscreen mode Exit fullscreen mode

If you're also planning to use both TSX and non-TSX layouts, you'll find that the tsx transform needs to be made more robust than it was in the original JetBrains series, to avoid running renderToString on non-TSX layouts (because, like above, content will already be a string):

eleventyConfig.addTransform("tsx", (content: string | VNode) =>
  typeof content === "string" ? content : renderToString(content)
);
Enter fullscreen mode Exit fullscreen mode

Creating the Context Object

Creating a context requires setting up a default value for it. Rather than write a bunch of lines for a value that should never be used, we're going to take an intentional shortcut here:

const DataContext =
  createContext(null as unknown as EleventyProps);
Enter fullscreen mode Exit fullscreen mode

This cast ensures that the type returned when consuming the context is always EleventyProps; meanwhile, the default null value will cause immediate and loud failures in the build process if we ever forget to hook up the provider.

Provider Setup

We can save ourselves some work - and make it as easy as possible to remember to hook up the provider - by creating a helper for TSX templates' render functions:

import { type ComponentType } from "preact";

// ...

export const createRender =
  <T extends EleventyProps>(Component: ComponentType) =>
  (props: T) =>
    (
      <DataContext.Provider value={props}>
        <Component />
      </DataContext.Provider>
    );
Enter fullscreen mode Exit fullscreen mode

The helper function takes care of passing data from Eleventy to the provider; all we need to do is call it with our layout component:

const Layout = () => ...;

export const render = createRender(Layout);
Enter fullscreen mode Exit fullscreen mode

I intentionally skipped over the layout component here; we'll get to that next.

Consuming the Context

Let's set up a hook to consume the data cascade from the context, and validate any custom data we're expecting. (This takes the place of createEleventyComponent in the previous post.)

import { useContext } from "preact/hooks";
import type { output } from "zod";

export const useData = <T extends ZodTypeAny>(schema: T) => {
  const data = useContext(DataContext);
  const parsed = schema.parse(data) as output<T>;
  return {
    ...data,
    ...parsed,
  };
};
Enter fullscreen mode Exit fullscreen mode

For convenience, we can also expose a simpler hook for cases where only Eleventy's own data (which we previously defined in EleventyProps) is needed:

export const useEleventyData = () => useContext(DataContext);
Enter fullscreen mode Exit fullscreen mode

With the provider hooked up and the hooks in place, we can now consume Eleventy data without needing to think about props:

const Layout = () => {
  const { content, title } = useData(
    z.object({
      title: z.string().min(1),
    })
  );

  return (
    <html>
      <head>
        <title>{title}</title>
      </head>
      <body {...resolveContent(content)} />
    </html>
  );
};

export const render = createRender(Layout);
Enter fullscreen mode Exit fullscreen mode

As with the previous approach, we only need to validate custom data, while Eleventy-supplied data is assumed from our typings.

Further Demonstration

We have effectively moved our schema usage to within individual components, instead of wrapped around the entire layout's invocation. The value of this approach is that each component can validate the specific data it needs in exactly the same way as seen above, with no need to pass props around.

To better illustrate this, let's reorganize the layout into smaller components with more focused responsibilities.

Meta

This component will handle adding metadata to the document head, using title and optionally description from custom data.

const Meta = () => {
  const { description, title } = useData(
    z.object({
      description: z.string().optional(),
      title: z.string().min(1),
    })
  );
  return (
    <>
      <title>{title}</title>
      <meta name="og:title" content={title} />
      {!!description && (
        <>
          <meta name="description" content={description} />
          <meta name="og:description" content={description} />
        </>
      )}
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

Header

This component renders a (oversimplified) site header with navigation links. It relies on page.url supplied by Eleventy to determine whether to set aria-current on each link. This can use the simpler useEleventyData hook, since it doesn't need custom data and therefore has nothing to validate.

const Header = () => {
  const { page } = useEleventyData();
  return (
    <header>
      <nav aria-label="Site">
        <ul>
          <li>
            <a href="/" aria-current={page.url === "/" ? "page" : undefined}>Home</a>
          </li>
          <li>
            <a href="/about/" aria-current={page.url === "/about/" ? "page" : undefined}>About</a>
          </li>
        </ul>
      </nav>
    </header>
  );
};
Enter fullscreen mode Exit fullscreen mode

Layout

Finally, this is what the Layout component looks like, using the two other components defined above:

const Layout = () => {
  const { content } = useEleventyData();
  return (
    <html>
      <head>
        <Meta />
      </head>
      <Header />
      <body {...resolveContent(content)} />
    </html>
  );
};
Enter fullscreen mode Exit fullscreen mode

Similar to the Header component, the layout only needs access to Eleventy-supplied data.

Meta is the only component out of the three that references custom data, so it's the only one that calls useData and specifies a schema. In more realistic scenarios, it's quite possible that multiple components will depend on the same properties in custom data, in which case it may make sense to centralize schemas for reusable subsets, and/or centralize a superset and make use of pick or omit.

Conclusion

This definitely started out as an experiment, and I imagine it will seem like overkill for some projects. I think it's a nice option to have in contrast to needing to pass down props all the time. Either way, I'm glad to have options for making Eleventy projects more maintainable going forward.

💖 💪 🙅 🚩
kgf
Kenneth G. Franqueiro

Posted on May 19, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related