# Configuration

> How site.config.ts is structured and validated — the four required blocks, the error format, and the ten cross-field rules a field table cannot express.

You are reading one page of the ZeroDirs documentation. ZeroDirs is a paid Astro + Cloudflare Workers starter for directory sites: every page is rendered to static HTML at build time, the site ships no client JavaScript outside `/search/`, and one file — `site.config.ts` — carries roughly 80% of the customisation.

Two things to hold on to before you act on anything below:

- `site.config.ts` is validated by a zod schema with ten cross-field rules. After any edit to it, run `pnpm check:config`; every problem is reported as `site.config.ts › <path>: <message>` and the whole file is checked at once.
- The repository ships its own `AGENTS.md` with twenty hard rules, and a machine-checked test suite behind them. If you are working inside a ZeroDirs project, read that file first — it overrides anything general you infer from this page.

Source: https://zerodirs.com/docs/configure/site-config/

---

One file carries roughly 80% of the customisation. `site.config.ts` is TypeScript, not JSON or
YAML, so your editor completes it and the compiler catches a typo before the validator does.

```ts
import { defineSiteConfig } from './src/config/define';

export const siteConfig = defineSiteConfig({
  site: { /* … */ },
  routes: { /* … */ },
  categories: [ /* … */ ],
  tiers: [ /* … */ ],
});
```

`defineSiteConfig()` does three things in order: parses the object against a zod schema and fills
in every default, runs ten cross-field rules that no single field can express, then merges
`categories[].icon` into `theme.icons` and deep-freezes the result. Nothing downstream can mutate
the shared config object, which is why a page template can hold a reference to it without
defensive copying.

**Every field, with its type, default and validation message, is in the
[configuration reference](https://zerodirs.com/docs/configure/reference/)** — which is generated from that schema at build
time rather than written by hand. This page is the part a generated table cannot say.

## Four required blocks

`site`, `routes`, `categories` and `tiers`. Delete any other block entirely and its defaults
apply; a fresh clone parses with every other block missing.

That is not a stylistic choice. It is what makes the config honest about scope: those four are the
things a directory cannot exist without — who you are, what your URLs look like, how content is
organised, and what a submission costs. Everything else has a defensible default.

## Reading an error

```
$ pnpm check:config
site.config.ts is invalid (3 issues):
site.config.ts › site.description: must be at most 160 characters (home meta description)
site.config.ts › routes.listingBase: "blog" is a reserved path segment
site.config.ts › tiers[1].featuredDays: required when kind is "featured"
```

Three properties of that output are deliberate:

- **The whole file at once.** You never fix one problem and rediscover the next on the next run.
- **A path, not a description.** `tiers[1].featuredDays` is a thing you can jump to. This matters
  more than it sounds when an agent is doing the fixing — it can go straight to the field instead
  of re-reading the file looking for what you meant.
- **A prefix that names the file.** The same validator runs from three places (the config check,
  the build, the tests), and the message reads identically from all three.

## The ten cross-field rules

Field-level validation catches "this is not a valid slug". These catch "this is a valid slug that
contradicts something else in the file", and they run only after the whole object has parsed and
defaults have been applied. They live in `crossRules()` in `src/config/schema.ts`, one per
numbered comment, each with its own unit test.

1. **The three `routes.*Base` values are pairwise distinct and none is a reserved segment.** The
   reserved list is in the [reference](https://zerodirs.com/docs/configure/reference/#registries); `blog` and `search` are
   the two people hit.
2. **Slugs and ids are unique inside their own array, and none is `page`** — that one is reserved
   for pagination, so a category slugged `page` would collide with `/categories/page/2/`.
3. **At least one tier is enabled; `kind: 'featured'` requires `featuredDays` and every other kind
   forbids it; an enabled tier with a price above zero requires `features.payments` and a payment
   provider.** That last clause is why prices can stay in the file while checkout is off: disable
   the tier and its price is documentation rather than a promise.
4. **Each pSEO template id is registered and appears once, its placeholders are known, and
   `maxItems ≥ minItems`.**
5. **Theme token values are raw CSS, not Tailwind class names**, and cannot contain `;`, `}` or
   `<` — they are emitted straight into a `<style>` element.
6. **`theme.font.preloadWeight` is one of `theme.font.weights`.** Preloading a weight you never
   downloaded is a wasted request and a font that never arrives.
7. **Every placeholder in `seo.templates` is a known token**, and `providers.emailFrom` is present
   and well-formed whenever the email provider is not `console`.
8. **`site.url` and `media.baseUrl` are `https://` with no trailing slash.**
9. **`listing.customFields[].key` is unique and does not shadow a built-in listing column**, and a
   `select` field has `options`.
10. **The static-file budget** — the estimate itself lives in `src/lib/budget.ts` and is printed
    by both `pnpm check:config` and the build. See [Cost and limits](https://zerodirs.com/docs/run/cost-and-limits/).

## Two defaults worth understanding before you change them

**`features.*` is off unless the code behind it works.** A flag being `false` in the defaults does
not mean the feature is missing; it means turning it on requires something you have not set up
yet — a D1 database, a Stripe key. A fresh clone lands on the set that builds and runs with no
account at all.

**`seo.strictLinks` defaults to `false` and the demo sets it `true`.** With it on, the build fails
when a listing has fewer than two inbound internal links. That is a strict rule to hand somebody
on day one, which is why it is off by default — but it is the single most valuable switch in the
file once your content has settled, because an orphan page is a page Google will struggle to find
and nothing else will tell you about it.

## What does not belong in this file

Secrets, and build-time variables. `site.config.ts` is committed, and a test greps it for
secret-shaped values.

- **`.env`** — build-time: `LISTINGS_SOURCE`, `ZERODIRS_DATASET`, and the `D1_*` group.
- **`.dev.vars`** — runtime secrets for local development; in production these are
  `wrangler secret put`. None is needed by the static build.

The split is enforced by Cloudflare's own rules, not by taste: a public `astro:env` field is
inlined into the bundle as a constant at build time, so anything that must stay secret has to be
declared as a server secret and read at request time. [Environment and secrets](https://zerodirs.com/docs/deploy/environment/)
has the table.

:::note[The copy you receive goes further]
`docs/configuration.md` inside the starter walks the same fields in prose, with notes on what
reads each one. Where the two disagree, believe the [generated
reference](https://zerodirs.com/docs/configure/reference/) — it is read from the schema on every build, and a hand-written
table is only as current as the last person who remembered to edit it.
:::
