support multilingual search
This commit is contained in:
@ -1,207 +0,0 @@
|
|||||||
// Configuration
|
|
||||||
const DEFAULT_CONFIG = {
|
|
||||||
search: {
|
|
||||||
minChars: 2, // Minimum characters before searching
|
|
||||||
maxResults: 5, // Maximum number of results to show
|
|
||||||
fields: { // Fields to search through
|
|
||||||
title: true, // Allow searching in title
|
|
||||||
description: true, // Allow searching in description
|
|
||||||
section: true // Allow searching in section
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
class FastSearch {
|
|
||||||
constructor({
|
|
||||||
searchInput, resultsContainer, json,
|
|
||||||
searchResultTemplate = null,
|
|
||||||
noResultsText = null,
|
|
||||||
}) {
|
|
||||||
this.searchInput = searchInput;
|
|
||||||
this.resultsContainer = resultsContainer;
|
|
||||||
this.json = json;
|
|
||||||
this.searchResultTemplate = searchResultTemplate;
|
|
||||||
this.noResultsText = noResultsText;
|
|
||||||
|
|
||||||
this.init();
|
|
||||||
}
|
|
||||||
|
|
||||||
init() {
|
|
||||||
this.loadSearchIndex();
|
|
||||||
// this.initShortcutListener();
|
|
||||||
this.searchInput.addEventListener('input', (event) => {
|
|
||||||
if (!this.searchIndex) {
|
|
||||||
this.resultsContainer.innerHTML = '<li class="search-message">Loading search index...</li>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.performSearch(this.searchInput.value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load the search index
|
|
||||||
async loadSearchIndex() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(this.json);
|
|
||||||
if (!response.ok) throw new Error('Failed to load search index');
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
this.searchIndex = data.map(item => ({
|
|
||||||
...item,
|
|
||||||
searchableTitle: item.title?.toLowerCase() || '',
|
|
||||||
searchableDesc: item.desc?.toLowerCase() || '',
|
|
||||||
searchableSection: item.section?.toLowerCase() || ''
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading search index:', error);
|
|
||||||
this.resultsContainer.innerHTML = '<li class="search-message">Error loading search index...</li>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
escapeHtml(unsafe) {
|
|
||||||
if (!unsafe) return '';
|
|
||||||
return unsafe
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Simple fuzzy match for single words
|
|
||||||
simpleFuzzyMatch(text, term) {
|
|
||||||
if (text.includes(term)) return true;
|
|
||||||
if (term.length < 3) return false;
|
|
||||||
|
|
||||||
let matches = 0;
|
|
||||||
let lastMatchIndex = -1;
|
|
||||||
|
|
||||||
for (let i = 0; i < term.length; i++) {
|
|
||||||
const found = text.indexOf(term[i], lastMatchIndex + 1);
|
|
||||||
if (found > -1) {
|
|
||||||
matches++;
|
|
||||||
lastMatchIndex = found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return matches === term.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if keyboard event matches shortcut config
|
|
||||||
matchesShortcut(event, shortcutConfig) {
|
|
||||||
return event.key === shortcutConfig.key &&
|
|
||||||
event.metaKey === shortcutConfig.metaKey &&
|
|
||||||
event.altKey === shortcutConfig.altKey &&
|
|
||||||
event.ctrlKey === shortcutConfig.ctrlKey &&
|
|
||||||
event.shiftKey === shortcutConfig.shiftKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
initShortcutListener() {
|
|
||||||
// Keyboard shortcuts
|
|
||||||
document.addEventListener('keydown', (event) => {
|
|
||||||
// ESC to close search
|
|
||||||
if (event.key === 'Escape' && this.searchInput.focus) {
|
|
||||||
this.searchInput.blur();
|
|
||||||
this.searchInput.value = '';
|
|
||||||
this.resultsContainer.innerHTML = '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
performSearch(term) {
|
|
||||||
term = term.toLowerCase().trim();
|
|
||||||
|
|
||||||
if (!term || !this.searchIndex) {
|
|
||||||
this.resultsContainer.innerHTML = '';
|
|
||||||
let resultsAvailable = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split search into terms
|
|
||||||
const searchTerms = term.split(/\s+/).filter(t => t.length > 0);
|
|
||||||
|
|
||||||
// Search with scoring
|
|
||||||
const results = this.searchIndex
|
|
||||||
.map(item => {
|
|
||||||
let score = 0;
|
|
||||||
const matchesAllTerms = searchTerms.every(term => {
|
|
||||||
let matched = false;
|
|
||||||
|
|
||||||
// Title matches (weighted higher)
|
|
||||||
if (DEFAULT_CONFIG.search.fields.title) {
|
|
||||||
if (item.searchableTitle.startsWith(term)) {
|
|
||||||
score += 3; // Highest score for prefix matches in title
|
|
||||||
matched = true;
|
|
||||||
} else if (this.simpleFuzzyMatch(item.searchableTitle, term)) {
|
|
||||||
score += 2; // Good score for fuzzy matches in title
|
|
||||||
matched = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Other field matches
|
|
||||||
if (!matched) {
|
|
||||||
if (DEFAULT_CONFIG.search.fields.description && item.searchableDesc.includes(term)) {
|
|
||||||
score += 0.5; // Lower score for description matches
|
|
||||||
matched = true;
|
|
||||||
}
|
|
||||||
if (DEFAULT_CONFIG.search.fields.section && item.searchableSection.includes(term)) {
|
|
||||||
score += 0.5; // Lower score for section matches
|
|
||||||
matched = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return matched;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
item,
|
|
||||||
score: matchesAllTerms ? score : 0
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(result => result.score > 0)
|
|
||||||
.sort((a, b) => b.score - a.score)
|
|
||||||
.slice(0, DEFAULT_CONFIG.search.maxResults)
|
|
||||||
.map(result => result.item);
|
|
||||||
|
|
||||||
let resultsAvailable = results.length > 0;
|
|
||||||
|
|
||||||
if (!resultsAvailable) {
|
|
||||||
this.resultsContainer.innerHTML = '<p class="mt-5">Oops! No results found.</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchItems = results.map( (item) => {
|
|
||||||
let categories = '';
|
|
||||||
let tags = '';
|
|
||||||
if (item.categories) {
|
|
||||||
categories = item.categories.join(', ');
|
|
||||||
categories = `<div class="me-sm-4"><i class="far fa-folder fa-fw"></i>${categories}</div>`;
|
|
||||||
}
|
|
||||||
if (item.tags) {
|
|
||||||
tags = item.tags.join(', ');
|
|
||||||
tags = `<div><i class="fa fa-tag fa-fw"></i>${tags}</div>`
|
|
||||||
}
|
|
||||||
return `
|
|
||||||
<article class="px-1 px-sm-2 px-lg-4 px-xl-0">
|
|
||||||
<header>
|
|
||||||
<h2><a href="${this.escapeHtml(item.permalink)}">${this.escapeHtml(item.title)}</a></h2>
|
|
||||||
<div class="post-meta d-flex flex-column flex-sm-row text-muted mt-1 mb-1">
|
|
||||||
${categories}
|
|
||||||
${tags}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
<p>${this.escapeHtml(item.contents)}</p>
|
|
||||||
</article>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
this.resultsContainer.innerHTML = searchItems;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const search = new FastSearch({
|
|
||||||
searchInput: document.getElementById('search-input'),
|
|
||||||
resultsContainer: document.getElementById('search-results'),
|
|
||||||
json: '/index.json'
|
|
||||||
});
|
|
@ -6,7 +6,6 @@
|
|||||||
-}}
|
-}}
|
||||||
{{- $themeOpts := merge $jsOpts (dict "global" "Theme") -}}
|
{{- $themeOpts := merge $jsOpts (dict "global" "Theme") -}}
|
||||||
{{- $theme := resources.Get "js/modules/theme.js" | js.Build $themeOpts -}}
|
{{- $theme := resources.Get "js/modules/theme.js" | js.Build $themeOpts -}}
|
||||||
{{- $search := resources.Get "js/search.js" | js.Build $jsOpts -}}
|
|
||||||
<head>
|
<head>
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||||
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f7f7f7">
|
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f7f7f7">
|
||||||
@ -78,7 +77,6 @@
|
|||||||
|
|
||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
<script src="{{ $theme.RelPermalink }}"></script>
|
<script src="{{ $theme.RelPermalink }}"></script>
|
||||||
<script defer src="{{ $search.RelPermalink }}"></script>
|
|
||||||
|
|
||||||
{{ partial "js-selector.html" . }}
|
{{ partial "js-selector.html" . }}
|
||||||
|
|
||||||
|
@ -6,3 +6,241 @@
|
|||||||
<div id="search-results" class="d-flex flex-wrap justify-content-center text-muted mt-3"></div>
|
<div id="search-results" class="d-flex flex-wrap justify-content-center text-muted mt-3"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const DEFAULT_CONFIG = {
|
||||||
|
search: {
|
||||||
|
minChars: 1, // 最小字符数
|
||||||
|
maxResults: 5, // 最大结果数
|
||||||
|
fields: { // 搜索字段
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
section: true,
|
||||||
|
contents: true
|
||||||
|
},
|
||||||
|
// 简化搜索配置
|
||||||
|
strictMode: true // 严格模式:只返回精确匹配结果
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class FastSearch {
|
||||||
|
constructor({
|
||||||
|
searchInput, resultsContainer, json,
|
||||||
|
searchResultTemplate = null,
|
||||||
|
noResultsText = null,
|
||||||
|
}) {
|
||||||
|
this.searchInput = searchInput;
|
||||||
|
this.resultsContainer = resultsContainer;
|
||||||
|
this.json = json;
|
||||||
|
this.searchResultTemplate = searchResultTemplate;
|
||||||
|
this.noResultsText = noResultsText;
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.loadSearchIndex();
|
||||||
|
this.searchInput.addEventListener('input', (event) => {
|
||||||
|
if (!this.searchIndex) {
|
||||||
|
this.resultsContainer.innerHTML = '<li class="search-message">Loading search index...</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.performSearch(this.searchInput.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the search index
|
||||||
|
async loadSearchIndex() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.json);
|
||||||
|
if (!response.ok) throw new Error('Failed to load search index');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
this.searchIndex = data.map(item => ({
|
||||||
|
...item,
|
||||||
|
// 转换为小写以进行不区分大小写的搜索
|
||||||
|
searchableTitle: item.title?.toLowerCase() || '',
|
||||||
|
searchableDesc: item.desc?.toLowerCase() || '',
|
||||||
|
searchableSection: item.section?.toLowerCase() || '',
|
||||||
|
searchableContents: item.contents?.toLowerCase() || ''
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading search index:', error);
|
||||||
|
this.resultsContainer.innerHTML = '<li class="search-message">Error loading search index...</li>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeHtml(unsafe) {
|
||||||
|
if (!unsafe) return '';
|
||||||
|
return unsafe
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检测文本是否包含查询术语 - 使用严格的精确匹配
|
||||||
|
containsTerm(text, term) {
|
||||||
|
if (!text || !term) return false;
|
||||||
|
return text.includes(term);
|
||||||
|
}
|
||||||
|
|
||||||
|
performSearch(query) {
|
||||||
|
// 清理和标准化查询
|
||||||
|
query = query.toLowerCase().trim();
|
||||||
|
|
||||||
|
// 检查查询是否有效
|
||||||
|
if (!query || !this.searchIndex || query.length < DEFAULT_CONFIG.search.minChars) {
|
||||||
|
this.resultsContainer.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用空格分隔查询词(对多语言适用)
|
||||||
|
const searchTerms = [
|
||||||
|
query, // 保留整个查询字符串作为必须匹配的项
|
||||||
|
...query.split(/\s+/).filter(term => term.length > 0) // 添加单独的词
|
||||||
|
];
|
||||||
|
|
||||||
|
// 去重
|
||||||
|
const uniqueTerms = [...new Set(searchTerms)];
|
||||||
|
|
||||||
|
// 要求完整查询必须匹配的标志
|
||||||
|
const requireFullQueryMatch = DEFAULT_CONFIG.search.strictMode;
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const results = this.searchIndex
|
||||||
|
.map(item => {
|
||||||
|
// 首先检查完整查询是否匹配(严格模式下必须匹配)
|
||||||
|
const fullQueryMatched = this.checkFieldsForMatch(item, uniqueTerms[0]);
|
||||||
|
|
||||||
|
// 如果启用了严格模式且完整查询未匹配,则跳过此项
|
||||||
|
if (requireFullQueryMatch && !fullQueryMatched) {
|
||||||
|
return { item, score: 0, matched: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算匹配得分
|
||||||
|
let score = 0;
|
||||||
|
let matchedTermsCount = 0;
|
||||||
|
let matchedInTitle = false;
|
||||||
|
|
||||||
|
// 检查每个词的匹配情况
|
||||||
|
uniqueTerms.forEach((term, index) => {
|
||||||
|
// 对于整个查询字符串(index=0),给予更高权重
|
||||||
|
const isFullQuery = index === 0;
|
||||||
|
const matched = this.checkFieldsForMatch(item, term);
|
||||||
|
|
||||||
|
if (matched) {
|
||||||
|
matchedTermsCount++;
|
||||||
|
|
||||||
|
// 基于匹配位置计算得分
|
||||||
|
if (matched.inTitle) {
|
||||||
|
score += isFullQuery ? 10 : 5;
|
||||||
|
matchedInTitle = true;
|
||||||
|
}
|
||||||
|
if (matched.inDesc) {
|
||||||
|
score += isFullQuery ? 8 : 4;
|
||||||
|
}
|
||||||
|
if (matched.inSection) {
|
||||||
|
score += isFullQuery ? 6 : 3;
|
||||||
|
}
|
||||||
|
if (matched.inContents) {
|
||||||
|
score += isFullQuery ? 4 : 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算匹配率
|
||||||
|
const matchRatio = matchedTermsCount / uniqueTerms.length;
|
||||||
|
|
||||||
|
// 最终得分计算
|
||||||
|
const finalScore = score * matchRatio * (matchedInTitle ? 1.5 : 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
item,
|
||||||
|
score: finalScore,
|
||||||
|
matched: fullQueryMatched // 只有完整查询匹配才视为有效
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(result => result.matched)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, DEFAULT_CONFIG.search.maxResults)
|
||||||
|
.map(result => result.item);
|
||||||
|
|
||||||
|
// 显示结果
|
||||||
|
if (results.length === 0) {
|
||||||
|
this.resultsContainer.innerHTML = '<p class="mt-5">{{ i18n "search.no_results" }}</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchItems = results.map((item) => {
|
||||||
|
let categories = '';
|
||||||
|
let tags = '';
|
||||||
|
if (item.categories) {
|
||||||
|
categories = item.categories.join(', ');
|
||||||
|
categories = `<div class="me-sm-4"><i class="far fa-folder fa-fw"></i>${categories}</div>`;
|
||||||
|
}
|
||||||
|
if (item.tags) {
|
||||||
|
tags = item.tags.join(', ');
|
||||||
|
tags = `<div><i class="fa fa-tag fa-fw"></i>${tags}</div>`
|
||||||
|
}
|
||||||
|
return `
|
||||||
|
<article class="px-1 px-sm-2 px-lg-4 px-xl-0">
|
||||||
|
<header>
|
||||||
|
<h2><a href="${this.escapeHtml(item.permalink)}">${this.escapeHtml(item.title)}</a></h2>
|
||||||
|
<div class="post-meta d-flex flex-column flex-sm-row text-muted mt-1 mb-1">
|
||||||
|
${categories}
|
||||||
|
${tags}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<p>${this.escapeHtml(item.contents)}</p>
|
||||||
|
</article>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
this.resultsContainer.innerHTML = searchItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查一个词是否匹配指定项的任何字段
|
||||||
|
checkFieldsForMatch(item, term) {
|
||||||
|
const matches = {
|
||||||
|
inTitle: false,
|
||||||
|
inDesc: false,
|
||||||
|
inSection: false,
|
||||||
|
inContents: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查各个字段
|
||||||
|
if (DEFAULT_CONFIG.search.fields.title && this.containsTerm(item.searchableTitle, term)) {
|
||||||
|
matches.inTitle = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DEFAULT_CONFIG.search.fields.description && this.containsTerm(item.searchableDesc, term)) {
|
||||||
|
matches.inDesc = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DEFAULT_CONFIG.search.fields.section && this.containsTerm(item.searchableSection, term)) {
|
||||||
|
matches.inSection = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DEFAULT_CONFIG.search.fields.contents && this.containsTerm(item.searchableContents, term)) {
|
||||||
|
matches.inContents = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果任何字段匹配,则返回匹配信息
|
||||||
|
if (matches.inTitle || matches.inDesc || matches.inSection || matches.inContents) {
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则返回false
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = new FastSearch({
|
||||||
|
searchInput: document.getElementById('search-input'),
|
||||||
|
resultsContainer: document.getElementById('search-results'),
|
||||||
|
json: `{{ path.Join .Site.Home.RelPermalink "index.json" }}`
|
||||||
|
});
|
||||||
|
|
||||||
|
</script>
|
Reference in New Issue
Block a user