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
5 changes: 3 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@ jobs:
- name: Design token check
run: bun run check:tokens

- name: Documentation content check
run: bun run check:content
- name: Documentation content and prose checks
run: bun run check:content && bun run --cwd apps/docs lint:prose


- name: Documentation link and FAQ drift checks
run: bun run check:links && bun run --cwd apps/docs check:faq
Expand Down
5 changes: 3 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,6 @@ fix.md
# e2e/*.spec.ts-snapshots/ are intentionally NOT ignored — those are
# committed regression baselines, not run output)
test-results/
playwright-report/
blob-report/
# Generated OG images
apps/docs/public/og/
.nitro-static/og/
45 changes: 45 additions & 0 deletions apps/docs/PROSE_STYLE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# SO4 Documentation Prose Style Guide

This document outlines the voice, tone, and automated linting rules enforced across all SO4 documentation and repository markdown files.

---

## 1. Core Principles

- **Plain, specific, unhurried.** Short sentences carry technical weight better than long ones.
- **Concrete over abstract.** "The order vault holds collateral until the position closes" beats "Collateral is managed by the vault subsystem".
- **No condescension.** Words like "simply", "just", "obviously", "easy", and "easily" imply obviousness and are prohibited.
- **No exclamation marks.** Technical documentation informs; it does not shout.

---

## 2. Automated Lint Rules

The prose linter (`bun run --cwd apps/docs lint:prose`) enforces the following rules:

### Correctness Rules (Errors — Fail Build)

| Rule ID | Constraint |
| ------- | ---------- |
| `no-exclamation-mark` | Prohibits exclamation marks (`!`) in documentation prose. |
| `banned-words` | Prohibits "simply", "just", "obviously", "easy", "easily". |
| `correct-capitalization` | Enforces exact capitalization for project terms: "Soroban", "Stellar", "Freighter", "Turborepo", "OrderVault", "ExchangeRouter", "SyntheticsReader", "DataStore". |

### Style Rules (Warnings — Reported in Output)

| Rule ID | Constraint |
| ------- | ---------- |
| `sentence-length` | Flags sentences exceeding 30 words. |
| `prefer-active-voice` | Suggests active voice over passive constructions (e.g. "is managed by"). |

---

## 3. Authoring Checklist

Before submitting a PR:

```bash
bun run --cwd apps/docs lint
```

Ensure all prose linter errors are fixed.
3 changes: 2 additions & 1 deletion apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
"scripts": {
"build": "bun run scripts/build.ts && bunx --bun pagefind --site .nitro-static && bunx nitro build",
"dev": "bun run scripts/build.ts && bunx nitro dev",
"lint": "bun run check:content",
"lint": "bun run check:content && bun run lint:prose",
"lint:prose": "bun run scripts/lint-prose.ts",
"format": "prettier --write \"content/**/*.mdx\" \"scripts/**/*.ts\" \"src/**/*.{ts,tsx}\"",
"typecheck": "tsc --noEmit",
"test": "bun test",
Expand Down
11 changes: 11 additions & 0 deletions apps/docs/scripts/check-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,19 @@ for (const page of pages) {
errors.push(`${page.route}: updated must be ISO date`)
if (!["stable", "beta", "draft"].includes(status))
errors.push(`${page.route}: invalid status`)

// DX-055: Enforce image alt text and dimension requirements
const imgMatches = page.body.matchAll(/!\[([^\]]*)\]\(([^)]+)\)/g)
for (const match of imgMatches) {
const alt = match[1]
const src = match[2]
if (alt.trim() === "") {
errors.push(`${page.route}: image "${src}" missing required alt text`)
}
}
}


