# Configuration reference

> Every field of site.config.ts, generated from the zod schema the starter validates against — types, defaults and the exact validation messages.

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/reference/

---

<!-- DO NOT EDIT — generated from zerodirs/zerodirs src/config/schema.ts by .github/docs/generate.ts (docs-sync.yml). Edit the source, not this page. -->

This page is generated from `src/config/schema.ts` at build time — the same module `pnpm check:config` runs. Nothing here is typed by hand, so it cannot fall behind the code: rename a field and this page renames it on the next build. **201 fields across 20 blocks.**

Read it alongside [Configuration](https://zerodirs.com/docs/configure/site-config/), which covers the four required blocks, the error format and the ten cross-field rules that no field table can express.

A field with no default is **required**. `x[]` in a path is one array item, so `categories[].slug` is the `slug` of each entry in `categories`.

## `site`

Identity. `url` is baked into canonicals, the sitemap, the feeds and the absolute OG image URLs at build time, so it has to be the real domain before the final build.

| Field | Type | Default | Rules |
| --- | --- | --- | --- |
| `site` | `object` | **required** |  |
| &nbsp;&nbsp;`name` | `string` | **required** |  |
| &nbsp;&nbsp;`tagline` | `string` | **required** |  |
| &nbsp;&nbsp;`description` | `string` | **required** | must be at most 160 characters (home meta description) |
| &nbsp;&nbsp;`url` | `url` | **required** | must be an https:// URL; must not end with "/" |
| &nbsp;&nbsp;`kind` | `'software' \| 'service' \| 'business'` | **required** |  |
| &nbsp;&nbsp;`locale` | `string` | `'en'` | at least 2 characters |
| &nbsp;&nbsp;`logo` | `string` | — | must start with `/` |
| &nbsp;&nbsp;`social` | `object` | — |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`social.twitter` | `string` | — |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`social.github` | `string` | — |  |
| &nbsp;&nbsp;`contactEmail` | `email` | **required** |  |
| &nbsp;&nbsp;`builtWith` | `boolean` | `true` |  |

## `routes`

The URL scheme. Rename a base and every link in the site moves with it, because `src/lib/paths.ts` is the only place a URL is constructed.

| Field | Type | Default | Rules |
| --- | --- | --- | --- |
| `routes` | `object` | **required** |  |
| &nbsp;&nbsp;`listingBase` | `string` | **required** | must match ^[a-z0-9-]+$ |
| &nbsp;&nbsp;`categoryBase` | `string` | **required** | must match ^[a-z0-9-]+$ |
| &nbsp;&nbsp;`tagBase` | `string` | **required** | must match ^[a-z0-9-]+$ |
| &nbsp;&nbsp;`listingNoun` | `object` | **required** |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`listingNoun.singular` | `string` | **required** |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`listingNoun.plural` | `string` | **required** |  |

## `categories`

Structural taxonomy. Every configured category always gets a page, even an empty one. A listing naming a category that is not here fails the build.

| Field | Type | Default | Rules |
| --- | --- | --- | --- |
| `categories` | `object[]` | **required** | at least one category is required |
| &nbsp;&nbsp;`[].slug` | `string` | **required** | must be a lowercase slug (a-z, 0-9, single dashes) |
| &nbsp;&nbsp;`[].name` | `string` | **required** |  |
| &nbsp;&nbsp;`[].description` | `string` | — |  |
| &nbsp;&nbsp;`[].seoTitle` | `string` | — |  |
| &nbsp;&nbsp;`[].seoDescription` | `string` | — |  |
| &nbsp;&nbsp;`[].icon` | `string` | — | must be a lucide icon name (a-z, 0-9, -) |
| &nbsp;&nbsp;`[].order` | `integer` | — |  |

## `tags`

Descriptive taxonomy, and the input to programmatic SEO. A listing naming a tag that is not here keeps building — the tag is dropped with a warning.

| Field | Type | Default | Rules |
| --- | --- | --- | --- |
| `tags` | `object[]` | `[]` |  |
| &nbsp;&nbsp;`[].slug` | `string` | **required** | must be a lowercase slug (a-z, 0-9, single dashes) |
| &nbsp;&nbsp;`[].name` | `string` | **required** |  |
| &nbsp;&nbsp;`[].description` | `string` | — |  |

## `pricingTypes`

The pricing badge vocabulary, and one of the search filters.

| Field | Type | Default | Rules |
| --- | --- | --- | --- |
| `pricingTypes` | `object[]` | `see rows below` |  |
| &nbsp;&nbsp;`[].slug` | `string` | **required** | must be a lowercase slug (a-z, 0-9, single dashes) |
| &nbsp;&nbsp;`[].name` | `string` | **required** |  |

## `tiers`

Submission plans. A tier with `kind: "featured"` is what makes a listing's `featuredUntil` produce the badge — that lookup filters on `kind`, not on `enabled`.

| Field | Type | Default | Rules |
| --- | --- | --- | --- |
| `tiers` | `object[]` | **required** | at least one tier is required |
| &nbsp;&nbsp;`[].id` | `string` | **required** | must be a lowercase slug (a-z, 0-9, single dashes) |
| &nbsp;&nbsp;`[].name` | `string` | **required** |  |
| &nbsp;&nbsp;`[].description` | `string` | **required** |  |
| &nbsp;&nbsp;`[].kind` | `'queue' \| 'express' \| 'featured'` | **required** |  |
| &nbsp;&nbsp;`[].priceCents` | `integer` | **required** | ≥ 0 |
| &nbsp;&nbsp;`[].currency` | `string` | **required** | must be a lowercase ISO 4217 code, e.g. "usd" |
| &nbsp;&nbsp;`[].featuredDays` | `integer` | — | > 0 |
| &nbsp;&nbsp;`[].slaHours` | `integer` | — | > 0 |
| &nbsp;&nbsp;`[].features` | `string[]` | `[]` |  |
| &nbsp;&nbsp;`[].enabled` | `boolean` | `true` |  |

## `listing`

How listings are sorted, paginated and rendered, plus per-listing custom fields.

| Field | Type | Default | Rules |
| --- | --- | --- | --- |
| `listing` | `object` | `see rows below` |  |
| &nbsp;&nbsp;`perPage` | `integer` | `24` | ≥ 12; ≤ 60 |
| &nbsp;&nbsp;`relatedCount` | `integer` | `6` | ≥ 0; ≤ 24 |
| &nbsp;&nbsp;`defaultSort` | `'featured' \| 'popular' \| 'newest'` | `'featured'` |  |
| &nbsp;&nbsp;`maxTags` | `integer` | `6` | ≥ 1; ≤ 20 |
| &nbsp;&nbsp;`screenshotMax` | `integer` | `3` | ≥ 0; ≤ 5 |
| &nbsp;&nbsp;`outboundRel` | `'dofollow' \| 'nofollow'` | `'nofollow'` |  |
| &nbsp;&nbsp;`customFields` | `object[]` | `[]` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`customFields[].key` | `string` | **required** | must match ^[a-z][a-zA-Z0-9]*$ |
| &nbsp;&nbsp;&nbsp;&nbsp;`customFields[].label` | `string` | **required** |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`customFields[].type` | `'text' \| 'url' \| 'select' \| 'textarea'` | **required** |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`customFields[].options` | `string[]` | — |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`customFields[].required` | `boolean` | — |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`customFields[].showOnCard` | `boolean` | — |  |

## `seo`

Meta templates, JSON-LD, OG images, `noindex` thresholds and the orphan check.

| Field | Type | Default | Rules |
| --- | --- | --- | --- |
| `seo` | `object` | `{}` |  |
| &nbsp;&nbsp;`templates` | `object` | `see rows below` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`templates.titleTemplate` | `string` | `'{title} \| {siteName}'` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`templates.home` | `object` | `see rows below` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`templates.home.title` | `string` | `'{tagline}'` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`templates.home.description` | `string` | `'Discover the best {nouns} on {siteName}. Browse {count} hand-picked {nouns} by category and tag.'` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`templates.listing` | `object` | `see rows below` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`templates.listing.title` | `string` | `'{name} - {tagline}'` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`templates.listing.description` | `string` | `'{name}: {tagline}. Pricing, alternatives and reviews on {siteName}.'` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`templates.category` | `object` | `see rows below` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`templates.category.title` | `string` | `'Best {category} {nouns} ({count})'` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`templates.category.description` | `string` | `'Compare {count} {category} {nouns}, hand-picked and updated for {year}.'` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`templates.tag` | `object` | `see rows below` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`templates.tag.title` | `string` | `'Best {tag} {nouns} ({count})'` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`templates.tag.description` | `string` | `'Browse {count} {nouns} tagged {tag}, updated for {year}.'` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`templates.pseo` | `object` | `see rows below` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`templates.pseo.title` | `string` | `'{title}'` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`templates.pseo.description` | `string` | `'{title} — {count} {nouns} compared, updated for {year}.'` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`templates.blog` | `object` | `see rows below` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`templates.blog.title` | `string` | `'{title}'` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`templates.blog.description` | `string` | `'{title} — from the {siteName} blog.'` |  |
| &nbsp;&nbsp;`jsonLd` | `object` | `{"breadcrumbs":true,"itemList":true,"organization":true}` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`jsonLd.listing` | `'software' \| 'product' \| 'organization' \| 'localBusiness' \| 'none'` | — |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`jsonLd.breadcrumbs` | `boolean` | `true` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`jsonLd.itemList` | `boolean` | `true` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`jsonLd.organization` | `boolean` | `true` |  |
| &nbsp;&nbsp;`ogImages` | `'generated' \| 'static'` | `'generated'` |  |
| &nbsp;&nbsp;`ogScope` | `object` | `{"listings":true,"categories":true,"pseo":false}` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`ogScope.listings` | `boolean` | `true` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`ogScope.categories` | `boolean` | `true` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`ogScope.pseo` | `boolean` | `false` |  |
| &nbsp;&nbsp;`noindexBelow` | `integer` | `2` | ≥ 0 |
| &nbsp;&nbsp;`strictLinks` | `boolean` | `false` |  |
| &nbsp;&nbsp;`robots` | `object` | `see rows below` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`robots.disallow` | `string[]` | `["/admin/","/api/","/out/","/submit/status/","/search/"]` | each item must start with `/` |
| &nbsp;&nbsp;`twitterHandle` | `string` | — |  |

## `pseo`

Programmatic SEO: which patterns generate pages, the thin-content guard, and the page cap.

| Field | Type | Default | Rules |
| --- | --- | --- | --- |
| `pseo` | `object` | `{"enabled":true,"maxPages":2000,"templates":[]}` |  |
| &nbsp;&nbsp;`enabled` | `boolean` | `true` |  |
| &nbsp;&nbsp;`maxPages` | `integer` | `2000` | ≥ 0 |
| &nbsp;&nbsp;`templates` | `object[]` | `[]` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`templates[].id` | `'best-category-for-tag' \| 'name-alternatives' \| 'free-category'` | **required** |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`templates[].enabled` | `boolean` | `true` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`templates[].minItems` | `integer` | **required** | ≥ 1 |
| &nbsp;&nbsp;&nbsp;&nbsp;`templates[].maxItems` | `integer` | — | ≥ 1 |
| &nbsp;&nbsp;&nbsp;&nbsp;`templates[].title` | `string` | **required** |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`templates[].description` | `string` | **required** |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`templates[].h1` | `string` | **required** |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`templates[].intro` | `string` | **required** |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`templates[].faq` | `object[]` | — |  |
| &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`templates[].faq[].q` | `string` | **required** |  |
| &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`templates[].faq[].a` | `string` | **required** |  |

## `theme`

Preset, token overrides, dark mode, fonts and the icon allow-list.

| Field | Type | Default | Rules |
| --- | --- | --- | --- |
| `theme` | `object` | `see rows below` |  |
| &nbsp;&nbsp;`preset` | `'default' \| 'warm' \| 'mono'` | `'default'` |  |
| &nbsp;&nbsp;`tokens` | `Record<key, string>` | — | keys: `primary`, `primaryFg`, `bg`, `fg`, `muted`, `mutedFg`, `surface`, `line`, `lineStrong`, `accent`, `featured`, `radius`, `radiusSm`, `font`, `fontDisplay`; must be a CSS value, not a Tailwind class name; must not contain ";", "}" or "<" |
| &nbsp;&nbsp;`darkTokens` | `Record<key, string>` | — | keys: `primary`, `primaryFg`, `bg`, `fg`, `muted`, `mutedFg`, `surface`, `line`, `lineStrong`, `accent`, `featured`, `radius`, `radiusSm`, `font`, `fontDisplay`; must be a CSS value, not a Tailwind class name; must not contain ";", "}" or "<" |
| &nbsp;&nbsp;`darkMode` | `'system' \| 'light' \| 'dark'` | `'system'` |  |
| &nbsp;&nbsp;`font` | `{ provider: 'system' } \| { provider: 'fontsource' \| 'google' \| 'bunny', family, weights, preloadWeight }` | `{"provider":"system"}` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`font.provider` | `'system' \| 'fontsource' \| 'google' \| 'bunny'` | **required** |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`font.family` | `string` | **required** | only when `provider` is 'fontsource' \| 'google' \| 'bunny' |
| &nbsp;&nbsp;&nbsp;&nbsp;`font.weights` | `integer[]` | **required** | only when `provider` is 'fontsource' \| 'google' \| 'bunny'; each item ≥ 100; each item ≤ 900 |
| &nbsp;&nbsp;&nbsp;&nbsp;`font.preloadWeight` | `integer` | **required** | only when `provider` is 'fontsource' \| 'google' \| 'bunny'; ≥ 100; ≤ 900 |
| &nbsp;&nbsp;`icons` | `string[]` | `[]` | each item must be a lucide icon name (a-z, 0-9, -) |
| &nbsp;&nbsp;`viewTransitions` | `boolean` | `false` |  |

## `features`

The feature switches. A flag is only on by default when there is working code behind it.

| Field | Type | Default | Rules |
| --- | --- | --- | --- |
| `features` | `object` | `see rows below` |  |
| &nbsp;&nbsp;`submissions` | `boolean` | `false` |  |
| &nbsp;&nbsp;`accounts` | `boolean` | `false` |  |
| &nbsp;&nbsp;`payments` | `boolean` | `false` |  |
| &nbsp;&nbsp;`newsletter` | `'off' \| 'embed' \| 'd1'` | `'off'` |  |
| &nbsp;&nbsp;`blog` | `boolean` | `true` |  |
| &nbsp;&nbsp;`search` | `boolean` | `true` |  |
| &nbsp;&nbsp;`clicks` | `'ping' \| 'redirect' \| 'off'` | `'off'` |  |
| &nbsp;&nbsp;`relatedLinks` | `boolean` | `true` |  |
| &nbsp;&nbsp;`pseo` | `boolean` | `true` |  |
| &nbsp;&nbsp;`honeypot` | `boolean` | `true` |  |

## `providers`

Which external service each capability talks to. Every one of these degrades to an inert default rather than failing the build when its secret is missing.

| Field | Type | Default | Rules |
| --- | --- | --- | --- |
| `providers` | `object` | `see rows below` |  |
| &nbsp;&nbsp;`email` | `'resend' \| 'cloudflare' \| 'console'` | `'console'` |  |
| &nbsp;&nbsp;`emailFrom` | `string` | `''` |  |
| &nbsp;&nbsp;`adminNotify` | `'same' \| 'cloudflare-verified' \| 'off'` | `'same'` |  |
| &nbsp;&nbsp;`payment` | `'stripe' \| 'none'` | `'none'` |  |
| &nbsp;&nbsp;`rebuild` | `'deploy-hook' \| 'github-dispatch' \| 'none'` | `'none'` |  |
| &nbsp;&nbsp;`rebuildAuto` | `boolean` | `true` |  |

## `media`

The R2 custom domain images are served from. Inert in files mode, where no logo is served from it.

| Field | Type | Default | Rules |
| --- | --- | --- | --- |
| `media` | `object` | `{"baseUrl":"https://media.example.com"}` |  |
| &nbsp;&nbsp;`baseUrl` | `url` | `'https://media.example.com'` | must be an https:// URL; must not end with "/" |

## `admin`

The review queue: who gets notified, and how long a session lasts.

| Field | Type | Default | Rules |
| --- | --- | --- | --- |
| `admin` | `object` | `{}` |  |
| &nbsp;&nbsp;`email` | `email` | — |  |
| &nbsp;&nbsp;`sessionHours` | `integer` | `12` | ≥ 1; ≤ 720 |

## `queue`

The free review queue: what a submitter is told to expect before the site has approval history, and when the operator's daily reminder goes out.

| Field | Type | Default | Rules |
| --- | --- | --- | --- |
| `queue` | `object` | `{}` |  |
| &nbsp;&nbsp;`estimateDays` | `integer` | `21` | ≥ 1; ≤ 365 |
| &nbsp;&nbsp;`digestHourUtc` | `integer` | `9` | ≥ 0; ≤ 23 |

## `limits`

Submission-side quotas and size caps.

| Field | Type | Default | Rules |
| --- | --- | --- | --- |
| `limits` | `object` | `see rows below` |  |
| &nbsp;&nbsp;`submissionsPerEmailPerDay` | `integer` | `3` | ≥ 1 |
| &nbsp;&nbsp;`logoMaxBytes` | `integer` | `524288` | > 0 |
| &nbsp;&nbsp;`screenshotMaxBytes` | `integer` | `2097152` | > 0 |
| &nbsp;&nbsp;`descriptionMaxChars` | `integer` | `2000` | ≥ 80 |
| &nbsp;&nbsp;`unpaidTtlDays` | `integer` | `7` | ≥ 1 |

## `newsletter`

The footer capture block.

| Field | Type | Default | Rules |
| --- | --- | --- | --- |
| `newsletter` | `object` | `see rows below` |  |
| &nbsp;&nbsp;`embedHtml` | `string` | — |  |
| &nbsp;&nbsp;`heading` | `string` | `'Stay in the loop'` |  |
| &nbsp;&nbsp;`blurb` | `string` | `'New {nouns} and updates, once a week. No spam.'` |  |

## `ads`

Four ad placements — a banner under the header, a sponsor wall on the home page, a card inside listing grids, a corner card — each one boolean, plus the `/advertise/` page that prices them.

| Field | Type | Default | Rules |
| --- | --- | --- | --- |
| `ads` | `object` | `{}` |  |
| &nbsp;&nbsp;`contactEmail` | `email` | — |  |
| &nbsp;&nbsp;`page` | `object` | `{}` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`page.enabled` | `boolean` | `false` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`page.intro` | `string` | `'Put your product in front of the people who come here to compare. Four placements, sold directly, no ad network in between.'` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`page.note` | `string` | — |  |
| &nbsp;&nbsp;`topBanner` | `object` | `{}` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`topBanner.enabled` | `boolean` | `false` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`topBanner.label` | `string` | `'Ad'` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`topBanner.text` | `string` | `'Sponsor this directory — your product in front of every reader.'` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`topBanner.url` | `string` | `'/advertise/'` | must be an https:// URL or a /-rooted path |
| &nbsp;&nbsp;&nbsp;&nbsp;`topBanner.linkLabel` | `string` | `'Learn more'` |  |
| &nbsp;&nbsp;`sponsors` | `object` | `{}` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`sponsors.enabled` | `boolean` | `false` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`sponsors.heading` | `string` | `'Supported by these partners'` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`sponsors.items` | `object[]` | `[]` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`sponsors.items[].name` | `string` | **required** |  |
| &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`sponsors.items[].url` | `string` | **required** | must be an https:// URL or a /-rooted path |
| &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`sponsors.items[].logo` | `string` | — | must be an https:// URL or a /-rooted path |
| &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;`sponsors.items[].tagline` | `string` | — |  |
| &nbsp;&nbsp;`inlineCard` | `object` | `{}` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`inlineCard.enabled` | `boolean` | `false` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`inlineCard.every` | `integer` | `6` | ≥ 2; ≤ 50 |
| &nbsp;&nbsp;&nbsp;&nbsp;`inlineCard.name` | `string` | `'Your product here'` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`inlineCard.tagline` | `string` | `'A card inside every listing grid.'` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`inlineCard.url` | `string` | `'/advertise/'` | must be an https:// URL or a /-rooted path |
| &nbsp;&nbsp;&nbsp;&nbsp;`inlineCard.logo` | `string` | — | must be an https:// URL or a /-rooted path |
| &nbsp;&nbsp;`corner` | `object` | `{}` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`corner.enabled` | `boolean` | `false` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`corner.name` | `string` | `'Your product here'` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`corner.tagline` | `string` | `'A small card on every page, dismissable.'` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`corner.url` | `string` | `'/advertise/'` | must be an https:// URL or a /-rooted path |
| &nbsp;&nbsp;&nbsp;&nbsp;`corner.logo` | `string` | — | must be an https:// URL or a /-rooted path |
| &nbsp;&nbsp;`rates` | `object[]` | `see rows below` |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`rates[].slot` | `'topBanner' \| 'sponsors' \| 'inlineCard' \| 'corner'` | **required** |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`rates[].price` | `string` | **required** |  |
| &nbsp;&nbsp;&nbsp;&nbsp;`rates[].blurb` | `string` | **required** |  |

## `legal`

Footer links. Both pages are built and in the sitemap regardless; these fields only control the links.

| Field | Type | Default | Rules |
| --- | --- | --- | --- |
| `legal` | `object` | `{}` |  |
| &nbsp;&nbsp;`termsUrl` | `string` | — |  |
| &nbsp;&nbsp;`privacyUrl` | `string` | — |  |

## `budget`

Which Cloudflare plan ceiling the build checks the static-file count against. It does not change your billing.

| Field | Type | Default | Rules |
| --- | --- | --- | --- |
| `budget` | `object` | `{"plan":"free"}` |  |
| &nbsp;&nbsp;`plan` | `'free' \| 'paid'` | `'free'` |  |

## Registries

Exported by `src/config/schema.ts` and enforced by it. Generated from the same module as the tables above.

### Reserved path segments (26)

No `routes.*Base` may be one of these, and the three bases must differ from each other.

`sitemap` · `page` · `search` · `submit` · `admin` · `api` · `out` · `og` · `blog` · `advertise` · `login` · `account` · `auth` · `media` · `logos` · `pagefind` · `rss.xml` · `robots.txt` · `sitemap-index.xml` · `404` · `_astro` · `_actions` · `_headers` · `_redirects` · `_worker.js` · `cdn-cgi`

### SEO template placeholders (10)

Allowed in every `seo.templates` string: `{title}` · `{siteName}` · `{name}` · `{tagline}` · `{category}` · `{tag}` · `{count}` · `{noun}` · `{nouns}` · `{year}`. Anything else is a config error, named by field path.

### pSEO template placeholders (12)

The SEO set plus `{pricing}` · `{topNames}`, allowed in `pseo.templates[].{title,description,h1,intro}`.

### pSEO patterns (3)

`pseo.templates[].id` must be one of `best-category-for-tag` · `name-alternatives` · `free-category`, and each may appear once.

### Theme presets (3)

| Preset | Description |
| --- | --- |
| `default` | Blue brand, 0.75rem radius. CSS: oklch(0.55 0.2 260). |
| `warm` | Orange brand, 1rem radius. CSS: oklch(0.555 0.17 40). |
| `mono` | Black brand, no radius. |

### Theme tokens (15)

Keys of `theme.tokens` and `theme.darkTokens`: `primary` · `primaryFg` · `bg` · `fg` · `muted` · `mutedFg` · `surface` · `line` · `lineStrong` · `accent` · `featured` · `radius` · `radiusSm` · `font` · `fontDisplay`.

### Built-in listing columns (22)

A `listing.customFields[].key` may not shadow one of these: `id` · `slug` · `name` · `url` · `tagline` · `description` · `category` · `tags` · `pricingType` · `tier` · `logo` · `screenshots` · `status` · `email` · `popularity` · `extra` · `approvedAt` · `featuredUntil` · `createdAt` · `updatedAt` · `submittedAt` · `paidAt`.
