在 NextJs SSG(Static Site) 純靜態頁面增加搜尋功能

前言

大部份的網路教學,都會建議界接 algolia 來自動產生搜尋功能
畢竟作法最簡單,缺點就是需要 Server 來串接
而一般小網站,基本上也很難觸及到他的付費條件,免費也足夠使用

但想要部署在 GitHub Page,只想純粹使用 Next SSG 的話
就沒辦法界接 algolia 了!

而網路上相關作法幾乎沒有,只有參考資料的一篇文章有簡單帶到
其解法也跟 hexo blog 作法相近:在 build 的時候產生db.json,作成資料來源於前端 filter

雖然會有一些額外的開發功,但並不算太困難

成品可參考我的 Side Project: DEMO
關鍵字可使用: 【乾】、【乾為天】來感受一下

UI 則是參考我 Hexo blog 現行 UI 設計出來的

作法

  1. getStaticsProps 產生db.json,籍由套件mdx-to-plain-text清除 html 資訊,將mdx變成純內容
  2. 製作相關UI,取 db.json filter 內容

產生db.json

須依靠mdx-to-plain-text來清除 html 雜訊
但使用他還得搭配 remark 套件
先安裝相關套件

小小遺憾是mdx-to-plain-text並沒有支援TypeScript QQ

npm i -D remark remark-mdx remark-mdx-to-plain-text

產生db.json的邏輯

import { remark } from 'remark';
import remarkMdx from 'remark-mdx';
import fs from 'fs/promises';

let strip = require('remark-mdx-to-plain-text');

export interface DbItem {
    title: string;
    link: string;
    content: string;
}

const mdxToPlainString = (fileContent: string, link: string): Promise<DbItem> => {
    return new Promise((resolve, reject) => {
        remark()
            .use(remarkMdx)
            .use(strip)
            .process(fileContent, (err, file) => {
                if (err) reject(err);

                const temp = (file!.value as string).split('\n');
                /**
                 * 這裡POP 3次原因:
                 * 
                 * 套件說明會將 mdx 的 import、export 都一起清理掉,但我測試結果是沒有清除
                 * 而我的 MDX 內容未 3 行固定為 layout render
                 * 如下: 
                 * import MdxLayout from '@app/mdx-layout';
                 * export default ({ children }) => <MdxLayout>{children}</MdxLayout>
                 * 
                 * 故在產生內容時裡手動清除,避免混雜進搜尋內容裡
                 */ 
                temp.pop(); 
                temp.pop();
                temp.pop();
            
                /**
                 * 我的 mdx 沒有另外使用 frontmatter,第一行固定為 title
                 * 若您的 mdx 有用 frontmatter。這裡再微調邏輯或著搭配`grey-matter`來取相關資訊
                 */ 
                resolve({
                    title: temp[0],
                    link,
                    content: temp.join('\n'),
                });
            });
    });
}

/**
 * 欲納入搜尋的頁面
 * 自己產製搜尋功能的好處就是可以避開如【關於本站】這類不想被索引的頁面
 */ 
const MDX_FOLDER = ['./pages/gua', './pages/formula', './pages/scripture'];

const genDbJson = async () => {
    console.log('[static db] generate db.json......');
    const files: string[] = [];
    for await (const folder of MDX_FOLDER) {
        const list = await fs.readdir(folder);
        list.filter(file => file !== 'index.tsx')
            .map((file) => `${folder}/${file}`)
            .forEach((file) => files.push(file));
    }

    const db: DbItem[] = [];
    for await (const file of files) {
        const sample = await fs.readFile(file, 'utf8');
        const link = file.replace('./pages', '').replace('.mdx', '');
        const res = await mdxToPlainString(sample, link);
        db.push(res);
    }

    await fs.writeFile('./public/db.json', JSON.stringify(db));
    
    console.log('[static db] generate db.json...... done!');

};

export default genDbJson;

最後到index.tsx,增加getStaticProps
此函式只會在 build 時被呼叫
而 dev 時則會在載入時呼叫,執行 dev 環境時,可以看到/public/db.json確實產生

export const getStaticProps: GetStaticProps = async (context) => {
    await genDbJson();
    return {
        props: {},
    };
}

最後別忘了最後要在.gitignore增加db.json。你不會想要把他納進版控的!

製作搜尋 UI,取得db.json

搜尋功能有一個最重要的就是 debounce
你不會想要高頻率的觸發 filter 的!很容易造成網頁效能問題
原本有想要直接用 lodash 現成的,但又不太想只用一個功能裝整個套件
後來查到usehooks-ts有提供各種常用的 hook
直接來 Copy 就行

為避免文章太長,就不貼程式過來了。基本上就是無腦照抄

我有使用 MUI Library,就是排列組合咻咻咻~

