Skip to content

Commit f060169

Browse files
committed
Revamp search indexing and UI for docs
Replaces old search algorithm to improve result ranking and relevance. Updates the search index build process, adds a new searchIndex.js template, and refines section extraction in MDX. The Search UI is simplified by removing navigation hierarchy and improving result display. Also updates navigation links and adds scroll offset for anchor targets in CSS.
1 parent 5340043 commit f060169

6 files changed

Lines changed: 232 additions & 105 deletions

File tree

apps/web/scripts/fix-api-reference.mts

Lines changed: 56 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ function rearrangeMdxFilesRecursively(dir: string) {
2525
`${baseName} - API reference`,
2626
);
2727
} else {
28-
fixLinksInMdxFile(fullPath, "API reference");
28+
const title =
29+
dir === reference
30+
? "API reference"
31+
: `${path.basename(dir)} - API reference`;
32+
fixLinksInMdxFile(fullPath, title);
2933
}
3034
}
3135
}
@@ -34,30 +38,71 @@ function rearrangeMdxFilesRecursively(dir: string) {
3438
function fixLinksInMdxFile(filePath: string, title: string) {
3539
const content = fs.readFileSync(filePath, "utf-8");
3640
// first let's replace /page.mdx with /
37-
let newContent = content.replace(/\/page.mdx/g, "");
38-
newContent = newContent.replace(/\(([^)]+)\.mdx\)/g, "($1)");
41+
let newContent = content.replace(/\/page\.mdx/g, "");
42+
// Remove .mdx from Markdown link destinations, preserving query/hash.
43+
// Examples:
44+
// - [X](/docs/Foo.mdx) -> [X](/docs/Foo)
45+
// - [X](/docs/Foo.mdx#bar) -> [X](/docs/Foo#bar)
46+
// - [X](../Foo.mdx?x=1#bar) -> [X](../Foo?x=1#bar)
47+
newContent = newContent.replace(/\]\(([^)]*?)\.mdx(?=[)#?])/g, "]($1");
3948

40-
// fix API reference breadcrumb link
49+
// fix API reference breadcrumb link and separator
50+
// Breadcrumb is the first line starting with `[API` - replace link text and separators
4151
newContent = newContent.replace(
42-
/\[API Reference\]\([^)]*\)/g,
43-
"[API reference](/docs/api-reference)",
52+
/^(\[API Reference\]\([^)]*\))(.*)/m,
53+
(_match, _apiLink, rest: string) => {
54+
const fixedRest = rest.replace(/ \/ /g, " › ");
55+
return `[API reference](/docs/api-reference)${fixedRest}`;
56+
},
4457
);
4558

46-
// Remove call signatures
59+
// Extract ## headings to generate sections for "On this page" navigation
60+
const sections = extractSections(newContent);
61+
const sectionsExport =
62+
sections.length > 0
63+
? `export const sections = ${JSON.stringify(sections)};`
64+
: "export const sections = [];";
65+
66+
// add meta tags (idempotent)
4767
newContent = newContent.replace(
48-
/##\s*Call Signature\r?\n\s*```ts[\s\S]*?```/g,
68+
/^export const metadata = \{ title: [^}]*\};\s*\r?\n\s*/,
69+
"",
70+
);
71+
newContent = newContent.replace(
72+
/^export const sections = .*;\s*\r?\n\s*/m,
4973
"",
5074
);
51-
52-
// add meta tags
5375
newContent = `export const metadata = { title: '${title}' };
54-
export const sections = [];
76+
${sectionsExport}
5577
5678
${newContent}`;
5779

5880
fs.writeFileSync(filePath, newContent);
5981
}
6082

