goframework-io-www

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