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>
This commit is contained in:
0x5c0f
2026-05-19 16:19:39 +08:00
committed by GitHub
parent 78778dad08
commit 4ffd531d63
12 changed files with 218 additions and 7 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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**
- 支持**代码高亮**
+142
View File
@@ -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('<mark>', `<${highlightTag}>`)
.replaceAll('</mark>', `</${highlightTag}>`);
};
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),
}));
},
};
}
+24
View File
@@ -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 = '';
+14 -1
View File
@@ -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"]
@@ -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 <publicDir>` after site build to create the search index.\n\n" -}}
{{- end -}}
+1
View File
@@ -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" . -}}
+13
View File
@@ -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 -}}
@@ -36,6 +36,8 @@
{{- template "_internal/opengraph.html" . -}}
{{- partial "layouts/head/twitter-cards.html" . -}}
{{- partial "plugin/pagefind-metadata.html" . -}}
<meta name="application-name" content="{{ .Site.Params.app.title | default .Site.Title }}">
<meta name="apple-mobile-web-app-title" content="{{ .Site.Params.app.title | default .Site.Title }}">
@@ -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") -}}
<meta data-pagefind-filter="hidden:{{ $hidden }}">
<meta data-pagefind-filter="encrypted:{{ $encrypted }}">
<meta data-pagefind-meta="date:{{ $dateLabel }}">
<meta data-pagefind-sort="date:{{ $pageDate.Unix }}">
{{- end -}}
+3 -2
View File
@@ -5,10 +5,11 @@
{{- define "content" -}}
{{- $params := partial "function/params.html" -}}
{{- $pagefindEnabled := and .Site.Params.search.enable (eq .Site.Params.search.type "pagefind") -}}
<article class="page single special">
<div class="header">
{{- /* Title */ -}}
<h1 class="single-title animate__animated animate__pulse animate__faster">{{- cond (.Param "capitalizeTitles") (title .Title) .Title -}}</h1>
<h1 class="single-title animate__animated animate__pulse animate__faster"{{ if $pagefindEnabled }} data-pagefind-body{{ end }}>{{- cond (.Param "capitalizeTitles") (title .Title) .Title -}}</h1>
{{- /* Subtitle */ -}}
{{- with $params.subtitle -}}<p class="single-subtitle animate__animated animate__fadeIn">{{ . | $.RenderString }}</p>{{- end -}}
@@ -16,7 +17,7 @@
{{- /* Content */ -}}
{{- $content := dict "Content" .Content "Ruby" $params.ruby "Fraction" $params.fraction "Fontawesome" $params.fontawesome | partial "function/content.html" | safeHTML -}}
<div class="content" id="content">
<div class="content" id="content"{{ if $pagefindEnabled }} data-pagefind-body{{ end }}>
{{- if not $params.password -}}
{{- $content -}}
{{- end -}}
+3 -2
View File
@@ -11,6 +11,7 @@
{{- $showToc := $toc.enable | and (ne $tableOfContents `<nav id="TableOfContents"></nav>`) -}}
{{- .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") -}}
<aside class="aside-collection animate__animated animate__fadeIn animate__faster" aria-label="{{ T "collections" }}">
{{- /* Collection List */ -}}
@@ -26,7 +27,7 @@
<article class="page single"{{ with .Params.fromAdapters }} data-adapters="{{ . }}"{{ end }}>
<div class="header">
{{- /* Title */ -}}
<h1 class="single-title animate__animated animate__flipInX">
<h1 class="single-title animate__animated animate__flipInX"{{ if $pagefindEnabled }} data-pagefind-body{{ end }}>
{{- $repost := $params.repost | default dict -}}
{{- with $repost -}}
{{- if eq .Enable true -}}
@@ -181,7 +182,7 @@
{{- /* Content */ -}}
{{- $content := dict "Content" .Content "Ruby" $params.ruby "Fraction" $params.fraction "Fontawesome" $params.fontawesome | partial "function/content.html" | safeHTML -}}
<div class="content" id="content"{{ with $params.endFlag }} data-end-flag="{{ . }}"{{ end }}>
<div class="content" id="content"{{ if $pagefindEnabled }} data-pagefind-body{{ end }}{{ with $params.endFlag }} data-end-flag="{{ . }}"{{ end }}>
{{- if not $params.password -}}
{{- $content -}}
{{- end -}}