# Listings and datasets

> One Markdown file per listing: the frontmatter contract, what fails the build, the importer, custom fields, the sample datasets, and the way to D1.

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

---

A listing is one Markdown file under `src/content/listings/`; the file name is its slug, so
`acme.md` is `/<routes.listingBase>/acme/`. The frontmatter is the shape a database row takes
later, so nothing written here is lost on the move to D1. Files mode is where every copy starts
and the only mode the free edition has.

## The Markdown contract

| Field | Required | Default | Rule |
| --- | --- | --- | --- |
| `name` | yes | — | 1 to 80 characters |
| `url` | yes | — | an absolute URL |
| `tagline` | yes | — | 1 to 160 characters |
| `category` | yes | — | a `categories[].slug`; anything else fails the build |
| `approvedAt` | yes | — | a date, `2026-01-05`; the newest sort |
| `logo` | no | `null` | `https://…` or a `/`-rooted path; a relative path fails the build |
| `screenshots` | no | `[]` | URLs or `/`-rooted paths |
| `tags` | no | `[]` | `tags[].slug` values; unknown ones are dropped with a warning |
| `pricingType` | no | `free` | a `pricingTypes[].slug`; unchecked in files mode |
| `tier` | no | `free` | a `tiers[].id`; unchecked in files mode |
| `featuredUntil` | no | `null` | a date; featured until then when the tier's `kind` is `featured` |
| `popularity` | no | `0` | a whole number; the popular sort |
| `createdAt` | no | — | a date |
| `extra` | no | `{}` | custom values — [Custom fields](#custom-fields) |

`slug` is the file name; `id`, `listingId` and `isFeatured` are computed. The body is the
description on the detail page. The demo's own file:

```md title="src/content/listings/jasper.md"
---
name: Jasper
url: https://www.jasper.ai
tagline: AI marketing platform for on-brand content at scale
logo: /logos/jasper.svg
category: writing
tags: [copywriting, marketing, seo, chrome-extension]
pricingType: paid
tier: featured
featuredUntil: 2028-06-30
approvedAt: 2026-01-05
popularity: 1820
extra: { founded: 2021 }
---
Jasper is a marketing-focused AI writing platform. It learns your brand voice from existing
material, keeps a shared knowledge base for the team, and generates blog posts, ad copy, email
sequences, and landing pages from templates or free-form prompts.
```

An unknown `category` fails the build, naming the file and the slugs it accepts:

```text
listings › my-tool: unknown category "marketing" (slug "my-tool"); categories in site.config.ts: writing, chatbots, image-generation, coding
```

A relative `logo` fails it too, under the file's name:

```text
Invalid string: must match pattern /^(https:\/\/|\/)/
```

An unknown tag only warns; the tag vanishes from that listing:

```text
listings › my-tool: unknown tag(s) "foo" filtered (slug "my-tool"; not in site.config.ts tags)
```

`pricingType` and `tier` are unchecked in files mode; only the importer rejects them.
[Logos and media](https://zerodirs.com/docs/configure/logos-and-media/) says where a logo file goes and what renders
without one.

## Import a spreadsheet

1. Dry-run the file.

   ```sh title="Terminal"
   pnpm listings:import my-data.csv --dry-run
   ```

   CSV with a header row, or a JSON array. One verdict per row, nothing written:

   ```text
   listings:import — my-data.csv (csv, 3 rows) → src/content/listings (dry run)
     ok     row 1  acme-writer.md               create    Acme Writer
     ok     row 2  jasper.md                    overwrite Jasper  (1 warning)
     error  row 3 (Zed): unknown category "marketing" (slug "zed"); categories in site.config.ts: writing, coding
     warn   row 2 (Jasper): unknown tag(s) "foo" filtered (slug "jasper"; not in site.config.ts tags)
     warn   ignored column(s) not in the listing schema: Notes (use --map or an "extra.<key>" header)
   dry run: 2 ok, 1 error; nothing written
   ```

   How columns are read:

   | Column | Rule |
   | --- | --- |
   | headers | matched against aliases after lower-casing: `Title` is `name`, `Website` is `url`, `Summary` is `tagline`, `Body` is `description` (the Markdown body), `Keywords` are `tags`, `Pricing` is `pricingType`, `Plan` is `tier`, `Date added` is `approvedAt` |
   | `--map name=Product,url=Website` | a header the aliases do not know; wins over them |
   | `extra.founded`, or a header equal to a `listing.customFields[].key` | lands in `extra`; an `extra` column holding JSON is merged too |
   | anything else | ignored, named once in a `warn` line |
   | `name`, `url`, `tagline`, `category` | required: `row 3 (Zed): missing required column "url"` |
   | `slug` | optional; otherwise the slugified `name`, and a duplicate name gets `-2` |
   | `tags`, dates | split on commas, semicolons, pipes or line breaks; any date format becomes `YYYY-MM-DD`, `approvedAt` defaulting to today |
   | `pricingType`, `tier`, `logo` | the contract's rules, but here a bad value is an error |

2. Run it.

   ```sh title="Terminal"
   pnpm listings:import my-data.csv
   ```

   All-or-nothing: one bad row and it prints `1 row cannot be imported; nothing was written:`
   with the rows. When every row passes:

   ```text
   wrote 3 files to src/content/listings (2 created, 1 overwritten)
   ```

   A corrected export overwrites the same files. `--out <dir>` writes elsewhere first;
   `--to d1 --remote` upserts into D1 instead.

3. Check and build.

   ```sh title="Terminal"
   pnpm check:config && pnpm build
   ```

   `listings: N (src/content/listings)` from the first; the `[zerodirs-files]` line with the new
   count from the second.

## Custom fields

Anything in `extra` is carried along; a key declared in `listing.customFields` renders in the
facts card of the detail page, on the card with `showOnCard: true`, and on the submit form,
where `required` applies.

![A listing page on the demo, with the custom fields in the facts card beside the description](../../../assets/screenshots/listing-detail.png)

```ts title="site.config.ts"
listing: {
  customFields: [
    { key: 'founded', label: 'Founded', type: 'text', showOnCard: true },
    { key: 'platforms', label: 'Platforms', type: 'select', options: ['Web', 'macOS', 'iOS'] },
    { key: 'docsUrl', label: 'Documentation', type: 'url' },
  ],
},
```

```md title="src/content/listings/acme.md"
extra:
  founded: 2021
  platforms: Web
  docsUrl: https://docs.acme.com
```

A `key` must match `^[a-z][a-zA-Z0-9]*$` and may not shadow a
[built-in listing column](https://zerodirs.com/docs/configure/reference/#registries); `type` is `text`, `url`, `select` or
`textarea`, and a `select` needs `options`. `pnpm check:config` reports all of it by field path.

> **Objects and arrays vanish**
>
> `customFieldValue()` in `src/lib/listing.ts` renders strings, numbers and booleans and returns
> null for anything else, and both renderers drop nulls without a warning: `founded: 2021` shows, a
> nested `pricing` map never appears. Keep structured values under their own `extra` key.

## Datasets

`seed/datasets/` holds the sample datasets — `ai-tools` (the demo, real products), `indie-tools`
and `local-businesses` (invented, `example.com` URLs) — each a directory with `listings/`,
`blog/`, `logos/`, `categories.json`, `tags.json` and `meta.json`.

```sh title="Terminal"
pnpm content:check                        # is src/content still the loaded dataset?
pnpm content:use local-businesses --yes   # load one
pnpm content:reset --yes                  # clear it; one example listing per category
```

`content:use` replaces the listings, the posts and `public/logos/` with the dataset, then prints
the `categories` and `tags` blocks to paste — it never edits `site.config.ts`:

```text
use: N files -> src/content/listings
use: N files -> src/content/blog
use: N files -> public/logos
…
use: snippet written to seed/.out/local-businesses-config.txt
next: paste the two blocks into site.config.ts, then pnpm check:config
```

The snippet also lists what `meta.json` suggests — `site.kind`, `routes.listingBase`,
`routes.listingNoun`, `seo.jsonLd.listing` — and the CLI writes those into `site.config.ts` when
it scaffolds a project, which is why `local-businesses` comes out with `/places/` URLs
([Quickstart](https://zerodirs.com/docs/start/quickstart/)).

`content:check` ends with `src/content and public/logos match the ai-tools dataset.`, or lists
the drift and exits 1. To preview a dataset without touching `src/content/`:

```sh title="Terminal"
ZERODIRS_DATASET=indie-tools pnpm build
```

Only the listings glob moves — the log says `listings: N from ./seed/datasets/indie-tools/listings`
— while categories and tags still come from `site.config.ts`, so an undeclared category still
fails. Files mode only.

`content:reset --yes` deletes every listing, every post except the starter's draft fixture, and
`public/logos/`, then writes one example listing per category from your own config, plus
`hello-world.md` and a pSEO example. Without `--yes` it refuses and says what it would delete.

> **The demo content is not licensed to you**
>
> The `ai-tools` entries and blog posts describe real products. They show you a full site; they are
> not yours to republish.

## Moving to the database

In D1 mode the build reads approved rows from D1 over the REST API instead of this directory,
and `/submit/` and `/admin/` write those rows. You need it the day an approval should appear on
the site without a commit. The move is a seed and four build variables: `pnpm db:seed:remote`
copies these files into D1, and `pnpm listings:export` brings a live database back out as the
same Markdown.

- [Listings from D1](https://zerodirs.com/docs/deploy/listings-from-d1/) — The seed, the read token, the four build variables, and the fallback trap.

## Verify

| | Where | You should see |
| --- | --- | --- |
| The config | `pnpm check:config` | `site.config.ts OK — <name> (<url>); …`, then `listings: N (src/content/listings); logos served directly` |
| The build | `pnpm build` | `[zerodirs-files] listings: N from ./src/content/listings (featured N, parsed N, refreshed N)` |
| A listing | `pnpm preview`, then `/<routes.listingBase>/<file name>/` | name, tagline, the logo or the letter tile, the body |

## Configuration

| Field | What it changes | Reference |
| --- | --- | --- |
| `routes.listingBase` | the segment before the slug in every listing URL | [`routes`](https://zerodirs.com/docs/configure/reference/#routes) |
| `listing.perPage`, `defaultSort`, `relatedCount`, `maxTags`, `screenshotMax`, `outboundRel`, `customFields` | paging and sort, what the detail page shows, which `extra` keys render | [`listing`](https://zerodirs.com/docs/configure/reference/#listing) |
| `categories` | the only values `category` may take | [`categories`](https://zerodirs.com/docs/configure/reference/#categories) |
| `tags` | the values `tags` may take | [`tags`](https://zerodirs.com/docs/configure/reference/#tags) |
| `pricingTypes`, `tiers` | the values `pricingType` and `tier` should take | [`pricingTypes`](https://zerodirs.com/docs/configure/reference/#pricingtypes), [`tiers`](https://zerodirs.com/docs/configure/reference/#tiers) |

## Files

- src/
  - content/
    - listings/ one file per listing; the file name is the slug
    - schema.ts `listingSchema`, the shape both modes end in
  - content.config.ts wires the collection to `listingsLoader()`
  - loaders/
    - files.ts the glob, and `ZERODIRS_DATASET`
    - normalize.ts the category check, the tag filter, `isFeatured`
- scripts/
  - import.ts `pnpm listings:import`
  - content.ts `pnpm content:reset`, `content:use`, `content:check`
- seed/
  - datasets/ the sample datasets

## Related

- [Listings from D1](https://zerodirs.com/docs/deploy/listings-from-d1/) — Build from the database once approvals should reach the site.
- [Logos and media](https://zerodirs.com/docs/configure/logos-and-media/) — Where a logo lives in each mode, and what renders without one.
- [Make it yours](https://zerodirs.com/docs/start/make-it-yours/) — The first edits after the quickstart, in order.
- [Import listings](https://zerodirs.com/docs/agents/recipes/import-listings/) — The same importer, as a recipe an agent can run for you.
