Skip to content

Commit 4ffd531

Browse files
0x5c0fLruihao
andauthored
feat(search): add pagefind search engine support (hugo-fixit#738)
* feat(search): add native pagefind support * chore: code review and change --------- Co-authored-by: Cell <1024@lruihao.cn>
1 parent 78778da commit 4ffd531

12 files changed

Lines changed: 218 additions & 7 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ Click the following links to generate a new repository with template:
137137
- **Sub Menu** supported
138138
- **Content Encryption** supported (Pages, Partial)
139139
- **Friends** page embedded template
140-
- **Search** supported by [algolia](https://www.algolia.com/), [Fuse.js](https://fusejs.io/), CSE or [PostChat](https://ai.zhheo.com/console/login?InviteID=85041330)
140+
- **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)
141141
- **Custom Search Engine (CSE)** supported by [Google](https://programmablesearchengine.google.com/)
142142
- **Twemoji** supported
143143
- Automatically **highlighting** code

README.zh-cn.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ pnpx fixit-cli create my-blog
137137
- 支持**二级菜单**
138138
- 支持**内容加密**(页面、局部)
139139
- 支持**友情链接**的页面模板
140-
- 支持基于 [algolia](https://www.algolia.com/)[Fuse.js](https://fusejs.io/) **CSE**[PostChat](https://ai.zhheo.com/console/login?InviteID=85041330)**搜索**
140+
- 支持基于 [algolia](https://www.algolia.com/)[Fuse.js](https://fusejs.io/)[Pagefind](https://pagefind.app)CSE 或 [PostChat](https://ai.zhheo.com/console/login?InviteID=85041330)**搜索**
141141
- 支持基于 [Google](https://programmablesearchengine.google.com/)**自定义搜索引擎 (CSE)**
142142
- 支持 **Twemoji**
143143
- 支持**代码高亮**

assets/js/lib/pagefind-search.js

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
const ABSOLUTE_URL_RE = /^(?:[a-z]+:)?\/\//i;
2+
3+
const normalizeBundlePath = (path, baseURL) => {
4+
let bundlePath = typeof path === 'string' && path.length > 0 ? path : 'pagefind/';
5+
if (!bundlePath.endsWith('/')) {
6+
bundlePath = `${bundlePath}/`;
7+
}
8+
if (ABSOLUTE_URL_RE.test(bundlePath)) {
9+
return bundlePath;
10+
}
11+
return new URL(bundlePath, baseURL || document.baseURI).toString();
12+
};
13+
14+
const toObject = (value) => (value && typeof value === 'object' ? value : {});
15+
16+
const normalizeSortOrder = (value) => (
17+
String(value).toLowerCase() === 'asc'
18+
? 'asc'
19+
: 'desc'
20+
);
21+
22+
const replaceExcerptHighlightTag = (excerpt, highlightTag) => {
23+
if (!excerpt || !highlightTag || highlightTag === 'mark') {
24+
return excerpt || '';
25+
}
26+
27+
return excerpt
28+
.replaceAll('<mark>', `<${highlightTag}>`)
29+
.replaceAll('</mark>', `</${highlightTag}>`);
30+
};
31+
32+
export function createPagefindSearch(searchConfig) {
33+
const pagefindConfig = toObject(searchConfig.pagefind);
34+
const bundlePath = normalizeBundlePath(pagefindConfig.bundlePath, pagefindConfig.baseURL);
35+
const rawDebounceTimeout = Number(pagefindConfig.debounceTimeoutMs ?? 300);
36+
const debounceTimeout = Number.isFinite(rawDebounceTimeout) ? Math.max(0, rawDebounceTimeout) : 300;
37+
const builtInFiltersEnabled = pagefindConfig.useBuiltInFilters !== false;
38+
const sortBy = typeof pagefindConfig.sortBy === 'string' ? pagefindConfig.sortBy.trim() : '';
39+
const sortOrder = normalizeSortOrder(pagefindConfig.sortOrder);
40+
const highlightTag = searchConfig.highlightTag ?? 'em';
41+
const excerptLength = Number(searchConfig.snippetLength ?? 30);
42+
43+
const state = {
44+
loading: null,
45+
initialized: false,
46+
availableFilters: null,
47+
};
48+
49+
const ensurePagefind = async () => {
50+
if (!state.loading) {
51+
state.loading = import(`${bundlePath}pagefind.js`)
52+
.then(async (mod) => {
53+
if (!state.initialized) {
54+
const options = {};
55+
if (Number.isFinite(excerptLength) && excerptLength >= 0) {
56+
options.excerptLength = excerptLength;
57+
}
58+
if (Object.keys(options).length && typeof mod.options === 'function') {
59+
await mod.options(options);
60+
}
61+
await mod.init();
62+
state.initialized = true;
63+
}
64+
return mod;
65+
})
66+
.catch((error) => {
67+
state.loading = null;
68+
throw error;
69+
});
70+
}
71+
return state.loading;
72+
};
73+
74+
const getAvailableFilters = async () => {
75+
if (state.availableFilters) return state.availableFilters;
76+
77+
const pagefind = await ensurePagefind();
78+
if (typeof pagefind.filters !== 'function') {
79+
state.availableFilters = {};
80+
return state.availableFilters;
81+
}
82+
83+
try {
84+
state.availableFilters = toObject(await pagefind.filters());
85+
} catch (error) {
86+
console.warn('[FixIt] failed to read Pagefind filters:', error);
87+
state.availableFilters = {};
88+
}
89+
return state.availableFilters;
90+
};
91+
92+
return {
93+
preload() {
94+
return ensurePagefind();
95+
},
96+
async search(query, maxResultLength) {
97+
if (!query || !query.trim()) return [];
98+
99+
const pagefind = await ensurePagefind();
100+
const searchOptions = {};
101+
102+
if (builtInFiltersEnabled) {
103+
const availableFilters = await getAvailableFilters();
104+
const filters = {};
105+
if (Object.prototype.hasOwnProperty.call(availableFilters, 'hidden')) {
106+
filters.hidden = 'false';
107+
}
108+
if (Object.prototype.hasOwnProperty.call(availableFilters, 'encrypted')) {
109+
filters.encrypted = 'false';
110+
}
111+
if (Object.keys(filters).length) {
112+
searchOptions.filters = filters;
113+
}
114+
}
115+
116+
if (sortBy) {
117+
searchOptions.sort = { [sortBy]: sortOrder };
118+
}
119+
120+
const resultLimit = Number.isFinite(maxResultLength)
121+
? Math.max(0, Math.floor(maxResultLength))
122+
: 10;
123+
124+
const searched = debounceTimeout > 0 && typeof pagefind.debouncedSearch === 'function'
125+
? await pagefind.debouncedSearch(query, searchOptions, debounceTimeout)
126+
: await pagefind.search(query, searchOptions);
127+
128+
if (searched === null) return null;
129+
130+
const records = await Promise.all(
131+
(searched.results || []).slice(0, resultLimit).map((entry) => entry.data()),
132+
);
133+
134+
return records.map((item) => ({
135+
uri: item.url || '#',
136+
title: item.meta?.title || item.url || '',
137+
date: item.meta?.date || '',
138+
context: replaceExcerptHighlightTag(item.excerpt || '', highlightTag),
139+
}));
140+
},
141+
};
142+
}

assets/js/theme.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
HTMLEscape,
1616
} from './utils/common';
1717
import FileTree from './lib/file-tree.js'
18+
import { createPagefindSearch } from './lib/pagefind-search.js'
1819

1920
const copyText = createCopyText();
2021

@@ -340,6 +341,14 @@ class FixIt {
340341
if ($searchInput.value === '') $searchClear.style.display = 'none';
341342
else $searchClear.style.display = 'inline';
342343
}, false);
344+
if (searchConfig.type === 'pagefind') {
345+
this._pagefindSearch = this._pagefindSearch || createPagefindSearch(searchConfig);
346+
$searchInput.addEventListener('focus', () => {
347+
this._pagefindSearch.preload().catch((error) => {
348+
console.error(error);
349+
});
350+
}, { once: true });
351+
}
343352

344353
const initAutosearch = () => {
345354
const autosearch = autocomplete(`#search-input-${suffix}`,
@@ -454,6 +463,16 @@ class FixIt {
454463
context: cseConfig.gotoResultsPage
455464
}]);
456465
}
466+
} else if (searchConfig.type === 'pagefind') {
467+
this._pagefindSearch
468+
.search(query, maxResultLength)
469+
.then((results) => {
470+
finish(results || []);
471+
})
472+
.catch((err) => {
473+
console.error(err);
474+
finish([]);
475+
});
457476
} else {
458477
finish([]);
459478
}
@@ -482,6 +501,11 @@ class FixIt {
482501
href = 'https://programmablesearchengine.google.com/';
483502
}
484503
break;
504+
case 'pagefind':
505+
searchType = 'Pagefind';
506+
icon = '';
507+
href = 'https://pagefind.app/';
508+
break;
485509
default:
486510
searchType = '';
487511
icon = '';

hugo.toml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -507,7 +507,7 @@ dark = "#151b23"
507507
# Search config
508508
[params.search]
509509
enable = false
510-
# type of search engine ["algolia", "fuse", "cse", "post-chat"]
510+
# type of search engine ["algolia", "fuse", "pagefind", "cse", "post-chat"]
511511
type = "fuse"
512512
# max index length of the chunked content
513513
contentLength = 4000
@@ -541,6 +541,19 @@ ignoreLocation = false
541541
useExtendedSearch = false
542542
ignoreFieldNorm = false
543543

544+
# Pagefind search config (http://pagefind.app/)
545+
[params.search.pagefind]
546+
# Pagefind bundle and index directory
547+
bundlePath = "pagefind/"
548+
# debounce timeout in milliseconds, set to 0 to disable debounce
549+
debounceTimeoutMs = 300
550+
# whether to respect FixIt built-in search visibility rules
551+
useBuiltInFilters = true
552+
# optional sort field, current recommended built-in value: "date"
553+
sortBy = ""
554+
# sort order for sortBy: ["asc", "desc"]
555+
sortOrder = "desc"
556+
544557
# FixIt 0.3.16 | NEW Custom Search Engine (CSE)
545558
[params.cse]
546559
# search engine: ["google", "bing"]
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{{- if and (eq .Site hugo.Sites.Default) .Site.Params.search.enable (eq .Site.Params.search.type "pagefind") -}}
2+
{{- warnf "FixIt Pagefind search enabled\nRun `npx pagefind --site <publicDir>` after site build to create the search index.\n\n" -}}
3+
{{- end -}}

layouts/_partials/init/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
{{- partial "init/detection-env.html" . -}}
55
{{- partial "init/detection-version.html" . -}}
66
{{- partial "init/detection-deprecated.html" . -}}
7+
{{- partial "init/detection-pagefind.html" . -}}
78
{{- partial "init/global.html" . -}}
89
{{- partial "init/patch.html" . -}}
910
{{- partial "init/compatibility.html" . -}}

layouts/_partials/layouts/assets.html

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,19 @@
4444
{{- $source := $cdn.fuseJS | default "lib/fuse/fuse.min.js" -}}
4545
{{- dict "Source" $source "Fingerprint" $fingerprint "Defer" true | dict "Page" . "Data" | partial "store/script.html" -}}
4646
{{- $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 -}}
47+
{{- else if eq $search.type "pagefind" -}}
48+
{{- $pagefind := $search.pagefind | default dict -}}
49+
{{- $config = dict
50+
"type" "pagefind"
51+
"pagefind" (dict
52+
"bundlePath" ($pagefind.bundlePath | default "pagefind/")
53+
"baseURL" .Site.BaseURL
54+
"debounceTimeoutMs" ($pagefind.debounceTimeoutMs | default 300)
55+
"useBuiltInFilters" ($pagefind.useBuiltInFilters | default true)
56+
"sortBy" ($pagefind.sortBy | default "")
57+
"sortOrder" ($pagefind.sortOrder | default "desc")
58+
)
59+
| dict "search" | merge $config -}}
4760
{{- else if eq $search.type "cse" -}}
4861
{{- $config = dict "type" "cse" | dict "search" | merge $config -}}
4962
{{- $cse := .Site.Params.cse -}}

layouts/_partials/layouts/head/index.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
{{- template "_internal/opengraph.html" . -}}
3737
{{- partial "layouts/head/twitter-cards.html" . -}}
3838

39+
{{- partial "plugin/pagefind-metadata.html" . -}}
40+
3941
<meta name="application-name" content="{{ .Site.Params.app.title | default .Site.Title }}">
4042
<meta name="apple-mobile-web-app-title" content="{{ .Site.Params.app.title | default .Site.Title }}">
4143

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{{- if and .Site.Params.search.enable (eq .Site.Params.search.type "pagefind") .IsPage -}}
2+
{{- $params := partial "function/params.html" -}}
3+
{{- $hidden := cond (eq $params.hiddenFromSearch true) "true" "false" -}}
4+
{{- $encrypted := cond (and (isset $params "password") (ne (printf "%v" $params.password) "")) "true" "false" -}}
5+
{{- $pageDate := (.PublishDate | default .Date) -}}
6+
{{- $dateLabel := $pageDate | dateFormat (.Site.Params.dateFormat | default "2006-01-02") -}}
7+
<meta data-pagefind-filter="hidden:{{ $hidden }}">
8+
<meta data-pagefind-filter="encrypted:{{ $encrypted }}">
9+
<meta data-pagefind-meta="date:{{ $dateLabel }}">
10+
<meta data-pagefind-sort="date:{{ $pageDate.Unix }}">
11+
{{- end -}}

0 commit comments

Comments
 (0)