My Website

Search

    flex-search

    search-worker.ts

    This Web worker creates and searches an index.

    import { createIndex, searchIndex } from './search.ts';
    import contents from '$lib/data/flex-search-input.json';
    import type { Result } from './search.ts';
    
    import { StopWatch } from '$lib/stopwatch.js';
    
    // Listen for messages
    addEventListener('message', async (e) => {
        const { type, payload } = e.data;
        console.log('type', type);
    
        if (type === 'load') {
            const sw = new StopWatch();
            sw.start();
    
            // Create search index.
            createIndex(contents);
    
            sw.elapsedMs('built index');
    
            // Tell requester FlexSearch is ready.
            postMessage({ type: 'ready' });
        }
    
        if (type === 'search_en') {
            const searchTerm = payload.searchTerm;
    
            // Search posts index.
            const results = searchIndex('en', searchTerm);
    
            // Send message with results and search term.
            postMessage({ type: 'results', payload: { results, searchTerm } });
        }
    
        if (type === 'search_es') {
            // Set search term.
            const searchTerm = payload.searchTerm;
    
            // Search posts index.
            const results = searchIndex('es', searchTerm);
    
            // const pageResults = results?.filter((result: Result) => result.isPage);
            // const postResults = results?.filter((result: Result) => !result.isPage);
            // const allResults = [...pageResults, ...postResults];
    
            // Send message with results and search term.
            postMessage({ type: 'results', payload: { results, searchTerm } });
        }
    });
    

    SearchButton.svelte

    This Svelte component presents a pseudo search button with the logic to invoke a search.

    <script lang="ts">
        import { onMount } from 'svelte';
        import { browser } from '$app/environment';
        //import { createPostsIndex, searchPostsIndex, type Result } from '$lib/search'
        import SearchWorker from '$lib/search-worker.ts?worker';
    
        import { type Result } from '$lib/search';
    
        import { StopWatch } from '$lib/stopwatch.js';
    
        let searchButton: HTMLButtonElement | null = null;
    
        // Svelete runes reactivity governs the search dialog behavior.
        let search: 'idle' | 'load' | 'ready' = $state('idle');
        let searchTerm = $state('');
    
        // The search results are displayed when results has at least one element.
        let results = $state([]);
    
        let searchWorker: Worker;
        let popoverElement: HTMLDivElement | null = null;
    
        interface ToggleEvent extends Event {
            newState: 'open' | 'closed';
            oldState: 'open' | 'closed';
        }
    
        function sortSearchResults() {
            if (results && results.length > 0) {
                results.sort((a: Result, b: Result) => {
                    // Primary sort: isPage (true comes first)
                    if (a.isPage !== b.isPage) {
                        return b.isPage ? 1 : -1; // Changed the logic here
                    }
                    // Secondary sort: title alphabetically
                    return a.title.localeCompare(b.title);
                });
            }
        }
    
        /*
    	 | Hardcoded for testing! Change for production.
    	 */
        const locale: string = 'en';
    
        onMount(() => {
            // Create FlexSearch web worker.
            searchWorker = new SearchWorker();
            // Listen for messages from the worker.
            searchWorker.addEventListener('message', (e) => {
                const { type, payload } = e.data;
                // When worker is ready, set the search state to ready.
                if (type === 'ready') {
                    search = 'ready';
                }
                // After worker creates search results get them.
                if (type === 'results') {
                    results = payload.results;
                    if (results) {
                        console.log($state.snapshot(results[0]));
                        sortSearchResults(results);
                    }
                }
            });
    
            // Initialize the FlexSearch web worker when the component mounts.
            searchWorker.postMessage({ type: 'load' });
    
            popoverElement?.addEventListener('beforetoggle', (event: Event) => {
                if ((event as ToggleEvent).newState === 'open') {
                    //document.body.setAttribute('inert', 'true')
                    searchTerm = '';
                } else {
                    //document.body.removeAttribute('inert')
                }
            });
    
            // The autofocus attribute causes issues with Sveltekit
            // accessibility rules. This is a workaround.
            popoverElement?.addEventListener('toggle', (event: Event) => {
                if ((event as ToggleEvent).newState === 'open') {
                    popoverElement?.querySelector('input')?.focus();
                }
            });
    
            //console.log(popoverElement)
        });
    
        $effect(() => {
            /*
    		 | Search initiated here.
    		 */
            // When search is ready and the search term changes send a
            // search message to the worker.
    
            if (search === 'ready') {
                if (locale === 'en') {
                    searchWorker.postMessage({ type: 'search_en', payload: { searchTerm } });
                } else {
                    searchWorker.postMessage({ type: 'search_es', payload: { searchTerm } });
                }
            }
        });
    
        if (browser) {
            // Listen for the Ctrl + K key combination to open the search dialog.
            window.addEventListener('keydown', (event) => {
                if (event.ctrlKey && (event.key === 'k' || event.key === 'K')) {
                    event.preventDefault();
                    searchButton?.click();
                }
            });
        }
    </script>
    
    <div class="search-button-container">
        <button
            type="button"
            class=""
            bind:this={searchButton}
            aria-label="search"
            popovertarget="search-results"
            popovertargetaction="show"
        >
            <svg width="24" height="24" fill="none" aria-hidden="true" class="mr-3 flex-none">
                <path
                    d="m19 19-3.5-3.5"
                    stroke="currentColor"
                    stroke-width="2"
                    stroke-linecap="round"
                    stroke-linejoin="round"
                />
                <circle
                    cx="11"
                    cy="11"
                    r="6"
                    stroke="currentColor"
                    stroke-width="2"
                    stroke-linecap="round"
                    stroke-linejoin="round"
                />
            </svg>Search...
            <span class="keystroke-container">
                <span class="keystroke">Ctrl</span> <span class="keystroke k">K</span>
            </span>
        </button>
    </div>
    
    <!-- {#if search === 'ready'} -->
    <div bind:this={popoverElement} popover="auto" id="search-results" class="popover">
        <div class="search">
            <input bind:value={searchTerm} placeholder="Search" autocomplete="off" spellcheck="false" type="search" />
    
            <div class="results">
                {#if results}
                    <ul>
                        {#each results as result}
                            <li>
                                <!-- <div>path here</div> -->
                                <a href={result?.slug}>
                                    {@html result?.title}
                                    {result?.isPage}
                                </a>
                                <!-- <div class="search-result-tags">{@html result.tags}</div> -->
                                <div>
                                    <a href={result?.slug}>
                                        {@html result.description}
                                    </a>
                                </div>
                            </li>
                        {/each}
                    </ul>
                {/if}
            </div>
        </div>
    </div>
    
    <!-- {/if} -->
    
    <style>
        div.popover {
            background-color: transparent;
            border-radius: 15px;
            padding: 0;
            /* outline: 1px solid red; */
        }
    
        :global(body) {
            font-family: 'Manrope', sans-serif;
            font-size: 1.5rem;
            color: hsl(220 10% 98%);
            background-color: hsl(220 10% 10%);
        }
    
        :global(span.search-result-tag) {
            color: darkslategray;
            background-color: aqua;
            font-size: 0.9rem;
            padding: 4px 8px;
            border-radius: 0.4rem;
        }
    
        :global(.search-results-tags-wrapper) {
            margin-top: 0.6rem;
            display: flex;
            flex-wrap: wrap;
            gap: 0.5rem;
        }
    
        :global(mark) {
            /* background-color: hsl(128 71.1% 82.4%);
    		font */
            color: yellow;
            background-color: transparent;
            font-style: italic;
            /* color: hsl(220 10% 98%); */
            border-radius: 5px;
        }
    
        .search {
            width: 90vw;
            top: 6rem;
            position: fixed;
            left: 50%;
            transform: translateX(-50%);
            max-width: 700px;
            border-radius: 0.5rem;
            box-shadow: 0px 0px 20px hsl(0 0% 0% / 40%);
            overflow: hidden;
    
            & input {
                width: 100%;
                padding: 1.5rem;
                color: hsl(220 10% 98%);
                background-color: hsl(220 10% 20%);
                font: inherit;
                border: none;
                outline: none;
            }
        }
    
        .results {
            max-height: 48vh;
            padding: 1.5rem;
            background-color: hsl(220 10% 14%);
            overflow-y: auto;
            scrollbar-width: thin;
    
            & ul {
                display: grid;
                gap: 1rem;
                padding: 0px;
                margin: 0px;
                list-style: none;
    
                & li p {
                    color: silver;
                    font-size: 1.2rem;
                }
    
                & li:not(:last-child) {
                    padding-block: 0.5rem;
                    border-bottom: 1px solid hsl(220 10% 20%);
                }
            }
    
            & a {
                display: block;
                font-size: 1.5rem;
                color: hsl(220 10% 80%);
                text-decoration: none;
                transition: color 0.3s ease;
    
                &:hover {
                    color: aqua;
                }
    
                &:focus {
                    outline: none;
                    /* background-color: gray; */
                    text-decoration: underline;
                }
            }
        }
    
        div.search-button-container {
            pointer-events: auto;
    
            & button {
                width: 15rem;
                display: flex;
                justify-content: flex-start;
                align-items: center;
                border-radius: 5px;
                padding: 4px;
                color: #36454f;
                outline: none;
                cursor: pointer;
    
                & svg {
                    margin-right: 2px;
                }
    
                & span.keystroke-container {
                    margin-left: auto;
                }
    
                & span.keystroke {
                    font-size: 0.9rem;
                    /* border: 1px solid lightgray; */
                    padding-inline: 4px;
                    border-radius: 3px;
                    background-color: #fff;
                    outline: 1px solid gray;
                }
    
                & span.keystroke {
                    padding-inline: 6px;
                }
            }
    
            & button:focus {
                box-shadow:
                    rgba(204, 85, 0, 1) 0px 0px 0px 1px,
                    rgb(209, 213, 219) 0px 0px 0px 1px inset;
            }
        }
    </style>
    

    search.ts

    This TypeScript file displays search results.

    import FlexSearch from 'flexsearch';
    
    export type Content = {
        locale: string;
        content: string;
        slug: string;
        title: string;
        description: string;
        tags_list: string;
        isPage: boolean;
    };
    
    export type Result = {
        content: string[];
        slug: string;
        title: string;
        description?: string;
        tags_list?: string;
        isPage?: boolean;
    };
    
    let contentsIndex: FlexSearch.Index;
    let contents: Content[];
    
    /*
     * Create a Flex search index for the content.
     */
    export function createIndex(data: Content[]) {
        contentsIndex = new FlexSearch.Index({ tokenize: 'forward' });
    
        data.forEach((post, i) => {
            // const item = `${post.title} ${post.content} ${post.description}`
            const item = `${post.content} ${post.description}`;
            contentsIndex.add(i, item);
        });
    
        contents = data;
    }
    
    function convertTagsListToHTMLString(tags: string): string {
        // Tags are indexed as a comma-separated string. This function converts that tags
        // string into an HTML string. Each tag is wrapped in a span element:
        // <span class="search-result-tag">${tag}</span>
    
        // The tags property of the indexing json files has already destructured the array
        // in a list.
        return `<span class="search-result-tag">${tags}</span>`;
    
        // const tagEach = tags.split(" ");
    
        // const tagHTMLString = tagEach.reduce((acc, tag) => {
        //     acc += `<span class="search-result-tag">${tag}</span>`;
        //     return acc;
        // }, "");
    
        // return tagHTMLString;
    }
    
    function dedupeByPropertyMap(arr: any, prop: string) {
        const uniqueMap = new Map();
        arr.forEach((obj: any) => {
            // We only set it if it's not already there, to keep the FIRST occurrence
            if (!uniqueMap.has(obj[prop])) {
                uniqueMap.set(obj[prop], obj);
            }
        });
        return Array.from(uniqueMap.values());
    }
    
    export function searchIndex(resultLocale: string, searchTerm: string) {
        if (searchTerm.trim().length === 0) {
            return;
        }
        const match = searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
        const results = contentsIndex.search(match, { limit: 50 });
    
        if (searchTerm.length > 0 && results.length === 0) {
            return [
                {
                    title: 'No results found.',
                    content: ['Please try a different search term.'],
                    slug: ''
                }
            ];
        }
    
        const searchResults = results
            .map((index) => contents[index as number])
            .filter((content) => content.locale === resultLocale)
            .map(({ slug, title, description, content, tags_list, isPage }) => {
                const tagHTMLString = convertTagsListToHTMLString(tags_list);
    
                const barPosition = title.indexOf('|');
                if (barPosition !== -1) {
                    title = title.substring(0, barPosition);
                }
    
                return {
                    slug,
                    title: `${replaceTextWithMarker(title, match)}`,
                    description: `${replaceTextWithMarker(description, match)}`,
                    content: '',
                    tags: `<span class="search-results-tags-wrapper">${tagHTMLString}</span>`,
                    isPage
                };
            });
    
        //console.log("searchResults", searchResults);
    
        // Ensure only unique 'slug' entries are returned. 
        return dedupeByPropertyMap(searchResults, 'slug');
    }
    
    /*
     * Find each search term match and surround it with a <mark> tag.
     */
    function replaceTextWithMarker(text: string, match: string) {
        // console.log("text", text);
        // console.log("match", match);
    
        const regex = new RegExp(match, 'gi');
        return text.replaceAll(regex, (match) => `<mark>${match}</mark>`);
    }
    
    /*
     * Substring each match.
     */
    function getMatches(text: string, searchTerm: string, limit = 1) {
        const regex = new RegExp(searchTerm, 'gi');
        const indexes = [];
        let matches = 0;
        let match;
    
        while ((match = regex.exec(text)) !== null && matches < limit) {
            indexes.push(match.index);
            matches++;
        }
    
        if (indexes.length === 0) {
            return 'Search term appears only in the title.';
        }
    
        return indexes.map((index) => {
            const start = index - 20;
            const end = index + 120;
            const excerpt = text.substring(start, end).trim();
            return `...${replaceTextWithMarker(excerpt, searchTerm)}...`;
        });
    }