Merge pull request 'Update dependency hexo-ai-summary-liushen to v2' (#7) from renovate/hexo-ai-summary-liushen-2.x into master
Reviewed-on: #7 ··[CST 2026-04-11 Saturday 19:57:09]
This commit is contained in:
562
js/search/algolia.js
Normal file
562
js/search/algolia.js
Normal file
@@ -0,0 +1,562 @@
|
||||
window.addEventListener('load', () => {
|
||||
const { algolia } = GLOBAL_CONFIG
|
||||
const { appId, apiKey, indexName, hitsPerPage = 5, languages } = algolia
|
||||
|
||||
if (!appId || !apiKey || !indexName) {
|
||||
return console.error('Algolia setting is invalid!')
|
||||
}
|
||||
|
||||
const $searchMask = document.getElementById('search-mask')
|
||||
const $searchDialog = document.querySelector('#algolia-search .search-dialog')
|
||||
|
||||
const animateElements = show => {
|
||||
const action = show ? 'animateIn' : 'animateOut'
|
||||
const maskAnimation = show ? 'to_show 0.5s' : 'to_hide 0.5s'
|
||||
const dialogAnimation = show ? 'titleScale 0.5s' : 'search_close .5s'
|
||||
btf[action]($searchMask, maskAnimation)
|
||||
btf[action]($searchDialog, dialogAnimation)
|
||||
}
|
||||
|
||||
const fixSafariHeight = () => {
|
||||
if (window.innerWidth < 768) {
|
||||
$searchDialog.style.setProperty('--search-height', `${window.innerHeight}px`)
|
||||
}
|
||||
}
|
||||
|
||||
const openSearch = () => {
|
||||
btf.overflowPaddingR.add()
|
||||
animateElements(true)
|
||||
showLoading(false)
|
||||
|
||||
setTimeout(() => {
|
||||
const searchInput = document.querySelector('#algolia-search-input .ais-SearchBox-input')
|
||||
if (searchInput) searchInput.focus()
|
||||
}, 100)
|
||||
|
||||
const handleEscape = event => {
|
||||
if (event.code === 'Escape') {
|
||||
closeSearch()
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
fixSafariHeight()
|
||||
window.addEventListener('resize', fixSafariHeight)
|
||||
}
|
||||
|
||||
const closeSearch = () => {
|
||||
btf.overflowPaddingR.remove()
|
||||
animateElements(false)
|
||||
window.removeEventListener('resize', fixSafariHeight)
|
||||
}
|
||||
|
||||
const searchClickFn = () => {
|
||||
btf.addEventListenerPjax(document.querySelector('#search-button > .search'), 'click', openSearch)
|
||||
}
|
||||
|
||||
const searchFnOnce = () => {
|
||||
$searchMask.addEventListener('click', closeSearch)
|
||||
document.querySelector('#algolia-search .search-close-button').addEventListener('click', closeSearch)
|
||||
}
|
||||
|
||||
const cutContent = content => {
|
||||
if (!content) return ''
|
||||
|
||||
let contentStr = ''
|
||||
if (typeof content === 'string') {
|
||||
contentStr = content.trim()
|
||||
} else if (typeof content === 'object') {
|
||||
if (content.value !== undefined) {
|
||||
contentStr = String(content.value).trim()
|
||||
if (!contentStr) return ''
|
||||
} else if (content.matchedWords || content.matchLevel || content.fullyHighlighted !== undefined) {
|
||||
return ''
|
||||
} else {
|
||||
try {
|
||||
contentStr = JSON.stringify(content).trim()
|
||||
if (contentStr === '{}' || contentStr === '[]' || contentStr === '""') {
|
||||
return ''
|
||||
}
|
||||
} catch (e) {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
} else if (content.toString && typeof content.toString === 'function') {
|
||||
contentStr = content.toString().trim()
|
||||
if (contentStr === '[object Object]' || contentStr === '[object Array]') {
|
||||
return ''
|
||||
}
|
||||
} else {
|
||||
return ''
|
||||
}
|
||||
|
||||
const firstOccur = contentStr.indexOf('<mark>')
|
||||
let start = firstOccur - 30
|
||||
let end = firstOccur + 120
|
||||
let pre = ''
|
||||
let post = ''
|
||||
|
||||
if (start <= 0) {
|
||||
start = 0
|
||||
end = 140
|
||||
} else {
|
||||
pre = '...'
|
||||
}
|
||||
|
||||
if (end > contentStr.length) {
|
||||
end = contentStr.length
|
||||
} else {
|
||||
post = '...'
|
||||
}
|
||||
|
||||
// Ensure we don't cut off HTML tags in the middle
|
||||
let substr = contentStr.substring(start, end)
|
||||
|
||||
// Handle tag completeness
|
||||
// Check for incomplete opening tags at the beginning
|
||||
const firstCloseBracket = substr.indexOf('>')
|
||||
const firstOpenBracket = substr.indexOf('<')
|
||||
|
||||
// If there's a closing bracket but no opening bracket before it, we've cut a tag
|
||||
if (firstCloseBracket !== -1 && (firstOpenBracket === -1 || firstCloseBracket < firstOpenBracket)) {
|
||||
substr = substr.substring(firstCloseBracket + 1)
|
||||
}
|
||||
|
||||
// Check for incomplete closing tags at the end
|
||||
const lastOpenBracket = substr.lastIndexOf('<')
|
||||
const lastCloseBracket = substr.lastIndexOf('>')
|
||||
|
||||
// If there's an opening bracket after the last closing bracket, we've cut a tag
|
||||
if (lastOpenBracket !== -1 && lastOpenBracket > lastCloseBracket) {
|
||||
substr = substr.substring(0, lastOpenBracket)
|
||||
}
|
||||
|
||||
// Balance tags in the substring
|
||||
const tagStack = []
|
||||
let balancedStr = ''
|
||||
let i = 0
|
||||
|
||||
while (i < substr.length) {
|
||||
if (substr[i] === '<') {
|
||||
// Check if it's a closing tag
|
||||
if (substr[i + 1] === '/') {
|
||||
const closeTagEnd = substr.indexOf('>', i)
|
||||
if (closeTagEnd !== -1) {
|
||||
const closeTagName = substr.substring(i + 2, closeTagEnd)
|
||||
// Remove matching opening tag from stack
|
||||
for (let j = tagStack.length - 1; j >= 0; j--) {
|
||||
if (tagStack[j] === closeTagName) {
|
||||
tagStack.splice(j, 1)
|
||||
break
|
||||
}
|
||||
}
|
||||
balancedStr += substr.substring(i, closeTagEnd + 1)
|
||||
i = closeTagEnd + 1
|
||||
continue
|
||||
}
|
||||
} else if (substr.substr(i, 2) === '<!' || (substr.indexOf('/>', i) !== -1 && substr.indexOf('/>', i) < substr.indexOf('>', i))) {
|
||||
const tagEnd = substr.indexOf('>', i)
|
||||
if (tagEnd !== -1) {
|
||||
balancedStr += substr.substring(i, tagEnd + 1)
|
||||
i = tagEnd + 1
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
const tagEnd = substr.indexOf('>', i)
|
||||
if (tagEnd !== -1) {
|
||||
const tagName = substr.substring(i + 1, (substr.indexOf(' ', i) > -1 && substr.indexOf(' ', i) < tagEnd)
|
||||
? substr.indexOf(' ', i)
|
||||
: tagEnd).split(/\s/)[0]
|
||||
tagStack.push(tagName)
|
||||
balancedStr += substr.substring(i, tagEnd + 1)
|
||||
i = tagEnd + 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
balancedStr += substr[i]
|
||||
i++
|
||||
}
|
||||
|
||||
// Close any unclosed tags
|
||||
while (tagStack.length > 0) {
|
||||
const tagName = tagStack.pop()
|
||||
balancedStr += `</${tagName}>`
|
||||
}
|
||||
|
||||
// If we removed content from the beginning, add prefix
|
||||
if (start > 0 || pre) {
|
||||
const actualFirstOpenBracket = contentStr.indexOf('<', start > 0 ? start - 30 : 0)
|
||||
const actualFirstMark = contentStr.indexOf('<mark>', start > 0 ? start - 30 : 0)
|
||||
|
||||
if (actualFirstOpenBracket !== -1 &&
|
||||
(actualFirstMark === -1 || actualFirstOpenBracket < actualFirstMark)) {
|
||||
pre = '...'
|
||||
}
|
||||
}
|
||||
|
||||
substr = balancedStr
|
||||
return `${pre}${substr}${post}`
|
||||
}
|
||||
|
||||
// Helper function to handle Algolia highlight results
|
||||
const extractHighlightValue = highlightObj => {
|
||||
if (!highlightObj) return ''
|
||||
|
||||
if (typeof highlightObj === 'string') {
|
||||
return highlightObj.trim()
|
||||
}
|
||||
|
||||
if (typeof highlightObj === 'object' && highlightObj.value !== undefined) {
|
||||
return String(highlightObj.value).trim()
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
// Initialize Algolia client
|
||||
let searchClient
|
||||
|
||||
if (window['algoliasearch/lite'] && typeof window['algoliasearch/lite'].liteClient === 'function') {
|
||||
searchClient = window['algoliasearch/lite'].liteClient(appId, apiKey)
|
||||
} else if (typeof window.algoliasearch === 'function') {
|
||||
searchClient = window.algoliasearch(appId, apiKey)
|
||||
} else {
|
||||
return console.error('Algolia search client not found!')
|
||||
}
|
||||
|
||||
if (!searchClient) {
|
||||
return console.error('Failed to initialize Algolia search client')
|
||||
}
|
||||
|
||||
// Search state
|
||||
let currentQuery = ''
|
||||
|
||||
// Show loading state
|
||||
const showLoading = show => {
|
||||
const loadingIndicator = document.getElementById('loading-status')
|
||||
if (loadingIndicator) {
|
||||
loadingIndicator.hidden = !show
|
||||
}
|
||||
}
|
||||
|
||||
// Cache frequently used elements
|
||||
const elements = {
|
||||
get searchInput () { return document.querySelector('#algolia-search-input .ais-SearchBox-input') },
|
||||
get hits () { return document.getElementById('algolia-hits') },
|
||||
get hitsEmpty () { return document.getElementById('algolia-hits-empty') },
|
||||
get hitsList () { return document.querySelector('#algolia-hits .ais-Hits-list') },
|
||||
get hitsWrapper () { return document.querySelector('#algolia-hits .ais-Hits') },
|
||||
get pagination () { return document.getElementById('algolia-pagination') },
|
||||
get paginationList () { return document.querySelector('#algolia-pagination .ais-Pagination-list') },
|
||||
get stats () { return document.querySelector('#algolia-info .ais-Stats-text') },
|
||||
}
|
||||
|
||||
// Show/hide search results area
|
||||
const toggleResultsVisibility = hasResults => {
|
||||
elements.pagination.style.display = hasResults ? '' : 'none'
|
||||
elements.stats.style.display = hasResults ? '' : 'none'
|
||||
}
|
||||
|
||||
// Render search results
|
||||
const renderHits = (hits, query, page = 0) => {
|
||||
if (hits.length === 0 && query) {
|
||||
elements.hitsEmpty.textContent = languages.hits_empty.replace(/\$\{query}/, query)
|
||||
elements.hitsEmpty.style.display = ''
|
||||
elements.hitsWrapper.style.display = 'none'
|
||||
elements.stats.style.display = 'none'
|
||||
return
|
||||
}
|
||||
|
||||
elements.hitsEmpty.style.display = 'none'
|
||||
|
||||
const hitsHTML = hits.map((hit, index) => {
|
||||
const itemNumber = page * hitsPerPage + index + 1
|
||||
const link = hit.permalink || (GLOBAL_CONFIG.root + hit.path)
|
||||
const result = hit._highlightResult || hit
|
||||
|
||||
// Content extraction
|
||||
let content = ''
|
||||
try {
|
||||
if (result.contentStripTruncate) {
|
||||
content = cutContent(result.contentStripTruncate)
|
||||
} else if (result.contentStrip) {
|
||||
content = cutContent(result.contentStrip)
|
||||
} else if (result.content) {
|
||||
content = cutContent(result.content)
|
||||
} else if (hit.contentStripTruncate) {
|
||||
content = cutContent(hit.contentStripTruncate)
|
||||
} else if (hit.contentStrip) {
|
||||
content = cutContent(hit.contentStrip)
|
||||
} else if (hit.content) {
|
||||
content = cutContent(hit.content)
|
||||
}
|
||||
} catch (error) {
|
||||
content = ''
|
||||
}
|
||||
|
||||
// Title handling
|
||||
let title = 'no-title'
|
||||
try {
|
||||
if (result.title) {
|
||||
title = extractHighlightValue(result.title) || 'no-title'
|
||||
} else if (hit.title) {
|
||||
title = extractHighlightValue(hit.title) || 'no-title'
|
||||
}
|
||||
|
||||
if (!title || title === 'no-title') {
|
||||
if (typeof hit.title === 'string' && hit.title.trim()) {
|
||||
title = hit.title.trim()
|
||||
} else if (hit.title && typeof hit.title === 'object' && hit.title.value) {
|
||||
title = String(hit.title.value).trim() || 'no-title'
|
||||
} else {
|
||||
title = 'no-title'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
title = 'no-title'
|
||||
}
|
||||
|
||||
return `
|
||||
<li class="ais-Hits-item" value="${itemNumber}">
|
||||
<a href="${link}" class="algolia-hit-item-link">
|
||||
<span class="algolia-hits-item-title">${title}</span>
|
||||
${content ? `<div class="algolia-hit-item-content">${content}</div>` : ''}
|
||||
</a>
|
||||
</li>`
|
||||
}).join('')
|
||||
|
||||
elements.hitsList.innerHTML = hitsHTML
|
||||
elements.hitsWrapper.style.display = query ? '' : 'none'
|
||||
|
||||
if (hits.length > 0) {
|
||||
elements.stats.style.display = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Render pagination
|
||||
const renderPagination = (page, nbPages) => {
|
||||
if (nbPages <= 1) {
|
||||
elements.pagination.style.display = 'none'
|
||||
elements.paginationList.innerHTML = ''
|
||||
return
|
||||
}
|
||||
|
||||
elements.pagination.style.display = 'block'
|
||||
|
||||
const isFirstPage = page === 0
|
||||
const isLastPage = page === nbPages - 1
|
||||
|
||||
// Responsive page display
|
||||
const isMobile = window.innerWidth < 768
|
||||
const maxVisiblePages = isMobile ? 3 : 5
|
||||
let startPage = Math.max(0, page - Math.floor(maxVisiblePages / 2))
|
||||
const endPage = Math.min(nbPages - 1, startPage + maxVisiblePages - 1)
|
||||
|
||||
// Adjust starting page to maintain max visible pages
|
||||
if (endPage - startPage + 1 < maxVisiblePages) {
|
||||
startPage = Math.max(0, endPage - maxVisiblePages + 1)
|
||||
}
|
||||
|
||||
let pagesHTML = ''
|
||||
|
||||
// Only add ellipsis and first page when there are many pages
|
||||
if (nbPages > maxVisiblePages && startPage > 0) {
|
||||
pagesHTML += `
|
||||
<li class="ais-Pagination-item ais-Pagination-item--page">
|
||||
<a class="ais-Pagination-link" aria-label="Page 1" href="#" data-page="0">1</a>
|
||||
</li>`
|
||||
if (startPage > 1) {
|
||||
pagesHTML += `
|
||||
<li class="ais-Pagination-item ais-Pagination-item--ellipsis">
|
||||
<span class="ais-Pagination-link">...</span>
|
||||
</li>`
|
||||
}
|
||||
}
|
||||
|
||||
// Add middle page numbers
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
const isSelected = i === page
|
||||
if (isSelected) {
|
||||
pagesHTML += `
|
||||
<li class="ais-Pagination-item ais-Pagination-item--page ais-Pagination-item--selected">
|
||||
<span class="ais-Pagination-link" aria-label="Page ${i + 1}">${i + 1}</span>
|
||||
</li>`
|
||||
} else {
|
||||
pagesHTML += `
|
||||
<li class="ais-Pagination-item ais-Pagination-item--page">
|
||||
<a class="ais-Pagination-link" aria-label="Page ${i + 1}" href="#" data-page="${i}">${i + 1}</a>
|
||||
</li>`
|
||||
}
|
||||
}
|
||||
|
||||
// Only add ellipsis and last page when there are many pages
|
||||
if (nbPages > maxVisiblePages && endPage < nbPages - 1) {
|
||||
if (endPage < nbPages - 2) {
|
||||
pagesHTML += `
|
||||
<li class="ais-Pagination-item ais-Pagination-item--ellipsis">
|
||||
<span class="ais-Pagination-link">...</span>
|
||||
</li>`
|
||||
}
|
||||
pagesHTML += `
|
||||
<li class="ais-Pagination-item ais-Pagination-item--page">
|
||||
<a class="ais-Pagination-link" aria-label="Page ${nbPages}" href="#" data-page="${nbPages - 1}">${nbPages}</a>
|
||||
</li>`
|
||||
}
|
||||
|
||||
if (nbPages > 1) {
|
||||
elements.paginationList.innerHTML = `
|
||||
<li class="ais-Pagination-item ais-Pagination-item--previousPage ${isFirstPage ? 'ais-Pagination-item--disabled' : ''}">
|
||||
${isFirstPage
|
||||
? '<span class="ais-Pagination-link ais-Pagination-link--disabled" aria-label="Previous Page"><i class="fas fa-angle-left"></i></span>'
|
||||
: `<a class="ais-Pagination-link" aria-label="Previous Page" href="#" data-page="${page - 1}"><i class="fas fa-angle-left"></i></a>`
|
||||
}
|
||||
</li>
|
||||
${pagesHTML}
|
||||
<li class="ais-Pagination-item ais-Pagination-item--nextPage ${isLastPage ? 'ais-Pagination-item--disabled' : ''}">
|
||||
${isLastPage
|
||||
? '<span class="ais-Pagination-link ais-Pagination-link--disabled" aria-label="Next Page"><i class="fas fa-angle-right"></i></span>'
|
||||
: `<a class="ais-Pagination-link" aria-label="Next Page" href="#" data-page="${page + 1}"><i class="fas fa-angle-right"></i></a>`
|
||||
}
|
||||
</li>`
|
||||
elements.pagination.style.display = currentQuery ? '' : 'none'
|
||||
} else {
|
||||
elements.pagination.style.display = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
// Render statistics
|
||||
const renderStats = (nbHits, processingTimeMS, query) => {
|
||||
if (query) {
|
||||
const stats = languages.hits_stats
|
||||
.replace(/\$\{hits}/, nbHits)
|
||||
.replace(/\$\{time}/, processingTimeMS)
|
||||
elements.stats.innerHTML = `<hr>${stats}`
|
||||
elements.stats.style.display = ''
|
||||
} else {
|
||||
elements.stats.style.display = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
// Perform search
|
||||
const performSearch = async (query, page = 0) => {
|
||||
if (!query.trim()) {
|
||||
currentQuery = ''
|
||||
renderHits([], '', 0)
|
||||
renderPagination(0, 0)
|
||||
renderStats(0, 0, '')
|
||||
toggleResultsVisibility(false)
|
||||
return
|
||||
}
|
||||
|
||||
showLoading(true)
|
||||
currentQuery = query
|
||||
|
||||
try {
|
||||
let result
|
||||
|
||||
if (searchClient && typeof searchClient.search === 'function') {
|
||||
// v5 multi-index search
|
||||
const searchResult = await searchClient.search([{
|
||||
indexName,
|
||||
query,
|
||||
params: {
|
||||
page,
|
||||
hitsPerPage,
|
||||
highlightPreTag: '<mark>',
|
||||
highlightPostTag: '</mark>',
|
||||
attributesToHighlight: ['title', 'content', 'contentStrip', 'contentStripTruncate']
|
||||
}
|
||||
}])
|
||||
result = searchResult.results[0]
|
||||
} else if (searchClient && typeof searchClient.initIndex === 'function') {
|
||||
// v4 single-index search
|
||||
const index = searchClient.initIndex(indexName)
|
||||
result = await index.search(query, {
|
||||
page,
|
||||
hitsPerPage,
|
||||
highlightPreTag: '<mark>',
|
||||
highlightPostTag: '</mark>',
|
||||
attributesToHighlight: ['title', 'content', 'contentStrip', 'contentStripTruncate']
|
||||
})
|
||||
} else {
|
||||
throw new Error('Algolia: No compatible search method available')
|
||||
}
|
||||
|
||||
renderHits(result.hits || [], query, page)
|
||||
|
||||
const actualNbPages = result.nbHits <= hitsPerPage ? 1 : (result.nbPages || 0)
|
||||
renderPagination(page, actualNbPages)
|
||||
renderStats(result.nbHits || 0, result.processingTimeMS || 0, query)
|
||||
|
||||
const hasResults = result.hits && result.hits.length > 0
|
||||
toggleResultsVisibility(hasResults)
|
||||
|
||||
// Refresh Pjax links
|
||||
if (window.pjax) {
|
||||
window.pjax.refresh(document.getElementById('algolia-hits'))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Algolia search error:', error)
|
||||
renderHits([], query, page)
|
||||
renderPagination(0, 0)
|
||||
renderStats(0, 0, query)
|
||||
} finally {
|
||||
showLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Debounced search
|
||||
let searchTimeout
|
||||
const debouncedSearch = (query, delay = 300) => {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => performSearch(query), delay)
|
||||
}
|
||||
|
||||
// Initialize search box and events
|
||||
const initializeSearch = () => {
|
||||
showLoading(false)
|
||||
|
||||
if (elements.searchInput) {
|
||||
elements.searchInput.addEventListener('input', e => {
|
||||
const query = e.target.value
|
||||
debouncedSearch(query)
|
||||
})
|
||||
}
|
||||
|
||||
const searchForm = document.querySelector('#algolia-search-input .ais-SearchBox-form')
|
||||
if (searchForm) {
|
||||
searchForm.addEventListener('submit', e => {
|
||||
e.preventDefault()
|
||||
const query = elements.searchInput.value
|
||||
performSearch(query)
|
||||
})
|
||||
}
|
||||
|
||||
// Pagination event delegation
|
||||
elements.pagination.addEventListener('click', e => {
|
||||
e.preventDefault()
|
||||
const link = e.target.closest('a[data-page]')
|
||||
if (link) {
|
||||
const page = parseInt(link.dataset.page, 10)
|
||||
if (!isNaN(page) && currentQuery) {
|
||||
performSearch(currentQuery, page)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Initial state
|
||||
toggleResultsVisibility(false)
|
||||
}
|
||||
|
||||
// Initialize
|
||||
initializeSearch()
|
||||
searchClickFn()
|
||||
searchFnOnce()
|
||||
|
||||
window.addEventListener('pjax:complete', () => {
|
||||
if (!btf.isHidden($searchMask)) closeSearch()
|
||||
searchClickFn()
|
||||
})
|
||||
})
|
||||
567
js/search/local-search.js
Normal file
567
js/search/local-search.js
Normal file
@@ -0,0 +1,567 @@
|
||||
/**
|
||||
* Refer to hexo-generator-searchdb
|
||||
* https://github.com/next-theme/hexo-generator-searchdb/blob/main/dist/search.js
|
||||
* Modified by hexo-theme-butterfly
|
||||
*/
|
||||
|
||||
class LocalSearch {
|
||||
constructor ({
|
||||
path = '',
|
||||
unescape = false,
|
||||
top_n_per_article = 1
|
||||
}) {
|
||||
this.path = path
|
||||
this.unescape = unescape
|
||||
this.top_n_per_article = top_n_per_article
|
||||
this.isfetched = false
|
||||
this.datas = null
|
||||
}
|
||||
|
||||
getIndexByWord (words, text, caseSensitive = false) {
|
||||
const index = []
|
||||
const included = new Set()
|
||||
|
||||
if (!caseSensitive) {
|
||||
text = text.toLowerCase()
|
||||
}
|
||||
words.forEach(word => {
|
||||
if (this.unescape) {
|
||||
const div = document.createElement('div')
|
||||
div.innerText = word
|
||||
word = div.innerHTML
|
||||
}
|
||||
const wordLen = word.length
|
||||
if (wordLen === 0) return
|
||||
let startPosition = 0
|
||||
let position = -1
|
||||
if (!caseSensitive) {
|
||||
word = word.toLowerCase()
|
||||
}
|
||||
while ((position = text.indexOf(word, startPosition)) > -1) {
|
||||
index.push({ position, word })
|
||||
included.add(word)
|
||||
startPosition = position + wordLen
|
||||
}
|
||||
})
|
||||
// Sort index by position of keyword
|
||||
index.sort((left, right) => {
|
||||
if (left.position !== right.position) {
|
||||
return left.position - right.position
|
||||
}
|
||||
return right.word.length - left.word.length
|
||||
})
|
||||
return [index, included]
|
||||
}
|
||||
|
||||
// Merge hits into slices
|
||||
mergeIntoSlice (start, end, index) {
|
||||
let item = index[0]
|
||||
let { position, word } = item
|
||||
const hits = []
|
||||
const count = new Set()
|
||||
while (position + word.length <= end && index.length !== 0) {
|
||||
count.add(word)
|
||||
hits.push({
|
||||
position,
|
||||
length: word.length
|
||||
})
|
||||
const wordEnd = position + word.length
|
||||
|
||||
// Move to next position of hit
|
||||
index.shift()
|
||||
while (index.length !== 0) {
|
||||
item = index[0]
|
||||
position = item.position
|
||||
word = item.word
|
||||
if (wordEnd > position) {
|
||||
index.shift()
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
hits,
|
||||
start,
|
||||
end,
|
||||
count: count.size
|
||||
}
|
||||
}
|
||||
|
||||
// Highlight title and content
|
||||
highlightKeyword (val, slice) {
|
||||
let result = ''
|
||||
let index = slice.start
|
||||
for (const { position, length } of slice.hits) {
|
||||
result += val.substring(index, position)
|
||||
index = position + length
|
||||
result += `<mark class="search-keyword">${val.substr(position, length)}</mark>`
|
||||
}
|
||||
result += val.substring(index, slice.end)
|
||||
return result
|
||||
}
|
||||
|
||||
getResultItems (keywords) {
|
||||
const resultItems = []
|
||||
this.datas.forEach(({ title, content, url }) => {
|
||||
// The number of different keywords included in the article.
|
||||
const [indexOfTitle, keysOfTitle] = this.getIndexByWord(keywords, title)
|
||||
const [indexOfContent, keysOfContent] = this.getIndexByWord(keywords, content)
|
||||
const includedCount = new Set([...keysOfTitle, ...keysOfContent]).size
|
||||
|
||||
// Show search results
|
||||
const hitCount = indexOfTitle.length + indexOfContent.length
|
||||
if (hitCount === 0) return
|
||||
|
||||
const slicesOfTitle = []
|
||||
if (indexOfTitle.length !== 0) {
|
||||
slicesOfTitle.push(this.mergeIntoSlice(0, title.length, indexOfTitle))
|
||||
}
|
||||
|
||||
let slicesOfContent = []
|
||||
while (indexOfContent.length !== 0) {
|
||||
const item = indexOfContent[0]
|
||||
const { position } = item
|
||||
// Cut out 120 characters. The maxlength of .search-input is 80.
|
||||
const start = Math.max(0, position - 20)
|
||||
const end = Math.min(content.length, position + 100)
|
||||
slicesOfContent.push(this.mergeIntoSlice(start, end, indexOfContent))
|
||||
}
|
||||
|
||||
// Sort slices in content by included keywords' count and hits' count
|
||||
slicesOfContent.sort((left, right) => {
|
||||
if (left.count !== right.count) {
|
||||
return right.count - left.count
|
||||
} else if (left.hits.length !== right.hits.length) {
|
||||
return right.hits.length - left.hits.length
|
||||
}
|
||||
return left.start - right.start
|
||||
})
|
||||
|
||||
// Select top N slices in content
|
||||
const upperBound = parseInt(this.top_n_per_article, 10)
|
||||
if (upperBound >= 0) {
|
||||
slicesOfContent = slicesOfContent.slice(0, upperBound)
|
||||
}
|
||||
|
||||
let resultItem = ''
|
||||
|
||||
url = new URL(url, location.origin)
|
||||
url.searchParams.append('highlight', keywords.join(' '))
|
||||
|
||||
if (slicesOfTitle.length !== 0) {
|
||||
resultItem += `<li class="local-search-hit-item"><a href="${url.href}"><span class="search-result-title">${this.highlightKeyword(title, slicesOfTitle[0])}</span>`
|
||||
} else {
|
||||
resultItem += `<li class="local-search-hit-item"><a href="${url.href}"><span class="search-result-title">${title}</span>`
|
||||
}
|
||||
|
||||
slicesOfContent.forEach(slice => {
|
||||
resultItem += `<p class="search-result">${this.highlightKeyword(content, slice)}...</p>`
|
||||
})
|
||||
|
||||
resultItem += '</a></li>'
|
||||
resultItems.push({
|
||||
item: resultItem,
|
||||
id: resultItems.length,
|
||||
hitCount,
|
||||
includedCount
|
||||
})
|
||||
})
|
||||
return resultItems
|
||||
}
|
||||
|
||||
fetchData () {
|
||||
const isXml = !this.path.endsWith('json')
|
||||
fetch(this.path)
|
||||
.then(response => response.text())
|
||||
.then(res => {
|
||||
// Get the contents from search data
|
||||
this.isfetched = true
|
||||
this.datas = isXml
|
||||
? [...new DOMParser().parseFromString(res, 'text/xml').querySelectorAll('entry')].map(element => ({
|
||||
title: element.querySelector('title').textContent,
|
||||
content: element.querySelector('content').textContent,
|
||||
url: element.querySelector('url').textContent
|
||||
}))
|
||||
: JSON.parse(res)
|
||||
// Only match articles with non-empty titles
|
||||
this.datas = this.datas.filter(data => data.title).map(data => {
|
||||
data.title = data.title.trim()
|
||||
data.content = data.content ? data.content.trim().replace(/<[^>]+>/g, '') : ''
|
||||
data.url = decodeURIComponent(data.url).replace(/\/{2,}/g, '/')
|
||||
return data
|
||||
})
|
||||
// Remove loading animation
|
||||
window.dispatchEvent(new Event('search:loaded'))
|
||||
})
|
||||
}
|
||||
|
||||
// Highlight by wrapping node in mark elements with the given class name
|
||||
highlightText (node, slice, className) {
|
||||
const val = node.nodeValue
|
||||
let index = slice.start
|
||||
const children = []
|
||||
for (const { position, length } of slice.hits) {
|
||||
const text = document.createTextNode(val.substring(index, position))
|
||||
index = position + length
|
||||
const mark = document.createElement('mark')
|
||||
mark.className = className
|
||||
mark.appendChild(document.createTextNode(val.substr(position, length)))
|
||||
children.push(text, mark)
|
||||
}
|
||||
node.nodeValue = val.substring(index, slice.end)
|
||||
children.forEach(element => {
|
||||
node.parentNode.insertBefore(element, node)
|
||||
})
|
||||
}
|
||||
|
||||
// Highlight the search words provided in the url in the text
|
||||
highlightSearchWords (body) {
|
||||
const params = new URL(location.href).searchParams.get('highlight')
|
||||
const keywords = params ? params.split(' ') : []
|
||||
if (!keywords.length || !body) return
|
||||
const walk = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, null)
|
||||
const allNodes = []
|
||||
while (walk.nextNode()) {
|
||||
if (!walk.currentNode.parentNode.matches('button, select, textarea, .mermaid')) allNodes.push(walk.currentNode)
|
||||
}
|
||||
allNodes.forEach(node => {
|
||||
const [indexOfNode] = this.getIndexByWord(keywords, node.nodeValue)
|
||||
if (!indexOfNode.length) return
|
||||
const slice = this.mergeIntoSlice(0, node.nodeValue.length, indexOfNode)
|
||||
this.highlightText(node, slice, 'search-keyword')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
// Search
|
||||
const { path, top_n_per_article, unescape, languages, pagination } = GLOBAL_CONFIG.localSearch
|
||||
const enablePagination = pagination && pagination.enable
|
||||
const localSearch = new LocalSearch({
|
||||
path,
|
||||
top_n_per_article,
|
||||
unescape
|
||||
})
|
||||
|
||||
const input = document.querySelector('.local-search-input input')
|
||||
const statsItem = document.getElementById('local-search-stats')
|
||||
const $loadingStatus = document.getElementById('loading-status')
|
||||
const isXml = !path.endsWith('json')
|
||||
|
||||
// Pagination variables (only initialize if pagination is enabled)
|
||||
let currentPage = 0
|
||||
const hitsPerPage = pagination.hitsPerPage || 10
|
||||
|
||||
let currentResultItems = []
|
||||
|
||||
if (!enablePagination) {
|
||||
// If pagination is disabled, we don't need these variables
|
||||
currentPage = undefined
|
||||
currentResultItems = undefined
|
||||
}
|
||||
|
||||
// Cache frequently used elements
|
||||
const elements = {
|
||||
get pagination () { return document.getElementById('local-search-pagination') },
|
||||
get paginationList () { return document.querySelector('#local-search-pagination .ais-Pagination-list') }
|
||||
}
|
||||
|
||||
// Show/hide search results area
|
||||
const toggleResultsVisibility = hasResults => {
|
||||
if (enablePagination) {
|
||||
elements.pagination.style.display = hasResults ? '' : 'none'
|
||||
} else {
|
||||
elements.pagination.style.display = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
// Render search results for current page
|
||||
const renderResults = (searchText, resultItems) => {
|
||||
const container = document.getElementById('local-search-results')
|
||||
|
||||
// Determine items to display based on pagination mode
|
||||
const itemsToDisplay = enablePagination
|
||||
? currentResultItems.slice(currentPage * hitsPerPage, (currentPage + 1) * hitsPerPage)
|
||||
: resultItems
|
||||
|
||||
// Handle empty page in pagination mode
|
||||
if (enablePagination && itemsToDisplay.length === 0 && currentResultItems.length > 0) {
|
||||
currentPage = 0
|
||||
renderResults(searchText, resultItems)
|
||||
return
|
||||
}
|
||||
|
||||
// Add numbering to items
|
||||
const numberedItems = itemsToDisplay.map((result, index) => {
|
||||
const itemNumber = enablePagination
|
||||
? currentPage * hitsPerPage + index + 1
|
||||
: index + 1
|
||||
return result.item.replace(
|
||||
'<li class="local-search-hit-item">',
|
||||
`<li class="local-search-hit-item" value="${itemNumber}">`
|
||||
)
|
||||
})
|
||||
|
||||
container.innerHTML = `<ol class="search-result-list">${numberedItems.join('')}</ol>`
|
||||
|
||||
// Update stats
|
||||
const displayCount = enablePagination ? currentResultItems.length : resultItems.length
|
||||
const stats = languages.hits_stats.replace(/\$\{hits}/, displayCount)
|
||||
statsItem.innerHTML = `<hr><div class="search-result-stats">${stats}</div>`
|
||||
|
||||
// Handle pagination
|
||||
if (enablePagination) {
|
||||
const nbPages = Math.ceil(currentResultItems.length / hitsPerPage)
|
||||
renderPagination(currentPage, nbPages, searchText)
|
||||
}
|
||||
|
||||
const hasResults = resultItems.length > 0
|
||||
toggleResultsVisibility(hasResults)
|
||||
|
||||
window.pjax && window.pjax.refresh(container)
|
||||
}
|
||||
|
||||
// Render pagination
|
||||
const renderPagination = (page, nbPages, query) => {
|
||||
if (nbPages <= 1) {
|
||||
elements.pagination.style.display = 'none'
|
||||
elements.paginationList.innerHTML = ''
|
||||
return
|
||||
}
|
||||
|
||||
elements.pagination.style.display = 'block'
|
||||
|
||||
const isFirstPage = page === 0
|
||||
const isLastPage = page === nbPages - 1
|
||||
|
||||
// Responsive page display
|
||||
const isMobile = window.innerWidth < 768
|
||||
const maxVisiblePages = isMobile ? 3 : 5
|
||||
let startPage = Math.max(0, page - Math.floor(maxVisiblePages / 2))
|
||||
const endPage = Math.min(nbPages - 1, startPage + maxVisiblePages - 1)
|
||||
|
||||
// Adjust starting page to maintain max visible pages
|
||||
if (endPage - startPage + 1 < maxVisiblePages) {
|
||||
startPage = Math.max(0, endPage - maxVisiblePages + 1)
|
||||
}
|
||||
|
||||
let pagesHTML = ''
|
||||
|
||||
// Only add ellipsis and first page when there are many pages
|
||||
if (nbPages > maxVisiblePages && startPage > 0) {
|
||||
pagesHTML += `
|
||||
<li class="ais-Pagination-item ais-Pagination-item--page">
|
||||
<a class="ais-Pagination-link" aria-label="Page 1" href="#" data-page="0">1</a>
|
||||
</li>`
|
||||
if (startPage > 1) {
|
||||
pagesHTML += `
|
||||
<li class="ais-Pagination-item ais-Pagination-item--ellipsis">
|
||||
<span class="ais-Pagination-link">...</span>
|
||||
</li>`
|
||||
}
|
||||
}
|
||||
|
||||
// Add middle page numbers
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
const isSelected = i === page
|
||||
if (isSelected) {
|
||||
pagesHTML += `
|
||||
<li class="ais-Pagination-item ais-Pagination-item--page ais-Pagination-item--selected">
|
||||
<span class="ais-Pagination-link" aria-label="Page ${i + 1}">${i + 1}</span>
|
||||
</li>`
|
||||
} else {
|
||||
pagesHTML += `
|
||||
<li class="ais-Pagination-item ais-Pagination-item--page">
|
||||
<a class="ais-Pagination-link" aria-label="Page ${i + 1}" href="#" data-page="${i}">${i + 1}</a>
|
||||
</li>`
|
||||
}
|
||||
}
|
||||
|
||||
// Only add ellipsis and last page when there are many pages
|
||||
if (nbPages > maxVisiblePages && endPage < nbPages - 1) {
|
||||
if (endPage < nbPages - 2) {
|
||||
pagesHTML += `
|
||||
<li class="ais-Pagination-item ais-Pagination-item--ellipsis">
|
||||
<span class="ais-Pagination-link">...</span>
|
||||
</li>`
|
||||
}
|
||||
pagesHTML += `
|
||||
<li class="ais-Pagination-item ais-Pagination-item--page">
|
||||
<a class="ais-Pagination-link" aria-label="Page ${nbPages}" href="#" data-page="${nbPages - 1}">${nbPages}</a>
|
||||
</li>`
|
||||
}
|
||||
|
||||
if (nbPages > 1) {
|
||||
elements.paginationList.innerHTML = `
|
||||
<li class="ais-Pagination-item ais-Pagination-item--previousPage ${isFirstPage ? 'ais-Pagination-item--disabled' : ''}">
|
||||
${isFirstPage
|
||||
? '<span class="ais-Pagination-link ais-Pagination-link--disabled" aria-label="Previous Page"><i class="fas fa-angle-left"></i></span>'
|
||||
: `<a class="ais-Pagination-link" aria-label="Previous Page" href="#" data-page="${page - 1}"><i class="fas fa-angle-left"></i></a>`
|
||||
}
|
||||
</li>
|
||||
${pagesHTML}
|
||||
<li class="ais-Pagination-item ais-Pagination-item--nextPage ${isLastPage ? 'ais-Pagination-item--disabled' : ''}">
|
||||
${isLastPage
|
||||
? '<span class="ais-Pagination-link ais-Pagination-link--disabled" aria-label="Next Page"><i class="fas fa-angle-right"></i></span>'
|
||||
: `<a class="ais-Pagination-link" aria-label="Next Page" href="#" data-page="${page + 1}"><i class="fas fa-angle-right"></i></a>`
|
||||
}
|
||||
</li>`
|
||||
} else {
|
||||
elements.pagination.style.display = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
// Clear search results and stats
|
||||
const clearSearchResults = () => {
|
||||
const container = document.getElementById('local-search-results')
|
||||
container.textContent = ''
|
||||
statsItem.textContent = ''
|
||||
toggleResultsVisibility(false)
|
||||
if (enablePagination) {
|
||||
currentResultItems = []
|
||||
currentPage = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Show no results message
|
||||
const showNoResults = searchText => {
|
||||
const container = document.getElementById('local-search-results')
|
||||
container.textContent = ''
|
||||
const statsDiv = document.createElement('div')
|
||||
statsDiv.className = 'search-result-stats'
|
||||
statsDiv.textContent = languages.hits_empty.replace(/\$\{query}/, searchText)
|
||||
statsItem.innerHTML = statsDiv.outerHTML
|
||||
toggleResultsVisibility(false)
|
||||
if (enablePagination) {
|
||||
currentResultItems = []
|
||||
currentPage = 0
|
||||
}
|
||||
}
|
||||
|
||||
const inputEventFunction = () => {
|
||||
if (!localSearch.isfetched) return
|
||||
let searchText = input.value.trim().toLowerCase()
|
||||
isXml && (searchText = searchText.replace(/</g, '<').replace(/>/g, '>'))
|
||||
|
||||
if (searchText !== '') $loadingStatus.hidden = false
|
||||
|
||||
const keywords = searchText.split(/[-\s]+/)
|
||||
let resultItems = []
|
||||
|
||||
if (searchText.length > 0) {
|
||||
resultItems = localSearch.getResultItems(keywords)
|
||||
}
|
||||
|
||||
if (keywords.length === 1 && keywords[0] === '') {
|
||||
clearSearchResults()
|
||||
} else if (resultItems.length === 0) {
|
||||
showNoResults(searchText)
|
||||
} else {
|
||||
// Sort results by relevance
|
||||
resultItems.sort((left, right) => {
|
||||
if (left.includedCount !== right.includedCount) {
|
||||
return right.includedCount - left.includedCount
|
||||
} else if (left.hitCount !== right.hitCount) {
|
||||
return right.hitCount - left.hitCount
|
||||
}
|
||||
return right.id - left.id
|
||||
})
|
||||
|
||||
if (enablePagination) {
|
||||
currentResultItems = resultItems
|
||||
currentPage = 0
|
||||
}
|
||||
renderResults(searchText, resultItems)
|
||||
}
|
||||
|
||||
$loadingStatus.hidden = true
|
||||
}
|
||||
|
||||
let loadFlag = false
|
||||
const $searchMask = document.getElementById('search-mask')
|
||||
const $searchDialog = document.querySelector('#local-search .search-dialog')
|
||||
|
||||
// fix safari
|
||||
const fixSafariHeight = () => {
|
||||
if (window.innerWidth < 768) {
|
||||
$searchDialog.style.setProperty('--search-height', window.innerHeight + 'px')
|
||||
}
|
||||
}
|
||||
|
||||
const openSearch = () => {
|
||||
btf.overflowPaddingR.add()
|
||||
btf.animateIn($searchMask, 'to_show 0.5s')
|
||||
btf.animateIn($searchDialog, 'titleScale 0.5s')
|
||||
setTimeout(() => { input.focus() }, 300)
|
||||
if (!loadFlag) {
|
||||
!localSearch.isfetched && localSearch.fetchData()
|
||||
input.addEventListener('input', inputEventFunction)
|
||||
loadFlag = true
|
||||
}
|
||||
// shortcut: ESC
|
||||
document.addEventListener('keydown', function f (event) {
|
||||
if (event.code === 'Escape') {
|
||||
closeSearch()
|
||||
document.removeEventListener('keydown', f)
|
||||
}
|
||||
})
|
||||
|
||||
fixSafariHeight()
|
||||
window.addEventListener('resize', fixSafariHeight)
|
||||
}
|
||||
|
||||
const closeSearch = () => {
|
||||
btf.overflowPaddingR.remove()
|
||||
btf.animateOut($searchDialog, 'search_close .5s')
|
||||
btf.animateOut($searchMask, 'to_hide 0.5s')
|
||||
window.removeEventListener('resize', fixSafariHeight)
|
||||
}
|
||||
|
||||
const searchClickFn = () => {
|
||||
btf.addEventListenerPjax(document.querySelector('#search-button > .search'), 'click', openSearch)
|
||||
}
|
||||
|
||||
const searchFnOnce = () => {
|
||||
document.querySelector('#local-search .search-close-button').addEventListener('click', closeSearch)
|
||||
$searchMask.addEventListener('click', closeSearch)
|
||||
if (GLOBAL_CONFIG.localSearch.preload) {
|
||||
localSearch.fetchData()
|
||||
}
|
||||
localSearch.highlightSearchWords(document.getElementById('article-container'))
|
||||
|
||||
// Pagination event delegation - only add if pagination is enabled
|
||||
if (enablePagination) {
|
||||
elements.pagination.addEventListener('click', e => {
|
||||
e.preventDefault()
|
||||
const link = e.target.closest('a[data-page]')
|
||||
if (link) {
|
||||
const page = parseInt(link.dataset.page, 10)
|
||||
if (!isNaN(page) && currentResultItems.length > 0) {
|
||||
currentPage = page
|
||||
renderResults(input.value.trim().toLowerCase(), currentResultItems)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Initial state
|
||||
toggleResultsVisibility(false)
|
||||
}
|
||||
|
||||
window.addEventListener('search:loaded', () => {
|
||||
const $loadDataItem = document.getElementById('loading-database')
|
||||
$loadDataItem.nextElementSibling.style.visibility = 'visible'
|
||||
$loadDataItem.remove()
|
||||
})
|
||||
|
||||
searchClickFn()
|
||||
searchFnOnce()
|
||||
|
||||
// pjax
|
||||
window.addEventListener('pjax:complete', () => {
|
||||
!btf.isHidden($searchMask) && closeSearch()
|
||||
localSearch.highlightSearchWords(document.getElementById('article-container'))
|
||||
searchClickFn()
|
||||
})
|
||||
})
|
||||
545
js/search/typesense-search.js
Normal file
545
js/search/typesense-search.js
Normal file
@@ -0,0 +1,545 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// 配置区域 - 请根据实际情况修改
|
||||
// ============================================================================
|
||||
const CONFIG = {
|
||||
apiKey: "2uxQzk7eD2GBCljdSmIiuU3ause7UK9n", // ⚠️ 建议使用 Search-Only API Key
|
||||
server: {
|
||||
host: "typesense.biss.click",
|
||||
port: "443",
|
||||
protocol: "https"
|
||||
},
|
||||
indexName: "blogs",
|
||||
searchParams: {
|
||||
query_by: "title,content",
|
||||
highlight_full_fields: "title,content",
|
||||
per_page: 8,
|
||||
num_typos: 1,
|
||||
typo_tokens_threshold: 1,
|
||||
prefix: true
|
||||
},
|
||||
ui: {
|
||||
maxRetries: 10,
|
||||
retryDelay: 100,
|
||||
animationDuration: 300
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 状态管理
|
||||
// ============================================================================
|
||||
let searchInstance = null;
|
||||
let isInitialized = false;
|
||||
let isSearchOpen = false;
|
||||
let initRetryCount = 0;
|
||||
const MAX_INIT_RETRIES = 30; // 最多重试30次 (3秒)
|
||||
|
||||
// ============================================================================
|
||||
// 错误提示函数
|
||||
// ============================================================================
|
||||
function showErrorMessage() {
|
||||
const hitsContainer = document.getElementById('hits');
|
||||
if (!hitsContainer) return;
|
||||
|
||||
hitsContainer.innerHTML =
|
||||
'<div class="ts-empty">' +
|
||||
'<div style="color: #f44336;"><i class="fas fa-exclamation-triangle" style="font-size: 3rem;"></i></div>' +
|
||||
'<div style="font-size: 1.1rem; font-weight: bold; margin: 15px 0;">搜索服务加载失败</div>' +
|
||||
'<div style="font-size: 0.9rem; color: #666; line-height: 1.8;">' +
|
||||
'<p>依赖库未能正确加载,请检查以下配置:</p>' +
|
||||
'<ol style="text-align: left; max-width: 500px; margin: 15px auto;">' +
|
||||
'<li>确认已在 <code>_config.butterfly.yml</code> 中正确引入依赖</li>' +
|
||||
'<li>检查 JS 文件加载顺序(先 instantsearch.js,再 adapter)</li>' +
|
||||
'<li>尝试更换 CDN 或使用本地文件</li>' +
|
||||
'<li>打开浏览器控制台查看详细错误信息</li>' +
|
||||
'</ol>' +
|
||||
'</div>' +
|
||||
'<div style="margin-top: 20px;">' +
|
||||
'<button onclick="location.reload()" style="padding: 10px 20px; background: #49b1f5; color: white; border: none; border-radius: 5px; cursor: pointer;">重新加载页面</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 1. 动态插入 HTML 结构
|
||||
// ============================================================================
|
||||
const searchHTML = `
|
||||
<div id="typesense-search-mask" class="ts-mask" style="display:none;">
|
||||
<div id="typesense-search-container" class="ts-container">
|
||||
<div class="ts-header">
|
||||
<span class="ts-title">
|
||||
<i class="fas fa-search"></i> 本站搜索
|
||||
</span>
|
||||
<span id="close-typesense" class="ts-close" aria-label="关闭搜索">×</span>
|
||||
</div>
|
||||
<div id="searchbox"></div>
|
||||
<div id="stats" class="ts-stats"></div>
|
||||
<div id="hits" class="ts-hits"></div>
|
||||
<div id="pagination" class="ts-pagination"></div>
|
||||
<div class="ts-footer">
|
||||
<small>Search powered by <strong>Typesense</strong></small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ts-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 10000;
|
||||
backdrop-filter: blur(5px);
|
||||
-webkit-backdrop-filter: blur(5px);
|
||||
opacity: 0;
|
||||
transition: opacity 300ms ease;
|
||||
}
|
||||
.ts-mask.active { opacity: 1; }
|
||||
.ts-container {
|
||||
margin: 5% auto;
|
||||
width: 90%;
|
||||
max-width: 650px;
|
||||
background: var(--search-bg, var(--card-bg, #fff));
|
||||
padding: 25px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.3);
|
||||
position: relative;
|
||||
z-index: 10001;
|
||||
transform: translateY(-50px);
|
||||
opacity: 0;
|
||||
transition: all 300ms ease;
|
||||
}
|
||||
.ts-mask.active .ts-container {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
.ts-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid var(--text-highlight-color, #49b1f5);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.ts-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
color: var(--text-highlight-color, #49b1f5);
|
||||
}
|
||||
.ts-close {
|
||||
cursor: pointer;
|
||||
font-size: 28px;
|
||||
color: var(--font-color, #333);
|
||||
line-height: 1;
|
||||
transition: color 0.2s, transform 0.2s;
|
||||
}
|
||||
.ts-close:hover {
|
||||
color: var(--text-highlight-color, #49b1f5);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.ais-SearchBox-input {
|
||||
position: relative;
|
||||
z-index: 10002;
|
||||
cursor: text;
|
||||
padding: 12px 40px 12px 15px !important;
|
||||
border-radius: 8px !important;
|
||||
border: 2px solid #eee !important;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
transition: border-color 0.3s, box-shadow 0.3s;
|
||||
background: var(--card-bg, #fff);
|
||||
color: var(--font-color, #333);
|
||||
font-size: 1rem;
|
||||
}
|
||||
.ais-SearchBox-input:focus {
|
||||
border-color: var(--text-highlight-color, #49b1f5) !important;
|
||||
box-shadow: 0 0 0 3px rgba(73, 177, 245, 0.1);
|
||||
}
|
||||
.ts-stats {
|
||||
margin: 10px 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--font-color, #666);
|
||||
opacity: 0.8;
|
||||
}
|
||||
.ts-hits {
|
||||
max-height: 55vh;
|
||||
overflow-y: auto;
|
||||
margin-top: 15px;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.ts-hits::-webkit-scrollbar { width: 6px; }
|
||||
.ts-hits::-webkit-scrollbar-track {
|
||||
background: var(--card-bg, #f1f1f1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.ts-hits::-webkit-scrollbar-thumb {
|
||||
background: var(--text-highlight-color, #49b1f5);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.ts-empty {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
color: var(--font-color, #999);
|
||||
}
|
||||
.ts-empty i {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 15px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
.ts-empty code {
|
||||
background: #f5f5f5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: monospace;
|
||||
color: #e91e63;
|
||||
}
|
||||
.ts-empty ol {
|
||||
padding-left: 20px;
|
||||
}
|
||||
.ts-empty li {
|
||||
margin: 8px 0;
|
||||
}
|
||||
.ts-result-item {
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
margin-bottom: 10px;
|
||||
padding: 15px;
|
||||
border: 1px solid transparent;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
background: var(--card-bg, #fff);
|
||||
}
|
||||
.ts-result-item:hover {
|
||||
background: var(--text-bg-hover, rgba(73, 177, 245, 0.05));
|
||||
border-color: var(--text-highlight-color, #49b1f5);
|
||||
transform: translateX(5px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.ts-result-title {
|
||||
font-weight: bold;
|
||||
color: var(--text-highlight-color, #49b1f5);
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 8px;
|
||||
display: block;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.ts-result-content {
|
||||
font-size: 0.9rem;
|
||||
color: var(--font-color, #666);
|
||||
line-height: 1.6;
|
||||
opacity: 0.85;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.ts-result-item mark {
|
||||
background: #ffeb3b;
|
||||
color: #000;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ts-pagination {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.ais-Pagination-list {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
gap: 5px;
|
||||
}
|
||||
.ais-Pagination-link {
|
||||
display: block;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
color: var(--font-color, #333);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
background: var(--card-bg, #fff);
|
||||
}
|
||||
.ais-Pagination-link:hover {
|
||||
background: var(--text-highlight-color, #49b1f5);
|
||||
color: #fff;
|
||||
}
|
||||
.ais-Pagination-item--selected .ais-Pagination-link {
|
||||
background: var(--text-highlight-color, #49b1f5);
|
||||
color: #fff;
|
||||
}
|
||||
.ts-footer {
|
||||
text-align: right;
|
||||
margin-top: 15px;
|
||||
border-top: 1px solid var(--border-color, #eee);
|
||||
padding-top: 10px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.ts-container {
|
||||
margin: 10px;
|
||||
width: calc(100% - 20px);
|
||||
padding: 20px 15px;
|
||||
}
|
||||
.ts-hits { max-height: 50vh; }
|
||||
}
|
||||
[data-theme="dark"] .ts-mask,
|
||||
.dark-mode .ts-mask {
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
document.body.insertAdjacentHTML('beforeend', searchHTML);
|
||||
|
||||
const mask = document.getElementById('typesense-search-mask');
|
||||
const closeBtn = document.getElementById('close-typesense');
|
||||
const container = document.getElementById('typesense-search-container');
|
||||
|
||||
// ============================================================================
|
||||
// 搜索控制
|
||||
// ============================================================================
|
||||
function openSearch() {
|
||||
if (isSearchOpen) return;
|
||||
isSearchOpen = true;
|
||||
mask.style.display = 'block';
|
||||
void mask.offsetWidth;
|
||||
mask.classList.add('active');
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
if (!isInitialized) {
|
||||
initTypesense();
|
||||
}
|
||||
focusSearchInput();
|
||||
}
|
||||
|
||||
function closeSearch() {
|
||||
if (!isSearchOpen) return;
|
||||
isSearchOpen = false;
|
||||
mask.classList.remove('active');
|
||||
setTimeout(function() {
|
||||
mask.style.display = 'none';
|
||||
document.body.style.overflow = '';
|
||||
}, CONFIG.ui.animationDuration);
|
||||
}
|
||||
|
||||
function focusSearchInput(retryCount) {
|
||||
retryCount = retryCount || 0;
|
||||
const input = document.querySelector('.ais-SearchBox-input');
|
||||
if (input) {
|
||||
input.focus();
|
||||
input.select();
|
||||
} else if (retryCount < CONFIG.ui.maxRetries) {
|
||||
setTimeout(function() {
|
||||
focusSearchInput(retryCount + 1);
|
||||
}, CONFIG.ui.retryDelay);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 事件监听
|
||||
// ============================================================================
|
||||
document.addEventListener('click', function(e) {
|
||||
if (e.target.closest('.search-typesense-trigger')) {
|
||||
e.preventDefault();
|
||||
openSearch();
|
||||
}
|
||||
});
|
||||
|
||||
closeBtn.addEventListener('click', closeSearch);
|
||||
mask.addEventListener('click', function(e) {
|
||||
if (e.target === mask) closeSearch();
|
||||
});
|
||||
container.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
window.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && isSearchOpen) {
|
||||
closeSearch();
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
openSearch();
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Typesense 初始化(带重试限制)
|
||||
// ============================================================================
|
||||
function initTypesense() {
|
||||
if (isInitialized || searchInstance) {
|
||||
console.warn('Typesense 搜索已初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
var instantsearchLoaded = typeof instantsearch !== 'undefined';
|
||||
var adapterLoaded = typeof TypesenseInstantSearchAdapter !== 'undefined' ||
|
||||
typeof window.TypesenseInstantSearchAdapter !== 'undefined';
|
||||
|
||||
console.log('📦 依赖库检查 (' + (initRetryCount + 1) + '/' + MAX_INIT_RETRIES + '):');
|
||||
console.log(' instantsearch.js:', instantsearchLoaded ? '✅ 已加载' : '❌ 未加载');
|
||||
console.log(' TypesenseAdapter:', adapterLoaded ? '✅ 已加载' : '❌ 未加载');
|
||||
|
||||
if (!instantsearchLoaded || !adapterLoaded) {
|
||||
initRetryCount++;
|
||||
|
||||
if (initRetryCount >= MAX_INIT_RETRIES) {
|
||||
console.error('');
|
||||
console.error('❌ Typesense 依赖库加载失败!');
|
||||
console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.error('');
|
||||
console.error('🔧 请检查 _config.butterfly.yml 配置:');
|
||||
console.error('');
|
||||
console.error('inject:');
|
||||
console.error(' bottom: # ⚠️ 使用 bottom 而不是 head');
|
||||
console.error(' - <script src="https://cdn.jsdelivr.net/npm/instantsearch.js@4.56.0"></script>');
|
||||
console.error(' - <script src="https://cdn.jsdelivr.net/npm/typesense-instantsearch-adapter@2.7.0/dist/typesense-instantsearch-adapter.min.js"></script>');
|
||||
console.error(' - <script src="/js/typesense-search-fixed.js"></script>');
|
||||
console.error('');
|
||||
console.error('💡 或在控制台手动检查:');
|
||||
console.error(' typeof instantsearch');
|
||||
console.error(' typeof TypesenseInstantSearchAdapter');
|
||||
console.error('');
|
||||
console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
|
||||
showErrorMessage();
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn('⏳ 100ms 后重试...');
|
||||
setTimeout(initTypesense, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
initRetryCount = 0;
|
||||
console.log('🚀 开始初始化 Typesense...');
|
||||
|
||||
try {
|
||||
const typesenseAdapter = new TypesenseInstantSearchAdapter({
|
||||
server: {
|
||||
apiKey: CONFIG.apiKey,
|
||||
nodes: [{
|
||||
host: CONFIG.server.host,
|
||||
port: CONFIG.server.port,
|
||||
protocol: CONFIG.server.protocol
|
||||
}],
|
||||
cacheSearchResultsForSeconds: 120
|
||||
},
|
||||
additionalSearchParameters: CONFIG.searchParams
|
||||
});
|
||||
|
||||
searchInstance = instantsearch({
|
||||
searchClient: typesenseAdapter.searchClient,
|
||||
indexName: CONFIG.indexName,
|
||||
routing: false
|
||||
});
|
||||
|
||||
searchInstance.addWidgets([
|
||||
instantsearch.widgets.searchBox({
|
||||
container: '#searchbox',
|
||||
placeholder: '输入关键词寻找故事...',
|
||||
autofocus: true,
|
||||
showReset: true,
|
||||
showSubmit: false,
|
||||
showLoadingIndicator: true
|
||||
}),
|
||||
instantsearch.widgets.stats({
|
||||
container: '#stats',
|
||||
templates: {
|
||||
text: function(data) {
|
||||
if (!data.query) return '';
|
||||
return '找到 <strong>' + data.nbHits + '</strong> 条结果 (' + data.processingTimeMS + 'ms)';
|
||||
}
|
||||
}
|
||||
}),
|
||||
instantsearch.widgets.hits({
|
||||
container: '#hits',
|
||||
templates: {
|
||||
empty: function(results) {
|
||||
return '<div class="ts-empty">' +
|
||||
'<div><i class="fas fa-search"></i></div>' +
|
||||
'<div>找不到与 "<strong>' + results.query + '</strong>" 相关的内容</div>' +
|
||||
'<div style="margin-top: 10px;">试试其他关键词吧 (´·ω·`)</div>' +
|
||||
'</div>';
|
||||
},
|
||||
item: function(hit) {
|
||||
// 使用 _highlightResult 获取高亮文本
|
||||
var titleHighlight = hit._highlightResult && hit._highlightResult.title
|
||||
? hit._highlightResult.title.value
|
||||
: (hit.title || '');
|
||||
|
||||
var contentHighlight = hit._highlightResult && hit._highlightResult.content
|
||||
? hit._highlightResult.content.value
|
||||
: (hit.content || '');
|
||||
|
||||
// 截取内容长度
|
||||
if (contentHighlight.length > 200) {
|
||||
contentHighlight = contentHighlight.substring(0, 200) + '...';
|
||||
}
|
||||
|
||||
return '<a href="' + hit.url + '" class="ts-result-item">' +
|
||||
'<div class="ts-result-title">' + titleHighlight + '</div>' +
|
||||
'<div class="ts-result-content">' + contentHighlight + '</div>' +
|
||||
'</a>';
|
||||
}
|
||||
}
|
||||
}),
|
||||
instantsearch.widgets.pagination({
|
||||
container: '#pagination',
|
||||
padding: 2,
|
||||
showFirst: false,
|
||||
showLast: false
|
||||
})
|
||||
]);
|
||||
|
||||
searchInstance.start();
|
||||
isInitialized = true;
|
||||
|
||||
console.log('✅ Typesense 初始化成功!');
|
||||
|
||||
searchInstance.on('render', function() {
|
||||
if (isSearchOpen) {
|
||||
const input = document.querySelector('.ais-SearchBox-input');
|
||||
if (input && document.activeElement !== input) {
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 初始化失败:', error);
|
||||
showErrorMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 初始化
|
||||
// ============================================================================
|
||||
function init() {
|
||||
console.log('🔍 Typesense 搜索已准备就绪');
|
||||
console.log('💡 快捷键: Ctrl/Cmd + K');
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 全局接口
|
||||
// ============================================================================
|
||||
window.TypesenseSearch = {
|
||||
open: openSearch,
|
||||
close: closeSearch,
|
||||
isOpen: function() { return isSearchOpen; },
|
||||
getInstance: function() { return searchInstance; }
|
||||
};
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user