Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,43 @@ jobs:
- name: Build
run: pnpm run build

e2e:
if: ${{ github.event.pull_request.draft == false && !startsWith(github.head_ref, 'dependabot') }}
runs-on: [arc-runner-set]

steps:
- name: Checkout repository
uses: actions/checkout@v6

- name: Install pnpm
uses: pnpm/action-setup@v5

- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm

- name: Install dependencies
run: pnpm install

# Not `pnpm exec playwright`: vocs ships its own playwright, and which
# copy pnpm links into node_modules/.bin varies with install order.
- name: Install Playwright browser
run: node node_modules/@playwright/test/cli.js install --with-deps chromium

# Builds the site and serves it -- see playwright.config.ts.
- name: Run browser tests
run: pnpm run test:e2e

- name: Upload Playwright report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7

typo-check:
if: ${{ github.event.pull_request.draft == false && !startsWith(github.head_ref, 'dependabot') }}
runs-on: [arc-runner-set]
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,7 @@ pnpm-debug.log*
.DS_Store
.vercel
.gstack/

# playwright
test-results/
playwright-report/
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ pnpm build
pnpm preview
```

## Tests

Browser tests cover the SEO metadata that has to survive client-side
navigation. They build the site and serve it themselves, so no dev server needs
to be running:

```bash
pnpm test:e2e:browsers # once, to download Chromium
pnpm test:e2e
```

## AI-readable formats

The site auto-generates machine-readable docs:
Expand Down
43 changes: 43 additions & 0 deletions docs/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { ReactNode } from 'react'

// Injected by `vite.define` in vocs.config.ts so the resolved site URL is a
// compile-time constant in both the prerender and the browser bundle.
declare const __DOCS_SITE_URL__: string

/**
* Route-dependent `<head>` tags.
*
* Vocs evaluates `config.head` once per page while prerendering and never
* again, so anything derived from the route there freezes at whichever page
* the visitor landed on and goes stale on client-side navigation. Tags
* that depend on the current route therefore live here instead: Vocs mounts
* this component inside the router (see `virtual:consumer-components`), so it
* re-renders on every navigation, and React 19 hoists `<link>`/`<meta>` into
* `<head>` when prerendering *and* in the browser. The prerendered HTML keeps
* the same tags it had before -- React emits them now instead of the config
* hook.
*
* Path-independent tags (og:site_name, og:locale, twitter:site, robots) stay
* in `config.head`; re-rendering constants buys nothing.
*/
export default function Layout({ children, path }: { children: ReactNode; path: string }) {
const url = canonicalUrl(path)
return (
<>
{url && <link rel="canonical" href={url} />}
{url && <meta property="og:url" content={url} />}
{children}
</>
)
}

/**
* `path` is the matched route, so it is already free of trailing slashes,
* query strings, and hashes. Vocs registers a `.html` alias for every page --
* point those at the extensionless URL that actually gets prerendered. The
* catch-all 404 route (`*`) has no canonical URL, so it gets no tags.
*/
function canonicalUrl(path: string) {
if (path === '*') return undefined
return `${__DOCS_SITE_URL__}${path.replace(/\.html$/, '')}`
}
10 changes: 7 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,18 @@
"scripts": {
"dev": "vocs dev",
"build": "vocs build && node scripts/substitute-site-url.mjs",
"preview": "vocs preview"
"preview": "vocs preview",
"test:e2e": "node node_modules/@playwright/test/cli.js test",
"test:e2e:browsers": "node node_modules/@playwright/test/cli.js install chromium"
},
"dependencies": {
"vocs": "^1.4.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"vocs": "^1.4.1"
},
"devDependencies": {
"@playwright/test": "^1.62.1",
"@types/react": "^19.0.0",
"typescript": "^5.4.5"
},
"packageManager": "pnpm@10.32.1"
Expand Down
27 changes: 27 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { defineConfig, devices } from '@playwright/test'
import { previewUrl, siteUrl } from './playwright.constants'

export default defineConfig({
testDir: './tests',
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? [['github'], ['list'], ['html', { open: 'never' }]] : 'list',
use: {
baseURL: previewUrl,
trace: 'on-first-retry',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],

// Always rebuild rather than reusing a running server: the specs assert on
// absolute URLs baked in at build time, so they are only meaningful against
// a build made with this SITE_URL.
webServer: {
command: 'pnpm run build && pnpm run preview',
url: previewUrl,
env: { SITE_URL: siteUrl },
reuseExistingServer: false,
stdout: 'pipe',
stderr: 'pipe',
timeout: 5 * 60 * 1000,
},
})
9 changes: 9 additions & 0 deletions playwright.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Shared between playwright.config.ts (which builds and serves the site) and
// the specs (which assert against the URLs that build produced).
export const previewPort = 4173
export const previewUrl = `http://localhost:${previewPort}`

