@remloyal/docsify-plugins 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/dist/flexsearch/docsify-flexsearch.css +248 -0
- package/dist/flexsearch/docsify-flexsearch.js +4614 -0
- package/dist/flexsearch/docsify-flexsearch.min.css +2 -0
- package/dist/flexsearch/docsify-flexsearch.min.css.map +1 -0
- package/dist/flexsearch/docsify-flexsearch.min.js +8 -0
- package/dist/flexsearch/docsify-flexsearch.min.js.map +1 -0
- package/dist/sidebar-collapse/docsify-sidebar-collapse.css +25 -0
- package/dist/sidebar-collapse/docsify-sidebar-collapse.js +193 -0
- package/dist/sidebar-collapse/docsify-sidebar-collapse.min.css +2 -0
- package/dist/sidebar-collapse/docsify-sidebar-collapse.min.css.map +1 -0
- package/dist/sidebar-collapse/docsify-sidebar-collapse.min.js +8 -0
- package/dist/sidebar-collapse/docsify-sidebar-collapse.min.js.map +1 -0
- package/package.json +60 -0
- package/src/flexsearch/index.js +822 -0
- package/src/flexsearch/markdown-to-txt.js +206 -0
- package/src/flexsearch/style.css +247 -0
- package/src/sidebar-collapse/index.js +11 -0
- package/src/sidebar-collapse/sidebar-collapse-plugin.js +251 -0
- package/src/sidebar-collapse/sidebar.css +27 -0
- package/src/sidebar-collapse/style.css +25 -0
- package/src/utils/utils.js +96 -0
|
@@ -0,0 +1,822 @@
|
|
|
1
|
+
import FlexSearch from "flexsearch";
|
|
2
|
+
import {
|
|
3
|
+
getAndRemoveConfig,
|
|
4
|
+
getAndRemoveDocsifyIgnoreConfig,
|
|
5
|
+
removeAtag,
|
|
6
|
+
} from "../utils/utils.js";
|
|
7
|
+
import { markdownToTxt } from "./markdown-to-txt.js";
|
|
8
|
+
import "./style.css";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* FlexSearch based search plugin for Docsify.
|
|
12
|
+
*
|
|
13
|
+
* - Fetches docs content (same source as built-in search plugin)
|
|
14
|
+
* - Builds a persistent FlexSearch Document index
|
|
15
|
+
* - Persists index in browser IndexedDB using FlexSearch built-in storage
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** @type {ReturnType<typeof createState>} */
|
|
19
|
+
let STATE = createState();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @type {{
|
|
23
|
+
* placeholder: string | Record<string, string>;
|
|
24
|
+
* noData: string | Record<string, string>;
|
|
25
|
+
* paths: string[] | 'auto';
|
|
26
|
+
* depth: number;
|
|
27
|
+
* maxAge: number;
|
|
28
|
+
* namespace?: string;
|
|
29
|
+
* pathNamespaces?: RegExp | string[];
|
|
30
|
+
* keyBindings: string[];
|
|
31
|
+
* insertAfter?: string;
|
|
32
|
+
* insertBefore?: string;
|
|
33
|
+
* limit?: number;
|
|
34
|
+
* mode?: 'sidebar' | 'modal';
|
|
35
|
+
* }}
|
|
36
|
+
*/
|
|
37
|
+
const CONFIG = {
|
|
38
|
+
placeholder: "Type to search",
|
|
39
|
+
noData: "No Results!",
|
|
40
|
+
paths: "auto",
|
|
41
|
+
depth: 2,
|
|
42
|
+
maxAge: 86400000, // 1 day
|
|
43
|
+
namespace: undefined,
|
|
44
|
+
pathNamespaces: undefined,
|
|
45
|
+
keyBindings: ["/", "meta+k", "ctrl+k"],
|
|
46
|
+
insertAfter: undefined, // CSS selector
|
|
47
|
+
insertBefore: undefined, // CSS selector
|
|
48
|
+
limit: 30,
|
|
49
|
+
mode: "sidebar",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function createState() {
|
|
53
|
+
return {
|
|
54
|
+
/** @type {string | null} */
|
|
55
|
+
key: null,
|
|
56
|
+
/** @type {import('flexsearch').Document<any, false, any> | null} */
|
|
57
|
+
index: null,
|
|
58
|
+
/** @type {Promise<void> | null} */
|
|
59
|
+
mounted: null,
|
|
60
|
+
/** @type {Promise<void> | null} */
|
|
61
|
+
building: null,
|
|
62
|
+
/** @type {boolean} */
|
|
63
|
+
uiReady: false,
|
|
64
|
+
/** @type {string} */
|
|
65
|
+
noDataText: "",
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function escapeHtml(string) {
|
|
70
|
+
const entityMap = {
|
|
71
|
+
"&": "&",
|
|
72
|
+
"<": "<",
|
|
73
|
+
">": ">",
|
|
74
|
+
'"': """,
|
|
75
|
+
"'": "'",
|
|
76
|
+
};
|
|
77
|
+
return String(string).replace(/[&<>"']/g, (s) => entityMap[s]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function stripDiacritics(text) {
|
|
81
|
+
if (text && text.normalize) {
|
|
82
|
+
return text.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
|
83
|
+
}
|
|
84
|
+
return text || "";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function escapeRegExp(string) {
|
|
88
|
+
return String(string).replace(/[|\\{}()[\]^$+*?.]/g, "\\$&");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function keywordList(query) {
|
|
92
|
+
const q = (query || "").trim();
|
|
93
|
+
if (!q) {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
let keywords = q.split(/[\s\-,\\/]+/).filter(Boolean);
|
|
97
|
+
if (keywords.length !== 1) {
|
|
98
|
+
keywords = [q, ...keywords];
|
|
99
|
+
}
|
|
100
|
+
return keywords;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function highlightSnippet(text, query, maxLen = 120) {
|
|
104
|
+
const content = stripDiacritics(text || "");
|
|
105
|
+
const keys = keywordList(query).map((k) => stripDiacritics(k));
|
|
106
|
+
const key = keys.find(
|
|
107
|
+
(k) => k && content.toLowerCase().includes(k.toLowerCase()),
|
|
108
|
+
);
|
|
109
|
+
if (!key) {
|
|
110
|
+
const clipped = content.slice(0, maxLen);
|
|
111
|
+
return escapeHtml(clipped);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const idx = content.toLowerCase().indexOf(key.toLowerCase());
|
|
115
|
+
const start = Math.max(0, idx - 30);
|
|
116
|
+
const end = Math.min(content.length, idx + key.length + 80);
|
|
117
|
+
const slice = content.slice(start, end);
|
|
118
|
+
|
|
119
|
+
// Escape the entire slice first
|
|
120
|
+
const escapedSlice = escapeHtml(slice);
|
|
121
|
+
// Escape the key for HTML, then escape for regex
|
|
122
|
+
const escapedKey = escapeHtml(key);
|
|
123
|
+
const reg = new RegExp(escapeRegExp(escapedKey), "ig");
|
|
124
|
+
const marked = escapedSlice.replace(
|
|
125
|
+
reg,
|
|
126
|
+
(word) => /* html */ `<mark>${word}</mark>`,
|
|
127
|
+
);
|
|
128
|
+
return (start > 0 ? "…" : "") + marked + (end < content.length ? "…" : "");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function getAllPaths(router) {
|
|
132
|
+
const paths = [];
|
|
133
|
+
|
|
134
|
+
Docsify.dom
|
|
135
|
+
.findAll(".sidebar-nav a:not(.section-link):not([data-nosearch])")
|
|
136
|
+
.forEach((node) => {
|
|
137
|
+
const href = /** @type {HTMLAnchorElement} */ (node).href;
|
|
138
|
+
const originHref = /** @type {HTMLAnchorElement} */ (node).getAttribute(
|
|
139
|
+
"href",
|
|
140
|
+
);
|
|
141
|
+
const parsed = router.parse(href);
|
|
142
|
+
const path = parsed.path;
|
|
143
|
+
|
|
144
|
+
if (
|
|
145
|
+
path &&
|
|
146
|
+
paths.indexOf(path) === -1 &&
|
|
147
|
+
!Docsify.util.isAbsolutePath(originHref)
|
|
148
|
+
) {
|
|
149
|
+
paths.push(path);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
return paths;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function getTableData(token) {
|
|
157
|
+
if (!token.text && token.type === "table") {
|
|
158
|
+
token.rows.unshift(token.header);
|
|
159
|
+
token.text = token.rows
|
|
160
|
+
.map((columns) => columns.map((r) => r.text).join(" | "))
|
|
161
|
+
.join(" |\n ");
|
|
162
|
+
}
|
|
163
|
+
return token.text;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function getListData(token) {
|
|
167
|
+
if (!token.text && token.type === "list") {
|
|
168
|
+
token.text = token.raw;
|
|
169
|
+
}
|
|
170
|
+
return token.text;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function genIndex(path, content = "", router, depth) {
|
|
174
|
+
const tokens = window.marked.lexer(content);
|
|
175
|
+
const slugify = window.Docsify.slugify;
|
|
176
|
+
/** @type {Record<string, {id: string, slug: string, title: string, body: string, path: string}>} */
|
|
177
|
+
const index = {};
|
|
178
|
+
let slug;
|
|
179
|
+
let title = "";
|
|
180
|
+
|
|
181
|
+
tokens.forEach((token, tokenIndex) => {
|
|
182
|
+
if (token.type === "heading" && token.depth <= depth) {
|
|
183
|
+
const { str, config } = getAndRemoveConfig(token.text);
|
|
184
|
+
slug = router.toURL(path, { id: slugify(config.id || token.text) });
|
|
185
|
+
|
|
186
|
+
if (str) {
|
|
187
|
+
title = getAndRemoveDocsifyIgnoreConfig(str).content;
|
|
188
|
+
title = removeAtag(title.trim());
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
index[slug] = {
|
|
192
|
+
id: slug,
|
|
193
|
+
slug,
|
|
194
|
+
title,
|
|
195
|
+
body: "",
|
|
196
|
+
path,
|
|
197
|
+
};
|
|
198
|
+
} else {
|
|
199
|
+
if (tokenIndex === 0) {
|
|
200
|
+
slug = router.toURL(path);
|
|
201
|
+
index[slug] = {
|
|
202
|
+
id: slug,
|
|
203
|
+
slug,
|
|
204
|
+
title: path !== "/" ? path.slice(1) : "Home Page",
|
|
205
|
+
body: markdownToTxt(/** @type {any} */ (token).text || ""),
|
|
206
|
+
path,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!slug) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (!index[slug]) {
|
|
215
|
+
index[slug] = { id: slug, slug, title: "", body: "", path };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// @ts-expect-error
|
|
219
|
+
token.text = getTableData(token);
|
|
220
|
+
// @ts-expect-error
|
|
221
|
+
token.text = getListData(token);
|
|
222
|
+
|
|
223
|
+
const txt = markdownToTxt(/** @type {any} */ (token).text || "");
|
|
224
|
+
if (!txt) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (index[slug].body) {
|
|
229
|
+
index[slug].body += "\n" + txt;
|
|
230
|
+
} else {
|
|
231
|
+
index[slug].body = txt;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
slugify.clear();
|
|
237
|
+
return index;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function resolveNamespaceSuffix(paths, config) {
|
|
241
|
+
let namespaceSuffix = "";
|
|
242
|
+
|
|
243
|
+
// only in auto mode
|
|
244
|
+
if (paths.length && config.paths === "auto" && config.pathNamespaces) {
|
|
245
|
+
const first = paths[0];
|
|
246
|
+
if (Array.isArray(config.pathNamespaces)) {
|
|
247
|
+
namespaceSuffix =
|
|
248
|
+
config.pathNamespaces.filter(
|
|
249
|
+
(prefix) => first.slice(0, prefix.length) === prefix,
|
|
250
|
+
)[0] || namespaceSuffix;
|
|
251
|
+
} else if (config.pathNamespaces instanceof RegExp) {
|
|
252
|
+
const matches = first.match(config.pathNamespaces);
|
|
253
|
+
if (matches) {
|
|
254
|
+
namespaceSuffix = matches[0];
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const isExistHome = paths.indexOf(namespaceSuffix + "/") === -1;
|
|
259
|
+
const isExistReadme = paths.indexOf(namespaceSuffix + "/README") === -1;
|
|
260
|
+
if (isExistHome && isExistReadme) {
|
|
261
|
+
paths.unshift(namespaceSuffix + "/");
|
|
262
|
+
}
|
|
263
|
+
} else if (paths.indexOf("/") === -1 && paths.indexOf("/README") === -1) {
|
|
264
|
+
paths.unshift("/");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return namespaceSuffix;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function storageKey(namespace, suffix) {
|
|
271
|
+
const ns = namespace ? String(namespace) : "";
|
|
272
|
+
const sfx = suffix ? String(suffix) : "";
|
|
273
|
+
return `docsify.flexsearch/${ns}/${sfx}`.replace(/\/+$/, "");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function safeDbName(key) {
|
|
277
|
+
return key.replace(/[^a-zA-Z0-9._-]+/g, "_");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function configHash(config, paths, suffix) {
|
|
281
|
+
// Small & stable hash input: paths + depth + suffix
|
|
282
|
+
// (no crypto, just enough to detect config changes)
|
|
283
|
+
return JSON.stringify({
|
|
284
|
+
paths,
|
|
285
|
+
depth: config.depth,
|
|
286
|
+
suffix,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function tpl(vm, defaultValue = "") {
|
|
291
|
+
const html = /* html */ `
|
|
292
|
+
<div class="input-wrap">
|
|
293
|
+
<input type="search" value="${defaultValue}" required aria-keyshortcuts="/ control+k meta+k" />
|
|
294
|
+
<button class="clear-button" title="Clear search">
|
|
295
|
+
<span class="visually-hidden">Clear search</span>
|
|
296
|
+
</button>
|
|
297
|
+
<div class="kbd-group">
|
|
298
|
+
<kbd title="Press / to search">/</kbd>
|
|
299
|
+
<kbd title="Press Control+K to search">⌃K</kbd>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
<p class="results-status" aria-live="polite"></p>
|
|
303
|
+
<div class="results-panel"></div>
|
|
304
|
+
`;
|
|
305
|
+
const root = Docsify.dom.create("section", html);
|
|
306
|
+
root.classList.add("flexsearch-search");
|
|
307
|
+
root.setAttribute("role", "search");
|
|
308
|
+
|
|
309
|
+
return root;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function mountSidebar(vm, defaultValue) {
|
|
313
|
+
const sidebarElm = Docsify.dom.find(".sidebar");
|
|
314
|
+
if (!sidebarElm) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const { insertAfter, insertBefore } = vm.config?.search || {};
|
|
319
|
+
const root = tpl(vm, defaultValue);
|
|
320
|
+
const insertElm = /** @type {HTMLElement} */ (
|
|
321
|
+
sidebarElm.querySelector(
|
|
322
|
+
`:scope ${insertAfter || insertBefore || "> :first-child"}`,
|
|
323
|
+
)
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
sidebarElm.insertBefore(
|
|
327
|
+
root,
|
|
328
|
+
insertAfter ? insertElm.nextSibling : insertElm,
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
bindEvents(vm);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function mountModalTrigger(vm, defaultValue) {
|
|
335
|
+
const sidebarElm = Docsify.dom.find(".sidebar");
|
|
336
|
+
if (!sidebarElm) {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (Docsify.dom.getNode(".flexsearch-trigger")) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const { insertAfter, insertBefore } = vm.config?.search || {};
|
|
345
|
+
const html = /* html */ `
|
|
346
|
+
<div class="input-wrap">
|
|
347
|
+
<input type="search" value="${defaultValue}" readonly aria-haspopup="dialog" aria-keyshortcuts="/ control+k meta+k" />
|
|
348
|
+
<div class="kbd-group">
|
|
349
|
+
<kbd title="Press / to search">/</kbd>
|
|
350
|
+
<kbd title="Press Control+K to search">⌃K</kbd>
|
|
351
|
+
</div>
|
|
352
|
+
</div>
|
|
353
|
+
`;
|
|
354
|
+
const root = Docsify.dom.create("section", html);
|
|
355
|
+
root.classList.add("flexsearch-trigger");
|
|
356
|
+
|
|
357
|
+
const insertElm = /** @type {HTMLElement} */ (
|
|
358
|
+
sidebarElm.querySelector(
|
|
359
|
+
`:scope ${insertAfter || insertBefore || "> :first-child"}`,
|
|
360
|
+
)
|
|
361
|
+
);
|
|
362
|
+
|
|
363
|
+
sidebarElm.insertBefore(
|
|
364
|
+
root,
|
|
365
|
+
insertAfter ? insertElm.nextSibling : insertElm,
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
const input = /** @type {HTMLInputElement | null} */ (
|
|
369
|
+
root.querySelector('input[type="search"]')
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
// Prevent to Fold sidebar (same behavior as built-in search)
|
|
373
|
+
Docsify.dom.on(root, "click", (e) => e.stopPropagation());
|
|
374
|
+
|
|
375
|
+
const open = () => {
|
|
376
|
+
ensureUI(vm);
|
|
377
|
+
openModal();
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
Docsify.dom.on(input, "focus", open);
|
|
381
|
+
Docsify.dom.on(input, "click", open);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function mountModal(vm, defaultValue) {
|
|
385
|
+
if (Docsify.dom.getNode(".flexsearch-modal")) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const modal = document.createElement("div");
|
|
390
|
+
modal.className = "flexsearch-modal";
|
|
391
|
+
modal.innerHTML = /* html */ `
|
|
392
|
+
<div class="flexsearch-modal__backdrop" aria-hidden="true"></div>
|
|
393
|
+
<div class="flexsearch-modal__dialog" role="dialog" aria-modal="true"></div>
|
|
394
|
+
`;
|
|
395
|
+
|
|
396
|
+
const dialog = /** @type {HTMLElement} */ (
|
|
397
|
+
modal.querySelector(".flexsearch-modal__dialog")
|
|
398
|
+
);
|
|
399
|
+
dialog.appendChild(tpl(vm, defaultValue));
|
|
400
|
+
document.body.appendChild(modal);
|
|
401
|
+
|
|
402
|
+
Docsify.dom.on(
|
|
403
|
+
/** @type {HTMLElement} */ (
|
|
404
|
+
modal.querySelector(".flexsearch-modal__backdrop")
|
|
405
|
+
),
|
|
406
|
+
"click",
|
|
407
|
+
() => closeModal(),
|
|
408
|
+
);
|
|
409
|
+
|
|
410
|
+
Docsify.dom.on(modal, "keydown", (e) => {
|
|
411
|
+
if (e.key === "Escape") {
|
|
412
|
+
e.preventDefault();
|
|
413
|
+
closeModal();
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
bindEvents(vm);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function openModal() {
|
|
421
|
+
const modal = /** @type {HTMLElement | null} */ (
|
|
422
|
+
Docsify.dom.getNode(".flexsearch-modal")
|
|
423
|
+
);
|
|
424
|
+
if (!modal) {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
modal.classList.add("is-open");
|
|
428
|
+
|
|
429
|
+
const input = /** @type {HTMLInputElement | null} */ (
|
|
430
|
+
modal.querySelector('.flexsearch-search input[type="search"]')
|
|
431
|
+
);
|
|
432
|
+
setTimeout(() => input?.focus(), 0);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function closeModal() {
|
|
436
|
+
const modal = /** @type {HTMLElement | null} */ (
|
|
437
|
+
Docsify.dom.getNode(".flexsearch-modal")
|
|
438
|
+
);
|
|
439
|
+
if (!modal) {
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
modal.classList.remove("is-open");
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function updatePlaceholder(text, path) {
|
|
446
|
+
const placeholder =
|
|
447
|
+
typeof text === "string"
|
|
448
|
+
? text
|
|
449
|
+
: text[Object.keys(text).filter((key) => path.indexOf(key) > -1)[0]];
|
|
450
|
+
|
|
451
|
+
const searchInput = /** @type {HTMLInputElement | null} */ (
|
|
452
|
+
Docsify.dom.getNode('.flexsearch-search input[type="search"]')
|
|
453
|
+
);
|
|
454
|
+
if (searchInput) {
|
|
455
|
+
searchInput.placeholder = placeholder;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const triggerInput = /** @type {HTMLInputElement | null} */ (
|
|
459
|
+
Docsify.dom.getNode('.flexsearch-trigger input[type="search"]')
|
|
460
|
+
);
|
|
461
|
+
if (triggerInput) {
|
|
462
|
+
triggerInput.placeholder = placeholder;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function updateNoData(text, path) {
|
|
467
|
+
if (typeof text === "string") {
|
|
468
|
+
STATE.noDataText = text;
|
|
469
|
+
} else {
|
|
470
|
+
const match = Object.keys(text).filter((key) => path.indexOf(key) > -1)[0];
|
|
471
|
+
STATE.noDataText = text[match];
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function ensureUI(vm) {
|
|
476
|
+
if (STATE.uiReady) {
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (Docsify.dom.getNode(".flexsearch-search")) {
|
|
481
|
+
STATE.uiReady = true;
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const keywords = vm.router.parse().query.s || "";
|
|
486
|
+
|
|
487
|
+
if (CONFIG.mode === "modal") {
|
|
488
|
+
mountModal(vm, escapeHtml(keywords));
|
|
489
|
+
mountModalTrigger(vm, "");
|
|
490
|
+
} else {
|
|
491
|
+
mountSidebar(vm, escapeHtml(keywords));
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
STATE.uiReady = true;
|
|
495
|
+
|
|
496
|
+
if (keywords) {
|
|
497
|
+
setTimeout(() => {
|
|
498
|
+
if (CONFIG.mode === "modal") {
|
|
499
|
+
openModal();
|
|
500
|
+
}
|
|
501
|
+
void doSearch(vm, keywords);
|
|
502
|
+
}, 500);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function bindEvents(vm) {
|
|
507
|
+
const $root = Docsify.dom.find(".flexsearch-search");
|
|
508
|
+
const $input = /** @type {HTMLInputElement} */ (
|
|
509
|
+
Docsify.dom.find($root, "input")
|
|
510
|
+
);
|
|
511
|
+
const $clear = Docsify.dom.find($root, ".clear-button");
|
|
512
|
+
|
|
513
|
+
let timeId;
|
|
514
|
+
|
|
515
|
+
Docsify.dom.on(
|
|
516
|
+
$root,
|
|
517
|
+
"click",
|
|
518
|
+
(e) =>
|
|
519
|
+
["A", "H2", "P", "EM"].indexOf(e.target.tagName) === -1 &&
|
|
520
|
+
e.stopPropagation(),
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
Docsify.dom.on($input, "input", (e) => {
|
|
524
|
+
clearTimeout(timeId);
|
|
525
|
+
timeId = setTimeout(() => {
|
|
526
|
+
const value = /** @type {HTMLInputElement} */ (e.target).value.trim();
|
|
527
|
+
void doSearch(vm, value);
|
|
528
|
+
}, 120);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
Docsify.dom.on($clear, "click", () => {
|
|
532
|
+
$input.value = "";
|
|
533
|
+
void doSearch(vm, "");
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
Docsify.dom.on($root, "click", (e) => {
|
|
537
|
+
const target = /** @type {HTMLElement} */ (e.target);
|
|
538
|
+
const link = target?.closest?.("a");
|
|
539
|
+
if (link && CONFIG.mode === "modal") {
|
|
540
|
+
closeModal();
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function setStatus(text) {
|
|
546
|
+
const $status = Docsify.dom.find(".flexsearch-search .results-status");
|
|
547
|
+
if ($status) {
|
|
548
|
+
$status.textContent = text || "";
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function setResultsHTML(html) {
|
|
553
|
+
const $root = Docsify.dom.find(".flexsearch-search");
|
|
554
|
+
const $panel = Docsify.dom.find($root, ".results-panel");
|
|
555
|
+
$panel.innerHTML = html || "";
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
async function computePaths(config, vm) {
|
|
559
|
+
const isAuto = config.paths === "auto";
|
|
560
|
+
const paths = isAuto ? getAllPaths(vm.router) : [...config.paths];
|
|
561
|
+
const suffix = resolveNamespaceSuffix(paths, config);
|
|
562
|
+
return { paths, suffix, isAuto };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function ensureIndex(config, vm) {
|
|
566
|
+
const { paths, suffix } = await computePaths(config, vm);
|
|
567
|
+
const key = storageKey(config.namespace, suffix);
|
|
568
|
+
|
|
569
|
+
const expiresKey = `${key}/expires`;
|
|
570
|
+
const hashKey = `${key}/hash`;
|
|
571
|
+
const now = Date.now();
|
|
572
|
+
const expiresAt = Number(localStorage.getItem(expiresKey) || 0);
|
|
573
|
+
const hash = configHash(config, paths, suffix);
|
|
574
|
+
const prevHash = localStorage.getItem(hashKey) || "";
|
|
575
|
+
const expired = expiresAt < now;
|
|
576
|
+
const changed = prevHash !== hash;
|
|
577
|
+
|
|
578
|
+
if (STATE.key !== key) {
|
|
579
|
+
// New namespace/suffix: reset in-memory state, but keep persisted index in IDB.
|
|
580
|
+
STATE = createState();
|
|
581
|
+
STATE.key = key;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (!STATE.index) {
|
|
585
|
+
STATE.index = new FlexSearch.Document({
|
|
586
|
+
tokenize: "forward",
|
|
587
|
+
cache: 100,
|
|
588
|
+
document: {
|
|
589
|
+
id: "id",
|
|
590
|
+
index: ["title", "body"],
|
|
591
|
+
store: ["id", "slug", "title", "body", "path"],
|
|
592
|
+
},
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (!STATE.mounted) {
|
|
597
|
+
STATE.mounted = (async () => {
|
|
598
|
+
const idx =
|
|
599
|
+
/** @type {import('flexsearch').Document<any, false, any>} */ (
|
|
600
|
+
STATE.index
|
|
601
|
+
);
|
|
602
|
+
// Mount persistent storage
|
|
603
|
+
const db = new FlexSearch.IndexedDB({
|
|
604
|
+
name: safeDbName(key),
|
|
605
|
+
type: "varchar",
|
|
606
|
+
});
|
|
607
|
+
await idx.mount(db);
|
|
608
|
+
})();
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
await STATE.mounted;
|
|
612
|
+
|
|
613
|
+
if (!STATE.building && (expired || changed)) {
|
|
614
|
+
STATE.building = (async () => {
|
|
615
|
+
setStatus("Building search index…");
|
|
616
|
+
|
|
617
|
+
const idx =
|
|
618
|
+
/** @type {import('flexsearch').Document<any, false, any>} */ (
|
|
619
|
+
STATE.index
|
|
620
|
+
);
|
|
621
|
+
|
|
622
|
+
// Drop persisted data when expired or config changed
|
|
623
|
+
try {
|
|
624
|
+
await idx.destroy();
|
|
625
|
+
} catch (e) {
|
|
626
|
+
// ignore
|
|
627
|
+
console.log(e);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Recreate & remount (destroy() clears internal refs)
|
|
631
|
+
STATE.index = new FlexSearch.Document({
|
|
632
|
+
tokenize: "forward",
|
|
633
|
+
cache: 100,
|
|
634
|
+
document: {
|
|
635
|
+
id: "id",
|
|
636
|
+
index: ["title", "body"],
|
|
637
|
+
store: ["id", "slug", "title", "body", "path"],
|
|
638
|
+
},
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
const db = new FlexSearch.IndexedDB({
|
|
642
|
+
name: safeDbName(key),
|
|
643
|
+
type: "varchar",
|
|
644
|
+
});
|
|
645
|
+
const newIdx =
|
|
646
|
+
/** @type {import('flexsearch').Document<any, false, any>} */ (
|
|
647
|
+
STATE.index
|
|
648
|
+
);
|
|
649
|
+
await newIdx.mount(db);
|
|
650
|
+
|
|
651
|
+
// Fetch and index docs
|
|
652
|
+
let count = 0;
|
|
653
|
+
const total = paths.length;
|
|
654
|
+
|
|
655
|
+
await Promise.all(
|
|
656
|
+
paths.map(async (path) => {
|
|
657
|
+
const file = vm.router.getFile(path);
|
|
658
|
+
const content = await Docsify.get(
|
|
659
|
+
file,
|
|
660
|
+
false,
|
|
661
|
+
vm.config.requestHeaders,
|
|
662
|
+
);
|
|
663
|
+
const parts = genIndex(path, content, vm.router, config.depth);
|
|
664
|
+
Object.values(parts).forEach((doc) => {
|
|
665
|
+
newIdx.add(doc);
|
|
666
|
+
});
|
|
667
|
+
count++;
|
|
668
|
+
if (count === 1 || count === total || count % 10 === 0) {
|
|
669
|
+
setStatus(`Building search index… (${count}/${total})`);
|
|
670
|
+
}
|
|
671
|
+
}),
|
|
672
|
+
);
|
|
673
|
+
|
|
674
|
+
await newIdx.commit();
|
|
675
|
+
|
|
676
|
+
localStorage.setItem(expiresKey, String(now + config.maxAge));
|
|
677
|
+
localStorage.setItem(hashKey, hash);
|
|
678
|
+
setStatus("");
|
|
679
|
+
})().finally(() => {
|
|
680
|
+
STATE.building = null;
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (STATE.building) {
|
|
685
|
+
await STATE.building;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function doSearch(vm, value) {
|
|
690
|
+
const query = (value || "").trim();
|
|
691
|
+
|
|
692
|
+
if (!query) {
|
|
693
|
+
setResultsHTML("");
|
|
694
|
+
setStatus("");
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Ensure index exists (mounted and built if needed)
|
|
699
|
+
await ensureIndex(CONFIG, vm);
|
|
700
|
+
const idx = /** @type {import('flexsearch').Document<any, false, any>} */ (
|
|
701
|
+
STATE.index
|
|
702
|
+
);
|
|
703
|
+
|
|
704
|
+
let results;
|
|
705
|
+
try {
|
|
706
|
+
results = await idx.search(query, {
|
|
707
|
+
limit: CONFIG.limit,
|
|
708
|
+
enrich: true,
|
|
709
|
+
merge: true,
|
|
710
|
+
});
|
|
711
|
+
} catch (e) {
|
|
712
|
+
console.log(e);
|
|
713
|
+
// In case persistent mount isn't supported in the environment
|
|
714
|
+
results = await idx.search(query, {
|
|
715
|
+
limit: CONFIG.limit,
|
|
716
|
+
enrich: true,
|
|
717
|
+
merge: true,
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const list = Array.isArray(results) ? results : [];
|
|
722
|
+
const items = list
|
|
723
|
+
.map((r) => r && r.doc)
|
|
724
|
+
.filter(Boolean)
|
|
725
|
+
.slice(0, CONFIG.limit);
|
|
726
|
+
|
|
727
|
+
let html = "";
|
|
728
|
+
items.forEach((doc, i) => {
|
|
729
|
+
const titlePlain = (doc.title || "").replace(/<[^>]+>/g, "");
|
|
730
|
+
const title = stripDiacritics(doc.title || "");
|
|
731
|
+
const content = doc.body ? highlightSnippet(doc.body, query) : "";
|
|
732
|
+
html += /* html */ `
|
|
733
|
+
<div class="matching-post" aria-label="search result ${i + 1}">
|
|
734
|
+
<a href="${doc.slug}" title="${escapeHtml(titlePlain)}">
|
|
735
|
+
<p class="title clamp-1">${highlightSnippet(title, query, 80)}</p>
|
|
736
|
+
<p class="content clamp-2">${content ? `...${content}...` : ""}</p>
|
|
737
|
+
</a>
|
|
738
|
+
</div>
|
|
739
|
+
`;
|
|
740
|
+
});
|
|
741
|
+
|
|
742
|
+
setResultsHTML(html);
|
|
743
|
+
setStatus(items.length ? `Found ${items.length} results` : STATE.noDataText);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function applyUserConfig(vm) {
|
|
747
|
+
const { util } = Docsify;
|
|
748
|
+
const opts = vm.config.search || CONFIG;
|
|
749
|
+
|
|
750
|
+
if (Array.isArray(opts)) {
|
|
751
|
+
CONFIG.paths = opts;
|
|
752
|
+
} else if (typeof opts === "object") {
|
|
753
|
+
CONFIG.paths = Array.isArray(opts.paths) ? opts.paths : "auto";
|
|
754
|
+
CONFIG.maxAge = util.isPrimitive(opts.maxAge) ? opts.maxAge : CONFIG.maxAge;
|
|
755
|
+
CONFIG.placeholder = opts.placeholder || CONFIG.placeholder;
|
|
756
|
+
CONFIG.noData = opts.noData || CONFIG.noData;
|
|
757
|
+
CONFIG.depth = opts.depth || CONFIG.depth;
|
|
758
|
+
CONFIG.namespace = opts.namespace || CONFIG.namespace;
|
|
759
|
+
CONFIG.pathNamespaces = opts.pathNamespaces || CONFIG.pathNamespaces;
|
|
760
|
+
CONFIG.keyBindings = opts.keyBindings || CONFIG.keyBindings;
|
|
761
|
+
CONFIG.insertAfter = opts.insertAfter || CONFIG.insertAfter;
|
|
762
|
+
CONFIG.insertBefore = opts.insertBefore || CONFIG.insertBefore;
|
|
763
|
+
CONFIG.limit = opts.limit || CONFIG.limit;
|
|
764
|
+
CONFIG.mode = opts.mode || CONFIG.mode;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const install = function (hook, vm) {
|
|
769
|
+
applyUserConfig(vm);
|
|
770
|
+
|
|
771
|
+
hook.init(() => {
|
|
772
|
+
const { keyBindings } = vm.config;
|
|
773
|
+
|
|
774
|
+
// Add key bindings
|
|
775
|
+
if (keyBindings && keyBindings.constructor === Object) {
|
|
776
|
+
keyBindings.focusFlexSearch = {
|
|
777
|
+
bindings: CONFIG.keyBindings,
|
|
778
|
+
callback() {
|
|
779
|
+
ensureUI(vm);
|
|
780
|
+
if (CONFIG.mode === "modal") {
|
|
781
|
+
openModal();
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const sidebarElm = document.querySelector(".sidebar");
|
|
786
|
+
const sidebarToggleElm = /** @type {HTMLElement} */ (
|
|
787
|
+
document.querySelector(".sidebar-toggle")
|
|
788
|
+
);
|
|
789
|
+
const searchElm = /** @type {HTMLInputElement | null} */ (
|
|
790
|
+
sidebarElm?.querySelector('.flexsearch-search input[type="search"]')
|
|
791
|
+
);
|
|
792
|
+
const isSidebarHidden =
|
|
793
|
+
(sidebarElm?.getBoundingClientRect().x ?? 0) < 0;
|
|
794
|
+
|
|
795
|
+
isSidebarHidden && sidebarToggleElm?.click();
|
|
796
|
+
setTimeout(() => searchElm?.focus(), isSidebarHidden ? 250 : 0);
|
|
797
|
+
},
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
hook.mounted(() => {
|
|
803
|
+
ensureUI(vm);
|
|
804
|
+
updatePlaceholder(CONFIG.placeholder, vm.route.path);
|
|
805
|
+
updateNoData(CONFIG.noData, vm.route.path);
|
|
806
|
+
void ensureIndex(CONFIG, vm);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
hook.doneEach(() => {
|
|
810
|
+
ensureUI(vm);
|
|
811
|
+
updatePlaceholder(CONFIG.placeholder, vm.route.path);
|
|
812
|
+
updateNoData(CONFIG.noData, vm.route.path);
|
|
813
|
+
|
|
814
|
+
// Auto mode: sidebar links may change after navigation
|
|
815
|
+
if (CONFIG.paths === "auto") {
|
|
816
|
+
void ensureIndex(CONFIG, vm);
|
|
817
|
+
}
|
|
818
|
+
});
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
window.$docsify = window.$docsify || {};
|
|
822
|
+
window.$docsify.plugins = [install, ...(window.$docsify.plugins || [])];
|