@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.
@@ -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
+ "&": "&amp;",
72
+ "<": "&lt;",
73
+ ">": "&gt;",
74
+ '"': "&quot;",
75
+ "'": "&#39;",
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 || [])];