fix(docs): make search work on the static site (client-side flexsearch)

The search dialog used `type: 'hanzo'`, POSTing to a remote auth-gated API a
public static site can't call — every query silently returned empty. The
`/api/search` route also froze to the force-static empty-query `[]`.

- route: export `staticGET` (the whole flexsearch index) instead of the dynamic
  GET; tokenize `forward` (prefix / as-you-type) over titles + section headings
  only. A full-body forward index exceeds V8's max string length and OOMs the
  export; headings-only is ~20MB / 1.7MB gzipped.
- dialog: `type: 'flexsearch-static'` from /api/search — no server, no auth.
- core: wire the missing `flexsearch-static` case into useDocsSearch; re-export
  buildIndexDefault so the route can tag pages (All/Services/SDKs/API/Products).

Verified on the built out/api/search: agents 85, authentication 113, getting
started 40, prefix "deplo"->/docs/deploy; all five filters return results.
This commit is contained in:
2026-07-24 23:21:05 -07:00
parent 81601cf33b
commit b4a7572cf5
4 changed files with 76 additions and 10 deletions
+61 -4
View File
@@ -1,7 +1,64 @@
import { source } from '@/lib/source';
import { flexsearchFromSource } from '@hanzo/docs-core/search/flexsearch';
import { buildIndexDefault, flexsearchFromSource } from '@hanzo/docs-core/search/flexsearch';
import { getSection } from '@/lib/source/navigation';
export const { GET } = flexsearchFromSource(source);
// The search dialog's "Products" filter covers Zen LM, Chat, MCP, Dev and ZAP.
// getSection keeps those as distinct sections, so we additionally tag their
// pages `products` for that one filter. The other filters (Services/SDKs/API)
// match the single section tag as before — flexsearch tags are a set, so a
// page can carry both its section and `products`.
const PRODUCT_SECTIONS = new Set(['chat', 'llm', 'mcp', 'dev', 'zap']);
// Static flexsearch index for CF Pages static export.
export const dynamic = 'force-static';
function sectionTags(slug: string | undefined): string | string[] {
const section = getSection(slug);
return PRODUCT_SECTIONS.has(section) ? [section, 'products'] : section;
}
// Client-side static search for the static export (served by hanzoai/static).
//
// `staticGET` serializes the flexsearch index to /api/search; the browser
// (search dialog, `type: 'flexsearch-static'`) downloads it once and searches
// locally — no server, no auth, works on a static host. This replaces the old
// `GET` (dynamic search handler) which, under `dynamic = 'force-static'`, froze
// to the empty-query response `[]`, so every search returned nothing.
//
// Index shape is tuned for this corpus (~1.7k docs):
// - `tokenize: 'forward'` gives prefix / search-as-you-type matching.
// - We index titles, descriptions and section headings, NOT body paragraphs
// (`contents: []`). Indexing full bodies with a prefix tokenizer produces a
// serialized index that exceeds V8's max string length and OOMs the build;
// headings-only keeps it at ~20MB (~1.7MB gzipped) while still matching the
// titles/headings users actually search for. Deep body text is reachable
// via the on-page anchors those headings link to.
//
// Tags mirror /static.json (getSection over the top-level slug) so the
// All/Services/SDKs/API filters narrow correctly.
export const { staticGET: GET } = flexsearchFromSource(source, {
document: { tokenize: 'forward' },
async buildIndex(page) {
const tag = sectionTags(page.slugs[0]);
// OpenAPI pages carry no MDX structured data; index title/description only.
// (In static-export mode they aren't generated at all, so this is a no-op
// there — it keeps `next dev`, where specs are present, from throwing.)
if (page.type === 'openapi') {
return {
id: page.url,
url: page.url,
title: page.data.title ?? page.url,
description: page.data.description,
tag,
structuredData: { headings: [], contents: [] },
};
}
const base = await buildIndexDefault(page);
return {
...base,
tag,
structuredData: { headings: base.structuredData.headings, contents: [] },
};
},
});
export const revalidate = false;
+2 -6
View File
@@ -22,7 +22,6 @@ import { cn } from '@/lib/cn';
import { useTreeContext } from '@hanzo/docs/ui/contexts/tree';
import type { Item, Node } from '@hanzo/docs/core/page-tree';
import { useRouter } from 'next/navigation';
import { publishableKey, searchEndpoint, searchBackend, searchIndex } from '@/lib/hanzo/client';
const items = [
{
@@ -55,11 +54,8 @@ export default function CustomSearchDialog(props: SharedProps) {
const [open, setOpen] = useState(false);
const [tag, setTag] = useState<string | undefined>();
const { search, setSearch, query } = useDocsSearch({
type: 'hanzo',
apiKey: publishableKey,
endpoint: searchEndpoint,
backend: searchBackend,
index: searchIndex,
type: 'flexsearch-static',
from: '/api/search',
tag,
});
const { full } = useTreeContext();
+9
View File
@@ -147,6 +147,15 @@ export function useDocsSearch(
client = oramaStaticClient(clientRest);
break;
}
case 'flexsearch-static': {
const res = (promiseMap[clientRest.type] ??=
import('./client/flexsearch-static')) as Promise<
typeof import('./client/flexsearch-static')
>;
const { flexsearchStaticClient } = use(res);
client = flexsearchStaticClient(clientRest);
break;
}
case 'hanzo': {
const res = (promiseMap[clientRest.type] ??= import('./client/hanzo')) as Promise<
typeof import('./client/hanzo')
+4
View File
@@ -8,6 +8,10 @@ import type { Awaitable } from '@/types';
import type { I18nConfig } from '@/i18n';
import { createDocument, search, type Doc } from './flexsearch/utils';
// Re-exported so apps can wrap the default page->index builder (e.g. to attach
// a `tag` for filtered client-side search) instead of reimplementing it.
export { buildIndexDefault } from './server/build-index';
export type Index = SharedIndex;
export interface IndexWithLocale extends Index {
locale: string;