import React, { useEffect, useState } from 'react';
import Link from 'next/link';
import useSWR from 'swr'
import useDebounce from '../../../utils/use-debounce';
import { DbItem } from '../../../utils/gen-db-json';
import { Box, Typography, Dialog, List, ListItem, ListItemButton } from '@mui/material';

import { Paper, InputBase, IconButton } from '@mui/material';
import { Clear as ClearIcon, Search as SearchIcon } from '@mui/icons-material';

interface SearchDialogProps {
    open: boolean;
    onClose: () => void;
}

interface SearchResult extends DbItem {
    contentFragment: string;
}

// 供 swr 使用
const fetcher = (url: string) => fetch(url).then(r => r.json());

interface SearchBoxProps {
    queryText: string,
    setQueryText: (text: string) => void
}

const SearchBox = ({ queryText, setQueryText }: SearchBoxProps) => {
    return (
        <Paper
            component="form"
            sx={{ p: '2px 4px', display: 'flex', alignItems: 'center', width: '100%' }}
        >
            <SearchIcon sx={{ p: '10px', fontSize: '40px', color: '#525252' }} />
            <InputBase
                sx={{ ml: 1, flex: 1 }}
                placeholder="搜尋..."
                inputProps={{ 'aria-label': 'search' }}
                value={queryText}
                fullWidth
                onChange={(event) => setQueryText(event.target.value)}
            />
            <IconButton type="button" sx={{ p: '10px' }} aria-label="clear" onClick={() => setQueryText('')}>
                <ClearIcon />
            </IconButton>
        </Paper>
    );
};

const SearchDialog = ({open, onClose}: SearchDialogProps) => {
    
    // ------重點在這裡 START-----
    const [result, setResult] = useState([] as SearchResult[]); // 儲存搜尋結果

    const [queryText, setQueryText] = useState<string>(''); // 儲存搜尋文字
    const debouncedValue = useDebounce<string>(queryText, 800); // debounce,避免高頻率觸發

    const { data } = useSWR<DbItem[] ,any>('/db.json', fetcher);

    const handleClick = () => onClose();

    useEffect(() => {
        if (!queryText) {
            setResult([]);
            return;
        }
        
        /**
         * 使用 regular expression 取搜尋關鍵字與前後文 0~10 個字,以便在 UI 呈現部份內容
         * 靈感來源來自我 BLOG 現行的搜尋功能,若懶得點進 DEMO 查閱,可以在我 BLOG 隨意搜尋
         */ 
        const res = data?.filter(item => item.content.includes(queryText))
            .map(item => {
                const reg = new RegExp(`.{0,10}${queryText}.{0,10}`, 'i');
                const contentFragment = `... ${item.content.match(reg)?.[0]} ...`;
                return { ...item, contentFragment };
            }) || [];
        setResult(res);
    }, [debouncedValue]);
    // ------重點在這裡 END -----

    // ------以下 ui 部份就當參考,可以直接在我的 demo 看結果-----
    return (<>
        <Dialog onClose={onClose}
                open={open}
                fullWidth
                PaperProps={{sx: { height: '80vh' }}}>
            <Box sx={{display: 'flex', alignItems: 'center', p: 2, flexDirection: 'column', gap: '10px'}}>
                <SearchBox queryText={queryText} setQueryText={setQueryText} />
                {debouncedValue && <Typography variant="body1" gutterBottom>共有 {result.length} 筆查詢結果</Typography>}
            </Box>
            <List sx={{height: {xs: '350px', sm: '700px'}}}>
                {result.map((res) => (
                    <ListItem divider>
                        <Link href={res.link} style={{textDecoration: 'none', color: '#262424', width: '100%'}}>
                            <ListItemButton key={res.title} onClick={handleClick}>
                                <Box sx={{display: 'flex', flexDirection: 'column',}}>
                                    <Typography variant="subtitle1" gutterBottom><b><u>{res.title}</u></b></Typography>
                                    <Typography variant="body1" gutterBottom>{res.contentFragment}</Typography>
                                </Box>
                            </ListItemButton>
                        </Link>
                    </ListItem>
                ))}
            </List>
        </Dialog>
    </>);
};

export default SearchDialog;

後記

小小缺陷就是目前沒有實作像 hexo blog 的關鍵字 highlight
不過這也不難

以我的 Side Project 來說,核心功能就是首頁的易經產生器
99% 的使用者都不會來搜尋其他資料 XD
那些資料也只是想說加點 SEO,讓網站豐富些罷了
就沒再花心力了

最主要是想挑戰看看自己能否實作出純靜態頁面的搜尋功能
畢竟目前網路上相關教學也滿少的!

參考資料

Build the Search functionality in a static blog with Next.js and Markdown