feat(site): migrate search from Pagefind to Algolia DocSearch v4 (#941)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Darius Cepulis
2026-03-20 10:25:23 -05:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 9ce94ee2d3
commit 9ef1e61b63
20 changed files with 658 additions and 378 deletions
+1 -1
View File
@@ -45,7 +45,7 @@ const { class: className, dark = false } = Astro.props;
>
<Logo width="100%" />
</a>
<Search class="mr-4 ml-auto sm:self-center" />
<Search client:load className="mr-4 ml-auto sm:self-center" />
{/* Desktop nav links */}
<div class="hidden md:flex">
<GetStartedLink
+1 -1
View File
@@ -42,7 +42,7 @@ const { class: className } = Astro.props;
<LogoDocs width="100%" />
</div>
</a>
<Search class="mr-4 ml-auto sm:self-center" />
<Search client:load className="mr-4 ml-auto sm:self-center" />
{/* Desktop nav links */}
<div class="hidden h-full md:flex">
<GetStartedLink
+36
View File
@@ -0,0 +1,36 @@
import { DocSearch } from '@docsearch/react';
import { useStore } from '@nanostores/react';
import { GITHUB_REPO_URL } from '@/consts';
import { DOCSEARCH_API_KEY, DOCSEARCH_APP_ID, DOCSEARCH_BLOG_INDEX, DOCSEARCH_DOCS_INDEX } from '@/search.config';
import { currentFramework } from '@/stores/preferences';
interface SearchProps {
className?: string;
}
export default function Search({ className }: SearchProps) {
const framework = useStore(currentFramework);
return (
<div className={className}>
<DocSearch
appId={DOCSEARCH_APP_ID}
apiKey={DOCSEARCH_API_KEY}
indices={[
{
name: DOCSEARCH_DOCS_INDEX,
searchParameters: {
facetFilters: framework ? [`framework:${framework}`] : [],
},
},
{
name: DOCSEARCH_BLOG_INDEX,
},
]}
getMissingResultsUrl={({ query }) =>
`${GITHUB_REPO_URL}issues/new?title=${encodeURIComponent(`Search: no results for "${query}"`)}&labels=search`
}
/>
</div>
);
}
-118
View File
@@ -1,118 +0,0 @@
---
import { join } from 'node:path/posix';
import clsx from 'clsx';
/**
* We wrap Search.tsx in an .astro entry point for
* 1. easy CSS support, since we can't use Tailwind on pagefind-ui
* 2. a synchronous script to set the meta key (ctrl or cmd) without FOUC or hydration errors
*/
import SearchClient from './Search';
import SearchIcon from './searchIcon.svg';
import searchIconString from './searchIcon.svg?raw';
const baseUrl = import.meta.env.BASE_URL || '/';
const bundlePath = join(baseUrl, 'pagefind/');
interface Props {
class?: string;
}
const { class: className } = Astro.props;
---
<SearchClient
client:load
className={clsx(
"inline-flex min-h-6 cursor-pointer items-center gap-2 rounded-full border-faded-black px-2 sm:border dark:border-manila-dark intent:border-manila-dark dark:intent:border-warm-gray",
className,
)}
baseUrl={baseUrl}
bundlePath={bundlePath}
searchId="pagefind-ui"
searchStyle={{
"--search-icon-url": `url('data:image/svg+xml;utf8,${encodeURIComponent(searchIconString)}')`,
} as React.CSSProperties}
>
<SearchIcon class="h-4 w-4 sm:h-3 sm:w-3" />
<kbd data-platform-key="default" class="hidden font-display text-h4 sm:inline"
>Ctrl K</kbd
>
<kbd data-platform-key="mac" class="hidden font-display text-h4 sm:inline"
>⌘K</kbd
>
</SearchClient>
{
/**
* Adapted from https://github.com/withastro/starlight/blob/8a72a19e2cfec235941b4e1401b69b44e6695068/packages/starlight/components/Search.astro#L55C1-L75C1
* This is intentionally inlined to avoid briefly showing an invalid shortcut.
* Purposely using the deprecated `navigator.platform` property to detect Apple devices, as the
* user agent is spoofed by some browsers when opening the devtools.
*/
}
<script is:inline>
(() => {
const defaultPlatformKey = document.querySelector(
'kbd[data-platform-key="default"]',
);
const macPlatformKey = document.querySelector(
'kbd[data-platform-key="mac"]',
);
if (/Mac|iPhone|iPod|iPad/i.test(navigator.platform)) {
if (defaultPlatformKey) defaultPlatformKey.style.display = "none";
} else {
if (macPlatformKey) macPlatformKey.style.display = "none";
}
})();
</script>
<style is:global>
@import url("@pagefind/default-ui/css/ui.css");
#pagefind-ui {
--pagefind-ui-scale: 1;
--pagefind-ui-primary: var(--color-gold);
--pagefind-ui-text: var(--color-faded-black);
--pagefind-ui-background: var(--color-manila-50);
--pagefind-ui-border: var(--color-manila-75);
--pagefind-ui-tag: green;
--pagefind-ui-border-width: 1px;
--pagefind-ui-border-radius: var(--radius-xs);
--pagefind-ui-font: inherit;
}
.dark #pagefind-ui {
--pagefind-ui-text: var(--color-manila-light);
--pagefind-ui-background: var(--color-warm-gray);
--pagefind-ui-border: var(--color-soot);
}
#pagefind-ui .pagefind-ui__form::before {
-webkit-mask-image: var(--search-icon-url);
mask-image: var(--search-icon-url);
}
#pagefind-ui .pagefind-ui__search-input {
font-size: var(--text-p2);
line-height: var(--text-p2--line-height);
letter-spacing: var(--text-p2--letter-spacing);
font-weight: var(--text-p2--font-weight);
}
#pagefind-ui .pagefind-ui__filter-block {
border-bottom: solid 1px var(--pagefind-ui-border);
}
#pagefind-ui .pagefind-ui__message {
font-weight: var(--font-weight-normal);
}
#pagefind-ui .pagefind-ui__result-title,
#pagefind-ui .pagefind-ui__filter-name {
font-weight: var(--font-weight-bold);
}
#pagefind-ui .pagefind-ui__result-nested .pagefind-ui__result-link::before {
font-weight: var(--font-weight-normal);
font-size: 0.9em;
}
#pagefind-ui mark {
background-color: var(--color-gold);
color: var(--color-faded-black);
}
</style>
-90
View File
@@ -1,90 +0,0 @@
import { Dialog } from '@base-ui/react/dialog';
import { useStore } from '@nanostores/react';
import { useEffect, useRef, useState } from 'react';
import { currentFramework } from '@/stores/preferences';
interface SearchProps {
children: React.ReactNode;
className?: string;
baseUrl: string;
bundlePath: string;
searchId: string;
searchStyle?: React.CSSProperties;
}
export default function Search({ children, className, baseUrl, bundlePath, searchId, searchStyle }: SearchProps) {
const [open, setOpen] = useState(false);
const framework = useStore(currentFramework);
const searchRef = useRef<HTMLDivElement>(null);
// load Pagefind when opened
useEffect(() => {
if (!open) return;
let search: any;
const init = async () => {
// default-ui is actually an ultra-lightweight svelte app
// ...which we're loading in this react component, in an astro app.
// this isn't relevant to dev. I just found it funny.
const { PagefindUI } = await import('@pagefind/default-ui');
// I'd love if we built our own ui with BaseUI/Autocomplete, but... time, ya know?
search = new PagefindUI({
element: `#${searchId}`,
baseUrl,
bundlePath,
showImages: false,
showSubResults: true,
autofocus: true,
});
search.triggerFilters({
framework: [framework],
});
};
init();
return () => {
search.destroy();
};
}, [baseUrl, bundlePath, framework, open, searchId]);
// open with cmd+k or ctrl+k
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
setOpen((prev) => !prev);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, []);
return (
<Dialog.Root
open={open}
onOpenChange={(newOpen) => {
setOpen(newOpen);
}}
modal
>
<Dialog.Trigger aria-label="Search" className={className}>
{children}
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Backdrop className="fixed inset-0 flex items-start justify-center bg-faded-black/20 backdrop-blur-xs z-50">
<Dialog.Popup
initialFocus={false} // pagefind.autofocus = true
id={searchId}
ref={searchRef}
className="w-full max-w-5xl mx-4 mb-4 p-4 rounded-xs bg-manila-light dark:bg-faded-black dark:border-warm-gray border-manila-75 border overflow-y-scroll overflow-x-hidden"
style={{
minHeight: '50svh',
maxHeight: '75svh',
marginTop: '12.5svh',
...searchStyle,
}}
/>
</Dialog.Backdrop>
</Dialog.Portal>
</Dialog.Root>
);
}
-1
View File
@@ -1 +0,0 @@
export { default } from './Search.astro';
-12
View File
@@ -1,12 +0,0 @@
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 21l-4.34-4.34" vector-effect="non-scaling-stroke" />
<circle cx="11" cy="11" r="8" vector-effect="non-scaling-stroke" />
</svg>

