Skip to content

Commit 0cf4143

Browse files
teallarsonclaude
andauthored
Update search UI for DocSearch content indexing (#845)
* Update Algolia search to display DocSearch hierarchy and content snippets Support the richer record format from helpers.docsearch() crawler config: heading hierarchy breadcrumbs, content snippet highlighting, and proper anchor-based navigation. Backward compatible with legacy flat records. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Address review feedback on search component - Fix key collision risk in Breadcrumb (use index-prefixed key) - Remove unnecessary optional chain on hit.type (required field) - Drop lvl6 from type and hierarchy (crawler only uses lvl0-lvl5) - Remove unreachable fallback in getHitUrl (url is required string) - Use explicit distinct={true} for consistency with other props Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Restore optional chain on hit.type for legacy record compatibility Legacy records from the old _pages index don't have a type field, so hit.type is undefined at runtime. Without optional chaining, hit.type.startsWith() throws a TypeError before reaching the legacy title fallback. Make type optional in the type definition to match. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix duplicate heading in breadcrumb and title for content hits Content-type hits showed the deepest heading in both the breadcrumb trail and the title. Apply the same slice(0, -1) used for heading hits so the breadcrumb always excludes the level shown as the title. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent d12db22 commit 0cf4143

1 file changed

Lines changed: 124 additions & 14 deletions

File tree

app/_components/algolia-search.tsx

Lines changed: 124 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,34 @@ import { liteClient as algoliasearch } from "algoliasearch/lite";
44
import { Search } from "lucide-react";
55
import { useEffect, useState } from "react";
66
import {
7+
Configure,
78
Highlight,
89
Hits,
910
InstantSearch,
1011
SearchBox,
12+
Snippet,
1113
useInstantSearch,
1214
} from "react-instantsearch";
1315

14-
type HitRecord = {
16+
type DocSearchHierarchy = {
17+
lvl0: string | null;
18+
lvl1: string | null;
19+
lvl2: string | null;
20+
lvl3: string | null;
21+
lvl4: string | null;
22+
lvl5: string | null;
23+
};
24+
25+
type DocSearchRecord = {
1526
objectID: string;
27+
type?: "lvl0" | "lvl1" | "lvl2" | "lvl3" | "lvl4" | "lvl5" | "content";
28+
hierarchy: DocSearchHierarchy;
29+
content: string | null;
30+
url: string;
31+
anchor: string | null;
32+
// Legacy flat fields from non-docsearch indexes
1633
title?: string;
1734
description?: string;
18-
url?: string;
1935
};
2036

2137
const appId = process.env.NEXT_PUBLIC_ALGOLIA_APP_ID;
@@ -38,24 +54,112 @@ function safeHref(url: string | undefined): string {
3854
return "/";
3955
}
4056

41-
function SearchHit({ hit }: { hit: HitRecord }) {
57+
function getHitUrl(hit: DocSearchRecord): string {
58+
// DocSearch records include full URLs; make them relative for same-site nav
59+
try {
60+
const parsed = new URL(hit.url);
61+
return safeHref(parsed.pathname + parsed.hash);
62+
} catch {
63+
return safeHref(hit.url);
64+
}
65+
}
66+
67+
function Breadcrumb({ hit }: { hit: DocSearchRecord }) {
68+
if (!hit.hierarchy) {
69+
return null;
70+
}
71+
72+
const levels = [
73+
hit.hierarchy.lvl0,
74+
hit.hierarchy.lvl1,
75+
hit.hierarchy.lvl2,
76+
hit.hierarchy.lvl3,
77+
hit.hierarchy.lvl4,
78+
hit.hierarchy.lvl5,
79+
].filter(Boolean) as string[];
80+
81+
// Exclude the deepest level — it's already shown as the hit title
82+
const breadcrumbLevels = levels.slice(0, -1);
83+
84+
if (breadcrumbLevels.length === 0) {
85+
return null;
86+
}
87+
88+
return (
89+
<div className="mb-0.5 flex flex-wrap items-center gap-1 text-xs text-muted-foreground">
90+
{breadcrumbLevels.map((level, i) => (
91+
<span className="flex items-center gap-1" key={`${i}-${level}`}>
92+
{i > 0 && (
93+
<span aria-hidden="true" className="text-muted-foreground/50">
94+
95+
</span>
96+
)}
97+
<span className="truncate">{level}</span>
98+
</span>
99+
))}
100+
</div>
101+
);
102+
}
103+
104+
function HitTitle({ hit }: { hit: DocSearchRecord }) {
105+
const castHit = hit as unknown as Parameters<typeof Highlight>[0]["hit"];
106+
107+
// For content-type records, the "title" is the nearest heading
108+
if (hit.type === "content") {
109+
// Find the deepest non-null heading level
110+
const headingAttr = (
111+
[
112+
"hierarchy.lvl5",
113+
"hierarchy.lvl4",
114+
"hierarchy.lvl3",
115+
"hierarchy.lvl2",
116+
"hierarchy.lvl1",
117+
"hierarchy.lvl0",
118+
] as const
119+
).find((attr) => {
120+
const key = attr.split(".")[1] as keyof DocSearchHierarchy;
121+
return hit.hierarchy?.[key];
122+
});
123+
124+
if (headingAttr) {
125+
return <Highlight attribute={headingAttr.split(".")} hit={castHit} />;
126+
}
127+
}
128+
129+
// For heading-type records, highlight the heading itself
130+
if (hit.type?.startsWith("lvl") && hit.hierarchy) {
131+
return <Highlight attribute={["hierarchy", hit.type]} hit={castHit} />;
132+
}
133+
134+
// Fallback for legacy flat records
135+
if (hit.title) {
136+
return <Highlight attribute="title" hit={castHit} />;
137+
}
138+
139+
return <span>{hit.hierarchy?.lvl0 ?? "Untitled"}</span>;
140+
}
141+
142+
function SearchHit({ hit }: { hit: DocSearchRecord }) {
143+
const castHit = hit as unknown as Parameters<typeof Snippet>[0]["hit"];
144+
const isContentHit = hit.type === "content";
145+
42146
return (
43147
<a
44148
className="block rounded-lg px-4 py-3 hover:bg-neutral-100 dark:hover:bg-white/5"
45-
href={safeHref(hit.url)}
149+
href={getHitUrl(hit)}
46150
>
151+
<Breadcrumb hit={hit} />
47152
<div className="truncate text-sm font-medium text-foreground">
48-
<Highlight
49-
attribute="title"
50-
hit={hit as Parameters<typeof Highlight>[0]["hit"]}
51-
/>
153+
<HitTitle hit={hit} />
52154
</div>
53-
{hit.description && (
155+
{isContentHit && hit.content && (
156+
<div className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
157+
<Snippet attribute="content" hit={castHit} />
158+
</div>
159+
)}
160+
{!isContentHit && hit.description && (
54161
<div className="mt-0.5 truncate text-xs text-muted-foreground">
55-
<Highlight
56-
attribute="description"
57-
hit={hit as Parameters<typeof Highlight>[0]["hit"]}
58-
/>
162+
<Highlight attribute="description" hit={castHit} />
59163
</div>
60164
)}
61165
</a>
@@ -146,6 +250,12 @@ export function AlgoliaSearch() {
146250
<div className="relative z-10 w-full max-w-2xl overflow-hidden rounded-xl border border-border bg-popover shadow-2xl">
147251
{searchClient && indexName ? (
148252
<InstantSearch indexName={indexName} searchClient={searchClient}>
253+
<Configure
254+
attributesToSnippet={["content:20"]}
255+
distinct={true}
256+
hitsPerPage={15}
257+
snippetEllipsisText="…"
258+
/>
149259
<div className="flex items-center border-b border-border px-4">
150260
<Search className="size-4 shrink-0 text-muted-foreground" />
151261
<SearchBox
@@ -168,7 +278,7 @@ export function AlgoliaSearch() {
168278
<Hits
169279
classNames={{ item: "", list: "space-y-0.5", root: "" }}
170280
hitComponent={({ hit }) => (
171-
<SearchHit hit={hit as unknown as HitRecord} />
281+
<SearchHit hit={hit as unknown as DocSearchRecord} />
172282
)}
173283
/>
174284
</div>

0 commit comments

Comments
 (0)