// Pinned so the assertions do not depend on the SITE_URL fallback chain in
// scripts/site-url.mjs, and so a wrong value cannot coincidentally match the
// origin the site is served from.
export const siteUrl = 'https://docs.example.test'
33 changes: 33 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

67 changes: 67 additions & 0 deletions tests/route-metadata.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { expect, type Page, test } from '@playwright/test'
import { siteUrl } from '../playwright.constants'

// Two pages in the same sidebar section, so one is always linked from the
// other and moving between them is a single click.
const overview = '/protocol/overview'
const basedRollups = '/protocol/based-rollups'

test('prerendered HTML carries exactly one canonical URL, matching its own route', async ({
request,
}) => {
for (const path of ['/', overview, basedRollups]) {
const html = await (await request.get(path)).text()
const expected = `${siteUrl}${path}`

expect(tags(html, 'link'), `canonical on ${path}`).toEqual([
expect.stringContaining(`href="${expected}"`),
])
expect(tags(html, 'meta'), `og:url on ${path}`).toEqual([
expect.stringContaining(`content="${expected}"`),
])
}
})

test('canonical and og:url follow client-side navigation', async ({ page }) => {
await page.goto(overview)
await expectRouteMetadata(page, overview)

await softNavigate(page, basedRollups)
await expectRouteMetadata(page, basedRollups)

// Navigating back must not leave a stale tag behind either.
await softNavigate(page, overview)
await expectRouteMetadata(page, overview)
})

/** Click through to `path` and assert the router handled it without a page load. */
async function softNavigate(page: Page, path: string) {
await page.evaluate(() => {
;(window as Window & { didNotReload?: boolean }).didNotReload = true
})

await page.locator(`a[href="${path}"]:visible`).first().click()
await expect(page).toHaveURL(path)

expect(
await page.evaluate(() => (window as Window & { didNotReload?: boolean }).didNotReload),
'expected a soft navigation, but the document reloaded',
).toBe(true)
}

async function expectRouteMetadata(page: Page, path: string) {
const expected = `${siteUrl}${path}`
const canonical = page.locator('link[rel="canonical"]')
const ogUrl = page.locator('meta[property="og:url"]')

await expect(canonical).toHaveCount(1)
await expect(canonical).toHaveAttribute('href', expected)
await expect(ogUrl).toHaveCount(1)
await expect(ogUrl).toHaveAttribute('content', expected)
}

/** Every canonical `<link>` / og:url `<meta>` in a raw HTML document. */
function tags(html: string, name: 'link' | 'meta') {
const attribute = name === 'link' ? 'rel="canonical"' : 'property="og:url"'
return [...html.matchAll(new RegExp(`<${name}\\b[^>]*${attribute}[^>]*>`, 'g'))].map(([tag]) => tag)
}
9 changes: 8 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,12 @@
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["docs/**/*.ts", "docs/**/*.tsx", "vocs.config.ts"]
"include": [
"docs/**/*.ts",
"docs/**/*.tsx",
"tests/**/*.ts",
"vocs.config.ts",
"playwright.config.ts",
"playwright.constants.ts"
]
}
22 changes: 16 additions & 6 deletions vocs.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,19 @@ export default defineConfig({
'/': `https://vocs.dev/api/og?logo=${encodeURIComponent(ogLogoUrl)}&title=%title&description=%description`,
},

// Per-page <head>: canonical + og:url + twitter completion. Previews get
// noindex so they never outrank production.
head({ path }) {
const url = `${siteUrl}${path}`
// Site-wide <head> tags. Vocs renders this once per page at prerender time
// and never re-runs it in the browser, so only route-independent tags belong
// here -- canonical and og:url are emitted from docs/layout.tsx so they track
// client-side navigation. Previews get noindex so they never outrank
// production.
//
// Stays a function: Vocs treats an object-valued `head` as a path -> element
// map, and a bare ReactElement is an object, so the element form silently
// emits nothing.
head() {
return React.createElement(
React.Fragment,
null,
React.createElement('link', { rel: 'canonical', href: url }),
React.createElement('meta', { property: 'og:url', content: url }),
React.createElement('meta', { property: 'og:site_name', content: 'Taiko Docs' }),
React.createElement('meta', { property: 'og:locale', content: 'en_US' }),
React.createElement('meta', { name: 'twitter:site', content: '@taikoxyz' }),
Expand Down Expand Up @@ -124,6 +128,12 @@ export default defineConfig({
],

vite: {
// docs/layout.tsx runs in the browser, where neither process.env nor the
// build-time siteUrl chain exists -- inline the resolved value instead.
define: {
__DOCS_SITE_URL__: JSON.stringify(siteUrl),
},

plugins: [
{
// Dev-server substitution: rewrites SITEURLPLACEHOLDER in .mdx files at
Expand Down
Loading