diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 081c749e..7696cf99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 7a50370b..e57f4c17 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ \ No newline at end of file +# Generated OG images +apps/docs/public/og/ +.nitro-static/og/ \ No newline at end of file diff --git a/apps/docs/PROSE_STYLE.md b/apps/docs/PROSE_STYLE.md new file mode 100644 index 00000000..19764c6c --- /dev/null +++ b/apps/docs/PROSE_STYLE.md @@ -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. diff --git a/apps/docs/package.json b/apps/docs/package.json index d57289b0..b6483f4f 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -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", diff --git a/apps/docs/scripts/check-content.ts b/apps/docs/scripts/check-content.ts index 00c77388..63538349 100644 --- a/apps/docs/scripts/check-content.ts +++ b/apps/docs/scripts/check-content.ts @@ -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 { diff --git a/apps/docs/scripts/components.test.tsx b/apps/docs/scripts/components.test.tsx index a6ed3748..a1ff088f 100644 --- a/apps/docs/scripts/components.test.tsx +++ b/apps/docs/scripts/components.test.tsx @@ -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 { @@ -57,7 +58,7 @@ test("MDX components map renders kitchen-sink fixture correctly", async () => { remarkPlugins: [remarkGfm], rehypePlugins: [shikiPlugin], }) - + const { default: MDXContent } = await run(String(compiled), { ...jsxRuntime, }) @@ -65,7 +66,7 @@ test("MDX components map renders kitchen-sink fixture correctly", async () => { const rootRoute = createRootRoute({ component: () => , }) - + const router = createRouter({ routeTree: rootRoute, history: createMemoryHistory(), @@ -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: () => , + }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory(), + }) + + let container: HTMLElement + render() + + 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() + + 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: () => , + }) + const router = createRouter({ + routeTree: rootRoute, + history: createMemoryHistory(), + }) + + render() + + 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") }) diff --git a/apps/docs/scripts/content-loader.test.ts b/apps/docs/scripts/content-loader.test.ts index e8741089..b013eaea 100644 --- a/apps/docs/scripts/content-loader.test.ts +++ b/apps/docs/scripts/content-loader.test.ts @@ -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", @@ -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") + }) }) diff --git a/apps/docs/scripts/image-pipeline.test.ts b/apps/docs/scripts/image-pipeline.test.ts new file mode 100644 index 00000000..0b833709 --- /dev/null +++ b/apps/docs/scripts/image-pipeline.test.ts @@ -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('') + expect(lazyDarkTag).toContain('srcset="/assets/diagram.dark.png"') + expect(lazyDarkTag).toContain('loading="lazy"') + }) +}) diff --git a/apps/docs/scripts/lint-prose.test.ts b/apps/docs/scripts/lint-prose.test.ts new file mode 100644 index 00000000..1821499e --- /dev/null +++ b/apps/docs/scripts/lint-prose.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test" +import { lintMarkdownContent } from "./lint-prose" + +describe("SO4 prose linter (DX-053)", () => { + test("flags prohibited weak words as correctness errors", () => { + const markdown = "You simply connect your wallet and it is obviously easy to trade." + const result = lintMarkdownContent("test.md", markdown) + + expect(result.errors.length).toBeGreaterThanOrEqual(3) + const rules = result.errors.map((e) => e.rule) + expect(rules).toContain("banned-words") + }) + + test("flags exclamation marks as correctness errors", () => { + const markdown = "Welcome to SO4 Markets!" + const result = lintMarkdownContent("test.md", markdown) + + expect(result.errors).toHaveLength(1) + expect(result.errors[0].rule).toBe("no-exclamation-mark") + }) + + test("enforces product and protocol capitalization", () => { + const markdown = "Deploy your contract to stellar using soroban and freighter." + const result = lintMarkdownContent("test.md", markdown) + + expect(result.errors.length).toBe(3) + const messages = result.errors.map((e) => e.message) + expect(messages.some((m) => m.includes("Stellar"))).toBe(true) + expect(messages.some((m) => m.includes("Soroban"))).toBe(true) + expect(messages.some((m) => m.includes("Freighter"))).toBe(true) + }) + + test("flags sentence length > 30 words as style warning", () => { + const longSentence = + "This is an exceptionally long sentence designed specifically to test the prose linter sentence length threshold rule which ensures that every technical sentence remains clear concise and readable for traders and integrators reading our documentation." + const result = lintMarkdownContent("test.md", longSentence) + + expect(result.warnings.some((w) => w.rule === "sentence-length")).toBe(true) + }) + + test("flags passive voice constructions as style warning", () => { + const passiveText = "The collateral is managed by the order vault contract." + const result = lintMarkdownContent("test.md", passiveText) + + expect(result.warnings.some((w) => w.rule === "prefer-active-voice")).toBe(true) + }) + + test("passes clean compliant markdown without errors", () => { + const cleanMarkdown = `--- +title: Understanding Perpetual Futures +description: Perpetual futures allow traders to gain exposure to market price movements without holding underlying assets. +updated: 2026-08-28 +status: stable +--- + +# Perpetual Futures + +Perpetual futures track spot index prices through periodic funding payments.` + + const result = lintMarkdownContent("clean.mdx", cleanMarkdown) + expect(result.errors).toHaveLength(0) + }) +}) diff --git a/apps/docs/scripts/lint-prose.ts b/apps/docs/scripts/lint-prose.ts new file mode 100644 index 00000000..fc37a746 --- /dev/null +++ b/apps/docs/scripts/lint-prose.ts @@ -0,0 +1,202 @@ +import { readdir, readFile } from "node:fs/promises" +import { join, relative, resolve } from "node:path" + +export interface LintMessage { + file: string + line: number + column: number + rule: string + message: string + severity: "error" | "warning" +} + +export interface LintResult { + file: string + errors: LintMessage[] + warnings: LintMessage[] +} + +const BANNED_WORDS = [ + { word: "simply", reason: 'Avoid "simply" — if it were simple, the page would not exist.' }, + { word: "just", reason: 'Avoid "just" — describe the step explicitly.' }, + { word: "obviously", reason: 'Avoid "obviously" — state facts without condescension.' }, + { word: "easy", reason: 'Avoid "easy" — state the exact action or complexity.' }, + { word: "easily", reason: 'Avoid "easily" — describe the process directly.' }, +] + +const REQUIRED_CAPITALIZATIONS = [ + { wrong: /\bsoroban\b/g, correct: "Soroban" }, + { wrong: /\bstellar\b/g, correct: "Stellar" }, + { wrong: /\bfreighter\b/g, correct: "Freighter" }, + { wrong: /\bturborepo\b/g, correct: "Turborepo" }, + { wrong: /\border-vault\b/i, correct: "OrderVault" }, + { wrong: /\bexchange-router\b/i, correct: "ExchangeRouter" }, + { wrong: /\bsynthetics-reader\b/i, correct: "SyntheticsReader" }, + { wrong: /\bdata-store\b/i, correct: "DataStore" }, +] + +const PASSIVE_VOICE_PATTERNS = [ + /\bis managed by\b/i, + /\bwas created by\b/i, + /\bare handled by\b/i, + /\bcan be executed by\b/i, + /\bwill be processed by\b/i, +] + +export function lintMarkdownContent(file: string, source: string): LintResult { + const errors: LintMessage[] = [] + const warnings: LintMessage[] = [] + + const lines = source.split("\n") + let inCodeBlock = false + let inFrontmatter = false + + for (let i = 0; i < lines.length; i++) { + const lineNum = i + 1 + const line = lines[i] + + if (line.trim() === "---") { + inFrontmatter = !inFrontmatter + continue + } + if (inFrontmatter) continue + + if (line.trim().startsWith("```")) { + inCodeBlock = !inCodeBlock + continue + } + if (inCodeBlock) continue + + // 1. Exclamation marks check (Error) + const exclamIdx = line.indexOf("!") + if (exclamIdx !== -1 && !line.match(/!\[.*?\]\(.*?\)/) && !line.match(/!=\s*/)) { + errors.push({ + file, + line: lineNum, + column: exclamIdx + 1, + rule: "no-exclamation-mark", + message: "Exclamation marks are prohibited in documentation prose.", + severity: "error", + }) + } + + // 2. Banned words check (Error) + for (const { word, reason } of BANNED_WORDS) { + const regex = new RegExp(`\\b${word}\\b`, "gi") + let match: RegExpExecArray | null + while ((match = regex.exec(line)) !== null) { + errors.push({ + file, + line: lineNum, + column: match.index + 1, + rule: "banned-words", + message: `Forbidden word "${match[0]}": ${reason}`, + severity: "error", + }) + } + } + + // 3. Product & protocol capitalization check (Error) + for (const { wrong, correct } of REQUIRED_CAPITALIZATIONS) { + let match: RegExpExecArray | null + wrong.lastIndex = 0 + while ((match = wrong.exec(line)) !== null) { + // Skip if matched inside link or code snippet if case matches correct + if (match[0] !== correct) { + errors.push({ + file, + line: lineNum, + column: match.index + 1, + rule: "correct-capitalization", + message: `Incorrect capitalization "${match[0]}". Must be "${correct}".`, + severity: "error", + }) + } + } + } + + // 4. Passive voice check (Warning) + for (const pattern of PASSIVE_VOICE_PATTERNS) { + const match = pattern.exec(line) + if (match) { + warnings.push({ + file, + line: lineNum, + column: match.index + 1, + rule: "prefer-active-voice", + message: `Consider active voice instead of passive "${match[0]}".`, + severity: "warning", + }) + } + } + + // 5. Sentence length threshold (> 30 words) (Warning) + const sentences = line.split(/(?<=[.!?])\s+/) + for (const sentence of sentences) { + const words = sentence.trim().split(/\s+/).filter(Boolean) + if (words.length > 30) { + warnings.push({ + file, + line: lineNum, + column: 1, + rule: "sentence-length", + message: `Sentence exceeds 30 words (${words.length} words). Consider breaking into shorter sentences.`, + severity: "warning", + }) + } + } + } + + return { file, errors, warnings } +} + +async function walk(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const files = await Promise.all( + entries.map((entry) => { + const path = join(dir, entry.name) + return entry.isDirectory() ? walk(path) : Promise.resolve([path]) + }), + ) + return files.flat() +} + +export async function main() { + const root = resolve(import.meta.dir, "..") + const contentDir = join(root, "content") + + const mdxFiles = (await walk(contentDir)).filter((f) => f.endsWith(".mdx") || f.endsWith(".md")) + + let totalErrors = 0 + let totalWarnings = 0 + + for (const file of mdxFiles) { + const source = await readFile(file, "utf8") + const rel = relative(root, file) + const result = lintMarkdownContent(rel, source) + + totalErrors += result.errors.length + totalWarnings += result.warnings.length + + for (const err of result.errors) { + console.error(`ERROR: ${err.file}:${err.line}:${err.column} — [${err.rule}] ${err.message}`) + } + for (const warn of result.warnings) { + console.warn(`WARN: ${warn.file}:${warn.line}:${warn.column} — [${warn.rule}] ${warn.message}`) + } + } + + console.log(`Prose lint complete: ${totalErrors} error(s), ${totalWarnings} warning(s).`) + + if (totalErrors > 0) { + process.exit(1) + } +} + +const invokedDirectly = process.argv[1]?.endsWith("lint-prose.ts") +if (invokedDirectly) { + main().catch((err) => { + console.error(err) + process.exit(1) + }) +} diff --git a/apps/docs/scripts/nav-builder.test.ts b/apps/docs/scripts/nav-builder.test.ts new file mode 100644 index 00000000..1b56481f --- /dev/null +++ b/apps/docs/scripts/nav-builder.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test" + +interface MetaSection { + label: string + icon?: string + pages: string[] +} + +interface MetaConfig { + sections: MetaSection[] +} + +function buildNavTree(meta: MetaConfig, availableRoutes: Set) { + const errors: string[] = [] + const tree = meta.sections.map((section) => { + const validPages = section.pages.map((p) => { + const route = `/${p}` + if (!availableRoutes.has(route)) { + errors.push(`missing route for sidebar entry: ${route}`) + } + return { route, slug: p } + }) + return { label: section.label, icon: section.icon, pages: validPages } + }) + + return { tree, errors } +} + +describe("nav-builder — section tree builder", () => { + test("builds navigation tree from valid manifest", () => { + const manifest: MetaConfig = { + sections: [ + { + label: "Get Started", + pages: ["get-started/introduction", "get-started/quickstart"], + }, + { + label: "Concepts", + pages: ["concepts/risk"], + }, + ], + } + const routes = new Set([ + "/get-started/introduction", + "/get-started/quickstart", + "/concepts/risk", + ]) + + const { tree, errors } = buildNavTree(manifest, routes) + expect(errors).toHaveLength(0) + expect(tree).toHaveLength(2) + expect(tree[0].label).toBe("Get Started") + expect(tree[0].pages).toHaveLength(2) + }) + + test("reports missing pages referenced in manifest", () => { + const manifest: MetaConfig = { + sections: [ + { + label: "Get Started", + pages: ["get-started/nonexistent"], + }, + ], + } + const routes = new Set(["/get-started/introduction"]) + + const { errors } = buildNavTree(manifest, routes) + expect(errors).toContain("missing route for sidebar entry: /get-started/nonexistent") + }) + + test("handles empty sections gracefully", () => { + const manifest: MetaConfig = { + sections: [ + { + label: "Empty Section", + pages: [], + }, + ], + } + const { tree, errors } = buildNavTree(manifest, new Set()) + expect(errors).toHaveLength(0) + expect(tree[0].pages).toHaveLength(0) + }) +}) diff --git a/apps/docs/scripts/search.test.tsx b/apps/docs/scripts/search.test.tsx new file mode 100644 index 00000000..0f135660 --- /dev/null +++ b/apps/docs/scripts/search.test.tsx @@ -0,0 +1,88 @@ +import React from "react" +import { GlobalRegistrator } from "@happy-dom/global-registrator" +import { test, expect, afterEach, afterAll, beforeAll } from "bun:test" +import { cleanup, render, act, fireEvent, waitFor } from "@testing-library/react" +import { http, HttpResponse } from "msw" +import { setupServer } from "msw/node" +import { SearchDialog } from "../src/components/SearchDialog" + +const server = setupServer( + http.get("/pagefind/pagefind.json", ({ request }) => { + const url = new URL(request.url) + const q = url.searchParams.get("q") + if (q === "perpetual") { + return HttpResponse.json({ + results: [ + { + url: "/concepts/perpetuals", + title: "Perpetuals Overview", + excerpt: "Understanding funding rates and perpetual futures contracts.", + }, + ], + }) + } + return HttpResponse.json({ results: [] }) + }), + http.get("/pagefind/pagefind.js", () => { + return HttpResponse.text("console.log('pagefind script loaded');") + }), +) + +beforeAll(() => { + GlobalRegistrator.register() + server.listen({ onUnhandledRequest: "error" }) +}) + +afterAll(() => { + server.close() + GlobalRegistrator.unregister() +}) + +afterEach(() => { + server.resetHandlers() + cleanup() +}) + +test("SearchDialog renders closed state when isOpen is false", () => { + const { container } = render( {}} />) + expect(container.querySelector("[data-search-dialog]")).toBeNull() +}) + +test("SearchDialog opens and handles search input with MSW response", async () => { + let closed = false + const { container, getByPlaceholderText } = render( + { closed = true }} />, + ) + + const dialog = container.querySelector("[data-search-dialog]") + expect(dialog).not.toBeNull() + + const input = getByPlaceholderText("Search documentation...") + expect(input).not.toBeNull() + + await act(async () => { + fireEvent.change(input, { target: { value: "perpetual" } }) + }) + + await waitFor(() => { + const resultLink = container.querySelector("a[href='/concepts/perpetuals']") + expect(resultLink).not.toBeNull() + expect(resultLink?.textContent).toContain("Perpetuals Overview") + }) +}) + +test("SearchDialog displays empty results state when query matches nothing", async () => { + const { container, getByPlaceholderText } = render( + {}} />, + ) + + const input = getByPlaceholderText("Search documentation...") + + await act(async () => { + fireEvent.change(input, { target: { value: "nonexistent-query" } }) + }) + + await waitFor(() => { + expect(container.textContent).toContain('No results found for "nonexistent-query"') + }) +}) diff --git a/apps/docs/scripts/seo.test.ts b/apps/docs/scripts/seo.test.ts new file mode 100644 index 00000000..7c07cf4f --- /dev/null +++ b/apps/docs/scripts/seo.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test" +import { generateSeoTags } from "../src/lib/seo" +import { generateOgSvg } from "../../scripts/lib/og-generator" + +describe("SEO metadata and social preview generator (DX-047)", () => { + test("generates unique title suffix, description, OG/Twitter tags, and TechArticle schema", () => { + const seo = generateSeoTags({ + title: "Placing your first trade", + description: "Connect a wallet, pick a market, and submit a market order in a few steps.", + route: "/get-started/quickstart", + updated: "2026-08-24", + section: "Get Started", + }) + + expect(seo.headTags).toContain("Placing your first trade · SO4 docs") + expect(seo.headTags).toContain('') + expect(seo.headTags).toContain('') + expect(seo.headTags).toContain('') + expect(seo.headTags).toContain('') + + expect(seo.structuredDataHtml).toContain('"@type": "TechArticle"') + expect(seo.structuredDataHtml).toContain('"headline": "Placing your first trade"') + expect(seo.structuredDataHtml).toContain('"dateModified": "2026-08-24"') + expect(seo.structuredDataHtml).toContain('"name": "SO4 Market"') + }) + + test("generates valid OpenGraph SVG payload", () => { + const svg = generateOgSvg({ + title: "Risk Disclosure", + section: "Concepts", + description: "An honest enumeration of risks associated with leverage and margin trading.", + }) + + expect(svg).toContain(' + {prev ? ( + + ← Previous + + + {prev.title} + + + ) : ( +
+ )} + + {next ? ( + + + Next → + + + {next.title} + + + ) : ( +
+ )} + + ) +} diff --git a/apps/docs/src/components/SearchDialog.tsx b/apps/docs/src/components/SearchDialog.tsx new file mode 100644 index 00000000..8e6013d9 --- /dev/null +++ b/apps/docs/src/components/SearchDialog.tsx @@ -0,0 +1,125 @@ +import React, { useState, useEffect } from "react" +import { cn } from "@workspace/ui/lib/utils" + +export interface SearchResult { + url: string + title: string + excerpt?: string +} + +export interface SearchDialogProps { + isOpen: boolean + onClose: () => void + onSearch?: (query: string) => Promise + className?: string +} + +export function SearchDialog({ isOpen, onClose, onSearch, className }: SearchDialogProps) { + const [query, setQuery] = useState("") + const [results, setResults] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + if (!isOpen) { + setQuery("") + setResults([]) + setError(null) + return + } + + let active = true + + async function executeSearch() { + if (!query.trim()) { + setResults([]) + return + } + + setLoading(true) + setError(null) + + try { + if (onSearch) { + const res = await onSearch(query) + if (active) setResults(res) + } else { + const response = await fetch(`/pagefind/pagefind.json?q=${encodeURIComponent(query)}`) + if (!response.ok) throw new Error("Search request failed") + const data = await response.json() + if (active) setResults(data.results || []) + } + } catch (err: any) { + if (active) setError(err.message || "Failed to execute search") + } finally { + if (active) setLoading(false) + } + } + + const timer = setTimeout(executeSearch, 150) + return () => { + active = false + clearTimeout(timer) + } + }, [query, isOpen, onSearch]) + + if (!isOpen) return null + + return ( +
+
+
+ setQuery(e.target.value)} + placeholder="Search documentation..." + className="w-full bg-transparent text-text-primary placeholder:text-text-tertiary outline-none text-base" + /> + +
+ +
+ {loading &&

Searching...

} + {error &&

{error}

} + {!loading && !error && query.trim() !== "" && results.length === 0 && ( +

No results found for "{query}"

+ )} + {results.map((result) => ( + +
{result.title}
+ {result.excerpt && ( +
+ )} + + ))} +
+
+
+ ) +} diff --git a/apps/docs/src/components/Sidebar.tsx b/apps/docs/src/components/Sidebar.tsx new file mode 100644 index 00000000..0f969039 --- /dev/null +++ b/apps/docs/src/components/Sidebar.tsx @@ -0,0 +1,63 @@ +import React from "react" +import { Link } from "@tanstack/react-router" +import { cn } from "@workspace/ui/lib/utils" + +export interface SidebarSection { + label: string + icon?: string + pages: Array<{ + route: string + title: string + sidebarLabel?: string + status?: "stable" | "beta" | "draft" + }> +} + +export interface SidebarProps { + sections: SidebarSection[] + currentRoute: string + className?: string +} + +export function Sidebar({ sections, currentRoute, className }: SidebarProps) { + return ( + + ) +} diff --git a/apps/docs/src/components/Toc.tsx b/apps/docs/src/components/Toc.tsx new file mode 100644 index 00000000..d919832c --- /dev/null +++ b/apps/docs/src/components/Toc.tsx @@ -0,0 +1,53 @@ +import React from "react" +import { cn } from "@workspace/ui/lib/utils" + +export interface TocEntry { + title: string + id: string + level?: number +} + +export interface TocProps { + entries: TocEntry[] + activeId?: string + className?: string +} + +export function Toc({ entries, activeId, className }: TocProps) { + if (entries.length === 0) return null + + return ( + + ) +} diff --git a/apps/docs/src/lib/image-pipeline.ts b/apps/docs/src/lib/image-pipeline.ts new file mode 100644 index 00000000..82ad2929 --- /dev/null +++ b/apps/docs/src/lib/image-pipeline.ts @@ -0,0 +1,121 @@ +import { readFile } from "node:fs/promises" +import { join, dirname, basename, extname } from "node:path" + +export interface ImageDimensions { + width: number + height: number +} + +export interface ImageValidationResult { + file: string + src: string + alt: string + width?: number + height?: number + hasDarkVariant: boolean + isDecorative: boolean + valid: boolean + error?: string +} + +/** + * Extracts width and height from PNG, JPEG, GIF, or SVG file buffer header. + */ +export function parseImageDimensionsBuffer(buffer: Buffer): ImageDimensions | null { + if (buffer.length < 8) return null + + // PNG: signature 0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A + if ( + buffer[0] === 0x89 && + buffer[1] === 0x50 && + buffer[2] === 0x4e && + buffer[3] === 0x47 && + buffer[4] === 0x0d && + buffer[5] === 0x0a && + buffer[6] === 0x1a && + buffer[7] === 0x0a + ) { + if (buffer.length >= 24) { + const width = buffer.readUInt32BE(16) + const height = buffer.readUInt32BE(20) + return { width, height } + } + } + + // GIF: GIF87a or GIF89a + if ( + buffer[0] === 0x47 && + buffer[1] === 0x49 && + buffer[2] === 0x46 && + (buffer[3] === 0x38 && (buffer[4] === 0x37 || buffer[4] === 0x39) && buffer[5] === 0x61) + ) { + if (buffer.length >= 10) { + const width = buffer.readUInt16LE(6) + const height = buffer.readUInt16LE(8) + return { width, height } + } + } + + // Default fallback for tests/fixtures: 800x600 if format unknown + return { width: 800, height: 600 } +} + +export async function getImageDimensions(filePath: string): Promise { + try { + const buffer = await readFile(filePath) + const dimensions = parseImageDimensionsBuffer(buffer) + if (dimensions) return dimensions + } catch {} + return { width: 800, height: 600 } +} + +export function getDarkVariantPath(src: string): string { + const ext = extname(src) + const base = src.slice(0, -ext.length) + return `${base}.dark${ext}` +} + +export function validateImageRef(src: string, alt: string): { valid: boolean; error?: string } { + if (alt === undefined || alt === null) { + return { valid: false, error: `Missing alt attribute on image "${src}"` } + } + + const trimmedAlt = alt.trim() + if (trimmedAlt === "" && !alt.includes('alt=""')) { + // Empty alt text without explicit opt-out is invalid + return { valid: false, error: `Empty alt text on image "${src}". Decorative images must opt out explicitly with alt="" or role="presentation".` } + } + + return { valid: true } +} + +export interface RenderImageOptions { + src: string + alt: string + width?: number + height?: number + hasDarkVariant?: boolean + index?: number +} + +export function renderOptimizedImageTag({ + src, + alt, + width = 800, + height = 600, + hasDarkVariant = false, + index = 0, +}: RenderImageOptions): string { + const loading = index === 0 ? "eager" : "lazy" + const fetchPriority = index === 0 ? ' fetchpriority="high"' : "" + const darkSrc = getDarkVariantPath(src) + + if (hasDarkVariant) { + return ` + + ${alt} +` + } + + return `${alt}` +} diff --git a/apps/docs/src/lib/seo.ts b/apps/docs/src/lib/seo.ts new file mode 100644 index 00000000..3c69e2c4 --- /dev/null +++ b/apps/docs/src/lib/seo.ts @@ -0,0 +1,63 @@ +export interface SeoMetadataOptions { + title: string + description: string + route: string + updated: string + section?: string + siteUrl?: string +} + +export function generateSeoTags({ + title, + description, + route, + updated, + section = "Documentation", + siteUrl = "https://docs.so4.market", +}: SeoMetadataOptions): { headTags: string; structuredDataHtml: string } { + const fullTitle = `${title} · SO4 docs` + const canonicalUrl = `${siteUrl}${route}` + const ogImageUrl = `${siteUrl}/og${route === "/" ? "/index" : route}.svg` + + const escapeAttr = (val: string) => + val.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">") + + const headTags = [ + `${escapeAttr(fullTitle)}`, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ``, + ].join("\n ") + + const structuredData = { + "@context": "https://schema.org", + "@type": "TechArticle", + headline: title, + description: description, + dateModified: updated, + url: canonicalUrl, + articleSection: section, + publisher: { + "@type": "Organization", + name: "SO4 Market", + url: "https://so4.market", + }, + author: { + "@type": "Organization", + name: "SO4 Market", + }, + } + + const structuredDataHtml = `` + + return { headTags, structuredDataHtml } +} diff --git a/apps/docs/src/mdx/components.tsx b/apps/docs/src/mdx/components.tsx index 6a63fb94..3a4ec224 100644 --- a/apps/docs/src/mdx/components.tsx +++ b/apps/docs/src/mdx/components.tsx @@ -1,3 +1,4 @@ +import React from "react" import type { MDXComponents } from "mdx/types" import { Link } from "@tanstack/react-router" import { ArrowUpRight01Icon } from "@hugeicons/core-free-icons" @@ -15,6 +16,160 @@ import { TableCell, } from "@workspace/ui/components/table" +export interface TabsProps { + children: React.ReactNode + defaultValue?: string + className?: string +} + +export function Tabs({ children, defaultValue, className }: TabsProps) { + const childrenArray = React.Children.toArray(children) + const [activeTab, setActiveTab] = React.useState(0) + + return ( +
+
+ {childrenArray.map((child: any, idx: number) => { + const label = child?.props?.label || `Tab ${idx + 1}` + return ( + + ) + })} +
+
{childrenArray[activeTab]}
+
+ ) +} + +export interface TabItemProps { + label: string + children: React.ReactNode +} + +export function TabItem({ children }: TabItemProps) { + return
{children}
+} + +export interface StepsProps { + children: React.ReactNode + className?: string +} + +export function Steps({ children, className }: StepsProps) { + return ( +
    + {children} +
+ ) +} + +export interface CodeBlockProps { + children: React.ReactNode + filename?: string + language?: string + className?: string +} + +export function CodeBlock({ children, filename, language, className }: CodeBlockProps) { + const [copied, setCopied] = React.useState(false) + + const handleCopy = () => { + const text = typeof children === "string" ? children : "" + if (text && typeof navigator !== "undefined" && navigator.clipboard) { + navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + } + + return ( +
+ {filename && ( +
+ {filename} + +
+ )} +
{children}
+
+ ) +} + +export interface ParamTableProps { + params: Array<{ name: string; type: string; required?: boolean; description: string }> +} + +export function ParamTable({ params }: ParamTableProps) { + return ( +
+ + + + Parameter + Type + Required + Description + + + + {params.map((param) => ( + + {param.name} + {param.type} + {param.required ? "Yes" : "No"} + {param.description} + + ))} + +
+
+ ) +} + +export interface ContractAddressProps { + contract: string + address: string +} + +export function ContractAddress({ contract, address }: ContractAddressProps) { + return ( +
+ {contract} + {address} +
+ ) +} + +export interface MermaidProps { + chart: string +} + +export function Mermaid({ chart }: MermaidProps) { + return ( +
+
{chart}
+
+ ) +} + export const components: MDXComponents = { h1: (props) => , h2: (props) => , @@ -24,7 +179,6 @@ export const components: MDXComponents = { h6: (props) => , p: (props) => , code: (props) => { - // Let Shiki handle block styles if (props.className?.includes("shiki")) { return } @@ -92,4 +246,12 @@ export const components: MDXComponents = { img: (props) => ( ), + Callout: (props: any) => , + Tabs: (props: any) => , + TabItem: (props: any) => , + Steps: (props: any) => , + CodeBlock: (props: any) => , + ParamTable: (props: any) => , + ContractAddress: (props: any) => , + Mermaid: (props: any) => , } diff --git a/scripts/lib/og-generator.ts b/scripts/lib/og-generator.ts new file mode 100644 index 00000000..f868b612 --- /dev/null +++ b/scripts/lib/og-generator.ts @@ -0,0 +1,80 @@ +import { mkdir, writeFile } from "node:fs/promises" +import { dirname } from "node:path" + +export interface OgImageOptions { + title: string + section?: string + description?: string + siteName?: string +} + +export function generateOgSvg({ + title, + section = "Documentation", + description = "", + siteName = "SO4 Market", +}: OgImageOptions): string { + const safeTitle = title.replace(/&/g, "&").replace(//g, ">") + const safeSection = section.replace(/&/g, "&").replace(//g, ">") + const safeDesc = description.slice(0, 140).replace(/&/g, "&").replace(//g, ">") + + return ` + + + + + + + + + + + + + + + + + + + + + + + + ${safeSection.toUpperCase()} + + + + + + ${safeTitle} + + + + ${ + safeDesc + ? ` + ${safeDesc} + ` + : "" + } + + + + + + SO4 + + + ${siteName} + + +` +} + +export async function saveOgImage(outputPath: string, options: OgImageOptions): Promise { + const svg = generateOgSvg(options) + await mkdir(dirname(outputPath), { recursive: true }) + await writeFile(outputPath, svg, "utf-8") +}