Collections
Group entries under one schema, from files or any loader.
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 read it by, collections.get("posts").
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, eghello-worldmetadata: the schema's output, eg a file's frontmatterbody: the text under the frontmatter, or whatevertransformreturnedfile:{ name, path }, orundefinedwhen 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.
import { defineConfig, directory } from "tomekit";
import { z } from "zod";
export default defineConfig({
collections: {
posts: {
loader: directory("content/posts", { exclude: "drafts/**" }),
schema: z.object({ title: z.string() }),
},
},
});include defaults to "**/*.md". Both options take one glob or a list, relative to the directory.
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.
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.object({ name: z.string() }),
},
},
});Each entry has a slug, and optionally metadata, body and file. load receives:
collection: the collection's nameroot: the project root, as an absolute pathdev: whether the Vite dev server is runningwatch: marks files whose changes rerunloadin 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.
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.object({
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, sodateabove is aDateandtagsis 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()readsslugbefore the schema runs. It stays inmetadataonly if the schema keeps it, andz.objectdrops keys it doesn't list.
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.
export default defineConfig({
collections: {
authors: {
loader: directory("content/authors"),
schema: z.object({ name: z.string() }),
},
posts: {
loader: directory("content/posts"),
schema: z.object({
author: z.string(),
sections: z.array(z.object({ 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 fromcollections. - Every slug must belong to a document that
collections.get("authors")returns, so a skipped or broken author doesn't count. Otherwisevite buildfails, 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 = collections.get("posts").get("hello");
collections.get("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.