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;
}