From b309a1a23c5b6b2b26e60ba09e765eaf7358cfe9 Mon Sep 17 00:00:00 2001 From: joyee730 Date: Fri, 28 Aug 2026 02:42:27 +0100 Subject: [PATCH 1/4] feat(docs): DX-058: Unit tests for docs components and the content pipeline (#570) --- apps/docs/scripts/components.test.tsx | 99 +++++++++---- apps/docs/scripts/content-loader.test.ts | 22 ++- apps/docs/scripts/nav-builder.test.ts | 84 +++++++++++ apps/docs/scripts/search.test.tsx | 88 ++++++++++++ apps/docs/src/components/Pager.tsx | 62 ++++++++ apps/docs/src/components/SearchDialog.tsx | 125 +++++++++++++++++ apps/docs/src/components/Sidebar.tsx | 63 +++++++++ apps/docs/src/components/Toc.tsx | 53 +++++++ apps/docs/src/mdx/components.tsx | 164 +++++++++++++++++++++- 9 files changed, 729 insertions(+), 31 deletions(-) create mode 100644 apps/docs/scripts/nav-builder.test.ts create mode 100644 apps/docs/scripts/search.test.tsx create mode 100644 apps/docs/src/components/Pager.tsx create mode 100644 apps/docs/src/components/SearchDialog.tsx create mode 100644 apps/docs/src/components/Sidebar.tsx create mode 100644 apps/docs/src/components/Toc.tsx diff --git a/apps/docs/scripts/components.test.tsx b/apps/docs/scripts/components.test.tsx index a6ed374..a1ff088 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 e874108..b013eae 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/nav-builder.test.ts b/apps/docs/scripts/nav-builder.test.ts new file mode 100644 index 0000000..1b56481 --- /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 0000000..0f13566 --- /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/src/components/Pager.tsx b/apps/docs/src/components/Pager.tsx new file mode 100644 index 0000000..c676e1c --- /dev/null +++ b/apps/docs/src/components/Pager.tsx @@ -0,0 +1,62 @@ +import React from "react" +import { Link } from "@tanstack/react-router" +import { cn } from "@workspace/ui/lib/utils" + +export interface PageLink { + title: string + route: string +} + +export interface PagerProps { + prev?: PageLink + next?: PageLink + className?: string +} + +export function Pager({ prev, next, className }: PagerProps) { + if (!prev && !next) return null + + return ( + + ) +} diff --git a/apps/docs/src/components/SearchDialog.tsx b/apps/docs/src/components/SearchDialog.tsx new file mode 100644 index 0000000..8e6013d --- /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 0000000..0f96903 --- /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 0000000..d919832 --- /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/mdx/components.tsx b/apps/docs/src/mdx/components.tsx index 6a63fb9..3a4ec22 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) => , } From 4d7216fff3ee2e32af8b903c3a6133d4249b5006 Mon Sep 17 00:00:00 2001 From: joyee730 Date: Fri, 28 Aug 2026 02:42:55 +0100 Subject: [PATCH 2/4] feat(docs): DX-047: Per-page SEO and social preview metadata (#559) --- .gitignore | 5 ++- apps/docs/scripts/seo.test.ts | 39 +++++++++++++++++ apps/docs/src/lib/seo.ts | 63 +++++++++++++++++++++++++++ scripts/lib/og-generator.ts | 80 +++++++++++++++++++++++++++++++++++ 4 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 apps/docs/scripts/seo.test.ts create mode 100644 apps/docs/src/lib/seo.ts create mode 100644 scripts/lib/og-generator.ts diff --git a/.gitignore b/.gitignore index 7a50370..e57f4c1 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/scripts/seo.test.ts b/apps/docs/scripts/seo.test.ts new file mode 100644 index 0000000..7c07cf4 --- /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(' + 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/scripts/lib/og-generator.ts b/scripts/lib/og-generator.ts new file mode 100644 index 0000000..f868b61 --- /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") +} From 424dd5e98f0eed4dd065227c8cecbf44f279d1c0 Mon Sep 17 00:00:00 2001 From: joyee730 Date: Fri, 28 Aug 2026 02:43:15 +0100 Subject: [PATCH 3/4] feat(docs): DX-055: Build the docs image and asset pipeline (#567) --- apps/docs/scripts/check-content.ts | 11 +++ apps/docs/scripts/image-pipeline.test.ts | 67 +++++++++++++ apps/docs/src/lib/image-pipeline.ts | 121 +++++++++++++++++++++++ 3 files changed, 199 insertions(+) create mode 100644 apps/docs/scripts/image-pipeline.test.ts create mode 100644 apps/docs/src/lib/image-pipeline.ts diff --git a/apps/docs/scripts/check-content.ts b/apps/docs/scripts/check-content.ts index 00c7738..6353834 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/image-pipeline.test.ts b/apps/docs/scripts/image-pipeline.test.ts new file mode 100644 index 0000000..0b83370 --- /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/src/lib/image-pipeline.ts b/apps/docs/src/lib/image-pipeline.ts new file mode 100644 index 0000000..82ad292 --- /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}` +} From a2dacf60da87f0acf852ced62d2c4c6bcbbd0ef6 Mon Sep 17 00:00:00 2001 From: joyee730 Date: Fri, 28 Aug 2026 02:43:51 +0100 Subject: [PATCH 4/4] feat(docs): DX-053: Add prose linting with the SO4 style rules (#565) --- .github/workflows/ci.yml | 5 +- apps/docs/PROSE_STYLE.md | 45 ++++++ apps/docs/package.json | 3 +- apps/docs/scripts/lint-prose.test.ts | 63 +++++++++ apps/docs/scripts/lint-prose.ts | 202 +++++++++++++++++++++++++++ 5 files changed, 315 insertions(+), 3 deletions(-) create mode 100644 apps/docs/PROSE_STYLE.md create mode 100644 apps/docs/scripts/lint-prose.test.ts create mode 100644 apps/docs/scripts/lint-prose.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 081c749..7696cf9 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/apps/docs/PROSE_STYLE.md b/apps/docs/PROSE_STYLE.md new file mode 100644 index 0000000..19764c6 --- /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 d57289b..b6483f4 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/lint-prose.test.ts b/apps/docs/scripts/lint-prose.test.ts new file mode 100644 index 0000000..1821499 --- /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 0000000..fc37a74 --- /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) + }) +}