在 NextJs SSG(Static Site) 純靜態頁面增加搜尋功能
前言
大部份的網路教學,都會建議界接 algolia 來自動產生搜尋功能
畢竟作法最簡單,缺點就是需要 Server 來串接
而一般小網站,基本上也很難觸及到他的付費條件,免費也足夠使用
但想要部署在 GitHub Page,只想純粹使用 Next SSG 的話
就沒辦法界接 algolia 了!
而網路上相關作法幾乎沒有,只有參考資料的一篇文章有簡單帶到
其解法也跟 hexo blog 作法相近:在 build 的時候產生db.json
,作成資料來源於前端 filter
雖然會有一些額外的開發功,但並不算太困難
成品可參考我的 Side Project: DEMO
關鍵字可使用: 【乾】、【乾為天】來感受一下
UI 則是參考我 Hexo blog 現行 UI 設計出來的
作法
- 在
getStaticsProps
產生db.json
,籍由套件mdx-to-plain-text
清除 html 資訊,將mdx
變成純內容 - 製作相關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