diff --git a/website/package-lock.json b/website/package-lock.json index c7f404c810..cf2be2b6d5 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -35,6 +35,7 @@ "just-kebab-case": "^4.2.0", "jwks-rsa": "^4.0.1", "luxon": "^3.7.2", + "minisearch": "^6.3.0", "neverthrow": "^8.2.0", "openid-client": "^5.7.1", "papaparse": "^5.5.3", @@ -48,6 +49,7 @@ "sanitize-html": "^2.17.4", "unplugin-icons": "^22.5.0", "winston": "^3.19.0", + "yaml": "^2.4.5", "zod": "^3.25.67" }, "devDependencies": { @@ -11849,6 +11851,12 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/minisearch": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-6.3.0.tgz", + "integrity": "sha512-ihFnidEeU8iXzcVHy74dhkxh/dn8Dc08ERl0xwoMMGqp4+LvRSCgicb+zGqWthVokQKvCSxITlh3P08OzdTYCQ==", + "license": "MIT" + }, "node_modules/mlly": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", diff --git a/website/package.json b/website/package.json index c4fea1d263..a8b38d5d38 100644 --- a/website/package.json +++ b/website/package.json @@ -20,6 +20,8 @@ "dependencies": { "@astrojs/mdx": "^4.3.13", "@astrojs/node": "^9.5.2", + "minisearch": "^6.3.0", + "yaml": "^2.4.5", "@emotion/react": "^11.14.0", "@headlessui/react": "^2.2.0", "@lokalise/xlsx": "^0.20.3", diff --git a/website/src/components/Navigation/DocsSearch.tsx b/website/src/components/Navigation/DocsSearch.tsx new file mode 100644 index 0000000000..4c7227217a --- /dev/null +++ b/website/src/components/Navigation/DocsSearch.tsx @@ -0,0 +1,184 @@ +import MiniSearch from 'minisearch'; +import { useState, useEffect, useRef, type FC } from 'react'; + +import DisabledUntilHydrated from '../DisabledUntilHydrated'; +import { Button } from '../common/Button'; +import CloseIcon from '~icons/material-symbols/close'; +import SearchIcon from '~icons/material-symbols/search'; + +interface SearchResult { + id: string; + title: string; + url: string; + section: 'docs' | 'about'; + score?: number; +} + +interface SearchIndexResponse { + options: ConstructorParameters[0]; + documents: Record[]; +} + +const DocsSearch: FC = () => { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [isOpen, setIsOpen] = useState(false); + const [miniSearch, setMiniSearch] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const searchRef = useRef(null); + const inputRef = useRef(null); + + // Load and initialize the search index + useEffect(() => { + const loadIndex = async () => { + try { + const response = await fetch('/search-index.json'); + const data = (await response.json()) as SearchIndexResponse; + const ms = new MiniSearch(data.options); + ms.addAll(data.documents); + setMiniSearch(ms); + } catch { + // Search remains unavailable until reload. + } finally { + setIsLoading(false); + } + }; + + void loadIndex(); + }, []); + + // Handle search + useEffect(() => { + if (!miniSearch || !query.trim()) { + setResults([]); + return; + } + + try { + const searchResults = miniSearch.search(query, { + prefix: true, + fuzzy: 0.2, + }) as unknown as SearchResult[]; + setResults(searchResults.slice(0, 8)); + } catch { + setResults([]); + } + }, [query, miniSearch]); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (searchRef.current && !searchRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Group results by section + const docsResults = results.filter((r) => r.section === 'docs'); + const aboutResults = results.filter((r) => r.section === 'about'); + + return ( +
+
+
+
+ + {isOpen && query && ( +
+ {isLoading ? ( +
Loading search…
+ ) : results.length === 0 ? ( +
No results found
+ ) : ( + <> + {docsResults.length > 0 && ( +
+
+ Documentation +
+ +
+ )} + + {aboutResults.length > 0 && ( +
+
+ About +
+ +
+ )} + + )} +
+ )} +
+
+ ); +}; + +export default DocsSearch; diff --git a/website/src/layouts/AboutLayout.astro b/website/src/layouts/AboutLayout.astro index e30e7eee73..05ad014d8c 100644 --- a/website/src/layouts/AboutLayout.astro +++ b/website/src/layouts/AboutLayout.astro @@ -2,6 +2,7 @@ import '../styles/mdcontainer.scss'; import BaseLayout from './BaseLayout.astro'; import DocsMenu from '../components/Navigation/DocsMenu'; +import DocsSearch from '../components/Navigation/DocsSearch'; import type { MdxPage } from '../types/mdxTypes'; const { frontmatter } = Astro.props; @@ -12,7 +13,10 @@ const currentPageUrl = Astro.url.pathname;
- +
+ + +

{frontmatter.title}

