sqzass.git

sqzass

~ A static site generator written in Rust
git clone https://github.com/debuman/sqzass.git

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 { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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 })();