feat(assets): search UI redesign with heading, metadata and Bing CSE support (#791)

* feat(assets): redesign search result UI with heading, tags, categories and collections

- Add heading/anchor display for anchorified search results (inline in title row)
- Add tags, categories, collections display in search result metadata row
- Extend SearchResult interface with optional heading, tags, categories, collections
- Pass metadata through all search engines (Fuse.js, Algolia, Pagefind)
- Add search-specific render hooks to eliminate replaceRE hacks:
  - render-heading.search.json: clean heading output without class/span
  - render-codeblock.search.json: code output without line numbers
  - fixit-encryptor.search.json: skip encrypted content in search index
- Add data-pagefind-ignore="all" to fixit-encryptor for Pagefind exclusion
- Add data-pagefind-meta for tags, categories, collections in Pagefind
- Add data-pagefind-sort for title (alphabetical sorting)
- Extend PagefindConfig.sortBy to support "title" option
- Redesign search modal SCSS with flex layout for title/heading/date row
- Add .suggestion-meta row with icon-styled tags/categories/collections

* feat(layouts): add Bing Custom Search Engine support

- Implement Bing CSE adapter in search engine (redirect to results page)
- Add Bing CSE widget embed in search page template via JavaScript snippet
- Update CSE engine warning message to be provider-agnostic
- Add Bing icon (fa-microsoft) for Bing CSE results
- Add cx config field for Bing and mark as unverified
This commit is contained in:
Cell
2026-06-25 12:49:17 +08:00
committed by GitHub
parent 5cad4d3df9
commit a5f362f5ff
17 changed files with 149 additions and 39 deletions
+12 -3
View File
@@ -65,17 +65,26 @@ export function createAlgoliaEngine(searchConfig: SearchConfig): SearchEngine {
const { hits } = await algoliaIndex!.search(query, {
offset: 0,
length: maxResultLength * 8,
attributesToHighlight: ['title'],
attributesToHighlight: ['title', 'heading'],
attributesToRetrieve: ['*'],
attributesToSnippet: [`content:${snippetLength}`],
highlightPreTag: `<${highlightTag}>`,
highlightPostTag: `</${highlightTag}>`,
})
const results: Record<string, SearchResult> = {}
hits.forEach(({ uri, date, _highlightResult: { title }, _snippetResult: { content } }: any) => {
hits.forEach(({ uri, date, heading, tags, categories, collections, _highlightResult, _snippetResult: { content } }: any) => {
if (results[uri] && results[uri].context.length > content.value.length)
return
results[uri] = { uri, title: title.value, date, context: content.value }
results[uri] = {
uri,
title: _highlightResult.title.value,
date,
context: content.value,
heading: _highlightResult.heading?.value || heading || undefined,
tags,
categories,
collections,
}
})
return Object.values(results).slice(0, maxResultLength)
}
+13 -4
View File
@@ -1,11 +1,11 @@
import type { CSEConfig, SearchEngine, SearchResult } from '../types'
/**
* Create a Google Custom Search Engine adapter.
* Create a Custom Search Engine adapter (Google or Bing).
*
* Returns a single result that links to the CSE results page.
* @param cseConfig - The CSE configuration, or `undefined` if not configured.
* @returns A SearchEngine instance for Google CSE.
* @returns A SearchEngine instance for the configured CSE provider.
*/
export function createCSEEngine(cseConfig: CSEConfig | undefined): SearchEngine {
return {
@@ -19,12 +19,21 @@ export function createCSEEngine(cseConfig: CSEConfig | undefined): SearchEngine
icon: '<i class="fa-brands fa-google" aria-hidden="true"></i>',
}]
}
console.warn('CSE is not properly configured. Please set cse.engine to "google" and provide a cx value in your site config.')
if (cseConfig?.engine === 'bing' && cseConfig.cx) {
return [{
uri: `${cseConfig.resultsPage}?q=${encodeURIComponent(query)}`,
title: cseConfig.searchIn || '',
date: '',
context: cseConfig.gotoResultsPage || '',
icon: '<i class="fa-brands fa-microsoft" aria-hidden="true"></i>',
}]
}
console.warn('CSE is not properly configured. Please set cse.engine and provide a cx value in your site config.')
return [{
uri: '',
title: 'CSE is not configured',
date: '',
context: 'Please set <code>cse.engine</code> and <code>cse.google.cx</code> in your site config.',
context: 'Please set <code>cse.engine</code> and the corresponding <code>cx</code> value in your site config.',
}]
},
}
+9 -1
View File
@@ -18,6 +18,7 @@ export function createFuseEngine(searchConfig: SearchConfig): SearchEngine {
window._fuseIndex!.search(query).forEach(({ item, matches }: any) => {
let title = item.title
let content = item.content
let heading = item.heading || ''
matches.forEach(({ indices, key }: any) => {
if (key === 'content') {
content = applyHighlightToText(content, indices, highlightTag)
@@ -25,12 +26,19 @@ export function createFuseEngine(searchConfig: SearchConfig): SearchEngine {
else if (key === 'title') {
title = applyHighlightToText(title, indices, highlightTag)
}
else if (key === 'heading') {
heading = applyHighlightToText(heading, indices, highlightTag)
}
})
results[item.uri] = {
uri: item.uri,
title,
date: item.date,
context: content,
heading: item.heading ? heading : undefined,
tags: item.tags,
categories: item.categories,
collections: item.collections,
}
})
return Object.values(results).slice(0, maxResultLength)
@@ -53,7 +61,7 @@ export function createFuseEngine(searchConfig: SearchConfig): SearchEngine {
includeScore: false,
shouldSort: true,
includeMatches: true,
keys: ['content', 'title'],
keys: ['content', 'title', 'heading'],
})
return doSearch()
}
+19 -6
View File
@@ -124,12 +124,25 @@ export function createPagefindEngine(searchConfig: SearchConfig, pagefindConfig:
(searched.results || []).slice(0, resultLimit).map((entry: any) => entry.data()),
)
return records.map((item: any) => ({
uri: item.url || '#',
title: item.meta?.title || item.url || '',
date: item.meta?.date || '',
context: replaceExcerptHighlightTag(item.excerpt || '', highlightTag),
}))
return records.map((item: any) => {
const url = item.url || '#'
const hashIndex = url.indexOf('#')
let heading: string | undefined
if (hashIndex > 0 && item.sub_results?.length) {
const subResult = item.sub_results.find((sr: any) => sr.url === url)
heading = subResult?.title || undefined
}
return {
uri: url,
title: item.meta?.title || item.url || '',
date: item.meta?.date || '',
context: replaceExcerptHighlightTag(item.excerpt || '', highlightTag),
heading,
tags: item.meta?.tags ? item.meta.tags.split(',').map((t: string) => t.trim()).filter(Boolean) : undefined,
categories: item.meta?.categories ? item.meta.categories.split(',').map((t: string) => t.trim()).filter(Boolean) : undefined,
collections: item.meta?.collections ? item.meta.collections.split(',').map((t: string) => t.trim()).filter(Boolean) : undefined,
}
})
},
}
}
+7 -1
View File
@@ -112,8 +112,14 @@ export class SearchModule implements SearchService {
const title = h`<span class="suggestion-title" dangerouslySetInnerHTML=${{ __html: item.title }}></span>`
const icon = item.icon ? h`<span class="suggestion-icon" dangerouslySetInnerHTML=${{ __html: item.icon }}></span>` : ''
const date = item.date ? h`<span class="suggestion-date">${item.date}</span>` : ''
const heading = item.heading ? h`<span class="suggestion-heading"><span class="suggestion-heading-mark">#</span> <span dangerouslySetInnerHTML=${{ __html: item.heading }}></span></span>` : ''
const context = h`<div class="suggestion-context" dangerouslySetInnerHTML=${{ __html: item.context }}></div>`
return h`<div class="search-item-wrapper"><div><a href="${item.uri}">${title}</a>${icon}${date}</div>${context}</div>`
const cats = item.categories?.length ? h`<span class="suggestion-category"><i class="fa-regular fa-folder" aria-hidden="true"></i> ${item.categories.join(', ')}</span>` : ''
const cols = item.collections?.length ? h`<span class="suggestion-collection"><i class="fa-solid fa-layer-group" aria-hidden="true"></i> ${item.collections.join(', ')}</span>` : ''
const tags = item.tags?.length ? h`<span class="suggestion-tag"><i class="fa-solid fa-tags" aria-hidden="true"></i> ${item.tags.join(', ')}</span>` : ''
const hasMeta = cats || cols || tags
const meta = hasMeta ? h`<div class="suggestion-meta">${cats}${cols}${tags}</div>` : ''
return h`<div class="search-item-wrapper"><div><a href="${item.uri}">${title}</a>${icon}${heading}${date}</div>${context}${meta}</div>`
},
noResults({ html: h }: { html: any }) {
return h`<div class="search-empty"><i class="fa-solid fa-magnifying-glass search-empty-icon" aria-hidden="true"></i><p>${searchConfig.noResultsFound}: <span class="search-query">"${query}"</span></p></div>`
+5 -1
View File
@@ -7,6 +7,10 @@ export interface SearchResult {
date: string
context: string
icon?: string
heading?: string
tags?: string[]
categories?: string[]
collections?: string[]
}
/** Common interface for all search engine backends. */
@@ -45,7 +49,7 @@ export interface PagefindConfig {
bundlePath?: string
debounceTimeoutMs?: number
useBuiltInFilters?: boolean
sortBy?: 'date'
sortBy?: 'date' | 'title'
sortOrder?: 'asc' | 'desc'
}