Before

Width:  |  Height:  |  Size: 319 B

+3 -1
View File
@@ -254,7 +254,9 @@ export function Tab({ value, children, initial, variant = 'compact' }: TabProps)
)}
<span className="relative">
{/* to prevent layout shift on state change, we have an invisible bold version of the text preserving space */}
<span className="font-bold invisible">{children}</span>
<span className="font-bold invisible" data-search-ignore data-llms-ignore>
{children}
</span>
<span className={clsx('absolute top-0 left-0', isActive && 'font-bold')}>{children}</span>
</span>
</button>
+1 -1
View File
@@ -30,7 +30,7 @@ const shouldRender = !frameworks || frameworks.includes(framework as SupportedFr
<div
class="contents"
hidden={!shouldRender}
data-pagefind-ignore={shouldRender ? undefined : "all"}
data-search-ignore={shouldRender ? undefined : "all"}
data-llms-ignore={shouldRender ? undefined : "all"}
>
<slot />
+3 -8
View File
@@ -56,12 +56,11 @@ const jsonLdSchema = createBlogPostingSchema({
<JsonLd slot="head" schema={jsonLdSchema} />
<article
class="@container"
data-pagefind-body
data-search-content
data-llms-content
data-llms-description={post.data.description}
data-llms-sort={post.data.pubDate.toISOString()}
data-site="blog"
data-pagefind-filter="site[data-site]"
>
<header
class="relative mx-auto mb-10 max-w-3xl border-y border-faded-black py-10 dark:border-manila-light"
@@ -69,12 +68,9 @@ const jsonLdSchema = createBlogPostingSchema({
<div
class="mb-3 flex flex-col gap-2 font-display text-h4 font-bold text-orange uppercase sm:flex-row sm:gap-4"
>
<FormattedDate
data-pagefind-sort={`date:${post.data.pubDate.toISOString()}`}
date={post.data.pubDate}
/>
<FormattedDate date={post.data.pubDate} />
<span class="hidden sm:inline">&bull;</span>
<span data-pagefind-ignore data-llms-ignore
<span data-search-ignore data-llms-ignore
>{remarkPluginFrontmatter.minutesRead}</span
>
</div>
@@ -91,7 +87,6 @@ const jsonLdSchema = createBlogPostingSchema({
<a
href={`/blog/authors/${authors.find((a) => a.data.name === part.value)?.id}`}
class="inline-block intent:underline"
data-pagefind-meta="author"
>
{part.value}
</a>
@@ -129,13 +129,12 @@ const jsonLdSchema = createTechArticleSchema({
>
<article
class="mb-18 flex-1"
data-pagefind-body
data-search-content
data-llms-content
data-llms-description={doc.data.description}
data-framework={framework}
data-site="docs"
data-category={sections[0]}
data-pagefind-filter="framework[data-framework], section[data-site]"
>
<header
class:list={[
+5
View File
@@ -0,0 +1,5 @@
/** Algolia DocSearch — search API key is public (read-only). */
export const DOCSEARCH_APP_ID = '5WD2PWU9HZ';
export const DOCSEARCH_API_KEY = '68c102f304dc6e38fa3c2d167d2389ed';
export const DOCSEARCH_DOCS_INDEX = 'videojs_docs';
export const DOCSEARCH_BLOG_INDEX = 'videojs_blog';
+282
View File
@@ -0,0 +1,282 @@
@import "@docsearch/css/dist/style.css";
/* ── Light theme ── */
:root {
--docsearch-primary-color: var(--color-gold);
--docsearch-text-color: var(--color-faded-black);
--docsearch-secondary-text-color: var(--color-soot);
--docsearch-background-color: var(--color-manila-50);
--docsearch-modal-background: var(--color-manila-50);
--docsearch-modal-shadow:
0 calc(var(--spacing) * 3) calc(var(--spacing) * 7) color-mix(in srgb, var(--color-faded-black) 15%, transparent),
0 calc(var(--spacing) / 2) var(--spacing) color-mix(in srgb, var(--color-faded-black) 8%, transparent);
--docsearch-searchbox-background: var(--color-manila-light);
--docsearch-searchbox-focus-background: var(--color-manila-light);
--docsearch-hit-background: var(--color-manila-light);
--docsearch-hit-color: var(--color-faded-black);
--docsearch-hit-highlight-color: color-mix(in srgb, var(--color-gold) 15%, var(--color-manila-light));
--docsearch-highlight-color: var(--color-orange);
--docsearch-soft-primary-color: color-mix(in srgb, var(--color-gold) 10%, transparent);
--docsearch-focus-color: var(--color-gold);
--docsearch-subtle-color: var(--color-manila-75);
--docsearch-muted-color: var(--color-manila-dark);
--docsearch-icon-color: var(--color-soot);
--docsearch-container-background: color-mix(in srgb, var(--color-faded-black) 20%, transparent);
--docsearch-key-background: var(--color-manila-75);
--docsearch-key-color: var(--color-faded-black);
--docsearch-key-pressed-shadow: inset 0 calc(var(--spacing) / 2) var(--spacing)
color-mix(in srgb, var(--color-faded-black) 15%, transparent);
--docsearch-footer-background: var(--color-manila-light);
--docsearch-footer-shadow: 0 -1px 0 0 var(--color-manila-75); /* 1px hairline */
--docsearch-logo-color: currentColor;
--docsearch-border-radius: var(--radius-xs);
--docsearch-spacing: calc(var(--spacing) * 4);
--docsearch-searchbox-height: calc(var(--spacing) * 14);
--docsearch-searchbox-initial-height: calc(var(--spacing) * 14);
--docsearch-hit-height: calc(var(--spacing) * 14);
--docsearch-actions-height: calc(var(--spacing) * 11);
--docsearch-footer-height: calc(var(--spacing) * 13);
--docsearch-icon-stroke-width: 2;
--docsearch-search-button-background: transparent;
--docsearch-search-button-text-color: var(--color-faded-black);
}
/* ── Dark theme ── */
.dark {
--docsearch-text-color: var(--color-manila-light);
--docsearch-secondary-text-color: var(--color-manila-dark);
--docsearch-background-color: var(--color-warm-gray);
--docsearch-modal-background: var(--color-faded-black);
--docsearch-modal-shadow:
inset 1px 1px 0 0 var(--color-warm-gray),
0 calc(var(--spacing) * 0.75) calc(var(--spacing) * 2) color-mix(in srgb, var(--color-black) 50%, transparent);
--docsearch-searchbox-background: var(--color-soot);
--docsearch-searchbox-focus-background: var(--color-soot);
--docsearch-hit-background: var(--color-warm-gray);
--docsearch-hit-color: var(--color-manila-light);
--docsearch-hit-highlight-color: color-mix(in srgb, var(--color-gold) 10%, var(--color-warm-gray));
--docsearch-highlight-color: var(--color-gold);
--docsearch-soft-primary-color: color-mix(in srgb, var(--color-gold) 10%, transparent);
--docsearch-focus-color: var(--color-gold);
--docsearch-subtle-color: var(--color-warm-gray);
--docsearch-muted-color: var(--color-manila-dark);
--docsearch-icon-color: var(--color-manila-dark);
--docsearch-container-background: color-mix(in srgb, var(--color-faded-black) 60%, transparent);
--docsearch-key-background: var(--color-soot);
--docsearch-key-color: var(--color-manila-light);
--docsearch-key-pressed-shadow: inset 0 calc(var(--spacing) / 2) var(--spacing)
color-mix(in srgb, var(--color-black) 40%, transparent);
--docsearch-footer-background: var(--color-soot);
--docsearch-footer-shadow: 0 -1px 0 0 var(--color-warm-gray); /* 1px hairline */
--docsearch-logo-color: currentColor;
--docsearch-search-button-background: transparent;
--docsearch-search-button-text-color: var(--color-manila-light);
}
/* ── Trigger button — match rounded-pill navbar style ── */
.DocSearch-Button {
box-sizing: border-box;
border-radius: 9999px;
border: none;
padding: 0 calc(var(--spacing) * 2);
gap: calc(var(--spacing) * 2);
height: auto;
min-height: calc(var(--spacing) * 6);
margin: 0;
font-family: inherit;
}
.DocSearch-Button:hover,
.DocSearch-Button:focus-within {
box-shadow: none;
}
.DocSearch-Button .DocSearch-Search-Icon {
width: calc(var(--spacing) * 4);
height: calc(var(--spacing) * 4);
}
.DocSearch-Search-Icon circle,
.DocSearch-Search-Icon path {
stroke-width: var(--docsearch-icon-stroke-width);
}
.DocSearch-Button .DocSearch-Search-Icon circle,
.DocSearch-Button .DocSearch-Search-Icon path {
stroke-width: calc(var(--docsearch-icon-stroke-width) * 2);
}
/* Hide button label text on small screens, keep icon + keyboard shortcut */
.DocSearch-Button-Placeholder {
display: none;
}
.DocSearch-Button-Keys {
display: none;
min-width: 0;
gap: 0;
}
.DocSearch-Button-Keys:has(.DocSearch-Button-Key--ctrl) {
gap: var(--spacing);
}
.DocSearch-Button .DocSearch-Button-Key {
background: none;
border: none;
box-shadow: none !important;
color: inherit;
width: auto;
height: auto;
font-family: var(--font-display);
font-size: var(--text-h4);
line-height: var(--text-h4--line-height);
letter-spacing: var(--text-h4--letter-spacing);
font-weight: var(--font-weight-bold);
padding: 0;
margin: 0;
}
@media (width >= 40rem) {
.DocSearch-Button {
border: 1px solid var(--color-faded-black);
width: calc(var(--spacing) * 16.5);
}
.DocSearch-Button:has(.DocSearch-Button-Key--ctrl) {
width: calc(var(--spacing) * 25);
}
.dark .DocSearch-Button {
border-color: var(--color-manila-dark);
}
.DocSearch-Button:hover,
.DocSearch-Button:focus-within {
border-color: var(--color-manila-dark);
}
.dark .DocSearch-Button:hover,
.dark .DocSearch-Button:focus-within {
border-color: var(--color-warm-gray);
}
.DocSearch-Button-Keys {
display: flex;
}
.DocSearch-Button .DocSearch-Search-Icon {
width: calc(var(--spacing) * 3);
height: calc(var(--spacing) * 3);
}
}
/* ── Modal backdrop ── */
.DocSearch-Container {
backdrop-filter: blur(calc(var(--spacing) / 2));
}
/* ── Consistent border-radius — DocSearch hardcodes 4px on these ── */
.DocSearch-Modal {
border-radius: var(--radius-xs);
}
.DocSearch-Form {
border-radius: var(--radius-xs) var(--radius-xs) 0 0;
}
.DocSearch-Footer {
border-radius: 0 0 var(--radius-xs) var(--radius-xs);
border-block-start: none;
}
.DocSearch-Dropdown + .DocSearch-Footer {
border-block-start: 1px solid var(--docsearch-subtle-color);
}
.DocSearch-Hit a {
border-radius: var(--radius-xs);
}
/* ── Typography — full text-token treatment (size + leading + tracking + weight) ── */
.DocSearch-Modal {
font-family: var(--font-sans);
font-size: var(--text-p2);
line-height: var(--text-p2--line-height);
letter-spacing: var(--text-p2--letter-spacing);
font-weight: var(--text-p2--font-weight);
text-underline-offset: 0.25em;
}
.DocSearch-Input {
letter-spacing: var(--tracking-tight);
font-weight: var(--text-p1--font-weight);
}
.DocSearch-Button-Key,
.DocSearch-Escape-Key {
font-family: var(--font-mono);
font-size: var(--text-code);
line-height: var(--text-code--line-height);
letter-spacing: var(--text-code--letter-spacing);
font-weight: var(--text-code--font-weight);
font-variant-ligatures: none;
text-underline-offset: 0.125em;
}
.DocSearch-Hit-source {
font-size: var(--text-p2);
line-height: var(--text-p2--line-height);
letter-spacing: var(--text-p2--letter-spacing);
font-weight: var(--font-weight-bold);
padding-block-start: calc(var(--spacing) * 4);
padding-block-end: calc(var(--spacing) * 2);
}
.DocSearch-Hit-title {
font-size: var(--text-p2);
line-height: var(--text-p2--line-height);
letter-spacing: var(--text-p2--letter-spacing);
}
.DocSearch-Hit-path {
font-size: var(--text-p3);
line-height: var(--text-p3--line-height);
letter-spacing: var(--text-p3--letter-spacing);
font-weight: var(--text-p3--font-weight);
}
.DocSearch-Label,
.DocSearch-Help,
.DocSearch-NoResults-Help {
font-size: var(--text-p3);
line-height: var(--text-p3--line-height);
letter-spacing: var(--text-p3--letter-spacing);
font-weight: var(--text-p3--font-weight);
}
.DocSearch-Clear {
font-size: var(--text-p3);
font-weight: var(--text-p3--font-weight);
}
.DocSearch-Title strong {
font-weight: var(--font-weight-semibold);
}
/* ── Hit spacing — more breathing room, tighter title/path gap ── */
.DocSearch-Hit-content-wrapper {
gap: 0;
}
/* ── Algolia logo — SVG paths hardcode blue fills via .cls-1/.cls-2 ── */
.DocSearch-Logo svg .cls-1,
.DocSearch-Logo svg .cls-2 {
fill: var(--docsearch-logo-color);
}
/* ── Text match highlight — keep readable in all states ── */
.DocSearch-Hit mark,
.DocSearch-Hit[aria-selected="true"] mark {
text-decoration-color: var(--color-gold);
color: currentColor;
}
+1
View File
@@ -1,5 +1,6 @@
@import "tailwindcss";
@import "./shiki-transformers.css";
@import "./docsearch.css";
/**
* @license