# Search

> Pagefind search built at pnpm build — what is indexed, the filters and sorts the URL carries, the one island, and what changes when it is off.

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

---

Search is [Pagefind](https://pagefind.app): an index built once, at `pnpm build`, from the
rendered listing pages, served as static files and queried in the browser. `features.search`
turns it on and is `true` by default. `/search/` is the only page on the site that runs client
JavaScript; everywhere else the search box is a plain form that lands there.

> **Free edition**
>
> Search ships in the free edition unchanged: the same integration, the same island, the same page.

## Setup

1. Build.

   ```sh title="Terminal"
   pnpm build
   ```

   It prints one line; `N` is the number of listing detail pages:

   ```text
   [zerodirs:pagefind] indexed N pages → pagefind/
   ```

   The `zero-js:` line further down still ends `— ok`.

2. Serve the build.

   ```sh title="Terminal"
   pnpm preview
   ```

   Open `http://localhost:4321/search/`. The status line under the form reads
   `Type at least 2 characters, or pick a filter, to search tools.`

3. Search with the query in the URL.

   Open `http://localhost:4321/search/?q=writing`. The index loads before you touch the box,
   and the status line counts the results: `<n> tools`, or `Showing 10 of <n> tools` past ten.

4. Filter without a query.

   Open `http://localhost:4321/search/?pricing=free`. Results appear with nothing typed. Change
   the sort to **Most popular**: the address bar gains `&sort=popularity` and the back button
   gains nothing.

## Verify

| | Where | You should see |
| --- | --- | --- |
| The bundle | `ls dist/client/pagefind/` | `pagefind.js`, `pagefind-entry.json` and the `index/`, `fragment/` and `filter/` directories |
| One island, on one page | the build log | `zero-js: <n> content pages, 0 islands, 0 external scripts — ok` |
| A result | `/search/?q=<a listing name>` | that listing first: logo or letter tile, name, pricing and category badges, tagline |
| A shared link | copy the address bar after filtering, open it in a new tab | the query, the filters and the sort come back |
| Only listings | `/search/?q=<a category name>` | listings from that category; never the category page itself |

![The search page on the demo: the query in the box, pricing and category filters, and the result list](../../../assets/screenshots/search.png)

## How it works

**What is indexed.** Only listing detail pages carry `data-pagefind-body`, on the `<main>` that
wraps `src/components/listing/Detail.astro`; category, tag, generated and blog pages are not in
the index. Inside a detail page, "Related"
and "Appears in" are marked `data-pagefind-ignore`, so an excerpt is the listing's own text.
Each indexed page costs one fragment file against the file budget, which is why the index stops
at listings.

**What a result carries.** The logo is `data-pagefind-meta="image[src], image_alt[alt]"`; the
tagline is `data-pagefind-meta="tagline"` with weight 2, and the panel ranks it the same way
with `metaWeights: { tagline: 2 }`. A hidden span on every detail page carries
`data-pagefind-sort="popularity[data-pop], date[data-date]"`; a page without it drops out of
sorted results.

**Filters, sorts and the URL.** The five parameters are read once on mount and written back
with `history.replaceState`, empty values and `sort=relevance` omitted, so a filtered search is
a link you can share and browsing never fills the back button.

| Parameter | Values | Notes |
| --- | --- | --- |
| `q` | text, trimmed, up to 200 characters | under 2 characters the filters search alone |
| `category`, `tag`, `pricing` | a slug from `site.config.ts` | the index stores the visible name; the island maps slug to name before each call, so a renamed category keeps old links working. Anything that is not slug-shaped is dropped |
| `sort` | `relevance` (default), `popularity`, `date` | shown as Relevance, Most popular, Newest. `popularity` is the listing's `popularity`, in D1 mode the 30-day click count |

Category and tag pages link in with `?category=<slug>` and `?tag=<slug>`.

**The island.** `src/islands/SearchPanel.tsx` (Preact) mounts with `client:idle` on `/search/`
and is the only file in `src/islands/`. It imports `/pagefind/pagefind.js` on the first focus of
the box, or on mount when the URL already carries state, so a visitor who never searches
downloads nothing. Every keystroke calls `preload()`; the search itself is debounced at 250 ms
and superseded responses are discarded. The constants are `SEARCH_MIN_QUERY = 2`,
`SEARCH_MAX_RESULTS = 10` and `SEARCH_META_WEIGHTS` in `src/lib/search.ts`, and `DEBOUNCE_MS`
in the island.

**The zero-JS rule.** After the build, `integrations/budget.ts` reads every page in
`dist/client` and fails the build if any page other than `/search/` contains an
`<astro-island>` or a `<script src>`; `/search/` is exempt by its route kind in the manifest. The two inline scripts on every page, the theme bootstrap and the toggle, are
allowed, and `<script type="application/ld+json">` is structured data, not JavaScript.
`/search/` is `noindex` and disallowed in `robots.txt`.

## When it is off or degraded

| Config state | The visitor sees | `/admin/` reports | Fix |
| --- | --- | --- | --- |
| `features.search: false` | no search box in the header, on the home page (**Browse all tools** and **Categories** buttons take its place) or on the 404 page, and no **Search within** button on category and tag pages. `/search/` itself is still built, island included: the page has no feature guard, only its links and its manifest entry go. And because it has left the manifest, the zero-JS check no longer exempts it, so the build stops with `content pages must ship zero JavaScript: 1 of <n> page(s) carry an island or a downloaded script` naming `/search/` | nothing — search has no runtime | leave the flag on until the page is guarded; it costs nothing at request time |
| `pnpm dev` | ``Search is not available yet. Run `pnpm build` once to enable search in dev.`` | nothing | `pnpm build`, then `pnpm preview`. Nothing serves the built index to the dev server |
| The index failed to write | a build **warning**, `indexing failed (…); build continues without pagefind/`, never a failure; the panel shows the same message as in dev | nothing | read the warning; the usual cause is the Pagefind binary for your platform |
| No page carries `data-pagefind-body` | `no page with data-pagefind-body found in …; writing an empty index so /search/ still loads`; every search finds nothing | nothing | the detail template lost its `pagefindBody()` spread |
| The file budget | `budget:` warns above 80% of the plan limit and fails the build above 90% | nothing | each listing costs one fragment on top of its page, plus the index chunks; see [Cost and limits](https://zerodirs.com/docs/run/cost-and-limits/) |

## Configuration

| Field | Default | What it changes |
| --- | --- | --- |
| `features.search` | `true` | the header, home and 404 search forms, the **Search within** buttons, and the `/search/` entry in the routes manifest |

See [`features`](https://zerodirs.com/docs/configure/reference/#features). There is no `search` block: the minimum query
length, the result cap and the tagline weight are constants in `src/lib/search.ts`, and the
debounce is in `src/islands/SearchPanel.tsx`.

## Files

- integrations/pagefind.ts builds the index at `astro:build:done`; warnings, never failures
- src/
  - islands/SearchPanel.tsx the island: lazy import, filters, sort, URL state
  - pages/search.astro the only page with an island; passes the filter options as props
  - lib/search.ts the attribute helpers, the URL parser and the constants
  - components/listing/Detail.astro the indexed body: meta, filters and the sort carrier

## Related

- [Listings and datasets](https://zerodirs.com/docs/configure/data/) — The listing files and datasets the index is built from.
- [Cost and limits](https://zerodirs.com/docs/run/cost-and-limits/) — The file budget: one fragment per listing, plus the index.
- [Click tracking](https://zerodirs.com/docs/grow/clicks/) — Where the popularity sort gets its numbers.
- [Troubleshooting](https://zerodirs.com/docs/run/troubleshooting/) — Search finds nothing under pnpm dev, and the other first-hour surprises.
