From f2c0635ee89c96cb32554a4163acbe3428265239 Mon Sep 17 00:00:00 2001 From: bisnsh Date: Sat, 7 Feb 2026 20:13:03 +0800 Subject: [PATCH] switch search function to typesense search --- .github/dependabot.yml | 7 - .github/workflows/main.yml | 70 -- _config.butterfly.yml | 9 +- genkey.js | 27 + package-lock.json | 77 +- package.json | 6 +- push2typesense.js | 69 ++ .../butterfly/layout/includes/header/nav.pug | 6 +- .../source/js/search/local-search.js | 1054 ++++++++--------- 9 files changed, 695 insertions(+), 630 deletions(-) delete mode 100644 .github/dependabot.yml delete mode 100644 .github/workflows/main.yml create mode 100644 genkey.js create mode 100644 push2typesense.js diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 93385d9..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,7 +0,0 @@ -version: 2 -updates: -- package-ecosystem: npm - directory: "/" - schedule: - interval: daily - open-pull-requests-limit: 20 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index 55c4b74..0000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: 自动部署 - -on: - push: - branches: - - master - - release: - types: - - published - - workflow_dispatch: - -env: - TZ: Asia/Shanghai - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - name: 检查分支 - uses: actions/checkout@v5 - with: - ref: master - token: ${{ secrets.GITHUB_TOKEN }} - - - name: 缓存项目 npm 包 - id: cache-node-modules - uses: actions/cache@v4 - with: - path: node_modules - key: ${{ runner.os }}-nodeModules-${{ hashFiles('package-lock.json') }}-${{ hashFiles('package.json') }} - restore-keys: | - ${{ runner.os }}-nodeModules- - - - name: 安装 Node - uses: actions/setup-node@v5 - with: - node-version: "22.x" - - - name: 安装 Hexo - run: | - npm install hexo-cli --global - - - name: 安装依赖 - if: steps.cache-node-modules.outputs.cache-hit != 'true' - run: | - npm install - - - name: 清理文件树 - run: | - npm run clean - - - name: 生成静态文件并压缩 - run: | - npm run build - - - name: 部署 - run: | - cd ./public - git init - git config user.name "${{ github.actor }}" - git config user.email "${{ github.actor }}@users.noreply.github.com" - git add . - git commit -m "${{ github.event.head_commit.message }}··[$(date +"%Z %Y-%m-%d %A %H:%M:%S")]" - git push --force --quiet "https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git" master:page - - - name: Deploy to Server - run: | - curl -k -X POST "https://45.145.229.95:40606/hook?access_key=1XJG8IvYTSZVvD5dpm86GYIpQxgxBcucULnX1MFskZSKayXU" diff --git a/_config.butterfly.yml b/_config.butterfly.yml index 61a51d8..dcc3397 100644 --- a/_config.butterfly.yml +++ b/_config.butterfly.yml @@ -589,10 +589,11 @@ math: # -------------------------------------- search: - # Choose: algolia_search / local_search / docsearch - # leave it empty if you don't need search use: local_search placeholder: + path: search.json + field: posts + content: true # Algolia Search algolia_search: @@ -1162,6 +1163,7 @@ css_prefix: true inject: head: # - + - - - - @@ -1185,6 +1187,9 @@ inject: - - - + - + - + # CDN Settings # Don't modify the following settings unless you know how they work diff --git a/genkey.js b/genkey.js new file mode 100644 index 0000000..cf98a27 --- /dev/null +++ b/genkey.js @@ -0,0 +1,27 @@ +const http = require('https'); + +const data = JSON.stringify({ + "description": "Public search only key", + "actions": ["documents:search"], + "collections": ["blogs"] +}); + +const options = { + hostname: 'typesense.biss.click', // 不要带 https:// + port: 443, + path: '/keys', + method: 'POST', + headers: { + 'X-TYPESENSE-API-KEY': 'tDPfzkghH3Zy55DHyWOnYkQiijOqN8bx', + 'Content-Type': 'application/json', + 'Content-Length': data.length + } +}; + +const req = http.request(options, res => { + res.on('data', d => { process.stdout.write(d); }); +}); + +req.on('error', error => { console.error(error); }); +req.write(data); +req.end(); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 2d64b39..0fa8797 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "name": "hexo-site", "version": "0.0.0", "dependencies": { - "@sveltia/cms": "^0.128.1", "axios": "^1.12.2", "cheerio": "^1.1.2", "hexo": "^7.3.0", @@ -20,6 +19,7 @@ "hexo-generator-archive": "^2.0.0", "hexo-generator-category": "^2.0.0", "hexo-generator-index": "^4.0.0", + "hexo-generator-search": "^2.4.3", "hexo-generator-searchdb": "^1.5.0", "hexo-generator-sitemap": "^3.0.1", "hexo-generator-tag": "^2.0.0", @@ -36,7 +36,9 @@ "moment": "^2.30.1", "node-fetch": "^3.3.2", "p-limit": "^7.2.0", - "vite-plugin-require-transform": "^1.0.21" + "typesense": "^3.0.1", + "vite-plugin-require-transform": "^1.0.21", + "xml2js": "^0.6.2" } }, "node_modules/@adobe/css-tools": { @@ -352,12 +354,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@sveltia/cms": { - "version": "0.128.6", - "resolved": "https://mirrors.huaweicloud.com/repository/npm/@sveltia/cms/-/cms-0.128.6.tgz", - "integrity": "sha512-y5+tM0hYeFZoYVr8Hu/QEnBofjHJaSROA78NMxR6Ju64ITA1CPvBdEczZHHgc0fZuDmoq+2YaUZoEtEYSXqTpA==", - "license": "MIT" - }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://mirrors.huaweicloud.com/repository/npm/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -2090,6 +2086,19 @@ "node": ">=18" } }, + "node_modules/hexo-generator-search": { + "version": "2.4.3", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/hexo-generator-search/-/hexo-generator-search-2.4.3.tgz", + "integrity": "sha512-Z5hfZq2g3np/Tgdp2q9HobfIvU6Pdz89tnTurc1IIq/vW0MHgDynk0Aiv6kvMtKWthnZ5l0iEMT3YLN35NdYwQ==", + "license": "MIT", + "dependencies": { + "nunjucks": "^3.0.1", + "utils-merge": "^1.0.0" + }, + "engines": { + "node": ">= 0.10.0" + } + }, "node_modules/hexo-generator-searchdb": { "version": "1.5.0", "resolved": "https://mirrors.huaweicloud.com/repository/npm/hexo-generator-searchdb/-/hexo-generator-searchdb-1.5.0.tgz", @@ -2903,6 +2912,19 @@ "promise": "^7.0.1" } }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://mirrors.huaweicloud.com/repository/npm/loose-envify/-/loose-envify-1.4.0.tgz", @@ -4126,6 +4148,23 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/typesense": { + "version": "3.0.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/typesense/-/typesense-3.0.1.tgz", + "integrity": "sha512-aRzuDQlwR7s2sWw+JiR3CufrMWpzH5UAJ4XlybYczD02QPy5jCsEQiueqUu0Wiai4zW/RGYRruF3XrdEXPgPJA==", + "license": "Apache-2.0", + "dependencies": { + "axios": "^1.8.4", + "loglevel": "^1.8.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@babel/runtime": "^7.23.2" + } + }, "node_modules/undici": { "version": "7.21.0", "resolved": "https://mirrors.huaweicloud.com/repository/npm/undici/-/undici-7.21.0.tgz", @@ -4345,6 +4384,28 @@ "node": ">=18" } }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://mirrors.huaweicloud.com/repository/npm/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://mirrors.huaweicloud.com/repository/npm/xmlchars/-/xmlchars-2.2.0.tgz", diff --git a/package.json b/package.json index 3d49832..3c50a31 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ "version": "7.3.0" }, "dependencies": { - "@sveltia/cms": "^0.128.1", "axios": "^1.12.2", "cheerio": "^1.1.2", "hexo": "^7.3.0", @@ -24,6 +23,7 @@ "hexo-generator-archive": "^2.0.0", "hexo-generator-category": "^2.0.0", "hexo-generator-index": "^4.0.0", + "hexo-generator-search": "^2.4.3", "hexo-generator-searchdb": "^1.5.0", "hexo-generator-sitemap": "^3.0.1", "hexo-generator-tag": "^2.0.0", @@ -40,6 +40,8 @@ "moment": "^2.30.1", "node-fetch": "^3.3.2", "p-limit": "^7.2.0", - "vite-plugin-require-transform": "^1.0.21" + "typesense": "^3.0.1", + "vite-plugin-require-transform": "^1.0.21", + "xml2js": "^0.6.2" } } diff --git a/push2typesense.js b/push2typesense.js new file mode 100644 index 0000000..73e4145 --- /dev/null +++ b/push2typesense.js @@ -0,0 +1,69 @@ +const Typesense = require('typesense'); +const fs = require('fs'); +const xml2js = require('xml2js'); + +// --- 配置区域 --- +const CONFIG = { + apiKey: 'tDPfzkghH3Zy55DHyWOnYkQiijOqN8bx', // 必须是 Admin Key + host: 'typesense.biss.click', + port: 443, + protocol: 'https', + collectionName: 'blogs' +}; + +const client = new Typesense.Client({ + 'nodes': [{ 'host': CONFIG.host, 'port': CONFIG.port, 'protocol': CONFIG.protocol }], + 'apiKey': CONFIG.apiKey, + 'connectionTimeoutSeconds': 5 +}); + +async function sync() { + try { + // 1. 读取并解析 XML + const xml = fs.readFileSync('./public/search.xml', 'utf8'); + const parser = new xml2js.Parser({ explicitArray: false }); + const result = await parser.parseStringPromise(xml); + + // 提取文章列表 (处理单篇文章和多篇文章的情况) + let entries = result.search.entry; + if (!Array.isArray(entries)) entries = [entries]; + + // 格式化数据以适配 Typesense + const documents = entries.map(post => ({ + title: post.title, + url: post.url, + content: post.content, + categories: post.categories ? (Array.isArray(post.categories.category) ? post.categories.category : [post.categories.category]) : [], + tags: post.tags ? (Array.isArray(post.tags.tag) ? post.tags.tag : [post.tags.tag]) : [], + })); + + // 2. 检查或创建 Collection (Schema) + try { + await client.collections(CONFIG.collectionName).retrieve(); + } catch (err) { + const schema = { + name: CONFIG.collectionName, + fields: [ + // 关键点:指定 locale 为 'zh' 开启中文分词 + { name: 'title', type: 'string', locale: 'zh' }, + { name: 'content', type: 'string', locale: 'zh' }, + { name: 'url', type: 'string' }, + { name: 'categories', type: 'string[]', facet: true }, + { name: 'tags', type: 'string[]', facet: true } + ] + }; + await client.collections().create(schema); + console.log('Collection created with Chinese support!'); + } + + // 3. 导入数据 (使用 upsert 模式:存在则更新,不存在则创建) + console.log(`Syncing ${documents.length} posts to Typesense...`); + await client.collections(CONFIG.collectionName).documents().import(documents, { action: 'upsert' }); + console.log('Sync complete!'); + + } catch (error) { + console.error('Sync failed:', error); + } +} + +sync(); \ No newline at end of file diff --git a/themes/butterfly/layout/includes/header/nav.pug b/themes/butterfly/layout/includes/header/nav.pug index 12c198f..2fef844 100644 --- a/themes/butterfly/layout/includes/header/nav.pug +++ b/themes/butterfly/layout/includes/header/nav.pug @@ -21,12 +21,12 @@ nav#nav if theme.menu != partial('includes/header/menu_item', {}, {cache: true}) #nav-right - if theme.search.use + if theme.search.use || true #random-post-button - a.site-page.social-icon#random-post-link(href='javascript:randomPost();') + a.site-page.social-icon#random-post-link(href='javascript:void(0);' onclick='randomPost()') i.fas.fa-solid.fa-shuffle #search-button - a.site-page.social-icon.search + a.site-page.social-icon.search-typesense-trigger i.fas.fa-search.fa-fw #toggle-menu span.site-page diff --git a/themes/butterfly/source/js/search/local-search.js b/themes/butterfly/source/js/search/local-search.js index f2ccb06..9b9a80d 100644 --- a/themes/butterfly/source/js/search/local-search.js +++ b/themes/butterfly/source/js/search/local-search.js @@ -1,567 +1,545 @@ -/** - * Refer to hexo-generator-searchdb - * https://github.com/next-theme/hexo-generator-searchdb/blob/main/dist/search.js - * Modified by hexo-theme-butterfly - */ +(function () { + 'use strict'; -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() + // ============================================================================ + // 配置区域 - 请根据实际情况修改 + // ============================================================================ + const CONFIG = { + apiKey: "VramSTWKUAggeZ5viQw8SlCwXQqmGCmA", // ⚠️ 建议使用 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 } - 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] + }; + + // ============================================================================ + // 状态管理 + // ============================================================================ + 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 = + '
' + + '
' + + '
搜索服务加载失败
' + + '
' + + '

