Files
FixIt/assets/js/modules/search/index.ts
T
Cell 6f83bc6da5 refactor: restructure TypeScript modules and search config
- Create PublicAPI class to encapsulate module initialization
- Move search config types to modules/search/types.ts
- Rename ui.ts to global.ts for clarity
- Simplify pagefind engine (remove normalizeSortOrder, toObject)
- Move pagefind config to top-level in FixItConfig
- Add TSDoc comments and @param tags
- Update PagefindConfig.sortOrder type to 'asc' | 'desc'
- Improve pagefind detection warning message
2026-06-18 16:09:16 +08:00

262 lines
9.2 KiB
TypeScript

import type { CoreService, SearchService } from '../../core/tokens'
import type { SearchConfig, SearchEngine, SearchResult } from './types'
import { createAlgoliaEngine } from './engines/algolia'
import { createCSEEngine } from './engines/cse'
import { createFuseEngine } from './engines/fuse'
import { createPagefindEngine } from './engines/pagefind'
/**
* Search module — orchestrates search engines, autocomplete, and UI lifecycle.
*
* Responsibilities:
* - Select and initialize the configured search engine backend.
* - Manage search dialog open/close and keyboard shortcuts.
* - Handle autocomplete, result rendering, and UI state reset.
*/
export class SearchModule implements SearchService {
#engine: SearchEngine | undefined
#autocompleteInstance: any
#openDialog: (() => void) | undefined
#closeDialog: (() => void) | undefined
#initialized = false
constructor(private readonly core: CoreService) {}
/** Initialize the search overlay, autocomplete, and engine-specific logic. */
initSearch() {
const searchConfig = this.core.config.search
if (!searchConfig || !searchConfig.type)
return
// Initialize engine once
if (!this.#engine) {
this.#engine = this.#createEngine(searchConfig.type, searchConfig)
}
// Initialize dialog and autocomplete once
if (!this.#initialized) {
this.#initialized = true
this.#initAutosearch()
this.#initDialog()
this.#initKeyboardShortcuts()
}
}
/** Create the appropriate search engine based on type. */
#createEngine(type: string, searchConfig: SearchConfig): SearchEngine {
switch (type) {
case 'algolia':
return createAlgoliaEngine(searchConfig)
case 'fuse':
return createFuseEngine(searchConfig)
case 'cse':
return createCSEEngine(this.core.config.cse)
case 'pagefind':
return createPagefindEngine(searchConfig, this.core.config.pagefind!)
default:
console.warn(`[FixIt] Unknown search type: "${type}". Supported types: algolia, fuse, cse, pagefind.`)
return {
async search() {
return []
},
}
}
}
/** Initialize @algolia/autocomplete-js instance. */
#initAutosearch() {
const searchConfig = this.core.config.search
if (!searchConfig || !this.#engine)
return
searchConfig.placeholder = searchConfig.placeholder || 'Search...'
searchConfig.clearText = searchConfig.clearText || 'Clear'
const autocompleteLib = (window as any)['@algolia/autocomplete-js']
if (!autocompleteLib?.autocomplete) {
console.error('[FixIt] @algolia/autocomplete-js is not available on window')
return
}
const { autocomplete } = autocompleteLib
const { maxResultLength = 10 } = searchConfig
const engine = this.#engine
// Render the panel inside .search-modal (sibling of .search-modal-header)
// so it stays within the <dialog> and benefits from the flex layout.
const panelContainer = document.querySelector('#search-dialog .search-modal')
let submitBtn: HTMLButtonElement | null = null
this.#autocompleteInstance = autocomplete({
container: '.search-modal-header',
panelContainer: panelContainer || undefined,
placeholder: searchConfig.placeholder,
defaultActiveItemId: 0,
detachedMediaQuery: 'none',
translations: {
clearButtonTitle: searchConfig.clearText,
},
classNames: {
root: 'search-autocomplete',
form: 'search-form',
input: 'search-input',
inputWrapper: 'search-input-wrapper',
inputWrapperPrefix: 'search-input-prefix',
inputWrapperSuffix: 'search-input-suffix',
submitButton: 'search-submit-btn',
clearButton: 'search-clear-btn',
loadingIndicator: 'search-loading',
label: 'search-label',
list: 'search-list',
item: 'search-item',
panel: 'search-panel',
panelLayout: 'search-panel-layout',
source: 'search-source',
sourceFooter: 'search-source-footer',
sourceHeader: 'search-source-header',
sourceNoResults: 'search-source-no-results',
},
getSources: ({ query }: { query: string }) => {
if (!query.trim())
return []
return [{
sourceId: 'search',
async getItems(): Promise<SearchResult[]> {
return engine.search(query, maxResultLength)
},
getItemUrl({ item }: { item: SearchResult }) {
return item.uri
},
templates: {
item({ item, html: h }: { item: SearchResult, html: any }) {
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 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>`
},
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>`
},
},
onSelect: ({ item }: { item: SearchResult }) => {
if (item.uri) {
this.#closeDialog?.()
window.location.assign(item.uri)
}
},
}]
},
onSubmit: ({ state }: { state: { activeItemId: number | null, collections: Array<{ items: SearchResult[] }> } }) => {
const { activeItemId, collections } = state
if (activeItemId === null || !collections.length)
return
const items = collections[0].items
const item = items[activeItemId]
if (item?.uri) {
this.#closeDialog?.()
window.location.assign(item.uri)
}
},
onStateChange: ({ state }: { state: { query: string, collections: Array<{ items: SearchResult[] }> } }) => {
if (!submitBtn)
return
const hasResults = state.collections.some(collection => collection.items.length > 0)
submitBtn.disabled = !state.query.trim() || !hasResults
},
})
// Cache submit button after autocomplete creates it
submitBtn = document.querySelector('.search-submit-btn')
// Replace clear button SVG with text
const clearBtn = document.querySelector('.search-autocomplete .search-clear-btn')
if (clearBtn) {
clearBtn.textContent = searchConfig.clearText
}
// Prevent autocomplete input blur on mousedown
document.addEventListener('mousedown', (e) => {
e.stopPropagation()
})
// Close button
document.querySelector('.search-close-btn')?.addEventListener('click', () => this.#closeDialog?.())
// Preload pagefind on first focus
if (searchConfig.type === 'pagefind' && this.#engine.preload) {
const container = document.querySelector('.search-autocomplete')
container?.addEventListener('focusin', () => {
this.#engine!.preload!().catch((error: Error) => {
console.error(error)
})
}, { once: true })
}
}
/** Initialize the search dialog open/close lifecycle using native <dialog>. */
#initDialog() {
const dialog = document.getElementById('search-dialog') as HTMLDialogElement | null
if (!dialog)
return
const focusInput = () => {
requestAnimationFrame(() => {
const input = dialog.querySelector('input') as HTMLInputElement | null
input?.focus()
})
}
const open = () => {
dialog.showModal()
focusInput()
}
const close = () => {
if (dialog.open)
dialog.close()
}
const resetAutocomplete = () => {
if (this.#autocompleteInstance) {
this.#autocompleteInstance.setQuery('')
this.#autocompleteInstance.setIsOpen(false)
}
}
// Expose open/close functions for use in autocomplete callbacks and keyboard shortcuts
this.#openDialog = open
this.#closeDialog = close
document.querySelector('.search-trigger.desktop')?.addEventListener('click', open)
document.querySelector('.search-trigger.mobile')?.addEventListener('click', () => {
this.core.closeMaskOverlay('menu-mobile')
open()
})
// Reset autocomplete state when dialog closes (Escape, backdrop click, etc.)
dialog.addEventListener('close', resetAutocomplete)
// @algolia/autocomplete-js calls preventDefault() on Escape,
// which prevents the native <dialog> close. Close manually.
dialog.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && dialog.open) {
e.preventDefault()
close()
}
})
}
/** Initialize keyboard shortcuts. */
#initKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault()
this.#openDialog?.()
}
})
}
}