Skip to content

Commit c14e419

Browse files
committed
fix(seo): resolve docs slugs through the catalog, keep API pages negotiable, and harden the crawler suite
1 parent d129483 commit c14e419

10 files changed

Lines changed: 107 additions & 45 deletions

File tree

‎apps/docs/app/[[...slug]]/page.tsx‎

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type React from 'react'
22
import { Children, cloneElement, isValidElement } from 'react'
3+
import { LANDING_SLUG_BY_DOCS_SLUG } from '@sim/deployment-config/integration-docs'
34
import { INTEGRATION_METADATA } from '@sim/deployment-config/integration-metadata'
45
import { highlight } from 'fumadocs-core/highlight'
56
import { findNeighbour } from 'fumadocs-core/page-tree'
@@ -25,17 +26,21 @@ import { DOCS_BASE_URL, SIM_SITE_URL } from '@/lib/urls'
2526

2627
const BASE_URL = DOCS_BASE_URL
2728

28-
/**
29-
* Landing page for each docs integration page. Docs pages are keyed by block type without its
30-
* version suffix (`gmail_v2` → `/integrations/gmail`); landing slugs are hyphenated catalog slugs.
31-
*/
32-
const INTEGRATION_LANDING_PAGES = new Map(
33-
INTEGRATION_METADATA.map((integration) => [
34-
integration.type.replace(/_v\d+$/, ''),
35-
{ name: integration.name, url: `${SIM_SITE_URL}/integrations/${integration.slug}` },
36-
])
29+
const INTEGRATION_NAME_BY_LANDING_SLUG = new Map(
30+
INTEGRATION_METADATA.map((integration) => [integration.slug, integration.name])
3731
)
3832

33+
/** The landing page a docs integration page (`/integrations/<docs slug>`) links to, if any. */
34+
function getIntegrationLandingPage(slug: string[] | undefined) {
35+
if (slug?.length !== 2 || slug[0] !== 'integrations') return undefined
36+
const landingSlug = LANDING_SLUG_BY_DOCS_SLUG.get(slug[1])
37+
if (!landingSlug) return undefined
38+
return {
39+
name: INTEGRATION_NAME_BY_LANDING_SLUG.get(landingSlug) ?? landingSlug,
40+
url: `${SIM_SITE_URL}/integrations/${landingSlug}`,
41+
}
42+
}
43+
3944
/**
4045
* Most pages close with a `## Next` / `## Next steps` grid of onward links.
4146
* That heading is navigation, not content, so it is kept out of the table of
@@ -134,10 +139,7 @@ export default async function Page(props: { params: Promise<{ slug?: string[] }>
134139
}
135140
const isOpenAPI = '_openapi' in data && data._openapi != null
136141
const isApiReference = slug?.some((s) => s === 'api-reference') ?? false
137-
const integrationLandingPage =
138-
slug?.length === 2 && slug[0] === 'integrations'
139-
? INTEGRATION_LANDING_PAGES.get(slug[1])
140-
: undefined
142+
const integrationLandingPage = getIntegrationLandingPage(slug)
141143
const isCli = slug?.[0] === 'cli'
142144
const isMcp = slug?.[0] === 'mcp'
143145

‎apps/docs/app/llms.txt/route.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ Sim is the open-source AI workspace where teams build, deploy, and manage AI age
4545
4646
## Documentation Overview
4747
48-
This file provides an overview of our documentation. For full content of all pages, see [llms-full.txt](${baseUrl}/llms-full.txt).
48+
This file provides an overview of our documentation. For the full content of every guide and integration page, see [llms-full.txt](${baseUrl}/llms-full.txt). API reference operations are not in it: each one is served as markdown at its page URL with \`.md\` appended, e.g. ${baseUrl}/api-reference/tables/countTableRows.md.
4949
5050
## Main Sections
5151
@@ -61,8 +61,8 @@ ${Object.entries(sections)
6161
6262
## Additional Resources
6363
64-
- [Full documentation content](${baseUrl}/llms-full.txt)
65-
- Individual page content: ${baseUrl}/llms.mdx/[page-path]
64+
- [Full documentation content, excluding API reference operations](${baseUrl}/llms-full.txt)
65+
- Individual page content as markdown: ${baseUrl}/[page-path].md
6666
- [API documentation](${baseUrl}/api-reference/getting-started)
6767
- [Integrations](${baseUrl}/integrations)
6868

‎apps/docs/app/robots.txt/route.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ Sitemap: ${baseUrl}/sitemap.xml
2323
# See https://github.com/AnswerDotAI/llms-txt for more info
2424
# LLM-friendly content:
2525
# Manifest: ${baseUrl}/llms.txt
26-
# Full content: ${baseUrl}/llms-full.txt
27-
# Individual pages: ${baseUrl}/llms.mdx/[page-path]`
26+
# Full content (excluding API reference operations): ${baseUrl}/llms-full.txt
27+
# Individual pages: ${baseUrl}/[page-path].md`
2828

2929
return new Response(robotsTxt, {
3030
headers: {

‎apps/docs/lib/llms.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import type { PageData, source } from '@/lib/source'
55

66
const TABLE_ROW = /^\|.*\|$/
77
const TABLE_DELIMITER_CELL = /^\s*(:?)-+(:?)\s*$/
8+
const UNESCAPED_PIPE = /(?<!\\)\|/
89

910
/**
1011
* The processed-markdown serializer pads every table cell to its column's widest cell, so one long
1112
* enum (a parameter listing every timezone) pads every row of its table and roughly doubles the
12-
* integration reference. Collapse the padding; the tables stay valid GFM.
13+
* integration reference. Drop that trailing padding; a cell's own leading indentation (the `↳`
14+
* nesting in output tables) and inline code are kept, and the tables stay valid GFM.
1315
*/
1416
function compactTables(markdown: string): string {
1517
let inFence = false
@@ -18,11 +20,11 @@ function compactTables(markdown: string): string {
1820
.map((line) => {
1921
if (line.startsWith('```')) inFence = !inFence
2022
if (inFence || !TABLE_ROW.test(line)) return line
21-
const cells = line.slice(1, -1).split('|')
23+
const cells = line.slice(1, -1).split(UNESCAPED_PIPE)
2224
if (cells.every((cell) => TABLE_DELIMITER_CELL.test(cell))) {
2325
return `|${cells.map((cell) => cell.replace(TABLE_DELIMITER_CELL, ' $1---$2 ')).join('|')}|`
2426
}
25-
return line.replace(/ {2,}/g, ' ')
27+
return `|${cells.map((cell) => ` ${cell.replace(/^ /, '').trimEnd()} `).join('|')}|`
2628
})
2729
.join('\n')
2830
}

‎apps/docs/proxy.ts‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,21 @@ import { type NextRequest, NextResponse } from 'next/server'
44
/** Docs pages live at the root (`/introduction`), so every page path maps to its markdown route. */
55
const { rewrite: rewriteLLM } = rewritePath('/*path', '/llms.mdx/*path')
66

7-
/** `.md`/`.mdx` URLs already resolve to markdown through the `next.config.ts` rewrites. */
8-
const MARKDOWN_SUFFIX = /\.mdx?$/
7+
/**
8+
* Docs page paths never carry an extension, so a final segment with one is a file or route
9+
* (`/openapi.json`, `/introduction.md`) that must not be rewritten to a markdown page.
10+
*/
11+
const FILE_EXTENSION = /\.[^/]+$/
912

1013
export default function proxy(request: NextRequest) {
1114
const { pathname } = request.nextUrl
12-
if (isMarkdownPreferred(request) && !MARKDOWN_SUFFIX.test(pathname)) {
15+
if (isMarkdownPreferred(request) && !FILE_EXTENSION.test(pathname)) {
1316
const result = rewriteLLM(pathname)
1417

1518
if (result) {
16-
return NextResponse.rewrite(new URL(result, request.nextUrl))
19+
const response = NextResponse.rewrite(new URL(result, request.nextUrl))
20+
response.headers.set('Vary', 'Accept')
21+
return response
1722
}
1823
}
1924

@@ -22,6 +27,6 @@ export default function proxy(request: NextRequest) {
2227

2328
export const config = {
2429
matcher: [
25-
'/((?!api|llms.mdx|_next/static|_next/image|favicon|icon.svg|static|robots.txt|sitemap.xml|llms.txt|llms-full.txt).*)',
30+
'/((?!api/|api$|llms.mdx|_next/static|_next/image|favicon|icon.svg|static|robots.txt|sitemap.xml|llms.txt|llms-full.txt).*)',
2631
],
2732
}

‎apps/docs/scripts/fetch-git-history.ts‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,21 @@ import { getErrorMessage } from '@sim/utils/errors'
1313

1414
const logger = createLogger('DocsGitHistory')
1515

16-
function git(args: string[]): string {
17-
return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim()
16+
/** Bounds the fetch so a stalled remote costs at most this much of the deployment, never all of it. */
17+
const FETCH_TIMEOUT_MS = 120_000
18+
19+
function git(args: string[], timeout?: number): string {
20+
return execFileSync('git', args, {
21+
encoding: 'utf8',
22+
stdio: ['ignore', 'pipe', 'pipe'],
23+
timeout,
24+
}).trim()
1825
}
1926

2027
if (process.env.VERCEL) {
2128
try {
2229
if (git(['rev-parse', '--is-shallow-repository']) === 'true') {
23-
git(['fetch', '--unshallow', '--filter=blob:none', '--no-tags', 'origin'])
30+
git(['fetch', '--unshallow', '--filter=blob:none', '--no-tags', 'origin'], FETCH_TIMEOUT_MS)
2431
logger.info('Fetched full commit history for docs last-modified dates')
2532
}
2633
} catch (error) {

‎apps/sim/proxy.ts‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import {
2+
LANDING_INTEGRATION_SLUGS,
3+
LANDING_SLUG_BY_DOCS_SLUG,
4+
} from '@sim/deployment-config/integration-docs'
15
import { createLogger } from '@sim/logger'
26
import { getSessionCookie } from 'better-auth/cookies'
37
import { type NextRequest, NextResponse } from 'next/server'
@@ -328,15 +332,17 @@ function handleSecurityFiltering(request: NextRequest): NextResponse | null {
328332
}
329333

330334
/**
331-
* Landing integration slugs are hyphenated (`google-drive`) while docs slugs use underscores
332-
* (`google_drive`), so underscore URLs reach the landing site through hand-written and
333-
* search-indexed links. Redirect them to the hyphenated page instead of serving a 404.
335+
* Docs integration slugs (`google_drive`, `microsoft_ad`) are not landing slugs (`google-drive`,
336+
* `azure-ad`), yet reach the landing site through hand-written and search-indexed links. Redirect a
337+
* known docs slug to its landing page through the catalog mapping instead of serving a 404.
334338
*/
335339
function handleIntegrationSlugRedirect(request: NextRequest): NextResponse | null {
336-
const slug = /^\/integrations\/([^/]*_[^/]*)\/?$/.exec(request.nextUrl.pathname)?.[1]
337-
if (!slug) return null
340+
const slug = /^\/integrations\/([^/]+)\/?$/.exec(request.nextUrl.pathname)?.[1]
341+
if (!slug || LANDING_INTEGRATION_SLUGS.has(slug)) return null
342+
const landingSlug = LANDING_SLUG_BY_DOCS_SLUG.get(slug)
343+
if (!landingSlug) return null
338344
const url = request.nextUrl.clone()
339-
url.pathname = `/integrations/${slug.replace(/_/g, '-')}`
345+
url.pathname = `/integrations/${landingSlug}`
340346
return NextResponse.redirect(url, 308)
341347
}
342348

@@ -409,7 +415,7 @@ export function proxy(request: NextRequest) {
409415
}
410416

411417
const integrationSlugRedirect = handleIntegrationSlugRedirect(request)
412-
if (integrationSlugRedirect) return integrationSlugRedirect
418+
if (integrationSlugRedirect) return applyIndexingPolicy(request, integrationSlugRedirect)
413419

414420
const invitationRedirect = handleInvitationRedirects(request, hasActiveSession)
415421
if (invitationRedirect) return applyIndexingPolicy(request, invitationRedirect)

‎apps/sim/scripts/test-landing-seo-e2e.ts‎

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import assert from 'node:assert/strict'
22
import { mkdir, writeFile } from 'node:fs/promises'
33
import { dirname, resolve } from 'node:path'
4+
import {
5+
LANDING_INTEGRATION_SLUGS,
6+
LANDING_SLUG_BY_DOCS_SLUG,
7+
} from '@sim/deployment-config/integration-docs'
48
import integrationsJson from '@sim/deployment-config/integrations.json'
59
import { createLogger } from '@sim/logger'
610
import { getErrorMessage } from '@sim/utils/errors'
@@ -104,15 +108,18 @@ async function readSitemap(): Promise<Map<string, string | undefined>> {
104108
}
105109

106110
async function run() {
107-
const sitemap = await readSitemap()
111+
let sitemap = new Map<string, string | undefined>()
112+
await check('sitemap.xml is served', async () => {
113+
sitemap = await readSitemap()
114+
})
108115
const sitemapPaths = [...sitemap.keys()]
109116
const firstPath = (pattern: RegExp) => {
110117
const path = sitemapPaths.find((candidate) => pattern.test(candidate))
111118
assert(path, `The sitemap lists no page matching ${pattern}`)
112119
return path
113120
}
114121

115-
await check('unknown slugs return 404 with a single noindex directive', async () => {
122+
await check('unknown slugs return 404 and every robots directive is noindex', async () => {
116123
const unknown = [
117124
'/integrations/not-a-real-integration',
118125
'/library/not-a-real-post',
@@ -126,7 +133,8 @@ async function run() {
126133
for (const path of unknown) {
127134
const { status, body } = await get(path)
128135
const robots = robotsDirectives(body)
129-
const indexable = robots.some((directive) => !directive.includes('noindex'))
136+
const indexable =
137+
robots.length === 0 || robots.some((directive) => !/\bnoindex\b/.test(directive))
130138
if (status !== 404 || indexable) {
131139
failures.push(`${path} → ${status}, robots ${JSON.stringify(robots)}`)
132140
}
@@ -154,10 +162,17 @@ async function run() {
154162
assert.deepEqual(failures, [])
155163
})
156164

157-
await check('underscore integration slugs redirect to the hyphenated page', async () => {
158-
const { status, location } = await get('/integrations/google_drive')
159-
assert.equal(status, 308)
160-
assert.equal(location && new URL(location, baseUrl).pathname, '/integrations/google-drive')
165+
await check('docs integration slugs redirect to their landing page', async () => {
166+
const failures: string[] = []
167+
for (const [docsSlug, landingSlug] of LANDING_SLUG_BY_DOCS_SLUG) {
168+
if (docsSlug === landingSlug || LANDING_INTEGRATION_SLUGS.has(docsSlug)) continue
169+
const { status, location } = await get(`/integrations/${docsSlug}`)
170+
const target = location ? new URL(location, baseUrl).pathname : null
171+
if (status !== 308 || target !== `/integrations/${landingSlug}`) {
172+
failures.push(`${docsSlug} → ${status} ${target}`)
173+
}
174+
}
175+
assert.deepEqual(failures, [])
161176
})
162177

163178
await check('integration sitemap dates are per integration, not one catalog date', async () => {
@@ -235,7 +250,11 @@ async function run() {
235250
}
236251
assert.deepEqual(missing, [])
237252
})
253+
}
238254

255+
try {
256+
await run()
257+
} finally {
239258
await mkdir(dirname(reportPath), { recursive: true })
240259
await writeFile(
241260
reportPath,
@@ -252,8 +271,6 @@ async function run() {
252271
)
253272
)
254273
}
255-
256-
await run()
257274
const failures = checks.filter((result) => result.status === 'failed')
258275
logger.info('Landing SEO checks complete', {
259276
passed: checks.length - failures.length,

‎packages/deployment-config/package.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
"types": "./src/integration-availability.ts",
1919
"default": "./src/integration-availability.ts"
2020
},
21+
"./integration-docs": {
22+
"types": "./src/integration-docs.ts",
23+
"default": "./src/integration-docs.ts"
24+
},
2125
"./integration-metadata": {
2226
"types": "./src/integration-metadata.ts",
2327
"default": "./src/integration-metadata.ts"
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { INTEGRATION_METADATA } from '@sim/deployment-config/integration-metadata'
2+
3+
/**
4+
* Docs integration pages are keyed by block type without its version suffix (`gmail_v2` →
5+
* `/integrations/gmail`), while landing pages use the hyphenated catalog slug, and the two differ
6+
* in more than spelling (`microsoft_ad` documents the `azure-ad` integration). Maps each docs slug
7+
* to its landing slug, so docs and landing links never guess one from the other.
8+
*/
9+
export const LANDING_SLUG_BY_DOCS_SLUG: ReadonlyMap<string, string> = new Map(
10+
INTEGRATION_METADATA.map((integration) => [
11+
integration.type.replace(/_v\d+$/, ''),
12+
integration.slug,
13+
])
14+
)
15+
16+
/** Every landing integration slug, i.e. every `/integrations/<slug>` page on the landing site. */
17+
export const LANDING_INTEGRATION_SLUGS: ReadonlySet<string> = new Set(
18+
INTEGRATION_METADATA.map((integration) => integration.slug)
19+
)

0 commit comments

Comments
 (0)