Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/guide/search.js

15.2 KiB, 1 run

created by r2519314175:1295, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1/* The guide's search: a box in the header, and results as you type.
2 *
3 * notes2.txt line 96 asks for it as a fallback -- "some users will choose to
4 * burn tokens on Daimond Help but a search facility in the guide is a fallback.
5 * It has to be of decent quality." So it is not a substring scan: it tokenises,
6 * requires every term to be present, ranks a heading match above a body match,
7 * and shows the sentence the match is in rather than the top of the section.
8 *
9 * WHAT SHAPES THIS FILE.
10 *
11 * The guide is framed inside Daimond's Web panel WITHOUT `allow-same-origin`.
12 * An opaque origin cannot fetch its own files and read the answer, cannot use
13 * localStorage, and cannot reach the app's document. Everything here therefore
14 * comes from `search-index.js`, which is loaded as a SCRIPT beside this one --
15 * the same channel `frame.js` uses to exist at all. There is no network call,
16 * no storage, and no dependency on the app.
17 *
18 * Five of the eight locales are not written in a Latin alphabet, and Japanese,
19 * Korean and Chinese do not put spaces between words. Splitting a query on
20 * whitespace and requiring whole "words" would find nothing in three of the
21 * eight languages the guide ships in, so the matcher treats a run of CJK as a
22 * sequence of characters to be found in order, not as a word.
23 */
24(function () {
25 'use strict';
26
27 var IX = window.GUIDE_INDEX;
28 if (!IX || !IX.sections || !IX.sections.length) return;
29 var W = IX.words || {};
30
31 // ── Folding ───────────────────────────────────────────────────────
32 //
33 // Case and accents are removed so "Modele" finds "Modèle" and "GUIDE"
34 // finds "guide". NFD splits a letter from its accent and the range below
35 // deletes the accents; it is a no-op on scripts that do not use them.
36 function fold(s) {
37 return String(s || '').toLowerCase().normalize('NFD')
38 .replace(/[̀-ͯ]/g, '');
39 }
40
41 var CJK = /[぀-ヿ㐀-䶿一-鿿豈-﫿가-힯]/;
42
43 // Words a reader types that the guide does not use, pointed at words it
44 // does. A help search is used by somebody who does not yet know the
45 // vocabulary -- that is why they are searching. The guide says "passphrase"
46 // throughout and never "password"; it explains bringing your own key and
47 // never writes "BYOK". The table is built into the index, per locale, so
48 // this file holds the mechanism and none of the vocabulary. English only
49 // for now: see dev/guide-index.mjs for why.
50 var ALIAS = (IX.alias && typeof IX.alias === 'object') ? IX.alias : {};
51
52 /// A term, and anything the guide might say instead. The term itself is
53 /// always first, so a direct hit is what gets scored when there is one.
54 function expand(term) {
55 var alt = ALIAS[term];
56 if (!alt || !alt.length) return [term];
57 var out = [term];
58 for (var i = 0; i < alt.length; i++) out.push(fold(alt[i]));
59 return out;
60 }
61
62 /// A query, as the terms that all have to be present.
63 ///
64 /// Latin runs split on whitespace and punctuation. A CJK run is kept whole
65 /// and matched as a substring: there is no space to split on, and a single
66 /// character is too common to be a useful term on its own.
67 function terms(q) {
68 var out = [], cur = '', curCJK = false;
69 var s = fold(q);
70 for (var i = 0; i < s.length; i++) {
71 var ch = s[i];
72 var isCJK = CJK.test(ch);
73 var isWord = /[\p{L}\p{N}]/u.test(ch);
74 if (!isWord) { if (cur) out.push(cur); cur = ''; continue; }
75 if (cur && isCJK !== curCJK) { out.push(cur); cur = ''; }
76 cur += ch; curCJK = isCJK;
77 }
78 if (cur) out.push(cur);
79 return out.filter(function (t) { return t.length > 0; });
80 }
81
82 // The index, folded once at load rather than once per keystroke. 137
83 // sections of up to 1200 characters is about 100 kB of lower-casing, which
84 // is nothing done once and noticeable done on every key.
85 var ROWS = IX.sections.map(function (s) {
86 return {
87 s: s,
88 ft: fold(s.t),
89 fu: fold(s.u),
90 fb: fold(s.b),
91 };
92 });
93
94 /// Where a term is found, and how good the find is.
95 ///
96 /// A match at the start of a word beats one in the middle, because "mod"
97 /// meaning "model" is a prefix and "mod" meaning "commodity" is a
98 /// coincidence. A CJK term has no word starts to look for, so it scores as
99 /// a plain substring.
100 function hit(hay, term) {
101 var at = hay.indexOf(term);
102 if (at < 0) return null;
103 var whole = false, start = false;
104 if (!CJK.test(term)) {
105 var b = at === 0 || !/[\p{L}\p{N}]/u.test(hay[at - 1]);
106 var e = at + term.length >= hay.length || !/[\p{L}\p{N}]/u.test(hay[at + term.length]);
107 start = b; whole = b && e;
108 }
109 return { at: at, start: start, whole: whole };
110 }
111
112 /// Score one section against the query. Null when a term is missing --
113 /// EVERY term must appear somewhere, which is what stops a two-word query
114 /// returning everything that has either word.
115 function score(row, ts) {
116 var total = 0, where = null;
117 for (var i = 0; i < ts.length; i++) {
118 var forms = expand(ts[i]);
119 var t = null, inT = null, inU = null, inB = null, viaAlias = false;
120 // The typed word first; only if the guide does not use it at all is
121 // an alias tried, and a hit through one is scored below a direct one
122 // so it can never outrank the real thing.
123 for (var f = 0; f < forms.length; f++) {
124 var a = hit(row.ft, forms[f]), b = hit(row.fu, forms[f]), c = hit(row.fb, forms[f]);
125 if (a || b || c) { t = forms[f]; inT = a; inU = b; inB = c; viaAlias = f > 0; break; }
126 }
127 if (!t) return null;
128 var best = 0;
129 // A heading match is what the reader is usually looking for: it is
130 // the name of the thing, not a mention of it in passing.
131 if (inT) best = Math.max(best, 100 + (inT.whole ? 40 : inT.start ? 25 : 0)
132 - Math.min(inT.at, 30));
133 if (inU) best = Math.max(best, 45 + (inU.whole ? 15 : inU.start ? 8 : 0));
134 if (inB) {
135 best = Math.max(best, 30 + (inB.whole ? 20 : inB.start ? 10 : 0)
136 - Math.min(inB.at / 40, 20));
137 if (!where) where = inB.at;
138 }
139 total += viaAlias ? best * 0.6 : best;
140 }
141 // A short heading that matches is a better answer than a long one that
142 // happens to contain the same words.
143 total += Math.max(0, 20 - row.ft.length / 4);
144 return { n: total, at: where == null ? 0 : where };
145 }
146
147 function search(q) {
148 var ts = terms(q);
149 if (!ts.length) return [];
150 var out = [];
151 for (var i = 0; i < ROWS.length; i++) {
152 var sc = score(ROWS[i], ts);
153 if (sc) out.push({ row: ROWS[i], n: sc.n, at: sc.at });
154 }
155 out.sort(function (a, b) { return b.n - a.n; });
156 return out.slice(0, 20);
157 }
158
159 // ── The snippet ───────────────────────────────────────────────────
160
161 function esc(s) {
162 return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
163 .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
164 }
165
166 /// The body around the first match, with every term marked.
167 ///
168 /// Cut on a space where there is one, so a snippet does not start
169 /// mid-word; CJK has none, and a hard cut there is correct.
170 function snippet(row, ts) {
171 var b = row.s.b || '';
172 if (!b) return '';
173 var at = -1;
174 for (var q = 0; q < ts.length && at < 0; q++) {
175 var forms = expand(ts[q]);
176 for (var f = 0; f < forms.length && at < 0; f++) at = row.fb.indexOf(forms[f]);
177 }
178 var from = at < 0 ? 0 : Math.max(0, at - 60);
179 if (from > 0 && !CJK.test(b[from])) {
180 var sp = b.indexOf(' ', from);
181 if (sp > 0 && sp - from < 25) from = sp + 1;
182 }
183 var text = b.slice(from, from + 190);
184 var out = esc(text);
185 // Mark longest-first, so "model" does not get chopped up by "mod".
186 var all = [];
187 for (var q = 0; q < ts.length; q++) all = all.concat(expand(ts[q]));
188 var sorted = all.sort(function (a, c) { return c.length - a.length; });
189 for (var i = 0; i < sorted.length; i++) {
190 out = mark(out, sorted[i]);
191 }
192 return (from > 0 ? '…' : '') + out + (from + 190 < b.length ? '…' : '');
193 }
194
195 /// Wrap each occurrence of `term` in <mark>, comparing FOLDED text but
196 /// keeping the original. Skips anything already inside a tag or an entity,
197 /// so a second pass cannot mark the inside of a `<mark>`.
198 function mark(html, term) {
199 var f = fold(html), t = term, out = '', i = 0;
200 while (i < html.length) {
201 var at = f.indexOf(t, i);
202 if (at < 0) { out += html.slice(i); break; }
203 // Inside a tag or an entity? Look back for an unclosed `<` or `&`.
204 var lt = html.lastIndexOf('<', at), gt = html.lastIndexOf('>', at);
205 var am = html.lastIndexOf('&', at), sc = html.lastIndexOf(';', at);
206 if (lt > gt || (am > sc && at - am < 8)) {
207 out += html.slice(i, at + t.length); i = at + t.length; continue;
208 }
209 out += html.slice(i, at) + '<mark>' + html.slice(at, at + t.length) + '</mark>';
210 i = at + t.length;
211 }
212 return out;
213 }
214
215 // ── The box ───────────────────────────────────────────────────────
216
217 var box, input, panel, results = [], sel = -1;
218
219 function build() {
220 var head = document.querySelector('.site-head-inner');
221 if (!head) return false;
222
223 box = document.createElement('div');
224 box.className = 'gsearch';
225
226 input = document.createElement('input');
227 input.type = 'search';
228 input.className = 'gsearch-in';
229 input.id = 'guide-search';
230 input.autocomplete = 'off';
231 input.spellcheck = false;
232 input.placeholder = W.ph || 'Search the guide';
233 input.setAttribute('aria-label', W.ph || 'Search the guide');
234 // A combobox, so a screen reader is told there is a list under this and
235 // which row is current -- the arrow keys move a highlight the focus
236 // never leaves, and without this that movement is silent.
237 input.setAttribute('role', 'combobox');
238 input.setAttribute('aria-expanded', 'false');
239 input.setAttribute('aria-controls', 'guide-search-results');
240 input.setAttribute('aria-autocomplete', 'list');
241
242 panel = document.createElement('div');
243 panel.className = 'gsearch-panel';
244 panel.id = 'guide-search-results';
245 panel.setAttribute('role', 'listbox');
246 panel.hidden = true;
247
248 var live = document.createElement('div');
249 live.className = 'gsearch-live';
250 live.setAttribute('aria-live', 'polite');
251
252 box.appendChild(input);
253 box.appendChild(panel);
254 box.appendChild(live);
255 head.appendChild(box);
256 box._live = live;
257 return true;
258 }
259
260 function close() {
261 panel.hidden = true;
262 input.setAttribute('aria-expanded', 'false');
263 input.removeAttribute('aria-activedescendant');
264 sel = -1;
265 }
266
267 function draw(q) {
268 var ts = terms(q);
269 results = search(q);
270 panel.innerHTML = '';
271 sel = -1;
272 if (!ts.length) { close(); return; }
273
274 if (!results.length) {
275 var none = document.createElement('p');
276 none.className = 'gsearch-none';
277 none.textContent = W.no || 'Nothing found';
278 panel.appendChild(none);
279 } else {
280 results.forEach(function (r, i) {
281 var a = document.createElement('a');
282 a.className = 'gsearch-hit';
283 a.id = 'gsearch-hit-' + i;
284 a.setAttribute('role', 'option');
285 a.setAttribute('aria-selected', 'false');
286 a.href = r.row.s.p + (r.row.s.a ? '#' + r.row.s.a : '');
287
288 var t = document.createElement('span');
289 t.className = 'gsearch-t';
290 t.innerHTML = markAll(esc(r.row.s.t), ts);
291 a.appendChild(t);
292
293 if (r.row.s.u) {
294 var u = document.createElement('span');
295 u.className = 'gsearch-u';
296 u.textContent = r.row.s.u;
297 a.appendChild(u);
298 }
299 var sn = snippet(r.row, ts);
300 if (sn) {
301 var d = document.createElement('span');
302 d.className = 'gsearch-b';
303 d.innerHTML = sn;
304 a.appendChild(d);
305 }
306 a.addEventListener('mousemove', function () { highlight(i); });
307 panel.appendChild(a);
308 });
309 }
310 panel.hidden = false;
311 input.setAttribute('aria-expanded', 'true');
312 var n = results.length;
313 box._live.textContent = n === 0 ? (W.no || 'Nothing found')
314 : n === 1 ? (W.n1 || '1 result')
315 : (W.n || '{n} results').replace('{n}', n);
316 }
317
318 function markAll(html, ts) {
319 var all = [];
320 for (var q = 0; q < ts.length; q++) all = all.concat(expand(ts[q]));
321 var sorted = all.sort(function (a, c) { return c.length - a.length; });
322 for (var i = 0; i < sorted.length; i++) html = mark(html, sorted[i]);
323 return html;
324 }
325
326 function highlight(i) {
327 var hits = panel.querySelectorAll('.gsearch-hit');
328 if (!hits.length) return;
329 if (sel >= 0 && hits[sel]) {
330 hits[sel].classList.remove('on');
331 hits[sel].setAttribute('aria-selected', 'false');
332 }
333 sel = (i + hits.length) % hits.length;
334 hits[sel].classList.add('on');
335 hits[sel].setAttribute('aria-selected', 'true');
336 input.setAttribute('aria-activedescendant', hits[sel].id);
337 // `nearest`, so a highlight one row down does not jerk the list.
338 if (hits[sel].scrollIntoView) hits[sel].scrollIntoView({ block: 'nearest' });
339 }
340
341 function wire() {
342 var timer = null;
343 input.addEventListener('input', function () {
344 // A short settle, so a fast typist searches once rather than eight
345 // times. 90ms is under the threshold at which a list feels laggy.
346 clearTimeout(timer);
347 timer = setTimeout(function () { draw(input.value); }, 90);
348 });
349 input.addEventListener('focus', function () {
350 if (input.value.trim()) draw(input.value);
351 });
352 input.addEventListener('keydown', function (e) {
353 if (e.key === 'Escape') {
354 if (!panel.hidden) { close(); e.preventDefault(); }
355 else { input.value = ''; input.blur(); }
356 return;
357 }
358 if (panel.hidden) return;
359 var hits = panel.querySelectorAll('.gsearch-hit');
360 if (e.key === 'ArrowDown') { e.preventDefault(); highlight(sel + 1); }
361 else if (e.key === 'ArrowUp') { e.preventDefault(); highlight(sel - 1); }
362 else if (e.key === 'Home' && hits.length) { e.preventDefault(); highlight(0); }
363 else if (e.key === 'End' && hits.length) { e.preventDefault(); highlight(hits.length - 1); }
364 else if (e.key === 'Enter') {
365 // With nothing picked, Enter takes the best answer — which is
366 // what the reader meant by pressing it at all.
367 var go = sel >= 0 ? hits[sel] : hits[0];
368 if (go) { e.preventDefault(); window.location.href = go.href; }
369 }
370 });
371 document.addEventListener('click', function (e) {
372 if (box && !box.contains(e.target)) close();
373 });
374 // `/` from anywhere, the way it works in every reader this audience uses.
375 // Not while typing in something else, and not with a modifier held.
376 document.addEventListener('keydown', function (e) {
377 if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey) return;
378 var el = document.activeElement;
379 if (el && /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName)) return;
380 if (el && el.isContentEditable) return;
381 e.preventDefault();
382 input.focus();
383 input.select();
384 });
385 }
386
387 /// Land on the section a result named, and say which one it is.
388 ///
389 /// A fragment jump alone leaves the reader at a heading that looks like
390 /// every other heading. The target is marked for a few seconds so the eye
391 /// has somewhere to go.
392 function landed() {
393 var id = (window.location.hash || '').slice(1);
394 if (!/^s\d+$/.test(id)) return;
395 var el = document.getElementById(id);
396 if (!el) return;
397 el.classList.add('gsearch-landed');
398 setTimeout(function () { el.classList.remove('gsearch-landed'); }, 2600);
399 }
400
401 function start() {
402 if (!build()) return;
403 wire();
404 landed();
405 }
406
407 if (document.readyState === 'loading') {
408 document.addEventListener('DOMContentLoaded', start);
409 } else {
410 start();
411 }
412 window.addEventListener('hashchange', landed);
413})();