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