diff --git a/.changelog/unreleased/537-dx-025-verify-release-tags.md b/.changelog/unreleased/537-dx-025-verify-release-tags.md new file mode 100644 index 00000000..d85e401b --- /dev/null +++ b/.changelog/unreleased/537-dx-025-verify-release-tags.md @@ -0,0 +1,7 @@ +--- +type: added +area: ci +pr: 537 +breaking: false +--- +Added CI release workflow that verifies changelog state and publishes GitHub Releases on release tags. diff --git a/.changelog/unreleased/538-docs-workspace.md b/.changelog/unreleased/538-docs-workspace.md index 107be59f..f0181b5c 100644 --- a/.changelog/unreleased/538-docs-workspace.md +++ b/.changelog/unreleased/538-docs-workspace.md @@ -1,7 +1,7 @@ --- -title: Scaffold docs workspace -category: docs +type: added +area: docs +pr: 538 +breaking: false --- - -Adds the initial `apps/docs` workspace, content validation command, keyboard -shortcut shell, and accessibility coverage for representative docs states. +Added the initial docs workspace, content validation command, keyboard shortcut shell, and accessibility coverage for representative docs states. diff --git a/.changelog/unreleased/544-dx-032-toc-scroll-spy.md b/.changelog/unreleased/544-dx-032-toc-scroll-spy.md new file mode 100644 index 00000000..8bb8adb4 --- /dev/null +++ b/.changelog/unreleased/544-dx-032-toc-scroll-spy.md @@ -0,0 +1,7 @@ +--- +type: added +area: docs +pr: 544 +breaking: false +--- +Added on-page table of contents with scroll-spy and heading focus navigation to the documentation site. diff --git a/.changelog/unreleased/557-dx-045-edit-page-and-freshness.md b/.changelog/unreleased/557-dx-045-edit-page-and-freshness.md new file mode 100644 index 00000000..235fd592 --- /dev/null +++ b/.changelog/unreleased/557-dx-045-edit-page-and-freshness.md @@ -0,0 +1,7 @@ +--- +type: added +area: docs +pr: 557 +breaking: false +--- +Added GitHub edit links, relative updated dates, and commit-date freshness validation to documentation pages. diff --git a/.changelog/unreleased/558-dx-046-sitemap-and-robots.md b/.changelog/unreleased/558-dx-046-sitemap-and-robots.md new file mode 100644 index 00000000..f2492107 --- /dev/null +++ b/.changelog/unreleased/558-dx-046-sitemap-and-robots.md @@ -0,0 +1,7 @@ +--- +type: added +area: docs +pr: 558 +breaking: false +--- +Generated sitemap.xml and robots.txt with canonical link tags during the documentation build. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..bdfc14d4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,74 @@ +name: Release + +on: + push: + tags: + - "v*" + +jobs: + release: + name: Verify & Publish Release + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Cache Bun dependencies + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install dependencies + run: bun install --frozen-lockfile + + # Quality Gate from AGENTS.md §1 + - name: Lint + run: bun lint + + - name: Typecheck + run: bun typecheck + + - name: Design token check + run: bun run check:tokens + + - 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 + + - name: Documentation generated-reference drift checks + run: bun run --cwd apps/docs check:tokens:generated && bun run --cwd apps/docs check:errors:generated + + - name: Test + run: bun run test + + - name: Coverage + run: bun run test:coverage + + - name: Build + run: bun run build + + # Release verification assertions & note extraction + - name: Verify release tag and changelog + run: bun run scripts/changelog/verify-tag.ts --tag "${{ github.ref_name }}" --output-notes /tmp/release-notes.md + + # Publish GitHub Release (never pushes a commit back) + - name: Publish GitHub Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "${{ github.ref_name }}" \ + --title "${{ github.ref_name }}" \ + --notes-file /tmp/release-notes.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b9c92e94..9466b15c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,6 +16,8 @@ quality bar every change must clear, and how to get a pull request merged. - [The quality gate](#the-quality-gate) - [Development workflow](#development-workflow) - [Fixing issues properly](#fixing-issues-properly) +- [Changelog entries](#changelog-entries) +- [Release procedure](#release-procedure) - [Commit conventions](#commit-conventions) - [Pull requests](#pull-requests) - [Project-specific gotchas](#project-specific-gotchas) @@ -252,6 +254,46 @@ Run `bun run changelog:validate` before committing to catch formatting errors. --- +## Release procedure + +SO4 Market uses tag-driven release automation with CI verification (DX-025). + +CI **verifies** release readiness; it **never writes** back to `main`. The release process is kept entirely in reviewed PRs: + +1. **Cut the release in a PR**: + Run the release command with the target SemVer version: + ```bash + bun run changelog:release 0.2.0 + ``` + This command: + - Validates all pending entry files in `.changelog/unreleased/`. + - Aggregates them into a new `## [0.2.0] - YYYY-MM-DD` section in `CHANGELOG.md`. + - Clears consumed entry files from `.changelog/unreleased/`. + - Updates compare links at the bottom of `CHANGELOG.md`. + +2. **Open, review, and merge the PR**: + Submit the release PR targeting `main`. Run the full quality gate. Once approved and CI is green, merge to `main`. + +3. **Tag the release on `main`**: + After merging, pull the latest `main` and push an annotated git tag matching the release version: + ```bash + git checkout main + git pull upstream main + git tag -a v0.2.0 -m "Release v0.2.0" + git push upstream v0.2.0 + ``` + +4. **CI Release Workflow (`.github/workflows/release.yml`)**: + On push of any `v*` tag, CI: + - Runs the full quality gate (`AGENTS.md` §1). + - Asserts `.changelog/unreleased/` is empty. + - Asserts `CHANGELOG.md` contains a release section matching the version in the tag. + - Asserts the release date in `CHANGELOG.md` is within 1 day of the tag date. + - Extracts the release section notes and creates a GitHub Release for the tag. + - Never pushes commits to any branch. + +--- + ## Commit conventions We follow [Conventional Commits](https://www.conventionalcommits.org/): diff --git a/apps/docs/content/concepts/liquidation.mdx b/apps/docs/content/concepts/liquidation.mdx index c8f2fb8f..8c6d06fc 100644 --- a/apps/docs/content/concepts/liquidation.mdx +++ b/apps/docs/content/concepts/liquidation.mdx @@ -1,7 +1,7 @@ --- title: Liquidation description: How maintenance margin and changing position value determine whether a leveraged position may be liquidated. -updated: 2026-08-24 +updated: 2026-08-25 status: stable --- diff --git a/apps/docs/content/guides/troubleshooting.mdx b/apps/docs/content/guides/troubleshooting.mdx index 13cbe6ab..c4780369 100644 --- a/apps/docs/content/guides/troubleshooting.mdx +++ b/apps/docs/content/guides/troubleshooting.mdx @@ -76,7 +76,7 @@ Likely causes, in order: 3. **The transaction that would have created it failed.** Check `TxStatus` for a failure state and the message against [/reference/errors](/reference/errors) — a position that never opened has nothing to display. **Checks:** -- Confirm the transaction hash on Stellar Expert actually invoked `exchange-router` successfully. +- Confirm the transaction hash on Stellar Expert actually invoked `ExchangeRouter` successfully. - Check the orders list for a pending order with the same parameters. - Confirm you're on the same network and market you traded on. diff --git a/apps/docs/content/reference/glossary.mdx b/apps/docs/content/reference/glossary.mdx index 54fe47f5..d7052802 100644 --- a/apps/docs/content/reference/glossary.mdx +++ b/apps/docs/content/reference/glossary.mdx @@ -1,7 +1,7 @@ --- title: Glossary description: Alphabetical definitions linking perpetual-markets and Stellar or Soroban terms to their detailed documentation. -updated: 2026-08-24 +updated: 2026-08-25 status: stable --- diff --git a/apps/docs/content/resources/changelog.mdx b/apps/docs/content/resources/changelog.mdx index 319da84c..cd10ffdc 100644 --- a/apps/docs/content/resources/changelog.mdx +++ b/apps/docs/content/resources/changelog.mdx @@ -1,7 +1,7 @@ --- title: Changelog description: How documentation updates relate to the product release history published on the SO4 interface. -updated: 2026-08-24 +updated: 2026-08-25 status: stable --- diff --git a/apps/docs/content/resources/faq.mdx b/apps/docs/content/resources/faq.mdx index 118496d2..ed7bb23f 100644 --- a/apps/docs/content/resources/faq.mdx +++ b/apps/docs/content/resources/faq.mdx @@ -1,7 +1,7 @@ --- title: Frequently asked questions description: Short answers to common SO4 trading, custody, fees, liquidation, wallet, and availability questions. -updated: 2026-08-24 +updated: 2026-08-25 status: stable landing: [what-is-so4, self-custody, liquidation, fees, availability] --- diff --git a/apps/docs/content/resources/roadmap.mdx b/apps/docs/content/resources/roadmap.mdx index 69f0a03d..0090f4da 100644 --- a/apps/docs/content/resources/roadmap.mdx +++ b/apps/docs/content/resources/roadmap.mdx @@ -1,7 +1,7 @@ --- title: Roadmap description: The current status of shipped and planned SO4 interface, protocol, and documentation work. -updated: 2026-08-24 +updated: 2026-08-25 status: stable --- diff --git a/apps/docs/package.json b/apps/docs/package.json index ba01d343..adbe9b0e 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -10,8 +10,8 @@ "lint:prose": "bun run scripts/lint-prose.ts", "format": "prettier --write \"content/**/*.mdx\" \"scripts/**/*.ts\" \"src/**/*.{ts,tsx}\"", "typecheck": "tsc --noEmit", - "test": "bun test", - "test:coverage": "bun test --coverage", + "test": "vitest run && bun test scripts/content-loader.test.ts scripts/content.test.ts scripts/frontmatter.test.ts scripts/generate-graphql.test.ts scripts/image-pipeline.test.ts scripts/lint-prose.test.ts scripts/nav-builder.test.ts scripts/seo.test.ts scripts/shiki.test.ts scripts/sitemap.test.ts", + "test:coverage": "vitest run --coverage", "check:content": "bun run scripts/check-content.ts", "check:links": "bun run scripts/check-links.ts", "generate:faq": "bun run scripts/generate-faq.ts", @@ -38,14 +38,21 @@ "@repo/vitest-config": "workspace:*", "@shikijs/rehype": "^4.4.3", "@shikijs/transformers": "^4.4.3", + "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/bun": "^1.3.0", + "@vitest/coverage-v8": "^3.2.0", + "@vitest/expect": "^3.2.7", + "vitest": "3", + "vitest-axe": "^0.1.0", "@types/mdx": "^2.0.14", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.5", "h3": "^2.0.1-rc.29", "happy-dom": "^20.11.6", "jsdom": "^30.0.1", + "msw": "^2.12.12", "nitro": "^3.0.260610-beta", "pagefind": "^1.5.2", "prettier": "^3.8.1", @@ -55,6 +62,7 @@ "shiki": "^4.4.3", "tailwindcss": "^4.1.18", "typescript": "^5.9.3", - "vite": "^7.3.2" + "vite": "^7.3.2", + "vite-tsconfig-paths": "^5.1.4" } } diff --git a/apps/docs/public/robots.txt b/apps/docs/public/robots.txt new file mode 100644 index 00000000..b660537e --- /dev/null +++ b/apps/docs/public/robots.txt @@ -0,0 +1,3 @@ +User-agent: * +Allow: / +Sitemap: https://docs.so4.market/sitemap.xml diff --git a/apps/docs/public/sitemap.xml b/apps/docs/public/sitemap.xml new file mode 100644 index 00000000..a3203805 --- /dev/null +++ b/apps/docs/public/sitemap.xml @@ -0,0 +1,111 @@ + + + + https://docs.so4.market/ + 2026-08-28 + + + https://docs.so4.market/concepts/funding-and-fees + 2026-08-25 + + + https://docs.so4.market/concepts/liquidation + 2026-08-25 + + + https://docs.so4.market/concepts/risk + 2026-08-25 + + + https://docs.so4.market/developers/architecture + 2026-08-25 + + + https://docs.so4.market/developers/contract-clients + 2026-08-25 + + + https://docs.so4.market/developers/design-system + 2026-08-25 + + + https://docs.so4.market/developers/indexer + 2026-08-25 + + + https://docs.so4.market/developers/json-feed + 2026-08-25 + + + https://docs.so4.market/developers/local-setup + 2026-08-25 + + + https://docs.so4.market/developers/reading-data + 2026-08-25 + + + https://docs.so4.market/developers/writing-transactions + 2026-08-25 + + + https://docs.so4.market/get-started/introduction + 2026-08-30 + + + https://docs.so4.market/get-started/quickstart + 2026-08-30 + + + https://docs.so4.market/guides/troubleshooting + 2026-08-25 + + + https://docs.so4.market/reference/contracts.generated + 2026-08-25 + + + https://docs.so4.market/reference/data-store + 2026-08-25 + + + https://docs.so4.market/reference/errors + 2026-08-25 + + + https://docs.so4.market/reference/exchange-router + 2026-08-25 + + + https://docs.so4.market/reference/glossary + 2026-08-25 + + + https://docs.so4.market/reference/order-vault + 2026-08-25 + + + https://docs.so4.market/reference/synthetics-reader + 2026-08-25 + + + https://docs.so4.market/reference/tokens.generated + 2026-08-25 + + + https://docs.so4.market/resources/changelog + 2026-08-25 + + + https://docs.so4.market/resources/faq + 2026-08-25 + + + https://docs.so4.market/resources/roadmap + 2026-08-25 + + + https://docs.so4.market/resources/security + 2026-08-25 + + diff --git a/apps/docs/scripts/check-content.ts b/apps/docs/scripts/check-content.ts index 8e6860ea..798ac360 100644 --- a/apps/docs/scripts/check-content.ts +++ b/apps/docs/scripts/check-content.ts @@ -1,10 +1,11 @@ -import { readFile } from "node:fs/promises" +import { readFile, writeFile } from "node:fs/promises" import { join } from "node:path" import { contentRoot, headingEntries, loadPages } from "./content" import { validateFrontmatter } from "../src/lib/frontmatter" import { parseMermaid } from "../src/lib/mermaid" +const isFix = process.argv.includes("--fix") const pages = await loadPages() const errors: Array = [] const routes = new Set(pages.map((page) => page.route)) @@ -21,6 +22,37 @@ for (const page of pages) { if (!["stable", "beta", "draft"].includes(status)) errors.push(`${page.route}: invalid status`) + // DX-045: Validate updated date against git commit history if tracked + if (!page.file.endsWith(".generated.mdx") && status !== "draft") { + try { + const proc = Bun.spawnSync([ + "git", + "log", + "-1", + "--format=%cs", + "--", + page.file, + ]) + const gitDate = proc.stdout.toString().trim() + if (gitDate && /^\d{4}-\d{2}-\d{2}$/.test(gitDate)) { + if (updated !== gitDate) { + if (isFix) { + const raw = await readFile(page.file, "utf-8") + const fixed = raw.replace(/^updated:\s*.*$/m, `updated: ${gitDate}`) + await writeFile(page.file, fixed, "utf-8") + page.frontmatter.updated = gitDate + } else { + errors.push( + `${page.route}: frontmatter updated date (${updated}) does not match last git commit date (${gitDate})` + ) + } + } + } + } catch { + // Ignore if git is not available + } + } + // DX-055: Enforce image alt text and dimension requirements const imgMatches = page.body.matchAll(/!\[([^\]]*)\]\(([^)]+)\)/g) for (const match of imgMatches) { @@ -52,7 +84,6 @@ for (const page of pages) { } } - const meta = JSON.parse( await readFile(join(contentRoot, "meta.json"), "utf8"), ) as { diff --git a/apps/docs/scripts/lint-prose.ts b/apps/docs/scripts/lint-prose.ts index 3bdc888d..ffe430c2 100644 --- a/apps/docs/scripts/lint-prose.ts +++ b/apps/docs/scripts/lint-prose.ts @@ -80,9 +80,15 @@ export function lintMarkdownContent(file: string, source: string): LintResult { } if (inCodeBlock) continue + // Mask inline code spans, link targets, and HTML tags/elements with spaces to preserve column indices + const proseLine = line + .replace(/`[^`]+`/g, (m) => " ".repeat(m.length)) + .replace(/\]\([^)]+\)/g, (m) => "]" + " ".repeat(m.length - 1)) + .replace(/<[^>]+>/g, (m) => " ".repeat(m.length)) + // 1. Exclamation marks check (Error) - const exclamIdx = line.indexOf("!") - if (exclamIdx !== -1 && !line.match(/!\[.*?\]\(.*?\)/) && !line.match(/!=\s*/)) { + const exclamIdx = proseLine.indexOf("!") + if (exclamIdx !== -1 && !proseLine.match(/!=\s*/)) { errors.push({ file, line: lineNum, @@ -97,7 +103,7 @@ export function lintMarkdownContent(file: string, source: string): LintResult { 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) { + while ((match = regex.exec(proseLine)) !== null) { errors.push({ file, line: lineNum, @@ -132,7 +138,7 @@ export function lintMarkdownContent(file: string, source: string): LintResult { // 4. Passive voice check (Warning) for (const pattern of PASSIVE_VOICE_PATTERNS) { - const match = pattern.exec(line) + const match = pattern.exec(proseLine) if (match) { warnings.push({ file, @@ -146,7 +152,7 @@ export function lintMarkdownContent(file: string, source: string): LintResult { } // 5. Sentence length threshold (> 30 words) (Warning) - const sentences = line.split(/(?<=[.!?])\s+/) + const sentences = proseLine.split(/(?<=[.!?])\s+/) for (const sentence of sentences) { const words = sentence.trim().split(/\s+/).filter(Boolean) if (words.length > 30) { diff --git a/apps/docs/scripts/seo.test.ts b/apps/docs/scripts/seo.test.ts index 7c07cf4f..0edfc4cd 100644 --- a/apps/docs/scripts/seo.test.ts +++ b/apps/docs/scripts/seo.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { generateSeoTags } from "../src/lib/seo" -import { generateOgSvg } from "../../scripts/lib/og-generator" +import { generateOgSvg } from "../../../scripts/lib/og-generator.ts" describe("SEO metadata and social preview generator (DX-047)", () => { test("generates unique title suffix, description, OG/Twitter tags, and TechArticle schema", () => { diff --git a/apps/docs/scripts/sitemap.test.ts b/apps/docs/scripts/sitemap.test.ts new file mode 100644 index 00000000..9e3c81d2 --- /dev/null +++ b/apps/docs/scripts/sitemap.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test" +import { generateSitemapXml, generateRobotsTxt, buildSitemapAndRobots } from "./sitemap.ts" + +describe("DX-046: sitemap.xml and robots.txt generation", () => { + test("generateSitemapXml builds standard schema compliant xml", () => { + const entries = [ + { url: "https://docs.so4.market/", lastmod: "2026-08-30" }, + { url: "https://docs.so4.market/get-started/quickstart", lastmod: "2026-08-28" }, + ] + + const xml = generateSitemapXml(entries) + expect(xml).toContain('') + expect(xml).toContain('') + expect(xml).toContain("https://docs.so4.market/") + expect(xml).toContain("2026-08-30") + expect(xml).toContain("https://docs.so4.market/get-started/quickstart") + expect(xml).toContain("2026-08-28") + }) + + test("generateRobotsTxt allows root and references sitemap URL", () => { + const robots = generateRobotsTxt("https://docs.so4.market") + expect(robots).toContain("User-agent: *") + expect(robots).toContain("Allow: /") + expect(robots).toContain("Sitemap: https://docs.so4.market/sitemap.xml") + }) + + test("buildSitemapAndRobots excludes draft pages and populates validated lastmod", async () => { + const { sitemapXml, entryCount } = await buildSitemapAndRobots("https://docs.so4.market") + expect(entryCount).toBeGreaterThan(0) + // Draft page should be excluded + expect(sitemapXml).not.toContain("/resources/terms") + expect(sitemapXml).toContain("/get-started/introduction") + expect(sitemapXml).toContain("") + }) +}) diff --git a/apps/docs/scripts/sitemap.ts b/apps/docs/scripts/sitemap.ts new file mode 100644 index 00000000..39d42bda --- /dev/null +++ b/apps/docs/scripts/sitemap.ts @@ -0,0 +1,92 @@ +/** + * DX-046: Generate sitemap.xml and robots.txt for the documentation site. + * + * Scans content pages, excludes drafts and redirect sources, attaches validated + * lastmod dates from frontmatter, and writes standard XML sitemap and robots.txt. + * + * Usage: + * bun run scripts/sitemap.ts + */ + +import { mkdir, writeFile } from "node:fs/promises" +import { dirname, join } from "node:path" +import { appRoot, loadPages } from "./content.ts" +import { DEFAULT_SITE_URL } from "../src/lib/seo.ts" + +export interface SitemapUrlEntry { + url: string + lastmod?: string +} + +export function generateSitemapXml( + entries: Array +): string { + const urlNodes = entries + .map((e) => { + const lastmodNode = e.lastmod ? `\n ${e.lastmod}` : "" + return ` \n ${e.url}${lastmodNode}\n ` + }) + .join("\n") + + return `\n\n${urlNodes}\n\n` +} + +export function generateRobotsTxt(siteUrl = DEFAULT_SITE_URL): string { + return [ + "User-agent: *", + "Allow: /", + `Sitemap: ${siteUrl}/sitemap.xml`, + "", + ].join("\n") +} + +export async function buildSitemapAndRobots( + siteUrl = DEFAULT_SITE_URL, + targetDirs = [ + join(appRoot, "public"), + join(appRoot, ".nitro-static"), + ] +): Promise<{ sitemapXml: string; robotsTxt: string; entryCount: number }> { + const pages = await loadPages() + // Exclude drafts + const publicPages = pages.filter((p) => p.frontmatter.status !== "draft") + + const entries: Array = publicPages.map((page) => { + const route = page.route === "/index" ? "/" : page.route + return { + url: `${siteUrl}${route}`, + lastmod: page.frontmatter.updated, + } + }) + + // Sort URLs deterministically + entries.sort((a, b) => a.url.localeCompare(b.url)) + + const sitemapXml = generateSitemapXml(entries) + const robotsTxt = generateRobotsTxt(siteUrl) + + for (const dir of targetDirs) { + try { + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, "sitemap.xml"), sitemapXml, "utf-8") + await writeFile(join(dir, "robots.txt"), robotsTxt, "utf-8") + } catch { + // Ignore if dir cannot be written yet + } + } + + return { sitemapXml, robotsTxt, entryCount: entries.length } +} + +async function main() { + const { entryCount } = await buildSitemapAndRobots() + console.log(`✓ Generated sitemap.xml with ${entryCount} URL(s) and robots.txt`) +} + +const invokedDirectly = process.argv[1]?.endsWith("sitemap.ts") +if (invokedDirectly) { + main().catch((err) => { + console.error(err) + process.exit(1) + }) +} diff --git a/apps/docs/src/components/DocsHome.test.tsx b/apps/docs/src/components/DocsHome.test.tsx index a0462981..1ecae181 100644 --- a/apps/docs/src/components/DocsHome.test.tsx +++ b/apps/docs/src/components/DocsHome.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from "@testing-library/react" import userEvent from "@testing-library/user-event" +import "@testing-library/jest-dom/vitest" import { axe } from "vitest-axe" import { describe, expect, it } from "vitest" import { KeyboardShortcut } from "@workspace/ui/components/keyboard-shortcut" diff --git a/apps/docs/src/components/Sidebar.tsx b/apps/docs/src/components/Sidebar.tsx index 0f969039..0e866f69 100644 --- a/apps/docs/src/components/Sidebar.tsx +++ b/apps/docs/src/components/Sidebar.tsx @@ -47,7 +47,7 @@ export function Sidebar({ sections, currentRoute, className }: SidebarProps) { > {page.sidebarLabel ?? page.title} {page.status === "beta" && ( - + beta )} diff --git a/apps/docs/src/components/Toc.test.tsx b/apps/docs/src/components/Toc.test.tsx new file mode 100644 index 00000000..5cfecf8b --- /dev/null +++ b/apps/docs/src/components/Toc.test.tsx @@ -0,0 +1,89 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest" +import { render, screen, fireEvent } from "@testing-library/react" +import { Toc } from "./Toc" + +describe("DX-032: Toc component", () => { + let mockObserve: ReturnType + let mockDisconnect: ReturnType + + beforeEach(() => { + mockObserve = vi.fn() + mockDisconnect = vi.fn() + + class MockIntersectionObserver { + observe = mockObserve + disconnect = mockDisconnect + unobserve = vi.fn() + } + + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it("renders nothing when there are fewer than 2 headings", () => { + const { container } = render( + + ) + expect(container.firstChild).toBeNull() + }) + + it("renders table of contents when there are 2 or more headings", () => { + render( + + ) + + expect( + screen.getByRole("navigation", { name: "Table of contents" }) + ).toBeInTheDocument() + expect( + screen.getByRole("link", { name: "First Section" }) + ).toBeInTheDocument() + expect( + screen.getByRole("link", { name: "Second Section" }) + ).toBeInTheDocument() + }) + + it("nests h3 headings under h2 headings", () => { + const { container } = render( + + ) + + const subtopicItem = container.querySelector("li.ps-3") + expect(subtopicItem).not.toBeNull() + expect(subtopicItem?.textContent).toContain("Subtopic Detail") + }) + + it("sets active state on click and invokes onSelect callback", () => { + const onSelect = vi.fn() + render( + + ) + + const detailsLink = screen.getByRole("link", { name: "Details" }) + fireEvent.click(detailsLink) + + expect(onSelect).toHaveBeenCalledWith("details") + expect(detailsLink).toHaveAttribute("aria-current", "location") + }) +}) diff --git a/apps/docs/src/components/Toc.tsx b/apps/docs/src/components/Toc.tsx index d919832c..ca160a18 100644 --- a/apps/docs/src/components/Toc.tsx +++ b/apps/docs/src/components/Toc.tsx @@ -1,4 +1,6 @@ -import React from "react" +"use client" + +import { useEffect, useState, useRef } from "react" import { cn } from "@workspace/ui/lib/utils" export interface TocEntry { @@ -11,35 +13,131 @@ export interface TocProps { entries: TocEntry[] activeId?: string className?: string + onSelect?: (id: string) => void } -export function Toc({ entries, activeId, className }: TocProps) { - if (entries.length === 0) return null +export function Toc({ + entries, + activeId: initialActiveId, + className, + onSelect, +}: TocProps) { + // Only h2 and h3 headings are included in the table of contents + const validEntries = entries.filter( + (e) => !e.level || e.level === 2 || e.level === 3 + ) + + const [activeId, setActiveId] = useState( + initialActiveId || validEntries[0]?.id || "" + ) + const isClickRef = useRef(false) + + useEffect(() => { + if ( + validEntries.length < 2 || + typeof window === "undefined" || + typeof IntersectionObserver === "undefined" + ) + return + + // Hold last heading when scrolled to bottom of document + const handleScroll = () => { + if (isClickRef.current) return + const isBottom = + window.innerHeight + window.scrollY >= + document.documentElement.scrollHeight - 50 + + if (isBottom && validEntries.length > 0) { + setActiveId(validEntries[validEntries.length - 1].id) + } + } + + window.addEventListener("scroll", handleScroll, { passive: true }) + + const observer = new IntersectionObserver( + (intersectingEntries) => { + if (isClickRef.current) return + + const visible = intersectingEntries + .filter((e) => e.isIntersecting) + .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top) + + if (visible.length > 0) { + setActiveId(visible[0].target.id) + } + }, + { + rootMargin: "0px 0px -70% 0px", + threshold: [0, 1.0], + } + ) + + validEntries.forEach((entry) => { + const element = document.getElementById(entry.id) + if (element) { + observer.observe(element) + } + }) + + return () => { + window.removeEventListener("scroll", handleScroll) + observer.disconnect() + } + }, [validEntries]) + + // DX-032: Pages with < 2 headings render no rail and re-center content + if (validEntries.length < 2) return null + + const handleLinkClick = ( + e: React.MouseEvent, + id: string + ) => { + isClickRef.current = true + setActiveId(id) + onSelect?.(id) + + const target = document.getElementById(id) + if (target) { + if (!target.hasAttribute("tabindex")) { + target.setAttribute("tabindex", "-1") + } + target.focus({ preventScroll: true }) + } + + setTimeout(() => { + isClickRef.current = false + }, 800) + } return (