Skip to content
Closed
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
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,15 @@ Use one term for one concept. Adapted from the [Design System word list](https:/
- Do not restate the title in the description; add context or outcome.
- Dynamic OG images are generated by `/api/og.tsx` using frontmatter and `vocs.config.ts`.

These rules are enforced. `src/lib/seo-metadata.test.ts` lints every page's frontmatter in `pnpm test`, and `scripts/check-seo.ts` checks the built HTML in `pnpm build`:

- **Document title.** The rendered title (`seoTitle`, or `title` plus the template suffix) ends with ` | Tempo Docs` and is at most 60 characters. Resources explainers under `/docs/resources/guides/` may end with ` | Tempo`.
- **Unique titles.** No two pages render the same document title.
- **Title patterns.** Write `seoTitle` as `<Concept> on Tempo: <what it does>`, `Tempo <Product>: <outcome>`, `<Verb> <task> on Tempo`, or `<Group> API Reference`, then the suffix. Shorten the `seoTitle`, not the H1 or URL.
- **Description.** Every page has a single-line `description` of 50 to 160 characters.
- **Canonical URL.** Every built page has exactly one `<link rel="canonical">`, equal to its sitemap URL: `https://tempo.xyz/developers` plus the path, with no trailing slash. `src/lib/docs-structured-data.ts` emits it.
- **Markdown twins.** `vercel.json` sends a `Link: <…>; rel="canonical"` header from each `.md` twin to its HTML page. `src/lib/vercel-routing.test.ts` covers the patterns.

## Examples and Code

- Examples must be tied to a real tool, workflow, API, command, or protocol detail.
Expand Down
6 changes: 3 additions & 3 deletions e2e/document-semantics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ for (const route of sampleBySection(publicRoutes)) {

test('keeps one route title through docs and OpenAPI client navigation', async ({ page }) => {
await page.goto('/docs/quickstart/integrate-tempo', { waitUntil: 'networkidle' })
await expectSingleTitle(page, 'Integrate Tempo with Apps, Wallets, and Services | Tempo Docs')
await expectSingleTitle(page, 'Integrate Tempo into Apps and Services | Tempo Docs')

await page
.getByRole('navigation', { name: 'Documentation sections', exact: true })
Expand All @@ -94,11 +94,11 @@ test('keeps one route title through docs and OpenAPI client navigation', async (
.getByRole('link', { name: 'API reference', exact: true })
.click()
await page.waitForURL(/\/docs\/api\/?$/)
await expectSingleTitle(page, 'Tempo API: Sponsor Gas Fees and Route Stablecoins | Tempo Docs')
await expectSingleTitle(page, 'Tempo API: Sponsor Fees and Route Stablecoins | Tempo Docs')

await page.locator('a[href="/docs/api/transactions"]:visible').first().click()
await page.waitForURL(/\/docs\/api\/transactions\/?$/)
await expectSingleTitle(page, 'Tempo Transactions API Reference | Docs')
await expectSingleTitle(page, 'Tempo Transactions API Reference | Tempo Docs')

await page.locator('a[href="/docs/api/transfers"]:visible').first().click()
await page.waitForURL(/\/docs\/api\/transfers\/?$/)
Expand Down
6 changes: 3 additions & 3 deletions e2e/seo-head.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const cases: {
}[] = [
{
path: '/',
title: 'Tempo Developer Docs: APIs, SDKs &amp; Guides',
title: 'Developer Documentation: APIs, SDKs, and Guides | Tempo Docs',
ogTitle: 'Documentation',
descriptionIncludes: 'Build with Tempo Accounts, Earn, Routes, and Zones',
ogImageIncludes: '/og/2026-q4/og_2026-q4_developers_developers_title-f5_v02.png',
Expand Down Expand Up @@ -72,14 +72,14 @@ const cases: {
},
{
path: '/docs/api',
title: 'Tempo API: Sponsor Gas Fees and Route Stablecoins | Tempo Docs',
title: 'Tempo API: Sponsor Fees and Route Stablecoins | Tempo Docs',
ogTitle: 'Tempo API',
descriptionIncludes: 'gas with a hosted fee payer, and move stablecoins onto Tempo',
ogImageIncludes: '/og/2026-q4/og_2026-q4_docs_api_linework-000_v02.png',
},
{
path: '/docs/api/console',
title: 'How to Use the Tempo Console | Docs',
title: 'How to Use the Tempo Console | Tempo Docs',
ogTitle: 'Tempo Console',
descriptionIncludes: 'create projects and API keys',
ogImageIncludes: '/og/2026-q4/og_2026-q4_docs_api-console_title-f5_v02.png',
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@
"scripts": {
"dev": "node --env-file-if-exists=.env --import=tsx --import=./scripts/native-json-imports.mjs node_modules/vite/bin/vite.js",
"build": "node scripts/guard-vocs-config.mjs && pnpm run check:types && pnpm run build:site",
"build:site": "pnpm run check:styles && vite build && node --experimental-strip-types scripts/finalize-sitemap.ts",
"build:site": "pnpm run check:styles && vite build && node --experimental-strip-types scripts/finalize-sitemap.ts && node --experimental-strip-types scripts/check-seo.ts",
"check": "biome check --write --unsafe . && pnpm run check:styles",
"check:seo": "node --experimental-strip-types scripts/check-seo.ts",
"check:anchors": "node --experimental-strip-types scripts/check-internal-anchors.ts",
"check:markdown": "node scripts/check-markdown-components.mjs && tsx scripts/check-ai-docs.ts",
"check:types": "tsgo --project tsconfig.app.json --noEmit && tsgo --project tsconfig.node.json --noEmit",
Expand Down
129 changes: 129 additions & 0 deletions scripts/check-seo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { canonicalUrlForRoute, titleProblems } from '../src/lib/seo-metadata.ts'

// Checks the prerendered HTML after `vite build`: every docs page in the
// sitemap, and every built docs page, has exactly one canonical link equal to
// its public sitemap URL and one document title that follows the title rules.

const publicDirectory = path.resolve(process.argv[2] ?? 'dist/public')
const sitemapPath = path.join(publicDirectory, 'sitemap.xml')
// Vocs titles generated OpenAPI groups "<Group> · <API title>" without the
// site title template. Report them, but only fail on authored page titles.
const generatedOpenApiTitle = /^[^|]+ · Tempo API$/

const sitemap = await fs.readFile(sitemapPath, 'utf-8').catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error
return undefined
})
if (!sitemap) {
if (process.env.VITE_E2E === 'true' || process.env.VERCEL_ENV !== 'production') {
console.log(
`Skipped the SEO check: no sitemap at ${path.relative(process.cwd(), sitemapPath)}.`,
)
process.exit(0)
}
throw new Error(`Missing production sitemap at ${sitemapPath}`)
}

const locations = Array.from(sitemap.matchAll(/<loc>([^<]+)<\/loc>/g), ([, location]) => location)
const indexLocation = locations.find((location) => /\/(?:get-started|docs\/api)\/?$/.test(location))
if (!indexLocation) throw new Error('Could not resolve the site base URL from the sitemap')
const siteBase = indexLocation.replace(/\/(?:get-started|docs\/api)\/?$/, '')

const sitemapRoutes = new Map<string, string>()
for (const location of locations) {
if (!location.startsWith(siteBase)) continue
const route = decodeURIComponent(location.slice(siteBase.length)).replace(/\/+$/, '') || '/'
sitemapRoutes.set(route, location)
}

async function builtRoutes(directory: string): Promise<string[]> {
const entries = await fs.readdir(directory, { withFileTypes: true }).catch(() => [])
const routes = await Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(directory, entry.name)
if (entry.isDirectory()) return builtRoutes(entryPath)
if (!entry.isFile() || entry.name !== 'index.html') return []
return [`/${path.relative(publicDirectory, directory).split(path.sep).join('/')}`]
}),
)
return routes.flat()
}

const routes = [
...new Set([
...sitemapRoutes.keys(),
'/',
...(await builtRoutes(path.join(publicDirectory, 'docs'))),
...(await builtRoutes(path.join(publicDirectory, 'get-started'))),
]),
].sort((a, b) => a.localeCompare(b))

const errors: string[] = []
const generatedTitles: string[] = []

for (const route of routes) {
const file = path.join(
publicDirectory,
route === '/' ? 'index.html' : route.slice(1),
route === '/' ? '' : 'index.html',
)
const html = await fs.readFile(file, 'utf-8').catch(() => undefined)
if (html === undefined) {
errors.push(
`${route}: listed in the sitemap but no built HTML at ${path.relative(process.cwd(), file)}`,
)
continue
}
if (!sitemapRoutes.has(route)) errors.push(`${route}: built page is missing from the sitemap`)

const head = html.slice(0, Math.max(html.indexOf('</head>'), 0))
const canonicals = Array.from(
head.matchAll(/<link\b[^>]*\brel=["']canonical["'][^>]*>/gi),
([tag]) => tag.match(/\bhref=["']([^"']*)["']/i)?.[1],
)
const expected = canonicalUrlForRoute(route)
if (canonicals.length !== 1) {
errors.push(`${route}: expected one canonical link, found ${canonicals.length}`)
} else if (canonicals[0] !== expected) {
errors.push(`${route}: canonical ${canonicals[0]} does not match ${expected}`)
}
const sitemapUrl = sitemapRoutes.get(route)
if (sitemapUrl && siteBase === canonicalUrlForRoute('/') && sitemapUrl !== expected) {
errors.push(`${route}: sitemap URL ${sitemapUrl} does not match canonical ${expected}`)
}

const titles = Array.from(
head.matchAll(/<title(?:\s[^>]*)?>([\s\S]*?)<\/title>/gi),
([, title]) =>
(title ?? '')
.replaceAll('&amp;', '&')
.replaceAll('&#x27;', "'")
.replaceAll('&#39;', "'")
.replaceAll('&quot;', '"'),
)
if (titles.length !== 1) {
errors.push(`${route}: expected one document title, found ${titles.length}`)
continue
}
const [title] = titles as [string]
if (generatedOpenApiTitle.test(title)) {
generatedTitles.push(`${route}: ${title}`)
continue
}
for (const problem of titleProblems(route, title)) errors.push(`${route}: ${problem} (${title})`)
}

if (errors.length > 0) {
throw new Error(`SEO check failed for ${errors.length} issue(s):\n${errors.join('\n')}`)
}

console.log(
`Validated canonical links and titles for ${routes.length} built pages in ${path.relative(process.cwd(), publicDirectory)}.`,
)
if (generatedTitles.length > 0) {
console.log(
`${generatedTitles.length} generated OpenAPI pages keep Vocs' "<Group> · Tempo API" title:\n${generatedTitles.join('\n')}`,
)
}
19 changes: 14 additions & 5 deletions src/lib/docs-seo-metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,13 @@ const migratedOpenApiTitleFiles = [
'api/versioning-policy.mdx',
]

const preservedJxomFiles = [
// Vocs renders pages under the OpenAPI mount with its own layout, which uses
// seoTitle or the bare title and skips the site title template. These
// jxom-authored pages keep their title and H1, and set an seoTitle only so the
// document title gets the docs suffix that seo-metadata.test.ts enforces.
const openApiLayoutSeoTitleFiles = [
'api/authentication.mdx',
'api/faq.mdx',
'api/fee-payer.mdx',
'api/indexer-api.mdx',
'api/json-rpc.mdx',
Expand Down Expand Up @@ -95,7 +100,11 @@ describe('docs SEO metadata', () => {
.map((file) => ({ file, source: fs.readFileSync(file, 'utf8') }))
.filter(
({ file, source }) =>
/^seoTitle:/m.test(source) && path.relative(docsRoot, file) !== 'api/reference.mdx',
/^seoTitle:/m.test(source) &&
path.relative(docsRoot, file) !== 'api/reference.mdx' &&
!openApiLayoutSeoTitleFiles.includes(path.relative(docsRoot, file)) &&
// TIP pages keep the proposal's title and a numbered H1.
!path.relative(docsRoot, file).startsWith('protocol/tips/'),
)

expect(auditedPages.length).toBeGreaterThanOrEqual(115)
Expand All @@ -112,10 +121,10 @@ describe('docs SEO metadata', () => {
}
})

test('keeps jxom-authored API metadata outside the audit rewrite', () => {
for (const file of preservedJxomFiles) {
test('gives jxom-authored OpenAPI pages a suffixed SEO title', () => {
for (const file of openApiLayoutSeoTitleFiles) {
const source = fs.readFileSync(path.join(docsRoot, file), 'utf8')
expect(frontmatterString(source, 'seoTitle'), file).toBeUndefined()
expect(frontmatterString(source, 'seoTitle'), file).toMatch(/ \| Tempo Docs$/)
}
})

Expand Down
14 changes: 13 additions & 1 deletion src/lib/docs-structured-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,19 @@ describe('docs structured data', () => {
expect(docsStructuredDataHead('/', {})).toMatchObject({
canonical: 'https://tempo.xyz/developers',
})
expect(docsStructuredDataHead('/get-started', {})).not.toHaveProperty('canonical')
})

test.each([
['/get-started', 'https://tempo.xyz/developers/get-started'],
['/get-started/use-cases', 'https://tempo.xyz/developers/get-started/use-cases'],
['/docs/accounts/access-keys', 'https://tempo.xyz/developers/docs/accounts/access-keys'],
['/docs/api/activities', 'https://tempo.xyz/developers/docs/api/activities'],
['/docs/guide/payments/', 'https://tempo.xyz/developers/docs/guide/payments'],
['docs/partners', 'https://tempo.xyz/developers/docs/partners'],
])('emits an explicit no-slash canonical for %s', (route, canonical) => {
expect(docsStructuredDataHead(route, {})?.canonical).toBe(canonical)
const schema = graph(route, { title: 'Page' })
expect(node(schema, 'TechArticle').url).toBe(canonical)
})

test('uses authored page metadata for TechArticle semantics', () => {
Expand Down
10 changes: 6 additions & 4 deletions src/lib/docs-structured-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,11 @@ export function docsStructuredDataHead(
return { base: false, meta: ogImage }
}

// The public mount has no trailing slash. Match its redirect policy and the
// URLs used by the docs graph instead of Vocs' default baseUrl + "/".
const canonical = pagePath === '/' ? { canonical: developersUrl } : {}
// Every docs page names its public URL explicitly, so preview and proxy hosts
// never become canonical. The public mount has no trailing slash: match its
// redirect policy and the sitemap instead of Vocs' default baseUrl + path.
const canonicalUrl = `${developersUrl}${pagePath === '/' ? '' : pagePath.replace(/\/+$/, '')}`
const canonical = { canonical: canonicalUrl }
// Advertise the existing generated text without requiring hydration or a
// reader to discover the page-tools menu. Use the canonical public mount.
const markdownRoute = pagePath === '/' ? '/index' : pagePath.replace(/\/$/, '')
Expand Down Expand Up @@ -120,7 +122,7 @@ export function docsStructuredDataHead(
const websiteId = 'https://tempo.xyz/#website'
const entityDescription =
'Tempo is a payments-first Layer 1 blockchain built for stablecoin payments, global payouts, agentic payments, and enterprise settlement.'
const url = `${developersUrl}${pagePath === '/' ? '' : pagePath}`
const url = canonicalUrl
const description = frontmatter?.description?.trim()
const breadcrumbId = `${url}#breadcrumb`
const breadcrumbs = [
Expand Down
Loading
Loading