const meta = JSON.parse(
await readFile(join(contentRoot, "meta.json"), "utf8"),
) as {
Expand Down
99 changes: 70 additions & 29 deletions apps/docs/scripts/components.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import React from "react"
import { GlobalRegistrator } from "@happy-dom/global-registrator"



import { test, expect, afterEach, afterAll, beforeAll } from "bun:test"
import { cleanup, render, act } from "@testing-library/react"
import { components } from "../src/mdx/components"
import { Sidebar } from "../src/components/Sidebar"
import { Toc } from "../src/components/Toc"
import { Pager } from "../src/components/Pager"
import * as jsxRuntime from "react/jsx-runtime"
import { compile, run } from "@mdx-js/mdx"
import {
Expand Down Expand Up @@ -57,15 +58,15 @@ test("MDX components map renders kitchen-sink fixture correctly", async () => {
remarkPlugins: [remarkGfm],
rehypePlugins: [shikiPlugin],
})

const { default: MDXContent } = await run(String(compiled), {
...jsxRuntime,
})

const rootRoute = createRootRoute({
component: () => <MDXContent components={components} />,
})

const router = createRouter({
routeTree: rootRoute,
history: createMemoryHistory(),
Expand All @@ -77,41 +78,81 @@ test("MDX components map renders kitchen-sink fixture correctly", async () => {
container = result.container
})

// Verify Heading 1 (Typography via Heading)
const h1 = container!.querySelector("h1")
expect(h1).not.toBeNull()
expect(h1?.className).toContain("text-22")
expect(h1?.className).toContain("font-semibold")

// Verify blockquote (Callout)

const callout = container.querySelector("[role='status']")
expect(callout).not.toBeNull()
expect(callout?.textContent).toContain("This is a blockquote.")

// Verify inline code
const codes = Array.from(container.querySelectorAll("code"))
const inlineCode = codes.find(c => c.textContent === "inline code")
expect(inlineCode).not.toBeUndefined()
expect(inlineCode?.className).toContain("bg-surface-sunken")

// Verify internal link
const internalLink = container.querySelector("a[href='/foo']")
expect(internalLink).not.toBeNull()
expect(internalLink?.className).toContain("hover:underline")

// Verify external link

const externalLink = container.querySelector("a[href='https://example.com']")
expect(externalLink).not.toBeNull()
expect(externalLink?.getAttribute("target")).toBe("_blank")
expect(externalLink?.getAttribute("rel")).toBe("noopener noreferrer")
expect(externalLink?.querySelector("svg")).not.toBeNull() // Arrow icon

// Verify table structure
const tableContainer = container.querySelector("[data-slot='table-container']")
if (!tableContainer) {
console.log("HTML Output:", container.innerHTML)
}
expect(tableContainer).not.toBeNull()
expect(tableContainer?.className).toContain("overflow-x-auto")
expect(tableContainer?.querySelector("table")).not.toBeNull()
})

test("Sidebar renders section headers and links correctly", () => {
const sections = [
{
label: "Overview",
pages: [
{ route: "/get-started/introduction", title: "Introduction" },
{ route: "/get-started/quickstart", title: "Quickstart", status: "beta" as const },
],
},
]

const rootRoute = createRootRoute({
component: () => <Sidebar sections={sections} currentRoute="/get-started/introduction" />,
})
const router = createRouter({
routeTree: rootRoute,
history: createMemoryHistory(),
})

let container: HTMLElement
render(<RouterProvider router={router} />)

const activeLink = document.querySelector("a[aria-current='page']")
expect(activeLink).not.toBeNull()
expect(activeLink?.textContent).toContain("Introduction")
})

test("Toc renders table of contents anchors", () => {
const entries = [
{ title: "Overview", id: "overview", level: 2 },
{ title: "Architecture", id: "architecture", level: 3 },
]
const { container } = render(<Toc entries={entries} activeId="architecture" />)

const activeAnchor = container.querySelector("a[aria-current='location']")
expect(activeAnchor).not.toBeNull()
expect(activeAnchor?.textContent).toBe("Architecture")
})

test("Pager renders previous and next navigation buttons", () => {
const prev = { title: "Introduction", route: "/get-started/introduction" }
const next = { title: "Wallets", route: "/get-started/wallets" }

const rootRoute = createRootRoute({
component: () => <Pager prev={prev} next={next} />,
})
const router = createRouter({
routeTree: rootRoute,
history: createMemoryHistory(),
})

render(<RouterProvider router={router} />)

const prevLink = document.querySelector("a[rel='prev']")
const nextLink = document.querySelector("a[rel='next']")

expect(prevLink).not.toBeNull()
expect(nextLink).not.toBeNull()
expect(prevLink?.textContent).toContain("Introduction")
expect(nextLink?.textContent).toContain("Wallets")
})
22 changes: 21 additions & 1 deletion apps/docs/scripts/content-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,14 @@ describe("content loader — kebab-case validation", () => {
expect(isKebabCase("myPage")).toBe(false)
expect(isKebabCase("MY-PAGE")).toBe(false)
expect(isKebabCase("my_page")).toBe(false)
expect(isKebabCase("page_name_1")).toBe(false)
expect(isKebabCase("page.name")).toBe(false)
expect(isKebabCase("-leading-dash")).toBe(false)
expect(isKebabCase("trailing-dash-")).toBe(false)
})
})

