diff --git a/.changelog/unreleased/566-build-time-mermaid.md b/.changelog/unreleased/566-build-time-mermaid.md new file mode 100644 index 00000000..5735d893 --- /dev/null +++ b/.changelog/unreleased/566-build-time-mermaid.md @@ -0,0 +1,7 @@ +--- +type: added +area: docs +pr: 566 +breaking: false +--- +Added build-time Mermaid diagram compilation to inline SVG with dual theme support, required accessibility captions, and responsive container scrolling. diff --git a/.changelog/unreleased/571-docs-e2e.md b/.changelog/unreleased/571-docs-e2e.md new file mode 100644 index 00000000..3b6aab52 --- /dev/null +++ b/.changelog/unreleased/571-docs-e2e.md @@ -0,0 +1,7 @@ +--- +type: added +area: docs +pr: 571 +breaking: false +--- +Added end-to-end Playwright test coverage for documentation navigation, Pagefind search, section pagers, deep-link anchor focus, mobile drawer, and theme persistence against production build output. diff --git a/apps/docs/content/concepts/oracles.mdx b/apps/docs/content/concepts/oracles.mdx index 15a7fb5b..bded5e46 100644 --- a/apps/docs/content/concepts/oracles.mdx +++ b/apps/docs/content/concepts/oracles.mdx @@ -93,27 +93,20 @@ seconds old and the dot turns red. From source to liquidation check, the value flows like this: -```text - SO4 oracle service ─┐ - Pyth Hermes ────────┤ first source that answers wins - Binance REST ───────┤ (fetchTokenPrices, oracle.ts) - GMX oracle ─────────┤ - DUMMY_PRICES ───────┘ - │ - ▼ - TokenPrice { minPrice, maxPrice, updatedAt, source } - │ - ├──▶ trade page: mark price + staleness dot - │ - ▼ - position valuation ──▶ maintenance-margin check ──▶ liquidation - (contract + client, see /concepts/liquidation) +```mermaid caption="Oracle price ingestion pipeline and liquidation check flow" +graph TD + SO4[SO4 oracle service] --> TP[TokenPrice
minPrice, maxPrice, updatedAt, source] + Pyth[Pyth Hermes] --> TP + Binance[Binance REST] --> TP + GMX[GMX oracle] --> TP + Dummy[DUMMY_PRICES] --> TP + TP --> Trade[trade page: mark price + staleness dot] + TP --> PosVal[position valuation] + PosVal --> Margin[maintenance-margin check] + Margin --> Liq[liquidation] ``` -Caption: the client only owns the top half — choosing a source and shaping a -`TokenPrice`. Position valuation and the liquidation decision run against that -price in contract and client code covered by -[/concepts/liquidation](/concepts/liquidation). +The client only owns the top half — choosing a source and shaping a `TokenPrice`. Position valuation and the liquidation decision run against that price in contract and client code covered by [/concepts/liquidation](/concepts/liquidation). ## Related diff --git a/apps/docs/content/developers/architecture.mdx b/apps/docs/content/developers/architecture.mdx index c032bac4..5ef0bb81 100644 --- a/apps/docs/content/developers/architecture.mdx +++ b/apps/docs/content/developers/architecture.mdx @@ -26,16 +26,12 @@ Most trading logic lives in `apps/web`. The indexer watches contracts and makes Market data, position state, and order history flow through four stops: -``` -Soroban contracts (on-chain state) - ↓ - RPC nodes (live reads) - ↓ - s03-indexer (database sync) - ↓ - web app (TanStack Query + Zustand) - ↓ - trader screen +```mermaid caption="SO4 architecture data flow from on-chain contracts to the trader screen" +graph TD + Contracts[Soroban contracts
on-chain state] --> RPC[RPC nodes
live reads] + RPC --> Indexer[s03-indexer
database sync] + Indexer --> WebApp[web app
TanStack Query + Zustand] + WebApp --> Screen[trader screen] ``` ### 1. Contracts diff --git a/apps/docs/scripts/build.ts b/apps/docs/scripts/build.ts index 3d28cc15..f97f0c4b 100644 --- a/apps/docs/scripts/build.ts +++ b/apps/docs/scripts/build.ts @@ -4,6 +4,7 @@ import { join } from "node:path" import { $ } from "bun" import { appRoot, loadPages, slugifyHeading } from "./content" import { DEFAULT_SITE_URL } from "../src/lib/seo" +import { renderMermaidFigureHtml } from "../src/lib/mermaid" await $`bun run ${join(appRoot, "scripts/check-content.ts")}` await $`bun run ${join(appRoot, "scripts/check-links.ts")}` @@ -34,10 +35,22 @@ function renderInline(value: string) { .replace(/`([^`]+)`/g, "$1") } -function render(body: string) { +function render(body: string, filePath: string) { const blocks = body.split(/\n\n+/) return blocks .map((block) => { + if (block.startsWith("```mermaid")) { + const firstLineEnd = block.indexOf("\n") + const header = firstLineEnd !== -1 ? block.slice(0, firstLineEnd) : block + const content = firstLineEnd !== -1 ? block.slice(firstLineEnd + 1).replace(/```$/, "").trim() : "" + + const captionMatch = header.match(/caption=(?:"([^"]+)"|'([^']+)'|([^\s]+))/) + const titleMatch = header.match(/title=(?:"([^"]+)"|'([^']+)'|([^\s]+))/) + const caption = captionMatch?.[1] || captionMatch?.[2] || captionMatch?.[3] + const title = titleMatch?.[1] || titleMatch?.[2] || titleMatch?.[3] + + return renderMermaidFigureHtml(content, { caption, title, file: filePath }) + } const heading = block.match(/^(#{2,6}) (.+?)(?: \{#([a-z0-9-]+)\})?$/) if (heading) { const level = heading[1].length @@ -81,7 +94,7 @@ if (!stylesheet) throw new Error("Vite did not emit the docs stylesheet") for (const page of pages) { const directory = join(outputRoot, page.route.slice(1)) await mkdir(directory, { recursive: true }) - const html = `${escape(page.frontmatter.title)} · SO4 docs
SO4 docsOpen interface

${escape(page.frontmatter.title)}

${render(page.body)}
` + const html = `${escape(page.frontmatter.title)} · SO4 docs
SO4 docsOpen interface

${escape(page.frontmatter.title)}

${render(page.body, page.file)}
` await Bun.write(join(directory, "index.html"), html) } diff --git a/apps/docs/scripts/check-content.ts b/apps/docs/scripts/check-content.ts index ad43bd66..8e6860ea 100644 --- a/apps/docs/scripts/check-content.ts +++ b/apps/docs/scripts/check-content.ts @@ -3,6 +3,7 @@ import { join } from "node:path" import { contentRoot, headingEntries, loadPages } from "./content" import { validateFrontmatter } from "../src/lib/frontmatter" +import { parseMermaid } from "../src/lib/mermaid" const pages = await loadPages() const errors: Array = [] @@ -29,6 +30,26 @@ for (const page of pages) { errors.push(`${page.route}: image "${src}" missing required alt text`) } } + + // DX-054: Enforce Mermaid diagram captions and valid syntax + const mermaidMatches = page.body.matchAll(/```mermaid([^\n]*)\n([\s\S]*?)```/g) + for (const match of mermaidMatches) { + const metaStr = match[1] + const code = match[2].trim() + const captionMatch = metaStr.match(/caption=(?:"([^"]+)"|'([^']+)'|([^\s]+))/) + const titleMatch = metaStr.match(/title=(?:"([^"]+)"|'([^']+)'|([^\s]+))/) + const caption = captionMatch?.[1] || captionMatch?.[2] || captionMatch?.[3] + const title = titleMatch?.[1] || titleMatch?.[2] || titleMatch?.[3] + + try { + const ast = parseMermaid(code, { caption, title, file: page.route }) + if (!caption && !ast.accDescr && !ast.accTitle && !title) { + errors.push(`${page.route}: mermaid diagram missing required caption="..."`) + } + } catch (err: any) { + errors.push(`${page.route}: ${err.message || String(err)}`) + } + } } diff --git a/apps/docs/scripts/mermaid.test.ts b/apps/docs/scripts/mermaid.test.ts new file mode 100644 index 00000000..33fabaaa --- /dev/null +++ b/apps/docs/scripts/mermaid.test.ts @@ -0,0 +1,228 @@ +import { test, expect, describe } from "bun:test" +import { + parseMermaid, + renderMermaidSvg, + renderMermaidFigureHtml, + generateDiagramId, +} from "../src/lib/mermaid" +import { rehypeMermaid } from "../src/lib/rehype-mermaid" + +describe("DX-054: Build-time Mermaid diagram support", () => { + const validFlowchart = ` +graph TD + Contracts[Soroban contracts
on-chain state] --> RPC[RPC nodes
live reads] + RPC --> Indexer[s03-indexer
database sync] + Indexer --> WebApp[web app
TanStack Query] + WebApp --> Screen[trader screen] +` + + const validSequence = ` +sequenceDiagram + autonumber + actor Trader + participant Web as Web App + participant Router as ExchangeRouter + participant Vault as OrderVault + + Trader->>Web: Click Submit Order + Web->>Router: buildCreateOrderTransaction() + Router->>Vault: Lock collateral + Vault-->>Web: OrderCreated event + Web-->>Trader: Toast confirmation +` + + test("parses flowchart AST correctly with directions and node shapes", () => { + const ast = parseMermaid(validFlowchart, { file: "test.mdx" }) + expect(ast.type).toBe("flowchart") + if (ast.type === "flowchart") { + expect(ast.direction).toBe("TD") + expect(ast.nodes.size).toBe(5) + expect(ast.nodes.get("Contracts")?.label).toBe( + "Soroban contracts
on-chain state" + ) + expect(ast.edges.length).toBe(4) + expect(ast.edges[0].from).toBe("Contracts") + expect(ast.edges[0].to).toBe("RPC") + } + }) + + test("parses sequence diagram AST correctly with actors and messages", () => { + const ast = parseMermaid(validSequence, { file: "seq.mdx" }) + expect(ast.type).toBe("sequence") + if (ast.type === "sequence") { + expect(ast.actors.length).toBe(4) + expect(ast.actors.find((a) => a.id === "Trader")?.isActor).toBe(true) + expect(ast.messages.length).toBe(5) + } + }) + + test("renders dual theme SVGs with token-derived colors and accessible tags", () => { + const ast = parseMermaid(validFlowchart) + const result = renderMermaidSvg( + ast, + { caption: "Data flow architecture" }, + "dia-1" + ) + + expect(result.lightSvg).toContain("Data flow architecture' + ) + expect(result.lightSvg).toContain( + 'Data flow architecture' + ) + expect(result.darkSvg).toContain( + 'Data flow architecture' + ) + expect(result.darkSvg).toContain( + 'Data flow architecture' + ) + + // Verify theme colors differ between light and dark + // Light node text is dark (#0f172a), dark node text is light (#f8fafc) + expect(result.lightSvg).toContain('fill="#0f172a"') + expect(result.darkSvg).toContain('fill="#f8fafc"') + + // Light node fill is white/light (#ffffff), dark node fill is slate (#1e293b) + expect(result.lightSvg).toContain('fill="#ffffff"') + expect(result.darkSvg).toContain('fill="#1e293b"') + + // Brand primary arrows (light: #0284c7, dark: #38bdf8) + expect(result.lightSvg).toContain('fill="#0284c7"') + expect(result.darkSvg).toContain('fill="#38bdf8"') + }) + + test("renders accessible scrollable figure container", () => { + const html = renderMermaidFigureHtml(validFlowchart, { + caption: "Architecture data flow", + title: "Architecture", + }) + + // Figure landmark and caption connection + expect(html).toContain('role="figure"') + expect(html).toContain('aria-labelledby="caption-') + expect(html).toContain('