coding

How to create an offline search page with Gatsby

Written in May 29, 2021 - 🕒 3 min. read

When making a JAMStack website, some compromises need to be made, but searching is not one of them, thanks to JavaScript libraries like flexsearch and lunr.js.

After following this tutorial and combining the code with the Material UI autocomplete component I was able to create the search bar you see in this blog.

Search results

That was already nice, but I’d like to also have a search page, where a query could be matched by the post’s tags, title, and content.

Creating a new search index

First I will add the following code to my Gatsby config, this will create a new localSearchPage index to be queried via GraphQL.

module.exports = [
    ...otherConfigs,
    {
        resolve: 'gatsby-plugin-local-search',
        options: {
            name: 'page',
            query: `
                {
                    allMarkdownRemark(
                        filter: {
                            frontmatter:{ show: { eq: true } }
                        }
                        sort: { fields: [frontmatter___date], order: DESC }
                        limit: 1000
                    ) {
                        edges {
                            node {
                                id
                                fields {
                                    locale
                                    path
                                }
                                frontmatter {
                                    date
                                    title
                                    show
                                    tags
                                }
                                rawMarkdownBody
                            }
                        }
                    }
                }
            `,
            engine: 'flexsearch',
            engineOptions: 'speed',
            ref: 'id',
            index: ['title', 'path', 'tags', 'body'],
            store: ['id', 'path', 'title', 'tags', 'locale', 'body', 'date'],
        },
    },
];

The search page

Search page
Search page

As shown in the tutorial, the useFlexSearch hook can be used to get the search results, but there is no indication of where the query was matched within the blog post fields.

const urlParams = new URLSearchParams(location.search);
const query = urlParams.get('q') || '';

const results = useFlexSearch(
    query,
    data.localSearchPage.index,
    data.localSearchPage.store
);

const getExcerpt = (post, length = 60) => {
    // magic happens here
};

const posts = useMemo(
    () => results.map(
        (post) => ({
            ...post,
            excerpt: getExcerpt(
                post.excerpt
                    // let's remove any line breaks
                    // or URLS
                    // or remove the markdown tags etc
                    // it would be better to do this in the build process
                    .replace(/(?:https?|ftp):\/\/[\n\S]+/g, '')
                    .replace(/\n/g, ' ')
            ),
        })
    ),
    [results]
);

The function getExcerpt needs to somehow return the excerpt of the blog post with the query highlighted within it.

So I need to find the exact position of the query in the blog post and replace it with an HTML span element like <span>query</span>, and for that, I’m going to use regular expressions.

Also, I will need a function to trim the text and add an ellipsis if needed and this one from StackOverflow will do the job just fine.

    const getExcerpt = useCallback((text, length = 60) => {
        // makes it lowercase to be sure to find it
        const regex = new RegExp(`\\b(${query.toLowerCase()})\\b`);
        const matchedRegex = text.toLowerCase().match(regex);

        if (matchedRegex?.length) {
            // now let's create a regex using the actual word found, capital-sensitive
            const actualQuery = text.substr(matchedRegex.index, query.length);
            const actualRegex = new RegExp(`\\b(${actualQuery})\\b`);

            // start of the excerpt
            const excerptEnd = truncateStringToWord(
                text.substr(matchedRegex.index),
                length,
                true
            );

            // yolo let's reverse the string and use the same function for the
            // beginning of the excerpt too
            const excerptStart = truncateStringToWord(
                text.substr(0, matchedRegex.index).split('').reverse().join(''),
                length,
                true
            );

            const excerpt = `${excerptStart.split('').reverse().join('')}${excerptEnd}`;

            const highlightedQuery = renderToString(
                <span className={classes.highlightedQuery}>
                    {actualQuery}
                </span>
            );

            // if nothing was found it means that the query matched
            // a tag or the post title
            return excerpt.replace(actualRegex, highlightedQuery);
        }

        return truncateStringToWord(text, length, true);
    }, [classes.highlightedQuery, query]);

This code will transform a string like Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed condimentum risus nec scelerisque auctor into Lorem ipsum dolor sit amet, consectetur <span>adipiscing</span> elit. Sed condimentum risus nec scelerisque auctor when the query is adipiscing, and since this string is in HTML, to display it I will use the dangerouslySetInnerHTML property in an em element.

The reversed usage of truncateStringToWord is a bit hacky, but most of this code can be moved to the Gatsby building process, so the blog performance won’t be impacted.

I hope this blog post was somehow useful for you, you can check how the page looks like by searching for something in this blog. See you in the next one!

Tags:


Post a comment

Comments

No comments yet.