mirror of
https://github.com/hugo-fixit/FixIt.git
synced 2026-08-24 15:28:57 +00:00
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:
@@ -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
@@ -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**
|
||||
- 支持**代码高亮**
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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 -}}
|
||||
@@ -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" . -}}
|
||||
|
||||
@@ -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
@@ -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 -}}
|
||||
|
||||
@@ -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 -}}
|
||||
|
||||
Reference in New Issue
Block a user