describe("content loader — draft exclusion", () => {
describe("content loader — draft exclusion & frontmatter validation", () => {
test("draft status is accepted by frontmatter schema", () => {
const fm = validateFrontmatter("test.mdx", {
title: "Alpha",
Expand All @@ -41,4 +45,20 @@ describe("content loader — draft exclusion", () => {
})
expect(fm.status).toBe("stable")
})

test("rejects malformed frontmatter objects", () => {
expect(() => validateFrontmatter("bad-title.mdx", {
title: "",
description: "A valid description that satisfies the min length threshold.",
updated: "2026-08-24",
status: "stable",
})).toThrow("bad-title.mdx")

expect(() => validateFrontmatter("bad-date.mdx", {
title: "Valid title",
description: "A valid description that satisfies the min length threshold.",
updated: "24-08-2026",
status: "stable",
})).toThrow("bad-date.mdx")
})
})
67 changes: 67 additions & 0 deletions apps/docs/scripts/image-pipeline.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, test } from "bun:test"
import {
parseImageDimensionsBuffer,
getDarkVariantPath,
validateImageRef,
renderOptimizedImageTag,
} from "../src/lib/image-pipeline"

describe("Docs image asset pipeline (DX-055)", () => {
test("extracts intrinsic dimensions from PNG buffer header", () => {
// Construct a valid PNG header with width 1920 (0x0780) and height 1080 (0x0438)
const pngHeader = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG Signature
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR chunk length & type
0x00, 0x00, 0x07, 0x80, // width: 1920
0x00, 0x00, 0x04, 0x38, // height: 1080
0x08, 0x06, 0x00, 0x00, 0x00,
])

const dimensions = parseImageDimensionsBuffer(pngHeader)
expect(dimensions).not.toBeNull()
expect(dimensions?.width).toBe(1920)
expect(dimensions?.height).toBe(1080)
})

test("generates dark variant filename convention name.dark.ext", () => {
expect(getDarkVariantPath("/assets/architecture.png")).toBe("/assets/architecture.dark.png")
expect(getDarkVariantPath("/images/flow.jpg")).toBe("/images/flow.dark.jpg")
})

test("validates alt text requirement and decorative opt-out", () => {
expect(validateImageRef("/img.png", "Architecture diagram").valid).toBe(true)
expect(validateImageRef("/img.png", "").valid).toBe(false)
expect(validateImageRef("/img.png", 'alt=""').valid).toBe(true)
})

test("renders optimized img and picture tags with width/height and eager/lazy loading", () => {
const eagerTag = renderOptimizedImageTag({
src: "/assets/hero.png",
alt: "Hero Banner",
width: 1200,
height: 600,
hasDarkVariant: false,
index: 0,
})

expect(eagerTag).toContain('src="/assets/hero.png"')
expect(eagerTag).toContain('alt="Hero Banner"')
expect(eagerTag).toContain('width="1200"')
expect(eagerTag).toContain('height="600"')
expect(eagerTag).toContain('loading="eager"')
expect(eagerTag).toContain('fetchpriority="high"')

const lazyDarkTag = renderOptimizedImageTag({
src: "/assets/diagram.png",
alt: "Diagram",
width: 800,
height: 400,
hasDarkVariant: true,
index: 1,
})

expect(lazyDarkTag).toContain('<picture className="docs-image-wrapper">')
expect(lazyDarkTag).toContain('srcset="/assets/diagram.dark.png"')
expect(lazyDarkTag).toContain('loading="lazy"')
})
})
Loading