diff --git a/apps/docs/content/docs/de/api-reference/python.mdx b/apps/docs/content/docs/de/api-reference/python.mdx index c6c7e7c99f0..a0fed9124bd 100644 --- a/apps/docs/content/docs/de/api-reference/python.mdx +++ b/apps/docs/content/docs/de/api-reference/python.mdx @@ -662,7 +662,7 @@ def stream_workflow(): 'X-API-Key': os.getenv('SIM_API_KEY') }, json={ - 'message': 'Generate a story', + 'input': {'message': 'Generate a story'}, 'stream': True, 'selectedOutputs': ['agent1.content'] }, diff --git a/apps/docs/content/docs/de/api-reference/typescript.mdx b/apps/docs/content/docs/de/api-reference/typescript.mdx index 36adb6d198e..1128a2f4bb4 100644 --- a/apps/docs/content/docs/de/api-reference/typescript.mdx +++ b/apps/docs/content/docs/de/api-reference/typescript.mdx @@ -972,7 +972,7 @@ function StreamingWorkflow() { 'X-API-Key': process.env.SIM_API_KEY! // Server-side environment variable only }, body: JSON.stringify({ - message: 'Generate a story', + input: { message: 'Generate a story' }, stream: true, selectedOutputs: ['agent1.content'] }) diff --git a/apps/docs/content/docs/es/api-reference/python.mdx b/apps/docs/content/docs/es/api-reference/python.mdx index f21ade28d81..2cc402cdf7d 100644 --- a/apps/docs/content/docs/es/api-reference/python.mdx +++ b/apps/docs/content/docs/es/api-reference/python.mdx @@ -662,7 +662,7 @@ def stream_workflow(): 'X-API-Key': os.getenv('SIM_API_KEY') }, json={ - 'message': 'Generate a story', + 'input': {'message': 'Generate a story'}, 'stream': True, 'selectedOutputs': ['agent1.content'] }, diff --git a/apps/docs/content/docs/es/api-reference/typescript.mdx b/apps/docs/content/docs/es/api-reference/typescript.mdx index 5fa07b4083d..b3a798ae882 100644 --- a/apps/docs/content/docs/es/api-reference/typescript.mdx +++ b/apps/docs/content/docs/es/api-reference/typescript.mdx @@ -972,7 +972,7 @@ function StreamingWorkflow() { 'X-API-Key': process.env.SIM_API_KEY! // Server-side environment variable only }, body: JSON.stringify({ - message: 'Generate a story', + input: { message: 'Generate a story' }, stream: true, selectedOutputs: ['agent1.content'] }) diff --git a/apps/docs/content/docs/fr/api-reference/python.mdx b/apps/docs/content/docs/fr/api-reference/python.mdx index 27e68cc00b2..c860ee2d826 100644 --- a/apps/docs/content/docs/fr/api-reference/python.mdx +++ b/apps/docs/content/docs/fr/api-reference/python.mdx @@ -662,7 +662,7 @@ def stream_workflow(): 'X-API-Key': os.getenv('SIM_API_KEY') }, json={ - 'message': 'Generate a story', + 'input': {'message': 'Generate a story'}, 'stream': True, 'selectedOutputs': ['agent1.content'] }, diff --git a/apps/docs/content/docs/fr/api-reference/typescript.mdx b/apps/docs/content/docs/fr/api-reference/typescript.mdx index e8420d51f7e..3473cdf20c9 100644 --- a/apps/docs/content/docs/fr/api-reference/typescript.mdx +++ b/apps/docs/content/docs/fr/api-reference/typescript.mdx @@ -972,7 +972,7 @@ function StreamingWorkflow() { 'X-API-Key': process.env.SIM_API_KEY! // Server-side environment variable only }, body: JSON.stringify({ - message: 'Generate a story', + input: { message: 'Generate a story' }, stream: true, selectedOutputs: ['agent1.content'] }) diff --git a/apps/docs/content/docs/ja/api-reference/python.mdx b/apps/docs/content/docs/ja/api-reference/python.mdx index 4d560acdffb..68fb803d0e9 100644 --- a/apps/docs/content/docs/ja/api-reference/python.mdx +++ b/apps/docs/content/docs/ja/api-reference/python.mdx @@ -662,7 +662,7 @@ def stream_workflow(): 'X-API-Key': os.getenv('SIM_API_KEY') }, json={ - 'message': 'Generate a story', + 'input': {'message': 'Generate a story'}, 'stream': True, 'selectedOutputs': ['agent1.content'] }, diff --git a/apps/docs/content/docs/ja/api-reference/typescript.mdx b/apps/docs/content/docs/ja/api-reference/typescript.mdx index 165c2a57a71..2faa6711b73 100644 --- a/apps/docs/content/docs/ja/api-reference/typescript.mdx +++ b/apps/docs/content/docs/ja/api-reference/typescript.mdx @@ -972,7 +972,7 @@ function StreamingWorkflow() { 'X-API-Key': process.env.SIM_API_KEY! // Server-side environment variable only }, body: JSON.stringify({ - message: 'Generate a story', + input: { message: 'Generate a story' }, stream: true, selectedOutputs: ['agent1.content'] }) diff --git a/apps/docs/content/docs/zh/api-reference/python.mdx b/apps/docs/content/docs/zh/api-reference/python.mdx index df11bd5d38d..1071421db8f 100644 --- a/apps/docs/content/docs/zh/api-reference/python.mdx +++ b/apps/docs/content/docs/zh/api-reference/python.mdx @@ -663,7 +663,7 @@ def stream_workflow(): 'X-API-Key': os.getenv('SIM_API_KEY') }, json={ - 'message': 'Generate a story', + 'input': {'message': 'Generate a story'}, 'stream': True, 'selectedOutputs': ['agent1.content'] }, diff --git a/apps/docs/content/docs/zh/api-reference/typescript.mdx b/apps/docs/content/docs/zh/api-reference/typescript.mdx index 46ef94492d7..1a51be133b6 100644 --- a/apps/docs/content/docs/zh/api-reference/typescript.mdx +++ b/apps/docs/content/docs/zh/api-reference/typescript.mdx @@ -971,7 +971,7 @@ function StreamingWorkflow() { 'X-API-Key': process.env.SIM_API_KEY! // Server-side environment variable only }, body: JSON.stringify({ - message: 'Generate a story', + input: { message: 'Generate a story' }, stream: true, selectedOutputs: ['agent1.content'] }) diff --git a/apps/docs/lib/redirects.ts b/apps/docs/lib/redirects.ts new file mode 100644 index 00000000000..8098749efa5 --- /dev/null +++ b/apps/docs/lib/redirects.ts @@ -0,0 +1,360 @@ +import type { NextConfig } from 'next' + +/** The shape Next expects back from `next.config.ts`'s `redirects()`. */ +type DocsRedirect = Awaited>>[number] + +/** + * Every redirect the docs site serves, in match order — Next applies the first + * matching rule. + * + * This lives outside `next.config.ts` so it can be read without evaluating that + * module. `createMDX()` runs at import time and bundles `source.config.ts` + * against `process.cwd()`, so importing the config from the root Vitest project + * fails with `The entry point "source.config.ts" cannot be marked as external`. + * `scripts/openapi/docs-redirects.test.ts` reads this array directly to keep the + * `/api-reference/` rules honest against the specs. + * + * The whole table lives here rather than only the `/api-reference/` block: Next + * applies the first matching rule, so splitting one ordered list across two + * modules would make match order an emergent property of two files. + */ +export const DOCS_REDIRECTS: DocsRedirect[] = [ + { + source: '/', + destination: '/introduction', + permanent: true, + }, + // building-agents/agents merged into the building-agents overview + { source: '/building-agents/agents', destination: '/agents', permanent: true }, + // form deployment removed + { source: '/deployment/form', destination: '/workflows/deployment', permanent: true }, + // copilot deprecated and removed + { source: '/copilot', destination: '/chat', permanent: true }, + { source: '/copilot/:path*', destination: '/chat', permanent: true }, + // mothership/* renamed to chat/* — the surface is "Chat", the agent is "Sim" + { source: '/mothership', destination: '/chat', permanent: true }, + { source: '/mothership/:path*', destination: '/chat/:path*', permanent: true }, + // connections/* and variables/* collapsed into single pages under workflows/ + { source: '/connections', destination: '/workflows/connections', permanent: true }, + { source: '/connections/:path*', destination: '/workflows/connections', permanent: true }, + { source: '/variables', destination: '/workflows/variables', permanent: true }, + { source: '/variables/:path*', destination: '/workflows/variables', permanent: true }, + // capabilities/* renamed to building-agents/* + { source: '/capabilities', destination: '/agents', permanent: true }, + { source: '/capabilities/agents', destination: '/agents', permanent: true }, + { + source: '/capabilities/choosing', + destination: '/agents/choosing', + permanent: true, + }, + // execution/* was broken up; redirect old URLs to their new homes + { source: '/execution', destination: '/workflows', permanent: true }, + { source: '/execution/index', destination: '/workflows', permanent: true }, + { source: '/execution/basics', destination: '/workflows/how-it-runs', permanent: true }, + { source: '/execution/files', destination: '/files/passing-files', permanent: true }, + { source: '/execution/logging', destination: '/logs-debugging/logging', permanent: true }, + { source: '/execution/costs', destination: '/platform/costs', permanent: true }, + { source: '/execution/api', destination: '/api-reference/getting-started', permanent: true }, + { + source: '/execution/api-deployment', + destination: '/workflows/deployment/api', + permanent: true, + }, + { source: '/execution/chat', destination: '/workflows/deployment/chat', permanent: true }, + // Points at the final page, not at `/deployment/form` — that is itself a + // redirect source above, and Next does not chain rules internally. + { source: '/execution/form', destination: '/workflows/deployment', permanent: true }, + { + source: '/mcp/deploy-workflows', + destination: '/workflows/deployment/mcp', + permanent: true, + }, + // building-agents section renamed to agents; mcp and skills folded into it + { source: '/building-agents', destination: '/agents', permanent: true }, + { source: '/building-agents/:path*', destination: '/agents/:path*', permanent: true }, + { source: '/mcp', destination: '/agents/mcp', permanent: true }, + { source: '/skills', destination: '/agents/skills', permanent: true }, + // tools/ + triggers/ unified into per-service integrations/ pages. + // Specific moves first (Next applies the first matching redirect): + { + source: '/tools/custom-tools', + destination: '/agents/custom-tools', + permanent: true, + }, + { source: '/tools', destination: '/integrations', permanent: true }, + { source: '/tools/:slug', destination: '/integrations/:slug', permanent: true }, + // Old blocks/triggers index pages were folded into the workflows overview. + // Native trigger pages (/triggers/start|schedule|webhook|rss|table) still exist. + { source: '/blocks', destination: '/workflows#blocks', permanent: true }, + { source: '/triggers', destination: '/workflows#triggers', permanent: true }, + // Integration trigger pages: provider slug differs from the block type for a few. + { + source: '/triggers/jsm', + destination: '/integrations/jira_service_management', + permanent: true, + }, + { + source: '/triggers/google-calendar', + destination: '/integrations/google_calendar', + permanent: true, + }, + { + source: '/triggers/google-drive', + destination: '/integrations/google_drive', + permanent: true, + }, + { + source: '/triggers/google-sheets', + destination: '/integrations/google_sheets', + permanent: true, + }, + { + source: '/triggers/microsoft-teams', + destination: '/integrations/microsoft_teams', + permanent: true, + }, + { + source: + '/triggers/:slug(airtable|ashby|attio|azure_devops|calcom|calendly|circleback|confluence|emailbison|fathom|fireflies|github|gmail|gong|google_forms|grain|greenhouse|hubspot|imap|intercom|jira|lemlist|linear|monday|notion|outlook|resend|salesforce|sendblue|servicenow|slack|stripe|telegram|twilio_voice|typeform|vercel|webflow|whatsapp|zoom)', + destination: '/integrations/:slug', + permanent: true, + }, + // URL structure now mirrors the sidebar: sections own their pages. + { source: '/blocks/:slug', destination: '/workflows/blocks/:slug', permanent: true }, + { + source: '/triggers/:slug(start|schedule|webhook|rss|table|sim)', + destination: '/workflows/triggers/:slug', + permanent: true, + }, + { source: '/deployment', destination: '/workflows/deployment', permanent: true }, + { + source: '/deployment/:path*', + destination: '/workflows/deployment/:path*', + permanent: true, + }, + { source: '/mailer', destination: '/chat/mailer', permanent: true }, + { source: '/credentials', destination: '/platform/credentials', permanent: true }, + { + source: '/credentials/:path*', + destination: '/platform/credentials', + permanent: true, + }, + { source: '/permissions', destination: '/platform/permissions', permanent: true }, + { + source: '/permissions/:path*', + destination: '/platform/permissions', + permanent: true, + }, + { source: '/workspaces/fundamentals', destination: '/platform/workspaces', permanent: true }, + { + source: '/workspaces/:slug(organization|permissions|credentials)', + destination: '/platform/:slug', + permanent: true, + }, + { source: '/costs', destination: '/platform/costs', permanent: true }, + { source: '/self-hosting', destination: '/platform/self-hosting', permanent: true }, + { + source: '/self-hosting/:path*', + destination: '/platform/self-hosting/:path*', + permanent: true, + }, + { source: '/enterprise', destination: '/platform/enterprise', permanent: true }, + { + source: '/enterprise/:path*', + destination: '/platform/enterprise/:path*', + permanent: true, + }, + + /** + * Continuity for the `/api-reference/` operation pages retired when the docs + * moved from the single v1 `openapi.json` to the seven code-first v2 specs. + * + * Every source below was a live, sitemap-submitted page whose slug no longer + * exists in the generated set. Sources are unprefixed and have no locale + * variants because generated pages mount only under the `en` base directory; + * `scripts/openapi/docs-redirects.test.ts` documents and implements the slug + * derivation. + * + * `permanent: true` (308) is reserved for a true 1:1 successor: same operation, + * renamed. A 308 is cached indefinitely and is effectively unrecallable, so + * anything that collapses two pages into one, changes the identifier model, or + * lands on a merely adjacent operation uses `permanent: false` (307). + * + * `scripts/openapi/docs-redirects.test.ts` pins both halves of the invariant + * against the specs: no source may shadow a live slug, and every destination + * must resolve. + */ + // Pure operationId renames — same path and method, v1 -> v2. + { + source: '/api-reference/audit-logs/getAuditLogDetails', + destination: '/api-reference/audit-logs/getAuditLog', + permanent: true, + }, + { + source: '/api-reference/knowledge-bases/listDocuments', + destination: '/api-reference/knowledge-bases/listKnowledgeDocuments', + permanent: true, + }, + { + source: '/api-reference/knowledge-bases/getDocument', + destination: '/api-reference/knowledge-bases/getKnowledgeDocument', + permanent: true, + }, + { + source: '/api-reference/knowledge-bases/deleteDocument', + destination: '/api-reference/knowledge-bases/deleteKnowledgeDocument', + permanent: true, + }, + { + source: '/api-reference/knowledge-bases/uploadDocument', + destination: '/api-reference/knowledge-bases/uploadKnowledgeDocument', + permanent: true, + }, + { + source: '/api-reference/knowledge-bases/searchKnowledgeBase', + destination: '/api-reference/knowledge-bases/searchKnowledge', + permanent: true, + }, + { + source: '/api-reference/logs/queryLogs', + destination: '/api-reference/logs/listLogs', + permanent: true, + }, + { + source: '/api-reference/tables/listRows', + destination: '/api-reference/tables/listTableRows', + permanent: true, + }, + { + source: '/api-reference/tables/getRow', + destination: '/api-reference/tables/getTableRow', + permanent: true, + }, + { + source: '/api-reference/tables/insertRows', + destination: '/api-reference/tables/createTableRows', + permanent: true, + }, + { + source: '/api-reference/tables/updateRow', + destination: '/api-reference/tables/updateTableRow', + permanent: true, + }, + // v1 `PUT /rows` and v2 `PATCH /rows` are both the filter-predicate bulk update. + { + source: '/api-reference/tables/updateRows', + destination: '/api-reference/tables/updateTableRows', + permanent: true, + }, + { + source: '/api-reference/tables/deleteRow', + destination: '/api-reference/tables/deleteTableRow', + permanent: true, + }, + { + source: '/api-reference/tables/deleteRows', + destination: '/api-reference/tables/deleteTableRows', + permanent: true, + }, + { + source: '/api-reference/tables/upsertRow', + destination: '/api-reference/tables/upsertTableRow', + permanent: true, + }, + { + source: '/api-reference/tables/addColumn', + destination: '/api-reference/tables/addTableColumn', + permanent: true, + }, + { + source: '/api-reference/tables/updateColumn', + destination: '/api-reference/tables/updateTableColumn', + permanent: true, + }, + { + source: '/api-reference/tables/deleteColumn', + destination: '/api-reference/tables/deleteTableColumn', + permanent: true, + }, + { + source: '/api-reference/workflows/executeWorkflow', + destination: '/api-reference/workflows/executeWorkflowV2', + permanent: true, + }, + { + source: '/api-reference/workflows/getWorkflowExecution', + destination: '/api-reference/workflow-runs/getWorkflowRunV2', + permanent: true, + }, + { + source: '/api-reference/workflows/cancelExecution', + destination: '/api-reference/workflow-runs/cancelRunV2', + permanent: true, + }, + // Approximations — 307 so a better destination stays reachable later. + // v1 took multipart up to 100MB; v2 `createFile` takes inline UTF-8/base64 + // and defers larger payloads to an upload session. + { + source: '/api-reference/files/uploadFile', + destination: '/api-reference/files/createFile', + permanent: false, + }, + // The human-in-the-loop tag is retired: pause state is now a field on the + // run resource rather than its own endpoint family. + { + source: '/api-reference/human-in-the-loop/listPausedExecutions', + destination: '/api-reference/workflow-runs/listWorkflowRunsV2', + permanent: false, + }, + { + source: '/api-reference/human-in-the-loop/getPausedExecution', + destination: '/api-reference/workflow-runs/getWorkflowRunV2', + permanent: false, + }, + { + source: '/api-reference/human-in-the-loop/getPausedExecutionByResumePath', + destination: '/api-reference/workflow-runs/getWorkflowRunV2', + permanent: false, + }, + { + source: '/api-reference/human-in-the-loop/getPauseContext', + destination: '/api-reference/workflow-runs/getWorkflowRunV2', + permanent: false, + }, + { + source: '/api-reference/human-in-the-loop/resumeExecution', + destination: '/api-reference/workflow-runs/resumeWorkflowRunV2', + permanent: false, + }, + // Two v1 identifiers (log id, execution id) collapse onto one v2 runId. + { + source: '/api-reference/logs/getLogDetails', + destination: '/api-reference/logs/getLog', + permanent: false, + }, + { + source: '/api-reference/logs/getExecutionDetails', + destination: '/api-reference/logs/getLog', + permanent: false, + }, + // v1 updated a batch of rows by id; v2 has no by-id batch, only the predicate form. + { + source: '/api-reference/tables/batchUpdateRows', + destination: '/api-reference/tables/updateTableRows', + permanent: false, + }, + // v1 reported rate limits alongside spend and storage; v2 billing status + // carries plan, credit allowance, and storage quota but not rate limits. + { + source: '/api-reference/usage/getUsageLimits', + destination: '/api-reference/billing/getBillingStatus', + permanent: false, + }, + // v1 polled a jobId; the v2 queue receipt returns a `statusUrl` pointing at + // this run endpoint, which is the successor poll target. + { + source: '/api-reference/workflows/getJobStatus', + destination: '/api-reference/workflow-runs/getWorkflowRunV2', + permanent: false, + }, +] diff --git a/apps/docs/next.config.ts b/apps/docs/next.config.ts index c015c48b4a5..da86d15eec4 100644 --- a/apps/docs/next.config.ts +++ b/apps/docs/next.config.ts @@ -1,5 +1,6 @@ import { createMDX } from 'fumadocs-mdx/next' import type { NextConfig } from 'next' +import { DOCS_REDIRECTS } from './lib/redirects' const withMDX = createMDX() @@ -16,151 +17,7 @@ const config: NextConfig = { webpackBuildWorker: true, }, async redirects() { - return [ - { - source: '/', - destination: '/introduction', - permanent: true, - }, - // building-agents/agents merged into the building-agents overview - { source: '/building-agents/agents', destination: '/agents', permanent: true }, - // form deployment removed - { source: '/deployment/form', destination: '/workflows/deployment', permanent: true }, - // copilot deprecated and removed - { source: '/copilot', destination: '/chat', permanent: true }, - { source: '/copilot/:path*', destination: '/chat', permanent: true }, - // mothership/* renamed to chat/* — the surface is "Chat", the agent is "Sim" - { source: '/mothership', destination: '/chat', permanent: true }, - { source: '/mothership/:path*', destination: '/chat/:path*', permanent: true }, - // connections/* and variables/* collapsed into single pages under workflows/ - { source: '/connections', destination: '/workflows/connections', permanent: true }, - { source: '/connections/:path*', destination: '/workflows/connections', permanent: true }, - { source: '/variables', destination: '/workflows/variables', permanent: true }, - { source: '/variables/:path*', destination: '/workflows/variables', permanent: true }, - // capabilities/* renamed to building-agents/* - { source: '/capabilities', destination: '/agents', permanent: true }, - { source: '/capabilities/agents', destination: '/agents', permanent: true }, - { - source: '/capabilities/choosing', - destination: '/agents/choosing', - permanent: true, - }, - // execution/* was broken up; redirect old URLs to their new homes - { source: '/execution', destination: '/workflows', permanent: true }, - { source: '/execution/index', destination: '/workflows', permanent: true }, - { source: '/execution/basics', destination: '/workflows/how-it-runs', permanent: true }, - { source: '/execution/files', destination: '/files/passing-files', permanent: true }, - { source: '/execution/logging', destination: '/logs-debugging/logging', permanent: true }, - { source: '/execution/costs', destination: '/platform/costs', permanent: true }, - { source: '/execution/api', destination: '/api-reference/getting-started', permanent: true }, - { - source: '/execution/api-deployment', - destination: '/workflows/deployment/api', - permanent: true, - }, - { source: '/execution/chat', destination: '/workflows/deployment/chat', permanent: true }, - { source: '/execution/form', destination: '/deployment/form', permanent: true }, - { - source: '/mcp/deploy-workflows', - destination: '/workflows/deployment/mcp', - permanent: true, - }, - // building-agents section renamed to agents; mcp and skills folded into it - { source: '/building-agents', destination: '/agents', permanent: true }, - { source: '/building-agents/:path*', destination: '/agents/:path*', permanent: true }, - { source: '/mcp', destination: '/agents/mcp', permanent: true }, - { source: '/skills', destination: '/agents/skills', permanent: true }, - // tools/ + triggers/ unified into per-service integrations/ pages. - // Specific moves first (Next applies the first matching redirect): - { - source: '/tools/custom-tools', - destination: '/agents/custom-tools', - permanent: true, - }, - { source: '/tools', destination: '/integrations', permanent: true }, - { source: '/tools/:slug', destination: '/integrations/:slug', permanent: true }, - // Old blocks/triggers index pages were folded into the workflows overview. - // Native trigger pages (/triggers/start|schedule|webhook|rss|table) still exist. - { source: '/blocks', destination: '/workflows#blocks', permanent: true }, - { source: '/triggers', destination: '/workflows#triggers', permanent: true }, - // Integration trigger pages: provider slug differs from the block type for a few. - { - source: '/triggers/jsm', - destination: '/integrations/jira_service_management', - permanent: true, - }, - { - source: '/triggers/google-calendar', - destination: '/integrations/google_calendar', - permanent: true, - }, - { - source: '/triggers/google-drive', - destination: '/integrations/google_drive', - permanent: true, - }, - { - source: '/triggers/google-sheets', - destination: '/integrations/google_sheets', - permanent: true, - }, - { - source: '/triggers/microsoft-teams', - destination: '/integrations/microsoft_teams', - permanent: true, - }, - { - source: - '/triggers/:slug(airtable|ashby|attio|azure_devops|calcom|calendly|circleback|confluence|emailbison|fathom|fireflies|github|gmail|gong|google_forms|grain|greenhouse|hubspot|imap|intercom|jira|lemlist|linear|monday|notion|outlook|resend|salesforce|sendblue|servicenow|slack|stripe|telegram|twilio_voice|typeform|vercel|webflow|whatsapp|zoom)', - destination: '/integrations/:slug', - permanent: true, - }, - // URL structure now mirrors the sidebar: sections own their pages. - { source: '/blocks/:slug', destination: '/workflows/blocks/:slug', permanent: true }, - { - source: '/triggers/:slug(start|schedule|webhook|rss|table|sim)', - destination: '/workflows/triggers/:slug', - permanent: true, - }, - { source: '/deployment', destination: '/workflows/deployment', permanent: true }, - { - source: '/deployment/:path*', - destination: '/workflows/deployment/:path*', - permanent: true, - }, - { source: '/mailer', destination: '/chat/mailer', permanent: true }, - { source: '/credentials', destination: '/platform/credentials', permanent: true }, - { - source: '/credentials/:path*', - destination: '/platform/credentials', - permanent: true, - }, - { source: '/permissions', destination: '/platform/permissions', permanent: true }, - { - source: '/permissions/:path*', - destination: '/platform/permissions', - permanent: true, - }, - { source: '/workspaces/fundamentals', destination: '/platform/workspaces', permanent: true }, - { - source: '/workspaces/:slug(organization|permissions|credentials)', - destination: '/platform/:slug', - permanent: true, - }, - { source: '/costs', destination: '/platform/costs', permanent: true }, - { source: '/self-hosting', destination: '/platform/self-hosting', permanent: true }, - { - source: '/self-hosting/:path*', - destination: '/platform/self-hosting/:path*', - permanent: true, - }, - { source: '/enterprise', destination: '/platform/enterprise', permanent: true }, - { - source: '/enterprise/:path*', - destination: '/platform/enterprise/:path*', - permanent: true, - }, - ] + return DOCS_REDIRECTS }, async rewrites() { return [ diff --git a/scripts/openapi/docs-redirects.test.ts b/scripts/openapi/docs-redirects.test.ts new file mode 100644 index 00000000000..4180e8aec8a --- /dev/null +++ b/scripts/openapi/docs-redirects.test.ts @@ -0,0 +1,108 @@ +/** + * Pins the `/api-reference/` rules in `apps/docs/lib/redirects.ts` against the + * generated operation pages. + * + * Page identity is derived from the specs at build time, so a spec regeneration + * can silently invalidate the map in two directions: a redirect `source` can + * start shadowing a page that now exists (the redirect wins and hides it), and a + * `destination` can stop resolving (the redirect lands on a 404). Neither shows + * up in a build, a type-check, or any other check — nothing else in the repo + * reads docs URLs. + * + * Both directions use the same slug set, so a rule cannot shadow a hand-authored + * page (`/api-reference/getting-started`) any more than a generated one. + * + * The slug derivation mirrors `fumadocs-openapi`'s auto preset: pages are + * emitted at `//.mdx`, `(generated)` is a + * folder group stripped from the URL, and `hideLocale: 'default-locale'` drops + * the `en` prefix — so the public URL is `/api-reference//`. + */ +import { readdirSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { OPENAPI_SPEC_FILES } from '../../apps/docs/lib/openapi-specs' +import { DOCS_REDIRECTS } from '../../apps/docs/lib/redirects' + +const ROOT = path.resolve(import.meta.dirname, '../..') +const DOCS_DIR = path.join(ROOT, 'apps/docs') +const STATIC_PAGES_DIR = path.join(DOCS_DIR, 'content/docs/en/api-reference') +const PREFIX = '/api-reference/' + +/** + * `fumadocs-openapi`'s `methodKeys`, which is what decides whether an operation + * becomes a page. It is deliberately narrower than the OpenAPI method set — + * `options` and `trace` are not enumerated, so an operation declared under + * either produces no page and must not count as a resolvable destination here. + */ +const HTTP_METHODS = ['get', 'post', 'patch', 'delete', 'head', 'put'] as const + +interface OperationObject { + operationId?: string + tags?: string[] +} + +/** `fumadocs-openapi`'s default `slugify` for tag folder names. */ +function slugify(value: string): string { + return value.replace(/\s+/g, '-').toLowerCase() +} + +function generatedSlugs(): Set { + const slugs = new Set() + for (const file of OPENAPI_SPEC_FILES) { + const spec = JSON.parse(readFileSync(path.join(DOCS_DIR, file), 'utf8')) as { + paths?: Record> + } + for (const pathItem of Object.values(spec.paths ?? {})) { + for (const method of HTTP_METHODS) { + const operation = pathItem[method] + if (!operation?.operationId) continue + const tags = operation.tags?.length ? operation.tags : ['unknown'] + for (const tag of tags) { + slugs.add(`${slugify(tag)}/${operation.operationId}`) + } + } + } + } + return slugs +} + +/** Every slug a `/api-reference/` URL can resolve to: generated plus hand-authored. */ +function resolvableSlugs(): Set { + const slugs = generatedSlugs() + for (const entry of readdirSync(STATIC_PAGES_DIR)) { + if (entry.endsWith('.mdx')) slugs.add(path.basename(entry, '.mdx')) + } + return slugs +} + +const API_REFERENCE_RULES = DOCS_REDIRECTS.filter( + (rule) => rule.source.startsWith(PREFIX) || rule.destination.startsWith(PREFIX) +) + +describe('api-reference redirects', () => { + it('is a non-empty set of literal paths', () => { + expect(API_REFERENCE_RULES.length).toBeGreaterThan(0) + for (const rule of API_REFERENCE_RULES) { + expect( + `${rule.source} -> ${rule.destination}`, + 'path params would make the slug checks below vacuous' + ).not.toMatch(/[:*]/) + } + }) + + it('never shadows a page that exists', () => { + const resolvable = resolvableSlugs() + const shadowed = API_REFERENCE_RULES.map((rule) => rule.source) + .filter((source) => source.startsWith(PREFIX)) + .filter((source) => resolvable.has(source.slice(PREFIX.length))) + expect(shadowed).toEqual([]) + }) + + it('only points at pages that exist', () => { + const resolvable = resolvableSlugs() + const broken = API_REFERENCE_RULES.map((rule) => rule.destination) + .filter((destination) => destination.startsWith(PREFIX)) + .filter((destination) => !resolvable.has(destination.slice(PREFIX.length))) + expect(broken).toEqual([]) + }) +})