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 =
+ '
' +
+ '
' +
+ '
搜索服务加载失败
' +
+ '
' +
+ '
依赖库未能正确加载,请检查以下配置:
' +
+ '
' +
+ '确认已在 _config.butterfly.yml 中正确引入依赖 ' +
+ '检查 JS 文件加载顺序(先 instantsearch.js,再 adapter) ' +
+ '尝试更换 CDN 或使用本地文件 ' +
+ '打开浏览器控制台查看详细错误信息 ' +
+ ' ' +
+ '
' +
+ '
' +
+ '重新加载页面 ' +
+ '
' +
+ '
';
}
- // 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 += `
- `
- if (startPage > 1) {
- pagesHTML += `
- `
- }
- }
-
- // Add middle page numbers
- for (let i = startPage; i <= endPage; i++) {
- const isSelected = i === page
- if (isSelected) {
- pagesHTML += `
- `
- } else {
- pagesHTML += `
- `
- }
- }
-
- // Only add ellipsis and last page when there are many pages
- if (nbPages > maxVisiblePages && endPage < nbPages - 1) {
- if (endPage < nbPages - 2) {
- pagesHTML += `
- `
- }
- pagesHTML += `
- `
- }
-
- if (nbPages > 1) {
- elements.paginationList.innerHTML = `
-
- ${pagesHTML}
- `
- } 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