{
// The docs root redirects to /introduction, which is listed on its own.
- return source
- .getPages()
- .filter((page) => page.url !== '/')
- .map((page) => ({
+ const entries: MetadataRoute.Sitemap = []
+ for (const page of source.getPages()) {
+ if (page.url === '/') continue
+ const data = page.data.type === 'docs' ? await page.data.load() : undefined
+ entries.push({
url: `${DOCS_BASE_URL}${page.url}`,
- lastModified: 'lastModified' in page.data ? page.data.lastModified : undefined,
- }))
+ lastModified: data?.lastModified,
+ })
+ }
+ return entries
}
diff --git a/apps/docs/components/docs-layout/sidebar-components.tsx b/apps/docs/components/docs-layout/sidebar-components.tsx
index 4ea13bc7417..a632e2cd87c 100644
--- a/apps/docs/components/docs-layout/sidebar-components.tsx
+++ b/apps/docs/components/docs-layout/sidebar-components.tsx
@@ -112,7 +112,7 @@ export function SidebarFolder({ item, children }: SidebarFolderProps) {
)}
{hasChildren && (
-
{children}
+ {open &&
{children}
}
)}
diff --git a/apps/docs/components/page-actions.tsx b/apps/docs/components/page-actions.tsx
index 53c92c8d087..7be1cd3ac9d 100644
--- a/apps/docs/components/page-actions.tsx
+++ b/apps/docs/components/page-actions.tsx
@@ -1,19 +1,57 @@
'use client'
-import { Chip } from '@sim/emcn'
+import { useState } from 'react'
+import { Chip, useCopyToClipboard } from '@sim/emcn'
import { Check, Duplicate } from '@sim/emcn/icons'
-import { useCopyButton } from 'fumadocs-ui/utils/use-copy-button'
-export function LLMCopyButton({ content }: { content: string }) {
- const [checked, onClick] = useCopyButton(() => navigator.clipboard.writeText(content))
+interface LLMCopyButtonProps {
+ markdownUrl: string
+}
+
+export function LLMCopyButton({ markdownUrl }: LLMCopyButtonProps) {
+ const { copied, copy } = useCopyToClipboard()
+ const [pending, setPending] = useState(false)
+ const [failed, setFailed] = useState(false)
+ const checked = copied && !pending && !failed
+
+ async function onClick() {
+ setPending(true)
+ setFailed(false)
+ const prepare = async () => {
+ const response = await fetch(markdownUrl, {
+ signal: typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(10000) : undefined,
+ })
+ if (!response.ok) throw new Error('Could not load page Markdown')
+ return response.text()
+ }
+ try {
+ // Promise-backed writes preserve Safari's transient user activation during the fetch.
+ const success =
+ typeof ClipboardItem !== 'undefined' && typeof navigator.clipboard.write === 'function'
+ ? await copy({ prepare, fallback: '' })
+ : await copy(await prepare())
+ setFailed(!success)
+ } catch {
+ setFailed(true)
+ } finally {
+ setPending(false)
+ }
+ }
return (
-
- {checked ? 'Copied' : 'Copy page'}
-
+
+
+ {checked ? 'Copied' : pending ? 'Copying…' : 'Copy page'}
+
+
+ {failed ? 'Could not copy. Try again.' : ''}
+
+
)
}
diff --git a/apps/docs/lib/source.ts b/apps/docs/lib/source.ts
index 4fee2a7debe..f26468d8908 100644
--- a/apps/docs/lib/source.ts
+++ b/apps/docs/lib/source.ts
@@ -129,4 +129,5 @@ export type PageData = DocData &
description?: string
full?: boolean
integration?: { type: string; color: string }
+ load: () => Promise
}
diff --git a/apps/docs/next.config.ts b/apps/docs/next.config.ts
index fd57395c691..8c62df1caee 100644
--- a/apps/docs/next.config.ts
+++ b/apps/docs/next.config.ts
@@ -6,7 +6,6 @@ const withMDX = createMDX()
const config: NextConfig = {
reactStrictMode: true,
- productionBrowserSourceMaps: true,
transpilePackages: ['@sim/emcn', '@sim/workflow-renderer'],
images: {
unoptimized: true,
diff --git a/apps/docs/package.json b/apps/docs/package.json
index 32dc1dee5fc..1191be6c832 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -18,7 +18,8 @@
"lint:check": "biome check .",
"format": "biome format --write .",
"format:check": "biome format .",
- "llms-full:generate": "fumadocs-mdx && bun scripts/generate-llms-full.ts"
+ "llms-full:generate": "fumadocs-mdx && bun scripts/generate-llms-full.ts",
+ "test:navigation:e2e": "bun scripts/test-navigation-e2e.ts"
},
"dependencies": {
"@sim/db": "workspace:*",
@@ -46,6 +47,7 @@
"zod": "4.3.6"
},
"devDependencies": {
+ "@playwright/test": "1.61.1",
"@sim/tsconfig": "workspace:*",
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "24.19.2",
diff --git a/apps/docs/scripts/test-navigation-e2e.ts b/apps/docs/scripts/test-navigation-e2e.ts
new file mode 100644
index 00000000000..dd9f4bfe93b
--- /dev/null
+++ b/apps/docs/scripts/test-navigation-e2e.ts
@@ -0,0 +1,190 @@
+import { mkdir, writeFile } from 'node:fs/promises'
+import { dirname, join } from 'node:path'
+import { type Browser, chromium, expect } from '@playwright/test'
+import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
+
+const logger = createLogger('DocsNavigationE2E', { logLevel: 'INFO' })
+const origin = process.env.DOCS_E2E_BASE_URL
+const reportPath = process.env.DOCS_NAVIGATION_REPORT_PATH
+if (!origin || !reportPath) {
+ throw new Error('A docs base URL and DOCS_NAVIGATION_REPORT_PATH are required')
+}
+const target = new URL(origin)
+if (!['localhost', '127.0.0.1', '[::1]'].includes(target.hostname)) {
+ throw new Error('The documentation server must use a loopback origin')
+}
+
+const pages = [
+ '/introduction',
+ '/getting-started',
+ '/files',
+ '/integrations/apollo',
+ '/api-reference/workflows/listWorkflows',
+] as const
+const viewports = [
+ { name: 'desktop', width: 1440, height: 1000 },
+ { name: 'mobile', width: 390, height: 844 },
+] as const
+const results: Record[] = []
+let failed = false
+await mkdir(dirname(reportPath), { recursive: true })
+let browser: Browser | undefined
+try {
+ browser = await chromium.launch()
+ for (const viewport of viewports) {
+ for (const path of pages) {
+ const started = performance.now()
+ const context = await browser.newContext({ viewport, reducedMotion: 'reduce' })
+ const page = await context.newPage()
+ const errors: string[] = []
+ const markdownRequests: string[] = []
+ page.on('pageerror', (error) => errors.push(error.message))
+ page.on('request', (request) => {
+ if (new URL(request.url()).pathname.startsWith('/llms.mdx')) {
+ markdownRequests.push(request.url())
+ }
+ })
+ try {
+ const response = await page.goto(new URL(path, origin).toString(), {
+ waitUntil: 'networkidle',
+ })
+ if (!response?.ok()) throw new Error(`Page returned ${response?.status() ?? 'no response'}`)
+ await page.getByRole('heading', { level: 1 }).waitFor()
+ await page.evaluate(async () => {
+ await document.fonts.ready
+ })
+ if (path.startsWith('/api-reference/')) {
+ const tabs = page.getByRole('tablist', { name: 'Request language' }).getByRole('tab')
+ const count = await tabs.count()
+ if (count < 2) throw new Error('API request-language controls are missing')
+ const selectedIndex = await tabs.evaluateAll((elements) =>
+ elements.findIndex((element) => element.getAttribute('aria-selected') === 'true')
+ )
+ if (selectedIndex < 0) throw new Error('API request language has no selected tab')
+ for (let offset = 1; offset <= count; offset += 1) {
+ const tab = tabs.nth((selectedIndex + offset) % count)
+ await tab.click()
+ await expect(tab).toHaveAttribute('aria-selected', 'true')
+ await page.waitForFunction(() => {
+ const selected = document.querySelector(
+ '[aria-label="Request language"] [aria-selected="true"]'
+ )
+ const panelId = selected?.getAttribute('aria-controls')
+ const panel = panelId ? document.getElementById(panelId) : null
+ return Boolean(
+ panel &&
+ panel.getBoundingClientRect().height > 0 &&
+ panel.querySelector('code')?.textContent?.trim()
+ )
+ })
+ }
+ }
+ if (viewport.name === 'desktop') {
+ const markdownUrl = new URL(`/llms.mdx${path}`, origin).toString()
+ const markdown = await page.request.get(markdownUrl)
+ expect(markdown.ok()).toBe(true)
+ const expected = await markdown.text()
+ const copy = page.getByRole('button', { name: 'Copy page content', exact: true })
+ await context.grantPermissions(['clipboard-read', 'clipboard-write'])
+ expect(markdownRequests).toEqual([])
+ await copy.click()
+ await expect(page.getByRole('button', { name: 'Copied to clipboard' })).toBeVisible()
+ expect(markdownRequests).toEqual([markdownUrl])
+ await expect
+ .poll(() => page.evaluate(() => navigator.clipboard.readText()))
+ .toBe(expected)
+ await page.route(markdownUrl, (route) =>
+ route.fulfill({ status: 503, body: 'Unavailable' })
+ )
+ await page.getByRole('button', { name: 'Copied to clipboard' }).click()
+ await expect(page.getByRole('status')).toHaveText('Could not copy. Try again.')
+ await expect(page.getByRole('button', { name: 'Copied to clipboard' })).toHaveCount(0)
+ await expect
+ .poll(() => page.evaluate(() => navigator.clipboard.readText()))
+ .toBe(expected)
+ await page.unroute(markdownUrl)
+ await copy.click()
+ await expect(page.getByRole('button', { name: 'Copied to clipboard' })).toBeVisible()
+ }
+ await page.screenshot({
+ path: join(dirname(reportPath), `${viewport.name}${path.replaceAll('/', '-')}.png`),
+ })
+ if (path === '/introduction') {
+ const mobile = viewport.name === 'mobile'
+ if (mobile) await page.getByRole('button', { name: 'Open Sidebar', exact: true }).click()
+ const navigation = page.getByRole(mobile ? 'dialog' : 'complementary', {
+ name: 'Documentation navigation',
+ })
+ const platform = navigation.getByRole('button', { name: 'Platform', exact: true })
+ const costs = navigation.getByRole('link', {
+ name: 'Credits, billing and limits',
+ exact: true,
+ })
+ await platform.click()
+ await expect(costs).toBeVisible()
+ await platform.press('Enter')
+ await expect(costs).toBeHidden()
+ await platform.press('Enter')
+ await costs.click()
+ await expect(page).toHaveURL(new URL('/platform/costs', origin).toString())
+ await expect(
+ page.getByRole('heading', {
+ level: 1,
+ name: 'Credits, billing and limits',
+ exact: true,
+ })
+ ).toBeVisible()
+ if (mobile) {
+ await expect(navigation).not.toBeVisible()
+ await page.getByRole('button', { name: 'Open Sidebar', exact: true }).click()
+ }
+ await expect(costs).toHaveAttribute('aria-current', 'page')
+ await expect(platform).toHaveAttribute('aria-expanded', 'true')
+ if (!mobile) {
+ const markdown = await page.request.get(
+ new URL('/llms.mdx/platform/costs', origin).toString()
+ )
+ expect(markdown.ok()).toBe(true)
+ await page.getByRole('button', { name: 'Copy page content', exact: true }).click()
+ await expect(page.getByRole('button', { name: 'Copied to clipboard' })).toBeVisible()
+ await expect
+ .poll(() => page.evaluate(() => navigator.clipboard.readText()))
+ .toBe(await markdown.text())
+ }
+ }
+ results.push({
+ path,
+ viewport: viewport.name,
+ status: errors.length ? 'failed' : 'passed',
+ durationMs: Math.round(performance.now() - started),
+ errors,
+ })
+ if (errors.length) failed = true
+ } catch (error) {
+ failed = true
+ results.push({
+ path,
+ viewport: viewport.name,
+ status: 'failed',
+ durationMs: Math.round(performance.now() - started),
+ error: getErrorMessage(error),
+ errors,
+ })
+ } finally {
+ await context.close()
+ }
+ }
+ }
+} catch (error) {
+ failed = true
+ results.push({ path: 'browser', status: 'failed', error: getErrorMessage(error) })
+} finally {
+ await browser?.close()
+ await writeFile(
+ reportPath,
+ `${JSON.stringify({ checkedAt: new Date().toISOString(), origin: target.origin, results }, null, 2)}\n`
+ )
+}
+logger.info('Verified documentation navigation', { pages: results.length, reportPath })
+if (failed) process.exitCode = 1
diff --git a/apps/docs/source.config.ts b/apps/docs/source.config.ts
index a1fcf8495a3..c69f7fb4e50 100644
--- a/apps/docs/source.config.ts
+++ b/apps/docs/source.config.ts
@@ -12,6 +12,7 @@ const DOCS_DIR = 'content/docs'
export const docs = defineDocs({
dir: DOCS_DIR,
docs: {
+ async: true,
schema: frontmatterSchema.extend({
integration: z.object({ type: z.string(), color: z.string() }).optional(),
}),
diff --git a/bun.lock b/bun.lock
index 1811b6fa345..cb5c338e428 100644
--- a/bun.lock
+++ b/bun.lock
@@ -113,6 +113,7 @@
"zod": "4.3.6",
},
"devDependencies": {
+ "@playwright/test": "1.61.1",
"@sim/tsconfig": "workspace:*",
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "24.19.2",