Skip to the content

Capsid menu

app/routes/search.tsx

462 lines and 20 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.

Functions

The functions of search.tsx
FunctionLinesComplexityCoveredCRAPMutation
readParams43 to 545No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:58>58 to 1102No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:84>84 to 942No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
loader113 to 1407No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:129>129 to 1311No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
headers142 to 1522No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
meta154 to 1652No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
searchHref174 to 1898No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
ActiveFilterChip192 to 2011No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
FacetSection204 to 2311No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:221>221 to 2272No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
Result241 to 2863No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:268>268 to 2732No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
ZeroState289 to 3293No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:305>305 to 3111No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:319>319 to 3231No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
Pagination332 to 3653No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
SearchPage367 to 50242No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:389>389 to 3891No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:463>463 to 4651No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code

Imports

It imports app/components/ask-panel.tsx, app/components/enhance.tsx, app/components/search-form.tsx, app/components/shell-footer.tsx, app/components/site-header.tsx, app/db/index.ts, app/kb/search-bases.mjs, app/lib/context.ts, app/lib/error-message.mjs, app/lib/negotiate.mjs, app/lib/search/ask.server.ts, app/lib/search/query.mjs, app/lib/search/search.server.ts, app/lib/search/zero-result.server.ts, app/lib/seo.ts. No module of this repo imports it.

Source

import { Link, data } from "react-router";

import { AskMount } from "~/components/ask-panel";
import { Enhance } from "~/components/enhance";
import { SearchForm } from "~/components/search-form";
import { ShellFooter } from "~/components/shell-footer";
import { SiteHeader } from "~/components/site-header";
import { nextScheduledPublishAt } from "~/db";
import { getEnv, getExecutionContext } from "~/lib/context";
import { prefersType } from "~/lib/negotiate.mjs";
import { hasFilters } from "~/lib/search/query.mjs";
import { askAvailable } from "~/lib/search/ask.server";
import { recordZeroResult } from "~/lib/search/zero-result.server";
import { SEARCH_BASES, baseLabel } from "~/kb/search-bases.mjs";
import {
  parseSort,
  search,
  zeroState,
  type MatchReason,
  type SearchHit,
} from "~/lib/search/search.server";
import {
  EDGE_CACHE_HEADER,
  NO_STORE_CACHE_CONTROL,
  HTML_VARY_ACCEPT,
  cacheTags,
  scheduledEdgeCacheControl,
  SHARED_CACHE_CONTROL,
  SITE,
  SITE_ORIGIN,
  pageMeta,
} from "~/lib/seo";
import type { Route } from "./+types/search";

import "~/styles/blog-search.css";
import "~/styles/search-page.css";
import "~/styles/ask.css";
import "~/styles/search-facets.css";
import { errorMessage } from "~/lib/error-message.mjs";

const PAGE_SIZE = 10;

function readParams(url: URL) {
  const page = Number.parseInt(url.searchParams.get("page") ?? "1", 10);
  return {
    q: url.searchParams.get("q") ?? "",
    type: url.searchParams.get("type"),
    base: url.searchParams.get("base"),
    tag: url.searchParams.get("tag"),
    year: url.searchParams.get("year"),
    sort: parseSort(url.searchParams.get("sort")),
    page: Number.isFinite(page) && page > 0 ? page : 1,
  };
}

/** Middleware: a document route's loader cannot return a raw Response. */
export const middleware: Route.MiddlewareFunction[] = [
  async ({ request, context }, next) => {
    if (!prefersType(request, "application/json")) return next();

    const url = new URL(request.url);
    const params = readParams(url);
    const result = await search(getEnv(context), { ...params, pageSize: PAGE_SIZE });

    return new Response(
      JSON.stringify(
        {
          query: result.parsed.raw,
          parsed: {
            terms: result.parsed.terms,
            phrases: result.parsed.phrases,
            tags: result.parsed.tags,
            types: result.parsed.types,
            bases: result.parsed.bases,
            year: result.parsed.year,
          },
          total: result.total,
          page: result.page,
          pageSize: result.pageSize,
          askAvailable: askAvailable(getEnv(context)),
          truncated: result.truncated,
          tookMs: result.tookMs,
          facets: result.facets,
          results: result.hits.map((hit) => ({
            url: `${SITE_ORIGIN}${hit.url}`,
            type: hit.type,
            title: hit.title,
            documentTitle: hit.docTitle,
            anchor: hit.anchor,
            tags: hit.tags,
            publishedAt: hit.publishAt ? new Date(hit.publishAt * 1000).toISOString() : null,
            snippet: hit.snippet,
            matchedOn: hit.why,
          })),
        },
        null,
        2,
      ),
      {
        headers: {
          "content-type": "application/json; charset=utf-8",
          // Never stored; do not "optimize" this back to a shared cache-control. /search varies on Accept and
          // Cookie, and once this JSON variant is stored the edge answers cookie-bearing requests from the
          // cookieless variant, so a `theme=dark` reader gets the light document.
          "cache-control": NO_STORE_CACHE_CONTROL,
          vary: "Accept",
        },
      },
    );
  },
];

