Skip to content

Listings and datasets

Markdown

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.

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

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:

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:

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:

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

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

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 says where a logo file goes and what renders without one.

  1. Dry-run the file.

    Terminal
    pnpm listings:import my-data.csv --dry-run

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

    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.

    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:

    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.

    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.

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

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' },
],
},
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; type is text, url, select or textarea, and a select needs options. pnpm check:config reports all of it by field path.

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.

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:

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).

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

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.

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.

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
Field What it changes Reference
routes.listingBase the segment before the slug in every listing URL routes
listing.perPage, defaultSort, relatedCount, maxTags, screenshotMax, outboundRel, customFields paging and sort, what the detail page shows, which extra keys render listing
categories the only values category may take categories
tags the values tags may take tags
pricingTypes, tiers the values pricingType and tier should take pricingTypes, tiers
  • Directorysrc/
    • Directorycontent/
      • Directorylistings/ 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()
    • Directoryloaders/
      • files.ts the glob, and ZERODIRS_DATASET
      • normalize.ts the category check, the tag filter, isFeatured
  • Directoryscripts/
    • import.ts pnpm listings:import
    • content.ts pnpm content:reset, content:use, content:check
  • Directoryseed/
    • Directorydatasets/ the sample datasets