diff --git a/.github/workflows/daily-question-sync.yml b/.github/workflows/daily-question-sync.yml index 7b2cc39..e594d59 100644 --- a/.github/workflows/daily-question-sync.yml +++ b/.github/workflows/daily-question-sync.yml @@ -74,6 +74,10 @@ jobs: if: steps.import.outputs.updated == 'true' run: hugo --minify + - name: 校验搜索索引收录 + if: steps.import.outputs.updated == 'true' + run: python3 scripts/check_search_index.py --site public + - name: 校验待提交文件 if: steps.import.outputs.updated == 'true' shell: bash diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 342ac11..f51b32e 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -42,3 +42,6 @@ jobs: - name: Build run: hugo --minify + + - name: Check search index coverage + run: python3 scripts/check_search_index.py --site public diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 0df5665..ce90f36 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -48,6 +48,9 @@ jobs: - name: Build run: hugo --minify + - name: Check search index coverage + run: python3 scripts/check_search_index.py --site public + - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: diff --git a/assets/_custom.scss b/assets/_custom.scss index 6256090..e88a550 100644 --- a/assets/_custom.scss +++ b/assets/_custom.scss @@ -1085,6 +1085,21 @@ select { transform: translateY(-1px); } +.sidebar-resizer { + display: none; +} + +body.sidebar-resizing, +body.sidebar-resizing * { + cursor: col-resize !important; + user-select: none !important; +} + +body.sidebar-resizing .book-menu, +body.sidebar-resizing .book-menu .book-menu-content { + transition: none; +} + .sidebar-toggle-icon { display: inline-flex; align-items: center; @@ -1161,14 +1176,55 @@ select { margin: 0 0 1rem; } +.book-search-topbar { + display: none; +} + +.book-search-field { + position: relative; +} + +.book-search-filters { + display: none; +} + +.book-search-clear { + position: absolute; + top: 50%; + right: 0.55rem; + padding: 0.3rem 0.48rem; + border: 0; + border-radius: 0.55rem; + background: rgba(var(--goclub-primary-rgb), 0.08); + color: var(--goclub-primary); + font-size: 0.68rem; + font-weight: 750; + cursor: pointer; + transform: translateY(-50%); +} + +.book-search-summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.book-search.is-results-collapsed .book-search-summary, +.book-search.is-results-collapsed #book-search-results { + display: none; +} + .book-menu .book-search input { width: 100%; - padding: 0.88rem 1rem; + padding: 0.78rem 1rem 0.98rem; border-radius: 1rem; border: 1px solid rgba(112, 121, 120, 0.18); background: rgba(255, 255, 255, 0.92); color: var(--body-font-color); + line-height: 1.5; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72); + padding-right: 3.75rem; } .book-menu .book-search input::placeholder { @@ -1204,6 +1260,335 @@ select { color: rgba(27, 28, 26, 0.54); } +.book-menu .book-search-status { + margin-top: 0.65rem; + padding: 0.55rem 0.7rem; + border-radius: 0.8rem; + background: rgba(var(--goclub-primary-rgb), 0.07); + color: var(--goclub-muted-text); + font-size: 0.74rem; + line-height: 1.45; +} + +.book-menu .book-search-status.is-error { + background: rgba(176, 57, 57, 0.08); + color: #8f3131; +} + +.book-menu #book-search-results:empty { + display: none; +} + +.book-menu #book-search-results .book-search-result + .book-search-result { + margin-top: 0.24rem; +} + +.book-menu #book-search-results .book-search-result a { + display: block; + padding: 0.72rem; + border-radius: 0.78rem; + text-decoration: none; +} + +.book-menu #book-search-results .book-search-result a:hover, +.book-menu #book-search-results .book-search-result a:focus-visible { + background: rgba(var(--goclub-primary-rgb), 0.08); +} + +.book-search-result-heading { + display: flex; + align-items: flex-start; + gap: 0.45rem; +} + +.book-search-result-badge { + flex: 0 0 auto; + margin-top: 0.06rem; + padding: 0.14rem 0.38rem; + border-radius: 999px; + background: rgba(var(--goclub-primary-rgb), 0.11); + color: var(--goclub-primary); + font-size: 0.62rem; + font-weight: 800; + line-height: 1.35; +} + +.book-search-result-badge.is-book { + background: rgba(var(--goclub-tertiary-rgb), 0.13); + color: var(--goclub-tertiary); +} + +.book-search-result-title { + min-width: 0; + color: var(--body-font-color); + font-size: 0.82rem; + line-height: 1.45; +} + +.book-menu #book-search-results .book-search-result-breadcrumb { + display: block; + padding: 0; +} + +.book-search-result-breadcrumb { + margin-top: 0.32rem; + overflow: hidden; + color: var(--goclub-muted-text); + font-size: 0.67rem; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.book-menu #book-search-results .book-search-result-snippet { + display: -webkit-box; + margin-top: 0.38rem; + overflow: hidden; + color: var(--goclub-muted-text); + font-size: 0.7rem; + line-height: 1.5; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.book-search-result mark { + padding: 0 0.08em; + border-radius: 0.2em; + background: rgba(224, 183, 72, 0.34); + color: inherit; +} + +.book-search-backdrop { + position: fixed; + inset: 0; + z-index: 79; + border: 0; + background: rgba(var(--goclub-ink-rgb), 0.34); + backdrop-filter: blur(3px); + cursor: default; +} + +.book-search.is-expanded { + position: fixed; + top: 1rem; + left: calc(max(1rem, calc((100vw - 102rem) / 2 + 1rem))); + z-index: 80; + display: flex; + flex-direction: column; + width: calc(min(46rem, calc(100vw - 2rem))); + height: calc(min(48rem, calc(100vh - 2rem))); + margin: 0; + padding: 1rem; + overflow: hidden; + border: 1px solid rgba(var(--goclub-primary-rgb), 0.2); + border-radius: 1.35rem; + background: + radial-gradient(circle at top right, rgba(255, 223, 150, 0.32), transparent 16rem), + rgba(246, 245, 241, 0.98); + box-shadow: 0 30px 80px rgba(var(--goclub-ink-rgb), 0.24); +} + +.book-search.is-expanded .book-search-topbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.85rem; +} + +.book-search.is-expanded .book-search-topbar strong, +.book-search.is-expanded .book-search-topbar small { + display: block; +} + +.book-search.is-expanded .book-search-topbar strong { + color: var(--goclub-primary); + font-family: var(--goclub-font-headline); + font-size: 1.25rem; +} + +.book-search.is-expanded .book-search-topbar small { + margin-top: 0.2rem; + color: var(--goclub-muted-text); + font-size: 0.76rem; +} + +.book-search-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + padding: 0; + border: 1px solid rgba(var(--goclub-primary-rgb), 0.14); + border-radius: 999px; + background: rgba(255, 255, 255, 0.82); + color: var(--goclub-primary); + font-size: 1.25rem; + cursor: pointer; +} + +.book-search.is-expanded input { + width: 100%; + padding: 0.9rem 4.5rem 1.1rem 1.05rem; + border: 1px solid rgba(var(--goclub-primary-rgb), 0.22); + border-radius: 1rem; + background: rgba(255, 255, 255, 0.96); + color: var(--body-font-color); + font-size: 1rem; + line-height: 1.5; + box-shadow: 0 8px 24px rgba(var(--goclub-ink-rgb), 0.07); +} + +.book-search.is-expanded input:focus { + outline: none; + border-color: rgba(var(--goclub-primary-rgb), 0.5); + box-shadow: 0 0 0 0.25rem rgba(var(--goclub-primary-rgb), 0.12); +} + +.book-search.is-expanded .book-search-clear { + right: 0.7rem; + padding: 0.42rem 0.62rem; + font-size: 0.74rem; +} + +.book-search.is-expanded .book-search-filters { + display: flex; + flex: 0 0 auto; + gap: 0.45rem; + margin-top: 0.7rem; + padding: 0.05rem 0.1rem 0.2rem; + overflow-x: auto; + overscroll-behavior-inline: contain; + scrollbar-width: thin; +} + +.book-search-filter { + flex: 0 0 auto; + padding: 0.38rem 0.68rem; + border: 1px solid rgba(var(--goclub-primary-rgb), 0.14); + border-radius: 999px; + background: rgba(255, 255, 255, 0.78); + color: var(--goclub-muted-text); + font-size: 0.72rem; + font-weight: 750; + line-height: 1.35; + white-space: nowrap; + cursor: pointer; +} + +.book-search-filter:hover, +.book-search-filter:focus-visible { + border-color: rgba(var(--goclub-primary-rgb), 0.34); + background: rgba(var(--goclub-primary-rgb), 0.08); + color: var(--goclub-primary); +} + +.book-search-filter:focus-visible { + outline: 2px solid rgba(var(--goclub-primary-rgb), 0.28); + outline-offset: 2px; +} + +.book-search-filter[aria-pressed="true"] { + border-color: var(--goclub-primary); + background: var(--goclub-primary); + color: var(--goclub-on-primary-action); +} + +.book-search.is-expanded .book-search-status { + margin: 0.6rem 0; + padding: 0; + background: transparent; + font-size: 0.78rem; +} + +.book-search.is-expanded #book-search-results { + display: grid; + flex: 1 1 auto; + grid-template-columns: repeat(2, minmax(0, 1fr)); + align-content: start; + gap: 0.65rem; + min-height: 0; + margin: 0; + padding: 0.15rem 0.25rem 0.4rem 0; + overflow-y: auto; + border: 0; + background: transparent; + list-style: none; +} + +.book-search.is-expanded #book-search-results:empty { + display: none; +} + +.book-search.is-expanded .book-search-result { + min-width: 0; + margin: 0; + border: 1px solid rgba(var(--goclub-primary-rgb), 0.12); + border-radius: 1rem; + background: rgba(255, 255, 255, 0.84); +} + +.book-search.is-expanded .book-search-result a { + display: block; + min-height: 100%; + padding: 0.85rem; + border-radius: inherit; + color: var(--body-font-color); + text-decoration: none; +} + +.book-search.is-expanded .book-search-result a:hover, +.book-search.is-expanded .book-search-result a:focus-visible { + background: rgba(var(--goclub-primary-rgb), 0.07); + outline: none; +} + +.book-search.is-expanded .book-search-result-title { + font-size: 0.88rem; +} + +.book-search.is-expanded .book-search-result-breadcrumb, +.book-search.is-expanded .book-search-result-snippet { + display: block; +} + +.book-search.is-expanded .book-search-result-snippet { + display: -webkit-box; + margin-top: 0.45rem; + overflow: hidden; + color: var(--goclub-muted-text); + font-size: 0.75rem; + line-height: 1.55; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; +} + +body.search-overlay-open { + overflow: hidden; +} + +body.search-overlay-open .goclub-chatbot { + visibility: hidden; + pointer-events: none; +} + +mark.goclub-search-hit { + padding: 0.05em 0.14em; + border-radius: 0.24em; + background: rgba(255, 217, 102, 0.72); + color: inherit; + scroll-margin-block: 35vh; + box-decoration-break: clone; + -webkit-box-decoration-break: clone; +} + +mark.goclub-search-hit.is-current { + background: #ffd166; + box-shadow: 0 0 0 0.2rem rgba(255, 193, 7, 0.2); + outline: 1px solid rgba(139, 94, 0, 0.24); +} + .book-menu nav ul ul { margin-top: 0.35rem; padding-inline-start: 1rem; @@ -1268,20 +1653,24 @@ body.sidebar-collapsed .sidebar-toggle:hover { transform: translateX(50%) translateY(-1px); } +body.sidebar-collapsed .goclub-umami-stats--sidebar, body.sidebar-collapsed .book-brand-copy, +body.sidebar-collapsed .book-brand-container .theme-toggle, body.sidebar-collapsed .book-search, body.sidebar-collapsed .book-menu nav > ul { - opacity: 0; - pointer-events: none; - visibility: hidden; + display: none; } body.sidebar-collapsed .book-brand a { + width: 100%; justify-content: center; - padding-inline: 0.6rem; + gap: 0; + padding: 0.3rem; } +body.sidebar-collapsed .book-brand-container, body.sidebar-collapsed .book-brand { + width: 100%; margin-bottom: 0; } @@ -2303,6 +2692,19 @@ body.sidebar-collapsed .book-brand-mark { display: none; } + .book-search.is-expanded { + inset: 0.75rem; + width: auto; + height: auto; + max-height: calc(100vh - 1.5rem); + padding: 0.85rem; + border-radius: 1.1rem; + } + + .book-search.is-expanded #book-search-results { + grid-template-columns: 1fr; + } + .book-page > article.book-article { padding: 1.35rem; border-radius: 1.35rem; @@ -2618,7 +3020,6 @@ body { } .book-brand a, -.book-menu .book-search input, .book-menu #book-search-results, .book-header, .book-toc .book-toc-content, @@ -3753,6 +4154,49 @@ body { transform: none; } + .sidebar-resizer { + position: fixed; + top: 1.5rem; + bottom: 1.5rem; + left: calc(max(1rem, calc((100vw - 102rem) / 2 + 1rem)) + var(--goclub-sidebar-width) - 0.3rem); + z-index: 8; + display: block; + width: 0.6rem; + border-radius: 999px; + cursor: col-resize; + touch-action: none; + } + + .sidebar-resizer::before { + position: absolute; + top: 50%; + left: 50%; + width: 0.22rem; + height: 4.5rem; + border-radius: 999px; + background: rgba(var(--goclub-primary-rgb), 0.16); + content: ""; + opacity: 0.38; + transform: translate(-50%, -50%); + transition: opacity 0.18s ease, background 0.18s ease; + } + + .sidebar-resizer:hover::before, + .sidebar-resizer:focus-visible::before, + body.sidebar-resizing .sidebar-resizer::before { + background: rgba(var(--goclub-primary-rgb), 0.42); + opacity: 1; + } + + .sidebar-resizer:focus-visible { + outline: 2px solid rgba(var(--goclub-primary-rgb), 0.3); + outline-offset: -2px; + } + + body.sidebar-collapsed .sidebar-resizer { + display: none; + } + .sidebar-toggle:hover { transform: translateY(-1px); } diff --git a/assets/search-data.json b/assets/search-data.json new file mode 100644 index 0000000..f53b328 --- /dev/null +++ b/assets/search-data.json @@ -0,0 +1,140 @@ +[ +{{- $pages := where .Site.Pages "Kind" "in" (slice "page" "section") -}} +{{- $pages = where $pages "Params.bookSearchExclude" "!=" true -}} +{{- $pages = where $pages "Content" "!=" "" -}} +{{- $records := slice -}} + +{{- range $page := $pages -}} + {{- $title := partial "docs/title" $page -}} + {{- $plain := $page.Plain -}} + {{- $description := $page.Description | default (truncate 180 $plain) -}} + {{- $headings := $page.TableOfContents | plainify | htmlUnescape -}} + {{- $aliases := slice $title (replaceRE `\s+` "" $title) -}} + {{- with $page.Params.aliases -}} + {{- $aliases = $aliases | append . -}} + {{- end -}} + {{- $breadcrumb := slice -}} + {{- range $page.Ancestors.Reverse -}} + {{- if and (ne .Kind "home") (ne .RelPermalink "/docs/") -}} + {{- $breadcrumb = $breadcrumb | append (partial "docs/title" .) -}} + {{- end -}} + {{- end -}} + {{- $href := $page.RelPermalink -}} + {{- $section := (partial "docs/title" $page.Parent) | default $.Site.Title -}} + {{- $category := "其他" -}} + {{- $type := "article" -}} + {{- if hasPrefix $href "/docs/interview/" -}} + {{- $category = "面试真题" -}} + {{- else if hasPrefix $href "/docs/baguwen/" -}} + {{- $category = "八股总结" -}} + {{- else if hasPrefix $href "/docs/blog/" -}} + {{- $category = "技术博客" -}} + {{- else if hasPrefix $href "/docs/companion/" -}} + {{- $category = "配套文章" -}} + {{- else if hasPrefix $href "/docs/project-study/" -}} + {{- $category = "项目学习" -}} + {{- else if hasPrefix $href "/docs/resources/" -}} + {{- $category = "资源荟萃" -}} + {{- end -}} + {{- if eq $page.Kind "section" -}} + {{- $type = "section" -}} + {{- else if hasPrefix $href "/docs/interview/" -}} + {{- $type = "interview" -}} + {{- else if or (hasPrefix $href "/docs/resources/epub-books/") (hasPrefix $href "/docs/resources/pdf-books/") (hasPrefix $href "/docs/resources/cloud-native-web3-fulltext/") (hasPrefix $href "/docs/resources/web3-books/") -}} + {{- $type = "book" -}} + {{- end -}} + + {{- $record := dict + "href" $href + "title" $title + "aliases" $aliases + "section" $section + "category" $category + "type" $type + "breadcrumb" $breadcrumb + "description" $description + "headings" $headings + "content" $plain + -}} + + {{- if and (eq $title "每日一问") (hasPrefix $href "/docs/baguwen/") -}} + {{- $records = $records | append (dict + "href" $href + "title" $title + "aliases" $aliases + "section" $section + "category" $category + "type" "article" + "breadcrumb" $breadcrumb + "description" $description + "headings" "" + "content" $description + ) -}} + {{- $renderedContent := printf "%s" $page.Content -}} + {{- $dateHeadings := slice -}} + {{- range $heading := $page.Fragments.Headings -}} + {{- if eq $heading.Level 2 -}} + {{- $dateHeadings = $dateHeadings | append $heading -}} + {{- else if eq $heading.Level 1 -}} + {{- range $childHeading := $heading.Headings -}} + {{- if eq $childHeading.Level 2 -}} + {{- $dateHeadings = $dateHeadings | append $childHeading -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- range $dateHeading := $dateHeadings -}} + {{- if eq $dateHeading.Level 2 -}} + {{- range $questionHeading := $dateHeading.Headings -}} + {{- if eq $questionHeading.Level 3 -}} + {{- $questionContent := "" -}} + {{- $idParts := split $renderedContent (printf `id="%s"` $questionHeading.ID) -}} + {{- if gt (len $idParts) 1 -}} + {{- $headingParts := split (index $idParts 1) "" -}} + {{- if gt (len $headingParts) 1 -}} + {{- $sectionHTML := index $headingParts 1 -}} + {{- $sectionHTML = index (split $sectionHTML " button.addEventListener('click', selectCategory)); + document.addEventListener('keypress', focusSearchFieldOnKeyPress); + document.addEventListener('keydown', closeSearchPanelOnEscape); + window.addEventListener('pagehide', persistSearchState); + window.addEventListener('pageshow', restoreSearchPanelFromPageCache); + + pendingRestoreState = readSearchState(); + if (pendingRestoreState) { + setActiveCategory(pendingRestoreState.category); + input.value = pendingRestoreState.query; + clearButton.classList.remove('hidden'); + if (pendingRestoreState.panelOpen) { + openSearchPanel(); + } + init(); + } + + function focusSearchFieldOnKeyPress(event) { + if (event.target.value !== undefined || input === document.activeElement) { + return; + } + + const characterPressed = String.fromCharCode(event.charCode); + const dataHotkeys = input.getAttribute('data-hotkeys') || ''; + if (dataHotkeys.indexOf(characterPressed) < 0) { + return; + } + + input.focus(); + event.preventDefault(); + } + + function init() { + if (searchIndex || searchPromise) { + return searchPromise; + } + + input.required = true; + setStatus('正在加载搜索索引…', 'loading'); + + searchPromise = fetch(searchDataURL) + .then(response => { + if (!response.ok) { + throw new Error(`Search index request failed: ${response.status}`); + } + return response.json(); + }) + .then(pages => { + searchIndex = new Fuse(pages, indexConfig); + input.required = false; + searchPromise = null; + const restoreState = pendingRestoreState; + pendingRestoreState = null; + if (restoreState && restoreState.query === input.value.trim()) { + search(restoreState); + } else if (input.value.trim()) { + search(); + } else { + clearStatus(); + } + }) + .catch(error => { + console.error(error); + input.required = false; + searchPromise = null; + setStatus('搜索索引加载失败,请重新聚焦搜索框重试。', 'error'); + }); + + return searchPromise; + } + + function scheduleSearch() { + suppressLastSearchRestore = false; + clearButton.classList.toggle('hidden', !input.value); + window.clearTimeout(debounceTimer); + debounceTimer = window.setTimeout(function () { + search(); + }, 250); + } + + function selectCategory(event) { + const category = event.currentTarget.getAttribute('data-search-category') || ''; + if (category === activeCategory) { + return; + } + + setActiveCategory(category); + results.scrollTop = 0; + if (input.value.trim()) { + search(); + } else if (activeCategory) { + setStatus(`已选择“${activeCategory}”,请输入关键词。`, 'ready'); + } else { + clearStatus(); + } + } + + function setActiveCategory(category) { + const nextCategory = filterButtons.some(button => { + return button.getAttribute('data-search-category') === category; + }) ? category : ''; + activeCategory = nextCategory; + filterButtons.forEach(button => { + const isActive = (button.getAttribute('data-search-category') || '') === activeCategory; + button.setAttribute('aria-pressed', String(isActive)); + }); + } + + function openSearchPanel() { + searchPanel.classList.remove('is-results-collapsed'); + if (searchPanel.parentNode !== document.body) { + document.body.appendChild(backdrop); + document.body.appendChild(searchPanel); + } + searchPanel.classList.add('is-expanded'); + backdrop.classList.remove('hidden'); + document.body.classList.add('search-overlay-open'); + } + + function closeSearchPanel() { + searchPanel.classList.remove('is-expanded'); + searchPanel.classList.toggle('is-results-collapsed', Boolean(input.value.trim())); + backdrop.classList.add('hidden'); + document.body.classList.remove('search-overlay-open'); + if (panelPlaceholder.parentNode) { + panelPlaceholder.parentNode.insertBefore(searchPanel, panelPlaceholder.nextSibling); + } + if (backdropPlaceholder.parentNode) { + backdropPlaceholder.parentNode.insertBefore(backdrop, backdropPlaceholder.nextSibling); + } + persistSearchState(); + } + + function closeSearchPanelOnEscape(event) { + if (event.key !== 'Escape' || !searchPanel.classList.contains('is-expanded')) { + return; + } + + closeSearchPanel(); + input.blur(); + } + + function clearSearch() { + suppressLastSearchRestore = true; + input.value = ''; + searchPanel.classList.remove('is-results-collapsed'); + clearButton.classList.add('hidden'); + search(); + clearSearchState(); + input.focus(); + } + + function search(restoreState) { + const query = input.value.trim(); + clearResults(); + currentSearchHits = []; + visibleResultCount = 0; + + if (!query) { + clearStatus(); + clearSearchState(); + return; + } + + if (!searchIndex) { + setStatus('正在加载搜索索引…', 'loading'); + init(); + return; + } + + const searchHits = filterCategoryHits(filterExactDateHits(searchIndex.search(query), query)); + currentSearchHits = rankSearchHits(searchHits); + if (!currentSearchHits.length) { + const categoryText = activeCategory ? `“${activeCategory}”栏目中` : ''; + setStatus(`没有在${categoryText || '知识库中'}找到与“${query}”相关的内容。`, 'empty'); + persistSearchState(); + return; + } + + const restoredCount = restoreState && Number(restoreState.visibleResultCount); + showMoreResults(Number.isFinite(restoredCount) && restoredCount > 0 + ? restoredCount + : resultPageSize); + if (restoreState) { + window.requestAnimationFrame(function () { + results.scrollTop = Math.max(0, Number(restoreState.resultsScrollTop) || 0); + persistSearchState(); + }); + } + } + + function rankSearchHits(searchHits) { + return searchHits + .map((hit, originalIndex) => { + const priorityFactor = contentPriorityFactor(hit.item); + return { + hit, + originalIndex, + priorityFactor, + adjustedScore: (hit.score === undefined ? 1 : hit.score) * priorityFactor + }; + }) + .sort((left, right) => { + return left.adjustedScore - right.adjustedScore + || left.priorityFactor - right.priorityFactor + || (left.hit.score || 0) - (right.hit.score || 0) + || left.originalIndex - right.originalIndex; + }) + .map(entry => entry.hit); + } + + function filterExactDateHits(searchHits, query) { + if (!/^\d{4}[.-]\d{2}[.-]\d{2}$/.test(query)) { + return searchHits; + } + + const normalizedDate = query.replace(/-/g, '.'); + return searchHits.filter(hit => { + const breadcrumb = Array.isArray(hit.item.breadcrumb) ? hit.item.breadcrumb : []; + return breadcrumb.some(part => part === normalizedDate); + }); + } + + function filterCategoryHits(searchHits) { + if (!activeCategory) { + return searchHits; + } + return searchHits.filter(hit => hit.item.category === activeCategory); + } + + function contentPriorityFactor(page) { + const href = page.href || ''; + if (page.category === '八股总结' || href.indexOf('/docs/baguwen/') === 0) { + return 0.72; + } + if (page.type === 'interview' || href.indexOf('/docs/interview/') === 0) { + return 0.78; + } + if (page.type === 'article') { + return 0.9; + } + if (page.type === 'book') { + return 1.15; + } + return 1; + } + + function showMoreResults(targetVisibleCount) { + const query = input.value.trim(); + const requestedCount = Number.isFinite(targetVisibleCount) + ? Math.max(visibleResultCount, targetVisibleCount) + : visibleResultCount + resultPageSize; + const nextVisibleCount = Math.min(requestedCount, currentSearchHits.length); + currentSearchHits + .slice(visibleResultCount, nextVisibleCount) + .forEach(hit => results.appendChild(renderResult(hit.item, query))); + visibleResultCount = nextVisibleCount; + const categoryText = activeCategory ? `在“${activeCategory}”栏目中` : ''; + setStatus(`${categoryText}找到 ${currentSearchHits.length} 条结果,当前显示 ${visibleResultCount} 条。`, 'ready'); + persistSearchState(); + } + + function handleResultsScroll() { + schedulePersistSearchState(); + if (autoLoadFrame || visibleResultCount >= currentSearchHits.length) { + return; + } + + const distanceFromBottom = results.scrollHeight - results.scrollTop - results.clientHeight; + if (distanceFromBottom > autoLoadThreshold) { + return; + } + + autoLoadFrame = window.requestAnimationFrame(function () { + autoLoadFrame = null; + showMoreResults(); + }); + } + + function schedulePersistSearchState() { + if (persistFrame) { + return; + } + persistFrame = window.requestAnimationFrame(function () { + persistFrame = null; + persistSearchState(); + }); + } + + function persistSearchState() { + const query = input.value.trim(); + if (!query) { + return; + } + + const searchState = { + path: window.location.pathname, + query: query, + visibleResultCount: visibleResultCount, + resultsScrollTop: results.scrollTop, + panelOpen: searchPanel.classList.contains('is-expanded'), + category: activeCategory + }; + const currentState = window.history.state && typeof window.history.state === 'object' + ? window.history.state + : {}; + window.history.replaceState(Object.assign({}, currentState, { + [searchHistoryKey]: searchState + }), ''); + try { + window.sessionStorage.setItem(searchSessionKey, JSON.stringify(searchState)); + } catch (error) { + // Search history restoration remains available when session storage is blocked. + } + } + + function readSearchState() { + const state = window.history.state && window.history.state[searchHistoryKey]; + if (!state || state.path !== window.location.pathname || typeof state.query !== 'string' + || !state.query.trim()) { + return null; + } + return state; + } + + function readLastSearchState() { + try { + const state = JSON.parse(window.sessionStorage.getItem(searchSessionKey)); + if (state && typeof state.query === 'string' && state.query.trim()) { + return state; + } + } catch (error) { + // Fall back to the query carried by search-result links. + } + + const query = new URLSearchParams(window.location.search).get('search'); + return query && query.trim() + ? { query: query, visibleResultCount: resultPageSize, resultsScrollTop: 0 } + : null; + } + + function restoreLastSearchState() { + if (suppressLastSearchRestore || input.value.trim()) { + return; + } + const state = readLastSearchState(); + if (!state) { + return; + } + + input.value = state.query; + setActiveCategory(state.category); + clearButton.classList.remove('hidden'); + pendingRestoreState = state; + if (searchIndex) { + pendingRestoreState = null; + search(state); + } + } + + function clearSearchState() { + if (window.history.state && window.history.state[searchHistoryKey]) { + const nextState = Object.assign({}, window.history.state); + delete nextState[searchHistoryKey]; + window.history.replaceState(nextState, ''); + } + try { + window.sessionStorage.removeItem(searchSessionKey); + } catch (error) { + // Ignore storage restrictions; the visible search state has still been cleared. + } + } + + function restoreSearchPanelFromPageCache(event) { + if (!event.persisted) { + return; + } + const state = readSearchState(); + if (!state) { + return; + } + if (state.panelOpen) { + openSearchPanel(); + } + window.requestAnimationFrame(function () { + results.scrollTop = Math.max(0, Number(state.resultsScrollTop) || 0); + }); + } + + function renderResult(page, query) { + const item = document.createElement('li'); + item.className = 'book-search-result'; + + const link = document.createElement('a'); + link.href = addSearchContext(page.href, query); + link.addEventListener('click', persistSearchState); + + const heading = document.createElement('span'); + heading.className = 'book-search-result-heading'; + + const badge = document.createElement('span'); + badge.className = `book-search-result-badge is-${page.type || 'article'}`; + badge.textContent = resultTypeLabel(page.type); + + const title = document.createElement('strong'); + title.className = 'book-search-result-title'; + appendHighlightedText(title, page.title, query); + + heading.appendChild(badge); + heading.appendChild(title); + link.appendChild(heading); + + const breadcrumb = document.createElement('span'); + breadcrumb.className = 'book-search-result-breadcrumb'; + const breadcrumbParts = Array.isArray(page.breadcrumb) ? page.breadcrumb : []; + breadcrumb.textContent = breadcrumbParts.length ? breadcrumbParts.join(' › ') : page.category || page.section; + link.appendChild(breadcrumb); + + const snippet = document.createElement('small'); + snippet.className = 'book-search-result-snippet'; + appendHighlightedText(snippet, makeSnippet(page, query), query); + link.appendChild(snippet); + + item.appendChild(link); + return item; + } + + function resultTypeLabel(type) { + return { + book: '书籍章节', + interview: '面试', + section: '栏目', + article: '文章' + }[type] || '文章'; + } + + function makeSnippet(page, query) { + const preferred = (page.content || page.description || '').replace(/\s+/g, ' ').trim(); + if (!preferred) { + return ''; + } + + const terms = queryTerms(query); + const lower = preferred.toLocaleLowerCase(); + const positions = terms + .map(term => lower.indexOf(term.toLocaleLowerCase())) + .filter(position => position >= 0); + const matchAt = positions.length ? Math.min.apply(null, positions) : 0; + const start = Math.max(0, matchAt - 42); + const end = Math.min(preferred.length, matchAt + 118); + return `${start > 0 ? '…' : ''}${preferred.slice(start, end)}${end < preferred.length ? '…' : ''}`; + } + + function addSearchContext(href, query) { + const url = new URL(href, window.location.origin); + url.searchParams.set('search', query); + return `${url.pathname}${url.search}${url.hash}`; + } + + function appendHighlightedText(container, text, query) { + const terms = queryTerms(query).sort((left, right) => right.length - left.length); + if (!terms.length) { + container.textContent = text; + return; + } + + const pattern = new RegExp(`(${terms.map(escapeRegExp).join('|')})`, 'gi'); + text.split(pattern).forEach(part => { + if (terms.some(term => term.toLocaleLowerCase() === part.toLocaleLowerCase())) { + const mark = document.createElement('mark'); + mark.textContent = part; + container.appendChild(mark); + } else { + container.appendChild(document.createTextNode(part)); + } + }); + } + + function queryTerms(query) { + const normalizedQuery = query.trim(); + const terms = normalizedQuery.split(/\s+/).map(term => term.trim()).filter(Boolean); + return terms.length > 1 ? [normalizedQuery].concat(terms) : terms; + } + + function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + function setStatus(message, state) { + status.textContent = message; + status.className = `book-search-status is-${state}`; + } + + function clearStatus() { + status.textContent = ''; + status.className = 'book-search-status hidden'; + } + + function clearResults() { + while (results.firstChild) { + results.removeChild(results.firstChild); + } + } + + function scheduleSearchContextHighlight() { + const run = function () { + window.requestAnimationFrame(function () { + window.requestAnimationFrame(highlightSearchContext); + }); + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', run, { once: true }); + } else { + run(); + } + } + + function highlightSearchContext() { + const query = new URLSearchParams(window.location.search).get('search'); + const article = document.querySelector('.book-page article.book-article'); + if (!query || !article) { + return; + } + + const terms = queryTerms(query).sort((left, right) => right.length - left.length); + if (!terms.length) { + return; + } + + const pattern = new RegExp(`(${terms.map(escapeRegExp).join('|')})`, 'gi'); + const walker = document.createTreeWalker(article, NodeFilter.SHOW_TEXT, { + acceptNode: function (node) { + const parent = node.parentElement; + if (!parent || !node.nodeValue.trim() + || parent.closest('script, style, noscript, mark.goclub-search-hit, a.anchor')) { + return NodeFilter.FILTER_REJECT; + } + pattern.lastIndex = 0; + const matches = pattern.test(node.nodeValue); + pattern.lastIndex = 0; + return matches ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; + } + }); + const matchingNodes = []; + while (walker.nextNode()) { + matchingNodes.push(walker.currentNode); + } + + matchingNodes.forEach(node => highlightTextNode(node, pattern, terms)); + + const fragmentTarget = document.querySelector(':target'); + const preferredHit = findPreferredHit(fragmentTarget, article, query) + || findExactHit(article, query) + || article.querySelector('mark.goclub-search-hit'); + const scrollTarget = preferredHit || fragmentTarget; + if (!scrollTarget) { + return; + } + + const collapsedAnswer = scrollTarget.closest('details:not([open])'); + if (collapsedAnswer) { + collapsedAnswer.open = true; + } + if (preferredHit) { + preferredHit.classList.add('is-current'); + } + if ('scrollRestoration' in window.history) { + window.history.scrollRestoration = 'manual'; + } + window.setTimeout(function () { + const targetRect = scrollTarget.getBoundingClientRect(); + const targetTop = window.scrollY + targetRect.top + - (window.innerHeight / 2) + + (targetRect.height / 2); + window.scrollTo({ top: Math.max(0, targetTop), behavior: 'auto' }); + }, 220); + } + + function highlightTextNode(node, pattern, terms) { + const fragment = document.createDocumentFragment(); + node.nodeValue.split(pattern).forEach(part => { + if (terms.some(term => term.toLocaleLowerCase() === part.toLocaleLowerCase())) { + const mark = document.createElement('mark'); + mark.className = 'goclub-search-hit'; + mark.textContent = part; + fragment.appendChild(mark); + } else { + fragment.appendChild(document.createTextNode(part)); + } + }); + node.parentNode.replaceChild(fragment, node); + } + + function findPreferredHit(fragmentTarget, article, query) { + if (!fragmentTarget || !article.contains(fragmentTarget)) { + return null; + } + + const targetLevel = headingLevel(fragmentTarget); + const hits = []; + let element = fragmentTarget; + while (element) { + if (element !== fragmentTarget && targetLevel && headingLevel(element) <= targetLevel) { + break; + } + if (element.matches && element.matches('mark.goclub-search-hit')) { + hits.push(element); + } + if (element.querySelectorAll) { + hits.push.apply(hits, element.querySelectorAll('mark.goclub-search-hit')); + } + element = element.nextElementSibling; + } + return hits.find(hit => hit.textContent.toLocaleLowerCase() === query.toLocaleLowerCase()) + || hits[0] + || null; + } + + function findExactHit(container, query) { + return Array.from(container.querySelectorAll('mark.goclub-search-hit')) + .find(hit => hit.textContent.toLocaleLowerCase() === query.toLocaleLowerCase()) + || null; + } + + function headingLevel(element) { + const match = element && /^H([1-6])$/.exec(element.tagName); + return match ? Number(match[1]) : 0; + } +})(); diff --git "a/content/docs/baguwen/\346\257\217\346\227\245\344\270\200\351\227\256.md" "b/content/docs/baguwen/\346\257\217\346\227\245\344\270\200\351\227\256.md" index 6f56d96..d902a1b 100644 --- "a/content/docs/baguwen/\346\257\217\346\227\245\344\270\200\351\227\256.md" +++ "b/content/docs/baguwen/\346\257\217\346\227\245\344\270\200\351\227\256.md" @@ -1270,3 +1270,12 @@ func main() { ## 2026.03.30 ### 自旋锁和互斥锁的区别 + +自旋锁在获取锁失败后会持续循环尝试,不主动让出 CPU;互斥锁在获取锁失败后会阻塞线程并让出 CPU,等锁可用时再被唤醒。 + +1. 自旋锁避免了线程阻塞和唤醒的上下文切换开销,但等待期间会持续消耗 CPU。 +2. 互斥锁等待时不占用 CPU,但阻塞和唤醒需要进行线程调度,存在上下文切换开销。 +3. 自旋锁适合临界区很短、锁竞争较轻且运行在多核 CPU 上的场景;互斥锁适合临界区较长或竞争较激烈的场景。 +4. 如果持锁时间不可控,使用自旋锁可能浪费大量 CPU,甚至降低系统整体吞吐,因此通常优先使用互斥锁,只在能够证明临界区足够短时考虑自旋锁。 + +结论:等待时间短时,自旋锁可以用 CPU 时间换取更低的切换延迟;等待时间较长时,互斥锁通过阻塞线程减少 CPU 浪费。 diff --git a/layouts/_partials/docs/search.html b/layouts/_partials/docs/search.html new file mode 100644 index 0000000..eb70b5e --- /dev/null +++ b/layouts/_partials/docs/search.html @@ -0,0 +1,34 @@ +{{ if default true .Site.Params.BookSearch }} + + + +{{ end }} diff --git a/layouts/baseof.html b/layouts/baseof.html index 4ef7598..713c999 100644 --- a/layouts/baseof.html +++ b/layouts/baseof.html @@ -45,6 +45,9 @@ + {{ end }} diff --git a/layouts/partials/docs/inject/body.html b/layouts/partials/docs/inject/body.html index 25e45a6..169e393 100644 --- a/layouts/partials/docs/inject/body.html +++ b/layouts/partials/docs/inject/body.html @@ -977,12 +977,21 @@

AI 助手

diff --git a/scripts/check_search_index.py b/scripts/check_search_index.py new file mode 100644 index 0000000..5728599 --- /dev/null +++ b/scripts/check_search_index.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 + +import argparse +import csv +import io +import json +import re +import subprocess +import sys +from html.parser import HTMLParser +from pathlib import Path +from urllib.parse import unquote, urlsplit + + +ALLOWED_CATEGORIES = { + "面试真题", + "八股总结", + "资源荟萃", + "技术博客", + "配套文章", + "项目学习", + "其他", +} +REQUIRED_FIELDS = ("href", "title", "category", "type") +SEARCH_EXCLUDE_PATTERN = re.compile(r"(?mi)^\s*bookSearchExclude\s*[:=]\s*true\s*$") + + +class IdCollector(HTMLParser): + def __init__(self): + super().__init__() + self.ids = set() + + def handle_starttag(self, _tag, attrs): + for name, value in attrs: + if name == "id" and value: + self.ids.add(value) + + +def parse_args(): + parser = argparse.ArgumentParser(description="校验 Hugo 搜索索引覆盖范围和结果链接") + parser.add_argument("--site", default="public", help="Hugo 输出目录,默认 public") + parser.add_argument("--hugo", default="hugo", help="Hugo 可执行文件,默认 hugo") + return parser.parse_args() + + +def configure_output(): + for stream in (sys.stdout, sys.stderr): + if hasattr(stream, "reconfigure"): + stream.reconfigure(encoding="utf-8") + + +def split_front_matter(text): + lines = text.splitlines() + if not lines or lines[0].strip() not in {"---", "+++"}: + return "", text + + delimiter = lines[0].strip() + for index in range(1, len(lines)): + if lines[index].strip() == delimiter: + return "\n".join(lines[1:index]), "\n".join(lines[index + 1 :]) + return "", text + + +def normalize_route(value): + path = unquote(urlsplit(value).path) + if not path.startswith("/"): + path = f"/{path}" + if path != "/" and not Path(path).suffix: + path = f"{path.rstrip('/')}/" + return path + + +def output_path_for_href(site_dir, href): + path = normalize_route(href).lstrip("/") + output = site_dir / Path(path) + if path.endswith("/") or not output.suffix: + output /= "index.html" + return output + + +def load_current_index(site_dir): + candidates = list(site_dir.glob("*.search-data.min.*.json")) + if not candidates: + raise RuntimeError(f"{site_dir} 中没有生成搜索索引,请先运行 hugo --minify") + index_path = max(candidates, key=lambda path: path.stat().st_mtime_ns) + with index_path.open("r", encoding="utf-8") as file: + records = json.load(file) + if not isinstance(records, list) or not records: + raise RuntimeError(f"搜索索引为空或格式错误:{index_path}") + return index_path, records + + +def list_published_content(hugo, repo_root): + result = subprocess.run( + [hugo, "list", "published"], + cwd=repo_root, + check=True, + capture_output=True, + text=True, + encoding="utf-8", + ) + return list(csv.DictReader(io.StringIO(result.stdout))) + + +def expected_routes(rows, repo_root): + expected = {} + for row in rows: + if row.get("kind") not in {"page", "section"}: + continue + source_value = row.get("path", "") + permalink = row.get("permalink", "") + if not source_value or not permalink: + continue + + source_path = repo_root / Path(source_value) + if not source_path.is_file(): + continue + text = source_path.read_text(encoding="utf-8") + front_matter, body = split_front_matter(text) + if SEARCH_EXCLUDE_PATTERN.search(front_matter) or not body.strip(): + continue + + route = normalize_route(permalink) + if route.startswith("/docs/") or route == "/docs/": + expected[route] = source_value + return expected + + +def collect_html_ids(path, cache): + if path not in cache: + parser = IdCollector() + parser.feed(path.read_text(encoding="utf-8")) + cache[path] = parser.ids + return cache[path] + + +def validate_records(records, site_dir): + errors = [] + indexed_routes = set() + seen_hrefs = set() + html_ids = {} + + for position, record in enumerate(records): + if not isinstance(record, dict): + errors.append(f"第 {position + 1} 条记录不是对象") + continue + missing = [field for field in REQUIRED_FIELDS if not record.get(field)] + if missing: + errors.append(f"第 {position + 1} 条记录缺少字段:{', '.join(missing)}") + continue + category = record["category"] + if category not in ALLOWED_CATEGORIES: + errors.append(f"{record['href']} 使用了未归一化栏目:{category}") + + href = record["href"] + if href in seen_hrefs: + errors.append(f"搜索记录链接重复:{href}") + seen_hrefs.add(href) + indexed_routes.add(normalize_route(href)) + + output_path = output_path_for_href(site_dir, href) + if not output_path.is_file(): + errors.append(f"搜索结果页面不存在:{href} -> {output_path}") + continue + + fragment = unquote(urlsplit(href).fragment) + if fragment and fragment not in collect_html_ids(output_path, html_ids): + errors.append(f"搜索结果锚点不存在:{href}") + + return errors, indexed_routes + + +def main(): + configure_output() + args = parse_args() + repo_root = Path(__file__).resolve().parents[1] + site_dir = (repo_root / args.site).resolve() + + try: + index_path, records = load_current_index(site_dir) + rows = list_published_content(args.hugo, repo_root) + expected = expected_routes(rows, repo_root) + errors, indexed_routes = validate_records(records, site_dir) + except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError) as error: + print(f"搜索索引校验失败:{error}", file=sys.stderr) + return 1 + + missing_routes = sorted(set(expected) - indexed_routes) + errors.extend( + f"应收录内容未进入搜索索引:{route}({expected[route]})" + for route in missing_routes + ) + + if errors: + print("搜索索引校验失败:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + + category_counts = {} + for record in records: + category = record["category"] + category_counts[category] = category_counts.get(category, 0) + 1 + summary = ",".join( + f"{category} {count} 条" for category, count in sorted(category_counts.items()) + ) + print( + f"搜索索引校验通过:{index_path.name},{len(records)} 条记录," + f"覆盖 {len(expected)} 个内容页面;{summary}。" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main())