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 <> +
+ +
+ + }