docs/static/js/search.js (7355B)
1 /* sqzass documentation search.
2 *
3 * Substring matching over the page text, not a word index. Korean attaches
4 * particles to nouns and writes compounds without spaces, so a word index
5 * cannot find 최적화 inside 검색엔진최적화. Running a morphological analyser
6 * instead trades that for a worse problem: the dictionary does not know
7 * loanwords, and technical documentation is made of them.
8 *
9 * The dialog is a <dialog>. Escape, the backdrop and the focus trap are the
10 * platform's job, not ours.
11 */
12 (function () {
13 var lang = (document.documentElement.lang || "en").split("-")[0];
14
15 var STRINGS = {
16 en: {
17 placeholder: "Search the documentation",
18 empty: "Type to search",
19 none: "No results for",
20 loading: "Loading index…",
21 count: function (n) { return n + (n === 1 ? " result" : " results"); },
22 /* Arrows survive every fallback stack; ↵ does not, and renders as a box
23 in the mono face this footer uses. Same reason the theme button says
24 "Theme" instead of a moon. */
25 hint: "↑↓ move · enter open · esc close",
26 },
27 ko: {
28 placeholder: "문서 검색",
29 empty: "검색어를 입력하세요",
30 none: "결과가 없습니다:",
31 loading: "색인을 불러오는 중…",
32 count: function (n) { return n + "건"; },
33 hint: "↑↓ 이동 · enter 열기 · esc 닫기",
34 },
35 };
36 var T = STRINGS[lang] || STRINGS.en;
37
38 var dialog = document.getElementById("search-dialog");
39 var trigger = document.getElementById("search-trigger");
40 if (!dialog || !trigger) return;
41
42 var input = dialog.querySelector("input");
43 var list = dialog.querySelector(".search-results");
44 var status = dialog.querySelector(".search-status");
45 input.placeholder = T.placeholder;
46 dialog.querySelector(".search-hint").textContent = T.hint;
47
48 var rows = null;
49 var pending = false;
50 var active = 0;
51
52 /* The index is the whole body text of every page in this language. It is
53 only worth downloading once someone actually opens the palette. */
54 function load() {
55 if (rows || pending) return;
56 pending = true;
57 status.textContent = T.loading;
58 fetch(trigger.dataset.index)
59 .then(function (r) { return r.json(); })
60 .then(function (data) { rows = data; pending = false; render(); })
61 .catch(function () { pending = false; status.textContent = "!"; });
62 }
63
64 function esc(s) {
65 return s.replace(/[&<>"]/g, function (c) {
66 return { "&": "&", "<": "<", ">": ">", '"': """ }[c];
67 });
68 }
69
70 /* Every term has to appear somewhere in the row — an AND, not an OR. Two
71 words that each match a different page are not a match. */
72 function score(row, terms) {
73 var title = row.t.toLowerCase();
74 var desc = (row.d || "").toLowerCase();
75 var body = row.c.toLowerCase();
76 var total = 0;
77 for (var i = 0; i < terms.length; i++) {
78 var q = terms[i];
79 var inTitle = title.indexOf(q);
80 var inDesc = desc.indexOf(q);
81 var inBody = body.indexOf(q);
82 if (inTitle < 0 && inDesc < 0 && inBody < 0) return 0;
83 if (inTitle === 0) total += 200;
84 else if (inTitle > 0) total += 120;
85 if (inDesc >= 0) total += 40;
86 if (inBody >= 0) total += 20;
87 }
88 return total;
89 }
90
91 /* A window of body text around the first hit, so the result shows why it
92 matched rather than just that it did. */
93 function snippet(text, term) {
94 var at = text.toLowerCase().indexOf(term);
95 if (at < 0) return "";
96 var from = Math.max(0, at - 60);
97 var to = Math.min(text.length, at + term.length + 90);
98 var cut = text.slice(from, to);
99 return (from > 0 ? "…" : "") + cut + (to < text.length ? "…" : "");
100 }
101
102 function mark(text, terms) {
103 var out = esc(text);
104 for (var i = 0; i < terms.length; i++) {
105 var q = terms[i];
106 if (!q) continue;
107 var lower = out.toLowerCase();
108 var built = "";
109 var at;
110 var rest = out;
111 var restLower = lower;
112 while ((at = restLower.indexOf(q)) >= 0) {
113 built += rest.slice(0, at) + "<mark>" + rest.slice(at, at + q.length) + "</mark>";
114 rest = rest.slice(at + q.length);
115 restLower = rest.toLowerCase();
116 }
117 out = built + rest;
118 }
119 return out;
120 }
121
122 function render() {
123 var raw = input.value.trim();
124 if (!raw) {
125 list.innerHTML = "";
126 status.textContent = T.empty;
127 return;
128 }
129 if (!rows) { load(); return; }
130
131 var terms = raw.toLowerCase().split(/\s+/);
132 var hits = [];
133 for (var i = 0; i < rows.length; i++) {
134 var s = score(rows[i], terms);
135 if (s > 0) hits.push({ row: rows[i], score: s });
136 }
137 hits.sort(function (a, b) { return b.score - a.score; });
138 hits = hits.slice(0, 12);
139
140 status.textContent = hits.length ? T.count(hits.length) : T.none + ' "' + raw + '"';
141 active = 0;
142 list.innerHTML = hits
143 .map(function (h, i) {
144 var r = h.row;
145 var body = snippet(r.c, terms[0]) || r.d || "";
146 return (
147 '<li><a href="' + esc(r.u) + '"' + (i === 0 ? ' aria-selected="true"' : "") + ">" +
148 (r.s ? '<span class="search-crumb">' + esc(r.s) + "</span>" : "") +
149 '<span class="search-title">' + mark(r.t, terms) + "</span>" +
150 '<span class="search-snippet">' + mark(body, terms) + "</span>" +
151 "</a></li>"
152 );
153 })
154 .join("");
155 }
156
157 function move(delta) {
158 var items = list.querySelectorAll("a");
159 if (!items.length) return;
160 items[active].removeAttribute("aria-selected");
161 active = (active + delta + items.length) % items.length;
162 items[active].setAttribute("aria-selected", "true");
163 items[active].scrollIntoView({ block: "nearest" });
164 }
165
166 function open() {
167 if (dialog.open) return;
168 dialog.showModal();
169 input.value = "";
170 render();
171 load();
172 input.focus();
173 }
174
175 trigger.addEventListener("click", open);
176
177 /* Ctrl/⌘+/. Matched on e.key rather than e.code, because on the layouts
178 where the slash needs a modifier of its own the physical key is somewhere
179 else entirely — what the reader typed is what matters, not where. */
180 document.addEventListener("keydown", function (e) {
181 if ((e.metaKey || e.ctrlKey) && e.key === "/") {
182 e.preventDefault();
183 dialog.open ? dialog.close() : open();
184 }
185 });
186
187 input.addEventListener("input", render);
188
189 dialog.addEventListener("keydown", function (e) {
190 if (e.key === "ArrowDown") { e.preventDefault(); move(1); }
191 else if (e.key === "ArrowUp") { e.preventDefault(); move(-1); }
192 else if (e.key === "Enter") {
193 var items = list.querySelectorAll("a");
194 if (items.length) { e.preventDefault(); location.href = items[active].href; }
195 }
196 });
197
198 /* Clicking the backdrop closes. A <dialog>'s backdrop is part of the dialog
199 element, so the target being the dialog itself means the click missed the
200 panel inside it. */
201 dialog.addEventListener("click", function (e) {
202 if (e.target === dialog) dialog.close();
203 });
204
205 /* The shortcut hint is written here, not in the template, because it is a
206 property of the reader's keyboard rather than of the page. */
207 var mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
208 trigger.querySelector("kbd").textContent = mac ? "⌘/" : "Ctrl /";
209 })();