isContentHeading(item) && (slug?.[0] !== 'integrations' || item.depth <= 3) )} full={data.full} @@ -359,7 +356,10 @@ export default async function Page(props: { params: Promise<{ slug?: string[] }> className='max-sm:[&>h1]:basis-0 max-sm:[&_a]:size-10' >
- +
diff --git a/apps/docs/app/sitemap.ts b/apps/docs/app/sitemap.ts index 17e36be5d4b..84f6a15fc6e 100644 --- a/apps/docs/app/sitemap.ts +++ b/apps/docs/app/sitemap.ts @@ -4,13 +4,16 @@ import { DOCS_BASE_URL } from '@/lib/urls' export const revalidate = 3600 -export default function sitemap(): MetadataRoute.Sitemap { +export default async function sitemap(): Promise { // 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 && ( )} 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",