(search) highlight found search terms

This commit is contained in:
Valentyne Stigloher 2024-12-16 16:52:43 +01:00
parent e8f5f22ba7
commit 9d15cb1672
2 changed files with 53 additions and 4 deletions

View File

@ -31,7 +31,7 @@ const searchInput = useTemplateRef('searchInput');
</section> </section>
<section> <section>
<ul class="list-group"> <ul class="list-group">
<li v-for="result of results.data.value" :key="result.id" class="list-group-item"> <li v-for="result of results.data.value" :key="result.url" class="list-group-item">
<nuxt-link :to="result.url" class="d-flex text-dark"> <nuxt-link :to="result.url" class="d-flex text-dark">
<div class="col-auto pt-1 pe-3"> <div class="col-auto pt-1 pe-3">
<Icon v="pen-nib" class="h3" /> <Icon v="pen-nib" class="h3" />
@ -46,7 +46,8 @@ const searchInput = useTemplateRef('searchInput');
> >
</div> </div>
<div class="col ps-2"> <div class="col ps-2">
<Spelling class="h4" :text="result.title" /> <Spelling class="d-block h4" :text="result.title" />
<Spelling :text="result.fragment" />
<ul class="list-inline mb-0 small"> <ul class="list-inline mb-0 small">
<li class="list-inline-item small"> <li class="list-inline-item small">
<Icon v="calendar" /> <Icon v="calendar" />

View File

@ -3,6 +3,7 @@ import fs from 'node:fs/promises';
import { JSDOM } from 'jsdom'; import { JSDOM } from 'jsdom';
import marked from 'marked'; import marked from 'marked';
import MiniSearch from 'minisearch'; import MiniSearch from 'minisearch';
import type { MatchInfo } from 'minisearch';
import { getPosts, type PostMetadata } from '~/server/blog.ts'; import { getPosts, type PostMetadata } from '~/server/blog.ts';
import { loadSuml, loadSumlFromBase } from '~/server/loader.ts'; import { loadSuml, loadSumlFromBase } from '~/server/loader.ts';
@ -21,6 +22,41 @@ interface SearchDocumentPost extends PostMetadata {
content: string; content: string;
} }
const getTermsByField = (matches: MatchInfo): Record<string, string[]> => {
const termsByField: Record<string, string[]> = {};
for (const [term, fields] of Object.entries(matches)) {
if (term.length === 1) {
continue;
}
for (const field of fields) {
if (!Object.hasOwn(termsByField, field)) {
termsByField[field] = [];
}
termsByField[field].push(term);
}
}
return termsByField;
};
const FRAGMENT_MAX_WORDCOUNT = 24;
const highlightMatches = (field: string, terms: string[] | undefined, fragment: boolean = false): string => {
const termsRegex = terms && terms.length > 0 ? new RegExp(`\\b(${terms.join('|')})\\b`, 'ig') : undefined;
if (fragment) {
const words = field.split(' ');
const firstMatch = termsRegex !== undefined ? words.findIndex((word) => word.match(termsRegex)) : 0;
const start = Math.max(Math.min(firstMatch - 2, words.length - FRAGMENT_MAX_WORDCOUNT), 0);
const end = Math.min(start + FRAGMENT_MAX_WORDCOUNT, words.length);
field = `${start > 0 ? '[…] ' : ''}${words.slice(start, end).join(' ')}${end < words.length ? ' […]' : ''}`;
}
if (termsRegex === undefined) {
return field;
}
return field.replaceAll(termsRegex, `<mark>$1</mark>`);
};
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const index = new MiniSearch<SearchDocumentPost>({ const index = new MiniSearch<SearchDocumentPost>({
fields: ['url', 'title', 'content'], fields: ['url', 'title', 'content'],
@ -29,7 +65,9 @@ export default defineEventHandler(async (event) => {
const posts: SearchDocumentPost[] = []; const posts: SearchDocumentPost[] = [];
for (const post of (await getPosts())) { for (const post of (await getPosts())) {
const content = await fs.readFile(`${rootDir}/data/blog/${post.slug}.md`, 'utf-8'); const content = await fs.readFile(`${rootDir}/data/blog/${post.slug}.md`, 'utf-8');
const markdown = marked(content); // exclude title, date and author from searchable content
const trimmed = content.replace(/^(.+\n+){2}/, '');
const markdown = marked(trimmed);
const parsed = await parseMarkdown(markdown, translator); const parsed = await parseMarkdown(markdown, translator);
const text = JSDOM.fragment(parsed.content ?? '').textContent; const text = JSDOM.fragment(parsed.content ?? '').textContent;
if (text !== null && config.links.enabled && config.links.blog) { if (text !== null && config.links.enabled && config.links.blog) {
@ -50,6 +88,16 @@ export default defineEventHandler(async (event) => {
const text = query.text as string; const text = query.text as string;
const results = index.search(text, { prefix: true, fuzzy: 1 }); const results = index.search(text, { prefix: true, fuzzy: 1 });
return results.map((result) => { return results.map((result) => {
return posts[result.id]; const post = posts[result.id];
const termsByField = getTermsByField(result.match);
return {
url: post.url,
title: highlightMatches(post.title, termsByField.title),
date: post.date,
authors: post.authors,
hero: post.hero,
fragment: highlightMatches(post.content, termsByField.content, true),
termsByField,
};
}); });
}); });