From 5822e4944ad8fda721a40d9f9d7d7614421e0d03 Mon Sep 17 00:00:00 2001 From: Emma Hodcroft Date: Fri, 22 May 2026 16:01:26 +0200 Subject: [PATCH 1/9] add minisearch & yaml to deps --- website/package.json | 2 ++ 1 file changed, 2 insertions(+) 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", From 0bc96e12931351ad3f139751bd4899a74bd9a4ea Mon Sep 17 00:00:00 2001 From: theosanderson-agent Date: Fri, 22 May 2026 15:09:08 +0100 Subject: [PATCH 2/9] chore(website): regenerate package-lock for minisearch dep Adds the missing minisearch entry to package-lock.json so `npm ci` no longer fails. yaml was already present as a transitive dep so this is mostly a no-op for yaml (just adds it to the root deps list). --- website/package-lock.json | 8 ++++++++ 1 file changed, 8 insertions(+) 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", From b773fe7ab7b3eaaa916ea5de944aad7c9b7ece24 Mon Sep 17 00:00:00 2001 From: theosanderson-agent Date: Fri, 22 May 2026 16:11:59 +0100 Subject: [PATCH 3/9] feat(website): add docs/about search bar Adds a MiniSearch-powered client-side search that's exposed as a bar at the top of the docs and about menu. - buildSearchIndex.ts pulls MDX content at build time via import.meta.glob('?raw') so Vite bundles the source into the server output. The {options, documents} payload is JSON.stringify-ed once when the Node module loads; the HTTP route just hands the precomputed string back, so there's no per-request work and no need for Cache-Control / ETag headers. - Frontmatter titles are parsed with the yaml lib. - DocLayout.astro and AboutLayout.astro render above the existing DocsMenu. This lands a feature that was originally drafted in pathoplexus PR pathoplexus/pathoplexus#974; moving the impl to Loculus so downstream consumers automatically get the same behaviour without overlay files. --- .../src/components/Navigation/DocsSearch.tsx | 183 ++++++++++++++++++ website/src/layouts/AboutLayout.astro | 6 +- website/src/layouts/DocLayout.astro | 6 +- website/src/pages/search-index.json.ts | 8 + website/src/utils/buildSearchIndex.ts | 83 ++++++++ 5 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 website/src/components/Navigation/DocsSearch.tsx create mode 100644 website/src/pages/search-index.json.ts create mode 100644 website/src/utils/buildSearchIndex.ts diff --git a/website/src/components/Navigation/DocsSearch.tsx b/website/src/components/Navigation/DocsSearch.tsx new file mode 100644 index 0000000000..5cb3e0fb66 --- /dev/null +++ b/website/src/components/Navigation/DocsSearch.tsx @@ -0,0 +1,183 @@ +import MiniSearch from 'minisearch'; +import { useState, useEffect, useRef, type FC } from 'react'; + +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 ( +
+
+
+
+ + {/* Results dropdown */} + {isOpen && query && ( +
+ {results.length === 0 ? ( +
No results found
+ ) : ( + <> + {docsResults.length > 0 && ( +
+
+ Documentation +
+ +
+ )} + + {aboutResults.length > 0 && ( +
+
+ About +
+ +
+ )} + + )} +
+ )} +
+ + {isLoading &&
Loading search...
} +
+ ); +}; + +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..a6438c2c45 --- /dev/null +++ b/website/src/pages/search-index.json.ts @@ -0,0 +1,8 @@ +import { searchIndexJson } from '../utils/buildSearchIndex'; + +export function GET() { + return new Response(searchIndexJson, { + // eslint-disable-next-line @typescript-eslint/naming-convention -- HTTP header name + headers: { 'Content-Type': 'application/json' }, + }); +} diff --git a/website/src/utils/buildSearchIndex.ts b/website/src/utils/buildSearchIndex.ts new file mode 100644 index 0000000000..e7ae51e027 --- /dev/null +++ b/website/src/utils/buildSearchIndex.ts @@ -0,0 +1,83 @@ +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 = /^---\n([\s\S]*?)\n---\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(//g, '') + .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); From c4bd4a01f49aa5aec0712285ba752098994a1abf Mon Sep 17 00:00:00 2001 From: theosanderson-agent Date: Fri, 22 May 2026 16:23:42 +0100 Subject: [PATCH 4/9] fix(website): handle CRLF in mdx + drop layout-shift loading text - buildSearchIndex frontmatter regex tolerated only LF line endings, so mdx files saved with CRLF (e.g. pathoplexus' about/governance/data-submission.mdx) failed to parse and showed up as "Untitled". - DocsSearch was rendering a "Loading search..." line below the input while the index was loading, which shifted the page content as the text appeared and disappeared. Move that indicator into the absolutely-positioned results dropdown so it only renders when the user is actually searching (no CLS). --- website/src/components/Navigation/DocsSearch.tsx | 9 ++++----- website/src/utils/buildSearchIndex.ts | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/website/src/components/Navigation/DocsSearch.tsx b/website/src/components/Navigation/DocsSearch.tsx index 5cb3e0fb66..3577f35466 100644 --- a/website/src/components/Navigation/DocsSearch.tsx +++ b/website/src/components/Navigation/DocsSearch.tsx @@ -95,8 +95,7 @@ const DocsSearch: FC = () => { setIsOpen(true); }} onFocus={() => setIsOpen(true)} - disabled={isLoading} - className='flex-1 ml-2 bg-transparent border-none outline-none text-sm text-gray-700 placeholder-primary-500 disabled:opacity-50' + className='flex-1 ml-2 bg-transparent border-none outline-none text-sm text-gray-700 placeholder-primary-500' aria-label='Search documentation' /> {query && ( @@ -115,10 +114,11 @@ const DocsSearch: FC = () => { )}
- {/* Results dropdown */} {isOpen && query && (
- {results.length === 0 ? ( + {isLoading ? ( +
Loading search…
+ ) : results.length === 0 ? (
No results found
) : ( <> @@ -175,7 +175,6 @@ const DocsSearch: FC = () => { )}
- {isLoading &&
Loading search...
}
); }; diff --git a/website/src/utils/buildSearchIndex.ts b/website/src/utils/buildSearchIndex.ts index e7ae51e027..b665c47fec 100644 --- a/website/src/utils/buildSearchIndex.ts +++ b/website/src/utils/buildSearchIndex.ts @@ -25,7 +25,7 @@ const aboutRaw = import.meta.glob('../pages/about/**/*.mdx', { import: 'default', }); -const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/; +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); From c565f441692d9f0e7a41b46faf27320a43d48ef8 Mon Sep 17 00:00:00 2001 From: theosanderson-agent Date: Fri, 22 May 2026 16:31:21 +0100 Subject: [PATCH 5/9] fix(website): remove default browser focus ring on docs search input The bare input rendered the browser's default blue focus outline. Add focus:outline-none focus:ring-0 to suppress it, and give the surrounding wrapper a focus-within:border-primary-400 so the search bar still has a clear themed focus indicator. --- website/src/components/Navigation/DocsSearch.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/website/src/components/Navigation/DocsSearch.tsx b/website/src/components/Navigation/DocsSearch.tsx index 3577f35466..b70f73b0e3 100644 --- a/website/src/components/Navigation/DocsSearch.tsx +++ b/website/src/components/Navigation/DocsSearch.tsx @@ -83,7 +83,7 @@ const DocsSearch: FC = () => { return (
-
+
)}
-
); }; From fa7a73250ab6e71baa73fdebb58b96a7adb0b163 Mon Sep 17 00:00:00 2001 From: Theo Sanderson Date: Fri, 22 May 2026 16:32:02 +0100 Subject: [PATCH 6/9] Potential fix for pull request finding 'CodeQL / Incomplete multi-character sanitization' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- website/src/utils/buildSearchIndex.ts | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/website/src/utils/buildSearchIndex.ts b/website/src/utils/buildSearchIndex.ts index b665c47fec..a93f1cb6a8 100644 --- a/website/src/utils/buildSearchIndex.ts +++ b/website/src/utils/buildSearchIndex.ts @@ -40,15 +40,23 @@ const splitFrontmatter = (content: string): { frontmatter: Record - body - .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') - .replace(/^#{1,6}\s+/gm, '') - .replace(/```[\s\S]*?```/g, '') - .replace(/`([^`]+)`/g, '$1') - .replace(//g, '') - .replace(/\s+/g, ' ') - .trim(); +const stripMarkdown = (body: string): string => { + let current = body; + let previous: string; + do { + previous = current; + current = current + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') + .replace(/^#{1,6}\s+/gm, '') + .replace(/```[\s\S]*?```/g, '') + .replace(/`([^`]+)`/g, '$1') + .replace(//g, '') + .replace(/\s+/g, ' ') + .trim(); + } while (current !== previous); + + return current; +}; const pathToUrl = (modulePath: string): string => { // ../pages/docs/how-to/revise-submissions.mdx -> /docs/how-to/revise-submissions From 96dec2f56dee6cfba18b41b980510cc66cb66c68 Mon Sep 17 00:00:00 2001 From: theosanderson-agent Date: Fri, 22 May 2026 16:38:18 +0100 Subject: [PATCH 7/9] drop html-comment regex from stripMarkdown The content field is only fed into MiniSearch for text matching and is never rendered as HTML (search results show titles only), so the multi-pass HTML-comment sanitization Copilot autofixed wasn't buying any safety and CodeQL still flagged it. Removing the rule entirely keeps the function single-pass and resolves the alert. --- website/src/utils/buildSearchIndex.ts | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/website/src/utils/buildSearchIndex.ts b/website/src/utils/buildSearchIndex.ts index a93f1cb6a8..576e95a3db 100644 --- a/website/src/utils/buildSearchIndex.ts +++ b/website/src/utils/buildSearchIndex.ts @@ -40,23 +40,14 @@ const splitFrontmatter = (content: string): { frontmatter: Record { - let current = body; - let previous: string; - do { - previous = current; - current = current - .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') - .replace(/^#{1,6}\s+/gm, '') - .replace(/```[\s\S]*?```/g, '') - .replace(/`([^`]+)`/g, '$1') - .replace(//g, '') - .replace(/\s+/g, ' ') - .trim(); - } while (current !== previous); - - return current; -}; +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 From d207b2a2d8b4cafe6fe5d79ef0abedbe3b1d96bd Mon Sep 17 00:00:00 2001 From: theosanderson-agent Date: Fri, 22 May 2026 16:44:06 +0100 Subject: [PATCH 8/9] fix(website): gate docs search input on client hydration website/AGENTS.md asks interactive elements to be wrapped with DisabledUntilHydrated (or gated on useClientFlag) so Playwright / hydration races can't drop early keystrokes. Wrap the search input accordingly. Codex review flagged this. --- .../src/components/Navigation/DocsSearch.tsx | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/website/src/components/Navigation/DocsSearch.tsx b/website/src/components/Navigation/DocsSearch.tsx index b70f73b0e3..4c7227217a 100644 --- a/website/src/components/Navigation/DocsSearch.tsx +++ b/website/src/components/Navigation/DocsSearch.tsx @@ -1,6 +1,7 @@ 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'; @@ -85,19 +86,21 @@ const DocsSearch: FC = () => {