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'
}
+40 -7
View File
@@ -187,13 +187,13 @@ dialog.search-dialog {
> :first-child {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.25rem;
margin-bottom: 0.5rem;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.375rem;
> a {
display: inline-block;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -208,9 +208,29 @@ dialog.search-dialog {
font-size: 1.25em;
}
.suggestion-date {
font-size: 0.875rem;
.suggestion-heading {
flex-shrink: 0;
font-size: 0.8125rem;
color: fi-var(global-font-secondary-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 40%;
.suggestion-heading-mark {
color: fi-var(global-font-tertiary-color);
margin-right: 0.125rem;
}
em {
font-style: normal;
background-color: fi-var(selection-color);
}
}
.suggestion-date {
flex-shrink: 0;
font-size: 0.8125rem;
color: fi-var(global-font-tertiary-color);
}
@@ -226,6 +246,19 @@ dialog.search-dialog {
@include overflow-wrap(break-word);
}
.suggestion-meta {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 0.75rem;
margin-top: 0.375rem;
font-size: 0.75rem;
color: fi-var(global-font-tertiary-color);
i {
margin-right: 0.125rem;
}
}
em {
font-style: normal;
background-color: fi-var(selection-color);
+5 -3
View File
@@ -576,7 +576,7 @@ bundlePath = "pagefind/"
debounceTimeoutMs = 300
# whether to respect FixIt built-in search visibility rules
useBuiltInFilters = true
# optional sort field, current recommended built-in value: "date"
# optional sort field: ["date", "title"]
sortBy = ""
# sort order for sortBy: ["asc", "desc"]
sortOrder = "desc"
@@ -588,13 +588,15 @@ engine = "google"
# search results page URL (layout: search)
resultsPage = "/search/"
# Google: https://programmablesearchengine.google.com/
# Google Custom Search Engine Context
# See: https://programmablesearchengine.google.com/
[params.cse.google]
cx = ""
# Bing (Unsupported): https://www.customsearch.ai/
# Bing Custom Search Engine Context (unverified)
# See: https://www.customsearch.ai/
[params.cse.bing]
cx = ""
# Header config
[params.header]
@@ -0,0 +1 @@
<pre><code>{{ .Inner }}</code></pre>
@@ -0,0 +1 @@
<h{{ .Level }} id="{{ .Anchor | safeURL }}">{{ .Text | safeHTML }}</h{{ .Level }}>
+7 -3
View File
@@ -62,12 +62,16 @@
{{- else if eq $search.type "cse" -}}
{{- $config = dict "type" "cse" | dict "search" | merge $config -}}
{{- $cse := .Site.Params.cse -}}
{{- $config = dict "resultsPage" $cse.resultsPage "gotoResultsPage" (T "assets.gotoResultsPage") | dict "cse" | merge $config -}}
{{- $resultsPage := $cse.resultsPage -}}
{{- if not (hasPrefix $resultsPage (relURL "")) -}}
{{- $resultsPage = path.Join (relURL "") $resultsPage -}}
{{- end -}}
{{- $config = dict "resultsPage" $resultsPage "gotoResultsPage" (T "assets.gotoResultsPage") | dict "cse" | merge $config -}}
{{- if (eq $cse.engine "google") | and $cse.google.cx -}}
{{- $config = dict "engine" "google" "cx" $cse.google.cx "searchIn" (T "assets.searchIn" "Google") | dict "cse" | merge $config -}}
{{- end -}}
{{- if eq $cse.engine "bing" -}}
{{- /* Unsupported */ -}}
{{- if eq $cse.engine "bing" | and $cse.bing.cx -}}
{{- $config = dict "engine" "bing" "cx" $cse.bing.cx "searchIn" (T "assets.searchIn" "Bing") | dict "cse" | merge $config -}}
{{- end -}}
{{- end -}}
{{- end -}}
@@ -20,7 +20,7 @@
{{- if .IsPartial -}}
{{- $id = dict "Page" .Page | partial "function/id.html" -}}
{{- end -}}
<fixit-encryptor>
<fixit-encryptor data-pagefind-ignore="all">
<div class="fixit-decryptor-container">
<img class="fixit-decryptor-loading" src="{{ $loading.RelPermalink }}" alt="decryptor loading" width="48" height="48" />
<label for="{{ $id }}" title="{{ T `single.password` }}">
+3 -3
View File
@@ -32,7 +32,7 @@
{{- . | safeHTML -}}
{{- end -}}
{{- if
$externalIcon
$externalIcon
| and $isExternal
| and (not (hasPrefix $ctx.Content `<img`))
| and (not (hasPrefix $ctx.Content `<svg`))
@@ -58,9 +58,9 @@
{{- $cardIcon = .RelPermalink -}}
{{- end -}}
{{- if (not $cardIcon) | and $url.Host -}}
{{- with partial "function/get-remote-image.html" (dict "Src" (add "https://favicon.im/" $url.Host)) -}}
{{- with partial "function/get-remote-image.html" (dict "Src" (add "https://api.lruihao.cn/google/s2/favicons?sz=64&domain=" $url.Host)) -}}
{{- $cardIcon = .RelPermalink -}}
{{- else with partial "function/get-remote-image.html" (dict "Src" (add "https://api.lruihao.cn/google/s2/favicons?sz=64&domain=" $url.Host)) -}}
{{- else with partial "function/get-remote-image.html" (dict "Src" (add "https://favicon.im/" $url.Host)) -}}
{{- $cardIcon = .RelPermalink -}}
{{- end -}}
{{- end -}}
@@ -6,6 +6,10 @@
{{- $dateLabel := $pageDate | dateFormat (.Site.Params.dateFormat | default "2006-01-02") -}}
<meta data-pagefind-filter="hidden:{{ $hidden }}">
<meta data-pagefind-filter="encrypted:{{ $encrypted }}">
<meta data-pagefind-meta="date:{{ $dateLabel }}">
<meta data-pagefind-sort="date:{{ $pageDate.Unix }}">
<meta data-pagefind-sort="title:{{ .Title }}">
<meta data-pagefind-meta="date:{{ $dateLabel }}">
{{- with .Params.tags }}<meta data-pagefind-meta="tags:{{ delimit . "," }}">{{ end -}}
{{- with .Params.categories }}<meta data-pagefind-meta="categories:{{ delimit . "," }}">{{ end -}}
{{- with .Params.collections }}<meta data-pagefind-meta="collections:{{ delimit . "," }}">{{ end -}}
{{- end -}}
@@ -0,0 +1,4 @@
{{- $password := cond .IsNamedParams (.Get "password") (.Get 0) | default "" -}}
{{- if not $password -}}
{{- .Inner -}}
{{- end -}}
+8 -4
View File
@@ -19,40 +19,44 @@
{{- $params := .Params | merge $.Site.Params.page -}}
{{/* Extended Markdown syntax */}}
{{- $content := dict "Content" .Content "Ruby" $params.ruby "Fraction" $params.fraction "Fontawesome" $params.fontawesome | partial "function/content.html" -}}
{{/* Remove line number for code */}}
{{- $content = $content | replaceRE `<span class="lnt?"> *\d*\n?</span>` "" -}}
{{- $content = $content | replaceRE ` class="heading-element"` "" -}}
{{- if ne $.Site.Params.search.anchorify false -}}
{{- /* When anchorify is enabled (default), create separate index entries for each heading */ -}}
{{- $anchor := "" -}}
{{- $headingText := "" -}}
{{- range $h, $contenth := split $content "<h1 id=" -}}
{{- if gt $h 0 -}}
{{- $anchor = replace (index (split $contenth ">") 0) `"` "" -}}
{{- $headingText = index (split (index (split $contenth ">") 1) "<") 0 | plainify | replaceRE `[\n\t ]+$` "" -}}
{{- $contenth = printf "<h1 id=%v" $contenth -}}
{{- end -}}
{{- range $i, $contenti := split $contenth "<h2 id=" -}}
{{- if gt $i 0 -}}
{{- $anchor = replace (index (split $contenti ">") 0) `"` "" -}}
{{- $headingText = index (split (index (split $contenti ">") 1) "<") 0 | plainify | replaceRE `[\n\t ]+$` "" -}}
{{- $contenti = printf "<h2 id=%v" $contenti -}}
{{- end -}}
{{- range $j, $contentj := split $contenti "<h3 id=" -}}
{{- if gt $j 0 -}}
{{- $anchor = replace (index (split $contentj ">") 0) `"` "" -}}
{{- $headingText = index (split (index (split $contentj ">") 1) "<") 0 | plainify | replaceRE `[\n\t ]+$` "" -}}
{{- $contentj = printf "<h3 id=%v" $contentj -}}
{{- end -}}
{{- range $k, $contentk := split $contentj "<h4 id=" -}}
{{- if gt $k 0 -}}
{{- $anchor = replace (index (split $contentk ">") 0) `"` "" -}}
{{- $headingText = index (split (index (split $contentk ">") 1) "<") 0 | plainify | replaceRE `[\n\t ]+$` "" -}}
{{- $contentk = printf "<h4 id=%v" $contentk -}}
{{- end -}}
{{- range $l, $contentl := split $contentk "<h5 id=" -}}
{{- if gt $l 0 -}}
{{- $anchor = replace (index (split $contentl ">") 0) `"` "" -}}
{{- $headingText = index (split (index (split $contentl ">") 1) "<") 0 | plainify | replaceRE `[\n\t ]+$` "" -}}
{{- $contentk = printf "<h5 id=%v" $contentl -}}
{{- end -}}
{{- range $m, $contentm := split $contentl "<h6 id=" -}}
{{- if gt $m 0 -}}
{{- $anchor = replace (index (split $contentm ">") 0) `"` "" -}}
{{- $headingText = index (split (index (split $contentm ">") 1) "<") 0 | plainify | replaceRE `[\n\t ]+$` "" -}}
{{- $contentm = printf "<h6 id=%v" $contentm -}}
{{- end -}}
{{/* Plainify and remove (\n, \t) */}}
@@ -61,7 +65,7 @@
{{- $contentj = substr $contentj 0 $.Site.Params.search.contentLength -}}
{{- end -}}
{{- if $contentj | and (ne $contentj " ") -}}
{{- $one := printf "%v:%v:%v" $uri $i $j | dict "content" $contentj "uri" (printf "%v#%v" $uri $anchor) "objectID" | merge $meta -}}
{{- $one := printf "%v:%v:%v" $uri $i $j | dict "content" $contentj "heading" $headingText "uri" (printf "%v#%v" $uri $anchor) "objectID" | merge $meta -}}
{{- $index = $index | append $one -}}
{{- end -}}
{{- end -}}
+9 -1
View File
@@ -29,6 +29,14 @@
{{- end -}}
{{- end -}}
{{- /* Bing CSE (Unsupported) */ -}}
{{- /* Bing CSE */ -}}
{{- if eq $cse.engine "bing" -}}
{{- with $cse.bing.cx -}}
<script type="text/javascript"
id="bcs_js_snippet"
src="https://ui.customsearch.ai/api/ux/rendering-js?customConfig={{ . }}&market=en-US&safeSearch=Moderate&version=latest&q=">
</script>
{{- end -}}
{{- end -}}
</article>
{{- end -}}