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('', `${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),
+ }));
+ },
+ };
+}
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") -}}