From dc948e08252ee8c9866773e904ad56696ae72f23 Mon Sep 17 00:00:00 2001 From: Eric Andrechek Date: Thu, 28 May 2026 15:12:20 -0400 Subject: [PATCH 01/24] prettier mermaid diagrams --- docs/astro.config.mjs | 10 +- docs/package.json | 1 + docs/scripts/mermaid-audit.mjs | 265 +++++++++++++++ docs/scripts/screenshot-diagrams.mjs | 121 +++++++ docs/src/content/docs/architecture.md | 6 +- docs/src/content/docs/why-wavehouse.md | 39 +-- docs/src/integrations/mermaid.mjs | 439 +++++++++++++++++++++++++ docs/src/styles/global.css | 286 ++++++++++++++++ 8 files changed, 1131 insertions(+), 36 deletions(-) create mode 100644 docs/scripts/mermaid-audit.mjs create mode 100644 docs/scripts/screenshot-diagrams.mjs create mode 100644 docs/src/integrations/mermaid.mjs diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 23c34ed4..db80c10d 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -9,6 +9,11 @@ import rehypeMermaid from "rehype-mermaid"; import remarkMath from "remark-math"; import rehypeKatex from "rehype-katex"; import { sidebar } from "./src/config/sidebar.ts"; +import { + fixMermaidOutput, + rehypeMermaidOptions, + remarkInjectMermaidClassdefs, +} from "./src/integrations/mermaid.mjs"; export default defineConfig({ site: "https://wavehouse.dev", @@ -18,8 +23,8 @@ export default defineConfig({ }, markdown: { syntaxHighlight: { excludeLangs: ["mermaid"] }, - remarkPlugins: [remarkMath], - rehypePlugins: [[rehypeMermaid, { strategy: "inline-svg" }], rehypeKatex], + remarkPlugins: [remarkMath, remarkInjectMermaidClassdefs], + rehypePlugins: [[rehypeMermaid, rehypeMermaidOptions], rehypeKatex], }, integrations: [ starlight({ @@ -107,5 +112,6 @@ posthog.init('phc_xFG2NGQa7bFg4QjBp3MAn8kr8bAPJxM7GvKzfoNEwZwj',{api_host:'https }), ], }), + fixMermaidOutput(), ], }); diff --git a/docs/package.json b/docs/package.json index e7567075..44f1552b 100644 --- a/docs/package.json +++ b/docs/package.json @@ -7,6 +7,7 @@ "scripts": { "dev": "astro dev", "start": "astro dev", + "prebuild": "playwright install chromium", "build": "astro build", "preview": "wrangler dev", "astro": "astro" diff --git a/docs/scripts/mermaid-audit.mjs b/docs/scripts/mermaid-audit.mjs new file mode 100644 index 00000000..0bcad871 --- /dev/null +++ b/docs/scripts/mermaid-audit.mjs @@ -0,0 +1,265 @@ +/* Measurement audit of the rendered mermaid SVGs. Loads each diagram into + * headless chromium with the actual site CSS, then probes precise pixel + * positions and color values for: + * - Edge label centers vs edge path midpoints (centering on lines) + * - Cylinder shape labels vs the path's visual body center + * - ClassDef fill colors + white text contrast ratios (WCAG) + * - Subgraph title pill position vs cluster rect border + * + * Reports numbers, not opinions. Run with: node scripts/mermaid-audit.mjs */ + +import { chromium } from "playwright"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +const ROOT = resolve(process.cwd()); +const OUT = resolve(ROOT, "screenshots"); +await mkdir(OUT, { recursive: true }); + +function extractMermaidSvgs(html) { + const svgs = []; + const re = + /]*aria-roledescription="(?:flowchart|sequence|class|state|gantt|pie|er)-[^"]*"[\s\S]*?<\/svg>/g; + let m; + while ((m = re.exec(html))) svgs.push(m[0]); + return svgs; +} + +const sources = [ + { name: "architecture", file: "dist/architecture/index.html" }, + { name: "why-wavehouse", file: "dist/why-wavehouse/index.html" }, +]; + +const all = []; +for (const s of sources) { + const html = await readFile(resolve(ROOT, s.file), "utf8"); + const svgs = extractMermaidSvgs(html); + all.push({ ...s, svgs }); +} + +const siteCss = await readFile(resolve(ROOT, "src/styles/global.css"), "utf8"); +function preparedCss() { + return siteCss + .replace(/@import\s+[^;]+;/g, "") + .replace(/:root\[data-theme="light"\]/g, '.pane[data-theme="light"]') + .replace(/:root\b/g, ".pane"); +} + +function relLum(hex) { + const m = hex.match(/^#?([0-9a-f]{6})$/i) || hex.match(/rgb\(([\d.]+),\s*([\d.]+),\s*([\d.]+)\)/i); + let r, g, b; + if (hex.startsWith("rgb")) { + [, r, g, b] = m.map(Number); + } else { + const h = hex.replace("#", ""); + r = parseInt(h.slice(0, 2), 16); + g = parseInt(h.slice(2, 4), 16); + b = parseInt(h.slice(4, 6), 16); + } + const lin = [r, g, b].map((v) => { + v /= 255; + return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]; +} +function contrast(c1, c2) { + const L1 = relLum(c1); + const L2 = relLum(c2); + const [hi, lo] = L1 > L2 ? [L1, L2] : [L2, L1]; + return (hi + 0.05) / (lo + 0.05); +} +function rgbToHex(rgb) { + const m = rgb.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)/); + if (!m) return rgb; + const [, r, g, b] = m.map(Number); + return ( + "#" + + [r, g, b] + .map((v) => Math.round(v).toString(16).padStart(2, "0")) + .join("") + ); +} + +function probePage(svg, css) { + return ` + + + +
+
${svg}
+
+
+
${svg}
+
+`; +} + +const browser = await chromium.launch(); +const ctx = await browser.newContext({ + viewport: { width: 1400, height: 1600 }, + deviceScaleFactor: 1, +}); +const page = await ctx.newPage(); + +const targets = [ + { name: "architecture", svg: all[0].svgs[0] }, + { name: "why-wavehouse-1 (10k clients)", svg: all[1].svgs[0] }, + { name: "why-wavehouse-3 (cache+singleflight)", svg: all[1].svgs[2] }, + { name: "why-wavehouse-5 (ingest path)", svg: all[1].svgs[4] }, +]; + +const audit = []; +for (const t of targets) { + await page.setContent(probePage(t.svg, preparedCss()), { waitUntil: "load" }); + await page.evaluate(() => document.fonts.ready); + + const result = await page.evaluate(() => { + function svgCenter(svgEl) { + // For SVG geometry (filter-free), use getBBox + element CTM to get + // page coordinates of the geometric center. + if (!svgEl || !svgEl.getBBox) return null; + const b = svgEl.getBBox(); + const m = svgEl.getScreenCTM(); + if (!m) return null; + // Convert (cx, cy) of bbox through CTM + const cx_local = b.x + b.width / 2; + const cy_local = b.y + b.height / 2; + const cx = m.a * cx_local + m.c * cy_local + m.e; + const cy = m.b * cx_local + m.d * cy_local + m.f; + return { cx, cy, w: b.width, h: b.height }; + } + function bbox(el) { + if (!el) return null; + const r = el.getBoundingClientRect(); + return { x: r.x, y: r.y, w: r.width, h: r.height, cx: r.x + r.width / 2, cy: r.y + r.height / 2 }; + } + const out = {}; + for (const pane of document.querySelectorAll(".pane")) { + const theme = pane.dataset.theme; + // ---- Cylinder centering ---- + const cylinderNodes = pane.querySelectorAll(".node:has(> path.basic.label-container)"); + const cylinders = []; + for (const n of cylinderNodes) { + const path = n.querySelector("path.basic.label-container"); + const label = n.querySelector(".label foreignObject"); + const text = n.querySelector(".nodeLabel p")?.textContent?.trim() || ""; + if (!path || !label) continue; + const pCenter = svgCenter(path); // filter-free geometric center + const lCenter = svgCenter(label); + const computedTransform = getComputedStyle(label).transform; + cylinders.push({ + text, + path_center_y: Math.round(pCenter.cy), + label_center_y: Math.round(lCenter.cy), + offset_y: Math.round(lCenter.cy - pCenter.cy), + fo_transform: computedTransform, + path_height: Math.round(pCenter.h), + }); + } + // ---- Edge label centering on lines ---- + const edgeLabels = pane.querySelectorAll(".edgeLabel"); + const edges = []; + for (const el of edgeLabels) { + // Edge label text lives in span.edgeLabel > p (not .nodeLabel) + const p = el.querySelector("span.edgeLabel p, .nodeLabel p"); + const text = p?.textContent?.trim(); + if (!text) continue; + const labelBox = bbox(p); + // Find the nearest .flowchart-link path; we identify edge by proximity + let nearest = null; + let nearestDist = Infinity; + for (const path of pane.querySelectorAll(".flowchart-link, .edgePath .path")) { + const r = path.getBoundingClientRect(); + // distance from label center to path bbox center + const dx = (r.x + r.width / 2) - labelBox.cx; + const dy = (r.y + r.height / 2) - labelBox.cy; + const d = Math.hypot(dx, dy); + if (d < nearestDist) { nearestDist = d; nearest = r; } + } + if (!nearest) continue; + edges.push({ + text, + label_cx: Math.round(labelBox.cx), + label_cy: Math.round(labelBox.cy), + path_cx: Math.round(nearest.x + nearest.width / 2), + path_cy: Math.round(nearest.y + nearest.height / 2), + dx: Math.round(labelBox.cx - (nearest.x + nearest.width / 2)), + dy: Math.round(labelBox.cy - (nearest.y + nearest.height / 2)), + }); + } + // ---- ClassDef fills + contrast ---- + const contrastChecks = []; + for (const cls of ["wh", "win", "pain", "fail", "neutral", "client", "infra", "store"]) { + // find the visible shape (rect or path) for the first node of this class + const node = pane.querySelector(`.node.default.${cls}`); + if (!node) continue; + const shape = + node.querySelector("rect.basic.label-container") || + node.querySelector("path.basic.label-container"); + const textEl = node.querySelector(".nodeLabel p"); + if (!shape || !textEl) continue; + const fill = getComputedStyle(shape).fill; + const color = getComputedStyle(textEl).color; + contrastChecks.push({ + class: cls, + fill, + text: color, + }); + } + // ---- Subgraph title position ---- + const titles = []; + for (const cluster of pane.querySelectorAll(".cluster")) { + const rect = cluster.querySelector("rect"); + const labelP = cluster.querySelector(".cluster-label .nodeLabel p"); + if (!rect || !labelP) continue; + const r = bbox(rect); + const l = bbox(labelP); + titles.push({ + text: labelP.textContent?.trim(), + rect_top: Math.round(r.y), + rect_bottom: Math.round(r.y + r.h), + title_top: Math.round(l.y), + title_bottom: Math.round(l.y + l.h), + title_height: Math.round(l.h), + straddles_top: l.y < r.y && l.y + l.h > r.y, + gap_to_rect_top: Math.round(l.y + l.h - r.y), + }); + } + out[theme] = { cylinders, edges, contrastChecks, titles }; + } + return out; + }); + audit.push({ name: t.name, ...result }); +} + +await browser.close(); + +// ---- Post-process: compute contrast ratios in Node ---- +for (const d of audit) { + for (const theme of ["dark", "light"]) { + if (!d[theme]?.contrastChecks) continue; + for (const c of d[theme].contrastChecks) { + c.fill_hex = rgbToHex(c.fill); + c.text_hex = rgbToHex(c.text); + c.ratio = +contrast(c.fill_hex, c.text_hex).toFixed(2); + c.wcag = c.ratio >= 7 ? "AAA" : c.ratio >= 4.5 ? "AA" : c.ratio >= 3 ? "AA-large" : "FAIL"; + } + } +} + +const report = JSON.stringify(audit, null, 2); +await writeFile(resolve(OUT, "audit.json"), report); +console.log(report); diff --git a/docs/scripts/screenshot-diagrams.mjs b/docs/scripts/screenshot-diagrams.mjs new file mode 100644 index 00000000..c0f45c60 --- /dev/null +++ b/docs/scripts/screenshot-diagrams.mjs @@ -0,0 +1,121 @@ +/* Mermaid diagram screenshots for visual regression. + * + * Usage: node scripts/screenshot-diagrams.mjs + * Prereq: `pnpm build` first — this script reads `dist/`. + * + * What it does: loads each built docs page directly via `file://`, + * intercepts the `/_astro/*.css` absolute hrefs so they resolve to + * `dist/_astro/*` on disk, then screenshots every flowchart SVG on + * the page in both light and dark mode at retina dpr. + * + * Why this approach (vs. the prior `mermaid-preview.mjs` / `zoom-*.mjs` + * pattern that loaded SVGs in a stub HTML page with hand-edited CSS): + * + * The stub-page approach silently mis-renders polish styles — the + * cluster-title pill and edge-label pill backgrounds didn't appear + * because the rewritten `:root` → `.pane` substitution doesn't + * reproduce the exact cascade. That looked like a regression but + * wasn't. Loading the real dist HTML eliminates the doubt: what you + * see here is what `wavehouse.dev` will serve. + * + * Output: screenshots/-{light|dark}-.png, one per diagram. + * `screenshots/` is gitignored. */ +import { chromium } from "playwright"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { readFile, mkdir } from "node:fs/promises"; + +const ROOT = resolve(process.cwd()); +const DIST = resolve(ROOT, "dist"); +const OUT = resolve(ROOT, "screenshots"); +await mkdir(OUT, { recursive: true }); + +// Pages whose mermaid output we care about. Add new ones here as the +// docs grow — anything not listed is skipped (no auto-discovery; cheap +// is good). +const PAGES = [ + { name: "architecture", file: "architecture/index.html" }, + { name: "why-wavehouse", file: "why-wavehouse/index.html" }, +]; + +const browser = await chromium.launch(); +try { + for (const theme of ["light", "dark"]) { + const ctx = await browser.newContext({ + viewport: { width: 2000, height: 1400 }, + deviceScaleFactor: 2, + colorScheme: theme, + }); + // Starlight reads theme from localStorage and the [data-theme] attr; + // initScript ensures both are set before the first render so the + // light/dark CSS branches in global.css apply correctly. + await ctx.addInitScript((t) => { + try { + localStorage.setItem("starlight-theme", t); + } catch {} + document.documentElement.setAttribute("data-theme", t); + }, theme); + + const page = await ctx.newPage(); + // Astro emits absolute hrefs like `/_astro/common..css`. Under + // file://, those resolve to the filesystem root and 404. Route them + // back into `dist/_astro/` so the page picks up its real CSS. + await page.route("**/*", async (route, req) => { + const u = new URL(req.url()); + if (u.protocol === "file:" && u.pathname.startsWith("/_astro/")) { + const path = resolve(DIST, u.pathname.replace(/^\//, "")); + try { + const body = await readFile(path); + const type = path.endsWith(".css") + ? "text/css" + : path.endsWith(".js") + ? "text/javascript" + : "application/octet-stream"; + return route.fulfill({ body, contentType: type }); + } catch { + return route.continue(); + } + } + return route.continue(); + }); + + for (const p of PAGES) { + const fileUrl = pathToFileURL(resolve(DIST, p.file)).href; + await page.goto(fileUrl, { waitUntil: "networkidle" }); + await page.evaluate(() => document.fonts.ready); + + const count = await page.$$eval( + 'svg[aria-roledescription^="flowchart"]', + (svgs) => svgs.length + ); + for (let i = 0; i < count; i++) { + // Scroll the i-th svg into view, then page.screenshot clipped to + // its bbox. Screenshotting the SVG element directly drops doc + // CSS for its foreignObject HTML content; clipping the page does + // not. + const box = await page.evaluate((idx) => { + const svg = document.querySelectorAll( + 'svg[aria-roledescription^="flowchart"]' + )[idx]; + svg.scrollIntoView({ block: "center" }); + const r = svg.getBoundingClientRect(); + return { x: r.x, y: r.y, w: r.width, h: r.height }; + }, i); + const outName = `${p.name}-${theme}-${i + 1}.png`; + await page.screenshot({ + path: resolve(OUT, outName), + clip: { + x: Math.max(0, box.x - 20), + y: Math.max(0, box.y - 20), + width: box.w + 40, + height: box.h + 40, + }, + }); + console.log(`✓ screenshots/${outName}`); + } + } + await ctx.close(); + } +} finally { + await browser.close(); +} diff --git a/docs/src/content/docs/architecture.md b/docs/src/content/docs/architecture.md index 1b79d82e..7249b7e8 100644 --- a/docs/src/content/docs/architecture.md +++ b/docs/src/content/docs/architecture.md @@ -13,7 +13,7 @@ WaveHouse is a Go-based gateway that sits in front of ClickHouse, acting as the ```mermaid flowchart TD - Clients["Clients
(REST API, SSE, WebSocket)"] + Clients["Clients
(REST API, SSE, WebSocket)"]:::client Clients --> IH Clients --> QH @@ -25,7 +25,7 @@ flowchart TD SR --> DD["Dedupe (optional)"] DD --> MQ["MQ (NATS)"] MQ --> BC["Buffer Consumer
(batch flush)"] - BC -.->|failed inserts| DLQ["DLQ"] + BC -.->|failed inserts| DLQ["DLQ"]:::fail QH["Query Handler"] --> Cache["Cache
(Ristretto + singleflight)"] @@ -37,7 +37,7 @@ flowchart TD NATS["NATS JetStream retains messages
for SSE/WS gap-fill
via DeliverByStartTime"] end - BC --> CH[("ClickHouse
(analytics storage)")] + BC --> CH[("ClickHouse
(analytics storage)")]:::store Cache --> CH style NATS fill:none,stroke-dasharray:5 5,stroke:#888,color:#888 diff --git a/docs/src/content/docs/why-wavehouse.md b/docs/src/content/docs/why-wavehouse.md index 16db8ad8..00168ecd 100644 --- a/docs/src/content/docs/why-wavehouse.md +++ b/docs/src/content/docs/why-wavehouse.md @@ -19,12 +19,6 @@ What goes wrong in concrete terms: ```mermaid flowchart TB - classDef pain fill:#b91c1c,stroke:#ef4444,color:#fff,stroke-width:2px - classDef fail fill:#7f1d1d,stroke:#dc2626,color:#fff,stroke-width:3px - classDef win fill:#15803d,stroke:#22c55e,color:#fff,stroke-width:2px - classDef wh fill:#0e7f8f,stroke:#5bbfcf,color:#fff,stroke-width:3px - classDef neutral fill:#475569,stroke:#94a3b8,color:#fff,stroke-width:2px - subgraph bad["DIRECT TO CLICKHOUSE"] direction TB CA["10,000 clients
1 event each"]:::neutral @@ -69,11 +63,6 @@ WaveHouse's SSE/WebSocket layer broadcasts events **before** they flush to Click ```mermaid flowchart TB - classDef pain fill:#b91c1c,stroke:#ef4444,color:#fff,stroke-width:2px - classDef win fill:#15803d,stroke:#22c55e,color:#fff,stroke-width:2px - classDef wh fill:#0e7f8f,stroke:#5bbfcf,color:#fff,stroke-width:3px - classDef neutral fill:#475569,stroke:#94a3b8,color:#fff,stroke-width:2px - subgraph poll["POLLING"] direction TB D1["100 dashboards"]:::neutral @@ -99,11 +88,6 @@ WaveHouse coalesces identical queries with an in-process Ristretto cache and Go' ```mermaid flowchart TB - classDef pain fill:#b91c1c,stroke:#ef4444,color:#fff,stroke-width:2px - classDef win fill:#15803d,stroke:#22c55e,color:#fff,stroke-width:2px - classDef wh fill:#0e7f8f,stroke:#5bbfcf,color:#fff,stroke-width:3px - classDef neutral fill:#475569,stroke:#94a3b8,color:#fff,stroke-width:2px - subgraph herd["THUNDERING HERD"] direction TB C1["50 clients
(same query)"]:::neutral @@ -129,13 +113,10 @@ ClickHouse has users and role grants, but nothing like row-level security driven The canonical DIY stack for user-facing analytics on ClickHouse looks like this: +
+ ```mermaid flowchart TB - classDef pain fill:#b91c1c,stroke:#ef4444,color:#fff,stroke-width:2px - classDef wh fill:#0e7f8f,stroke:#5bbfcf,color:#fff,stroke-width:3px - classDef neutral fill:#475569,stroke:#94a3b8,color:#fff,stroke-width:2px - classDef infra fill:#334155,stroke:#64748b,color:#fff,stroke-width:2px - subgraph diy["DIY: 7 COMPONENTS TO OPERATE"] direction TB Cs["Clients"]:::neutral @@ -148,7 +129,10 @@ flowchart TB Worker --> CHd[("ClickHouse")]:::infra WS --> Cs end +``` +```mermaid +flowchart TB subgraph single["WAVEHOUSE: 1 BINARY + CLICKHOUSE"] direction TB Cs2["Clients"]:::neutral @@ -157,6 +141,8 @@ flowchart TB end ``` +
+ **Ingredient count, cleanly:** | Capability | DIY stack | WaveHouse | @@ -219,12 +205,7 @@ How an event actually moves through WaveHouse, end to end. The ingest path is sp ```mermaid flowchart TB - classDef client fill:#475569,stroke:#94a3b8,color:#fff,stroke-width:2px - classDef wh fill:#0e7f8f,stroke:#5bbfcf,color:#fff,stroke-width:2px - classDef store fill:#334155,stroke:#64748b,color:#fff,stroke-width:2px - classDef fail fill:#b91c1c,stroke:#ef4444,color:#fff,stroke-width:2px - - C["Client
POST /v1/ingest?table=\{clicks\}"]:::client + C["Client
POST /v1/ingest?table={clicks}"]:::client C --> AUTH["JWT auth (optional)"]:::wh AUTH --> POL["Policy check
row + column"]:::wh POL --> VAL["Schema validation
(system.columns)"]:::wh @@ -244,10 +225,6 @@ flowchart TB ```mermaid flowchart TB - classDef client fill:#475569,stroke:#94a3b8,color:#fff,stroke-width:2px - classDef wh fill:#0e7f8f,stroke:#5bbfcf,color:#fff,stroke-width:2px - classDef store fill:#334155,stroke:#64748b,color:#fff,stroke-width:2px - Q["Client
POST /v1/query?table={table}"]:::client Q --> L1["Ristretto cache
(in-process)"]:::wh L1 -. "miss + singleflight" .-> CH[("ClickHouse")]:::store diff --git a/docs/src/integrations/mermaid.mjs b/docs/src/integrations/mermaid.mjs new file mode 100644 index 00000000..c8616f56 --- /dev/null +++ b/docs/src/integrations/mermaid.mjs @@ -0,0 +1,439 @@ +// Mermaid build-time plumbing for the WaveHouse docs site. Split out of +// astro.config.mjs so the config file stays a config file. +// +// Three exports: +// • rehypeMermaidOptions — pass to the rehype-mermaid plugin +// • fixMermaidOutput() — Astro integration that rewrites emitted SVGs +// • remarkInjectMermaidClassdefs() — remark plugin that injects the +// standard classDef declarations into every mermaid block so the +// source markdown can stay free of presentation metadata +// +// The polish CSS that used to be injected per-SVG now lives in +// src/styles/global.css under "SVG-element polish". + +import { readFile, writeFile, readdir } from "node:fs/promises"; +import { resolve as joinPath } from "node:path"; +import { fileURLToPath } from "node:url"; +import { visit } from "unist-util-visit"; + +// --------------------------------------------------------------------------- +// Build-time font inlining +// +// Inject Inter Variable into the build-time Chromium that mermaid-isomorphic +// uses for SSR. Without this, Mermaid measures node widths against Chromium's +// fallback (~Arial) but the SVG declares whatever font-family we set in +// mermaidConfig — so if runtime renders with Inter, labels overflow because +// Inter is ~6-8% wider per character than Arial. Inlining the woff2 as a +// data URL with `font-display: block` makes Chromium load Inter synchronously +// before mermaid runs and measures correctly. (`font-display: swap`, which +// fontsource uses by default, doesn't block, so the package's own index.css +// can't substitute for this.) +let buildTimeFontCssDataUrl; +let buildTimeFontFamily; +try { + const interWoff2 = await readFile( + new URL( + "../../node_modules/@fontsource-variable/inter/files/inter-latin-wght-normal.woff2", + import.meta.url + ) + ); + const interBase64 = interWoff2.toString("base64"); + const inlineCss = [ + "@font-face{", + "font-family:'Inter Variable';", + `src:url(data:font/woff2;base64,${interBase64}) format('woff2-variations');`, + "font-weight:100 900;", + "font-style:normal;", + "font-display:block;", + "}", + ].join(""); + buildTimeFontCssDataUrl = + "data:text/css;base64," + Buffer.from(inlineCss).toString("base64"); + buildTimeFontFamily = + '"Inter Variable", ui-sans-serif, system-ui, sans-serif'; +} catch { + // Fontsource not on disk (fresh clone, no `pnpm install` yet). Fall back + // to Mermaid's default Arial so the build still works. + buildTimeFontFamily = '"Arial", sans-serif'; +} + +// --------------------------------------------------------------------------- +// Mermaid theme — tracks the dark-mode palette in src/styles/global.css. +// SVGs are rendered (and baked) at build time, so colors here are fixed; +// the values are picked to read well against the dark site chrome that is +// the brand default. The build hook below swaps each literal hex for a +// CSS variable, so light-mode rendering still works at runtime. + +const mermaidThemeVariables = { + fontFamily: buildTimeFontFamily, + fontSize: "14px", + + // Default nodes — surface fill, brand-teal border, ink text + primaryColor: "#14171C", + primaryBorderColor: "#06B0BF", + primaryTextColor: "#F1F3F7", + + // Secondary / tertiary nodes (used by some diagram types for alt fills) + secondaryColor: "#1B1F26", + secondaryBorderColor: "#2F343F", + secondaryTextColor: "#F1F3F7", + tertiaryColor: "#232830", + tertiaryBorderColor: "#2F343F", + tertiaryTextColor: "#F1F3F7", + + // Edges / arrows + lineColor: "#6B7280", + + // Subgraphs (clusters) + clusterBkg: "rgba(6, 176, 191, 0.04)", + clusterBorder: "rgba(6, 176, 191, 0.30)", + titleColor: "#F1F3F7", + + // Edge labels + edgeLabelBackground: "#14171C", + labelBoxBkgColor: "#14171C", + labelBoxBorderColor: "#2F343F", + labelTextColor: "#9CA3AF", + + // Notes + noteBkgColor: "#1B1F26", + noteBorderColor: "#2F343F", + noteTextColor: "#F1F3F7", + + // Generic + mainBkg: "#14171C", + secondBkg: "#1B1F26", + background: "transparent", + textColor: "#F1F3F7", +}; + +export const rehypeMermaidOptions = { + strategy: "inline-svg", + // The data URL contains an inline Inter Variable woff2 with + // `font-display: block`, so Chromium uses Inter for getBBox() + // measurement before mermaid renders. + ...(buildTimeFontCssDataUrl ? { css: buildTimeFontCssDataUrl } : {}), + mermaidConfig: { + // `fontFamily` must live at the top of mermaidConfig — mermaid-isomorphic + // hard-codes `arial,sans-serif` here if absent and ignores the same key + // inside themeVariables. + fontFamily: buildTimeFontFamily, + theme: "base", + themeVariables: mermaidThemeVariables, + flowchart: { + curve: "basis", + padding: 20, + nodeSpacing: 48, + rankSpacing: 56, + wrappingWidth: 480, + useMaxWidth: true, + }, + sequence: { useMaxWidth: true, wrap: false }, + securityLevel: "strict", + }, +}; + +// --------------------------------------------------------------------------- +// Standard WaveHouse classDef declarations injected into every mermaid block +// so source markdown can use `:::class` without restating the palette in +// every diagram. Hex values must match MERMAID_THEME_REPLACEMENTS below — +// they get swapped for CSS variables at build time, so light mode works. + +const WAVEHOUSE_CLASSDEFS = [ + "classDef client fill:#475569,stroke:#94a3b8,color:#fff,stroke-width:2px", + "classDef store fill:#334155,stroke:#64748b,color:#fff,stroke-width:2px", + "classDef fail fill:#7f1d1d,stroke:#dc2626,color:#fff,stroke-width:2px", + "classDef pain fill:#b91c1c,stroke:#ef4444,color:#fff,stroke-width:2px", + "classDef win fill:#15803d,stroke:#22c55e,color:#fff,stroke-width:2px", + "classDef wh fill:#0e7f8f,stroke:#5bbfcf,color:#fff,stroke-width:3px", + "classDef neutral fill:#475569,stroke:#94a3b8,color:#fff,stroke-width:2px", + "classDef infra fill:#334155,stroke:#64748b,color:#fff,stroke-width:2px", +]; + +// `classDef` is valid syntax in flowchart/graph diagrams only. Other +// diagram types (sequenceDiagram, classDiagram, erDiagram, stateDiagram, +// gantt, pie, journey, …) parse it as an error, so we only inject when +// the block opens with a recognized flowchart header. +const FLOWCHART_HEADER_RE = /^\s*(?:flowchart|graph)\b/; + +export function remarkInjectMermaidClassdefs() { + return (tree) => { + visit(tree, "code", (node) => { + if (node.lang !== "mermaid") return; + const lines = node.value.split("\n"); + const headerIdx = lines.findIndex((l) => l.trim().length > 0); + if (headerIdx < 0) return; + if (!FLOWCHART_HEADER_RE.test(lines[headerIdx])) return; + // Inject all eight classDefs after the diagram-type header. + // Mermaid silently ignores unused declarations, and a per-diagram + // `classDef` later in the block will override ours since later + // declarations win in mermaid. + const indent = lines[headerIdx].match(/^\s*/)[0]; + const injected = WAVEHOUSE_CLASSDEFS.map((l) => indent + " " + l); + lines.splice(headerIdx + 1, 0, ...injected); + node.value = lines.join("\n"); + }); + }; +} + +// --------------------------------------------------------------------------- +// Build-time SVG patches +// +// Astro integration: rewrite Mermaid SVG output so it (a) survives +// Chromium's parser and (b) responds to the site's light/dark theme. +// +// 1.


. Mermaid v11.15 emits `

` for line breaks. +// Per HTML5, an end tag for a void element is a parse error and the +// parser inserts a duplicate void element, so Chrome reads it as two +//
nodes — three lines of rendered height. Mermaid only sized +// the for two lines, so the third line overflows and +// appears dropped. Patching to `
` aligns Chrome's parse with +// Mermaid's measurement. +// +// 2. Hex colors in each SVG → CSS variables. Mermaid bakes our theme's +// colors as literal hex inside the SVG's scoped CSS rules AND inside +// inline style="fill:#…" attributes on each //. +// Swapping them for `var(--wh-mermaid-*)` lets global.css drive the +// colors per `[data-theme]`, so light mode actually renders as light. +// +// Two pools of hex colors get swapped: +// 1. Default theme tokens from `mermaidThemeVariables` above — these +// drive the architecture diagram (and any unstyled node). +// 2. classDef colors from WAVEHOUSE_CLASSDEFS — let every diagram pull +// from the same brand palette without editing the source mermaid +// blocks. +// +// Hex values here are the LITERAL ones Mermaid serializes (preserving +// case). The two pools don't collide, so the replacement is safe to +// apply to the entire SVG body. +const MERMAID_THEME_REPLACEMENTS = [ + // --- default theme tokens (from mermaidThemeVariables) --------------- + ["#14171C", "var(--wh-mermaid-surface)"], + ["#F1F3F7", "var(--wh-mermaid-ink)"], + ["#9CA3AF", "var(--wh-mermaid-ink-muted)"], + ["#6B7280", "var(--wh-mermaid-line)"], + ["#1B1F26", "var(--wh-mermaid-surface-2)"], + ["#232830", "var(--wh-mermaid-surface-3)"], + ["#2F343F", "var(--wh-mermaid-border)"], + ["rgba(20, 23, 28, 0.5)", "var(--wh-mermaid-surface-fade)"], + ["rgba(6, 176, 191, 0.04)", "var(--wh-mermaid-cluster-bg)"], + ["rgba(6, 176, 191, 0.30)", "var(--wh-mermaid-cluster-border)"], + + // --- classDef colors from WAVEHOUSE_CLASSDEFS, mapped to brand ------- + // wh (WaveHouse itself) → brand accent + ["#0e7f8f", "var(--wh-mermaid-wh-bg)"], + ["#5bbfcf", "var(--wh-mermaid-wh-border)"], + // pain (problem state) → rose + ["#b91c1c", "var(--wh-mermaid-pain-bg)"], + ["#ef4444", "var(--wh-mermaid-pain-border)"], + // fail (critical error) → deeper rose + ["#7f1d1d", "var(--wh-mermaid-fail-bg)"], + ["#dc2626", "var(--wh-mermaid-fail-border)"], + // win (success outcome) → emerald + ["#15803d", "var(--wh-mermaid-win-bg)"], + ["#22c55e", "var(--wh-mermaid-win-border)"], + // neutral / client (external user, dashboard) → slate + ["#475569", "var(--wh-mermaid-neutral-bg)"], + ["#94a3b8", "var(--wh-mermaid-neutral-border)"], + // infra / store (databases, queues, persistent storage) → terracotta + ["#334155", "var(--wh-mermaid-infra-bg)"], + ["#64748b", "var(--wh-mermaid-infra-border)"], +]; + +const SVG_BLOCK_RE = + /]*aria-roledescription="(?:flowchart|sequence|class|state|gantt|pie|er)[^"]*"[\s\S]*?<\/svg>/g; + +const FLOWCHART_SVG_RE = + /]*aria-roledescription="flowchart[^"]*"[\s\S]*?<\/svg>/g; + +export function fixMermaidOutput() { + async function walk(dir) { + const out = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const full = joinPath(dir, entry.name); + if (entry.isDirectory()) out.push(...(await walk(full))); + else if (entry.name.endsWith(".html")) out.push(full); + } + return out; + } + + // Replace hex colors with CSS variables across the whole SVG body — + // catches both the ` EOF -# --- 2. Starlight nav logos (light + dark) ------------------------------------ -# Starlight loads these via , where CSS media queries inside the SVG -# don't fire — so we ship two separate files and let Starlight swap them. -cat > "$OUT_BRANDING/wavehouse-mark-light.svg" < "$OUT_KIT/mark-light.svg" <$(mark_with_color "$COLOR_LIGHT") EOF -cat > "$OUT_BRANDING/wavehouse-mark-dark.svg" < "$OUT_KIT/mark-dark.svg" <$(mark_with_color "$COLOR_DARK") EOF -# --- 3. favicon.ico (16 / 32 / 48 multi-resolution) --------------------------- -rsvg-convert -w 16 -h 16 "$OUT_PUBLIC/favicon.svg" -o "$TMP/fav-16.png" -rsvg-convert -w 32 -h 32 "$OUT_PUBLIC/favicon.svg" -o "$TMP/fav-32.png" -rsvg-convert -w 48 -h 48 "$OUT_PUBLIC/favicon.svg" -o "$TMP/fav-48.png" -magick "$TMP/fav-16.png" "$TMP/fav-32.png" "$TMP/fav-48.png" "$OUT_PUBLIC/favicon.ico" +# --- 3. lockup-dark.svg (mark + wordmark for dark backgrounds) ----------------- +cat > "$OUT_KIT/lockup-dark.svg" <$(lockup_with_colors "$COLOR_DARK" "$OG_INK") +EOF + +# --- 4. favicon.ico (16/32/48) → kit + site root ------------------------------ +rsvg-convert -w 16 -h 16 "$OUT_KIT/favicon.svg" -o "$TMP/fav-16.png" +rsvg-convert -w 32 -h 32 "$OUT_KIT/favicon.svg" -o "$TMP/fav-32.png" +rsvg-convert -w 48 -h 48 "$OUT_KIT/favicon.svg" -o "$TMP/fav-48.png" +magick "$TMP/fav-16.png" "$TMP/fav-32.png" "$TMP/fav-48.png" "$OUT_KIT/favicon.ico" +cp "$OUT_KIT/favicon.ico" "$OUT_ROOT/favicon.ico" -# --- 4. apple-touch-icon.png (180x180, solid teal tile) ----------------------- -# Mark scaled to ~70% of the tile, centred, white on teal. iOS clips to a -# rounded square automatically — keep the mark well clear of the edges. +# --- 5. apple-touch-icon.png (180x180 solid tile) ----------------------------- cat > "$TMP/touch.svg" <$(mark_with_color "$COLOR_TOUCH_FG") EOF -rsvg-convert -w 180 -h 180 "$TMP/touch.svg" -o "$OUT_PUBLIC/apple-touch-icon.png" - -# --- 5. og.png (1200x630 social card) ----------------------------------------- -# og.template.svg has the composition (gradient, accent grid, glow, tagline, -# footer row) baked in — we splice the canonical lockup (mark + wordmark unit) -# into its __LOCKUP_GROUP__ slot, recolored for the dark background. -# Escape `&` (special on the sed replacement side) but nothing else: SVG path -# data + attributes never contain `|`, so we can use `|` as the s delimiter. -lockup_og_escaped=$(lockup_with_colors "$COLOR_OG" "$COLOR_OG_WM" | sed 's/&/\\&/g') -sed "s|__LOCKUP_GROUP__|$lockup_og_escaped|" "$OG_TEMPLATE" > "$TMP/og.svg" -rsvg-convert -w 1200 -h 630 "$TMP/og.svg" -o "$OUT_PUBLIC/og.png" +rsvg-convert -w 180 -h 180 "$TMP/touch.svg" -o "$OUT_KIT/apple-touch-icon.png" + +# --- 6. og.png (1200x630 social card) ----------------------------------------- +# Fill the template's color placeholders from --brand-*, splice the lockup +# (mark accent + wordmark ink) into __LOCKUP_GROUP__. Escape `&` (sed RHS). +lockup_og_escaped=$(lockup_with_colors "$COLOR_DARK" "$OG_INK" | sed 's/&/\\&/g') +sed -e "s|__BG__|$OG_BG|g" \ + -e "s|__SURFACE__|$OG_SURFACE|g" \ + -e "s|__ACCENT__|$COLOR_DARK|g" \ + -e "s|__INK_MUTED__|$OG_INK_MUTED|g" \ + -e "s|__LOCKUP_GROUP__|$lockup_og_escaped|" \ + "$OG_TEMPLATE" > "$TMP/og.svg" +rsvg-convert -w 1200 -h 630 "$TMP/og.svg" -o "$OUT_KIT/og.png" # --- Report ------------------------------------------------------------------- -for out in \ - "${OUT_PUBLIC#"$ROOT/"}/favicon.svg" \ - "${OUT_PUBLIC#"$ROOT/"}/favicon.ico" \ - "${OUT_PUBLIC#"$ROOT/"}/apple-touch-icon.png" \ - "${OUT_PUBLIC#"$ROOT/"}/og.png" \ - "${OUT_BRANDING#"$ROOT/"}/wavehouse-mark-light.svg" \ - "${OUT_BRANDING#"$ROOT/"}/wavehouse-mark-dark.svg"; do - printf ' %s✓%s %s\n' "$GREEN" "$RESET" "$out" +printf '%s colors%s light=%s dark=%s bg=%s ink=%s\n' \ + "$CYAN" "$RESET" "$COLOR_LIGHT" "$COLOR_DARK" "$OG_BG" "$OG_INK" +for out in favicon.svg favicon.ico apple-touch-icon.png og.png \ + mark-light.svg mark-dark.svg lockup-dark.svg; do + printf ' %s✓%s %s\n' "$GREEN" "$RESET" "docs/public/branding/$out" done +printf ' %s✓%s %s\n' "$GREEN" "$RESET" "docs/public/favicon.ico (site root)" diff --git a/docs/scripts/branding/mark.svg b/docs/scripts/branding/mark.svg deleted file mode 100644 index b311a171..00000000 --- a/docs/scripts/branding/mark.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/docs/scripts/branding/lockup.svg b/docs/src/assets/branding/lockup.svg similarity index 100% rename from docs/scripts/branding/lockup.svg rename to docs/src/assets/branding/lockup.svg diff --git a/docs/src/assets/branding/mark.svg b/docs/src/assets/branding/mark.svg new file mode 100644 index 00000000..df963753 --- /dev/null +++ b/docs/src/assets/branding/mark.svg @@ -0,0 +1 @@ + diff --git a/docs/scripts/branding/og.template.svg b/docs/src/assets/branding/og.template.svg similarity index 78% rename from docs/scripts/branding/og.template.svg rename to docs/src/assets/branding/og.template.svg index c6086768..c03d0135 100644 --- a/docs/scripts/branding/og.template.svg +++ b/docs/src/assets/branding/og.template.svg @@ -1,17 +1,17 @@ - - + + - - + + - + @@ -21,7 +21,7 @@ - The open-source real-time API gateway for ClickHouse. + font-size="34" font-weight="400" fill="__INK_MUTED__"> + The open-source real-time API gateway for ClickHouse. Schema-aware ingest, async batching, real-time streaming. @@ -52,7 +52,7 @@ split (tagline gray left / accent cyan right) gives the URL CTA-weight without enlarging it. --> A Wave RF open-source project + font-size="28" font-weight="400" fill="__INK_MUTED__">A Wave RF open-source project wavehouse.dev + font-size="28" font-weight="500" fill="__ACCENT__" text-anchor="end">wavehouse.dev diff --git a/docs/src/components/Footer.astro b/docs/src/components/Footer.astro index f5232dc1..fc73e107 100644 --- a/docs/src/components/Footer.astro +++ b/docs/src/components/Footer.astro @@ -1,76 +1,268 @@ --- +// Starlight's default Footer renders these three; overriding Footer drops +// them, so we re-render them on doc pages below (prev/next nav especially). +// They read from Astro.locals and self-style; EditLink/LastUpdated honour the +// `editLink` / `lastUpdated` config and render nothing when disabled. +import EditLink from "virtual:starlight/components/EditLink"; +import LastUpdated from "virtual:starlight/components/LastUpdated"; +import Pagination from "virtual:starlight/components/Pagination"; +import WaveMark from "./WaveMark.astro"; + const year = new Date().getFullYear(); + +// Doc pages have the persistent fixed left sidebar; splash / landing pages +// (template: splash) don't. We render two different footers accordingly — the +// pattern every polished docs site uses (Astro docs, Stripe, Linear, Vercel): +// - no sidebar → full-width, multi-column "marketing" footer (room to breathe) +// - has sidebar → slim, content-aligned footer (a big band crammed next to a +// fixed sidebar reads as broken; a slim one reads as native) +const { hasSidebar } = Astro.locals.starlightRoute; +const repo = "https://github.com/Wave-RF/WaveHouse"; --- - + ) +}