Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/search.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: Docs search

on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: search-${{ github.ref }}
cancel-in-progress: true

jobs:
search:
runs-on: ubuntu-latest
timeout-minutes: 20
env:
NEXT_PUBLIC_POSTHOG_KEY: phc_search_test
NEXT_PUBLIC_POSTHOG_HOST: https://us.i.posthog.com
steps:
- uses: actions/checkout@v5
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.13
- run: bun install --frozen-lockfile
- run: bun run test:search
- run: bun run build
- run: bunx --no-install playwright install --with-deps chromium
- name: Verify production search
run: |
bunx --no-install next start -p 3001 > /tmp/search-server.log 2>&1 &
server_pid=$!
trap 'kill "$server_pid"' EXIT
for attempt in $(seq 1 30); do
if curl -fsS http://localhost:3001/docs/api/search?query=ready > /dev/null; then
break
fi
sleep 1
done
bun run validate-search
SEARCH_ANALYTICS_TEST=1 bun run test:search:browser
- uses: actions/upload-artifact@v4
if: failure()
with:
name: search-failures
path: |
test-results/
/tmp/search-server.log
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,7 @@ content/docs/reference/stack/api-reference/*

# serena (local MCP tooling state)
.serena/
playwright-report/
test-results/
# EQL release manifest extracted by generate-eql-docs.ts (see generate-eql-api-docs.ts)
.eql-manifest.release.json
2 changes: 2 additions & 0 deletions IA.md
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,8 @@ and the old `/compare/*` paths redirect there (`v2-redirects.mjs`).
- [x] Root catch-all routes (`src/app/[...slug]`), llms.mdx mirror, sitemap/llms.txt include v2
- [x] `v2-redirects.mjs` (unconditional) + `validate-redirects` gate in prebuild
- [x] `/quickstart` vanity redirect
- [x] Identifier-aware search, weighted code/heading results, page-type filters,
keyboard navigation and search/zero-result analytics (CIP-3350).
- [ ] OG images for v2 pages (route only covers legacy tree)
- [ ] Correctness CI: snippet type-checking, SQL-vs-EQL-Docker, terminology lint (CIP-3337)
- [ ] llms.txt curation + Cloudflare AI crawl policy + md-degradation check (CIP-3339)
Expand Down
61 changes: 60 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,65 @@ Open [http://localhost:3001/docs](http://localhost:3001/docs) to view the docs.
- `bun run lint` — Run Biome lint check
- `bun run format` — Run Biome format

## Search (CIP-3350)

Search uses Orama 3.1.18 with a custom tokenizer and the current Fumadocs MDX
collection. Standalone code fences are indexed alongside their rendered heading
anchors; navigation cards are excluded. Dots and underscores normalize to the
same namespace separator, identifier suffixes and camelCase parts are searchable,
and prefix matches precede one-character typo fallback. SQL `text_eq` stays
distinct from SDK `TextEq`.

Titles and headings carry more weight than fenced code, followed by prose.
Results show each page once, at its best matching anchor. The first three remain
in relevance order; additional results group by documentation section. The page
type filter uses frontmatter (`reference`, `guide`, `concept`, `tutorial`).
The legacy `/stack` collection was retired in #116 and must remain excluded.

The index is built once per server instance from the deployed MDX exports.
There is no separately persisted search index or crawler refresh. Responses
are not cached and include `X-Search-Revision` (content digest) and
`X-Search-Pages` for deployment verification.

The dialog sends `docs_search` after a query settles for 700 ms (or immediately
on selection), `docs_search_zero_results` for successful empty searches, and
`docs_search_result_selected` on navigation. Properties include `query`,
`page_type`, the displayed `result_count`, and `search_revision`; selections
include `result_url` and position. Repeated query/filter combinations are
deduplicated within one dialog session. Failed and cancelled requests do not
count as content gaps. PostHog must be configured as in `.env.example`.

Validate against a running production build or a PR preview:

```bash
bun run test:search
bun run build
bunx --no-install next start -p 3001
# In another terminal:
bun run validate-search
bunx --no-install playwright install chromium
bun run test:search:browser
# Or use the preview URL, including /docs:
bun run validate-search https://PREVIEW_HOST/docs
SEARCH_BASE_URL=https://PREVIEW_HOST/docs bun run test:search:browser
```

To include analytics assertions locally, build with
`NEXT_PUBLIC_POSTHOG_KEY=phc_search_test` and
`NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com`, then run browser tests with
`SEARCH_ANALYTICS_TEST=1`. Browser tests intercept all PostHog ingest requests
and simulate a normal browser for capture; no test events reach PostHog.
The Docs search CI workflow runs these checks against a full production build.

**Engine decision:** retain Orama. The installed engine supports the tokenizer,
field weights, filters and typo fallback we need, keeps preview indexes tied to
their deployments, and avoids sending the full index to every browser.
[Algolia DocSearch](https://docsearch.algolia.com/docs/who-can-apply/) remains a
fallback if relevance or operational needs outgrow this implementation. Its
application requires an Algolia account and domain-owner verification; no
application is submitted or crawler configured by this change. AI answers remain
out of scope.

## Contributing

We welcome contributions. Open an issue with the `documentation` tag or submit a pull request.
Expand All @@ -28,4 +87,4 @@ See [SECURITY.md](SECURITY.md) for security policies and reporting process.

## License

See [LICENSE](LICENSE.md) for license information.
See [LICENSE](LICENSE.md) for license information.
8 changes: 8 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,14 @@
"check-eql-pin": "tsx scripts/check-eql-pin.ts",
"validate-links": "tsx scripts/validate-links.ts",
"validate-redirects": "tsx scripts/validate-v2-redirects.ts",
"test:search": "tsx --test scripts/search.test.ts",
"test:search:browser": "playwright test",
"validate-search": "tsx scripts/validate-search.ts",
"validate-content": "tsx scripts/validate-content-api.ts",
"validate-mermaid": "tsx scripts/validate-mermaid.ts"
},
"dependencies": {
"@orama/orama": "3.1.18",
"fumadocs-core": "16.6.0",
"fumadocs-mdx": "14.2.7",
"fumadocs-ui": "16.6.0",
Expand All @@ -41,6 +45,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.3.14",
"@playwright/test": "^1.64.1",
"@tailwindcss/postcss": "^4.1.18",
"@types/jsdom": "^28.0.3",
"@types/mdx": "^2.0.13",
Expand Down
14 changes: 14 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
testDir: "./tests",
testMatch: "search.spec.ts",
workers: 1,
timeout: 60_000,
use: {
baseURL: process.env.SEARCH_BASE_URL ?? "http://localhost:3001/docs",
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
});
148 changes: 148 additions & 0 deletions scripts/search.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createDocsSearch, type SearchPage } from "../src/lib/search/engine";
import { groupResults } from "../src/lib/search/shared";
import { tokenize } from "../src/lib/search/tokenizer";

function page(
url: string,
heading: string,
contents: string[],
type: SearchPage["pageType"] = "reference",
): SearchPage {
return {
url,
title: url.split("/").at(-1) ?? url,
pageType: type,
structuredData: {
headings: [{ id: "anchor", content: heading }],
contents: contents.map((content) => ({ heading: "anchor", content })),
},
};
}

test("identifier normalization preserves qualified queries and indexes suffixes", () => {
const indexed = tokenize(
"public.eql_v3_text_eq encryptedSupabaseV3 CS_CLIENT_KEY",
undefined,
"code",
);
for (const query of [
"text_eq",
"eql_v3.text",
"encryptedSupabase",
"supabase",
"CS_CLIENT_KEY",
"client_key",
]) {
const [token] = tokenize(query);
assert(
indexed.some((term) => term.startsWith(token)),
query,
);
}
assert.deepEqual(tokenize("LIKE"), ["like"]);
assert.deepEqual(tokenize("text_eq"), ["text_eq"]);
assert.deepEqual(tokenize("eql_v3.text_eq"), ["eql_v3_text_eq"]);
});

test("headings rank over fences, then prose; distinct pages retain matching anchors", async () => {
const search = await createDocsSearch([
page("/reference/prose", "Overview", ["magic_symbol"]),
page("/reference/code", "Overview", ["```ts\nmagic_symbol()\n```"]),
page("/reference/heading", "magic_symbol", ["Usage"]),
]);
const results = await search.query("magic_symbol");
assert.deepEqual(
results.map((r) => r.url),
[
"/reference/heading#anchor",
"/reference/code#anchor",
"/reference/prose#anchor",
],
);
});

test("partial identifiers, camelCase, typo fallback, facets and zero results", async () => {
const search = await createDocsSearch([
page("/reference/eql/text", "Types", [
"```sql\npublic.eql_v3_text_eq\neql_v3.ord_term_ore(col)\n```",
]),
page(
"/integrations/supabase/auth",
"Supabase Auth",
["```ts\nencryptedSupabase()\n```"],
"guide",
),
page("/stack/legacy", "RetiredOnly", ["old docs"]),
]);
for (const query of ["text_eq", "eql_v3.text", "ord_term", "ord_term_ore"]) {
assert.equal(
(await search.query(query))[0]?.url,
"/reference/eql/text#anchor",
);
}
for (const query of [
"encryptedSupabase",
"encryptedsupabase",
"encryptedSupabse",
"supabase auth",
]) {
assert.equal(
(await search.query(query))[0]?.url,
"/integrations/supabase/auth#anchor",
);
}
assert.equal(
(await search.query("encryptedSupabase", "reference")).length,
0,
);
assert.equal((await search.query("encryptedSupabase", "guide")).length, 1);
for (const query of [
"",
" ",
"!!!",
"zzzzunfindable",
"RetiredOnly",
"supabase zzzzunfindable",
]) {
assert.deepEqual(await search.query(query), [], query);
}
});

test("all query terms must match across fields and a deploy changes the revision", async () => {
const old = page("/integrations/supabase", "Authentication", ["original"]);
const search = await createDocsSearch([old]);
assert.equal((await search.query("supabase auth")).length, 1);
assert.equal((await search.query("supabase zzzmissing")).length, 0);
const updated = await createDocsSearch([
page(old.url, "Authentication", ["newdeploysymbol"]),
]);
assert.notEqual(search.revision, updated.revision);
assert.equal((await search.query("newdeploysymbol")).length, 0);
assert.equal((await updated.query("newdeploysymbol")).length, 1);
});

test("grouping keeps the top three in relevance order", async () => {
const search = await createDocsSearch(
Array.from({ length: 6 }, (_, i) =>
page(`/reference/${i}`, "sharedsymbol", []),
),
);
const results = await search.query("sharedsymbol");
const groups = groupResults(results);
assert.deepEqual(groups[0].items, results.slice(0, 3));
assert.equal(groups.flatMap((group) => group.items).length, results.length);
});

test("code excerpts preserve SQL operators and escape sequences", async () => {
const search = await createDocsSearch([
page("/reference/example", "Example", [
"```sql\nSELECT * FROM users WHERE id = $1;\n```",
]),
]);
assert.match(
(await search.query("SELECT"))[0].excerpt,
/SELECT \* FROM users/,
);
});
Loading
Loading