Hugo + PaperMod 自定义高级搜索集成档案
项目目标:将 PaperMod 主题默认的独立搜索页面,升级为一个从导航栏触发的、拥有现代化设计和流畅交互的模态框搜索体验。
集成说明:请按照下面的文件路径,创建或覆盖你项目中的相应文件。所有代码块都已准备好,可以直接完整复制和粘贴。
1. 布局文件 (/layouts/)
1.1 layouts/partials/header.html
操作:覆盖此文件。 目的:整合所有功能,修复所有模板错误,并引入搜索触发器。
{{- $currentPage := . }}
{{- /* theme-toggle is enabled */}}
{{- if (not site.Params.disableThemeToggle) }}
{{- /* theme is light */}}
{{- if (eq site.Params.defaultTheme "light") }}
<script>
if (localStorage.getItem("pref-theme") === "dark") {
document.body.classList.add('dark');
}
</script>
{{- /* theme is dark */}}
{{- else if (eq site.Params.defaultTheme "dark") }}
<script>
if (localStorage.getItem("pref-theme") === "light") {
document.body.classList.remove('dark')
}
</script>
{{- else }}
{{- /* theme is auto */}}
<script>
if (localStorage.getItem("pref-theme") === "dark") {
document.body.classList.add('dark');
} else if (localStorage.getItem("pref-theme") === "light") {
document.body.classList.remove('dark')
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.body.classList.add('dark');
}
</script>
{{- end }}
{{- /* theme-toggle is disabled and theme is auto */}}
{{- else if (and (ne site.Params.defaultTheme "light") (ne site.Params.defaultTheme "dark"))}}
<script>
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.body.classList.add('dark');
}
</script>
{{- end }}
<header class="header">
<nav class="nav">
<div class="logo">
{{- $label_text := (site.Params.label.text | default site.Title) }}
{{- if site.Title }}
<a href="{{ "" | absLangURL }}" accesskey="h" title="{{ $label_text }} (Alt + H)">
{{- if site.Params.label.icon }}
{{- $img := resources.Get site.Params.label.icon }}
{{- if $img }}
{{- $processableFormats := (slice "jpg" "jpeg" "png" "tif" "bmp" "gif") -}}
{{- if hugo.IsExtended -}}
{{- $processableFormats = $processableFormats | append "webp" -}}
{{- end -}}
{{- $prod := (hugo.IsProduction | or (eq site.Params.env "production")) }}
{{- if and (in $processableFormats $img.MediaType.SubType) (eq $prod true)}}
{{- if site.Params.label.iconHeight }}
{{- $img = $img.Resize (printf "x%d" site.Params.label.iconHeight) }}
{{ else }}
{{- $img = $img.Resize "x30" }}
{{- end }}
{{- end }}
<img src="{{ $img.Permalink }}" alt="" aria-label="logo"
height="{{- site.Params.label.iconHeight | default "30" -}}">
{{- else }}
<img src="{{- site.Params.label.icon | absURL -}}" alt="" aria-label="logo"
height="{{- site.Params.label.iconHeight | default "30" -}}">
{{- end -}}
{{- else if hasPrefix site.Params.label.iconSVG "<svg" }}
{{ site.Params.label.iconSVG | safeHTML }}
{{- end -}}
{{- $label_text -}}
</a>
{{- end }}
<div class="logo-switches">
{{- if (not site.Params.disableThemeToggle) }}
<button id="theme-toggle" accesskey="t" title="(Alt + T)" aria-label="Toggle theme">
<svg id="moon" xmlns="http://www.w3.org/2000/svg" width="24" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
<svg id="sun" xmlns="http://www.w3.org/2000/svg" width="24" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round">
<circle cx="12" cy="12" r="5"></circle>
<line x1="12" y1="1" x2="12" y2="3"></line>
<line x1="12" y1="21" x2="12" y2="23"></line>
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
<line x1="1" y1="12" x2="3" y2="12"></line>
<line x1="21" y1="12" x2="23" y2="12"></line>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
</svg>
</button>
{{- end }}
{{- $lang := .Lang}}
{{- $separator := or $label_text (not site.Params.disableThemeToggle)}}
{{- with site.Home.Translations }}
<ul class="lang-switch">
{{- if $separator }}<li>|</li>{{ end }}
{{- range . -}}
{{- if ne $lang .Lang }}
<li>
<a href="{{- .Permalink -}}" title="{{ .Language.Params.languageAltTitle | default (.Language.LanguageName | emojify) | default (.Lang | title) }}"
aria-label="{{ .Language.LanguageName | default (.Lang | title) }}">
{{- if (and site.Params.displayFullLangName (.Language.LanguageName)) }}
{{- .Language.LanguageName | emojify -}}
{{- else }}
{{- .Lang | title -}}
{{- end -}}
</a>
</li>
{{- end -}}
{{- end}}
</ul>
{{- end }}
</div>
</div>
<ul id="menu">
{{- range site.Menus.main }}
{{- $menu_item_url := (cond (strings.HasSuffix .URL "/") .URL (printf "%s/" .URL) ) | absLangURL }}
{{- $page_url:= $currentPage.Permalink | absLangURL }}
{{- $is_search := eq (site.GetPage .KeyName).Layout `search` }}
<li>
<a href="{{ .URL | absLangURL }}" title="{{ .Title | default .Name }} {{- cond $is_search (" (Alt + /)" | safeHTMLAttr) ("" | safeHTMLAttr ) }}"
{{- cond $is_search (" accesskey=/" | safeHTMLAttr) ("" | safeHTMLAttr ) }}>
<span {{- if eq $menu_item_url $page_url }} class="active" {{- end }}>
{{- .Pre }}
{{- .Name -}}
{{ .Post -}}
</span>
{{- if (findRE "://" .URL) }}
<svg fill="none" shape-rendering="geometricPrecision" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" stroke-width="2.5" viewBox="0 0 24 24" height="12" width="12">
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"></path>
<path d="M15 3h6v6"></path>
<path d="M10 14L21 3"></path>
</svg>
{{- end }}
</a>
</li>
{{- end }}
</ul>
<!-- BEGIN: 我们的自定义搜索整合点 -->
{{- partial "search.html" . -}}
<!-- END: 我们的自定义搜索整合点 -->
</nav>
</header>
1.2 layouts/partials/search.html
操作:新建此文件。 目的:定义搜索功能的 HTML 骨架。
<a class="search-trigger" href="#search" title="搜索 (Alt + S)" accesskey="s">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
</a>
<div id="search-modal" class="search-modal">
<div class="search-modal-content">
<div class="search-input-wrapper">
<svg class="search-icon-inside" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
<input type="text" id="search-input" placeholder="搜索文章、片段或标签...">
</div>
<div id="search-results-container">
<ul id="search-results"></ul>
<div id="no-results-found" style="display: none;">
<p>未能找到与 "<span id="search-query-display"></span>" 相关的内容。</p>
</div>
</div>
</div>
</div>
1.3 layouts/partials/footer.html
操作:覆盖此文件。 目的:修复主题自带的 JavaScript 潜在错误。
{{- if not (.Param "hideFooter") }}
<footer class="footer">
{{- if not site.Params.footer.hideCopyright }}
{{- if site.Copyright }}
<span>{{ site.Copyright | markdownify }}</span>
{{- else }}
<span>© {{ now.Year }} <a href="{{ "" | absLangURL }}">{{ site.Title }}</a></span>
{{- end }}
{{- print " · "}}
{{- end }}
{{- with site.Params.footer.text }}
{{ . | markdownify }}
{{- print " · "}}
{{- end }}
<span>
Powered by
<a href="https://gohugo.io/" rel="noopener noreferrer" target="_blank">Hugo</a> &
<a href="https://github.com/adityatelange/hugo-PaperMod/" rel="noopener" target="_blank">PaperMod</a>
</span>
</footer>
{{- end }}
{{- if (not site.Params.disableScrollToTop) }}
<a href="#top" aria-label="go to top" title="Go to Top (Alt + G)" class="top-link" id="top-link" accesskey="g">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 6" fill="currentColor">
<path d="M12 6H0l6-6z" />
</svg>
</a>
{{- end }}
{{- partial "extend_footer.html" . }}
<script>
let menu = document.getElementById('menu')
if (menu) {
menu.scrollLeft = localStorage.getItem("menu-scroll-position");
menu.onscroll = function () {
localStorage.setItem("menu-scroll-position", menu.scrollLeft);
}
}
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener("click", function (e) {
e.preventDefault();
var id = this.getAttribute("href").substr(1);
const targetElement = document.querySelector(`[id='${decodeURIComponent(id)}']`);
if (targetElement) {
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
targetElement.scrollIntoView({
behavior: "smooth"
});
} else {
targetElement.scrollIntoView();
}
}
if (id === "top") {
history.replaceState(null, null, " ");
} else {
history.pushState(null, null, `#${id}`);
}
});
});
</script>
{{- if (not site.Params.disableScrollToTop) }}
<script>
var mybutton = document.getElementById("top-link");
if(mybutton) {
window.onscroll = function () {
if (document.body.scrollTop > 800 || document.documentElement.scrollTop > 800) {
mybutton.style.visibility = "visible";
mybutton.style.opacity = "1";
} else {
mybutton.style.visibility = "hidden";
mybutton.style.opacity = "0";
}
};
}
</script>
{{- end }}
{{- if (not site.Params.disableThemeToggle) }}
<script>
document.getElementById("theme-toggle").addEventListener("click", () => {
if (document.body.className.includes("dark")) {
document.body.classList.remove('dark');
localStorage.setItem("pref-theme", 'light');
} else {
document.body.classList.add('dark');
localStorage.setItem("pref-theme", 'dark');
}
})
</script>
{{- end }}
{{- if (and (eq .Kind "page") (ne .Layout "archives") (ne .Layout "search") (.Param "ShowCodeCopyButtons")) }}
<script>
document.querySelectorAll('pre > code').forEach((codeblock) => {
const container = codeblock.parentNode.parentNode;
const copybutton = document.createElement('button');
copybutton.classList.add('copy-code');
copybutton.innerHTML = '{{- i18n "code_copy" | default "copy" }}';
copybutton.addEventListener('click', (cb) => {
if ('clipboard' in navigator) {
navigator.clipboard.writeText(codeblock.textContent);
copyingDone();
return;
}
const range = document.createRange();
range.selectNodeContents(codeblock);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
try {
document.execCommand('copy');
copyingDone();
} catch (e) { };
selection.removeRange(range);
});
function copyingDone() {
copybutton.innerHTML = '{{- i18n "code_copied" | default "copied!" }}';
setTimeout(() => {
copybutton.innerHTML = '{{- i18n "code_copy" | default "copy" }}';
}, 2000);
}
if (container.classList.contains("highlight")) {
container.appendChild(copybutton);
} else if (container.parentNode.firstChild == container) {
} else if (codeblock.parentNode.parentNode.parentNode.parentNode.parentNode.nodeName == "TABLE") {
codeblock.parentNode.parentNode.parentNode.parentNode.parentNode.appendChild(copybutton);
} else {
codeblock.parentNode.appendChild(copybutton);
}
});
</script>
{{- end }}
1.4 layouts/partials/extend_head.html
操作:新建或修改此文件。 目的:安全地注入自定义 CSS。
{{ $searchCSS := resources.Get "css/search.css" }}
{{ if $searchCSS }}
<link rel="stylesheet" href="{{ $searchCSS.RelPermalink }}">
{{ end }}
1.5 layouts/partials/extend_footer.html
操作:新建或修改此文件。 目的:安全地注入自定义 JavaScript。
{{ $searchJS := resources.Get "js/search.js" }}
{{ if $searchJS }}
<script src="{{ $searchJS.RelPermalink }}"></script>
{{ end }}
2. 静态资源文件 (/assets/)
2.1 assets/js/search.js
操作:新建此文件。 目的:驱动搜索功能的交互逻辑。
(function () {
const searchTrigger = document.querySelector('.search-trigger');
const searchModal = document.getElementById('search-modal');
const searchInput = document.getElementById('search-input');
const resultsContainer = document.getElementById('search-results-container');
const searchResultsList = document.getElementById('search-results');
const noResultsDiv = document.getElementById('no-results-found');
const queryDisplay = document.getElementById('search-query-display');
if (!searchTrigger || !searchModal || !searchInput || !resultsContainer || !searchResultsList || !noResultsDiv) {
console.error("搜索功能的一个或多个关键HTML元素未找到。请检查 layouts/partials/search.html。");
return;
}
let searchIndex = null;
let indexLoaded = false;
let activeResultIndex = -1;
async function loadSearchIndex() {
if (indexLoaded) return;
try {
const response = await fetch('/index.json');
if (!response.ok) { console.error('搜索索引加载失败。'); return; }
searchIndex = await response.json();
indexLoaded = true;
} catch (error) { console.error('获取搜索索引时出错:', error); }
}
function openModal() {
searchModal.classList.add('is-active');
document.body.style.overflow = 'hidden';
setTimeout(() => searchInput.focus(), 100);
loadSearchIndex();
}
function closeModal() {
searchModal.classList.remove('is-active');
document.body.style.overflow = '';
searchInput.value = '';
clearResults();
}
function performSearch() {
const query = searchInput.value.trim();
if (!query) {
clearResults();
return;
}
if (!indexLoaded) {
return;
}
const lowerCaseQuery = query.toLowerCase();
const results = searchIndex.filter(item =>
item.title.toLowerCase().includes(lowerCaseQuery) ||
item.content.toLowerCase().includes(lowerCaseQuery)
);
displayResults(results, query);
}
function displayResults(results, query) {
clearResults();
activeResultIndex = -1;
resultsContainer.style.display = 'block';
if (results.length === 0) {
queryDisplay.textContent = query;
noResultsDiv.style.display = 'block';
return;
}
noResultsDiv.style.display = 'none';
const queryRegex = new RegExp(`(${query.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&')})`, 'gi');
results.slice(0, 20).forEach(result => {
const li = document.createElement('li');
const highlightedTitle = result.title.replace(queryRegex, '<mark>$1</mark>');
const highlightedSummary = result.content.substring(0, 150).replace(queryRegex, '<mark>$1</mark>');
li.innerHTML = `<a href="${result.permalink}"><div class="search-result-title">${highlightedTitle}</div><div class="search-result-summary">${highlightedSummary}...</div></a>`;
searchResultsList.appendChild(li);
});
}
function clearResults() {
searchResultsList.innerHTML = '';
noResultsDiv.style.display = 'none';
resultsContainer.style.display = 'none';
}
function handleKeyboardNav(e) {
if (!['ArrowDown', 'ArrowUp', 'Enter'].includes(e.key)) return;
e.preventDefault();
const results = searchResultsList.querySelectorAll('a');
if (results.length === 0) return;
if (e.key === 'ArrowDown') { activeResultIndex = (activeResultIndex + 1) % results.length; }
else if (e.key === 'ArrowUp') { activeResultIndex = (activeResultIndex - 1 + results.length) % results.length; }
else if (e.key === 'Enter' && activeResultIndex > -1) { results[activeResultIndex].click(); return; }
updateActiveResult(results);
}
function updateActiveResult(results) {
results.forEach(result => result.classList.remove('is-active-result'));
if (activeResultIndex > -1) {
results[activeResultIndex].classList.add('is-active-result');
results[activeResultIndex].scrollIntoView({ block: 'nearest' });
}
}
searchTrigger.addEventListener('click', e => { e.preventDefault(); openModal(); });
searchModal.addEventListener('click', e => { if (e.target === searchModal) closeModal(); });
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && searchModal.classList.contains('is-active')) { closeModal(); }
else if (searchModal.classList.contains('is-active')) handleKeyboardNav(e);
});
let debounceTimer;
searchInput.addEventListener('input', () => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(performSearch, 200);
});
})();
2.2 assets/css/search.css
操作:新建此文件。 目的:定义搜索功能的全部样式。
/*
==============================================
最终的、优化了“层次感与清晰度”的样式表
==============================================
*/
/* --- 1. 触发器 --- */
.nav { display: flex; justify-content: space-between; align-items: center; }
.search-trigger { order: 10; display: inline-block; padding: 0 12px; cursor: pointer; color: var(--primary); line-height: var(--header-height); transition: transform 0.2s ease-in-out; }
.search-trigger:hover { transform: scale(1.1); }
/* --- 2. 遮罩层 --- */
#search-modal { display: none; position: fixed; z-index: 9999; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.4); -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); align-items: flex-start; justify-content: center; padding: 20px; padding-top: 15vh; }
#search-modal.is-active { display: flex; }
#search-modal .search-modal-content { width: 100%; max-width: 640px; background: transparent; border: none; box-shadow: none; border-radius: 0; }
/* --- 3. 搜索框“药丸” --- */
#search-modal .search-input-wrapper { display: flex; align-items: center; border-radius: 999px; background: var(--card-bg); padding: 10px 20px; border: 1px solid var(--search-box-border-color, var(--border)); box-shadow: var(--card-shadow); transition: border-color 0.2s ease; animation: popInSlightly 0.4s cubic-bezier(0.2, 0.8, 0.2, 1); }
#search-modal .search-input-wrapper:focus-within { border-color: var(--primary); }
#search-modal .search-icon-inside { color: var(--secondary); margin-right: 12px; }
#search-modal #search-input { width: 100%; font-size: 1.1rem; border: none; outline: none; background: transparent; color: var(--content); }
/* --- 4. 结果容器“卡片” --- */
#search-modal #search-results-container {
display: none;
margin-top: 12px;
background: var(--card-bg-t, rgba(255, 255, 255, 0.85));
.dark & {
background: var(--dark-card-bg-t, rgba(30, 30, 30, 0.85));
}
border-radius: var(--radius);
border: 1px solid var(--border);
box-shadow: var(--card-shadow);
padding: 8px;
max-height: 60vh;
overflow-y: auto;
animation: expandDown 0.3s ease;
}
/* --- 5. 结果项样式 --- */
#search-modal #search-results { list-style: none; padding: 0; margin: 0; }
#search-modal #search-results li a { display: block; padding: 10px 12px; border-radius: calc(var(--radius) - 4px); text-decoration: none; transition: background-color 0.15s ease; }
#search-modal #search-results .search-result-title { color: var(--content); font-size: 1em; font-weight: 500; }
#search-modal #search-results .search-result-summary { color: var(--secondary); font-size: 0.9em; margin-top: 4px; }
#search-modal #search-results li a:hover,
#search-modal #search-results li a.is-active-result {
background-color: rgba(0, 0, 0, 0.05);
.dark & {
background-color: rgba(255, 255, 255, 0.1);
}
}
#search-modal #search-results mark { background-color: var(--tertiary-bg); color: var(--primary); padding: 1px 3px; border-radius: 3px; }
/* --- 6. 动画与工具 --- */
@keyframes popInSlightly { from { opacity: 0; transform: translateY(-20px); } to { opacity: 1; transform: translateY(0); } }
@keyframes expandDown { from { opacity: 0; transform: scaleY(0.95); transform-origin: top; } to { opacity: 1; transform: scaleY(1); transform-origin: top;} }
#search-modal #no-results-found { text-align: center; padding: 20px; color: var(--secondary); }
2.3 assets/css/extended/variables.css
操作:新建此文件和 extended 文件夹。 目的:安全地定义我们自己的 CSS 变量。
/*
==============================================
自定义颜色变量
==============================================
*/
:root {
/* 在亮色模式下,我们用一个清晰、专业的中灰色 */
--search-box-border-color: #c0c0c0;
}
.dark {
/* 在暗黑模式下,我们用一个能与黑色背景拉开层次的、更亮的灰色 */
--search-box-border-color: #555555;
}