依赖库未能正确加载,请检查以下配置:

' + + '
    ' + + '
  1. 确认已在 _config.butterfly.yml 中正确引入依赖
  2. ' + + '
  3. 检查 JS 文件加载顺序(先 instantsearch.js,再 adapter)
  4. ' + + '
  5. 尝试更换 CDN 或使用本地文件
  6. ' + + '
  7. 打开浏览器控制台查看详细错误信息
  8. ' + + '
' + + '
' + + '
' + + '' + + '
' + + '
'; } - // 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 + // ============================================================================ + // 1. 动态插入 HTML 结构 + // ============================================================================ + const searchHTML = ` + - // 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 + + `; + + 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(); } - return { - hits, - start, - end, - count: count.size + 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); } } - // 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 += `${val.substr(position, length)}` + // ============================================================================ + // 事件监听 + // ============================================================================ + document.addEventListener('click', function(e) { + if (e.target.closest('.search-typesense-trigger')) { + e.preventDefault(); + openSearch(); } - 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 + 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(); + } + }); - // Show search results - const hitCount = indexOfTitle.length + indexOfContent.length - if (hitCount === 0) return + // ============================================================================ + // Typesense 初始化(带重试限制) + // ============================================================================ + function initTypesense() { + if (isInitialized || searchInstance) { + console.warn('Typesense 搜索已初始化'); + return; + } - const slicesOfTitle = [] - if (indexOfTitle.length !== 0) { - slicesOfTitle.push(this.mergeIntoSlice(0, title.length, indexOfTitle)) + 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(' - '); + console.error(' - '); + console.error(' - '); + 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...'); - 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)) - } + 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 + }); - // 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 - }) + searchInstance = instantsearch({ + searchClient: typesenseAdapter.searchClient, + indexName: CONFIG.indexName, + routing: false + }); - // 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 += `
  • ${this.highlightKeyword(title, slicesOfTitle[0])}` - } else { - resultItem += `
  • ${title}` - } - - slicesOfContent.forEach(slice => { - resultItem += `

    ${this.highlightKeyword(content, slice)}...

    ` - }) - - resultItem += '
  • ' - 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 + 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 '找到 ' + data.nbHits + ' 条结果 (' + data.processingTimeMS + 'ms)'; + } + } + }), + instantsearch.widgets.hits({ + container: '#hits', + templates: { + empty: function(results) { + return '
    ' + + '
    ' + + '
    找不到与 "' + results.query + '" 相关的内容
    ' + + '
    试试其他关键词吧 (´·ω·`)
    ' + + '
    '; + }, + 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 '' + + '
    ' + titleHighlight + '
    ' + + '
    ' + contentHighlight + '
    ' + + '
    '; + } + } + }), + instantsearch.widgets.pagination({ + container: '#pagination', + padding: 2, + showFirst: false, + showLast: false }) - // 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) - }) - } + searchInstance.start(); + isInitialized = true; + + console.log('✅ Typesense 初始化成功!'); - // 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( - '
  • ', - `
  • ` - ) - }) - - container.innerHTML = `
      ${numberedItems.join('')}
    ` - - // Update stats - const displayCount = enablePagination ? currentResultItems.length : resultItems.length - const stats = languages.hits_stats.replace(/\$\{hits}/, displayCount) - statsItem.innerHTML = `
    ${stats}
    ` - - // 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 += ` -
  • - 1 -
  • ` - if (startPage > 1) { - pagesHTML += ` -
  • - ... -
  • ` - } - } - - // Add middle page numbers - for (let i = startPage; i <= endPage; i++) { - const isSelected = i === page - if (isSelected) { - pagesHTML += ` -
  • - ${i + 1} -
  • ` - } else { - pagesHTML += ` -
  • - ${i + 1} -
  • ` - } - } - - // Only add ellipsis and last page when there are many pages - if (nbPages > maxVisiblePages && endPage < nbPages - 1) { - if (endPage < nbPages - 2) { - pagesHTML += ` -
  • - ... -
  • ` - } - pagesHTML += ` -
  • - ${nbPages} -
  • ` - } - - if (nbPages > 1) { - elements.paginationList.innerHTML = ` -
  • - ${isFirstPage - ? '' - : `` - } -
  • - ${pagesHTML} -
  • - ${isLastPage - ? '' - : `` - } -
  • ` - } 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, '>')) - - 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) + searchInstance.on('render', function() { + if (isSearchOpen) { + const input = document.querySelector('.ais-SearchBox-input'); + if (input && document.activeElement !== input) { + input.focus(); } } - }) - } + }); - // Initial state - toggleResultsVisibility(false) + } catch (error) { + console.error('❌ 初始化失败:', error); + showErrorMessage(); + } } - window.addEventListener('search:loaded', () => { - const $loadDataItem = document.getElementById('loading-database') - $loadDataItem.nextElementSibling.style.visibility = 'visible' - $loadDataItem.remove() - }) + // ============================================================================ + // 初始化 + // ============================================================================ + function init() { + console.log('🔍 Typesense 搜索已准备就绪'); + console.log('💡 快捷键: Ctrl/Cmd + K'); + } - searchClickFn() - searchFnOnce() + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } - // pjax - window.addEventListener('pjax:complete', () => { - !btf.isHidden($searchMask) && closeSearch() - localSearch.highlightSearchWords(document.getElementById('article-container')) - searchClickFn() - }) -}) + // ============================================================================ + // 全局接口 + // ============================================================================ + window.TypesenseSearch = { + open: openSearch, + close: closeSearch, + isOpen: function() { return isSearchOpen; }, + getInstance: function() { return searchInstance; } + }; + +})(); \ No newline at end of file