docula 2.0.0 → 2.1.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/README.md +16 -2
- package/dist/docula.d.ts +7 -0
- package/dist/docula.js +503 -34
- package/package.json +17 -15
- package/templates/modern/css/styles.css +263 -7
- package/templates/modern/includes/header-bar.hbs +8 -0
- package/templates/modern/includes/search.hbs +46 -0
- package/templates/modern/js/search.js +436 -0
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Docula client-side search.
|
|
3
|
+
*
|
|
4
|
+
* Loads the build-generated search-index.json on first open and powers a
|
|
5
|
+
* keyboard-navigable search modal (Cmd/Ctrl+K to open, arrows to navigate,
|
|
6
|
+
* Enter to select, Esc to close). No external dependencies.
|
|
7
|
+
*/
|
|
8
|
+
(function () {
|
|
9
|
+
"use strict";
|
|
10
|
+
|
|
11
|
+
var config = window.__doculaSearch || {};
|
|
12
|
+
var indexUrl = config.indexUrl || "/search-index.json";
|
|
13
|
+
|
|
14
|
+
var button = document.getElementById("search-button");
|
|
15
|
+
var modal = document.getElementById("search-modal");
|
|
16
|
+
var input = document.getElementById("search-input");
|
|
17
|
+
var resultsEl = document.getElementById("search-results");
|
|
18
|
+
var emptyEl = document.getElementById("search-empty");
|
|
19
|
+
var emptyQueryEl = document.getElementById("search-empty-query");
|
|
20
|
+
var initialEl = document.getElementById("search-initial");
|
|
21
|
+
var clearBtn = document.getElementById("search-clear");
|
|
22
|
+
|
|
23
|
+
if (!modal || !input || !resultsEl) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
var MAX_RESULTS = 12;
|
|
28
|
+
var FILE_ICON =
|
|
29
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></svg>';
|
|
30
|
+
var HASH_ICON =
|
|
31
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="9" x2="20" y2="9"/><line x1="4" y1="15" x2="20" y2="15"/><line x1="10" y1="3" x2="8" y2="21"/><line x1="16" y1="3" x2="14" y2="21"/></svg>';
|
|
32
|
+
|
|
33
|
+
var records = null;
|
|
34
|
+
var loadingPromise = null;
|
|
35
|
+
var currentResults = [];
|
|
36
|
+
var activeIndex = -1;
|
|
37
|
+
var debounceTimer = null;
|
|
38
|
+
|
|
39
|
+
function loadIndex() {
|
|
40
|
+
if (records) {
|
|
41
|
+
return Promise.resolve(records);
|
|
42
|
+
}
|
|
43
|
+
if (loadingPromise) {
|
|
44
|
+
return loadingPromise;
|
|
45
|
+
}
|
|
46
|
+
loadingPromise = fetch(indexUrl)
|
|
47
|
+
.then(function (response) {
|
|
48
|
+
return response.ok ? response.json() : { records: [] };
|
|
49
|
+
})
|
|
50
|
+
.then(function (data) {
|
|
51
|
+
records = (data && data.records) || [];
|
|
52
|
+
return records;
|
|
53
|
+
})
|
|
54
|
+
.catch(function () {
|
|
55
|
+
records = [];
|
|
56
|
+
return records;
|
|
57
|
+
});
|
|
58
|
+
return loadingPromise;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isOpen() {
|
|
62
|
+
return !modal.hasAttribute("hidden");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function openModal() {
|
|
66
|
+
if (isOpen()) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
modal.removeAttribute("hidden");
|
|
70
|
+
document.body.classList.add("search-open");
|
|
71
|
+
loadIndex().then(function () {
|
|
72
|
+
if (input.value) {
|
|
73
|
+
runSearch(input.value);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
requestAnimationFrame(function () {
|
|
77
|
+
input.focus();
|
|
78
|
+
input.select();
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function closeModal() {
|
|
83
|
+
if (!isOpen()) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
modal.setAttribute("hidden", "");
|
|
87
|
+
document.body.classList.remove("search-open");
|
|
88
|
+
if (button) {
|
|
89
|
+
button.focus();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function tokenize(query) {
|
|
94
|
+
return query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function scoreRecord(record, tokens) {
|
|
98
|
+
var title = (record.title || "").toLowerCase();
|
|
99
|
+
var breadcrumb = (record.titles || []).join(" ").toLowerCase();
|
|
100
|
+
var text = (record.text || "").toLowerCase();
|
|
101
|
+
var haystack = title + " " + breadcrumb + " " + text;
|
|
102
|
+
var score = 0;
|
|
103
|
+
|
|
104
|
+
for (var i = 0; i < tokens.length; i++) {
|
|
105
|
+
var token = tokens[i];
|
|
106
|
+
// AND semantics: every token must appear somewhere in the record.
|
|
107
|
+
if (haystack.indexOf(token) === -1) {
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
if (title.indexOf(token) === 0) {
|
|
111
|
+
score += 6;
|
|
112
|
+
}
|
|
113
|
+
if (title.indexOf(token) !== -1) {
|
|
114
|
+
score += 10;
|
|
115
|
+
}
|
|
116
|
+
if (breadcrumb.indexOf(token) !== -1) {
|
|
117
|
+
score += 4;
|
|
118
|
+
}
|
|
119
|
+
if (text.indexOf(token) !== -1) {
|
|
120
|
+
score += 2;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
var phrase = tokens.join(" ");
|
|
125
|
+
if (title.indexOf(phrase) !== -1) {
|
|
126
|
+
score += 12;
|
|
127
|
+
}
|
|
128
|
+
if (text.indexOf(phrase) !== -1) {
|
|
129
|
+
score += 3;
|
|
130
|
+
}
|
|
131
|
+
return score;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function search(query) {
|
|
135
|
+
var tokens = tokenize(query);
|
|
136
|
+
if (tokens.length === 0 || !records) {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
var scored = [];
|
|
141
|
+
for (var i = 0; i < records.length; i++) {
|
|
142
|
+
var score = scoreRecord(records[i], tokens);
|
|
143
|
+
if (score > 0) {
|
|
144
|
+
scored.push({ record: records[i], score: score });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
scored.sort(function (a, b) {
|
|
149
|
+
if (b.score !== a.score) {
|
|
150
|
+
return b.score - a.score;
|
|
151
|
+
}
|
|
152
|
+
return (a.record.title || "").length - (b.record.title || "").length;
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return scored.slice(0, MAX_RESULTS).map(function (item) {
|
|
156
|
+
return item.record;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function makeSnippet(text, tokens) {
|
|
161
|
+
if (!text) {
|
|
162
|
+
return "";
|
|
163
|
+
}
|
|
164
|
+
var lower = text.toLowerCase();
|
|
165
|
+
var pos = -1;
|
|
166
|
+
for (var i = 0; i < tokens.length; i++) {
|
|
167
|
+
var found = lower.indexOf(tokens[i]);
|
|
168
|
+
if (found !== -1 && (pos === -1 || found < pos)) {
|
|
169
|
+
pos = found;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
var radius = 60;
|
|
174
|
+
var anchor = pos === -1 ? 0 : pos;
|
|
175
|
+
var start = Math.max(0, anchor - radius);
|
|
176
|
+
var end = Math.min(text.length, anchor + radius * 2);
|
|
177
|
+
var snippet = text.slice(start, end);
|
|
178
|
+
if (start > 0) {
|
|
179
|
+
snippet = "… " + snippet;
|
|
180
|
+
}
|
|
181
|
+
if (end < text.length) {
|
|
182
|
+
snippet = snippet + " …";
|
|
183
|
+
}
|
|
184
|
+
return snippet;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function appendHighlighted(parent, text, tokens) {
|
|
188
|
+
if (!text) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (!tokens.length) {
|
|
192
|
+
parent.appendChild(document.createTextNode(text));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
var escaped = tokens.map(function (token) {
|
|
197
|
+
return token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
198
|
+
});
|
|
199
|
+
var pattern = new RegExp("(" + escaped.join("|") + ")", "gi");
|
|
200
|
+
var lastIndex = 0;
|
|
201
|
+
var match = pattern.exec(text);
|
|
202
|
+
while (match !== null) {
|
|
203
|
+
if (match.index > lastIndex) {
|
|
204
|
+
parent.appendChild(
|
|
205
|
+
document.createTextNode(text.slice(lastIndex, match.index)),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
var mark = document.createElement("mark");
|
|
209
|
+
mark.className = "search-result__mark";
|
|
210
|
+
mark.textContent = match[0];
|
|
211
|
+
parent.appendChild(mark);
|
|
212
|
+
lastIndex = match.index + match[0].length;
|
|
213
|
+
if (match.index === pattern.lastIndex) {
|
|
214
|
+
pattern.lastIndex++;
|
|
215
|
+
}
|
|
216
|
+
match = pattern.exec(text);
|
|
217
|
+
}
|
|
218
|
+
if (lastIndex < text.length) {
|
|
219
|
+
parent.appendChild(document.createTextNode(text.slice(lastIndex)));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function createResultItem(record, index, tokens) {
|
|
224
|
+
var item = document.createElement("li");
|
|
225
|
+
item.className = "search-result";
|
|
226
|
+
item.id = "search-result-" + index;
|
|
227
|
+
item.setAttribute("role", "option");
|
|
228
|
+
item.setAttribute("aria-selected", "false");
|
|
229
|
+
|
|
230
|
+
var link = document.createElement("a");
|
|
231
|
+
link.className = "search-result__link";
|
|
232
|
+
link.href = record.url;
|
|
233
|
+
link.tabIndex = -1;
|
|
234
|
+
|
|
235
|
+
var icon = document.createElement("span");
|
|
236
|
+
icon.className = "search-result__icon";
|
|
237
|
+
icon.setAttribute("aria-hidden", "true");
|
|
238
|
+
icon.innerHTML =
|
|
239
|
+
record.url && record.url.indexOf("#") !== -1 ? HASH_ICON : FILE_ICON;
|
|
240
|
+
link.appendChild(icon);
|
|
241
|
+
|
|
242
|
+
var content = document.createElement("div");
|
|
243
|
+
content.className = "search-result__content";
|
|
244
|
+
|
|
245
|
+
if (record.titles && record.titles.length) {
|
|
246
|
+
var breadcrumb = document.createElement("div");
|
|
247
|
+
breadcrumb.className = "search-result__breadcrumb";
|
|
248
|
+
breadcrumb.textContent = record.titles.join(" › ");
|
|
249
|
+
content.appendChild(breadcrumb);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
var title = document.createElement("div");
|
|
253
|
+
title.className = "search-result__title";
|
|
254
|
+
appendHighlighted(title, record.title || "Untitled", tokens);
|
|
255
|
+
content.appendChild(title);
|
|
256
|
+
|
|
257
|
+
var snippet = makeSnippet(record.text, tokens);
|
|
258
|
+
if (snippet) {
|
|
259
|
+
var textEl = document.createElement("div");
|
|
260
|
+
textEl.className = "search-result__text";
|
|
261
|
+
appendHighlighted(textEl, snippet, tokens);
|
|
262
|
+
content.appendChild(textEl);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
link.appendChild(content);
|
|
266
|
+
item.appendChild(link);
|
|
267
|
+
|
|
268
|
+
item.addEventListener("mousemove", function () {
|
|
269
|
+
setActive(index);
|
|
270
|
+
});
|
|
271
|
+
link.addEventListener("click", function () {
|
|
272
|
+
closeModal();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
return item;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function render(resultList, tokens) {
|
|
279
|
+
resultsEl.textContent = "";
|
|
280
|
+
currentResults = resultList;
|
|
281
|
+
activeIndex = resultList.length ? 0 : -1;
|
|
282
|
+
|
|
283
|
+
for (var i = 0; i < resultList.length; i++) {
|
|
284
|
+
resultsEl.appendChild(createResultItem(resultList[i], i, tokens));
|
|
285
|
+
}
|
|
286
|
+
updateActive();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function setActive(index) {
|
|
290
|
+
if (index === activeIndex) {
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
activeIndex = index;
|
|
294
|
+
updateActive();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function updateActive() {
|
|
298
|
+
var items = resultsEl.children;
|
|
299
|
+
for (var i = 0; i < items.length; i++) {
|
|
300
|
+
var selected = i === activeIndex;
|
|
301
|
+
items[i].setAttribute("aria-selected", selected ? "true" : "false");
|
|
302
|
+
items[i].classList.toggle("search-result--active", selected);
|
|
303
|
+
}
|
|
304
|
+
if (activeIndex >= 0 && items[activeIndex]) {
|
|
305
|
+
input.setAttribute("aria-activedescendant", items[activeIndex].id);
|
|
306
|
+
items[activeIndex].scrollIntoView({ block: "nearest" });
|
|
307
|
+
} else {
|
|
308
|
+
input.setAttribute("aria-activedescendant", "");
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function showState(state, query) {
|
|
313
|
+
if (initialEl) {
|
|
314
|
+
initialEl.toggleAttribute("hidden", state !== "initial");
|
|
315
|
+
}
|
|
316
|
+
if (emptyEl) {
|
|
317
|
+
emptyEl.toggleAttribute("hidden", state !== "empty");
|
|
318
|
+
}
|
|
319
|
+
resultsEl.toggleAttribute("hidden", state !== "results");
|
|
320
|
+
if (state === "empty" && emptyQueryEl) {
|
|
321
|
+
emptyQueryEl.textContent = query;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function runSearch(query) {
|
|
326
|
+
var trimmed = query.trim();
|
|
327
|
+
if (clearBtn) {
|
|
328
|
+
clearBtn.toggleAttribute("hidden", trimmed.length === 0);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (!trimmed) {
|
|
332
|
+
render([], []);
|
|
333
|
+
showState("initial");
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Index may still be loading; the open handler re-runs once it resolves.
|
|
338
|
+
if (!records) {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
var tokens = tokenize(trimmed);
|
|
343
|
+
var resultList = search(trimmed);
|
|
344
|
+
render(resultList, tokens);
|
|
345
|
+
showState(resultList.length ? "results" : "empty", trimmed);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function navigate(url) {
|
|
349
|
+
closeModal();
|
|
350
|
+
window.location.assign(url);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
input.addEventListener("input", function () {
|
|
354
|
+
var query = input.value;
|
|
355
|
+
clearTimeout(debounceTimer);
|
|
356
|
+
debounceTimer = setTimeout(function () {
|
|
357
|
+
runSearch(query);
|
|
358
|
+
}, 80);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
input.addEventListener("keydown", function (event) {
|
|
362
|
+
if (event.key === "ArrowDown") {
|
|
363
|
+
event.preventDefault();
|
|
364
|
+
if (currentResults.length) {
|
|
365
|
+
setActive((activeIndex + 1) % currentResults.length);
|
|
366
|
+
}
|
|
367
|
+
} else if (event.key === "ArrowUp") {
|
|
368
|
+
event.preventDefault();
|
|
369
|
+
if (currentResults.length) {
|
|
370
|
+
setActive(
|
|
371
|
+
(activeIndex - 1 + currentResults.length) % currentResults.length,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
} else if (event.key === "Enter") {
|
|
375
|
+
event.preventDefault();
|
|
376
|
+
// Flush any pending debounced search so Enter acts on the current input
|
|
377
|
+
// rather than stale results when typing then immediately pressing Enter.
|
|
378
|
+
clearTimeout(debounceTimer);
|
|
379
|
+
runSearch(input.value);
|
|
380
|
+
if (activeIndex >= 0 && currentResults[activeIndex]) {
|
|
381
|
+
navigate(currentResults[activeIndex].url);
|
|
382
|
+
}
|
|
383
|
+
} else if (event.key === "Escape") {
|
|
384
|
+
event.preventDefault();
|
|
385
|
+
closeModal();
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
if (clearBtn) {
|
|
390
|
+
clearBtn.addEventListener("click", function () {
|
|
391
|
+
input.value = "";
|
|
392
|
+
runSearch("");
|
|
393
|
+
input.focus();
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (button) {
|
|
398
|
+
button.addEventListener("click", openModal);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
modal.addEventListener("click", function (event) {
|
|
402
|
+
var target = event.target;
|
|
403
|
+
if (target && target.hasAttribute && target.hasAttribute("data-search-close")) {
|
|
404
|
+
closeModal();
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
document.addEventListener("keydown", function (event) {
|
|
409
|
+
var key = (event.key || "").toLowerCase();
|
|
410
|
+
if ((event.metaKey || event.ctrlKey) && key === "k") {
|
|
411
|
+
event.preventDefault();
|
|
412
|
+
if (isOpen()) {
|
|
413
|
+
closeModal();
|
|
414
|
+
} else {
|
|
415
|
+
openModal();
|
|
416
|
+
}
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
if (key === "escape" && isOpen()) {
|
|
420
|
+
event.preventDefault();
|
|
421
|
+
closeModal();
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (key === "/" && !isOpen()) {
|
|
425
|
+
var active = document.activeElement;
|
|
426
|
+
var tag = active ? active.tagName : "";
|
|
427
|
+
var editable = active ? active.isContentEditable : false;
|
|
428
|
+
if (tag !== "INPUT" && tag !== "TEXTAREA" && tag !== "SELECT" && !editable) {
|
|
429
|
+
event.preventDefault();
|
|
430
|
+
openModal();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
showState("initial");
|
|
436
|
+
})();
|