Skip to content
Merged
8 changes: 8 additions & 0 deletions website/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
184 changes: 184 additions & 0 deletions website/src/components/Navigation/DocsSearch.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof MiniSearch>[0];
documents: Record<string, unknown>[];
}

const DocsSearch: FC = () => {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [miniSearch, setMiniSearch] = useState<MiniSearch | null>(null);
const [isLoading, setIsLoading] = useState(true);
const searchRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<div ref={searchRef} className='mb-4 relative'>
<div className='relative'>
<div className='flex items-center bg-primary-50 border border-primary-200 rounded-md px-3 py-2 focus-within:border-primary-400'>
<SearchIcon className='w-4 h-4 text-primary-600 flex-shrink-0' aria-hidden='true' />
<DisabledUntilHydrated>
<input
ref={inputRef}
type='text'
placeholder='Search documentation...'
value={query}
onChange={(e) => {
setQuery(e.target.value);
setIsOpen(true);
}}
onFocus={() => setIsOpen(true)}
className='flex-1 ml-2 bg-transparent border-none text-sm text-gray-700 placeholder-primary-500 focus:outline-none focus:ring-0 disabled:cursor-not-allowed'
aria-label='Search documentation'
/>
</DisabledUntilHydrated>
{query && (
<Button
type='button'
onClick={() => {
setQuery('');
setResults([]);
inputRef.current?.focus();
}}
className='text-primary-600 hover:text-primary-700 p-1'
aria-label='Clear search'
>
<CloseIcon className='w-4 h-4' />
</Button>
)}
</div>

{isOpen && query && (
<div className='absolute top-full left-0 right-0 mt-1 bg-white border border-primary-200 rounded-md shadow-lg z-50 max-h-96 overflow-y-auto'>
{isLoading ? (
<div className='px-4 py-3 text-sm text-gray-500'>Loading search…</div>
) : results.length === 0 ? (
<div className='px-4 py-3 text-sm text-gray-500'>No results found</div>
) : (
<>
{docsResults.length > 0 && (
<div>
<div className='px-4 py-2 bg-gray-50 border-b border-primary-200 text-xs font-semibold text-primary-700 uppercase'>
Documentation
</div>
<ul className='py-1'>
{docsResults.map((result) => (
<li key={result.id}>
<a
href={result.url}
className='block px-4 py-2 text-sm text-gray-700 hover:bg-primary-50 transition-colors'
onClick={() => {
setQuery('');
setIsOpen(false);
}}
>
{result.title}
</a>
</li>
))}
</ul>
</div>
)}

{aboutResults.length > 0 && (
<div>
<div className='px-4 py-2 bg-gray-50 border-b border-primary-200 text-xs font-semibold text-primary-700 uppercase'>
About
</div>
<ul className='py-1'>
{aboutResults.map((result) => (
<li key={result.id}>
<a
href={result.url}
className='block px-4 py-2 text-sm text-gray-700 hover:bg-primary-50 transition-colors'
onClick={() => {
setQuery('');
setIsOpen(false);
}}
>
{result.title}
</a>
</li>
))}
</ul>
</div>
)}
</>
)}
</div>
)}
</div>
</div>
);
};

export default DocsSearch;
6 changes: 5 additions & 1 deletion website/src/layouts/AboutLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -12,7 +13,10 @@ const currentPageUrl = Astro.url.pathname;

<BaseLayout title={frontmatter.title} activeTopNavigationItem={activeTopNavigationItem}>
<div class='md:flex md:justify-between max-w-5xl mx-auto items-start gap-10'>
<DocsMenu docsPages={aboutPages} currentPageUrl={currentPageUrl} title='About' client:load />
<div class='md:w-64 md:flex-none'>
<DocsSearch client:load />
<DocsMenu docsPages={aboutPages} currentPageUrl={currentPageUrl} title='About' client:load />
</div>
<div class='mdContainer mdContainerItself text-gray-700 leading-relaxed'>
<h1>{frontmatter.title}</h1>

Expand Down
6 changes: 5 additions & 1 deletion website/src/layouts/DocLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -19,7 +20,10 @@ const editUrl = `${gitHubEditLink}${fileLocation}`;

<BaseLayout title={frontmatter.title} activeTopNavigationItem={activeTopNavigationItem}>
<div class='md:flex md:justify-between max-w-5xl mx-auto items-start gap-10'>
<DocsMenu docsPages={docsPages} currentPageUrl={currentPageUrl} title='Documentation' client:load />
<div class='md:w-64 md:flex-none'>
<DocsSearch client:load />
<DocsMenu docsPages={docsPages} currentPageUrl={currentPageUrl} title='Documentation' client:load />
</div>
<div class='mdContainer mdContainerItself text-gray-700 leading-relaxed'>
<h1>{frontmatter.title}</h1>
<slot />
Expand Down
23 changes: 23 additions & 0 deletions website/src/pages/search-index.json.ts
Original file line number Diff line number Diff line change
@@ -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 */
85 changes: 85 additions & 0 deletions website/src/utils/buildSearchIndex.ts
Original file line number Diff line number Diff line change
@@ -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<typeof MiniSearch>[0];
documents: SearchDocument[];
}

const docsRaw = import.meta.glob<string>('../pages/docs/**/*.mdx', {
eager: true,
query: '?raw',
import: 'default',
});
const aboutRaw = import.meta.glob<string>('../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<string, unknown>; 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<string, unknown>;
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<string, string>, 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')}"`;
Loading