// SummerCMS docs: client-side search over search-index.json. // The index is fetched lazily on the first open and cached. Result rows are // built with createElement and textContent only; indexed text is never // parsed as HTML. (function () { "use strict"; var MAX_RESULTS = 20; var QUERY_KEY = "summer-docs-query"; var dialog = document.getElementById("search"); var input = document.getElementById("search-input"); var list = document.getElementById("search-results"); var status = document.getElementById("search-status"); var live = document.getElementById("search-live"); var trigger = document.querySelector(".search-trigger"); if (!dialog || !input || !list || !status || typeof dialog.showModal !== "function") { return; } var items = null; // null until loaded var loading = null; // the pending load promise var loadError = ""; // "server" or "index" after a failed load var results = []; var activeIndex = -1; var isMac = /mac/i.test((navigator.userAgentData && navigator.userAgentData.platform) || navigator.platform || ""); var kbd = trigger && trigger.querySelector("kbd"); if (kbd && isMac) { kbd.textContent = "⌘ K"; } try { input.value = window.sessionStorage.getItem(QUERY_KEY) || ""; } catch (e) { // No session storage: start empty. } function el(tag, className, text) { var node = document.createElement(tag); if (className) { node.className = className; } if (text !== undefined) { node.textContent = text; } return node; } // appendRich appends text in which `code spans` become elements. function appendRich(parent, text) { var parts = text.split("`"); for (var i = 0; i < parts.length; i++) { if (!parts[i]) { continue; } parent.appendChild(i % 2 ? el("code", "", parts[i]) : document.createTextNode(parts[i])); } } function setStatus(title, body) { status.textContent = ""; if (title) { status.appendChild(el("p", "search-status-title", title)); } if (body) { var p = el("p"); appendRich(p, body); status.appendChild(p); } } function announce(text) { if (live) { live.textContent = text; } } function clearResults() { results = []; activeIndex = -1; list.textContent = ""; input.setAttribute("aria-expanded", "false"); input.setAttribute("aria-activedescendant", ""); } function load() { if (items || loading) { return loading || Promise.resolve(); } setStatus("", "Loading the search index…"); loading = fetch(list.getAttribute("data-index")) .then( function (res) { if (!res.ok) { throw new Error("index"); } return res.json().catch(function () { throw new Error("index"); }); }, function () { throw new Error("server"); } ) .then(function (data) { items = buildItems(data); loadError = ""; }) .catch(function (err) { loadError = err && err.message === "server" ? "server" : "index"; }) .then(function () { loading = null; update(); }); return loading; } // buildItems flattens the index: one item per page (title hit) and one // per section entry (heading and text hits). function buildItems(data) { if (!data || !Array.isArray(data.p) || !Array.isArray(data.e)) { throw new Error("index"); } var out = []; data.p.forEach(function (page) { out.push({ url: page.u, title: page.t, section: page.s, heading: page.t, text: "", pageHit: true }); }); data.e.forEach(function (entry) { var page = data.p[entry.p]; if (!page) { return; } out.push({ url: page.u + (entry.a ? "#" + entry.a : ""), title: page.t, section: page.s, heading: entry.h || page.t, text: entry.x || "", pageHit: false }); }); return out; } function tokenize(q) { return q.toLowerCase().split(/\s+/).filter(Boolean); } // search ranks with AND-token matching: a title hit outranks a heading // hit, which outranks a text hit. function search(tokens) { var scored = []; for (var i = 0; i < items.length; i++) { var it = items[i]; var title = it.title.toLowerCase(); var heading = it.pageHit ? "" : it.heading.toLowerCase(); var text = it.text.toLowerCase(); var score = 0; var all = true; for (var j = 0; j < tokens.length; j++) { var t = tokens[j]; var s = 0; if (title.indexOf(t) >= 0) { s += 100; } if (heading.indexOf(t) >= 0) { s += 10; } if (text.indexOf(t) >= 0) { s += 1; } if (!s) { all = false; break; } score += s; } if (all) { if (it.pageHit) { score += 5; } scored.push({ item: it, score: score, order: i }); } } scored.sort(function (a, b) { return b.score - a.score || a.order - b.order; }); return scored.slice(0, MAX_RESULTS).map(function (s) { return s.item; }); } // appendMarked appends text with every token occurrence wrapped in a // element. function appendMarked(parent, text, tokens) { var lower = text.toLowerCase(); var pos = 0; while (pos < text.length) { var best = -1; var len = 0; for (var i = 0; i < tokens.length; i++) { var at = lower.indexOf(tokens[i], pos); if (at >= 0 && (best < 0 || at < best || (at === best && tokens[i].length > len))) { best = at; len = tokens[i].length; } } if (best < 0) { parent.appendChild(document.createTextNode(text.slice(pos))); return; } if (best > pos) { parent.appendChild(document.createTextNode(text.slice(pos, best))); } parent.appendChild(el("mark", "", text.slice(best, best + len))); pos = best + len; } } // excerpt starts the text near its first match. function excerpt(text, tokens) { var lower = text.toLowerCase(); var first = -1; tokens.forEach(function (t) { var at = lower.indexOf(t); if (at >= 0 && (first < 0 || at < first)) { first = at; } }); if (first > 60) { var start = text.lastIndexOf(" ", first - 40); return "…" + text.slice(start > 0 ? start + 1 : first - 40); } return text; } function render(tokens) { clearResults(); results = search(tokens); if (!results.length) { setStatus('No results for "' + input.value.trim() + '"', "Check the spelling, or try a shorter term such as a module or command name."); announce("No results"); return; } setStatus("", ""); results.forEach(function (it, i) { var li = el("li", "search-result"); li.id = "search-result-" + i; li.setAttribute("role", "option"); li.setAttribute("aria-selected", "false"); li.appendChild(el("div", "search-result-path", it.section + " › " + it.title)); var heading = el("div", "search-result-heading"); appendMarked(heading, it.heading, tokens); li.appendChild(heading); if (it.text) { var ex = el("div", "search-result-excerpt"); appendMarked(ex, excerpt(it.text, tokens), tokens); li.appendChild(ex); } li.addEventListener("click", function () { go(i); }); li.addEventListener("mousemove", function () { setActive(i); }); list.appendChild(li); }); input.setAttribute("aria-expanded", "true"); setActive(0); announce(results.length === 1 ? "1 result" : results.length + " results"); } function setActive(i) { var rows = list.children; if (!rows.length) { return; } if (activeIndex >= 0 && rows[activeIndex]) { rows[activeIndex].setAttribute("aria-selected", "false"); } activeIndex = (i + rows.length) % rows.length; var row = rows[activeIndex]; row.setAttribute("aria-selected", "true"); input.setAttribute("aria-activedescendant", row.id); if (row.scrollIntoView) { row.scrollIntoView({ block: "nearest" }); } } function go(i) { var it = results[i]; if (!it) { return; } dialog.close(); window.location.href = it.url; } function update() { try { window.sessionStorage.setItem(QUERY_KEY, input.value); } catch (e) { // Session-only query is optional. } if (loadError === "server") { clearResults(); setStatus("", "Search needs a web server. Run `summer docs:serve` and open the address it prints."); return; } if (loadError === "index") { clearResults(); setStatus("", "The search index could not be loaded. Reload the page to try again."); return; } if (!items) { clearResults(); setStatus("", "Loading the search index…"); return; } var tokens = tokenize(input.value); if (!tokens.length) { clearResults(); setStatus("Search the documentation", "Type a module name, a command or a topic, for example `surf`, `migrate` or `relations`."); announce(""); return; } render(tokens); } function open() { if (dialog.open) { return; } dialog.showModal(); input.focus(); input.select(); if (!items && !loadError) { load(); } update(); } if (trigger) { trigger.addEventListener("click", open); } dialog.addEventListener("close", function () { if (trigger) { trigger.focus(); } }); // A click on the backdrop (outside the dialog box) closes the dialog. dialog.addEventListener("click", function (e) { if (e.target === dialog) { dialog.close(); } }); input.addEventListener("input", update); input.addEventListener("keydown", function (e) { if (e.key === "ArrowDown") { e.preventDefault(); setActive(activeIndex + 1); } else if (e.key === "ArrowUp") { e.preventDefault(); setActive(activeIndex - 1); } else if (e.key === "Enter") { e.preventDefault(); go(activeIndex); } }); function inTextField(target) { if (!target) { return false; } var tag = target.tagName; return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable; } document.addEventListener("keydown", function (e) { if ((e.metaKey || e.ctrlKey) && !e.altKey && (e.key === "k" || e.key === "K")) { e.preventDefault(); open(); } else if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey && !inTextField(e.target) && !dialog.open) { e.preventDefault(); open(); } }); })();