goframework-io-www

860 lines · 46.5 KiB Raw
1/* goframework.io: the page. Two panes: on the left the groups of modules,
2 by family (app*, callstack, codecs*, config*...); on the right the one
3 chosen, in tabs: an overview (real code and what it printed, how its
4 modules fit, where it sits in the framework), its modules (each with
5 what it was checked against), how it compares with the competition,
6 its measurements against other libraries, examples, recordings.
7 Routes live in the address: #/, #/g/<group>[/<tab>], #/m/<module>,
8 #/performance, #/search/<words>. Everything shown comes from
9 data/site.js, which tools/www-site.py writes from the workspace. */
10(function () {
11 "use strict";
12 const { el, svg, store, copyButton, codeBlock } = window.GF;
13 const site = window.GOFRAMEWORK;
14 const flows = Object.fromEntries((window.GOFRAMEWORK_FLOWS || []).map((flow) => [flow.id, flow]));
15 const view = document.getElementById("view");
16 const side = document.getElementById("side");
17 const SYSTEMS = { linux: "Linux", macos: "macOS", windows: "Windows", freebsd: "FreeBSD", openbsd: "OpenBSD", netbsd: "NetBSD" };
18 const OS_TAGS = new Set(["Linux", "macOS", "Windows", "FreeBSD", "OpenBSD", "NetBSD", "BSDs"]);
19 const ROLE = { contract: "contract", picks: "picks per system", command: "command", examples: "examples" };
20 const groupsById = Object.fromEntries(site.groups.map((group) => [group.id, group]));
21 const moduleNames = Object.keys(site.modules);
22 // T is the page's text, from tools/www/text.toml.
23 const T = site.text;
24 const pick = (pair, count) => pair[count === 1 ? 0 : 1];
25 const counted = (pair, count) => `${count} ${pick(pair, count)}`;
26 // fill puts values into a text's {names}: text or elements; the rest may
27 // hold **bold**, `code` and links.
28 function fill(template, values = {}) {
29 return template.split(/(\{\w+\})/).flatMap((part) => {
30 const name = part.match(/^\{(\w+)\}$/);
31 if (name && name[1] in values) return [values[name[1]]];
32 return part ? inline(part) : [];
33 });
34 }
35 const fillText = (template, values = {}) => template.replace(/\{(\w+)\}/g, (all, name) => (name in values ? String(values[name]) : all));
36 // paragraphs: a text's blank lines start new ones.
37 const paragraphs = (text) => text.trim().split(/\n\s*\n/).map((part) => el("p", {}, fill(part.replace(/\s*\n\s*/g, " "))));
38 const measureCount = moduleNames.reduce((n, name) => n + site.modules[name].measures.length, 0);
39
40 // show replaces the view's content; null parts are left out.
41 function show(...items) {
42 view.replaceChildren(...items.flat(Infinity).filter((item) => item !== null && item !== undefined && item !== false), footer());
43 }
44
45 function footer() {
46 return el("footer", { class: "bottom" },
47 el("span", { class: "terms" }, fill(T.site.footer)),
48 el("span", { class: "powered" }, T.site.powered_by_label, " ", T.site.powered_by.map((item, at) => [at ? " · " : "", fill(item)])));
49 }
50
51 // ---- the header's links ------------------------------------------------------
52
53 const ICONS = {
54 // an issue: a bug
55 issues: () => svg("svg", { viewBox: "0 0 16 16", width: 18, height: 18, "aria-hidden": "true", fill: "none", stroke: "currentColor", "stroke-width": 1.3, "stroke-linecap": "round", "stroke-linejoin": "round" },
56 svg("path", { d: "M5.5 6.2a2.5 2.5 0 0 1 5 0" }),
57 svg("rect", { x: 5, y: 6.2, width: 6, height: 7.3, rx: 3 }),
58 svg("path", { d: "M8 8.2v5.2M6.6 4.2 5.4 2.6M9.4 4.2l1.2-1.6M5 8.6H2.5M11 8.6h2.5M5.1 11.2 3 12.6M10.9 11.2l2.1 1.4M5.2 7 3.4 5.6M10.8 7l1.8-1.4" })),
59 // source code: </>
60 source: () => svg("svg", { viewBox: "0 0 16 16", width: 18, height: 18, "aria-hidden": "true", fill: "none", stroke: "currentColor", "stroke-width": 1.4, "stroke-linecap": "round", "stroke-linejoin": "round" },
61 svg("path", { d: "M5 4.5 1.5 8 5 11.5M11 4.5 14.5 8 11 11.5M9.2 3 6.8 13" })),
62 // a discussion: two speech bubbles
63 discussions: () => svg("svg", { viewBox: "0 0 16 16", width: 18, height: 18, "aria-hidden": "true" },
64 svg("path", { d: "M2.5 2.5h7a1.5 1.5 0 0 1 1.5 1.5v4a1.5 1.5 0 0 1-1.5 1.5H6L3.5 11.5V9.5h-1A1.5 1.5 0 0 1 1 8V4a1.5 1.5 0 0 1 1.5-1.5z", fill: "none", stroke: "currentColor", "stroke-width": 1.3, "stroke-linejoin": "round" }),
65 svg("path", { d: "M13 6h.5A1.5 1.5 0 0 1 15 7.5v4a1.5 1.5 0 0 1-1.5 1.5h-.5v2l-2.5-2H8a1.5 1.5 0 0 1-1.5-1.5V11", fill: "none", stroke: "currentColor", "stroke-width": 1.3, "stroke-linejoin": "round" })),
66 };
67 document.getElementById("header-links").replaceChildren(...["source", "issues", "discussions"].map((name) =>
68 el("a", { class: "icon-button", href: T.header[`${name}_link`], target: "_blank", rel: "noopener", title: T.header[name], "aria-label": T.header[name] }, ICONS[name]())));
69
70 // ---- theme ---------------------------------------------------------------
71
72 const themeButton = document.getElementById("theme");
73 const themes = ["system", "light", "dark"];
74 let theme = store.get("theme") || "system";
75 function applyTheme() {
76 if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme;
77 else delete document.documentElement.dataset.theme;
78 themeButton.textContent = { system: "◐", light: "☀", dark: "☾" }[theme];
79 themeButton.title = `Theme: ${theme}`;
80 for (const frame of document.querySelectorAll("iframe.source-frame")) frame.src = embedURL(JSON.parse(frame.dataset.embed));
81 }
82 applyTheme();
83 themeButton.addEventListener("click", () => {
84 theme = themes[(themes.indexOf(theme) + 1) % themes.length];
85 store.set("theme", theme === "system" ? null : theme);
86 applyTheme();
87 });
88
89 // ---- small parts ----------------------------------------------------------
90
91 function maturity(module) {
92 return module.maturity === "new"
93 ? el("span", { class: "badge new", title: `${T.module.new} ${module.newBecause}` }, T.module.badge_new)
94 : el("span", { class: "badge verified", title: T.module.verified_means }, T.module.badge_verified);
95 }
96
97 function tags(module) {
98 const out = [];
99 for (const tag of module.tags) {
100 if (ROLE[tag]) out.push(el("span", { class: "tag role" }, ROLE[tag]));
101 else if (OS_TAGS.has(tag)) out.push(el("span", { class: "tag os", title: `Only on ${tag}` }, tag));
102 else if (tag === "weak" || tag === "broken") out.push(el("span", { class: "tag warn" }, tag));
103 }
104 for (const third of module.thirdParty) out.push(el("span", { class: "tag third", title: "Third-party code" }, third.split(" ")[0].replace(/^github\.com\//, "")));
105 return out;
106 }
107
108 function card(title, ...children) {
109 return el("section", { class: "card" }, title ? el("h3", { class: "card-title" }, title) : null, ...children);
110 }
111
112 function sectionTitle(text, note) {
113 return el("div", { class: "section-title" }, el("h2", {}, fill(text)), note ? el("p", {}, fill(note)) : null);
114 }
115
116 function tabbed(items, kind) {
117 const pane = el("div", { class: "tab-pane" });
118 const bar = el("div", { class: `tabs ${kind}`, role: "tablist" });
119 items.forEach((item, at) => {
120 const button = el("button", { type: "button", role: "tab", "aria-selected": String(at === 0) }, item.label);
121 button.addEventListener("click", () => {
122 for (const other of bar.children) other.setAttribute("aria-selected", "false");
123 button.setAttribute("aria-selected", "true");
124 pane.replaceChildren(item.content());
125 });
126 bar.append(button);
127 });
128 pane.append(items[0].content());
129 return el("div", {}, items.length > 1 ? bar : null, pane);
130 }
131
132 // pills is a row of buttons choosing one value; choose gets the value.
133 function pills(label, values, current, name, choose) {
134 if (values.length < 2) return null;
135 return el("div", { class: "pills", role: "tablist" }, label ? el("span", { class: "choose-label" }, label) : null,
136 values.map((value) => el("button", { type: "button", role: "tab", "aria-selected": String(value === current), onclick: () => choose(value) }, name(value))));
137 }
138
139 // inline turns a line of markdown into nodes: `code`, **bold**, links;
140 // links to the workspace's own files become plain text.
141 function inline(text) {
142 const out = [];
143 const pattern = /`([^`]+)`|\*\*(.+?)\*\*|\[([^\]]+)\]\(([^)]+)\)/g;
144 let at = 0;
145 for (const match of text.matchAll(pattern)) {
146 if (match.index > at) out.push(text.slice(at, match.index));
147 if (match[1] !== undefined) out.push(el("code", {}, match[1]));
148 else if (match[2] !== undefined) out.push(el("b", {}, inline(match[2])));
149 else if (/^https?:\/\//.test(match[4])) out.push(el("a", { href: match[4], rel: "noopener" }, match[3]));
150 else out.push(match[3]);
151 at = match.index + match[0].length;
152 }
153 if (at < text.length) out.push(text.slice(at));
154 return out;
155 }
156
157 function markdown(blocks) {
158 const list = (block) => el(block.ordered ? "ol" : "ul", {}, block.items.map((item) =>
159 el("li", {}, inline(item.text), item.items.length ? list({ ordered: false, items: item.items }) : null)));
160 return el("div", { class: "prose" }, blocks.map((block) => {
161 if (block.t === "h") return el(block.level <= 2 ? "h4" : "h5", {}, inline(block.text));
162 if (block.t === "p") return el("p", {}, inline(block.text));
163 if (block.t === "list") return list(block);
164 if (block.t === "code") return codeBlock(block.text);
165 if (block.t === "table") return el("div", { class: "table-wrap" }, el("table", { class: "matrix" },
166 el("thead", {}, el("tr", {}, block.header.map((cell) => el("th", {}, inline(cell))))),
167 el("tbody", {}, block.rows.map((row) => el("tr", {}, row.map((cell, at) => el(at ? "td" : "th", {}, inline(cell))))))));
168 return null;
169 }));
170 }
171
172 function outputBlock(text) {
173 // "=== name" lines split the output into sections to choose from;
174 // "### " lines are headings inside it.
175 const lines = text.replace(/\n$/, "").split("\n");
176 const sections = [];
177 const preamble = [];
178 let current = null;
179 for (const line of lines) {
180 if (line.startsWith("=== ")) {
181 current = { name: line.slice(4), lines: [] };
182 sections.push(current);
183 } else if (current) current.lines.push(line);
184 else preamble.push(line);
185 }
186 const render = (body) => el("pre", { class: "output" }, body.map((line, at) => {
187 const node = line.startsWith("### ") ? el("b", { class: "out-head" }, line.slice(4)) : line;
188 return at ? ["\n", node] : [node];
189 }));
190 if (sections.length < 2) return render(lines);
191 const pane = el("div");
192 let chosen = sections[0];
193 const row = el("div");
194 const draw = () => {
195 row.replaceChildren(pills(T.example.choose, sections, chosen, (section) => section.name, (section) => { chosen = section; draw(); }));
196 pane.replaceChildren(render(chosen.lines));
197 };
198 draw();
199 return el("div", {}, preamble.some((line) => line.trim()) ? render(preamble) : null, row, pane);
200 }
201
202 // ---- the blocks a showcase is made of -------------------------------------
203
204 function exampleBlock(block) {
205 const example = block.example;
206 const usual = example.parts.find((part) => part.name === "usual");
207 const withPart = example.parts.find((part) => part.name === "with");
208 const others = example.parts.filter((part) => part !== usual && part !== withPart);
209 let code;
210 if (usual && withPart) {
211 code = el("div", { class: "side-by-side" },
212 el("div", { class: "code-pane usual" }, el("div", { class: "pane-label" }, T.example.usual, " ", el("b", {}, usual.label)), codeBlock(usual.code)),
213 el("div", { class: "code-pane with" }, el("div", { class: "pane-label" }, T.example.with, " ", el("b", {}, withPart.label)), codeBlock(withPart.code)));
214 } else {
215 const parts = withPart ? [withPart, ...others] : others;
216 code = tabbed(parts.map((part) => ({ label: part.label, content: () => codeBlock(part.code) })), "code-tabs");
217 }
218 const runs = example.runs.map((run) => ({
219 label: run.name === run.system ? SYSTEMS[run.system] || run.name : run.name,
220 content: () => el("div", {}, outputBlock(run.output),
221 el("p", { class: "run-meta" }, fillText(T.example.ran, { system: SYSTEMS[run.system] || run.system, go: run.go, date: run.date }), " ", el("code", {}, run.command), run.exit ? ` (exit ${run.exit})` : "")),
222 }));
223 const section = card(example.title,
224 el("p", { class: "problem" }, example.problem),
225 code,
226 el("h4", { class: "output-title" }, example.runs.length > 1 ? T.example.printed_each : T.example.printed),
227 runs.length > 1 ? tabbed(runs, "run-tabs") : runs[0].content(),
228 example.notice ? el("p", { class: "notice" }, example.notice) : null,
229 el("p", { class: "source" }, T.example.file, " ", el("code", {}, example.file)));
230 section.id = `ex-${example.id.replace("/", "-")}`;
231 return section;
232 }
233
234 function tableBlock(block) {
235 return card(block.title,
236 el("div", { class: "table-wrap" }, el("table", { class: "matrix" },
237 el("thead", {}, el("tr", {}, block.header.map((cell) => el("th", {}, cell)))),
238 el("tbody", {}, block.rows.map((row) => el("tr", {}, row.map((cell, at) => {
239 const mark = cell === "yes" ? "yes" : cell === "–" || cell === "-" || cell === "no" ? "no" : "";
240 return el(at === 0 ? "th" : "td", { class: mark ? `cell-${mark}` : "" }, cell);
241 })))))),
242 block.caption ? el("p", { class: "caption" }, block.caption) : null,
243 el("p", { class: "source" }, T.measure.from, " ", el("code", {}, block.source)));
244 }
245
246 function factsBlock(block) {
247 return el("div", { class: "facts" }, block.items.map((item) => el("div", { class: "fact" },
248 el("b", {}, item.value), el("span", {}, item.label), item.source ? el("small", {}, item.source) : null)));
249 }
250
251 function stagesBlock(block) {
252 const panel = el("div", { class: "stage-panel" });
253 const row = el("div", { class: "stages", role: "tablist" });
254 const choose = (at) => {
255 Array.from(row.querySelectorAll(".stage-button")).forEach((button, index) => button.setAttribute("aria-selected", String(index === at)));
256 const stage = block.stages[at];
257 panel.replaceChildren(el("h4", {}, stage.label), el("p", {}, stage.text),
258 stage.modules && stage.modules.length ? el("div", { class: "chips" }, stage.modules.map((name) => el("a", { class: "chip", href: `#/m/${name}` }, name))) : null);
259 };
260 block.stages.forEach((stage, at) => {
261 if (at) row.append(el("span", { class: "stage-arrow", "aria-hidden": "true" }, "→"));
262 row.append(el("button", { type: "button", role: "tab", class: "stage-button", onclick: () => choose(at) },
263 el("b", {}, stage.label), stage.modules ? el("small", {}, counted(T.group.stats.modules, stage.modules.length)) : null));
264 });
265 choose(0);
266 return card(block.title, block.intro ? el("p", { class: "problem" }, block.intro) : null, row, panel);
267 }
268
269 function flowBlock(block) {
270 const flow = flows[block.id];
271 if (!flow) return null;
272 const holder = el("div", { class: "flow-holder" });
273 const section = card(block.title || flow.title, el("p", { class: "flow-intro" }, fill(T.group.recorded.intro)), holder);
274 requestAnimationFrame(() => new window.GF.FlowPlayer(holder, flow, site));
275 return section;
276 }
277
278 function textBlock(block) {
279 return card(block.title, el("p", {}, block.body));
280 }
281
282 function renderBlock(block) {
283 const kinds = { example: exampleBlock, table: tableBlock, facts: factsBlock, stages: stagesBlock, flow: flowBlock, text: textBlock };
284 try {
285 return kinds[block.type] ? kinds[block.type](block) : null;
286 } catch (error) {
287 console.error("block", block, error);
288 return null;
289 }
290 }
291
292 // ---- measurements -------------------------------------------------------------
293
294 const formatNumber = (value) => value.toLocaleString("en-US", { maximumFractionDigits: value < 10 ? 2 : 1 });
295 const samePath = (path, prefix) => prefix.every((part, at) => path[at] === part);
296 const unique = (values) => [...new Set(values)];
297
298 // explorer shows one measurement: pick the scenario (one row of choices
299 // per part of its name), the CPUs and what is measured; the bars are the
300 // libraries, lowest first, goframework's highlighted.
301 function explorer(measure) {
302 const ours = new Set(measure.ours);
303 const records = measure.records;
304 const name = (library) => measure.names[library] || library;
305 const label = (part) => measure.labels[part] || part;
306 const measured = (record) => record.values.some((value) => value !== null);
307 let path = (measure.default || []).filter(Boolean);
308 let cpu = "";
309 let metric = measure.metric;
310 const holder = el("div", { class: "explorer" });
311
312 // settle makes the choices name data that exists, keeping what it can.
313 function settle() {
314 const fixed = [];
315 for (let level = 0; ; level++) {
316 const options = unique(records.filter((r) => r.path.length > level && samePath(r.path, fixed) && measured(r)).map((r) => r.path[level]));
317 if (!options.length) break;
318 fixed.push(options.includes(path[level]) ? path[level] : options[0]);
319 }
320 path = fixed;
321 const here = records.filter((r) => r.path.length === path.length && samePath(r.path, path));
322 const cpus = unique(here.map((r) => r.cpu).filter(Boolean)).sort((a, b) => a - b);
323 if (!cpus.includes(cpu)) cpu = cpus.length ? cpus[cpus.length - 1] : "";
324 const shown = here.filter((r) => !cpu || r.cpu === cpu);
325 const metrics = measure.metrics.map((_, at) => at).filter((at) => shown.some((r) => r.values[at] !== null));
326 if (!metrics.includes(metric)) metric = metrics.includes(measure.metric) ? measure.metric : metrics[0] || 0;
327 return { here, cpus, shown, metrics };
328 }
329
330 function draw() {
331 const { here, cpus, shown, metrics } = settle();
332 const rows = [];
333 for (let level = 0; level < path.length; level++) {
334 const options = unique(records.filter((r) => r.path.length > level && samePath(r.path, path.slice(0, level)) && measured(r)).map((r) => r.path[level]));
335 rows.push(pills("", options, path[level], label, (value) => { path = [...path.slice(0, level), value]; draw(); }));
336 }
337 rows.push(pills(T.measure.goroutines_on, cpus, cpu, (value) => counted(T.measure.cpus, Number(value)), (value) => { cpu = value; draw(); }));
338 rows.push(pills(T.measure.measured, metrics, metric, (at) => measure.metrics[at], (value) => { metric = value; draw(); }));
339 const bars = shown.filter((r) => r.values[metric] !== null).sort((a, b) => a.values[metric] - b.values[metric]);
340 const missing = shown.filter((r) => r.values[metric] === null);
341 const values = bars.map((r) => r.values[metric]);
342 const max = Math.max(...values);
343 const min = Math.min(...values.filter((v) => v > 0));
344 const log = values.every((v) => v > 0) && max / min > 60;
345 const scale = (v) => (log ? (Math.log10(v / min) + 0.15) / (Math.log10(max / min) + 0.15) : max ? v / max : 0);
346 const display = (r) => (r.shown ? r.shown[metric] : formatNumber(r.values[metric]));
347 holder.replaceChildren(
348 el("div", { class: "choices" }, rows),
349 el("div", { class: "bars" }, bars.map((r) => el("div", { class: "bar-row" + (ours.has(r.name) ? " ours" : "") },
350 el("span", { class: "bar-name" }, name(r.name)),
351 el("span", { class: "bar-track" }, el("span", { class: "bar", style: `width:${Math.max(scale(r.values[metric]) * 100, 0.8)}%` })),
352 el("span", { class: "bar-value" }, display(r)))),
353 missing.map((r) => el("div", { class: "bar-row missing" }, el("span", { class: "bar-name" }, name(r.name)), el("span", { class: "bar-text" }, r.shown ? r.shown[metric] : T.measure.not_measured)))),
354 el("p", { class: "bar-note" }, `${measure.metrics[metric]}; ${T.measure.lower}${log ? "; " + fillText(T.measure.log_scale, { span: Math.round(max / min) }) : ""}.${ours.size ? " " + T.measure.highlighted : ""}`),
355 numbers(here, cpus.length > 0));
356 }
357
358 // numbers is every number of the scenario chosen, or, for a table
359 // from a readme, the whole table.
360 function numbers(here, byCpu) {
361 let header, body;
362 if (records.some((r) => r.shown) && records.every((r) => r.path.length <= 1)) {
363 const scenarios = unique(records.map((r) => r.path[0]).filter((part) => part !== undefined));
364 const libraries = unique(records.map((r) => r.name));
365 if (scenarios.length) {
366 header = ["", ...libraries.map(name)];
367 body = scenarios.map((scenario) => [scenario, ...libraries.map((library) => {
368 const record = records.find((r) => r.path[0] === scenario && r.name === library);
369 return record ? record.shown[0] : "";
370 })]);
371 } else {
372 header = ["", measure.metrics[0]];
373 body = records.map((r) => [name(r.name), r.shown[0]]);
374 }
375 } else {
376 header = ["", ...(byCpu ? ["CPUs"] : []), ...measure.metrics];
377 body = here.map((r) => [name(r.name), ...(byCpu ? [r.cpu] : []), ...r.values.map((v) => (v === null ? "–" : formatNumber(v)))]);
378 }
379 return el("details", { class: "numbers" }, el("summary", {}, T.measure.every_number),
380 el("div", { class: "table-wrap" }, el("table", {}, el("thead", {}, el("tr", {}, header.map((cell) => el("th", {}, cell)))),
381 el("tbody", {}, body.map((row) => el("tr", {}, row.map((cell, at) => el(at ? "td" : "th", {}, cell))))))));
382 }
383
384 draw();
385 return holder;
386 }
387
388 function measureTable(measure) {
389 const rename = (cell) => {
390 for (const [from, to] of Object.entries(measure.names)) if (cell.startsWith(from)) return to + cell.slice(from.length);
391 return cell;
392 };
393 const ours = (row) => measure.ours.some((name) => row[0].startsWith(name));
394 return el("div", { class: "table-wrap" }, el("table", { class: "matrix" },
395 el("thead", {}, el("tr", {}, measure.header.map((cell) => el("th", {}, cell)))),
396 el("tbody", {}, measure.rows.map((row) => el("tr", { class: ours(row) ? "ours" : "" }, row.map((cell, at) => el(at ? "td" : "th", {}, at ? cell : rename(cell))))))));
397 }
398
399 function measurement(measure) {
400 const how = [measure.measured, measure.how].filter(Boolean).join(". ");
401 return el("div", { class: "measurement" },
402 el("p", { class: "against" }, fill(T.measure.against, { against: measure.against })),
403 measure.kind === "table" ? measureTable(measure) : explorer(measure),
404 measure.legend ? el("p", { class: "caption" }, measure.legend) : null,
405 how ? el("p", { class: "measured-how" }, how) : null,
406 el("p", { class: "source" }, T.measure.from, " ", el("code", {}, measure.source),
407 measure.reproduce ? [". ", T.measure.reproduce, " ", el("code", {}, measure.reproduce), " ", copyButton(measure.reproduce, "the command")] : null));
408 }
409
410 function measuredModule(name) {
411 const list = site.modules[name].measures;
412 return el("section", { class: "card measured-module", id: `measured-${name}` },
413 el("h3", { class: "card-title" }, el("a", { class: "module-link", href: `#/m/${name}` }, name), el("span", { class: "card-sub" }, site.modules[name].line)),
414 tabbed(list.map((measure) => ({ label: measure.title, content: () => measurement(measure) })), "measure-tabs"));
415 }
416
417 // ---- what a module was checked against -----------------------------------------
418
419 const KINDS = {
420 "test suite": "suite", reference: "reference", "real servers": "servers", systems: "systems",
421 "other libraries": "libraries", measured: "measured", fuzzed: "fuzzed", model: "model", tests: "tests",
422 };
423
424 function checkedList(facts, withModule) {
425 return el("ul", { class: "checked" }, facts.map((fact) => el("li", {},
426 el("span", { class: `kind kind-${KINDS[fact.kind] || "tests"}` }, fact.kind),
427 el("div", { class: "fact-body" },
428 withModule ? el("a", { class: "fact-module", href: `#/m/${fact.module}` }, fact.module) : null,
429 el("b", {}, fact.against), fact.result ? [": ", fact.result] : null,
430 el("details", { class: "quote" }, el("summary", {}, fact.source), el("blockquote", {}, "“", fact.quote, "”"))))));
431 }
432
433 // ---- modules ----------------------------------------------------------------
434
435 function moduleCard(name, open) {
436 const module = site.modules[name];
437 const body = el("div", { class: "module-body" });
438 const details = el("details", { class: "module", id: `m-${name}`, open: open || undefined },
439 el("summary", {}, el("span", { class: "name" }, name), el("span", { class: "line" }, module.line),
440 el("span", { class: "tags" },
441 module.measures.length ? el("span", { class: "tag measured", title: T.module.measured }, T.module.badge_measured) : null,
442 module.checked.length ? el("span", { class: "tag checks", title: T.module.checked }, counted(T.module.badge_checks, module.checked.length)) : null,
443 maturity(module), tags(module))),
444 body);
445 const fillBody = () => {
446 if (body.firstChild) return;
447 const link = (other) => el("a", { class: "mini", href: `#/m/${other}` }, other);
448 const column = (title, items) => el("div", { class: "col" }, el("h5", {}, title),
449 items.length ? items.slice(0, 8).map(link) : el("span", { class: "mini none" }, T.module.none),
450 items.length > 8 ? el("span", { class: "more" }, `and ${items.length - 8} more`) : null);
451 const about = () => el("div", { class: "module-about" }, [
452 el("p", { class: "module-summary" }, fill(module.summary)),
453 el("div", { class: "module-grid" + (module.snippet && module.checked.length ? "" : " single") },
454 module.snippet ? el("div", { class: "snippet" }, el("div", { class: "pane-label" }, T.module.from_readme), codeBlock(module.snippet)) : null,
455 module.checked.length ? el("div", { class: "checked-box" }, el("h5", {}, T.module.checked), checkedList(module.checked)) : null),
456 module.measures.length ? el("div", { class: "module-measures" }, el("h5", {}, T.module.measured),
457 tabbed(module.measures.map((measure) => ({ label: measure.title, content: () => measurement(measure) })), "measure-tabs")) : null,
458 module.replaces ? el("p", { class: "replaces" }, el("b", {}, T.module.replaces), " ", fill(module.replaces)) : null,
459 module.notWhen ? el("p", { class: "replaces" }, el("b", {}, T.module.not_when), " ", fill(module.notWhen)) : null,
460 module.maturity === "new" ? el("p", { class: "replaces" }, el("b", {}, T.module.new), " ", fill(module.newBecause)) : null,
461 el("div", { class: "deps" }, column(T.module.uses, module.uses), el("div", { class: "col" }, el("h5", {}, T.module.this), el("span", { class: "mini center" }, name)), column(T.module.used_by, module.usedBy)),
462 module.thirdParty.length ? el("p", { class: "replaces" }, el("b", {}, T.module.third_party), " ", module.thirdParty.join(", ")) : null,
463 el("div", { class: "get" }, el("code", {}, `go get goframework.io/${name}`), el("span", { class: "muted", style: "font-size:13px" }, T.module.get_note)),
464 ].flat(Infinity).filter(Boolean));
465 const source = () => {
466 const pane = el("div", { class: "module-source" }, sourceWidget([name], "list", `${name}: ${T.module.source}`, fillText(T.module.source_none, { module: name })));
467 // the link to its files, once the viewer shows it
468 published([name]).then((repos) => {
469 if (!repos || repos.length) pane.append(el("a", { class: "browse", href: `${T.site.source_viewer.replace(/\/$/, "")}/${name}`, target: "_blank", rel: "noopener" }, fillText(T.module.browse, { module: name }), " ↗"));
470 });
471 return pane;
472 };
473 window.GF.append(body, [tabbed([{ label: T.module.about, content: about }, { label: T.module.source, content: source }], "module-tabs")]);
474 };
475 details.addEventListener("toggle", () => { if (details.open) fillBody(); });
476 if (open) fillBody();
477 return details;
478 }
479
480 // ---- the left pane ----------------------------------------------------------
481
482 const shortName = (group, name) => (name === group.id ? name : name.slice(group.id.length + 1));
483
484 // The left pane: its links, the filter, and the groups, drawn again for
485 // each page and at each change of the filter. Filtering shows the groups
486 // that match and, under each, its modules that do.
487 const sideLinks = el("div", { class: "side-links" });
488 const filter = el("input", { class: "side-filter", id: "filter", type: "search", placeholder: T.side.filter, autocomplete: "off", spellcheck: "false", "aria-label": T.side.filter });
489 const sideList = el("div", { class: "side-list" });
490 side.replaceChildren(el("nav", { class: "side-nav" }, sideLinks,
491 el("div", { class: "side-heading" }, fillText(T.side.heading, { groups: site.groups.length, modules: moduleNames.length })),
492 el("div", { class: "side-search" }, filter, el("kbd", { "aria-hidden": "true" }, "/")),
493 sideList));
494 let where = { kind: "home" };
495
496 function renderSide(kind, groupId, moduleName) {
497 where = { kind, groupId, moduleName };
498 sideLinks.replaceChildren(
499 el("a", { class: "side-link" + (kind === "home" ? " current" : ""), href: "#/" }, T.side.introduction),
500 el("a", { class: "side-link" + (kind === "performance" ? " current" : ""), href: "#/performance" }, T.side.performance, el("span", { class: "side-count" }, String(measureCount))));
501 drawGroups();
502 // keep the current entry in view, scrolling the pane alone
503 const current = side.querySelector(".side-module.current") || side.querySelector(".side-group.current, .side-link.current");
504 if (current) {
505 const top = current.offsetTop, bottom = top + current.offsetHeight;
506 if (top < side.scrollTop || bottom > side.scrollTop + side.clientHeight) side.scrollTop = Math.max(0, top - 120);
507 }
508 }
509
510 function drawGroups() {
511 const { groupId, moduleName } = where;
512 const query = filter.value.trim();
513 const found = query ? search(query) : null;
514 const matched = new Set(found ? found.modules : []);
515 const shown = found ? site.groups.filter((group) => found.groups.includes(group) || group.modules.some((name) => matched.has(name))) : site.groups;
516 sideList.replaceChildren(...[
517 shown.map((group) => {
518 const names = found ? group.modules.filter((name) => matched.has(name)) : group.id === groupId && group.modules.length > 1 ? group.modules : [];
519 return [
520 el("a", { class: "side-group" + (group.id === groupId ? " current" : ""), href: `#/g/${group.id}`, title: group.purpose },
521 el("code", {}, group.pattern), el("span", { class: "side-count" }, String(group.modules.length)),
522 el("span", { class: "side-title" }, group.title)),
523 names.length ? el("div", { class: "side-modules" }, names.map((name) =>
524 el("a", { class: "side-module" + (name === moduleName ? " current" : ""), href: `#/m/${name}`, title: site.modules[name].line }, shortName(group, name)))) : null,
525 ];
526 }),
527 found && !shown.length ? el("p", { class: "side-empty" }, fillText(T.side.no_match, { query })) : null,
528 found ? el("a", { class: "side-all", href: `#/search/${encodeURIComponent(query)}` }, fillText(T.side.all_results, { query })) : null,
529 ].flat(Infinity).filter(Boolean));
530 }
531 filter.addEventListener("input", drawGroups);
532 filter.addEventListener("keydown", (event) => {
533 if (event.key === "Enter" && filter.value.trim()) location.hash = `#/search/${encodeURIComponent(filter.value.trim())}`;
534 if (event.key === "Escape") { filter.value = ""; drawGroups(); filter.blur(); }
535 });
536
537 const menu = document.getElementById("menu");
538 function openSide(open) {
539 document.body.classList.toggle("side-open", open);
540 menu.setAttribute("aria-expanded", String(open));
541 }
542 menu.addEventListener("click", () => openSide(!document.body.classList.contains("side-open")));
543 side.addEventListener("click", (event) => { if (event.target.closest("a")) openSide(false); });
544 // a click beside the opened pane folds it
545 document.addEventListener("click", (event) => {
546 if (document.body.classList.contains("side-open") && !event.target.closest("#side, #menu")) openSide(false);
547 });
548
549 // ---- a group ------------------------------------------------------------------
550
551 // the tabs, in text.toml's order
552 const TABS = Object.entries(T.group.tabs);
553
554 // ---- the source viewer's widget ------------------------------------------------
555
556 // embedURL is the source viewer's list of repos, the names in order, links
557 // opening in a new tab, in the page's theme when one is chosen.
558 function embedURL({ names, layout }) {
559 const query = new URLSearchParams({ repos: names.join(","), layout, limit: String(Math.min(names.length, 100)), more: "0", target: "blank" });
560 if (theme === "light" || theme === "dark") query.set("theme", theme);
561 return `${T.site.source_viewer.replace(/\/$/, "")}/-/embed?${query}`;
562 }
563
564 // published asks the source viewer which of these repos it shows, with
565 // their descriptions and topics (its JSON is open to other sites); the
566 // answer is kept, and is null when the viewer cannot be reached.
567 const publishedCache = {};
568 function published(names) {
569 const key = names.join(",");
570 if (!publishedCache[key]) {
571 const query = new URLSearchParams({ repos: key, limit: String(Math.min(names.length, 100)) });
572 publishedCache[key] = fetch(`${T.site.source_viewer.replace(/\/$/, "")}/-/api/repos?${query}`)
573 .then((answer) => (answer.ok ? answer.json() : null)).then((data) => (data ? data.repos : null)).catch(() => null);
574 }
575 return publishedCache[key];
576 }
577
578 // sourceWidget frames that list, or says none is published yet. A frame
579 // cannot size itself, so its height is worked out from what it shows:
580 // the cards' widths (200 px or more), each name and description wrapped
581 // as the viewer's styles wrap them, a row of topics when there are any.
582 function sourceWidget(names, layout, title, none) {
583 const settings = { names, layout };
584 const holder = el("div", { class: "source-widget" });
585 published(names).then((repos) => {
586 if (repos && !repos.length) {
587 holder.replaceChildren(el("p", { class: "muted source-none" }, fill(none)));
588 return;
589 }
590 const frame = el("iframe", { class: "source-frame", src: embedURL(settings), title, loading: "lazy", dataset: { embed: JSON.stringify(settings) } });
591 const lines = (text, width, perChar) => Math.max(1, Math.ceil((text.length * perChar) / Math.max(width, 40)));
592 const size = () => {
593 const width = (frame.clientWidth || 800) - 4;
594 const shown = repos || names.map((name) => ({ name, description: site.modules[name] ? site.modules[name].line : "", topics: [] }));
595 let height = 4 + 6;
596 if (layout === "grid") {
597 const columns = Math.max(1, Math.floor((width + 8) / 208));
598 const text = (width - (columns - 1) * 8) / columns - 22;
599 const card = (repo) => 18 + lines(repo.name, text, 8.6) * 20 + (repo.description ? lines(repo.description, text, 6.9) * 19 : 0) + (repo.topics && repo.topics.length ? 26 : 0);
600 for (let at = 0; at < shown.length; at += columns) height += Math.max(...shown.slice(at, at + columns).map(card)) + 8;
601 } else {
602 for (const repo of shown) height += 13 + lines(repo.name, width, 8.6) * 20 + (repo.description ? lines(repo.description, width, 6.9) * 19 : 0) + (repo.topics && repo.topics.length ? 26 : 0);
603 }
604 if (frame.style.height !== `${height}px`) frame.style.height = `${height}px`;
605 };
606 holder.replaceChildren(frame);
607 requestAnimationFrame(size);
608 if (window.ResizeObserver) new ResizeObserver(size).observe(frame);
609 });
610 return holder;
611 }
612
613
614 function groupFacts(group) {
615 return group.modules.flatMap((name) => site.modules[name].checked.map((fact) => ({ ...fact, module: name })));
616 }
617
618 function tabCounts(group) {
619 return {
620 modules: group.modules.length,
621 competition: group.competition.length,
622 performance: group.measured.reduce((n, name) => n + site.modules[name].measures.length, 0),
623 examples: group.blocks.filter((b) => b.type === "example").length,
624 recorded: group.blocks.filter((b) => b.type === "flow").length,
625 };
626 }
627
628 // groupHead says what the group is for, atop its summary; the tabs and
629 // the trail in the header bar say where the reader is.
630 function groupHead(group) {
631 return el("header", { class: "g-head" }, el("p", { class: "g-purpose" }, fill(group.purpose)));
632 }
633
634 // crumbs writes the trail in the header bar as an import path is
635 // written: goframework/config* on a group, goframework/config-yaml on an
636 // opened module; each part but the last links back.
637 const crumbsBar = document.getElementById("crumbs");
638 function crumbs(...parts) {
639 document.body.classList.toggle("has-crumbs", parts.length > 0);
640 crumbsBar.replaceChildren(...parts.flatMap((part, at) => {
641 const last = at === parts.length - 1;
642 return [el("span", { class: "crumb-sep", "aria-hidden": "true" }, "/"),
643 last ? el("span", { class: "crumb current", "aria-current": "page" }, part.label) : el("a", { class: "crumb", href: part.href }, part.label)];
644 }));
645 }
646
647
648
649 function groupTabs(group, tab) {
650 const counts = tabCounts(group);
651 return el("nav", { class: "g-tabs", "aria-label": "Sections" }, TABS
652 .filter(([id]) => id !== "recorded" || counts.recorded)
653 .map(([id, label]) => el("a", { href: `#/g/${group.id}/${id}`, class: id === tab ? "current" : "", "aria-current": id === tab ? "page" : undefined },
654 label, counts[id] !== undefined ? el("span", { class: "count" + (counts[id] ? "" : " zero") }, String(counts[id])) : null)));
655 }
656
657 // neighbours shows where a group sits in the framework: the groups it
658 // is built on, and the groups built on it.
659 function neighbours(group) {
660 const inside = new Set(group.modules);
661 const tally = (names) => {
662 const byGroup = {};
663 for (const name of names) if (!inside.has(name)) (byGroup[site.modules[name].group] ||= new Set()).add(name);
664 return Object.entries(byGroup).sort((a, b) => b[1].size - a[1].size);
665 };
666 const on = tally(group.modules.flatMap((name) => site.modules[name].uses));
667 const by = tally(group.modules.flatMap((name) => site.modules[name].usedBy));
668 const chips = (list) => list.length ? el("div", { class: "chips" }, list.map(([id, names]) => el("a", { class: "chip group-chip", href: `#/g/${id}`, title: [...names].join(", ") },
669 el("code", {}, groupsById[id].pattern), el("span", {}, counted(T.group.stats.modules, names.size))))) : el("p", { class: "muted" }, fill(T.group.overview.built_on_none));
670 return card(T.group.overview.where_title,
671 el("div", { class: "neighbours" },
672 el("div", {}, el("h5", {}, T.group.overview.built_on), chips(on)),
673 el("div", { class: "centre" }, el("code", {}, group.pattern)),
674 el("div", {}, el("h5", {}, T.group.overview.built_on_it), by.length ? chips(by) : el("p", { class: "muted" }, fill(T.group.overview.built_on_it_none)))));
675 }
676
677 function overview(group) {
678 const sample = group.sample;
679 const words = T.group.overview;
680 const lead = sample ? el("section", { class: "card in-code" },
681 el("div", { class: "side-by-side code-output" },
682 el("div", { class: "code-pane with" }, el("div", { class: "pane-label" }, words.code_label), codeBlock(sample.code)),
683 el("div", { class: "output-pane" }, el("div", { class: "pane-label dark" }, words.output_label), outputBlock(sample.output)))) : null;
684 return [groupHead(group), lead, neighbours(group), group.blocks.filter((b) => b.type !== "example" && b.type !== "flow").map(renderBlock)];
685 }
686
687 function modulesTab(group, openModule) {
688 return [
689 sectionTitle(T.group.modules.title, T.group.modules.note),
690 el("div", { class: "modules" }, group.modules.map((name) => moduleCard(name, name === openModule)))];
691 }
692
693 function competitionTab(group) {
694 const replaces = group.replaces.length ? card(T.group.competition.replaces, el("div", { class: "chips" }, group.replaces.map((item) => el("span", { class: "chip plain" }, fill(item))))) : null;
695 const sections = group.competition.map((section) => el("section", { class: "card compared" },
696 el("h3", { class: "card-title" }, section.title),
697 el("p", { class: "source" }, T.measure.from, " ", el("code", {}, section.source), " › ", section.heading),
698 markdown(section.blocks)));
699 return [
700 sectionTitle(T.group.competition.title, T.group.competition.note),
701 replaces, sections.length ? sections : el("p", { class: "empty" }, fill(T.group.competition.empty))];
702 }
703
704 function performanceTab(group) {
705 if (group.measured.length) {
706 return [sectionTitle(T.group.performance.title, T.group.performance.note),
707 group.measured.map(measuredModule)];
708 }
709 const facts = groupFacts(group).filter((fact) => ["other libraries", "reference", "test suite", "real servers", "measured"].includes(fact.kind));
710 return [sectionTitle(T.group.performance.title),
711 el("p", { class: "empty" }, fill(T.group.performance.empty)),
712 facts.length ? card(T.group.performance.instead, checkedList(facts, true)) : null];
713 }
714
715 function groupView(id, tab, openModule) {
716 const group = groupsById[id];
717 if (!group) return home();
718 if (!TABS.some(([name]) => name === tab)) tab = "overview";
719 document.title = openModule ? fillText(T.group.module_title, { module: openModule, pattern: group.pattern, title: group.title })
720 : fillText(T.group.page_title, { pattern: group.pattern, title: group.title });
721 const examples = group.blocks.filter((b) => b.type === "example");
722 const body = {
723 overview: () => overview(group),
724 modules: () => modulesTab(group, openModule),
725 competition: () => competitionTab(group),
726 performance: () => performanceTab(group),
727 examples: () => [sectionTitle(T.group.examples.title, T.group.examples.note), examples.map(renderBlock)],
728 source: () => [sectionTitle(T.group.source.title, T.group.source.note), el("div", { class: "card source-card" }, sourceWidget(group.modules, "grid", `${group.pattern}: ${T.group.source.title}`, T.group.source.none))],
729 recorded: () => [sectionTitle(T.group.recorded.title, T.group.recorded.note), group.blocks.filter((b) => b.type === "flow").map(renderBlock)],
730 }[tab]();
731 crumbs({ label: openModule || group.pattern });
732 view.classList.add("flush");
733 show(groupTabs(group, tab), el("div", { class: "g-body" }, body));
734 if (openModule) {
735 const target = document.getElementById(`m-${openModule}`);
736 if (target) requestAnimationFrame(() => target.scrollIntoView({ block: "start" }));
737 }
738 }
739
740 // ---- the other views -------------------------------------------------------------
741
742 function home() {
743 view.classList.remove("flush");
744 crumbs();
745 const words = T.home;
746 document.title = T.site.title;
747 const checks = moduleNames.reduce((n, name) => n + site.modules[name].checked.length, 0);
748 show(
749 el("section", { class: "hero" },
750 el("h1", {}, fill(words.headline)),
751 el("p", { class: "lead" }, fill(words.lead)),
752 el("div", { class: "intro" }, paragraphs(words.intro)),
753 el("div", { class: "runs-on" }, el("span", {}, words.runs_on_label), words.runs_on.map((name) => el("span", { class: "system" }, name)))),
754 el("div", { class: "stats home-stats" },
755 el("a", { class: "stat", href: "#/search/" }, el("b", {}, String(moduleNames.length)), el("span", {}, fill(words.stats.modules, { groups: String(site.groups.length) }))),
756 el("div", { class: "stat" }, el("b", {}, String(checks)), el("span", {}, fill(words.stats.checks))),
757 el("a", { class: "stat", href: "#/performance" }, el("b", {}, String(measureCount)), el("span", {}, fill(words.stats.measurements)))),
758 sectionTitle(words.groups_title),
759 el("div", { class: "group-grid" }, site.groups.map((group) => {
760 const counts = tabCounts(group);
761 return el("a", { class: "group-tile", href: `#/g/${group.id}` },
762 el("div", { class: "tile-head" }, el("code", {}, group.pattern), el("span", { class: "tile-count" }, counted(T.group.stats.modules, group.modules.length))),
763 el("b", {}, group.title),
764 el("p", {}, fill(group.purpose)),
765 el("div", { class: "tile-foot" },
766 counts.examples ? el("span", { class: "has" }, counted(words.tile_examples, counts.examples)) : null));
767 })));
768 }
769
770 function performanceView() {
771 view.classList.remove("flush");
772 crumbs({ label: T.side.performance });
773 const words = T.performance;
774 document.title = words.title;
775 const groups = site.groups.filter((group) => group.measured.length);
776 show(
777 el("header", { class: "g-head" }, el("div", { class: "g-pattern" }, words.badge)),
778 groups.map((group) => [
779 el("h2", { class: "measured-group" }, el("a", { href: `#/g/${group.id}/performance` }, el("code", {}, group.pattern), " ", group.title)),
780 group.measured.map(measuredModule)]));
781 }
782
783 function haystack(name) {
784 const module = site.modules[name];
785 const group = groupsById[module.group];
786 return [name, name.replace(/-/g, " "), module.line, module.keywords, module.summary, group.title, group.pattern, module.replaces].join(" ").toLowerCase();
787 }
788
789 function search(query) {
790 const words = query.toLowerCase().split(/\s+/).filter(Boolean);
791 const modules = moduleNames.filter((name) => words.every((word) => haystack(name).includes(word)));
792 const groups = site.groups.filter((group) => words.every((word) => [group.title, group.pattern, group.purpose, group.replaces.join(" ")].join(" ").toLowerCase().includes(word)));
793 return { modules, groups };
794 }
795
796 function searchView(query) {
797 view.classList.remove("flush");
798 crumbs({ label: query ? T.search.badge : fillText(T.search.every, { count: moduleNames.length }) });
799 const words = T.search;
800 const { modules, groups } = query ? search(query) : { modules: moduleNames, groups: [] };
801 document.title = fillText(words.title, { query: query || fillText(words.every, { count: moduleNames.length }) });
802 show(
803 el("header", { class: "g-head" }, el("div", { class: "g-pattern" }, query ? words.badge : "*"),
804 el("h1", {}, query
805 ? fillText(words.heading, { modules: counted(words.module, modules.length), groups: counted(words.group, groups.length), query })
806 : fillText(words.every, { count: moduleNames.length }))),
807 groups.length ? el("div", { class: "group-grid" }, groups.map((group) => el("a", { class: "group-tile", href: `#/g/${group.id}` },
808 el("div", { class: "tile-head" }, el("code", {}, group.pattern)), el("b", {}, group.title), el("p", {}, fill(group.purpose))))) : null,
809 el("div", { class: "modules" }, modules.map((name) => moduleCard(name))));
810 }
811
812 // ---- routing -------------------------------------------------------------------
813
814 // the groups' earlier names, so links to them keep working
815 const RENAMED = { cli: "cliflags", crashes: "crashproof", concurrency: "flow", collections: "dsa-collections", services: "system-service",
816 processes: "system-process", sysinfo: "system-info", native: "system-native" };
817
818 function applyRoute() {
819 const hash = decodeURIComponent(location.hash.replace(/^#\/?/, ""));
820 const [kind, ...rest] = hash.split("/");
821 if (kind === "g" && RENAMED[rest[0]]) { location.replace(`#/g/${[RENAMED[rest[0]], ...rest.slice(1)].join("/")}`); return; }
822 const argument = rest.join("/");
823 let keepScroll = false;
824 if (kind === "g" && groupsById[rest[0]]) {
825 groupView(rest[0], rest[1] || "overview");
826 renderSide("group", rest[0]);
827 } else if (kind === "m" && site.modules[argument]) {
828 groupView(site.modules[argument].group, "modules", argument);
829 renderSide("group", site.modules[argument].group, argument);
830 keepScroll = true;
831 } else if (kind === "search") {
832 searchView(argument);
833 renderSide("search");
834 } else if (kind === "performance" || kind === "measured") {
835 performanceView();
836 renderSide("performance");
837 } else {
838 home();
839 renderSide("home");
840 }
841 if (!keepScroll) window.scrollTo(0, 0);
842 for (const link of document.querySelectorAll(".top nav a")) link.classList.toggle("current", link.getAttribute("href") === "#/" + kind);
843 }
844 window.addEventListener("hashchange", applyRoute);
845 applyRoute();
846
847 // ---- keys ------------------------------------------------------------------------
848
849 // "/" goes to the filter, opening the left pane where it is folded away;
850 // Escape folds it again.
851 document.addEventListener("keydown", (event) => {
852 const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement.tagName);
853 if (event.key === "/" && !typing) {
854 event.preventDefault();
855 if (window.matchMedia("(max-width: 960px)").matches) openSide(true);
856 filter.focus();
857 }
858 if (event.key === "Escape") openSide(false);
859 });
860})();