goframework-io-www

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