diff --git a/.gitignore b/.gitignore index afad7152..31ef0dc8 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,8 @@ storybook-static/ # Generated public/r/ +public/_pagefind/ +apps/registry/public/_pagefind/ next-env.d.ts # Local config diff --git a/apps/registry/components/header/header.tsx b/apps/registry/components/header/header.tsx index 54842b4b..2da89b1c 100644 --- a/apps/registry/components/header/header.tsx +++ b/apps/registry/components/header/header.tsx @@ -4,6 +4,7 @@ import { NavbarSaas, SearchDialog } from "@vllnt/ui"; import { Github } from "lucide-react"; import { useRouter } from "next/navigation"; +import { searchPagefind } from "@/components/header/pagefind-search"; import registryData from "@/registry.json"; const GITHUB_URL = "https://github.com/vllnt/ui"; @@ -26,6 +27,7 @@ export function Header() { .filter((item) => item.type === "registry:component") .map((item) => ({ description: item.description, + href: `/components/${item.name}`, id: item.name, title: item.title, })); @@ -37,14 +39,20 @@ export function Header() { rightSlot={
{ + router.push(item.href ?? item.id); + }} onSelect={(item) => { - router.push(`/components/${item.id}`); + router.push(item.href ?? `/components/${item.id}`); }} - searchPlaceholder="Search components..." + searchPlaceholder="Search docs and components..." /> > & + Partial>; + +type PagefindSubResult = { + excerpt?: string; + title?: string; + url: string; +} & Partial>; + +type PagefindSearchResponse = { + results: { + data: () => Promise; + id: string; + }[]; +}; + +type PagefindModule = { + debouncedSearch?: ( + query: string, + options?: Record, + timeout?: number, + ) => Promise; + init?: () => Promise | void; + search: (query: string) => Promise; +}; + +const PAGEFIND_BUNDLE_PATH = "/_pagefind/pagefind.js"; +const MAX_RESULTS = 8; +const SNIPPET_CONTEXT = 50; + +let pagefindPromise: Promise | undefined; + +async function loadPagefind() { + pagefindPromise ??= import( + /* webpackIgnore: true */ PAGEFIND_BUNDLE_PATH + ).then((module) => { + const pagefind = module as PagefindModule; + void pagefind.init?.(); + return pagefind; + }); + + return pagefindPromise; +} + +function stripMarkup(value: string) { + return value.replaceAll(/<[^>]*>/g, ""); +} + +function getFallbackTitle(url: string) { + const path = url.split("#")[0]?.replaceAll(/^\/|\/$/g, "") || "Home"; + const lastSegment = path.split("/").findLast(Boolean) ?? path; + + return lastSegment + .split("-") + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +function getSnippet(text: string, query: string) { + const compactText = text.replaceAll(/\s+/g, " ").trim(); + + if (compactText.length <= SNIPPET_CONTEXT) { + return compactText; + } + + const index = compactText.toLowerCase().indexOf(query.toLowerCase()); + + if (index === -1) { + return `${compactText.slice(0, SNIPPET_CONTEXT).trim()}…`; + } + + const start = Math.max(0, index - Math.floor(SNIPPET_CONTEXT / 2)); + const end = Math.min( + compactText.length, + index + query.length + Math.floor(SNIPPET_CONTEXT / 2), + ); + const prefix = start > 0 ? "… " : ""; + const suffix = end < compactText.length ? " …" : ""; + + return `${prefix}${compactText.slice(start, end).trim()}${suffix}`; +} + +type SearchItemInput = { + entry: PagefindResultData | PagefindSubResult; + index: number; + query: string; + result: PagefindResultData; +}; + +function toSearchItem({ + entry, + index, + query, + result, +}: SearchItemInput): SearchItem { + const plainExcerpt = + entry.plain_excerpt ?? stripMarkup(entry.excerpt ?? result.excerpt ?? ""); + const url = entry.url || result.url; + const title = + "title" in entry && entry.title + ? entry.title + : (result.meta?.title ?? getFallbackTitle(url)); + + return { + description: url, + href: url, + id: `docs:${url}:${index}`, + keywords: plainExcerpt, + snippet: getSnippet(plainExcerpt, query), + title, + }; +} + +async function runPagefindSearch(query: string) { + const pagefind = await loadPagefind(); + + return ( + (await pagefind.debouncedSearch?.(query, {}, 150)) ?? + (await pagefind.search(query)) + ); +} + +async function loadSearchItems( + response: PagefindSearchResponse, + query: string, +) { + const resultData = await Promise.all( + response.results.slice(0, MAX_RESULTS).map((result) => result.data()), + ); + const items = resultData.flatMap((result, resultIndex) => { + const entries = result.sub_results?.length + ? result.sub_results.slice(0, 2) + : [result]; + + return entries.map((entry, entryIndex) => + toSearchItem({ + entry, + index: resultIndex * 10 + entryIndex, + query, + result, + }), + ); + }); + + return [...new Map(items.map((item) => [item.href, item])).values()].slice( + 0, + MAX_RESULTS, + ); +} + +export async function searchPagefind(query: string): Promise { + const trimmedQuery = query.trim(); + + if (trimmedQuery.length < 2 || typeof window === "undefined") { + return []; + } + + try { + const response = await runPagefindSearch(trimmedQuery); + return response === null + ? [] + : await loadSearchItems(response, trimmedQuery); + } catch { + return []; + } +} diff --git a/apps/registry/lib/component-metadata.json b/apps/registry/lib/component-metadata.json index bc9df8fa..2c718378 100644 --- a/apps/registry/lib/component-metadata.json +++ b/apps/registry/lib/component-metadata.json @@ -2904,13 +2904,17 @@ }, "search-dialog": { "category": "learning", - "defaultStoryId": "exampletitle--default", + "defaultStoryId": "learning-searchdialog--default", "description": "Full-screen search dialog with keyboard navigation.", "name": "search-dialog", "stories": [ { - "id": "exampletitle--default", + "id": "learning-searchdialog--default", "name": "Default" + }, + { + "id": "learning-searchdialog--with-docs", + "name": "With Docs" } ], "title": "Search Dialog" diff --git a/apps/registry/package.json b/apps/registry/package.json index e00e1b04..0a4e51da 100644 --- a/apps/registry/package.json +++ b/apps/registry/package.json @@ -5,13 +5,14 @@ "type": "module", "scripts": { "dev": "next dev", - "build": "pnpm registry:build && next build", - "clean": "rm -rf .next node_modules public/r", + "build": "pnpm registry:build && next build && pnpm search:index", + "clean": "rm -rf .next node_modules public/r public/_pagefind", "start": "next start", "lint": "eslint .", "lint:fix": "eslint . --fix", "registry:build": "tsx scripts/inline-component-source.ts && shadcn build && tsx scripts/stamp-registry-metadata.ts && tsx scripts/generate-component-metadata.ts", "registry:sync-shims": "tsx scripts/inline-component-source.ts", + "search:index": "pagefind --site .next/server/app --output-path public/_pagefind --root-selector main --force-language en", "sync-storybook": "tsx scripts/generate-component-metadata.ts" }, "dependencies": { @@ -42,6 +43,7 @@ "@vllnt/typescript": "^1.0.0", "autoprefixer": "^10.4.20", "eslint": "^9.39.1", + "pagefind": "^1.5.2", "postcss": "^8.5", "tailwindcss": "^3.4.17", "tailwindcss-animate": "^1.0.7", diff --git a/apps/registry/registry.json b/apps/registry/registry.json index 029360c6..3735d35c 100644 --- a/apps/registry/registry.json +++ b/apps/registry/registry.json @@ -4492,5 +4492,5 @@ } ], "version": "0.2.1", - "generatedAt": "2026-05-10T19:14:31.709Z" + "generatedAt": "2026-05-13T20:57:25.231Z" } diff --git a/apps/registry/registry/default/search-dialog/search-dialog.tsx b/apps/registry/registry/default/search-dialog/search-dialog.tsx index b261593a..c8134e5b 100644 --- a/apps/registry/registry/default/search-dialog/search-dialog.tsx +++ b/apps/registry/registry/default/search-dialog/search-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Search } from "lucide-react"; @@ -15,13 +15,17 @@ import { CommandList, } from "@vllnt/ui"; -type SearchItem = { +export type SearchItem = { description?: string; + href?: string; id: string; keywords?: string; + snippet?: string; title: string; }; +type SearchScope = "components" | "docs" | "everything"; + function useKeyboardShortcut(callback: () => void) { useEffect(() => { const down = (event: KeyboardEvent) => { @@ -56,83 +60,634 @@ function useKeyboardShortcut(callback: () => void) { type SearchDialogProps = { buttonText?: string; buttonTextMobile?: string; + docsEmptyText?: string; + docsGroupHeading?: string; + docsSearch?: (query: string) => Promise; emptyText?: string; enableKeyboardShortcut?: boolean; groupHeading?: string; items: SearchItem[]; + minDocsSearchLength?: number; + onDocsSelect?: (item: SearchItem) => void; onSelect: (item: SearchItem) => void; + scopeLabels?: Partial>; searchPlaceholder?: string; }; -export function SearchDialog({ - buttonText = "Search...", - buttonTextMobile = "Search...", - emptyText = "No results found.", - enableKeyboardShortcut = true, +const DEFAULT_SCOPE_LABELS: Record = { + components: "Components", + docs: "Docs", + everything: "Everything", +}; + +function getItemValue(item: SearchItem) { + return [ + item.title, + item.description, + item.snippet, + item.keywords, + item.href, + item.id, + ] + .filter(Boolean) + .join(" "); +} + +function HighlightedText({ query, text }: { query: string; text: string }) { + const trimmedQuery = query.trim(); + + if (!trimmedQuery) { + return text; + } + + const index = text.toLowerCase().indexOf(trimmedQuery.toLowerCase()); + + if (index === -1) { + return text; + } + + return ( + <> + {text.slice(0, index)} + + {text.slice(index, index + trimmedQuery.length)} + + {text.slice(index + trimmedQuery.length)} + + ); +} + +function ScopeTabs({ + labels, + onScopeChange, + scope, +}: { + labels: Record; + onScopeChange: (scope: SearchScope) => void; + scope: SearchScope; +}) { + const scopes: SearchScope[] = ["components", "docs", "everything"]; + + return ( +
+ {scopes.map((nextScope) => ( + + ))} +
+ ); +} + +function SearchResultContent({ + item, + query, +}: { + item: SearchItem; + query: string; +}) { + return ( +
+ {item.title} + {item.snippet ? ( + + + + ) : item.description ? ( + + {item.description} + + ) : null} +
+ ); +} + +function SearchTriggerButton({ + buttonText, + buttonTextMobile, + onOpen, +}: { + buttonText?: string; + buttonTextMobile?: string; + onOpen: () => void; +}) { + return ( + + ); +} + +function ComponentResultsGroup({ groupHeading, + hasDocumentationSearch, items, + labels, onSelect, - searchPlaceholder = "Search...", -}: SearchDialogProps) { - const [open, setOpen] = useState(false); + query, +}: { + groupHeading?: string; + hasDocumentationSearch: boolean; + items: SearchItem[]; + labels: Record; + onSelect: (item: SearchItem) => void; + query: string; +}) { + return ( + + {items.map((item) => ( + { + onSelect(item); + }} + value={getItemValue(item)} + > + + + ))} + + ); +} + +function DocumentationStatusItem({ + documentationSearchLength, + trimmedQuery, +}: { + documentationSearchLength: number; + trimmedQuery: string; +}) { + return ( + + + Type at least {documentationSearchLength} characters to search docs. + + + ); +} + +function DocumentationLoadingItem({ trimmedQuery }: { trimmedQuery: string }) { + return ( + + Searching docs... + + ); +} + +function DocumentationResultsGroup({ + docsGroupHeading, + documentationItems, + documentationLoading, + documentationSearchLength, + onSelect, + query, + scope, + trimmedQuery, +}: { + docsGroupHeading?: string; + documentationItems: SearchItem[]; + documentationLoading: boolean; + documentationSearchLength: number; + onSelect: (item: SearchItem) => void; + query: string; + scope: SearchScope; + trimmedQuery: string; +}) { + const showMinimumLengthPrompt = + scope === "docs" && trimmedQuery.length < documentationSearchLength; + const showDocumentationItems = + !documentationLoading && trimmedQuery.length >= documentationSearchLength; - const sortedItems = [...items].sort((a, b) => a.title.localeCompare(b.title)); + return ( + + {showMinimumLengthPrompt ? ( + + ) : null} + {documentationLoading ? ( + + ) : null} + {showDocumentationItems + ? documentationItems.map((item) => ( + { + onSelect(item); + }} + value={getItemValue(item)} + > + + + )) + : null} + + ); +} + +function SearchDialogList({ + currentEmptyText, + docsGroupHeading, + documentationItems, + documentationLoading, + documentationSearchLength, + groupHeading, + hasDocumentationSearch, + labels, + onComponentSelect, + onDocumentationSelect, + query, + scope, + showComponents, + showDocumentation, + sortedItems, + trimmedQuery, +}: { + currentEmptyText: string; + docsGroupHeading?: string; + documentationItems: SearchItem[]; + documentationLoading: boolean; + documentationSearchLength: number; + groupHeading?: string; + hasDocumentationSearch: boolean; + labels: Record; + onComponentSelect: (item: SearchItem) => void; + onDocumentationSelect: (item: SearchItem) => void; + query: string; + scope: SearchScope; + showComponents: boolean; + showDocumentation: boolean; + sortedItems: SearchItem[]; + trimmedQuery: string; +}) { + return ( + + {currentEmptyText} + {showComponents ? ( + + ) : null} + {showDocumentation ? ( + + ) : null} + + ); +} - useKeyboardShortcut(() => { - if (enableKeyboardShortcut) { +type DocumentationSearchOptions = { + docsSearch?: (query: string) => Promise; + minDocsSearchLength?: number; +}; + +function useDocumentationSearch({ + docsSearch, + minDocsSearchLength, +}: DocumentationSearchOptions) { + const [documentationItems, setDocumentationItems] = useState( + [], + ); + const [documentationLoading, setDocumentationLoading] = useState(false); + const activeDocumentationRequest = useRef(0); + const documentationSearchLength = minDocsSearchLength ?? 2; + + const runDocumentationSearch = useCallback( + (nextQuery: string, nextScope: SearchScope) => { + const nextTrimmedQuery = nextQuery.trim(); + const nextRequest = activeDocumentationRequest.current + 1; + activeDocumentationRequest.current = nextRequest; + + if ( + !docsSearch || + nextScope === "components" || + nextTrimmedQuery.length < documentationSearchLength + ) { + setDocumentationItems([]); + setDocumentationLoading(false); + return; + } + + setDocumentationLoading(true); + + docsSearch(nextTrimmedQuery) + .then((results) => { + if (activeDocumentationRequest.current === nextRequest) { + setDocumentationItems(results); + } + }) + .catch(() => { + if (activeDocumentationRequest.current === nextRequest) { + setDocumentationItems([]); + } + }) + .finally(() => { + if (activeDocumentationRequest.current === nextRequest) { + setDocumentationLoading(false); + } + }); + }, + [docsSearch, documentationSearchLength], + ); + + return { + documentationItems, + documentationLoading, + documentationSearchLength, + hasDocumentationSearch: docsSearch !== undefined, + runDocumentationSearch, + }; +} + +type SearchDialogHandlersOptions = { + enableKeyboardShortcut?: boolean; + onDocsSelect?: (item: SearchItem) => void; + onSelect: (item: SearchItem) => void; + runDocumentationSearch: (query: string, scope: SearchScope) => void; +}; + +function useSearchDialogHandlers({ + enableKeyboardShortcut, + onDocsSelect, + onSelect, + runDocumentationSearch, +}: SearchDialogHandlersOptions) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [scope, setScope] = useState("components"); + + const toggleOpen = useCallback(() => { + if (enableKeyboardShortcut ?? true) { setOpen((previous) => !previous); } - }); + }, [enableKeyboardShortcut]); - const handleSelect = (item: SearchItem) => { - setOpen(false); - onSelect(item); + useKeyboardShortcut(toggleOpen); + + const handleQueryChange = useCallback( + (nextQuery: string) => { + setQuery(nextQuery); + runDocumentationSearch(nextQuery, scope); + }, + [runDocumentationSearch, scope], + ); + + const handleScopeChange = useCallback( + (nextScope: SearchScope) => { + setScope(nextScope); + runDocumentationSearch(query, nextScope); + }, + [query, runDocumentationSearch], + ); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + setOpen(nextOpen); + + if (nextOpen) { + runDocumentationSearch(query, scope); + } + }, + [query, runDocumentationSearch, scope], + ); + + const handleComponentSelect = useCallback( + (item: SearchItem) => { + setOpen(false); + onSelect(item); + }, + [onSelect], + ); + + const handleDocumentationSelect = useCallback( + (item: SearchItem) => { + setOpen(false); + (onDocsSelect ?? onSelect)(item); + }, + [onDocsSelect, onSelect], + ); + + return { + handleComponentSelect, + handleDocumentationSelect, + handleOpenChange, + handleQueryChange, + handleScopeChange, + open, + query, + scope, }; +} +function getCurrentEmptyText({ + docsEmptyText, + documentationSearchLength, + emptyText, + scope, + trimmedQuery, +}: { + docsEmptyText?: string; + documentationSearchLength: number; + emptyText?: string; + scope: SearchScope; + trimmedQuery: string; +}) { + if (scope === "docs" && trimmedQuery.length < documentationSearchLength) { + return `Type at least ${documentationSearchLength} characters to search docs.`; + } + + if (scope === "docs") { + return docsEmptyText ?? "No docs found."; + } + + return emptyText ?? "No results found."; +} + +type SearchDialogViewProps = Pick< + SearchDialogProps, + | "buttonText" + | "buttonTextMobile" + | "docsGroupHeading" + | "groupHeading" + | "searchPlaceholder" +> & { + currentEmptyText: string; + documentationSearch: ReturnType; + handlers: ReturnType; + labels: Record; + showDocumentation: boolean; + sortedItems: SearchItem[]; + trimmedQuery: string; +}; + +function SearchDialogView({ + buttonText, + buttonTextMobile, + currentEmptyText, + docsGroupHeading, + documentationSearch, + groupHeading, + handlers, + labels, + searchPlaceholder, + showDocumentation, + sortedItems, + trimmedQuery, +}: SearchDialogViewProps) { return ( <> - - - - - {emptyText} - - {sortedItems.map((item) => ( - { - handleSelect(item); - }} - value={`${item.title} ${item.description || ""} ${item.keywords || ""} ${item.id}`} - > -
- {item.title} - {item.description ? ( - - {item.description} - - ) : null} -
-
- ))} -
-
+ + {documentationSearch.hasDocumentationSearch ? ( + + ) : null} +
); } + +export function SearchDialog({ + buttonText, + buttonTextMobile, + docsEmptyText, + docsGroupHeading, + docsSearch, + emptyText, + enableKeyboardShortcut, + groupHeading, + items, + minDocsSearchLength, + onDocsSelect, + onSelect, + scopeLabels, + searchPlaceholder, +}: SearchDialogProps) { + const documentationSearch = useDocumentationSearch({ + docsSearch, + minDocsSearchLength, + }); + const handlers = useSearchDialogHandlers({ + enableKeyboardShortcut, + onDocsSelect, + onSelect, + runDocumentationSearch: documentationSearch.runDocumentationSearch, + }); + const labels = { ...DEFAULT_SCOPE_LABELS, ...scopeLabels }; + const trimmedQuery = handlers.query.trim(); + const sortedItems = useMemo( + () => [...items].sort((a, b) => a.title.localeCompare(b.title)), + [items], + ); + const currentEmptyText = getCurrentEmptyText({ + docsEmptyText, + documentationSearchLength: documentationSearch.documentationSearchLength, + emptyText, + scope: handlers.scope, + trimmedQuery, + }); + const showDocumentation = + documentationSearch.hasDocumentationSearch && + handlers.scope !== "components"; + + return ( + + ); +} diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 7389b28d..42cd1655 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -655,7 +655,7 @@ export { } from "./share-section"; // Registry/Documentation components -export { SearchDialog } from "./search-dialog"; +export { SearchDialog, type SearchItem } from "./search-dialog"; // Theme & Language providers export { LangProvider } from "./lang-provider"; diff --git a/packages/ui/src/components/search-dialog/index.ts b/packages/ui/src/components/search-dialog/index.ts index a6e13f99..9e2b6209 100644 --- a/packages/ui/src/components/search-dialog/index.ts +++ b/packages/ui/src/components/search-dialog/index.ts @@ -1 +1 @@ -export { SearchDialog } from "./search-dialog"; +export { SearchDialog, type SearchItem } from "./search-dialog"; diff --git a/packages/ui/src/components/search-dialog/search-dialog.mdx b/packages/ui/src/components/search-dialog/search-dialog.mdx index af82ed6b..fe819eb0 100644 --- a/packages/ui/src/components/search-dialog/search-dialog.mdx +++ b/packages/ui/src/components/search-dialog/search-dialog.mdx @@ -49,12 +49,18 @@ const items = [ | ---- | ---- | -------- | ----------- | | `buttonText` | `string` | No | - | | `buttonTextMobile` | `string` | No | - | +| `docsEmptyText` | `string` | No | - | +| `docsGroupHeading` | `string` | No | - | +| `docsSearch` | `(query: string) => Promise<SearchItem[]>` | No | Optional async docs search provider | | `emptyText` | `string` | No | - | | `enableKeyboardShortcut` | `boolean` | No | `true` or `false` | | `groupHeading` | `string` | No | - | | `items` | `SearchItem[]` | Yes | Array of `SearchItem` objects | +| `minDocsSearchLength` | `number` | No | Minimum query length before docs search runs | +| `onDocsSelect` | `(item: SearchItem) => void` | No | Optional docs result callback | | `onSelect` | `(item: SearchItem) => void` | Yes | Callback: `(item: SearchItem) => void` | | `searchPlaceholder` | `string` | No | - | +| `scopeLabels` | `Partial<Record<"components" \| "docs" \| "everything", string>>` | No | Optional tab labels | ## Type Definitions @@ -63,8 +69,10 @@ const items = [ ```tsx type SearchItem = { description?: string + href?: string id: string keywords?: string + snippet?: string title: string } ``` @@ -72,7 +80,8 @@ type SearchItem = { | Field | Type | Required | Description | | ----- | ---- | -------- | ----------- | | `description` | `string` | No | - | +| `href` | `string` | No | - | | `id` | `string` | Yes | - | | `keywords` | `string` | No | - | +| `snippet` | `string` | No | - | | `title` | `string` | Yes | - | - diff --git a/packages/ui/src/components/search-dialog/search-dialog.stories.tsx b/packages/ui/src/components/search-dialog/search-dialog.stories.tsx index ad86453e..3b0b3279 100644 --- a/packages/ui/src/components/search-dialog/search-dialog.stories.tsx +++ b/packages/ui/src/components/search-dialog/search-dialog.stories.tsx @@ -1,15 +1,24 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; -import { SearchDialog } from "./search-dialog"; +import { SearchDialog, type SearchItem } from "./search-dialog"; + +function createItem( + id: string, + title: string, + description: string, +): SearchItem { + return { description, href: `/components/${id}`, id, title }; +} + +const items = [ + createItem("button", "Button", "Accessible button primitive."), + createItem("command", "Command", "Composable command menu."), +]; const meta = { args: { - items: [{ - id: "1", - title: "Example Title", - onSelect: (item) => {}, - }], - onSelect: (item) => {}, + items: items, + onSelect: () => {}, }, component: SearchDialog, title: "Learning/SearchDialog", @@ -18,4 +27,24 @@ const meta = { export default meta; type Story = StoryObj; -export const Default: Story = {}; +export const Default: Story = { + args: { + items: items, + onSelect: () => {}, + }, +}; + +export const WithDocs: Story = { + args: { + docsSearch: async () => [ + { + description: "/docs", + href: "/docs", + id: "docs", + snippet: "Install VLLNT UI and configure dark mode.", + title: "Documentation", + }, + ], + searchPlaceholder: "Search docs and components...", + }, +}; diff --git a/packages/ui/src/components/search-dialog/search-dialog.test.tsx b/packages/ui/src/components/search-dialog/search-dialog.test.tsx new file mode 100644 index 00000000..5c2ec60d --- /dev/null +++ b/packages/ui/src/components/search-dialog/search-dialog.test.tsx @@ -0,0 +1,77 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { SearchDialog, type SearchItem } from "./search-dialog"; + +const componentItems: SearchItem[] = [ + { + description: "Accessible button primitive.", + href: "/components/button", + id: "button", + title: "Button", + }, +]; + +describe("SearchDialog", () => { + it("renders docs search scopes when docs search is configured", () => { + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /search/i })); + + expect(screen.getByRole("tab", { name: "Components" })).toHaveAttribute( + "aria-selected", + "true", + ); + expect(screen.getByRole("tab", { name: "Docs" })).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "Everything" })).toBeInTheDocument(); + }); + + it("searches docs and highlights matching snippet text", async () => { + const documentationResult: SearchItem = { + description: "/docs", + href: "/docs", + id: "docs:/docs", + snippet: "Use dark mode themes in production.", + title: "Documentation", + }; + const documentationSearch = vi + .fn() + .mockResolvedValue([documentationResult]); + const onDocumentationSelect = vi.fn(); + + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /search/i })); + fireEvent.click(screen.getByRole("tab", { name: "Docs" })); + fireEvent.change( + screen.getByPlaceholderText("Search docs and components..."), + { + target: { value: "dark" }, + }, + ); + + await waitFor(() => { + expect(documentationSearch).toHaveBeenCalledWith("dark"); + }); + + await screen.findByText("Documentation"); + expect(screen.getByText("dark").tagName).toBe("MARK"); + + fireEvent.click(screen.getByText("Documentation")); + expect(onDocumentationSelect).toHaveBeenCalledWith(documentationResult); + }); +}); diff --git a/packages/ui/src/components/search-dialog/search-dialog.tsx b/packages/ui/src/components/search-dialog/search-dialog.tsx index b2a7b7f6..451ca7b4 100644 --- a/packages/ui/src/components/search-dialog/search-dialog.tsx +++ b/packages/ui/src/components/search-dialog/search-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Search } from "lucide-react"; @@ -15,13 +15,17 @@ import { CommandList, } from "../command"; -type SearchItem = { +export type SearchItem = { description?: string; + href?: string; id: string; keywords?: string; + snippet?: string; title: string; }; +type SearchScope = "components" | "docs" | "everything"; + function useKeyboardShortcut(callback: () => void) { useEffect(() => { const down = (event: KeyboardEvent) => { @@ -56,83 +60,634 @@ function useKeyboardShortcut(callback: () => void) { type SearchDialogProps = { buttonText?: string; buttonTextMobile?: string; + docsEmptyText?: string; + docsGroupHeading?: string; + docsSearch?: (query: string) => Promise; emptyText?: string; enableKeyboardShortcut?: boolean; groupHeading?: string; items: SearchItem[]; + minDocsSearchLength?: number; + onDocsSelect?: (item: SearchItem) => void; onSelect: (item: SearchItem) => void; + scopeLabels?: Partial>; searchPlaceholder?: string; }; -export function SearchDialog({ - buttonText = "Search...", - buttonTextMobile = "Search...", - emptyText = "No results found.", - enableKeyboardShortcut = true, +const DEFAULT_SCOPE_LABELS: Record = { + components: "Components", + docs: "Docs", + everything: "Everything", +}; + +function getItemValue(item: SearchItem) { + return [ + item.title, + item.description, + item.snippet, + item.keywords, + item.href, + item.id, + ] + .filter(Boolean) + .join(" "); +} + +function HighlightedText({ query, text }: { query: string; text: string }) { + const trimmedQuery = query.trim(); + + if (!trimmedQuery) { + return text; + } + + const index = text.toLowerCase().indexOf(trimmedQuery.toLowerCase()); + + if (index === -1) { + return text; + } + + return ( + <> + {text.slice(0, index)} + + {text.slice(index, index + trimmedQuery.length)} + + {text.slice(index + trimmedQuery.length)} + + ); +} + +function ScopeTabs({ + labels, + onScopeChange, + scope, +}: { + labels: Record; + onScopeChange: (scope: SearchScope) => void; + scope: SearchScope; +}) { + const scopes: SearchScope[] = ["components", "docs", "everything"]; + + return ( +
+ {scopes.map((nextScope) => ( + + ))} +
+ ); +} + +function SearchResultContent({ + item, + query, +}: { + item: SearchItem; + query: string; +}) { + return ( +
+ {item.title} + {item.snippet ? ( + + + + ) : item.description ? ( + + {item.description} + + ) : null} +
+ ); +} + +function SearchTriggerButton({ + buttonText, + buttonTextMobile, + onOpen, +}: { + buttonText?: string; + buttonTextMobile?: string; + onOpen: () => void; +}) { + return ( + + ); +} + +function ComponentResultsGroup({ groupHeading, + hasDocumentationSearch, items, + labels, onSelect, - searchPlaceholder = "Search...", -}: SearchDialogProps) { - const [open, setOpen] = useState(false); + query, +}: { + groupHeading?: string; + hasDocumentationSearch: boolean; + items: SearchItem[]; + labels: Record; + onSelect: (item: SearchItem) => void; + query: string; +}) { + return ( + + {items.map((item) => ( + { + onSelect(item); + }} + value={getItemValue(item)} + > + + + ))} + + ); +} + +function DocumentationStatusItem({ + documentationSearchLength, + trimmedQuery, +}: { + documentationSearchLength: number; + trimmedQuery: string; +}) { + return ( + + + Type at least {documentationSearchLength} characters to search docs. + + + ); +} + +function DocumentationLoadingItem({ trimmedQuery }: { trimmedQuery: string }) { + return ( + + Searching docs... + + ); +} + +function DocumentationResultsGroup({ + docsGroupHeading, + documentationItems, + documentationLoading, + documentationSearchLength, + onSelect, + query, + scope, + trimmedQuery, +}: { + docsGroupHeading?: string; + documentationItems: SearchItem[]; + documentationLoading: boolean; + documentationSearchLength: number; + onSelect: (item: SearchItem) => void; + query: string; + scope: SearchScope; + trimmedQuery: string; +}) { + const showMinimumLengthPrompt = + scope === "docs" && trimmedQuery.length < documentationSearchLength; + const showDocumentationItems = + !documentationLoading && trimmedQuery.length >= documentationSearchLength; - const sortedItems = [...items].sort((a, b) => a.title.localeCompare(b.title)); + return ( + + {showMinimumLengthPrompt ? ( + + ) : null} + {documentationLoading ? ( + + ) : null} + {showDocumentationItems + ? documentationItems.map((item) => ( + { + onSelect(item); + }} + value={getItemValue(item)} + > + + + )) + : null} + + ); +} + +function SearchDialogList({ + currentEmptyText, + docsGroupHeading, + documentationItems, + documentationLoading, + documentationSearchLength, + groupHeading, + hasDocumentationSearch, + labels, + onComponentSelect, + onDocumentationSelect, + query, + scope, + showComponents, + showDocumentation, + sortedItems, + trimmedQuery, +}: { + currentEmptyText: string; + docsGroupHeading?: string; + documentationItems: SearchItem[]; + documentationLoading: boolean; + documentationSearchLength: number; + groupHeading?: string; + hasDocumentationSearch: boolean; + labels: Record; + onComponentSelect: (item: SearchItem) => void; + onDocumentationSelect: (item: SearchItem) => void; + query: string; + scope: SearchScope; + showComponents: boolean; + showDocumentation: boolean; + sortedItems: SearchItem[]; + trimmedQuery: string; +}) { + return ( + + {currentEmptyText} + {showComponents ? ( + + ) : null} + {showDocumentation ? ( + + ) : null} + + ); +} - useKeyboardShortcut(() => { - if (enableKeyboardShortcut) { +type DocumentationSearchOptions = { + docsSearch?: (query: string) => Promise; + minDocsSearchLength?: number; +}; + +function useDocumentationSearch({ + docsSearch, + minDocsSearchLength, +}: DocumentationSearchOptions) { + const [documentationItems, setDocumentationItems] = useState( + [], + ); + const [documentationLoading, setDocumentationLoading] = useState(false); + const activeDocumentationRequest = useRef(0); + const documentationSearchLength = minDocsSearchLength ?? 2; + + const runDocumentationSearch = useCallback( + (nextQuery: string, nextScope: SearchScope) => { + const nextTrimmedQuery = nextQuery.trim(); + const nextRequest = activeDocumentationRequest.current + 1; + activeDocumentationRequest.current = nextRequest; + + if ( + !docsSearch || + nextScope === "components" || + nextTrimmedQuery.length < documentationSearchLength + ) { + setDocumentationItems([]); + setDocumentationLoading(false); + return; + } + + setDocumentationLoading(true); + + docsSearch(nextTrimmedQuery) + .then((results) => { + if (activeDocumentationRequest.current === nextRequest) { + setDocumentationItems(results); + } + }) + .catch(() => { + if (activeDocumentationRequest.current === nextRequest) { + setDocumentationItems([]); + } + }) + .finally(() => { + if (activeDocumentationRequest.current === nextRequest) { + setDocumentationLoading(false); + } + }); + }, + [docsSearch, documentationSearchLength], + ); + + return { + documentationItems, + documentationLoading, + documentationSearchLength, + hasDocumentationSearch: docsSearch !== undefined, + runDocumentationSearch, + }; +} + +type SearchDialogHandlersOptions = { + enableKeyboardShortcut?: boolean; + onDocsSelect?: (item: SearchItem) => void; + onSelect: (item: SearchItem) => void; + runDocumentationSearch: (query: string, scope: SearchScope) => void; +}; + +function useSearchDialogHandlers({ + enableKeyboardShortcut, + onDocsSelect, + onSelect, + runDocumentationSearch, +}: SearchDialogHandlersOptions) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [scope, setScope] = useState("components"); + + const toggleOpen = useCallback(() => { + if (enableKeyboardShortcut ?? true) { setOpen((previous) => !previous); } - }); + }, [enableKeyboardShortcut]); - const handleSelect = (item: SearchItem) => { - setOpen(false); - onSelect(item); + useKeyboardShortcut(toggleOpen); + + const handleQueryChange = useCallback( + (nextQuery: string) => { + setQuery(nextQuery); + runDocumentationSearch(nextQuery, scope); + }, + [runDocumentationSearch, scope], + ); + + const handleScopeChange = useCallback( + (nextScope: SearchScope) => { + setScope(nextScope); + runDocumentationSearch(query, nextScope); + }, + [query, runDocumentationSearch], + ); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + setOpen(nextOpen); + + if (nextOpen) { + runDocumentationSearch(query, scope); + } + }, + [query, runDocumentationSearch, scope], + ); + + const handleComponentSelect = useCallback( + (item: SearchItem) => { + setOpen(false); + onSelect(item); + }, + [onSelect], + ); + + const handleDocumentationSelect = useCallback( + (item: SearchItem) => { + setOpen(false); + (onDocsSelect ?? onSelect)(item); + }, + [onDocsSelect, onSelect], + ); + + return { + handleComponentSelect, + handleDocumentationSelect, + handleOpenChange, + handleQueryChange, + handleScopeChange, + open, + query, + scope, }; +} +function getCurrentEmptyText({ + docsEmptyText, + documentationSearchLength, + emptyText, + scope, + trimmedQuery, +}: { + docsEmptyText?: string; + documentationSearchLength: number; + emptyText?: string; + scope: SearchScope; + trimmedQuery: string; +}) { + if (scope === "docs" && trimmedQuery.length < documentationSearchLength) { + return `Type at least ${documentationSearchLength} characters to search docs.`; + } + + if (scope === "docs") { + return docsEmptyText ?? "No docs found."; + } + + return emptyText ?? "No results found."; +} + +type SearchDialogViewProps = Pick< + SearchDialogProps, + | "buttonText" + | "buttonTextMobile" + | "docsGroupHeading" + | "groupHeading" + | "searchPlaceholder" +> & { + currentEmptyText: string; + documentationSearch: ReturnType; + handlers: ReturnType; + labels: Record; + showDocumentation: boolean; + sortedItems: SearchItem[]; + trimmedQuery: string; +}; + +function SearchDialogView({ + buttonText, + buttonTextMobile, + currentEmptyText, + docsGroupHeading, + documentationSearch, + groupHeading, + handlers, + labels, + searchPlaceholder, + showDocumentation, + sortedItems, + trimmedQuery, +}: SearchDialogViewProps) { return ( <> - - - - - {emptyText} - - {sortedItems.map((item) => ( - { - handleSelect(item); - }} - value={`${item.title} ${item.description || ""} ${item.keywords || ""} ${item.id}`} - > -
- {item.title} - {item.description ? ( - - {item.description} - - ) : null} -
-
- ))} -
-
+ + {documentationSearch.hasDocumentationSearch ? ( + + ) : null} +
); } + +export function SearchDialog({ + buttonText, + buttonTextMobile, + docsEmptyText, + docsGroupHeading, + docsSearch, + emptyText, + enableKeyboardShortcut, + groupHeading, + items, + minDocsSearchLength, + onDocsSelect, + onSelect, + scopeLabels, + searchPlaceholder, +}: SearchDialogProps) { + const documentationSearch = useDocumentationSearch({ + docsSearch, + minDocsSearchLength, + }); + const handlers = useSearchDialogHandlers({ + enableKeyboardShortcut, + onDocsSelect, + onSelect, + runDocumentationSearch: documentationSearch.runDocumentationSearch, + }); + const labels = { ...DEFAULT_SCOPE_LABELS, ...scopeLabels }; + const trimmedQuery = handlers.query.trim(); + const sortedItems = useMemo( + () => [...items].sort((a, b) => a.title.localeCompare(b.title)), + [items], + ); + const currentEmptyText = getCurrentEmptyText({ + docsEmptyText, + documentationSearchLength: documentationSearch.documentationSearchLength, + emptyText, + scope: handlers.scope, + trimmedQuery, + }); + const showDocumentation = + documentationSearch.hasDocumentationSearch && + handlers.scope !== "components"; + + return ( + + ); +} diff --git a/packages/ui/src/components/search-dialog/search-dialog.visual.tsx b/packages/ui/src/components/search-dialog/search-dialog.visual.tsx index 078e80d8..3ac39c2d 100644 --- a/packages/ui/src/components/search-dialog/search-dialog.visual.tsx +++ b/packages/ui/src/components/search-dialog/search-dialog.visual.tsx @@ -4,7 +4,12 @@ import { SearchDialog } from "./search-dialog"; test.describe("SearchDialog Visual", () => { test("default", async ({ mount, page }) => { - await mount(); + await mount( + {}} + />, + ); await expect(page).toHaveScreenshot("search-dialog-default.png"); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40e57535..34c7e8b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,7 +67,7 @@ importers: version: 10.1.0(@types/react@19.2.13)(react@19.2.4) shadcn: specifier: canary - version: 3.5.1-canary.0(@types/node@22.19.10)(typescript@5.9.3) + version: 4.2.0-canary.0(@types/node@22.19.10)(typescript@5.9.3) tailwind-merge: specifier: ^3.3.1 version: 3.4.0 @@ -102,6 +102,9 @@ importers: eslint: specifier: ^9.39.1 version: 9.39.2(jiti@1.21.7) + pagefind: + specifier: ^1.5.2 + version: 1.5.2 postcss: specifier: ^8.5 version: 8.5.6 @@ -368,10 +371,6 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@antfu/ni@25.0.0': - resolution: {integrity: sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA==} - hasBin: true - '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} @@ -1676,6 +1675,41 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@pagefind/darwin-arm64@1.5.2': + resolution: {integrity: sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==} + cpu: [arm64] + os: [darwin] + + '@pagefind/darwin-x64@1.5.2': + resolution: {integrity: sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw==} + cpu: [x64] + os: [darwin] + + '@pagefind/freebsd-x64@1.5.2': + resolution: {integrity: sha512-7EVzo9+0w+2cbe671BtMj10UlNo83I+HrLVLfRxO731svHRJKUfJ/mo05gU14pe9PCfpKNQT8FS3Xc/oDN6pOA==} + cpu: [x64] + os: [freebsd] + + '@pagefind/linux-arm64@1.5.2': + resolution: {integrity: sha512-Ovt9+K35sqzn8H3ZMXGwls4TD/wMJuvRtShHIsmUQREmaxjrDEX7gHckRCrwYJ4XE1H1p6HkLz3wukrAnsfXQw==} + cpu: [arm64] + os: [linux] + + '@pagefind/linux-x64@1.5.2': + resolution: {integrity: sha512-V+tFqHKXhQKq/WqPBD67AFy7scn1/aZID00ws4fSDd+1daSi5UHR9VVlRrOUYKxn3VuFQYRD7lYXdZK1WED1YA==} + cpu: [x64] + os: [linux] + + '@pagefind/windows-arm64@1.5.2': + resolution: {integrity: sha512-hN9Nh90fNW61nNRCW9ZyQrAj/mD0eRvmJ8NlTUzkbuW8kIzGJUi3cxjFkEcMZ5h/8FsKWD/VcouZl4yo1F7B6g==} + cpu: [arm64] + os: [win32] + + '@pagefind/windows-x64@1.5.2': + resolution: {integrity: sha512-Fa2Iyw7kaDRzGMfNYNUXNW2zbL5FQVDgSOcbDHdzBrDEdpqOqg8TcZ68F22ol6NJ9IGzvUdmeyZypLW5dyhqsg==} + cpu: [x64] + os: [win32] + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -2835,6 +2869,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/validate-npm-package-name@4.0.2': + resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} + '@types/wait-on@5.3.4': resolution: {integrity: sha512-EBsPjFMrFlMbbUFf9D1Fp+PAB2TwmUn7a3YtHyD9RLuTIk1jDd8SxXVAoez2Ciy+8Jsceo2MYEYZzJ/DvorOKw==} @@ -3293,10 +3330,6 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} - ansis@4.2.0: - resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} - engines: {node: '>=14'} - any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -4608,9 +4641,6 @@ packages: fuzzysort@3.1.0: resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==} - fzf@0.5.2: - resolution: {integrity: sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==} - generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -4999,6 +5029,10 @@ packages: eslint: '*' typescript: '>=4.7.4' + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + is-inside-container@1.0.0: resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} engines: {node: '>=14.16'} @@ -5908,6 +5942,10 @@ packages: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -5958,8 +5996,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - package-manager-detector@1.6.0: - resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pagefind@1.5.2: + resolution: {integrity: sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q==} + hasBin: true parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} @@ -6128,6 +6167,10 @@ packages: resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} engines: {node: '>=4'} + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} @@ -6139,6 +6182,10 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -6525,8 +6572,8 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shadcn@3.5.1-canary.0: - resolution: {integrity: sha512-aw+JbFI0RxoxHdr2Q+vON6zCC6GggKQjOU1U/R1LNrNzDhXEnk6DKtdXZtN+Ej1oRadCAOV7yDMbi4jber/7Ig==} + shadcn@4.2.0-canary.0: + resolution: {integrity: sha512-J8dSM3vMuOSJjvIkgja1JMqF3i8yAVGh/aFLx2yrC8Pm2F4nA2071rfhiJLmH10zo42AB8hQKYyrJIKniDhA9A==} hasBin: true sharp@0.34.5: @@ -7144,6 +7191,7 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-to-istanbul@9.3.0: @@ -7158,6 +7206,10 @@ packages: typescript: optional: true + validate-npm-package-name@7.0.2: + resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} + engines: {node: ^20.17.0 || >=22.9.0} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -7420,6 +7472,10 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} @@ -7509,13 +7565,6 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@antfu/ni@25.0.0': - dependencies: - ansis: 4.2.0 - fzf: 0.5.2 - package-manager-detector: 1.6.0 - tinyexec: 1.0.2 - '@asamuzakjp/css-color@3.2.0': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) @@ -8734,6 +8783,27 @@ snapshots: '@open-draft/until@2.1.0': {} + '@pagefind/darwin-arm64@1.5.2': + optional: true + + '@pagefind/darwin-x64@1.5.2': + optional: true + + '@pagefind/freebsd-x64@1.5.2': + optional: true + + '@pagefind/linux-arm64@1.5.2': + optional: true + + '@pagefind/linux-x64@1.5.2': + optional: true + + '@pagefind/windows-arm64@1.5.2': + optional: true + + '@pagefind/windows-x64@1.5.2': + optional: true + '@pkgjs/parseargs@0.11.0': optional: true @@ -9936,6 +10006,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/validate-npm-package-name@4.0.2': {} + '@types/wait-on@5.3.4': dependencies: '@types/node': 22.19.10 @@ -10435,8 +10507,6 @@ snapshots: ansi-styles@6.2.3: {} - ansis@4.2.0: {} - any-promise@1.3.0: {} anymatch@3.1.3: @@ -11953,8 +12023,6 @@ snapshots: fuzzysort@3.1.0: {} - fzf@0.5.2: {} - generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} @@ -12383,6 +12451,8 @@ snapshots: transitivePeerDependencies: - supports-color + is-in-ssh@1.0.0: {} + is-inside-container@1.0.0: dependencies: is-docker: 3.0.0 @@ -13774,6 +13844,15 @@ snapshots: is-inside-container: 1.0.0 wsl-utils: 0.1.0 + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -13836,7 +13915,15 @@ snapshots: package-json-from-dist@1.0.1: {} - package-manager-detector@1.6.0: {} + pagefind@1.5.2: + optionalDependencies: + '@pagefind/darwin-arm64': 1.5.2 + '@pagefind/darwin-x64': 1.5.2 + '@pagefind/freebsd-x64': 1.5.2 + '@pagefind/linux-arm64': 1.5.2 + '@pagefind/linux-x64': 1.5.2 + '@pagefind/windows-arm64': 1.5.2 + '@pagefind/windows-x64': 1.5.2 parent-module@1.0.1: dependencies: @@ -13972,6 +14059,11 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + postcss-value-parser@4.2.0: {} postcss@8.4.31: @@ -13986,6 +14078,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + powershell-utils@0.1.0: {} + prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.1: @@ -14494,15 +14588,15 @@ snapshots: setprototypeof@1.2.0: {} - shadcn@3.5.1-canary.0(@types/node@22.19.10)(typescript@5.9.3): + shadcn@4.2.0-canary.0(@types/node@22.19.10)(typescript@5.9.3): dependencies: - '@antfu/ni': 25.0.0 '@babel/core': 7.29.0 '@babel/parser': 7.29.0 '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) '@dotenvx/dotenvx': 1.52.0 '@modelcontextprotocol/sdk': 1.26.0(zod@3.25.76) + '@types/validate-npm-package-name': 4.0.2 browserslist: 4.28.1 commander: 14.0.3 cosmiconfig: 9.0.0(typescript@5.9.3) @@ -14517,13 +14611,17 @@ snapshots: kleur: 4.1.5 msw: 2.12.9(@types/node@22.19.10)(typescript@5.9.3) node-fetch: 3.3.2 + open: 11.0.0 ora: 8.2.0 postcss: 8.5.6 + postcss-selector-parser: 7.1.1 prompts: 2.4.2 recast: 0.23.11 stringify-object: 5.0.0 + tailwind-merge: 3.4.0 ts-morph: 26.0.0 tsconfig-paths: 4.2.0 + validate-npm-package-name: 7.0.2 zod: 3.25.76 zod-to-json-schema: 3.25.1(zod@3.25.76) transitivePeerDependencies: @@ -15271,6 +15369,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + validate-npm-package-name@7.0.2: {} + vary@1.1.2: {} vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): @@ -15515,6 +15615,11 @@ snapshots: dependencies: is-wsl: 3.1.1 + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + xml-name-validator@5.0.0: {} xml@1.0.1: {} diff --git a/turbo.json b/turbo.json index 4661c108..7cc67799 100644 --- a/turbo.json +++ b/turbo.json @@ -4,7 +4,13 @@ "tasks": { "build": { "dependsOn": ["^build"], - "outputs": [".next/**", "!.next/cache/**", "dist/**", "public/r/**"] + "outputs": [ + ".next/**", + "!.next/cache/**", + "dist/**", + "public/r/**", + "public/_pagefind/**" + ] }, "clean": { "cache": false