diff --git a/.changeset/stale-eels-sing.md b/.changeset/stale-eels-sing.md new file mode 100644 index 00000000..258f2615 --- /dev/null +++ b/.changeset/stale-eels-sing.md @@ -0,0 +1,5 @@ +--- +"vocs": patch +--- + +Added `search` config option. diff --git a/site/pages/docs/api/config.mdx b/site/pages/docs/api/config.mdx index 45dfa14b..064abb8b 100644 --- a/site/pages/docs/api/config.mdx +++ b/site/pages/docs/api/config.mdx @@ -518,6 +518,25 @@ export default defineConfig({ }) ``` +### search + +- **Type:** `Search` + +Search configuration. Accepts [`MiniSearch` options](https://lucaong.github.io/minisearch/types/MiniSearch.SearchOptions.html). + +```tsx twoslash +import { defineConfig } from 'vocs' + +export default defineConfig({ + search: { // [!code focus] + boostDocument(documentId) { // [!code focus] + return documentId === '/core' ? 2 : 1 // [!code focus] + } // [!code focus] + }, // [!code focus] + title: 'Viem' +}) +``` + ### sidebar - **Type:** `Sidebar` diff --git a/src/app/components/SearchDialog.tsx b/src/app/components/SearchDialog.tsx index 8b6d994f..be9858a4 100644 --- a/src/app/components/SearchDialog.tsx +++ b/src/app/components/SearchDialog.tsx @@ -2,9 +2,9 @@ import * as Dialog from '@radix-ui/react-dialog' import { ArrowLeftIcon, ChevronRightIcon, + FileIcon, ListBulletIcon, MagnifyingGlassIcon, - FileIcon, } from '@radix-ui/react-icons' import * as Label from '@radix-ui/react-label' import clsx from 'clsx' @@ -13,6 +13,7 @@ import { type SearchResult } from 'minisearch' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Link, useNavigate } from 'react-router-dom' +import { useConfig } from '../hooks/useConfig.js' import { useDebounce } from '../hooks/useDebounce.js' import { useLocalStorage } from '../hooks/useLocalStorage.js' import { type Result, useSearchIndex } from '../hooks/useSearchIndex.js' @@ -22,6 +23,7 @@ import { KeyboardShortcut } from './KeyboardShortcut.js' import * as styles from './SearchDialog.css.js' export function SearchDialog(props: { open: boolean; onClose(): void }) { + const { search: searchOptions } = useConfig() const navigate = useNavigate() const inputRef = useRef(null) const listRef = useRef(null) @@ -41,8 +43,8 @@ export function SearchDialog(props: { open: boolean; onClose(): void }) { return [] } setSelectedIndex(0) - return searchIndex.search(searchTerm).slice(0, 16) as (SearchResult & Result)[] - }, [searchIndex, searchTerm]) + return searchIndex.search(searchTerm, searchOptions).slice(0, 16) as (SearchResult & Result)[] + }, [searchIndex, searchOptions, searchTerm]) const resultsCount = results.length const selectedResult = results[selectedIndex] diff --git a/src/app/hooks/useConfig.tsx b/src/app/hooks/useConfig.tsx index a6606e4f..46c53647 100644 --- a/src/app/hooks/useConfig.tsx +++ b/src/app/hooks/useConfig.tsx @@ -1,19 +1,19 @@ import { sha256 } from '@noble/hashes/sha256' import { bytesToHex } from '@noble/hashes/utils' import { type ReactNode, createContext, useContext, useEffect, useState } from 'react' -import type { ParsedConfig } from '../../config.js' +import { type ParsedConfig, deserializeConfig, serializeConfig } from '../../config.js' import { config as virtualConfig } from 'virtual:config' const ConfigContext = createContext(virtualConfig) export const configHash = import.meta.env.DEV - ? bytesToHex(sha256(JSON.stringify(virtualConfig))).slice(0, 8) + ? bytesToHex(sha256(serializeConfig(virtualConfig))).slice(0, 8) : '' export function getConfig(): ParsedConfig { if (typeof window !== 'undefined' && import.meta.env.DEV) { const storedConfig = window.localStorage.getItem(`vocs.config.${configHash}`) - if (storedConfig) return JSON.parse(storedConfig) + if (storedConfig) return deserializeConfig(storedConfig) } return virtualConfig } @@ -33,7 +33,7 @@ export function ConfigProvider({ useEffect(() => { if (typeof window !== 'undefined' && import.meta.env.DEV) - window.localStorage.setItem(`vocs.config.${configHash}`, JSON.stringify(config)) + window.localStorage.setItem(`vocs.config.${configHash}`, serializeConfig(config)) }, [config]) return {children} diff --git a/src/config.ts b/src/config.ts index 89de8f7b..915a9ffe 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,5 @@ import type { RehypeShikiOptions } from '@shikijs/rehype' +import type { SearchOptions } from 'minisearch' import type { ReactElement } from 'react' import type { TwoslashOptions } from 'twoslash' import type { PluggableList } from 'unified' @@ -117,6 +118,10 @@ export type Config< * @default "docs" */ rootDir?: string + /** + * Configuration for docs search. + */ + search?: Normalize /** * Navigation displayed on the sidebar. */ @@ -439,6 +444,8 @@ export type Markdown = RequiredBy< parsed extends true ? 'code' : never > +export type Search = SearchOptions + export type SidebarItem = { /** Whether or not to collapse the sidebar item by default. */ collapsed?: boolean @@ -551,3 +558,66 @@ export type ParsedTopNavItem = TopNavItem & { export type TopNav = parsed extends true ? ParsedTopNavItem[] : TopNavItem[] + +////////////////////////////////////////////////////// +// Utilities + +export function serializeConfig(config: Config) { + return JSON.stringify(serializeFunctions(config)) +} + +export function deserializeConfig(config: string) { + return deserializeFunctions(JSON.parse(config)) +} + +export function serializeFunctions(value: any, key?: string): any { + if (Array.isArray(value)) { + return value.map((v) => serializeFunctions(v)) + } else if (typeof value === 'object' && value !== null) { + return Object.keys(value).reduce((acc, key) => { + if (key[0] === '_') return acc + acc[key] = serializeFunctions(value[key], key) + return acc + }, {} as any) + } else if (typeof value === 'function') { + let serialized = value.toString() + if (key && (serialized.startsWith(key) || serialized.startsWith(`async ${key}`))) { + serialized = serialized.replace(key, 'function') + } + return `_vocs-fn_${serialized}` + } else { + return value + } +} + +export function deserializeFunctions(value: any): any { + if (Array.isArray(value)) { + return value.map(deserializeFunctions) + } else if (typeof value === 'object' && value !== null) { + return Object.keys(value).reduce((acc: any, key) => { + acc[key] = deserializeFunctions(value[key]) + return acc + }, {}) + } else if (typeof value === 'string' && value.includes('_vocs-fn_')) { + return new Function(`return ${value.slice(9)}`)() + } else { + return value + } +} + +export const deserializeFunctionsStringified = ` + function deserializeFunctions(value) { + if (Array.isArray(value)) { + return value.map(deserializeFunctions) + } else if (typeof value === 'object' && value !== null) { + return Object.keys(value).reduce((acc, key) => { + acc[key] = deserializeFunctions(value[key]) + return acc + }, {}) + } else if (typeof value === 'string' && value.includes('_vocs-fn_')) { + return new Function(\`return \${value.slice(9)}\`)() + } else { + return value + } + } +` diff --git a/src/vite/plugins/virtual-config.ts b/src/vite/plugins/virtual-config.ts index 6dffd57c..4824859e 100644 --- a/src/vite/plugins/virtual-config.ts +++ b/src/vite/plugins/virtual-config.ts @@ -1,5 +1,6 @@ import { type PluginOption } from 'vite' +import { deserializeFunctionsStringified, serializeConfig } from '../../config.js' import { resolveVocsConfig } from '../utils/resolveVocsConfig.js' export function virtualConfig(): PluginOption { @@ -25,8 +26,10 @@ export function virtualConfig(): PluginOption { async load(id) { if (id === resolvedVirtualModuleId) { const { config } = await resolveVocsConfig() - // TODO: serialize fns - return `export const config = ${JSON.stringify(config)}` + return ` + ${deserializeFunctionsStringified} + + export const config = deserializeFunctions(${serializeConfig(config)})` } return },