Skip to content
Closed
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ internal

# Local Netlify folder
.netlify

# Pagefind generated files
**/pagefind/
7 changes: 6 additions & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"type": "module",
"scripts": {
"dev": "VITE_APP_URL=\"http://localhost:3000\" dotenvx run --ignore MISSING_ENV_FILE -f ../../.env.supabase -f ../../.env.restate -f .env -- vite dev --port 3000",
"build": "vite build",
"build": "vite build && pagefind --site dist/client --glob \"docs/**/*.html\"",
"serve": "vite preview",
"test": "playwright test",
"typecheck": "pnpm -F @hypr/web build && tsc --project tsconfig.json --noEmit",
Expand Down Expand Up @@ -38,6 +38,8 @@
"@tanstack/react-start": "^1.139.3",
"@tanstack/router-plugin": "^1.139.3",
"@unpic/react": "^1.0.1",
"cmdk": "1.1.1",
"dompurify": "^3.3.0",
"drizzle-orm": "^0.44.7",
"exa-js": "^1.10.2",
"lucide-react": "^0.544.0",
Expand Down Expand Up @@ -70,16 +72,19 @@
"@tailwindcss/typography": "^0.5.19",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.0",
"@types/dompurify": "^3.2.0",
"@types/node": "^22.19.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/semver": "^7.7.1",
"@vitejs/plugin-react": "^5.1.1",
"jsdom": "^27.2.0",
"netlify": "^23.11.1",
"pagefind": "^1.4.0",
"tanstack-router-sitemap": "^1.0.13",
"typescript": "^5.9.3",
"vite": "^7.2.4",
"vite-plugin-pagefind": "^1.0.7",
"web-vitals": "^5.1.0"
}
}
162 changes: 162 additions & 0 deletions apps/web/src/components/docs-search.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import DOMPurify from "dompurify";
import { Search } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import type {
Pagefind,
PagefindSearchFragment,
} from "vite-plugin-pagefind/types";

import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@hypr/ui/components/ui/command";
import { cn } from "@hypr/utils";

export function DocsSearch() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [results, setResults] = useState<PagefindSearchFragment[]>([]);
const [isLoading, setIsLoading] = useState(false);
const pagefindRef = useRef<Pagefind | null>(null);

useEffect(() => {
if (typeof window === "undefined") return;
let cancelled = false;

(async () => {
try {
const pagefind = (await import(
"/pagefind/pagefind.js"
)) as unknown as Pagefind;
if (!cancelled) pagefindRef.current = pagefind;
} catch {
// Pagefind not available in dev mode
}
})();

return () => {
cancelled = true;
};
}, []);

useEffect(() => {
if (typeof window === "undefined") return;

const handler = (event: KeyboardEvent) => {
const isCmdK =
(event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k";

const target = event.target as HTMLElement | null;
if (
isCmdK &&
target &&
!["INPUT", "TEXTAREA"].includes(target.tagName) &&
!target.isContentEditable
) {
event.preventDefault();
setOpen((prev) => !prev);
}
};

window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);

const handleSearch = useCallback(async (value: string) => {
setQuery(value);
if (!value.trim() || !pagefindRef.current) {
setResults([]);
return;
}

setIsLoading(true);
try {
const res = await pagefindRef.current.search(value);
if (!res?.results) {
setResults([]);
return;
}
const data = await Promise.all(
res.results.slice(0, 10).map((r) => r.data()),
);
setResults(data);
} catch {
setResults([]);
} finally {
setIsLoading(false);
}
}, []);

const handleSelect = useCallback((url: string) => {
setOpen(false);
setQuery("");
setResults([]);
window.location.assign(url);
}, []);

return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className={cn([
"w-full flex items-center justify-between",
"px-3 py-2 text-sm",
"bg-neutral-50 border border-neutral-200 rounded-sm",
"text-neutral-500 hover:bg-neutral-100",
"transition-colors cursor-pointer",
])}
>
<span className="flex items-center gap-2">
<Search size={16} className="text-neutral-400" />
<span>Search docs...</span>
</span>
<span className="text-[11px] rounded border border-neutral-300 px-1.5 py-0.5 text-neutral-400">
<span className="font-sans">&#8984;</span>K
</span>
</button>

<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput
placeholder="Search docs..."
value={query}
onValueChange={handleSearch}
/>
<CommandList>
{isLoading && (
<div className="py-6 text-center text-sm text-muted-foreground">
Searching...
</div>
)}
{!isLoading && query && results.length === 0 && (
<CommandEmpty>No results found.</CommandEmpty>
)}
{!isLoading && results.length > 0 && (
<CommandGroup heading="Results">
{results.map((result) => (
<CommandItem
key={result.url}
value={`${result.meta.title} ${result.url}`}
onSelect={() => handleSelect(result.url)}
className="flex flex-col items-start gap-1 py-3"
>
<div className="text-sm font-medium">{result.meta.title}</div>
<div
className="text-xs text-muted-foreground line-clamp-2"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(result.excerpt),
}}
/>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</CommandDialog>
</>
);
}
3 changes: 3 additions & 0 deletions apps/web/src/routes/_view/docs/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
import { allDocs } from "content-collections";
import { useMemo } from "react";

import { DocsSearch } from "@/components/docs-search";

import { docsStructure } from "./-structure";

export const Route = createFileRoute("/_view/docs")({
Expand Down Expand Up @@ -78,6 +80,7 @@ function LeftSidebar() {
return (
<aside className="hidden md:block w-64 shrink-0">
<div className="sticky top-[69px] max-h-[calc(100vh-69px)] overflow-y-auto scrollbar-hide space-y-6 px-4 py-6">
<DocsSearch />
<DocsNavigation
sections={docsBySection.sections}
currentSlug={currentSlug}
Expand Down
6 changes: 6 additions & 0 deletions apps/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { generateSitemap } from "tanstack-router-sitemap";
import { defineConfig } from "vite";
import { pagefind } from "vite-plugin-pagefind";
import viteTsConfigPaths from "vite-tsconfig-paths";

import { getSitemap } from "./src/utils/sitemap";
Expand Down Expand Up @@ -36,6 +37,11 @@ const config = defineConfig(() => ({
viteReact(),
generateSitemap(getSitemap()),
netlify({ dev: { images: { enabled: true } } }),
pagefind({
outputDirectory: "dist/client",
buildScript: "build",
developStrategy: "lazy",
}),
],
ssr: {
noExternal: ["posthog-js", "@posthog/react", "react-tweet"],
Expand Down
Loading
Loading