diff --git a/assets/libs/fuse.min.js b/assets/libs/fuse.min.js
new file mode 100644
index 0000000..60a7cfd
--- /dev/null
+++ b/assets/libs/fuse.min.js
@@ -0,0 +1,9 @@
+/**
+ * Fuse.js v7.1.0 - Lightweight fuzzy-search (http://fusejs.io)
+ *
+ * Copyright (c) 2025 Kiro Risk (http://kiro.me)
+ * All Rights Reserved. Apache Software License 2.0
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ */
+function t(t){return Array.isArray?Array.isArray(t):"[object Array]"===h(t)}const e=1/0;function n(t){return null==t?"":function(t){if("string"==typeof t)return t;let n=t+"";return"0"==n&&1/t==-e?"-0":n}(t)}function s(t){return"string"==typeof t}function i(t){return"number"==typeof t}function r(t){return!0===t||!1===t||function(t){return u(t)&&null!==t}(t)&&"[object Boolean]"==h(t)}function u(t){return"object"==typeof t}function c(t){return null!=t}function o(t){return!t.trim().length}function h(t){return null==t?void 0===t?"[object Undefined]":"[object Null]":Object.prototype.toString.call(t)}const a=t=>`Missing ${t} property in key`,l=t=>`Property 'weight' in key '${t}' must be a positive integer`,d=Object.prototype.hasOwnProperty;class g{constructor(t){this._keys=[],this._keyMap={};let e=0;t.forEach((t=>{let n=f(t);this._keys.push(n),this._keyMap[n.id]=n,e+=n.weight})),this._keys.forEach((t=>{t.weight/=e}))}get(t){return this._keyMap[t]}keys(){return this._keys}toJSON(){return JSON.stringify(this._keys)}}function f(e){let n=null,i=null,r=null,u=1,c=null;if(s(e)||t(e))r=e,n=A(e),i=p(e);else{if(!d.call(e,"name"))throw new Error(a("name"));const t=e.name;if(r=t,d.call(e,"weight")&&(u=e.weight,u<=0))throw new Error(l(t));n=A(t),i=p(t),c=e.getFn}return{path:n,id:i,weight:u,src:r,getFn:c}}function A(e){return t(e)?e:e.split(".")}function p(e){return t(e)?e.join("."):e}var C={isCaseSensitive:!1,ignoreDiacritics:!1,includeScore:!1,keys:[],shouldSort:!0,sortFn:(t,e)=>t.score===e.score?t.idx 没有找到结果 ${this.getOptimalDescription(item.description, query)} 无法获取 Feed:${e.message} 无法获取 Feed:${e.message}t.normalize("NFD").replace(/[\u0300-\u036F\u0483-\u0489\u0591-\u05BD\u05BF\u05C1\u05C2\u05C4\u05C5\u05C7\u0610-\u061A\u064B-\u065F\u0670\u06D6-\u06DC\u06DF-\u06E4\u06E7\u06E8\u06EA-\u06ED\u0711\u0730-\u074A\u07A6-\u07B0\u07EB-\u07F3\u07FD\u0816-\u0819\u081B-\u0823\u0825-\u0827\u0829-\u082D\u0859-\u085B\u08D3-\u08E1\u08E3-\u0903\u093A-\u093C\u093E-\u094F\u0951-\u0957\u0962\u0963\u0981-\u0983\u09BC\u09BE-\u09C4\u09C7\u09C8\u09CB-\u09CD\u09D7\u09E2\u09E3\u09FE\u0A01-\u0A03\u0A3C\u0A3E-\u0A42\u0A47\u0A48\u0A4B-\u0A4D\u0A51\u0A70\u0A71\u0A75\u0A81-\u0A83\u0ABC\u0ABE-\u0AC5\u0AC7-\u0AC9\u0ACB-\u0ACD\u0AE2\u0AE3\u0AFA-\u0AFF\u0B01-\u0B03\u0B3C\u0B3E-\u0B44\u0B47\u0B48\u0B4B-\u0B4D\u0B56\u0B57\u0B62\u0B63\u0B82\u0BBE-\u0BC2\u0BC6-\u0BC8\u0BCA-\u0BCD\u0BD7\u0C00-\u0C04\u0C3E-\u0C44\u0C46-\u0C48\u0C4A-\u0C4D\u0C55\u0C56\u0C62\u0C63\u0C81-\u0C83\u0CBC\u0CBE-\u0CC4\u0CC6-\u0CC8\u0CCA-\u0CCD\u0CD5\u0CD6\u0CE2\u0CE3\u0D00-\u0D03\u0D3B\u0D3C\u0D3E-\u0D44\u0D46-\u0D48\u0D4A-\u0D4D\u0D57\u0D62\u0D63\u0D82\u0D83\u0DCA\u0DCF-\u0DD4\u0DD6\u0DD8-\u0DDF\u0DF2\u0DF3\u0E31\u0E34-\u0E3A\u0E47-\u0E4E\u0EB1\u0EB4-\u0EB9\u0EBB\u0EBC\u0EC8-\u0ECD\u0F18\u0F19\u0F35\u0F37\u0F39\u0F3E\u0F3F\u0F71-\u0F84\u0F86\u0F87\u0F8D-\u0F97\u0F99-\u0FBC\u0FC6\u102B-\u103E\u1056-\u1059\u105E-\u1060\u1062-\u1064\u1067-\u106D\u1071-\u1074\u1082-\u108D\u108F\u109A-\u109D\u135D-\u135F\u1712-\u1714\u1732-\u1734\u1752\u1753\u1772\u1773\u17B4-\u17D3\u17DD\u180B-\u180D\u1885\u1886\u18A9\u1920-\u192B\u1930-\u193B\u1A17-\u1A1B\u1A55-\u1A5E\u1A60-\u1A7C\u1A7F\u1AB0-\u1ABE\u1B00-\u1B04\u1B34-\u1B44\u1B6B-\u1B73\u1B80-\u1B82\u1BA1-\u1BAD\u1BE6-\u1BF3\u1C24-\u1C37\u1CD0-\u1CD2\u1CD4-\u1CE8\u1CED\u1CF2-\u1CF4\u1CF7-\u1CF9\u1DC0-\u1DF9\u1DFB-\u1DFF\u20D0-\u20F0\u2CEF-\u2CF1\u2D7F\u2DE0-\u2DFF\u302A-\u302F\u3099\u309A\uA66F-\uA672\uA674-\uA67D\uA69E\uA69F\uA6F0\uA6F1\uA802\uA806\uA80B\uA823-\uA827\uA880\uA881\uA8B4-\uA8C5\uA8E0-\uA8F1\uA8FF\uA926-\uA92D\uA947-\uA953\uA980-\uA983\uA9B3-\uA9C0\uA9E5\uAA29-\uAA36\uAA43\uAA4C\uAA4D\uAA7B-\uAA7D\uAAB0\uAAB2-\uAAB4\uAAB7\uAAB8\uAABE\uAABF\uAAC1\uAAEB-\uAAEF\uAAF5\uAAF6\uABE3-\uABEA\uABEC\uABED\uFB1E\uFE00-\uFE0F\uFE20-\uFE2F]/g,""):t=>t;class L{constructor(t,{location:e=C.location,threshold:n=C.threshold,distance:s=C.distance,includeMatches:i=C.includeMatches,findAllMatches:r=C.findAllMatches,minMatchCharLength:u=C.minMatchCharLength,isCaseSensitive:c=C.isCaseSensitive,ignoreDiacritics:o=C.ignoreDiacritics,ignoreLocation:h=C.ignoreLocation}={}){if(this.options={location:e,threshold:n,distance:s,includeMatches:i,findAllMatches:r,minMatchCharLength:u,isCaseSensitive:c,ignoreDiacritics:o,ignoreLocation:h},t=c?t:t.toLowerCase(),t=o?y(t):t,this.pattern=t,this.chunks=[],!this.pattern.length)return;const a=(t,e)=>{this.chunks.push({pattern:t,alphabet:x(t),startIndex:e})},l=this.pattern.length;if(l>D){let t=0;const e=l%D,n=l-e;for(;t{let n=1;t.matches.forEach((({key:t,norm:s,score:i})=>{const r=t?t.weight:null;n*=Math.pow(0===i&&r?Number.EPSILON:i,(r||1)*(e?1:s))})),t.score=n}))}(h,{ignoreFieldNorm:o}),u&&h.sort(c),i(e)&&e>-1&&(h=h.slice(0,e)),function(t,e,{includeMatches:n=C.includeMatches,includeScore:s=C.includeScore}={}){const i=[];return n&&i.push(V),s&&i.push(U),t.map((t=>{const{idx:n}=t,s={item:e[n],refIndex:n};return i.length&&i.forEach((e=>{e(t,s)})),s}))}(h,this._docs,{includeMatches:n,includeScore:r})}_searchStringList(t){const e=O(t,this.options),{records:n}=this._myIndex,s=[];return n.forEach((({v:t,i:n,n:i})=>{if(!c(t))return;const{isMatch:r,score:u,indices:o}=e.searchIn(t);r&&s.push({item:t,idx:n,matches:[{score:u,value:t,norm:i,indices:o}]})})),s}_searchLogical(t){const e=J(t,this.options),n=(t,e,s)=>{if(!t.children){const{keyId:n,searcher:i}=t,r=this._findMatches({key:this._keyStore.get(n),value:this._myIndex.getValueForItemAtKeyId(e,n),searcher:i});return r&&r.length?[{idx:s,item:e,matches:r}]:[]}const i=[];for(let r=0,u=t.children.length;r{if(c(t)){let u=n(e,t,s);u.length&&(i[s]||(i[s]={idx:s,item:t,matches:[]},r.push(i[s])),u.forEach((({matches:t})=>{i[s].matches.push(...t)})))}})),r}_searchObjectList(t){const e=O(t,this.options),{keys:n,records:s}=this._myIndex,i=[];return s.forEach((({$:t,i:s})=>{if(!c(t))return;let r=[];n.forEach(((n,s)=>{r.push(...this._findMatches({key:n,value:t[s],searcher:e}))})),r.length&&i.push({idx:s,item:t,matches:r})})),i}_findMatches({key:e,value:n,searcher:s}){if(!c(n))return[];let i=[];if(t(n))n.forEach((({v:t,i:n,n:r})=>{if(!c(t))return;const{isMatch:u,score:o,indices:h}=s.searchIn(t);u&&i.push({score:o,key:e,value:t,idx:n,norm:r,indices:h})}));else{const{v:t,n:r}=n,{isMatch:u,score:c,indices:o}=s.searchIn(t);u&&i.push({score:c,key:e,value:t,norm:r,indices:o})}return i}}G.version="7.1.0",G.createIndex=M,G.parseIndex=function(t,{getFn:e=C.getFn,fieldNormWeight:n=C.fieldNormWeight}={}){const{keys:s,records:i}=t,r=new F({getFn:e,fieldNormWeight:n});return r.setKeys(s),r.setIndexRecords(i),r},G.config=C,function(...t){R.push(...t)}(N);export{G as default};
\ No newline at end of file
diff --git a/assets/search.css b/assets/search.css
new file mode 100644
index 0000000..f6e3dcd
--- /dev/null
+++ b/assets/search.css
@@ -0,0 +1,198 @@
+/* Ensure [hidden] attribute hides the modal even if other rules set display */
+.search-modal[hidden] {
+ display: none !important;
+}
+
+/* search modal and result styles */
+.search-modal {
+ position: fixed;
+ inset: 0;
+ z-index: 9999;
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding: 48px 16px;
+ /* Ensure the modal covers full viewport and prevent scroll chaining to background */
+ height: 100vh;
+ overscroll-behavior: none; /* 阻止滚动穿透到页面主体 */
+ -webkit-overflow-scrolling: touch;
+}
+.search-overlay {
+ position: absolute;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.45);
+ /* also prevent scroll chaining on the overlay */
+ overscroll-behavior: none;
+}
+.search-panel {
+ position: relative;
+ width: 100%;
+ max-width: 1100px;
+}
+.search-panel .container {
+ background: var(--bg);
+ color: var(--fg);
+ border-radius: 6px;
+ padding: 18px;
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
+}
+.search-header {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+#search-input {
+ flex: 1;
+ padding: 10px 12px;
+ font-size: 16px;
+ background: var(--bg);
+ color: var(--fg);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
+}
+
+#search-input:focus {
+ outline: none;
+ border-color: #0066cc;
+ box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.2);
+}
+#search-close {
+ background: transparent;
+ border: 0;
+ font-size: 20px;
+ cursor: pointer;
+ color: var(--fg);
+}
+#search-results {
+ margin-top: 12px;
+ max-height: 60vh;
+ padding-right: 10px;
+ overflow: auto;
+}
+.search-item {
+ padding: 10px 8px;
+ border-bottom: 1px solid var(--border);
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ align-items: flex-start;
+ transition: background-color 0.2s ease;
+}
+
+.search-item:hover,
+.search-item:focus-within {
+ background-color: rgba(0, 0, 0, 0.03);
+}
+
+.search-item a {
+ color: inherit;
+ text-decoration: none;
+ flex: 1;
+ border-radius: 2px;
+ padding: 2px;
+}
+
+.search-item h4 {
+ margin: 0;
+ font-size: 16px;
+}
+.search-item p {
+ margin: 6px 0 0 0;
+ font-size: 13px;
+ color: #3c3c3c;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ word-wrap: break-word;
+}
+.search-item .label {
+ font-size: 12px;
+ padding: 2px 6px;
+ border-radius: 4px;
+ background: var(--bg);
+ color: var(--border);
+}
+.search-highlight {
+ background: #fff5c2;
+ transition: background 1.2s ease;
+}
+
+@media (max-width: 767px) and (orientation: portrait) {
+ .search-modal {
+ padding: 0;
+ align-items: stretch;
+ }
+ .search-panel {
+ display: flex;
+ flex-direction: column;
+ }
+ /* 使 container 占满可视高度,内部结果区做独立滚动,避免背景滚动 */
+ .search-panel .container {
+ border-radius: 0;
+ display: flex;
+ flex-direction: column;
+ padding: 16px;
+ max-height: 100vh;
+ }
+
+ #search-results {
+ overflow: auto;
+ flex: 0.98;
+ max-height: none;
+ }
+}
+
+@media (min-width: 768px) {
+ .search-panel .container {
+ max-width: 800px;
+ margin: 0 auto;
+ }
+}
+
+/* search button in header */
+#search-btn {
+ background: transparent;
+ border: 0;
+ cursor: pointer;
+ color: currentColor;
+ margin-left: 20px;
+ padding: 8px;
+}
+
+/* align SVG icon with text baseline */
+#search-btn svg {
+ vertical-align: middle;
+}
+
+/* dark mode support */
+@media (prefers-color-scheme: dark) {
+ .search-item p {
+ color: #e6e6e69f;
+ }
+ .search-item .label {
+ color: #6c757d;
+ }
+ /* dark-mode highlight for search results and matched elements */
+ .search-highlight {
+ background: #665c00;
+ transition: background 1.2s ease;
+ }
+
+ .search-item:hover,
+ .search-item:focus-within {
+ background-color: rgba(255, 255, 255, 0.05);
+ }
+
+ #search-input:focus {
+ border-color: #4d9eff;
+ box-shadow: 0 0 0 2px rgba(77, 158, 255, 0.2);
+ }
+
+ .search-item a:focus {
+ outline-color: #4d9eff;
+ }
+}
diff --git a/assets/search.js b/assets/search.js
new file mode 100644
index 0000000..c8a0202
--- /dev/null
+++ b/assets/search.js
@@ -0,0 +1,264 @@
+import Fuse from "/libs/fuse.min.js";
+
+const DEFAULT_FEEDS = [
+ { url: "/learn/index.xml", module: "学习" },
+ { url: "/monthly/index.xml", module: "月刊" },
+ { url: "/post/index.xml", module: "文章" },
+];
+
+class Search {
+ constructor({ feeds = DEFAULT_FEEDS } = {}) {
+ this.feeds = feeds;
+ this.fuse = null;
+
+ this.dom = {
+ modal: document.getElementById("search-modal"),
+ input: document.getElementById("search-input"),
+ results: document.getElementById("search-results"),
+ btn: document.getElementById("search-btn"),
+ closeBtn: document.getElementById("search-close"),
+ overlay: document.getElementById("search-overlay"),
+ };
+
+ this._debouncedSearch = this.debounce((q) => this.performSearch(q), 300);
+ }
+
+ // ---- utilities ----
+ static stripHtml(s) {
+ return s ? s.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim() : "";
+ }
+
+ static escapeRegExp(s) {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ }
+
+ debounce(fn, wait = 300) {
+ let timeout;
+ return (...args) => {
+ clearTimeout(timeout);
+ timeout = setTimeout(() => fn(...args), wait);
+ };
+ }
+
+ // ---- feed / fuse ----
+ async fetchFeed(feed) {
+ try {
+ const res = await fetch(feed.url, { cache: "no-store" });
+ if (!res.ok) {
+ throw new Error(`无法获取 Feed: ${feed.url} (HTTP ${res.status})`);
+ }
+ const text = await res.text();
+ const doc = new DOMParser().parseFromString(text, "application/xml");
+ const items = Array.from(doc.querySelectorAll("item"));
+ return items.map((item) => ({
+ title: Search.stripHtml(item.querySelector("title")?.textContent || ""),
+ description: Search.stripHtml(item.querySelector("description")?.textContent || ""),
+ url: Search.stripHtml(item.querySelector("link")?.textContent || ""),
+ module: feed.module,
+ }));
+ } catch (e) {
+ // 向上抛出错误,供上层提示使用
+ throw new Error(e?.message ? e.message : `无法获取 Feed: ${feed.url}`);
+ }
+ }
+
+ async initSearch() {
+ if (this.fuse) return this.fuse;
+ const all = await Promise.all(this.feeds.map((f) => this.fetchFeed(f)));
+ const index = all.flat();
+ this.fuse = new Fuse(index, { keys: ["title", "description"], threshold: 0.1, ignoreLocation: true, shouldSort: true });
+ return this.fuse;
+ }
+
+ // ---- rendering / highlighting ----
+ getOptimalDescription(description, q) {
+ const regex = new RegExp(Search.escapeRegExp(q), 'i');
+ const idx = (description || '').search(regex);
+
+ return this.highlightText(description.slice(Math.max(description.lastIndexOf("\n", idx) + 1, Math.max(0, idx - 100)), Math.min(idx + 100, description.indexOf("\n", idx))), q);
+ }
+
+ highlightText(text, query) {
+ if (!query || !text) return text;
+ try {
+ const regex = new RegExp(Search.escapeRegExp(query), "gi");
+ return text.replace(regex, (m) => `${m}`);
+ } catch (e) {
+ return text;
+ }
+ }
+
+ renderResults(results = [], query = "") {
+ const container = this.dom.results;
+ if (!container) return;
+ if (!results?.length) {
+ container.innerHTML = "${this.highlightText(item.title, query)}
+ Table of Contents
-
+
+ \ No newline at end of file diff --git a/layouts/monthly.shtml b/layouts/monthly.shtml index 06a3d9c..58320c3 100644 --- a/layouts/monthly.shtml +++ b/layouts/monthly.shtml @@ -2,5 +2,7 @@
\ No newline at end of file diff --git a/layouts/page.shtml b/layouts/page.shtml index 06a3d9c..58320c3 100644 --- a/layouts/page.shtml +++ b/layouts/page.shtml @@ -2,5 +2,7 @@
\ No newline at end of file diff --git a/layouts/post.shtml b/layouts/post.shtml index a0d9be4..e822865 100644 --- a/layouts/post.shtml +++ b/layouts/post.shtml @@ -14,5 +14,7 @@
+