export async function loader({ request, context }: Route.LoaderArgs) {
  const url = new URL(request.url);
  const params = readParams(url);
  const env = getEnv(context);

  const [result, nextPublishAt] = await Promise.all([
    search(env, { ...params, pageSize: PAGE_SIZE }),
    nextScheduledPublishAt(env),
  ]);

  const asked = !result.parsed.isEmpty || hasFilters(result.parsed);
  const suggestions = asked && result.total === 0 ? await zeroState(env, result.parsed) : null;

  /* After the response via `waitUntil`, failure logged: demand signal is worth less than the page rendering. */
  if (asked && result.total === 0 && !result.parsed.isEmpty) {
    getExecutionContext(context).waitUntil(
      recordZeroResult(env, params.q).catch((error: unknown) => {
        console.error(JSON.stringify({ alert: "zero-result-record-failed", detail: errorMessage(error) }));
      }),
    );
  }

  // The loader that renders classic results must never wait on the AI layer.
  return data(
    { params, result, suggestions, askAvailable: askAvailable(env) },
    { headers: { [EDGE_CACHE_HEADER]: scheduledEdgeCacheControl(new Date(), nextPublishAt) } },
  );
}

export function headers({ loaderHeaders }: Route.HeadersArgs) {
  const headers = new Headers({
    // `Accept` stays: this URL also serves JSON.
    "Cache-Control": SHARED_CACHE_CONTROL,
    "Cache-Tag": cacheTags(),
    Vary: HTML_VARY_ACCEPT,
  });
  const edge = loaderHeaders.get(EDGE_CACHE_HEADER);
  if (edge) headers.set(EDGE_CACHE_HEADER, edge);
  return headers;
}

export function meta({ loaderData }: Route.MetaArgs) {
  const q = loaderData?.params.q;
  const title = q ? `Search: ${q} | ${SITE.name}` : `Search | ${SITE.name}`;
  return [
    ...pageMeta({
      title,
      description: `Search the writing and pages on ${SITE.name}'s site.`,
      path: "/search",
    }),
    { name: "robots", content: "noindex, follow" },
  ];
}

type SearchParams = ReturnType<typeof readParams>;
type FacetKey = "type" | "base" | "tag" | "year";

/**
 * The current search with some parts changed. A change of filter or sort drops the page, since
 * the old page number means nothing in the new list; only a pager link passes one.
 */
function searchHref(
  params: SearchParams,
  change: Partial<Pick<SearchParams, FacetKey | "sort" | "page">> = {},
) {
  const merged = { ...params, page: 1, ...change };
  const next = new URLSearchParams();
  if (merged.q) next.set("q", merged.q);
  if (merged.type) next.set("type", merged.type);
  if (merged.base) next.set("base", merged.base);
  if (merged.tag) next.set("tag", merged.tag);
  if (merged.year) next.set("year", merged.year);
  // Relevance is the absent value: two spellings of the default would be two cache entries of one page.
  if (merged.sort === "date") next.set("sort", "date");
  if (merged.page > 1) next.set("page", String(merged.page));
  return `/search?${next.toString()}`;
}

/** A filter in force, as a chip whose link is the same search without it. */
function ActiveFilterChip({ params, facet, label }: { params: SearchParams; facet: FacetKey; label: string }) {
  return (
    <li>
      <Link to={searchHref(params, { [facet]: null })} className="search-chip is-active">
        {label} <span aria-hidden="true">x</span>
        <span className="sr-only">remove filter</span>
      </Link>
    </li>
  );
}

/** One facet's choices, each a link narrowing the search to it, with its count. */
function FacetSection({
  params,
  facet,
  heading,
  values,
  floor,
}: {
  params: SearchParams;
  facet: FacetKey;
  heading: string;
  values: Array<{ value: string | number; label?: string; count: number }>;
  floor: string;
}) {
  return (
    <section>
      <h2>{heading}</h2>
      <ul>
        {values.map((entry) => (
          <li key={entry.value}>
            <Link to={searchHref(params, { [facet]: String(entry.value) })} className="search-chip">
              {entry.label ?? entry.value} <span className="search-chip-count">{entry.count}{floor}</span>
            </Link>
          </li>
        ))}
      </ul>
    </section>
  );
}

/** Keyed by the union with no fallback: a reason without a label is a typecheck failure, not a raw value shown to readers. */
const WHY_LABEL: Record<MatchReason, string> = {
  title: "title",
  tag: "tag",
  body: "body",
  filter: "filter",
};

function Result({ hit }: { hit: SearchHit }) {
  return (
    <li className="search-result">
      <div className="search-result-head">
        <span className="search-type" data-type={hit.type}>
          {hit.type}
        </span>
        <h2 className="search-result-title">
          <Link to={hit.url}>{hit.title}</Link>
        </h2>
      </div>

      {hit.anchor ? (
        <p className="search-result-context">
          in <Link to={hit.docUrl}>{hit.docTitle}</Link>
        </p>
      ) : null}

      {/* Safe as HTML: the snippet was escaped before its <mark> markers were
          substituted in. See renderSnippet in search.server.ts. */}
      <p
        className="search-snippet"
        dangerouslySetInnerHTML={{ __html: hit.snippet }}
      />

      <p className="search-why">
        matched on{" "}
        {hit.why.map((reason, i) => (
          <span key={reason}>
            {i > 0 ? ", " : ""}
            <span className="search-why-field">{WHY_LABEL[reason]}</span>
          </span>
        ))}
        {hit.publishAt ? (
          <>
            {" "}
            <span className="search-why-sep">/</span>{" "}
            <time dateTime={new Date(hit.publishAt * 1000).toISOString()}>
              {new Date(hit.publishAt * 1000).getUTCFullYear()}
            </time>
          </>
        ) : null}
      </p>
    </li>
  );
}

/** A search that matched nothing: said plainly, with nearby tags and recent writing to go to instead. */
function ZeroState({
  raw,
  suggestions,
}: {
  raw: string;
  suggestions: NonNullable<Route.ComponentProps["loaderData"]["suggestions"]>;
}) {
  return (
    <div className="search-zero">
      <p>
        Nothing matched <strong>{raw}</strong>.
      </p>
      {suggestions.nearestTags.length > 0 ? (
        <section>
          <h2>Related tags</h2>
          <ul className="search-chip-row">
            {suggestions.nearestTags.map((tag) => (
              <li key={tag}>
                <Link to={`/search?tag=${encodeURIComponent(tag)}`} className="search-chip">
                  {tag}
                </Link>
              </li>
            ))}
          </ul>
        </section>
      ) : null}
      {suggestions.recentPosts.length > 0 ? (
        <section>
          <h2>Recent writing</h2>
          <ul className="search-recent">
            {suggestions.recentPosts.map((post) => (
              <li key={post.url}>
                <Link to={post.url}>{post.title}</Link>
              </li>
            ))}
          </ul>
        </section>
      ) : null}
    </div>
  );
}

/** Previous and next around "Page n of m"; an empty span holds each missing side's place. */
function Pagination({
  params,
  page,
  pageCount,
  floor,
}: {
  params: SearchParams;
  page: number;
  pageCount: number;
  floor: string;
}) {
  return (
    <nav className="search-pagination" aria-label="Search results pages">
      {page > 1 ? (
        <Link rel="prev" to={searchHref(params, { page: page - 1 })}>
          Previous
        </Link>
      ) : (
        <span aria-hidden="true" />
      )}
      <span>
        Page {page} of {pageCount}
        {floor}
      </span>
      {page < pageCount ? (
        <Link rel="next" to={searchHref(params, { page: page + 1 })}>
          Next
        </Link>
      ) : (
        <span aria-hidden="true" />
      )}
    </nav>
  );
}

export default function SearchPage({ loaderData }: Route.ComponentProps) {
  const { params, result, suggestions, askAvailable } = loaderData;
  const { facets } = result;
  const pageCount = Math.max(1, Math.ceil(result.total / result.pageSize));
  /* A truncated match set makes every count a floor, so it says so rather than reading as exact. */
  const floor = result.truncated ? "+" : "";
  // `isEmpty` means no matchable text, not no request: a bare year or tag is answered by the browse path.
  const hasQuery = !result.parsed.isEmpty || hasFilters(result.parsed);

  return (
    <>
      <SiteHeader />
      <main className="search-page" id="main" tabIndex={-1}>
        <h1 className="search-heading">Search</h1>

        <SearchForm defaultValue={params.q}>
          {params.type ? <input type="hidden" name="type" value={params.type} /> : null}
          {params.base ? <input type="hidden" name="base" value={params.base} /> : null}
          {params.tag ? <input type="hidden" name="tag" value={params.tag} /> : null}
          {params.year ? <input type="hidden" name="year" value={params.year} /> : null}
          {params.sort === "date" ? <input type="hidden" name="sort" value="date" /> : null}
          <p className="search-hint">
            Operators: <code>tag:</code>, <code>type:</code>, <code>base:</code> ({SEARCH_BASES.map((b) => b.id).join(", ")}), a bare year, and
            &quot;quoted phrases&quot;.
          </p>
        </SearchForm>

        {/* Rendered even empty: a live region must exist before its text changes, or the live
            search's count is never announced. */}
        <p className="search-count" aria-live="polite">
          {hasQuery
            ? `${
                result.total === 0
                  ? "No results"
                  : `${result.total}${result.truncated ? "+" : ""} result${
                      result.total === 1 ? "" : "s"
                    }`
              }${result.parsed.year !== null ? ` from ${result.parsed.year}` : ""}`
            : null}
        </p>

        {params.type || params.base || params.tag || params.year ? (
          <ul className="search-active-filters">
            {params.type ? (
              <ActiveFilterChip params={params} facet="type" label={`type: ${params.type}`} />
            ) : null}
            {params.base ? (
              <ActiveFilterChip params={params} facet="base" label={`in: ${baseLabel(params.base.toLowerCase())}`} />
            ) : null}
            {params.tag ? (
              <ActiveFilterChip params={params} facet="tag" label={`tag: ${params.tag}`} />
            ) : null}
            {params.year ? <ActiveFilterChip params={params} facet="year" label={params.year} /> : null}
          </ul>
        ) : null}

        <Enhance module="search" />

        {askAvailable && (params.q ?? "").trim().length > 0 ? (
          <AskMount question={params.q ?? ""} />
        ) : null}

        {/* Only on the text path: the browse path is date-ordered in SQL, because a filter carries no relevance signal. */}
        {hasQuery && result.total > 1 && !result.parsed.isEmpty ? (
          <nav className="search-sort" aria-label="Sort results">
            <span className="search-sort-label" id="search-sort-label">
              Sort
            </span>
            <ul aria-labelledby="search-sort-label">
              <li>
                <Link
                  to={searchHref(params, { sort: "relevance" })}
                  className="search-chip"
                  aria-current={params.sort === "relevance" ? "true" : undefined}
                >
                  Relevance
                </Link>
              </li>
              <li>
                <Link
                  to={searchHref(params, { sort: "date" })}
                  className="search-chip"
                  aria-current={params.sort === "date" ? "true" : undefined}
                >
                  Newest
                </Link>
              </li>
            </ul>
          </nav>
        ) : null}

        {/* The body is always there, empty without results, so the live search has a place to
            write its results on a page that opened with none. */}
        <div className="search-body">
          {hasQuery && result.total > 0 ? (
            <ol className="search-results">
              {result.hits.map((hit) => (
                <Result key={hit.uid} hit={hit} />
              ))}
            </ol>
          ) : null}

          {hasQuery && result.total > 0 ? (
            <aside className="search-facets" aria-label="Filter results">
              {facets.types.length > 1 ? (
                <FacetSection params={params} facet="type" heading="Type" values={facets.types} floor={floor} />
              ) : null}

              {/* One search across the knowledge bases (docs/KNOWLEDGE-BASE.md, A2); each library keeps its own facets. */}
              {facets.bases.length > 1 ? (
                <FacetSection params={params} facet="base" heading="Knowledge base" values={facets.bases} floor={floor} />
              ) : null}

              {facets.tags.length > 0 ? (
                <FacetSection params={params} facet="tag" heading="Tag" values={facets.tags} floor={floor} />
              ) : null}

              {facets.years.length > 0 ? (
                <FacetSection params={params} facet="year" heading="Year" values={facets.years} floor={floor} />
              ) : null}
            </aside>
          ) : null}
        </div>

        {hasQuery && result.total === 0 && suggestions ? (
          <ZeroState raw={result.parsed.raw} suggestions={suggestions} />
        ) : null}

        {pageCount > 1 ? (
          <Pagination params={params} page={result.page} pageCount={pageCount} floor={floor} />
        ) : null}
      </main>
      <ShellFooter />
    </>
  );
}