83+
/** Extract ## headings from MDX content to generate sections */
84+
function extractSections(
85+
content: string,
86+
): Array<{ id: string; title: string }> {
87+
const sections: Array<{ id: string; title: string }> = [];
88+
// Match ## headings (not ### or deeper)
89+
const headingRegex = /^## (.+)$/gm;
90+
let match;
91+
while ((match = headingRegex.exec(content)) !== null) {
92+
const title = match[1].trim();
93+
// Generate id from title (kebab-case)
94+
const id = title
95+
.toLowerCase()
96+
.replace(/[^a-z0-9\s-]/g, "")
97+
.replace(/\s+/g, "-")
98+
.replace(/-+/g, "-");
99+
if (id) {
100+
sections.push({ id, title });
101+
}
102+
}
103+
return sections;
104+
}
105+
61106
// Run the script
62107
rearrangeMdxFilesRecursively(reference);
63108

apps/web/src/components/Search.tsx

Lines changed: 3 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import clsx from "clsx";
1111
import { usePathname, useRouter, useSearchParams } from "next/navigation";
1212
import {
1313
forwardRef,
14-
Fragment,
1514
Suspense,
1615
useCallback,
1716
useEffect,
@@ -21,7 +20,6 @@ import {
2120
} from "react";
2221
import Highlighter from "react-highlight-words";
2322

24-
import { navigation } from "@/lib/navigation";
2523
import { type Result } from "@/mdx/search.mjs";
2624

2725
type EmptyObject = Record<string, never>;
@@ -83,7 +81,7 @@ function useAutocomplete({ close }: { close: () => void }) {
8381
{
8482
sourceId: "documentation",
8583
getItems() {
86-
return search(query, { limit: 5 });
84+
return search(query);
8785
},
8886
getItemUrl({ item }) {
8987
return item.url;
@@ -179,20 +177,13 @@ function SearchResult({
179177
}) {
180178
const id = useId();
181179

182-
const sectionTitle = navigation.find((section) =>
183-
section.links.find((link) => link.href === result.url.split("#")[0]),
184-
)?.title;
185-
const hierarchy = [sectionTitle, result.pageTitle].filter(
186-
(x): x is string => typeof x === "string",
187-
);
188-
189180
return (
190181
<li
191182
className={clsx(
192183
"group block cursor-default px-4 py-3 aria-selected:bg-zinc-50 dark:aria-selected:bg-zinc-800/50",
193184
resultIndex > 0 && "border-t border-zinc-100 dark:border-zinc-800",
194185
)}
195-
aria-labelledby={`${id}-hierarchy ${id}-title`}
186+
aria-labelledby={`${id}-title`}
196187
{...autocomplete.getItemProps({
197188
item: result,
198189
source: collection.source,
@@ -203,35 +194,8 @@ function SearchResult({
203194
aria-hidden="true"
204195
className="text-sm font-medium text-zinc-900 group-aria-selected:text-blue-500 dark:text-white"
205196
>
206-
{result.url.startsWith("/docs/api-reference") ? (
207-
<span className="mr-1.5">API Reference /</span>
208-
) : (
209-
<></>
210-
)}
211197
<HighlightQuery text={result.title} query={query} />
212198
</div>
213-
{hierarchy.length > 0 && (
214-
<div
215-
id={`${id}-hierarchy`}
216-
aria-hidden="true"
217-
className="text-2xs mt-1 truncate whitespace-nowrap text-zinc-500"
218-
>
219-
{hierarchy.map((item, itemIndex, items) => (
220-
<Fragment key={itemIndex}>
221-
<HighlightQuery text={item} query={query} />
222-
<span
223-
className={
224-
itemIndex === items.length - 1
225-
? "sr-only"
226-
: "mx-2 text-zinc-300 dark:text-zinc-700"
227-
}
228-
>
229-
/
230-
</span>
231-
</Fragment>
232-
))}
233-
</div>
234-
)}
235199
<div className="text-2xs mt-1 truncate whitespace-nowrap text-zinc-500">
236200
<HighlightQuery text={result.url} query={query} />
237201
</div>
@@ -408,7 +372,7 @@ function SearchDialog({
408372
/>
409373
<div
410374
ref={panelRef}
411-
className="border-t border-zinc-200 bg-white empty:hidden dark:border-zinc-100/5 dark:bg-white/2.5"
375+
className="max-h-[60vh] overflow-y-auto border-t border-zinc-200 bg-white empty:hidden dark:border-zinc-100/5 dark:bg-white/2.5"
412376
{...autocomplete.getPanelProps({})}
413377
>
414378
{autocompleteState.isOpen && (

apps/web/src/lib/navigation.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export const navigation: Array<NavGroup> = [
2121
},
2222
{
2323
title: "Task",
24-
href: "/docs/api-reference/common/Task/interfaces/Task",
24+
href: "/docs/api-reference/common/Task/type-aliases/Task",
2525
},
2626
{
2727
title: "Type",

apps/web/src/mdx/search.mjs

Lines changed: 23 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import { SKIP, visit } from "unist-util-visit";
1111
import * as url from "url";
1212

1313
const __filename = url.fileURLToPath(import.meta.url);
14+
const searchIndexPath = path.resolve(
15+
path.dirname(__filename),
16+
"./searchIndex.js",
17+
);
1418
const processor = remark().use(remarkMdx).use(extractSections);
1519
const slugify = slugifyWithCounter();
1620

@@ -30,14 +34,20 @@ function extractSections() {
3034
slugify.reset();
3135

3236
visit(tree, (node) => {
33-
if (node.type === "heading" || node.type === "paragraph") {
37+
if (node.type === "heading" && node.depth <= 2) {
3438
let content = toString(excludeObjectExpressions(node));
35-
if (node.type === "heading" && node.depth <= 2) {
36-
let hash = node.depth === 1 ? null : slugify(content);
37-
sections.push([content, hash, []]);
38-
} else {
39-
sections.at(-1)?.[2].push(content);
40-
}
39+
let hash = node.depth === 1 ? null : slugify(content);
40+
sections.push([content, hash, []]);
41+
return SKIP;
42+
}
43+
// Extract text from paragraphs, table cells, list items, etc.
44+
if (
45+
node.type === "paragraph" ||
46+
node.type === "tableCell" ||
47+
node.type === "listItem"
48+
) {
49+
let content = toString(excludeObjectExpressions(node));
50+
sections.at(-1)?.[2].push(content);
4151
return SKIP;
4252
}
4353
});
@@ -76,53 +86,12 @@ export default function Search(nextConfig = {}) {
7686
return { url, sections };
7787
});
7888

79-
// When this file is imported within the application
80-
// the following module is loaded:
81-
return `
82-
import FlexSearch from 'flexsearch'
83-
84-
let sectionIndex = new FlexSearch.Document({
85-
tokenize: 'full',
86-
document: {
87-
id: 'url',
88-
index: 'content',
89-
store: ['title', 'pageTitle'],
90-
},
91-
context: {
92-
resolution: 9,
93-
depth: 2,
94-
bidirectional: true
95-
}
96-
})
97-
98-
let data = ${JSON.stringify(data)}
99-
100-
for (let { url, sections } of data) {
101-
for (let [title, hash, content] of sections) {
102-
sectionIndex.add({
103-
url: url + (hash ? ('#' + hash) : ''),
104-
title,
105-
content: [title, ...content].join('\\n'),
106-
pageTitle: hash ? sections[0][0] : undefined,
107-
})
108-
}
109-
}
110-
111-
export function search(query, options = {}) {
112-
let result = sectionIndex.search(query, {
113-
...options,
114-
enrich: true,
115-
})
116-
if (result.length === 0) {
117-
return []
118-
}
119-
return result[0].result.map((item) => ({
120-
url: item.id,
121-
title: item.doc.title,
122-
pageTitle: item.doc.pageTitle,
123-
}))
124-
}
125-
`;
89+
// Read the search index template and inject the data
90+
const template = fs.readFileSync(searchIndexPath, "utf8");
91+
return template.replace(
92+
'const data = "DATA_PLACEHOLDER";',
93+
`const data = ${JSON.stringify(data)};`,
94+
);
12695
}),
12796
],
12897
});

0 commit comments

Comments
 (0)