From 4ffd531d633a13aaa1c23c3254f993b6d15544f7 Mon Sep 17 00:00:00 2001 From: 0x5c0f Date: Tue, 19 May 2026 16:19:39 +0800 Subject: [PATCH] feat(search): add pagefind search engine support (#738) * feat(search): add native pagefind support * chore: code review and change --------- Co-authored-by: Cell <1024@lruihao.cn> --- README.md | 2 +- README.zh-cn.md | 2 +- assets/js/lib/pagefind-search.js | 142 ++++++++++++++++++ assets/js/theme.js | 24 +++ hugo.toml | 15 +- .../_partials/init/detection-pagefind.html | 3 + layouts/_partials/init/index.html | 1 + layouts/_partials/layouts/assets.html | 13 ++ layouts/_partials/layouts/head/index.html | 2 + .../_partials/plugin/pagefind-metadata.html | 11 ++ layouts/page.html | 5 +- layouts/posts/single.html | 5 +- 12 files changed, 218 insertions(+), 7 deletions(-) create mode 100644 assets/js/lib/pagefind-search.js create mode 100644 layouts/_partials/init/detection-pagefind.html create mode 100644 layouts/_partials/plugin/pagefind-metadata.html diff --git a/README.md b/README.md index 4ec08a96..3a52fa0c 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ Click the following links to generate a new repository with template: - **Sub Menu** supported - **Content Encryption** supported (Pages, Partial) - **Friends** page embedded template -- **Search** supported by [algolia](https://www.algolia.com/), [Fuse.js](https://fusejs.io/), CSE or [PostChat](https://ai.zhheo.com/console/login?InviteID=85041330) +- **Search** supported by [algolia](https://www.algolia.com/), [Fuse.js](https://fusejs.io/), [Pagefind](https://pagefind.app), CSE or [PostChat](https://ai.zhheo.com/console/login?InviteID=85041330) - **Custom Search Engine (CSE)** supported by [Google](https://programmablesearchengine.google.com/) - **Twemoji** supported - Automatically **highlighting** code diff --git a/README.zh-cn.md b/README.zh-cn.md index 4b3ad4cb..2976a5c0 100644 --- a/README.zh-cn.md +++ b/README.zh-cn.md @@ -137,7 +137,7 @@ pnpx fixit-cli create my-blog - 支持**二级菜单** - 支持**内容加密**(页面、局部) - 支持**友情链接**的页面模板 -- 支持基于 [algolia](https://www.algolia.com/)、[Fuse.js](https://fusejs.io/)、 **CSE** 或 [PostChat](https://ai.zhheo.com/console/login?InviteID=85041330) 的**搜索** +- 支持基于 [algolia](https://www.algolia.com/)、[Fuse.js](https://fusejs.io/)、[Pagefind](https://pagefind.app)、CSE 或 [PostChat](https://ai.zhheo.com/console/login?InviteID=85041330) 的**搜索** - 支持基于 [Google](https://programmablesearchengine.google.com/) 的**自定义搜索引擎 (CSE)** - 支持 **Twemoji** - 支持**代码高亮** diff --git a/assets/js/lib/pagefind-search.js b/assets/js/lib/pagefind-search.js new file mode 100644 index 00000000..f678c1d4 --- /dev/null +++ b/assets/js/lib/pagefind-search.js @@ -0,0 +1,142 @@ +const ABSOLUTE_URL_RE = /^(?:[a-z]+:)?\/\//i; + +const normalizeBundlePath = (path, baseURL) => { + let bundlePath = typeof path === 'string' && path.length > 0 ? path : 'pagefind/'; + if (!bundlePath.endsWith('/')) { + bundlePath = `${bundlePath}/`; + } + if (ABSOLUTE_URL_RE.test(bundlePath)) { + return bundlePath; + } + return new URL(bundlePath, baseURL || document.baseURI).toString(); +}; + +const toObject = (value) => (value && typeof value === 'object' ? value : {}); + +const normalizeSortOrder = (value) => ( + String(value).toLowerCase() === 'asc' + ? 'asc' + : 'desc' +); + +const replaceExcerptHighlightTag = (excerpt, highlightTag) => { + if (!excerpt || !highlightTag || highlightTag === 'mark') { + return excerpt || ''; + } + + return excerpt + .replaceAll('', `<${highlightTag}>`) + .replaceAll('', ``); +}; + +export function createPagefindSearch(searchConfig) { + const pagefindConfig = toObject(searchConfig.pagefind); + const bundlePath = normalizeBundlePath(pagefindConfig.bundlePath, pagefindConfig.baseURL); + const rawDebounceTimeout = Number(pagefindConfig.debounceTimeoutMs ?? 300); + const debounceTimeout = Number.isFinite(rawDebounceTimeout) ? Math.max(0, rawDebounceTimeout) : 300; + const builtInFiltersEnabled = pagefindConfig.useBuiltInFilters !== false; + const sortBy = typeof pagefindConfig.sortBy === 'string' ? pagefindConfig.sortBy.trim() : ''; + const sortOrder = normalizeSortOrder(pagefindConfig.sortOrder); + const highlightTag = searchConfig.highlightTag ?? 'em'; + const excerptLength = Number(searchConfig.snippetLength ?? 30); + + const state = { + loading: null, + initialized: false, + availableFilters: null, + }; + + const ensurePagefind = async () => { + if (!state.loading) { + state.loading = import(`${bundlePath}pagefind.js`) + .then(async (mod) => { + if (!state.initialized) { + const options = {}; + if (Number.isFinite(excerptLength) && excerptLength >= 0) { + options.excerptLength = excerptLength; + } + if (Object.keys(options).length && typeof mod.options === 'function') { + await mod.options(options); + } + await mod.init(); + state.initialized = true; + } + return mod; + }) + .catch((error) => { + state.loading = null; + throw error; + }); + } + return state.loading; + }; + + const getAvailableFilters = async () => { + if (state.availableFilters) return state.availableFilters; + + const pagefind = await ensurePagefind(); + if (typeof pagefind.filters !== 'function') { + state.availableFilters = {}; + return state.availableFilters; + } + + try { + state.availableFilters = toObject(await pagefind.filters()); + } catch (error) { + console.warn('[FixIt] failed to read Pagefind filters:', error); + state.availableFilters = {}; + } + return state.availableFilters; + }; + + return { + preload() { + return ensurePagefind(); + }, + async search(query, maxResultLength) { + if (!query || !query.trim()) return []; + + const pagefind = await ensurePagefind(); + const searchOptions = {}; + + if (builtInFiltersEnabled) { + const availableFilters = await getAvailableFilters(); + const filters = {}; + if (Object.prototype.hasOwnProperty.call(availableFilters, 'hidden')) { + filters.hidden = 'false'; + } + if (Object.prototype.hasOwnProperty.call(availableFilters, 'encrypted')) { + filters.encrypted = 'false'; + } + if (Object.keys(filters).length) { + searchOptions.filters = filters; + } + } + + if (sortBy) { + searchOptions.sort = { [sortBy]: sortOrder }; + } + + const resultLimit = Number.isFinite(maxResultLength) + ? Math.max(0, Math.floor(maxResultLength)) + : 10; + + const searched = debounceTimeout > 0 && typeof pagefind.debouncedSearch === 'function' + ? await pagefind.debouncedSearch(query, searchOptions, debounceTimeout) + : await pagefind.search(query, searchOptions); + + if (searched === null) return null; + + const records = await Promise.all( + (searched.results || []).slice(0, resultLimit).map((entry) => entry.data()), + ); + + return records.map((item) => ({ + uri: item.url || '#', + title: item.meta?.title || item.url || '', + date: item.meta?.date || '', + context: replaceExcerptHighlightTag(item.excerpt || '', highlightTag), + })); + }, + }; +} diff --git a/assets/js/theme.js b/assets/js/theme.js index a43b7591..9f5ccc2c 100644 --- a/assets/js/theme.js +++ b/assets/js/theme.js @@ -15,6 +15,7 @@ import { HTMLEscape, } from './utils/common'; import FileTree from './lib/file-tree.js' +import { createPagefindSearch } from './lib/pagefind-search.js' const copyText = createCopyText(); @@ -340,6 +341,14 @@ class FixIt { if ($searchInput.value === '') $searchClear.style.display = 'none'; else $searchClear.style.display = 'inline'; }, false); + if (searchConfig.type === 'pagefind') { + this._pagefindSearch = this._pagefindSearch || createPagefindSearch(searchConfig); + $searchInput.addEventListener('focus', () => { + this._pagefindSearch.preload().catch((error) => { + console.error(error); + }); + }, { once: true }); + } const initAutosearch = () => { const autosearch = autocomplete(`#search-input-${suffix}`, @@ -454,6 +463,16 @@ class FixIt { context: cseConfig.gotoResultsPage }]); } + } else if (searchConfig.type === 'pagefind') { + this._pagefindSearch + .search(query, maxResultLength) + .then((results) => { + finish(results || []); + }) + .catch((err) => { + console.error(err); + finish([]); + }); } else { finish([]); } @@ -482,6 +501,11 @@ class FixIt { href = 'https://programmablesearchengine.google.com/'; } break; + case 'pagefind': + searchType = 'Pagefind'; + icon = ''; + href = 'https://pagefind.app/'; + break; default: searchType = ''; icon = ''; diff --git a/hugo.toml b/hugo.toml index 7b2d0f1a..426f8d34 100644 --- a/hugo.toml +++ b/hugo.toml @@ -507,7 +507,7 @@ dark = "#151b23" # Search config [params.search] enable = false -# type of search engine ["algolia", "fuse", "cse", "post-chat"] +# type of search engine ["algolia", "fuse", "pagefind", "cse", "post-chat"] type = "fuse" # max index length of the chunked content contentLength = 4000 @@ -541,6 +541,19 @@ ignoreLocation = false useExtendedSearch = false ignoreFieldNorm = false +# Pagefind search config (http://pagefind.app/) +[params.search.pagefind] +# Pagefind bundle and index directory +bundlePath = "pagefind/" +# debounce timeout in milliseconds, set to 0 to disable debounce +debounceTimeoutMs = 300 +# whether to respect FixIt built-in search visibility rules +useBuiltInFilters = true +# optional sort field, current recommended built-in value: "date" +sortBy = "" +# sort order for sortBy: ["asc", "desc"] +sortOrder = "desc" + # FixIt 0.3.16 | NEW Custom Search Engine (CSE) [params.cse] # search engine: ["google", "bing"] diff --git a/layouts/_partials/init/detection-pagefind.html b/layouts/_partials/init/detection-pagefind.html new file mode 100644 index 00000000..bdb5566c --- /dev/null +++ b/layouts/_partials/init/detection-pagefind.html @@ -0,0 +1,3 @@ +{{- if and (eq .Site hugo.Sites.Default) .Site.Params.search.enable (eq .Site.Params.search.type "pagefind") -}} + {{- warnf "FixIt Pagefind search enabled\nRun `npx pagefind --site ` after site build to create the search index.\n\n" -}} +{{- end -}} diff --git a/layouts/_partials/init/index.html b/layouts/_partials/init/index.html index 836ae483..8faec88f 100644 --- a/layouts/_partials/init/index.html +++ b/layouts/_partials/init/index.html @@ -4,6 +4,7 @@ {{- partial "init/detection-env.html" . -}} {{- partial "init/detection-version.html" . -}} {{- partial "init/detection-deprecated.html" . -}} +{{- partial "init/detection-pagefind.html" . -}} {{- partial "init/global.html" . -}} {{- partial "init/patch.html" . -}} {{- partial "init/compatibility.html" . -}} diff --git a/layouts/_partials/layouts/assets.html b/layouts/_partials/layouts/assets.html index dd511ec0..a4c52e6e 100644 --- a/layouts/_partials/layouts/assets.html +++ b/layouts/_partials/layouts/assets.html @@ -44,6 +44,19 @@ FixIt theme assets partial {{- $source := $cdn.fuseJS | default "lib/fuse/fuse.min.js" -}} {{- dict "Source" $source "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}} {{- $config = dict "isCaseSensitive" $search.fuse.isCaseSensitive "minMatchCharLength" $search.fuse.minMatchCharLength "findAllMatches" $search.fuse.findAllMatches "location" $search.fuse.location "threshold" $search.fuse.threshold "distance" $search.fuse.distance "ignoreLocation" $search.fuse.ignoreLocation "useExtendedSearch" $search.fuse.useExtendedSearch "ignoreFieldNorm" $search.fuse.ignoreFieldNorm | dict "search" | merge $config -}} + {{- else if eq $search.type "pagefind" -}} + {{- $pagefind := $search.pagefind | default dict -}} + {{- $config = dict + "type" "pagefind" + "pagefind" (dict + "bundlePath" ($pagefind.bundlePath | default "pagefind/") + "baseURL" .Site.BaseURL + "debounceTimeoutMs" ($pagefind.debounceTimeoutMs | default 300) + "useBuiltInFilters" ($pagefind.useBuiltInFilters | default true) + "sortBy" ($pagefind.sortBy | default "") + "sortOrder" ($pagefind.sortOrder | default "desc") + ) + | dict "search" | merge $config -}} {{- else if eq $search.type "cse" -}} {{- $config = dict "type" "cse" | dict "search" | merge $config -}} {{- $cse := .Site.Params.cse -}} diff --git a/layouts/_partials/layouts/head/index.html b/layouts/_partials/layouts/head/index.html index 43520827..65bb2d5f 100644 --- a/layouts/_partials/layouts/head/index.html +++ b/layouts/_partials/layouts/head/index.html @@ -36,6 +36,8 @@ {{- template "_internal/opengraph.html" . -}} {{- partial "layouts/head/twitter-cards.html" . -}} +{{- partial "plugin/pagefind-metadata.html" . -}} + diff --git a/layouts/_partials/plugin/pagefind-metadata.html b/layouts/_partials/plugin/pagefind-metadata.html new file mode 100644 index 00000000..2c6d459a --- /dev/null +++ b/layouts/_partials/plugin/pagefind-metadata.html @@ -0,0 +1,11 @@ +{{- if and .Site.Params.search.enable (eq .Site.Params.search.type "pagefind") .IsPage -}} + {{- $params := partial "function/params.html" -}} + {{- $hidden := cond (eq $params.hiddenFromSearch true) "true" "false" -}} + {{- $encrypted := cond (and (isset $params "password") (ne (printf "%v" $params.password) "")) "true" "false" -}} + {{- $pageDate := (.PublishDate | default .Date) -}} + {{- $dateLabel := $pageDate | dateFormat (.Site.Params.dateFormat | default "2006-01-02") -}} + + + + +{{- end -}} diff --git a/layouts/page.html b/layouts/page.html index a9ffb02b..b65f26d6 100644 --- a/layouts/page.html +++ b/layouts/page.html @@ -5,10 +5,11 @@ {{- define "content" -}} {{- $params := partial "function/params.html" -}} + {{- $pagefindEnabled := and .Site.Params.search.enable (eq .Site.Params.search.type "pagefind") -}}
{{- /* Title */ -}} -

{{- cond (.Param "capitalizeTitles") (title .Title) .Title -}}

+

{{- cond (.Param "capitalizeTitles") (title .Title) .Title -}}

{{- /* Subtitle */ -}} {{- with $params.subtitle -}}

{{ . | $.RenderString }}

{{- end -}} @@ -16,7 +17,7 @@ {{- /* Content */ -}} {{- $content := dict "Content" .Content "Ruby" $params.ruby "Fraction" $params.fraction "Fontawesome" $params.fontawesome | partial "function/content.html" | safeHTML -}} -
+
{{- if not $params.password -}} {{- $content -}} {{- end -}} diff --git a/layouts/posts/single.html b/layouts/posts/single.html index e2256a8a..c11adae1 100644 --- a/layouts/posts/single.html +++ b/layouts/posts/single.html @@ -11,6 +11,7 @@ {{- $showToc := $toc.enable | and (ne $tableOfContents ``) -}} {{- .Store.Set "showToc" $showToc -}} {{- $tableOfContents = dict "Content" $tableOfContents "Ruby" $params.ruby "Fraction" $params.fraction "Fontawesome" $params.fontawesome | partial "function/content.html" | safeHTML -}} + {{- $pagefindEnabled := and .Site.Params.search.enable (eq .Site.Params.search.type "pagefind") -}}