Collections

Group entries under one schema, from files or any loader.

View as Markdown

A collection is a named set of documents: posts, authors, docs. Everything in it comes from one loader, which says where its entries come from, and passes one schema, which validates each entry's metadata. The name you give it in defineConfig is the name you import it by, import { posts } from "tomekit/content", so it must be a valid JavaScript name that isn't reserved, eg not default, and not collections.

A document is one validated entry. Every document in every collection has the same four fields, and only their types differ:

  • slug: its key inside the collection, eg hello-world
  • metadata: the schema's output, eg a file's frontmatter
  • body: the text under the frontmatter, or whatever transform returned
  • file: { path }, or undefined when the entry came from no file

So a folder of Markdown and rows from an API are read the same way, and a field from your schema can never clash with a field tomekit sets.

Loaders

Files in a directory

directory() loads each Markdown file in a folder. Its frontmatter becomes the metadata and the rest becomes the body. The slug is the frontmatter's slug, or the file's path without the extension.

tomekit.config.ts
import { defineConfig, directory } from "tomekit";
import { z } from "zod";

export default defineConfig({
  collections: {
    posts: {
      loader: directory("content/posts", { files: ["**/*.md", "!drafts/**"] }),
      schema: z.strictObject({ title: z.string() }),
    },
  },
});

files takes one glob or a list, relative to the directory, and defaults to "**/*.md". A pattern that starts with ! leaves out files the others match, wherever it sits in the list. Nothing else is left out for you, so a folder that holds other things, eg the project root, needs its own ! patterns: directory(".", { files: ["**/*.md", "!node_modules/**"] }).

Your own loader

A loader is an object with a load function that returns entries. Use it for content that isn't a folder of Markdown, eg JSON or an API.

tomekit.config.ts
import { readFile } from "node:fs/promises";
import path from "node:path";

export default defineConfig({
  collections: {
    authors: {
      loader: {
        load: async ({ root, watch }) => {
          watch("data/authors.json");

          return {
            entries: JSON.parse(
              await readFile(path.join(root, "data/authors.json"), "utf-8")
            ),
          };
        },
      },
      schema: z.strictObject({ name: z.string() }),
    },
  },
});

Each entry has a slug, and optionally metadata, body and file. load receives:

  • collection: the collection's name
  • root: the project root, as an absolute path
  • dev: whether the Vite dev server is running
  • watch: marks files whose changes rerun load in dev

Return issues for entries that couldn't load and warnings for anything else worth saying. The other entries still load. A load that throws fails the whole collection.

Reloading in dev

Call watch with globs, relative to the project root, whose changes rerun load. Start a pattern with ! to leave files out. A ! pattern only applies to the globs from the same call, so a loader that calls directory(...).load(context) keeps both sets. directory() watches its own files. A loader that never calls watch reruns only when the config changes.

Sharing a loader

defineLoader keeps a loader's types when it lives outside the config, eg in its own file.

loaders/authors.ts
import { defineLoader } from "tomekit";

export const authors = defineLoader({
  load: () => ({ entries: [{ metadata: { name: "Ada" }, slug: "ada" }] }),
});

To combine sources in one collection, call another loader's load inside yours and add your entries to its result.

Schema

schema validates each entry's metadata. Use any validator that implements Standard Schema, eg Zod, Valibot or ArkType.

posts: {
  loader: directory("content/posts"),
  schema: z.strictObject({
    date: z.coerce.date(),
    tags: z.array(z.string()).default([]),
    title: z.string(),
  }),
}
  • The schema's output becomes the document's metadata, with its types, so date above is a Date and tags is never missing.
  • It must produce an object. A file without frontmatter is validated as {}.
  • Frontmatter must be keys and values. A YAML list or a single value is an error.
  • An issue points at the key's line and column: content/posts/hello.md:3:1: title: and then the validator's message. A missing key points at its deepest parent that exists.
  • directory() reads slug before the schema runs. It stays in metadata only if the schema keeps it.

Use a strict object schema, so a misspelled key fails the build:

// Zod
schema: z.strictObject({ title: z.string() });

// Valibot
schema: v.strictObject({ title: v.string() });

// ArkType
schema: type({ "+": "reject", title: "string" });

References

references names the metadata fields that hold slugs of another collection. It sits next to collections, keyed by collection name and then by key path.

tomekit.config.ts
export default defineConfig({
  collections: {
    authors: {
      loader: directory("content/authors"),
      schema: z.strictObject({ name: z.string() }),
    },
    posts: {
      loader: directory("content/posts"),
      schema: z.strictObject({
        author: z.string(),
        sections: z.array(
          z.strictObject({ author: z.string(), title: z.string() })
        ),
      }),
    },
  },
  references: {
    posts: { author: "authors", "sections.author": "authors" },
  },
});
  • A path goes through nested objects and arrays, eg sections.author. TypeScript accepts only paths to strings or arrays of strings, and names from collections.
  • A path goes at most 5 levels deep and never into a Date, Map, Set, RegExp or URL. A key that contains . can't be part of a path.
  • Every slug must belong to a document that authors.get() returns, so a skipped or broken author doesn't count. Otherwise vite build fails, pointing at the file and line. Dev leaves the post out and shows the error in the overlay.
  • The check runs on documents after transform, and only on strings, so a transform can replace a slug with something else.

In the generated types the field holds that collection's slugs, so following it needs no undefined check:

const post = posts.get("hello");

authors.get(post.metadata.author).metadata.name;

Inside transform, metadata.author is still a string: the check runs after every transform, since a transform's skip() decides which slugs exist.