import flexsearch, { type CreateOptions } from 'flexsearch' export type IndexOptions = { data?: (string | null)[][] } & CreateOptions export function createIndex({ data = [], ...options }: IndexOptions = {}) { const index = new flexsearch.Document({ preset: 'memory', // optimize: true, // resolution: 9, charset: 'latin:simple', language: 'en', // encode: 'extra', fastupdate: false, ...options, document: { id: 'id', // tag: 'category', index: [ { field: 'title', tokenize: 'forward', }, { field: 'content', tokenize: 'strict', minlength: 2, }, ], }, }) for (const [key, value] of data) { index.import(key, value) } return index } export function createSearch({ dev, store, currentVersion = typeof __SVELTEKIT_APP_VERSION__ === 'string' ? __SVELTEKIT_APP_VERSION__ : undefined, ...options }: { dev?: boolean currentVersion?: string store: { href: string section: string label: string category: string title: string content: string }[] } & IndexOptions) { const index = createIndex({ cache: 50, ...options }) // const categories = ['guides', 'packages', 'api'] return (request: Request) => { const term = new URL(request.url).searchParams.get('q') || '' const expectedVersion = request.headers.get('x-app-version') const matchingVersion = expectedVersion && currentVersion && expectedVersion === currentVersion const needsUpdate = expectedVersion && currentVersion && expectedVersion !== currentVersion const results = term ? index .search(term, 35, { suggest: true }) .flatMap((x) => x.result) .slice(0, 35) .map((id) => { const value = store[id] return { href: value.href, section: value.section, label: value.label === value.title ? undefined : value.label, category: value.category, title: excerpt(value.title, term), excerpt: excerpt(value.content, term), } }) : // TODO: sort by section? // title:guides // content:guides // title:packages // content:packages // title:api // content:api // .sort((a, b) => { // const byCategory = categories.indexOf(a.category) - categories.indexOf(b.category) // if (byCategory) return byCategory // return 0 // }) [] return new Response(JSON.stringify({ term, results, needsUpdate: needsUpdate || undefined }), { headers: { 'content-type': 'application/json', vary: 'x-app-version', 'cache-control': dev ? 'private, no-store' : matchingVersion ? // if current app version matches the client app version the response is immutable 'public, max-age=604800, immutable' : // if different versions clients must re-validate 'public, max-age=0, must-revalidate', }, }) } } function escape(text: string) { return text.replace(//g, '>') } function excerpt(content: string, query: string) { if (!query) { return escape(content) } const index = content.toLowerCase().indexOf(query.toLowerCase()) if (index === -1) { return escape(content.slice(0, 100)) } const startIndex = index <= 20 ? 0 : content.lastIndexOf(' ', index - 15) const prefix = startIndex <= 5 ? content.slice(0, index) : `…${content.slice(startIndex, index)}` const lastIndex = index + query.length + (80 - (prefix.length + query.length)) const endIndex = content.indexOf(' ', lastIndex) const suffix = content.slice(index + query.length, endIndex > 0 ? endIndex : lastIndex) return ( escape(prefix) + `${escape(content.slice(index, index + query.length))}` + escape(suffix) ) }