Repo

zander.wtf

zander.wtf is the site this package was extracted from. It indexes five content types — blog posts, code notes, projects, worklog entries and standalone pages — into a single D1 database, and exposes them through both a site-wide search page and a section-scoped one.

Try it:

Read the source (mrmartineau/zander.wtf):

File What it shows
search.config.ts The shared options object — sources, URL patterns, recency tuning
astro.config.mjs Wiring the integration with that config
scripts/build-search-index.ts The three-line indexer CLI wrapper
src/utils/search.ts Wrapping searchIndex() once to narrow type to a union
src/pages/search.astro Site-wide server-rendered search page with type filters
src/pages/notes/search.astro The section-scoped variant, 30 lines

zander.wtf develops the package as a workspace package rather than installing it from npm, so its imports read ./packages/astro-d1-search/src/... where yours read astro-d1-search/.... Everything else transfers directly.

What's worth stealing

One config module, three consumers

search.config.ts exports a plain D1SearchOptions object and nothing else. The Astro config, the index script and the search pages all import it, so ranking weights can't drift between the endpoint and the pages:

// search.config.ts
import type { D1SearchOptions } from "astro-d1-search";

const searchConfig: D1SearchOptions = {
  database: "zander-wtf-search",
  binding: "SEARCH_DB",
  site: "zander.wtf",
  sources: [
    { dir: "src/content/blog", type: "blog", url: "/blog/:slug" },
    { dir: "src/content/codenotes", type: "note", url: "/notes/:slug" },
    { dir: "src/content/projects", type: "project", url: "/projects/:slug" },
    // Worklog entries all render on the single /worklog page
    { dir: "src/content/worklog", type: "worklog", url: "/worklog" },
    {
      files: [
        "src/pages/about.mdx",
        "src/pages/colophon.md",
        "src/pages/uses.md",
        "src/pages/feeds.md",
      ],
      type: "page",
      url: "/:slug",
    },
  ],
  recency: {
    boost: 0.35, // a doc published today gets relevance × 1.35
    windowDays: 1095, // falling linearly to ×1 at 3 years old
  },
};

export default searchConfig;

Note the worklog source: every entry renders on one /worklog page, so its url has no :slug. Several documents pointing at the same URL is fine.

A typed wrapper around searchIndex()

type is a plain string in the package because your types are yours. Sites that want a union narrow it once in a helper, then use that everywhere instead of calling resolveConfig() per page:

// src/utils/search.ts
import {
  type SearchResult as CoreSearchResult,
  searchIndex as coreSearchIndex,
  type D1Like,
  resolveConfig,
  type SearchOptions,
} from "astro-d1-search/core";
import searchConfig from "../../search.config";

const config = resolveConfig(searchConfig);

export const SEARCH_TYPES = ["blog", "note", "project", "worklog", "page"] as const;
export type SearchType = (typeof SEARCH_TYPES)[number];
export type SearchResult = Omit<CoreSearchResult, "type"> & { type: SearchType };

/** Site-configured search: the package core bound to this site's ranking. */
export function searchIndex(db: D1Like, options: SearchOptions): Promise<SearchResult[]> {
  return coreSearchIndex(db, options, config) as Promise<SearchResult[]>;
}

The site-wide page reads ?type= alongside ?q=, validates it against SEARCH_TYPES, and renders the filters as plain anchors. No client-side JavaScript is involved in search at all:

---
export const prerender = false;

import { SEARCH_TYPES, type SearchResult, type SearchType, searchIndex } from "~/utils/search";

const query = Astro.url.searchParams.get("q")?.trim() || "";
const typeParam = Astro.url.searchParams.get("type") || "";
const type = SEARCH_TYPES.includes(typeParam as SearchType) ? (typeParam as SearchType) : undefined;

let results: SearchResult[] = [];
let searchError = "";

if (query.length >= 2) {
  try {
    results = await searchIndex(Astro.locals.runtime.env.SEARCH_DB, { query, limit: 25, type });
  } catch (error) {
    console.error("Search error:", error);
    searchError = `An error occurred while searching for "${query}". Please try again.`;
  }
}

const filterUrl = (t?: SearchType) => {
  const params = new URLSearchParams({ q: query });
  if (t) params.set("type", t);
  return `/search?${params.toString()}`;
};
---

Two details worth copying: the try/catch around the query, so a D1 hiccup degrades to a message rather than a 500; and the empty state — when there's no query, the page lists recent posts and notes instead of showing nothing.

Using this package?

Open a pull request adding your site to this page.

Theme

Copy this CSS to your project: