Skip to content

Docs search: index page content, not just titles - #990

Merged
blove merged 19 commits into
mainfrom
blove/docs-search-content-index
Sep 3, 2026
Merged

Docs search: index page content, not just titles#990
blove merged 19 commits into
mainfrom
blove/docs-search-content-index

Conversation

@blove

@blove blove commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Docs search matched titles, not documentation. DocsSearch's searchableText() concatenated title, description, slug, section and libraryTitle — all from docs-config.ts. No page body was ever read, so searching checkpointer, toAgent, or an error string pasted from a stack trace returned "No results found" unless the term happened to sit in a page title.

This makes body prose searchable at heading granularity, with anchor deep links and highlighted snippets.

  • Pure indexer (lib/docs-search-index.ts) turns MDX into per-heading section records: frontmatter and fenced code stripped, inline code unwrapped, markdown link targets dropped (indexing them made github match every page linking to GitHub), component prose kept including title/caption attributes.
  • Anchors come from extract-headings.ts and nowhere else, so a search deep link always matches what the TOC produces. An anchor-parity test asserts this across all 121 real docs.
  • Ranking (lib/docs-search-query.ts): AND matching, weighted title 3 / heading 2 / prose 1, ties to the shorter section, capped at 8. Snippets return text plus [start,end) offsets, never HTML — the client wraps the ranges itself.
  • Route (api/docs-search): index built once per instance (measured 34ms for 121 docs / 983 sections), Cache-Control: public, max-age=300, queries under 2 characters return empty without scanning, one malformed document is skipped rather than poisoning the instance.
  • Client: the existing title matcher still renders instantly and unchanged; server hits merge beneath it. A slow, failed or offline request degrades to exactly today's behaviour rather than an empty box.

Sequencing

Independent of #986 — this branch never touches the files that PR changes — but #986 is what makes search the primary nav affordance (leading the control-plane pane and closing every content page), which is what motivates indexing content at all. Landing #986 first reads better.

Test Plan

  • nx test website green; nx lint website 0 errors; nx build website succeeds with /api/docs-search registering as a dynamic ƒ route
  • nx e2e website119/119, including a new test that searches a term appearing only in body prose and asserts the result lands on an anchor that exists on the destination page, plus a negative control (a nonsense term returns the empty state) so a matcher returning everything can't make the positive test pass by accident
  • Anchor parity across 121 docs / 983 sections, 0 mismatches
  • 85 snippet mark offsets verified against the real corpus, 0 mismatches, no overlapping ranges
  • Verified in a live browser: request returns 200 with the cache header, 8 hits all under "In page content" (the title matcher finds nothing for checkpointer — that's the point), arrow keys traverse both groups, Enter lands on #whats-next and the anchor exists

Notes for review

  • Fenced code is deliberately not indexed. It was roughly half the corpus and mostly ranking noise. The cost is explicit: an error string appearing only inside a code sample will not match. Inline code is kept, since that's where API names appear in sentences.
  • Keyboard navigation is one continuous list across the title/content divider. The first draft added content hits as extra role="option" elements while nav stayed bound to the title array — which would have left half the listbox mouse-only.
  • The tag scanner is hand-written, not a regex. An attribute-aware regex for MDX tags backtracks catastrophically on an unterminated tag; since the indexer runs inside a request handler, that was a hang risk. The linear quote-aware scanner does 20,000 attributes in 61ms.
  • outputFileTracingIncludes gains content/docs/**/*.mdx. Worth knowing: @vercel/nft already traces those files by statically resolving the fs reads in lib/docs.ts (122 paths in the route trace with and without the include), so this is belt-and-braces rather than load-bearing. Also next start never consumes .nft.json, so it cannot be used to verify tracing — only Vercel's builder or an output: 'standalone' build can.

🤖 Generated with Claude Code

blove and others added 18 commits September 3, 2026 11:07
Search matches titles, not documentation -- searchableText() only reads
title/description/slug/section/library out of docs-config, never a page
body. That mattered less when search was a power-user shortcut; #986
made it the front door in two places.

Adds a dynamic route that indexes prose at heading granularity and
returns anchor deep links with highlighted snippets, while keeping the
existing client-side title matcher as the instant layer so the fast
path stays fast and a failed request degrades to today's behaviour.

Records two traps found while exploring: outputFileTracingIncludes does
not cover content/docs (api/markdown only works because it is statically
generated), and extract-headings hand-rolls slugification, so the index
must reuse it rather than introduce a second slugger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eight tasks, 41 TDD steps. Ordered so the shared tokenizer lands before
the route and query module that import it.

Two refinements from the spec, both recorded in the plan: the wire types
get a dependency-free module (a client component needs DocsSearchHit,
whose natural home transitively imports fs), and DocSection drops the
per-page fields the spec repeated on every record.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The route needs identical tokenisation, and two copies would drift the
first time a stop word is added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tokenizer is shared so the client matcher and the server route agree
on what a query means. Adding or removing a stop word changes every
query on both sides identically, so no behavioural test elsewhere would
notice. This is the only thing that catches it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verified against real content: 16 occurrences in the persistence guide,
7 outside fenced code so it survives the indexer's code stripping, and
it heads two sections so a deep link has somewhere to land. Critically
it is in no page title, which is why today's title-only search cannot
find it -- the test proves the feature rather than passing trivially.

So a failing checkpointer assertion means the pipeline is broken, not
that the term was a bad guess. Do not swap in an easier one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pure MDX-to-sections indexer: frontmatter and fenced code stripped,
inline code unwrapped, component prose kept. Anchors come from
extract-headings so search deep links cannot drift from the TOC, which
an anchor-parity test pins across all real content.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The plan tells implementers to cd into apps/website for targeted vitest
runs, which makes that spec double-join its WEBSITE_ROOT path and report
3 failures. Verified pre-existing: the file matches origin/main and
nx test website from the repo root is green. Flagged so nobody chases it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Link targets were searchable, so a query for 'github' or 'docs' matched
every page that happened to link somewhere, and snippets would have
rendered raw [text](url). Keeps link text, unwraps emphasis, and leaves
underscores inside identifiers like TEXT_MESSAGE_CONTENT alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Weighted AND matching over title, heading and prose, capped at eight to
match the existing result list. Snippets return offsets rather than
HTML so the client renders the marks itself; the window snaps to word
boundaries and overlapping marks are merged rather than emitted raw.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tag regex scanned to the first > even inside a quoted value, so
title="a > b" leaked the tail of the tag into indexed text. No current
doc trips it; the next one with an arrow in a caption would have, with
nothing to catch it. Also drops the DocSection re-export, which offered
a second import path through the module that reaches fs -- the exact
thing the dependency-free types module exists to prevent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each field was re-lowercased once per query token per section, so a
multi-token query allocated the same strings hundreds of times per
request. Behaviour is unchanged; a mixed-case test now guards against a
half-converted refactor making one field case-sensitive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Builds the section index once per instance and answers queries from it.
Short queries return empty without scanning, and responses are
cacheable because the corpus only changes on deploy. A per-document
try/catch guards index construction so one malformed doc cannot take
down search for the whole instance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The route reads MDX at request time and cannot be statically generated
the way api/markdown is, so without this it deploys with no corpus and
returns empty for every query -- silently, and only in production.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Executing it disproved two things I asserted. @vercel/nft already
traces content/docs by statically resolving the fs reads in lib/docs.ts
-- 122 mdx paths in the route trace both before and after the include,
so the include is belt-and-braces rather than the load-bearing fix.

More seriously, the verification I wrote does not verify anything:
next start never consumes .nft.json (only Vercel's builder or an
output:'standalone' build do), and nx build emits to dist/apps/website
rather than the path the recipe used. It would have passed whether or
not tracing worked, which is worse than no check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Task 7 used Meta+k and an unnamed combobox. workspace-shell.spec.ts
clicks the "Search docs" button and addresses the combobox by its
accessible name "Search documentation..." -- more portable across
runners, and the button is the affordance a real user has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Debounced, abortable requests merge server hits beneath the instant
title matches, each showing its section heading and a snippet with the
match highlighted. A failed request falls back to the instant results
rather than surfacing an error.

Title matches and content hits now share one continuous keyboard-
navigable list instead of two disjoint ones: arrow keys and Enter
operate over a combined array so every rendered option is reachable
and selectable, aria-activedescendant and aria-selected stay in sync
with it, and the selected index is clamped whenever the combined list
shrinks (a narrower query, or a slow response landing after arrow
navigation) so it can never dangle past the end. A response for a
query that is no longer current is dropped even when the underlying
fetch ignores the abort signal, so a slow, stale response can never
clobber a newer query's results.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Searches "checkpointer" — a term that exists only in body prose in the
persistence guide and appears in no page title — and asserts the hit
comes from the content-search group (not the instant title matcher)
and lands on a real section anchor, not just a well-formed fragment.

Adds a negative-control test searching a term confirmed absent from
all docs content, asserting the empty state, so a matcher that always
returns something can't make the positive test pass by accident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The indexer re-implemented extract-headings' fence tracking and heading
regex, then paired the two scans by position. Copy-identical today, but a
change to either would silently misassign anchors -- and the parity test
could not catch it, since the anchors would all still be valid members of
the same file's set. Only the assignment would be wrong, so a deep link
would scroll to the wrong section and look like it worked.

extractHeadings now reports each heading's source line, and indexDocSections
slices the body between those lines instead of re-scanning for fences and
`##`/`###`. There is exactly one place left that decides what a heading is.

Also: clarify the section/sections field collision in IndexedDoc, and strip
un-fenced import lines in toSearchableText so a doc that imports a component
for real JSX use can't leak the import statement into a search snippet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
threadplane Ready Ready Preview Sep 3, 2026 8:20pm UTC

Request Review

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated approval: this PR received an intelligent (AI) code review. See the review comments on this PR.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 0s —— View job


I'll analyze this and get back to you.

@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Claude finished @blove's task in 0s —— View job


I'll analyze this and get back to you.

@blove
blove merged commit 81eea6c into main Sep 3, 2026
31 checks passed
@blove
blove deleted the blove/docs-search-content-index branch September 3, 2026 22:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant