Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changelog/unreleased/566-build-time-mermaid.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changelog/unreleased/571-docs-e2e.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 12 additions & 19 deletions apps/docs/content/concepts/oracles.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<br/>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

Expand Down
16 changes: 6 additions & 10 deletions apps/docs/content/developers/architecture.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<br/>on-chain state] --> RPC[RPC nodes<br/>live reads]
RPC --> Indexer[s03-indexer<br/>database sync]
Indexer --> WebApp[web app<br/>TanStack Query + Zustand]
WebApp --> Screen[trader screen]
```

### 1. Contracts
Expand Down
17 changes: 15 additions & 2 deletions apps/docs/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")}`
Expand Down Expand Up @@ -34,10 +35,22 @@ function renderInline(value: string) {
.replace(/`([^`]+)`/g, "<code>$1</code>")
}

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
Expand Down Expand Up @@ -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 = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escape(page.frontmatter.title)} · SO4 docs</title><meta name="description" content="${escape(page.frontmatter.description)}"><link rel="stylesheet" href="/assets/${stylesheet}"></head><body class="bg-surface-canvas text-text-primary"><header class="mx-auto flex h-16 max-w-3xl items-center justify-between border-b border-border px-4" data-pagefind-ignore><a class="text-sm font-semibold text-text-primary" href="/">SO4 docs</a><a class="text-sm font-medium text-text-link" href="https://so4.market">Open interface</a></header><main class="mx-auto max-w-3xl px-4 py-10" data-pagefind-body><h1 class="mb-6 text-2xl font-semibold text-text-primary">${escape(page.frontmatter.title)}</h1>${render(page.body)}<footer class="docs-print-footer" data-pagefind-ignore data-print-url="${escape(`${DEFAULT_SITE_URL}${page.route}`)}">Last updated <time datetime="${escape(page.frontmatter.updated)}">${escape(page.frontmatter.updated)}</time></footer></main></body></html>`
const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escape(page.frontmatter.title)} · SO4 docs</title><meta name="description" content="${escape(page.frontmatter.description)}"><link rel="stylesheet" href="/assets/${stylesheet}"></head><body class="bg-surface-canvas text-text-primary"><header class="mx-auto flex h-16 max-w-3xl items-center justify-between border-b border-border px-4" data-pagefind-ignore><a class="text-sm font-semibold text-text-primary" href="/">SO4 docs</a><a class="text-sm font-medium text-text-link" href="https://so4.market">Open interface</a></header><main class="mx-auto max-w-3xl px-4 py-10" data-pagefind-body><h1 class="mb-6 text-2xl font-semibold text-text-primary">${escape(page.frontmatter.title)}</h1>${render(page.body, page.file)}<footer class="docs-print-footer" data-pagefind-ignore data-print-url="${escape(`${DEFAULT_SITE_URL}${page.route}`)}">Last updated <time datetime="${escape(page.frontmatter.updated)}">${escape(page.frontmatter.updated)}</time></footer></main></body></html>`
await Bun.write(join(directory, "index.html"), html)
}

Expand Down
21 changes: 21 additions & 0 deletions apps/docs/scripts/check-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = []
Expand All @@ -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)}`)
}
}
}


Expand Down
228 changes: 228 additions & 0 deletions apps/docs/scripts/mermaid.test.ts
Original file line number Diff line number Diff line change
@@ -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<br/>on-chain state] --> RPC[RPC nodes<br/>live reads]
RPC --> Indexer[s03-indexer<br/>database sync]
Indexer --> WebApp[web app<br/>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<br/>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("<svg")
expect(result.darkSvg).toContain("<svg")

// Verify accessible labels inside SVGs
expect(result.lightSvg).toContain(
'<title id="title-dia-1-light">Data flow architecture</title>'
)
expect(result.lightSvg).toContain(
'<desc id="desc-dia-1-light">Data flow architecture</desc>'
)
expect(result.darkSvg).toContain(
'<title id="title-dia-1-dark">Data flow architecture</title>'
)
expect(result.darkSvg).toContain(
'<desc id="desc-dia-1-dark">Data flow architecture</desc>'
)

// 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('<figcaption id="caption-')
expect(html).toContain("Architecture data flow")

// Scrollable region with tabindex=0 for keyboard accessibility
expect(html).toContain('class="mermaid-scroll overflow-x-auto')
expect(html).toContain('tabindex="0"')
expect(html).toContain('role="region"')
expect(html).toContain('aria-label="Diagram content"')

// Dual theme CSS switching classes
expect(html).toContain("mermaid-diagram-light block dark:hidden")
expect(html).toContain("mermaid-diagram-dark hidden dark:block")
})

test("fails build when caption is missing", () => {
const ast = parseMermaid(validFlowchart, { file: "missing-caption.mdx" })
expect(() => {
renderMermaidSvg(
ast,
{ caption: "", file: "missing-caption.mdx" },
"dia-err"
)
}).toThrow("missing a required caption / text alternative")
})

test("fails build on malformed diagram syntax with file and parser error", () => {
const malformedCode = `
graph TD
A --> [Unclosed bracket
`
expect(() => {
parseMermaid(malformedCode, { file: "broken-diagram.mdx" })
}).toThrow("Mermaid parse error in broken-diagram.mdx")
})

test("rehype plugin transforms fenced mermaid code block into figure HAST node", () => {
const tree = {
type: "root",
children: [
{
type: "element",
tagName: "pre",
children: [
{
type: "element",
tagName: "code",
properties: { className: ["language-mermaid"] },
data: {
meta: 'caption="The complete SO4 trading and settlement data flow" title="Architecture Overview"',
},
children: [
{
type: "text",
value: `graph TD\n A[Client] --> B[Server]\n B --> C[(Database)]`,
},
],
},
],
},
],
}

const plugin = rehypeMermaid()
plugin(tree, { path: "architecture.mdx" })

const figure = tree.children[0]
expect(figure.tagName).toBe("figure")
expect(figure.properties.className).toContain("mermaid-wrapper")
expect(figure.properties.role).toBe("figure")

const scrollDiv = figure.children[0]
expect(scrollDiv.properties.className).toContain("mermaid-scroll")
expect(scrollDiv.properties.tabIndex).toBe(0)

const lightDiv = scrollDiv.children[0]
const darkDiv = scrollDiv.children[1]
expect(lightDiv.properties.className).toContain("mermaid-diagram-light")
expect(lightDiv.properties.className).toContain("block")
expect(lightDiv.properties.className).toContain("dark:hidden")

expect(darkDiv.properties.className).toContain("mermaid-diagram-dark")
expect(darkDiv.properties.className).toContain("hidden")
expect(darkDiv.properties.className).toContain("dark:block")

const figcaption = figure.children[1]
expect(figcaption.tagName).toBe("figcaption")
expect(figcaption.children[0].value).toBe(
"The complete SO4 trading and settlement data flow"
)
})

test("rehype plugin fails when caption is missing in AST", () => {
const tree = {
type: "root",
children: [
{
type: "element",
tagName: "pre",
children: [
{
type: "element",
tagName: "code",
properties: { className: ["language-mermaid"] },
data: { meta: "" },
children: [
{
type: "text",
value: `graph TD\n A --> B`,
},
],
},
],
},
],
}

const plugin = rehypeMermaid()
expect(() => {
plugin(tree, { path: "no-caption.mdx" })
}).toThrow("missing a required caption")
})
})
Loading
Loading