diff --git a/website/src/layouts/DocLayout.astro b/website/src/layouts/DocLayout.astro index 0b20830b00..4be5eadd80 100644 --- a/website/src/layouts/DocLayout.astro +++ b/website/src/layouts/DocLayout.astro @@ -2,6 +2,7 @@ import '../styles/mdcontainer.scss'; import BaseLayout from './BaseLayout.astro'; import DocsMenu from '../components/Navigation/DocsMenu'; +import DocsSearch from '../components/Navigation/DocsSearch'; import { getWebsiteConfig } from '../config'; import type { MdxPage } from '../types/mdxTypes'; import MdiGithub from '~icons/mdi/github'; @@ -19,7 +20,10 @@ const editUrl = `${gitHubEditLink}${fileLocation}`;
- +
+ + +

{frontmatter.title}

diff --git a/website/src/pages/search-index.json.ts b/website/src/pages/search-index.json.ts new file mode 100644 index 0000000000..abcbb16cad --- /dev/null +++ b/website/src/pages/search-index.json.ts @@ -0,0 +1,23 @@ +import { searchIndexETag, searchIndexJson } from '../utils/buildSearchIndex'; + +/* eslint-disable @typescript-eslint/naming-convention -- HTTP header names */ +export function GET({ request }: { request: Request }) { + if (request.headers.get('if-none-match') === searchIndexETag) { + return new Response(null, { + status: 304, + headers: { + 'ETag': searchIndexETag, + 'Cache-Control': 'public, max-age=3600', + }, + }); + } + + return new Response(searchIndexJson, { + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'public, max-age=3600', + 'ETag': searchIndexETag, + }, + }); +} +/* eslint-enable @typescript-eslint/naming-convention */ diff --git a/website/src/utils/buildSearchIndex.ts b/website/src/utils/buildSearchIndex.ts new file mode 100644 index 0000000000..4213d5ad25 --- /dev/null +++ b/website/src/utils/buildSearchIndex.ts @@ -0,0 +1,85 @@ +import { createHash } from 'node:crypto'; + +import type MiniSearch from 'minisearch'; +import { parse as parseYaml } from 'yaml'; + +interface SearchDocument { + id: string; + title: string; + section: 'docs' | 'about'; + url: string; + content: string; +} + +interface SearchIndexPayload { + options: ConstructorParameters[0]; + documents: SearchDocument[]; +} + +const docsRaw = import.meta.glob('../pages/docs/**/*.mdx', { + eager: true, + query: '?raw', + import: 'default', +}); +const aboutRaw = import.meta.glob('../pages/about/**/*.mdx', { + eager: true, + query: '?raw', + import: 'default', +}); + +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/; + +const splitFrontmatter = (content: string): { frontmatter: Record; body: string } => { + const match = FRONTMATTER_RE.exec(content); + if (!match) return { frontmatter: {}, body: content }; + let parsed: unknown = {}; + try { + parsed = parseYaml(match[1]) ?? {}; + } catch { + parsed = {}; + } + const frontmatter = (typeof parsed === 'object' && parsed !== null ? parsed : {}) as Record; + return { frontmatter, body: content.slice(match[0].length) }; +}; + +const stripMarkdown = (body: string): string => + body + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/^#{1,6}\s+/gm, '') + .replace(/```[\s\S]*?```/g, '') + .replace(/`([^`]+)`/g, '$1') + .replace(/\s+/g, ' ') + .trim(); + +const pathToUrl = (modulePath: string): string => { + // ../pages/docs/how-to/revise-submissions.mdx -> /docs/how-to/revise-submissions + // ../pages/docs/how-to/index.mdx -> /docs/how-to + const fromPages = modulePath.split('/pages/')[1]; + return '/' + fromPages.replace(/\.mdx$/, '').replace(/\/index$/, ''); +}; + +const buildDocuments = (modules: Record, section: 'docs' | 'about'): SearchDocument[] => { + return Object.entries(modules).map(([modulePath, raw], i) => { + const { frontmatter, body } = splitFrontmatter(raw); + const title = + typeof frontmatter.title === 'string' && frontmatter.title.length > 0 ? frontmatter.title : 'Untitled'; + return { + id: `${section}-${i}`, + title, + section, + url: pathToUrl(modulePath), + content: stripMarkdown(body), + }; + }); +}; + +const payload: SearchIndexPayload = { + options: { + fields: ['title', 'content'], + storeFields: ['title', 'url', 'section'], + }, + documents: [...buildDocuments(docsRaw, 'docs'), ...buildDocuments(aboutRaw, 'about')], +}; + +export const searchIndexJson: string = JSON.stringify(payload); +export const searchIndexETag: string = `"${createHash('sha1').update(searchIndexJson).digest('hex')}"`;