diff --git a/blog.config.js b/blog.config.js
index 27cd8e1f..d1a836db 100644
--- a/blog.config.js
+++ b/blog.config.js
@@ -134,6 +134,12 @@ const BLOG = {
POSTS_PER_PAGE: 12, // post counts per page
POSTS_SORT_BY: process.env.NEXT_PUBLIC_POST_SORT_BY || 'notion', // 排序方式 'date'按时间,'notion'由notion控制
+ ALGOLIA_APP_ID: process.env.NEXT_PUBLIC_ALGOLIA_APP_ID || null, // 在这里查看 https://dashboard.algolia.com/account/api-keys/
+ ALGOLIA_ADMIN_APP_KEY: process.env.ALGOLIA_ADMIN_APP_KEY || null, // 管理后台的KEY,不要暴露在代码中,在这里查看 https://dashboard.algolia.com/account/api-keys/
+ ALGOLIA_SEARCH_ONLY_APP_KEY: process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_ONLY_APP_KEY || null, // 客户端搜索用的KEY
+ ALGOLIA_INDEX: process.env.NEXT_PUBLIC_ALGOLIA_INDEX || null, // 在Algolia中创建一个index用作数据库
+ ALGOLIA_RECREATE_DATA: process.env.ALGOLIA_RECREATE_DATA || process.env.npm_lifecycle_event === 'build', // 为true时重新构建索引数据; 默认在build时会构建
+
PREVIEW_CATEGORY_COUNT: 16, // 首页最多展示的分类数量,0为不限制
PREVIEW_TAG_COUNT: 16, // 首页最多展示的标签数量,0为不限制
diff --git a/components/AlgoliaSearchModal.js b/components/AlgoliaSearchModal.js
new file mode 100644
index 00000000..32d0401d
--- /dev/null
+++ b/components/AlgoliaSearchModal.js
@@ -0,0 +1,92 @@
+import { useState, useImperativeHandle } from 'react'
+import BLOG from '@/blog.config'
+import algoliasearch from 'algoliasearch'
+import replaceSearchResult from '@/components/Mark'
+
+/**
+ * 结合 Algolia 实现的弹出式搜索框
+ * 打开方式 cRef.current.openSearch()
+ * https://www.algolia.com/doc/api-reference/search-api-parameters/
+ */
+export default function AlgoliaSearchModal({ cRef }) {
+ const [searchResults, setSearchResults] = useState([])
+ const [isModalOpen, setIsModalOpen] = useState(false)
+ /**
+ * 对外暴露方法
+ */
+ useImperativeHandle(cRef, () => {
+ return {
+ openSearch: () => {
+ setIsModalOpen(true)
+ }
+ }
+ })
+
+ if (!BLOG.ALGOLIA_APP_ID) {
+ return <>>
+ }
+
+ const client = algoliasearch(BLOG.ALGOLIA_APP_ID, BLOG.ALGOLIA_SEARCH_ONLY_APP_KEY)
+ const index = client.initIndex(BLOG.ALGOLIA_INDEX)
+
+ const handleSearch = async (query) => {
+ try {
+ const res = await index.search(query)
+ console.log(res)
+ const { hits } = res
+ setSearchResults(hits)
+ const doms = document.getElementById('search-wrapper').getElementsByClassName('replace')
+ replaceSearchResult({
+ doms,
+ search: query,
+ target: {
+ element: 'span',
+ className: 'text-blue-600 border-b border-dashed'
+ }
+ })
+ } catch (error) {
+ console.error('Algolia search error:', error)
+ }
+ }
+
+ const closeModal = () => {
+ setIsModalOpen(false)
+ }
+
+ return (
+
+ {/* 内容 */}
+
+
+
+
+
handleSearch(e.target.value)}
+ className="bg-gray-50 dark:bg-gray-600 outline-blue-500 w-full px-4 my-2 py-1 mb-4 border rounded-md" />
+
+ {/* 标签组 */}
+
+
+
+
+
+
+
Algolia 提供搜索服务
+
+
+ {/* 遮罩 */}
+
+
+
+ )
+}
diff --git a/lib/algolia.js b/lib/algolia.js
new file mode 100644
index 00000000..b7439438
--- /dev/null
+++ b/lib/algolia.js
@@ -0,0 +1,42 @@
+import BLOG from '@/blog.config'
+import { getPageContentText } from '@/pages/search/[keyword]'
+import algoliasearch from 'algoliasearch'
+
+/**
+ * 生成全文索引
+ * @param {*} allPages
+ */
+const generateAlgoliaSearch = async({ allPages, force = false }) => {
+ allPages?.forEach(p => {
+ // 判断这篇文章是否需要重新创建索引
+ if (p && !p.password) {
+ uploadDataToAlgolia(p)
+ }
+ })
+}
+
+/**
+ * 上传数据
+ */
+const uploadDataToAlgolia = (post) => {
+ // Connect and authenticate with your Algolia app
+ const client = algoliasearch(BLOG.ALGOLIA_APP_ID, BLOG.ALGOLIA_ADMIN_APP_KEY)
+
+ // Create a new index and add a record
+ const index = client.initIndex(BLOG.ALGOLIA_INDEX)
+ const record = {
+ objectID: post.id,
+ title: post.title,
+ category: post.category,
+ tags: post.tags,
+ pageCover: post.pageCover,
+ slug: post.slug,
+ summary: post.summary,
+ content: getPageContentText(post, post.blockMap)
+ }
+ index.saveObject(record).wait().then(r => {
+ console.log('Algolia索引', r, record)
+ })
+}
+
+export { uploadDataToAlgolia, generateAlgoliaSearch }
diff --git a/lib/notion/getNotionData.js b/lib/notion/getNotionData.js
index fb6bc942..393e3c63 100644
--- a/lib/notion/getNotionData.js
+++ b/lib/notion/getNotionData.js
@@ -185,10 +185,10 @@ export function getNavPages({ allPages }) {
const result = allNavPages.map(item => ({ id: item.id, title: item.title || '', category: item.category || null, tags: item.tags || null, summary: item.summary || null, slug: item.slug }))
const groupedArray = result.reduce((groups, item) => {
- const categoryName = item.category ? item.category.join('/') : '' // 将category转换为字符串
+ const categoryName = item?.category ? item?.category : '' // 将category转换为字符串
const lastGroup = groups[groups.length - 1] // 获取最后一个分组
- if (!lastGroup || lastGroup.category !== categoryName) { // 如果当前元素的category与上一个元素不同,则创建新分组
+ if (!lastGroup || lastGroup?.category !== categoryName) { // 如果当前元素的category与上一个元素不同,则创建新分组
groups.push({ category: categoryName, items: [] })
}
diff --git a/lib/notion/getPageProperties.js b/lib/notion/getPageProperties.js
index 104a208e..dea5c2b1 100644
--- a/lib/notion/getPageProperties.js
+++ b/lib/notion/getPageProperties.js
@@ -69,9 +69,10 @@ export default async function getPageProperties(id, block, schema, authToken, ta
})
}
- // type\status是下拉选框 取数组第一个
- properties.type = properties.type?.[0]
- properties.status = properties.status?.[0]
+ // type\status\category 是单选下拉框 取数组第一个
+ properties.type = properties.type?.[0] || ''
+ properties.status = properties.status?.[0] || ''
+ properties.category = properties.category?.[0] || ''
// 映射值:用户个性化type和status字段的下拉框选项,在此映射回代码的英文标识
mapProperties(properties)
diff --git a/lib/sitemap.xml.js b/lib/sitemap.xml.js
index e2725f23..6c8bd040 100644
--- a/lib/sitemap.xml.js
+++ b/lib/sitemap.xml.js
@@ -38,6 +38,12 @@ export async function generateSitemapXml({ allPages }) {
console.warn('无法写入文件', error)
}
}
+
+/**
+ * 生成站点地图
+ * @param {*} urls
+ * @returns
+ */
function createSitemapXml(urls) {
let urlsXml = ''
urls.forEach(u => {
diff --git a/package.json b/package.json
index 5e528937..ca406763 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,7 @@
"@headlessui/react": "^1.7.15",
"@next/bundle-analyzer": "^12.1.1",
"@vercel/analytics": "^1.0.0",
+ "algoliasearch": "^4.18.0",
"animejs": "^3.2.1",
"aos": "^3.0.0-beta.6",
"axios": ">=0.21.1",
diff --git a/pages/[prefix]/[slug].js b/pages/[prefix]/[slug].js
index 65d025c1..cf249228 100644
--- a/pages/[prefix]/[slug].js
+++ b/pages/[prefix]/[slug].js
@@ -3,7 +3,7 @@ import { getPostBlocks } from '@/lib/notion'
import { getGlobalData } from '@/lib/notion/getNotionData'
import { idToUuid } from 'notion-utils'
import { getNotion } from '@/lib/notion/getNotion'
-import Slug from '.'
+import Slug, { getRecommendPost } from '.'
/**
* 根据notion的slug访问页面
@@ -84,39 +84,4 @@ export async function getStaticProps({ params: { prefix, slug } }) {
}
}
-/**
- * 获取文章的关联推荐文章列表,目前根据标签关联性筛选
- * @param post
- * @param {*} allPosts
- * @param {*} count
- * @returns
- */
-function getRecommendPost(post, allPosts, count = 6) {
- let recommendPosts = []
- const postIds = []
- const currentTags = post?.tags || []
- for (let i = 0; i < allPosts.length; i++) {
- const p = allPosts[i]
- if (p.id === post.id || p.type.indexOf('Post') < 0) {
- continue
- }
-
- for (let j = 0; j < currentTags.length; j++) {
- const t = currentTags[j]
- if (postIds.indexOf(p.id) > -1) {
- continue
- }
- if (p.tags && p.tags.indexOf(t) > -1) {
- recommendPosts.push(p)
- postIds.push(p.id)
- }
- }
- }
-
- if (recommendPosts.length > count) {
- recommendPosts = recommendPosts.slice(0, count)
- }
- return recommendPosts
-}
-
export default PrefixSlug
diff --git a/pages/[prefix]/index.js b/pages/[prefix]/index.js
index 788cee7d..8c10b063 100644
--- a/pages/[prefix]/index.js
+++ b/pages/[prefix]/index.js
@@ -9,6 +9,7 @@ import { getPageTableOfContents } from '@/lib/notion/getPageTableOfContents'
import { getLayoutByTheme } from '@/themes/theme'
import md5 from 'js-md5'
import { isBrowser } from '@/lib/utils'
+import { uploadDataToAlgolia } from '@/lib/algolia'
/**
* 根据notion的slug访问页面
@@ -95,7 +96,6 @@ export async function getStaticPaths() {
}
export async function getStaticProps({ params: { prefix } }) {
- // let fullSlug = slug.join('/')
let fullSlug = prefix
if (JSON.parse(BLOG.PSEUDO_STATIC)) {
if (!fullSlug.endsWith('.html')) {
@@ -129,6 +129,10 @@ export async function getStaticProps({ params: { prefix } }) {
props.post.blockMap = await getPostBlocks(props.post.id, from)
}
+ if (BLOG.ALGOLIA_APP_ID && BLOG.ALGOLIA_APP_KEY) {
+ uploadDataToAlgolia(props?.post)
+ }
+
// 推荐关联文章处理
const allPosts = props.allPages.filter(page => page.type === 'Post' && page.status === 'Published')
if (allPosts && allPosts.length > 0) {
@@ -156,7 +160,7 @@ export async function getStaticProps({ params: { prefix } }) {
* @param {*} count
* @returns
*/
-function getRecommendPost(post, allPosts, count = 6) {
+export function getRecommendPost(post, allPosts, count = 6) {
let recommendPosts = []
const postIds = []
const currentTags = post?.tags || []
diff --git a/pages/index.js b/pages/index.js
index af85c245..404fab19 100644
--- a/pages/index.js
+++ b/pages/index.js
@@ -6,6 +6,8 @@ import { generateRobotsTxt } from '@/lib/robots.txt'
import { useRouter } from 'next/router'
import { getLayoutByTheme } from '@/themes/theme'
+import { generateAlgoliaSearch } from '@/lib/algolia'
+
/**
* 首页布局
* @param {*} props
@@ -61,6 +63,11 @@ export async function getStaticProps() {
generateRss(props?.latestPosts || [])
}
+ // 生成全文索引 - 仅在 yarn build 时执行 && process.env.npm_lifecycle_event === 'build'
+ if (BLOG.ALGOLIA_APP_ID && JSON.parse(BLOG.ALGOLIA_RECREATE_DATA)) {
+ generateAlgoliaSearch({ allPages: props.allPages })
+ }
+
delete props.allPages
return {
diff --git a/pages/search/[keyword]/index.js b/pages/search/[keyword]/index.js
index c2e9817c..93fa6826 100644
--- a/pages/search/[keyword]/index.js
+++ b/pages/search/[keyword]/index.js
@@ -121,16 +121,7 @@ async function filterByMemCache(allPosts, keyword) {
const categoryContent = post.category && Array.isArray(post.category) ? post.category.join(' ') : ''
const articleInfo = post.title + post.summary + tagContent + categoryContent
let hit = articleInfo.toLowerCase().indexOf(keyword) > -1
- let indexContent = [post.summary]
- // 防止搜到加密文章的内容
- if (page && page.block && !post.password) {
- const contentIds = Object.keys(page.block)
- contentIds.forEach(id => {
- const properties = page?.block[id]?.value?.properties
- indexContent = appendText(indexContent, properties, 'title')
- indexContent = appendText(indexContent, properties, 'caption')
- })
- }
+ const indexContent = getPageContentText(post, page)
// console.log('全文搜索缓存', cacheKey, page != null)
post.results = []
let hitCount = 0
@@ -157,4 +148,18 @@ async function filterByMemCache(allPosts, keyword) {
return filterPosts
}
+export function getPageContentText(post, pageBlockMap) {
+ let indexContent = []
+ // 防止搜到加密文章的内容
+ if (pageBlockMap && pageBlockMap.block && !post.password) {
+ const contentIds = Object.keys(pageBlockMap.block)
+ contentIds.forEach(id => {
+ const properties = pageBlockMap?.block[id]?.value?.properties
+ indexContent = appendText(indexContent, properties, 'title')
+ indexContent = appendText(indexContent, properties, 'caption')
+ })
+ }
+ return indexContent.join('')
+}
+
export default Index
diff --git a/themes/heo/components/SearchButton.js b/themes/heo/components/SearchButton.js
index aee73e49..e23907a4 100644
--- a/themes/heo/components/SearchButton.js
+++ b/themes/heo/components/SearchButton.js
@@ -1,9 +1,30 @@
+import BLOG from '@/blog.config'
import { useGlobal } from '@/lib/global'
-import Link from 'next/link'
+import { useRouter } from 'next/router'
+import AlgoliaSearchModal from '@/components/AlgoliaSearchModal'
+import { useRef } from 'react'
+/**
+ * 搜索按钮
+ * @returns
+ */
export default function SearchButton() {
const { locale } = useGlobal()
- return
-
-
+ const router = useRouter()
+ const searchModal = useRef(null)
+
+ function handleSearch() {
+ if (BLOG.ALGOLIA_APP_ID) {
+ searchModal.current.openSearch()
+ } else {
+ router.push('/search')
+ }
+ }
+
+ return <>
+
+
+
+
+ >
}