Content collections
A namespace of pages can be a collection: blog posts, docs, a changelog, a list of people. Code reads the pages as typed entries, and a page whose frontmatter doesn't fit the collection's schema isn't saved.
<!-- tcm: namespace blog.posts --> --- title: Hello date: 2026-11-07 --- Our first post.
// tcm: namespace site
import * as files from "#codebase/blog/posts";
import { collection } from "#codebase/site/lib/content";
import { z } from "zod";
export const posts = collection(files, {
schema: z.object({ title: z.string(), date: z.iso.date(), draft: z.boolean().default(false) }),
sort: (a, b) => b.data.date.localeCompare(a.data.date),
});
export const published = () => posts.all().filter((p) => !p.data.draft);
$ tcm add hello.md
doesn't fit site.posts: date: Invalid input: expected string, received undefined
hello.md has problems; nothing was saved.
A namespace of files
import * as files from "#codebase/blog/posts" imports a namespace of files whole: an object with each file by its
name in the namespace (files.hello), its text as saved (pages).
- It follows the namespace. A file added, changed, renamed or removed there changes the code that imports it, as
an update does. Moving the namespace (
tcm mv blog news) moves the import with it. - Removing a file isn't refused for code that has it only this way: that code gets the namespace without it. Code that names the file itself still holds it.
- Only files, only that namespace. A namespace that holds code can't be imported whole, and files in namespaces
under it (
blog.posts.old.first) aren't in it.
The content library
tcm lib add tcm content adds it, from the tcm you're running (libraries).
collection(files, { schema, format?, sort? })- the files as entries: each one's
name, itsdataas the schema makes it, itsbody, and an MDX page'sContent posts.all()- every entry, sorted; throws, naming each entry that doesn't fit, if any doesn't
posts.get(name)- one entry, or
undefined posts.check(name, text)- what's wrong with
textas entryname(nothing: it fits) await posts.load()- check every entry once, waiting for a schema that checks asynchronously: with one,
allandgetread entries only after it frontmatter(text)- a Markdown file's YAML frontmatter and the text under it
markdown(text, { link?, highlight? })- Markdown as HTML;
{@link name}shows the name, linked wherelinksays; a fenced code block is ashighlight(code, lang)makes it (below) shiki({ langs, themes?, defaultColor? })- a
highlightmade with Shiki rss({ title, link, description, items })- an RSS 2.0 feed, with its items' links made absolute
standardSite({ did, url, name, description?, posts })- the posts as a standard.site publication, for
tcm publish(below)
- Any Standard Schema. Zod, Valibot, ArkType and others implement Standard Schema;
the entries'
datahas the type the schema makes. One that checks asynchronously (Zod's async refinements, say) is waited for as files are saved;await posts.load()before reading entries. - Markdown, JSON or YAML. A Markdown file's data is its frontmatter, the YAML between
---lines at its top (none is{}). Withformat: "json"or"yaml", a file's data is its content and itsbodyis empty. - MDX pages (MDX pages) are read as Markdown files are, and each entry's
Contentis the page, a component (<post.Content />). For other files it'sundefined. - Entries are read when asked for, once: a collection with an entry that doesn't fit still gives the others.
Highlighted code
// tcm: namespace site
import { markdown, shiki } from "#codebase/site/lib/content";
const highlighter = shiki({ langs: ["ts", "sh"] });
export const html = async (body: string) => markdown(body, { highlight: await highlighter });
highlight(code, lang)is given each fenced block with a language (its info string's first word) and returns its HTML, orundefinedto show it as written. Any highlighter can be one.shikihighlights the languages inlangsand leaves the rest as written. Make it once: it loads their grammars.- A light and a dark theme,
github-lightandgithub-darkunlessthemessays. The light one's colours are in the HTML, and the dark one's are CSS variables, for a page in dark mode to use:@media (prefers-color-scheme: dark) { .shiki, .shiki span { color: var(--shiki-dark) !important; background-color: var(--shiki-dark-bg) !important; } }.defaultColor: "dark"swaps them;defaultColor: falsewrites only variables (--shiki-lighttoo).
Publishing on the AT Protocol
A blog can be a standard.site publication, so AT Protocol readers (Leaflet, and others) find
its posts and show them as its account's. standardSite makes its records from the posts, and tcm publish writes
them to the account's repository.
// tcm: namespace site
import { standardSite } from "#codebase/site/lib/content";
export const PUBLICATION = standardSite({
did: "did:plc:…", // the account whose repository holds it
url: "https://example.com/blog",
name: "Example blog",
posts: posts.all().map((p) => ({
name: p.name,
path: `/${p.name}.html`,
title: p.data.title,
publishedAt: p.data.date,
markdown: p.body,
})),
});
$ tcm publish site.PUBLICATION --as example.com
new at://did:plc:…/site.standard.publication/example.com:blog
new at://did:plc:…/site.standard.document/example.com:blog:hello
published site.PUBLICATION as @example.com: 2 new, 0 updated, 0 removed, 0 as they were
- A record for the blog and one for each post:
site.standard.publication(its address, name and description) andsite.standard.document(its path, title, when it was published, and optionallyupdatedAt,descriptionandtags). A post's Markdown is in it asat.markpub.markdown, its links made absolute and{@link}s as names in code, and as plain text (textContent). - Keyed by the blog's address and each post's name (
example.com:blog:hello), so publishing again updates them. Only what changed is written, and the documents of posts the blog no longer has are removed. Renaming a post makes a new document. - The site says they're its own. It serves the publication's AT-URI at
wellKnown.path(/.well-known/site.standard.publication/blog; writewellKnown.textthere), and each post's page haslink(name), its<link rel="site.standard.document">, in its<head>.tcm publishsays if the well-known address doesn't answer with it.tcm serve --siteserves a well-known file as text. - Signing in is as for
tcm release --announce: in the browser once, or withTCM_ATPROTO_APP_PASSWORD.--dry-runsays what would change and writes nothing.
Checked when saved
tcm add, tcm update and tcm check of a file ask each collection of its namespace whether it fits: the code that
imports the namespace whole and is a collection (its type is Collection<…>). One that doesn't fit isn't saved, and
each problem names the collection and where in the data it is. A namespace no collection reads takes any file.
A collection is checked against the files it has too:
- Saving a collection (new, or its schema changed) asks it about each of its files: if one doesn't fit, it isn't
saved, and each that doesn't is named (
hello doesn't fit site.posts: tags: …).tcm checksays so too. - A collection that changes because something it uses did (a schema kept in a definition of its own) follows
that change, and goes in
tcm todowhile its files don't fit it, naming them. Fixing the files, or the schema, clears it.