From 390f88b6b96ebd4d36bcc23d0dc31fe8af51da41 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 31 Jan 2026 10:35:55 +0000 Subject: [PATCH 01/24] Add App Router migration proof-of-concept for API reference pages This commit adds a proof-of-concept implementation for migrating the API reference pages from Pages Router to App Router with Server Components. Two approaches are demonstrated: 1. Transitional approach (/api-reference-v2): - Maintains compatibility with existing component architecture - Data is passed from server to client components - Good starting point for incremental migration 2. Optimized approach (/api-reference-v2/optimized): - Full server component rendering where possible - Only interactive elements use client components ('islands') - OpenAPI spec never leaves the server - Minimal JavaScript payload Key files added: - lib/openApiSpec.server.ts: Cached spec loading with React cache() - app/(api-reference)/layout.tsx: Shared layout with providers - app/(api-reference)/providers.tsx: Client-side providers - app/(api-reference)/api-reference-v2/: Transitional implementation - app/(api-reference)/api-reference-v2/optimized/: Full server component implementation Benefits of this approach: - OpenAPI spec (1MB+) stays on server, not serialized to client - React cache() deduplicates spec loading across renders - Streaming support for faster TTFB - Individual resource sections can be suspended independently Co-authored-by: chris --- .../api-reference-v2/api-reference-client.tsx | 109 +++++++ .../api-reference-context.tsx | 60 ++++ .../api-reference-method-client.tsx | 283 +++++++++++++++++ .../api-reference-section-client.tsx | 170 ++++++++++ .../optimized/expandable-response.tsx | 97 ++++++ .../api-reference-v2/optimized/page-shell.tsx | 143 +++++++++ .../api-reference-v2/optimized/page.tsx | 113 +++++++ .../optimized/pre-content.tsx | 17 + .../optimized/resource-section.tsx | 139 +++++++++ .../optimized/static-endpoint-list.tsx | 58 ++++ .../optimized/static-method-content.tsx | 292 ++++++++++++++++++ .../optimized/static-schema-content.tsx | 123 ++++++++ .../optimized/static-section.tsx | 50 +++ app/(api-reference)/api-reference-v2/page.tsx | 103 ++++++ app/(api-reference)/layout.tsx | 33 ++ app/(api-reference)/providers.tsx | 55 ++++ lib/openApiSpec.server.ts | 275 +++++++++++++++++ 17 files changed, 2120 insertions(+) create mode 100644 app/(api-reference)/api-reference-v2/api-reference-client.tsx create mode 100644 app/(api-reference)/api-reference-v2/api-reference-context.tsx create mode 100644 app/(api-reference)/api-reference-v2/api-reference-method-client.tsx create mode 100644 app/(api-reference)/api-reference-v2/api-reference-section-client.tsx create mode 100644 app/(api-reference)/api-reference-v2/optimized/expandable-response.tsx create mode 100644 app/(api-reference)/api-reference-v2/optimized/page-shell.tsx create mode 100644 app/(api-reference)/api-reference-v2/optimized/page.tsx create mode 100644 app/(api-reference)/api-reference-v2/optimized/pre-content.tsx create mode 100644 app/(api-reference)/api-reference-v2/optimized/resource-section.tsx create mode 100644 app/(api-reference)/api-reference-v2/optimized/static-endpoint-list.tsx create mode 100644 app/(api-reference)/api-reference-v2/optimized/static-method-content.tsx create mode 100644 app/(api-reference)/api-reference-v2/optimized/static-schema-content.tsx create mode 100644 app/(api-reference)/api-reference-v2/optimized/static-section.tsx create mode 100644 app/(api-reference)/api-reference-v2/page.tsx create mode 100644 app/(api-reference)/layout.tsx create mode 100644 app/(api-reference)/providers.tsx create mode 100644 lib/openApiSpec.server.ts diff --git a/app/(api-reference)/api-reference-v2/api-reference-client.tsx b/app/(api-reference)/api-reference-v2/api-reference-client.tsx new file mode 100644 index 000000000..31ca212b9 --- /dev/null +++ b/app/(api-reference)/api-reference-v2/api-reference-client.tsx @@ -0,0 +1,109 @@ +"use client"; + +/** + * Client component for the API reference page. + * + * This receives the pre-rendered data from the server component + * and handles client-side interactivity like navigation and state. + * + * IMPORTANT: In a fully optimized implementation, you would NOT pass + * the full openApiSpec here. Instead, you'd: + * 1. Render static content in the server component + * 2. Only pass minimal data needed for client interactivity + * + * This example shows a transitional approach that maintains compatibility + * with existing components while demonstrating the pattern. + */ + +import { MDXRemote, MDXRemoteSerializeResult } from "next-mdx-remote"; +import { usePathname } from "next/navigation"; +import { OpenAPIV3 } from "@scalar/openapi-types"; + +import { MDX_COMPONENTS } from "../../../lib/mdxComponents"; +import { StainlessConfig } from "../../../lib/openApiSpec"; +import { SidebarSection } from "../../../data/types"; +import { getSidebarContent } from "../../../components/ui/ApiReference/helpers"; +import Meta from "../../../components/Meta"; +import { Page as TelegraphPage } from "../../../components/ui/Page"; +import { ContentActions } from "../../../components/ui/ContentActions"; +import { ApiReferenceProvider } from "./api-reference-context"; +import { ApiReferenceSectionClient } from "./api-reference-section-client"; + +interface Props { + name: string; + openApiSpec: OpenAPIV3.Document; + stainlessSpec: StainlessConfig; + preContentMdx: MDXRemoteSerializeResult; + resourceOrder: string[]; + preSidebarContent?: SidebarSection[]; + baseUrl: string; + schemaReferences: Record; + basePath: string; +} + +export function ApiReferencePageClient({ + name, + openApiSpec, + stainlessSpec, + preContentMdx, + resourceOrder, + preSidebarContent, + baseUrl, + schemaReferences, + basePath, +}: Props) { + const pathname = usePathname(); + + const sidebarContent = getSidebarContent( + openApiSpec, + stainlessSpec, + resourceOrder, + basePath, + preSidebarContent + ); + + return ( + + + + } + /> + + + + + + } + /> + + + {resourceOrder.map((resourceName) => ( + + ))} + + + + + + ); +} diff --git a/app/(api-reference)/api-reference-v2/api-reference-context.tsx b/app/(api-reference)/api-reference-v2/api-reference-context.tsx new file mode 100644 index 000000000..4441fa196 --- /dev/null +++ b/app/(api-reference)/api-reference-v2/api-reference-context.tsx @@ -0,0 +1,60 @@ +"use client"; + +/** + * Client-side context for API reference components. + * + * This is similar to the existing ApiReferenceContext but designed + * for the App Router. The key difference is that the data is passed + * from the server component rather than loaded via getStaticProps. + */ + +import { createContext, useContext, ReactNode } from "react"; +import { OpenAPIV3 } from "@scalar/openapi-types"; +import { StainlessConfig } from "../../../lib/openApiSpec"; + +interface ApiReferenceContextType { + openApiSpec: OpenAPIV3.Document; + stainlessConfig: StainlessConfig; + baseUrl: string; + schemaReferences: Record; +} + +const ApiReferenceContext = createContext( + undefined +); + +interface ApiReferenceProviderProps { + children: ReactNode; + openApiSpec: OpenAPIV3.Document; + stainlessConfig: StainlessConfig; + baseUrl: string; + schemaReferences: Record; +} + +export function ApiReferenceProvider({ + children, + openApiSpec, + stainlessConfig, + baseUrl, + schemaReferences, +}: ApiReferenceProviderProps) { + return ( + + {children} + + ); +} + +export function useApiReference() { + const context = useContext(ApiReferenceContext); + if (context === undefined) { + throw new Error( + "useApiReference must be used within an ApiReferenceProvider" + ); + } + return context; +} + +export default ApiReferenceContext; diff --git a/app/(api-reference)/api-reference-v2/api-reference-method-client.tsx b/app/(api-reference)/api-reference-v2/api-reference-method-client.tsx new file mode 100644 index 000000000..7daf7deb7 --- /dev/null +++ b/app/(api-reference)/api-reference-v2/api-reference-method-client.tsx @@ -0,0 +1,283 @@ +"use client"; + +/** + * Client component for rendering API method details. + * + * This handles the interactive elements like expandable response properties. + */ + +import type { OpenAPIV3 } from "@scalar/openapi-types"; +import { useState } from "react"; +import Markdown from "react-markdown"; +import { AnimatePresence, motion } from "framer-motion"; + +import { Box } from "@telegraph/layout"; +import { Code, Heading } from "@telegraph/typography"; + +import { Callout } from "../../../components/ui/Callout"; +import RateLimit from "../../../components/ui/RateLimit"; +import { ContentColumn, ExampleColumn, Section } from "../../../components/ui/ApiSections"; +import { CodeBlock } from "../../../components/ui/CodeBlock"; +import { Endpoint } from "../../../components/ui/Endpoints"; +import MultiLangExample from "../../../components/ui/ApiReference/MultiLangExample"; +import OperationParameters from "../../../components/ui/ApiReference/OperationParameters/OperationParameters"; +import { SchemaProperties } from "../../../components/ui/ApiReference/SchemaProperties"; +import { PropertyRow } from "../../../components/ui/ApiReference/SchemaProperties/PropertyRow"; +import { + augmentSnippetsWithCurlRequest, + formatResponseStatusCodes, + resolveResponseSchemas, +} from "../../../components/ui/ApiReference/helpers"; +import { useApiReference } from "./api-reference-context"; + +interface Props { + methodName: string; + methodType: "get" | "post" | "put" | "delete"; + endpoint: string; + path?: string; + mdPath?: string; +} + +export function ApiReferenceMethodClient({ + methodName, + methodType, + endpoint, + path, + mdPath, +}: Props) { + const { openApiSpec, baseUrl, schemaReferences } = useApiReference(); + const [isResponseExpanded, setIsResponseExpanded] = useState(false); + const method = openApiSpec.paths?.[endpoint]?.[methodType]; + + if (!method) { + return null; + } + + const parameters = method.parameters || []; + + const pathParameters = parameters.filter( + (p) => p.in === "path" + ) as OpenAPIV3.ParameterObject[]; + const queryParameters = parameters.filter( + (p) => p.in === "query" + ) as OpenAPIV3.ParameterObject[]; + + const responseSchemas: OpenAPIV3.SchemaObject[] = resolveResponseSchemas(method); + + const requestBody: OpenAPIV3.SchemaObject | undefined = + method.requestBody?.content?.["application/json"]?.schema; + + const rateLimit = method?.["x-ratelimit-tier"] ?? null; + const isIdempotent = method?.["x-idempotent"] ?? false; + const isRetentionSubject = method?.["x-retention-policy"] ?? false; + const isBeta = method?.["x-beta"] ?? false; + + return ( +
+ + {method.description ?? ""} + {isBeta && ( + + This endpoint is currently in beta. If you'd like early access, + or this is blocking your adoption of Knock, please{" "} + + get in touch + + . + + } + /> + )} + + + Endpoint + + + + + {rateLimit && ( + + + Rate limit + + + + )} + + {pathParameters.length > 0 && ( + <> + + Path parameters + + + + )} + + {queryParameters.length > 0 && ( + <> + + Query parameters + + + + )} + + {requestBody && ( + <> + + Request body + + + + )} + + + Returns + + + {responseSchemas.length > 0 && + responseSchemas.map((responseSchema) => ( + + + + + {responseSchema.title} + + + + {responseSchema.description ?? ""} + + + {responseSchema.properties && ( + <> + setIsResponseExpanded(!isResponseExpanded)} + > + {isResponseExpanded ? "Hide properties" : "Show properties"} + + + + + + + + + + + )} + + + ))} + + {responseSchemas.length === 0 && ( + + {formatResponseStatusCodes(method).map((formattedStatus, index) => ( + + {formattedStatus} + + ))} + + )} + + + + {responseSchemas.map( + (responseSchema) => + responseSchema?.example && ( + + {JSON.stringify(responseSchema?.example, null, 2)} + + ) + )} + +
+ ); +} diff --git a/app/(api-reference)/api-reference-v2/api-reference-section-client.tsx b/app/(api-reference)/api-reference-v2/api-reference-section-client.tsx new file mode 100644 index 000000000..663354792 --- /dev/null +++ b/app/(api-reference)/api-reference-v2/api-reference-section-client.tsx @@ -0,0 +1,170 @@ +"use client"; + +/** + * Client component for rendering API reference sections. + * + * This is a client-side version of the ApiReferenceSection component + * that works with the App Router context setup. + */ + +import React from "react"; +import type { OpenAPIV3 } from "@scalar/openapi-types"; +import Markdown from "react-markdown"; +import JSONPointer from "jsonpointer"; + +import { Box } from "@telegraph/layout"; +import { Heading } from "@telegraph/typography"; + +import { StainlessResource } from "../../../lib/openApiSpec"; +import { ContentColumn, ExampleColumn, Section } from "../../../components/ui/ApiSections"; +import { Endpoint, Endpoints } from "../../../components/ui/Endpoints"; +import { CodeBlock } from "../../../components/ui/CodeBlock"; +import { SchemaProperties } from "../../../components/ui/ApiReference/SchemaProperties"; +import { resolveEndpointFromMethod } from "../../../components/ui/ApiReference/helpers"; +import { useApiReference } from "./api-reference-context"; +import { ApiReferenceMethodClient } from "./api-reference-method-client"; + +interface Props { + resourceName: string; + resource: StainlessResource; + path?: string; + basePath: string; +} + +export function ApiReferenceSectionClient({ + resourceName, + resource, + path, + basePath, +}: Props) { + const { openApiSpec } = useApiReference(); + const methods = resource.methods || {}; + const models = resource.models || {}; + const currentPath = path ?? `/${resourceName}`; + + // Generate markdown path for the resource overview + const resourceMdPath = `/${basePath}${currentPath}/index.md`; + + return ( + <> + +
+ + {resource.description && ( + {resource.description} + )} + + + {Object.entries(methods).length > 0 && ( + + {Object.entries(methods).map( + ([methodName, endpointOrMethodConfig]) => { + const [methodType, endpoint] = resolveEndpointFromMethod( + endpointOrMethodConfig + ); + + return ( + + ); + } + )} + + )} + +
+
+ + {Object.entries(methods).map(([methodName, endpointOrMethodConfig]) => { + const [methodType, endpoint] = resolveEndpointFromMethod( + endpointOrMethodConfig + ); + + const methodPath = `${currentPath}/${methodName}`; + const methodMdPath = `/${basePath}${currentPath}/${methodName}.md`; + + return ( + + + + ); + })} + + {Object.entries(resource.subresources ?? {}).map( + ([subresourceName, subresource]) => { + return ( + + ); + } + )} + + {Object.entries(models).map(([modelName, modelReference]) => { + const schema: OpenAPIV3.SchemaObject | undefined = JSONPointer.get( + openApiSpec, + modelReference.replace("#", "") + ); + + if (!schema) { + return null; + } + + const schemaPath = `${currentPath}/schemas/${modelName}`; + const schemaMdPath = `/${basePath}${currentPath}/schemas/${modelName}.md`; + + return ( + +
+ + {schema.description && ( + {schema.description} + )} + + + Attributes + + + + + + {JSON.stringify(schema.example, null, 2)} + + +
+
+ ); + })} + + ); +} diff --git a/app/(api-reference)/api-reference-v2/optimized/expandable-response.tsx b/app/(api-reference)/api-reference-v2/optimized/expandable-response.tsx new file mode 100644 index 000000000..b23757ab6 --- /dev/null +++ b/app/(api-reference)/api-reference-v2/optimized/expandable-response.tsx @@ -0,0 +1,97 @@ +"use client"; + +/** + * Client component for expandable response properties. + * + * This is a "client island" within the server-rendered content. + * It receives only the data it needs for its specific functionality. + */ + +import { useState } from "react"; +import type { OpenAPIV3 } from "@scalar/openapi-types"; +import { AnimatePresence, motion } from "framer-motion"; +import { ChevronDown, ChevronRight } from "lucide-react"; + +interface Props { + schema: OpenAPIV3.SchemaObject; + schemaReferences: Record; +} + +export function ExpandableResponseProperties({ + schema, + schemaReferences, +}: Props) { + const [isExpanded, setIsExpanded] = useState(false); + + const properties = schema.properties || {}; + const required = schema.required || []; + + if (Object.keys(properties).length === 0) { + return null; + } + + return ( +
+ + + + {isExpanded && ( + +
+
+ {Object.entries(properties).map(([name, prop]) => { + const propSchema = prop as OpenAPIV3.SchemaObject; + const isRequired = required.includes(name); + + let typeDisplay = propSchema.type || "any"; + if (propSchema.type === "array" && propSchema.items) { + const itemType = + (propSchema.items as OpenAPIV3.SchemaObject).type || "any"; + typeDisplay = `${itemType}[]`; + } + + return ( +
+
+ + {name} + + + {typeDisplay} + + {isRequired && ( + required + )} +
+ {propSchema.description && ( +

+ {propSchema.description} +

+ )} +
+ ); + })} +
+
+
+ )} +
+
+ ); +} diff --git a/app/(api-reference)/api-reference-v2/optimized/page-shell.tsx b/app/(api-reference)/api-reference-v2/optimized/page-shell.tsx new file mode 100644 index 000000000..c2c3c11d0 --- /dev/null +++ b/app/(api-reference)/api-reference-v2/optimized/page-shell.tsx @@ -0,0 +1,143 @@ +"use client"; + +/** + * Page shell component that provides the layout structure. + * + * This is a client component because it uses hooks for sidebar state, + * but it receives minimal data - just what's needed for the sidebar. + */ + +import { ReactNode } from "react"; +import { StainlessConfig } from "../../../../lib/openApiSpec"; +import { SidebarSection } from "../../../../data/types"; +import { Page as TelegraphPage } from "../../../../components/ui/Page"; +import { ContentActions } from "../../../../components/ui/ContentActions"; +import Meta from "../../../../components/Meta"; + +interface Props { + children: ReactNode; + name: string; + basePath: string; + stainlessSpec: StainlessConfig; + preSidebarContent?: SidebarSection[]; + resourceOrder: string[]; +} + +/** + * Build sidebar content from stainless spec. + * This is a simplified version that doesn't need the full OpenAPI spec. + */ +function buildSimpleSidebarContent( + stainlessSpec: StainlessConfig, + resourceOrder: string[], + basePath: string, + preSidebarContent?: SidebarSection[] +): SidebarSection[] { + const resourceSections = resourceOrder.map((resourceName) => { + const resource = stainlessSpec.resources[resourceName]; + + const pages: Array<{ title: string; slug: string; pages?: Array<{ title: string; slug: string }> }> = [ + { title: "Overview", slug: "/" }, + ]; + + // Add methods + if (resource.methods) { + Object.entries(resource.methods).forEach(([methodName]) => { + pages.push({ + title: methodName, + slug: `/${methodName}`, + }); + }); + } + + // Add subresources + if (resource.subresources) { + Object.entries(resource.subresources).forEach( + ([subresourceName, subresource]) => { + const subPages: Array<{ title: string; slug: string }> = [ + { title: "Overview", slug: "/" }, + ]; + + if (subresource.methods) { + Object.keys(subresource.methods).forEach((methodName) => { + subPages.push({ + title: methodName, + slug: `/${methodName}`, + }); + }); + } + + pages.push({ + title: subresource.name || subresourceName, + slug: `/${subresourceName}`, + pages: subPages, + }); + } + ); + } + + // Add models/schemas + if (resource.models) { + pages.push({ + title: "Object definitions", + slug: "/schemas", + pages: Object.keys(resource.models).map((modelName) => ({ + title: modelName, + slug: `/${modelName}`, + })), + }); + } + + return { + title: resource.name || resourceName, + slug: `/${basePath}/${resourceName}`, + pages, + }; + }); + + return (preSidebarContent || []).concat(resourceSections); +} + +export function PageShell({ + children, + name, + basePath, + stainlessSpec, + preSidebarContent, + resourceOrder, +}: Props) { + const sidebarContent = buildSimpleSidebarContent( + stainlessSpec, + resourceOrder, + basePath, + preSidebarContent + ); + + return ( + + + + } + /> + + + + + } + /> + {children} + + + + ); +} diff --git a/app/(api-reference)/api-reference-v2/optimized/page.tsx b/app/(api-reference)/api-reference-v2/optimized/page.tsx new file mode 100644 index 000000000..8eff29261 --- /dev/null +++ b/app/(api-reference)/api-reference-v2/optimized/page.tsx @@ -0,0 +1,113 @@ +/** + * OPTIMIZED API Reference Page (Full Server Component Approach) + * + * This demonstrates the ideal end-state where the OpenAPI spec + * stays entirely on the server. The spec is processed during + * rendering and only the final HTML is sent to the client. + * + * Benefits: + * - Zero JS payload for static content + * - Spec never serialized to client + * - Can use streaming for faster TTFB + * - Memory-efficient for large specs + * + * Trade-offs: + * - Interactive features need careful client/server splitting + * - More granular component architecture required + */ + +import { Suspense } from "react"; +import fs from "fs/promises"; +import { serialize } from "next-mdx-remote/serialize"; +import remarkGfm from "remark-gfm"; +import rehypeMdxCodeProps from "rehype-mdx-code-props"; + +import { + getOpenApiSpec, + getStainlessSpec, + buildSchemaReferences, + resolveEndpointFromMethod, +} from "../../../../lib/openApiSpec.server"; +import { CONTENT_DIR } from "../../../../lib/content.server"; +import { + RESOURCE_ORDER, + API_REFERENCE_OVERVIEW_CONTENT, +} from "../../../../data/sidebars/apiOverviewSidebar"; + +import { PageShell } from "./page-shell"; +import { ResourceSection } from "./resource-section"; +import { PreContent } from "./pre-content"; + +export const dynamic = "force-static"; +export const revalidate = 3600; + +export const metadata = { + title: "API reference (Optimized) | Knock Docs", + description: "Complete reference documentation for the Knock API.", +}; + +export default async function OptimizedApiReferencePage() { + // Load specs on server - these never leave the server + const [openApiSpec, stainlessSpec] = await Promise.all([ + getOpenApiSpec("api"), + getStainlessSpec("api"), + ]); + + const baseUrl = stainlessSpec.environments.production; + const schemaReferences = await buildSchemaReferences( + "api", + "/api-reference-v2/optimized" + ); + + // Load pre-content MDX + const preContent = await fs.readFile( + `${CONTENT_DIR}/__api-reference/content.mdx`, + "utf-8" + ); + const preContentMdx = await serialize(preContent, { + parseFrontmatter: true, + mdxOptions: { + remarkPlugins: [remarkGfm], + rehypePlugins: [rehypeMdxCodeProps], + }, + }); + + return ( + + {/* Pre-content is client component for MDX interactivity */} + + + {/* Each resource section streams independently */} + {RESOURCE_ORDER.map((resourceName) => ( + } + > + + + ))} + + ); +} + +function ResourceSectionSkeleton({ name }: { name: string }) { + return ( +
+
+
+
+ ); +} diff --git a/app/(api-reference)/api-reference-v2/optimized/pre-content.tsx b/app/(api-reference)/api-reference-v2/optimized/pre-content.tsx new file mode 100644 index 000000000..2e4dbc98e --- /dev/null +++ b/app/(api-reference)/api-reference-v2/optimized/pre-content.tsx @@ -0,0 +1,17 @@ +"use client"; + +/** + * Client component for rendering MDX pre-content. + * MDX requires client-side rendering for interactive components. + */ + +import { MDXRemote, MDXRemoteSerializeResult } from "next-mdx-remote"; +import { MDX_COMPONENTS } from "../../../../lib/mdxComponents"; + +interface Props { + mdx: MDXRemoteSerializeResult; +} + +export function PreContent({ mdx }: Props) { + return ; +} diff --git a/app/(api-reference)/api-reference-v2/optimized/resource-section.tsx b/app/(api-reference)/api-reference-v2/optimized/resource-section.tsx new file mode 100644 index 000000000..0883dfd3a --- /dev/null +++ b/app/(api-reference)/api-reference-v2/optimized/resource-section.tsx @@ -0,0 +1,139 @@ +/** + * Server Component for rendering a resource section. + * + * This is a SERVER component - it runs only on the server. + * The OpenAPI spec is processed here and converted to HTML. + * Only the HTML is sent to the client. + * + * For interactive elements (like expandable properties), we use + * client component islands within the server-rendered content. + */ + +import type { OpenAPIV3 } from "@scalar/openapi-types"; +import JSONPointer from "jsonpointer"; + +import { Box } from "@telegraph/layout"; +import { Heading } from "@telegraph/typography"; + +import { StainlessResource } from "../../../../lib/openApiSpec"; +import { resolveEndpointFromMethod } from "../../../../lib/openApiSpec.server"; + +// Static display components (server-safe) +import { StaticSection } from "./static-section"; +import { StaticEndpointList } from "./static-endpoint-list"; +import { StaticMethodContent } from "./static-method-content"; +import { StaticSchemaContent } from "./static-schema-content"; + +interface Props { + resourceName: string; + resource: StainlessResource; + openApiSpec: OpenAPIV3.Document; + baseUrl: string; + schemaReferences: Record; + basePath: string; + parentPath?: string; +} + +export function ResourceSection({ + resourceName, + resource, + openApiSpec, + baseUrl, + schemaReferences, + basePath, + parentPath = "", +}: Props) { + const methods = resource.methods || {}; + const models = resource.models || {}; + const currentPath = parentPath ? `${parentPath}/${resourceName}` : `/${resourceName}`; + + // Build endpoint list for the overview + const endpoints = Object.entries(methods).map( + ([methodName, endpointOrMethodConfig]) => { + const [methodType, endpoint] = resolveEndpointFromMethod( + endpointOrMethodConfig + ); + return { methodName, methodType, endpoint }; + } + ); + + return ( + <> + {/* Resource Overview Section */} + + {endpoints.length > 0 && } + + + {/* Method Sections */} + {Object.entries(methods).map(([methodName, endpointOrMethodConfig]) => { + const [methodType, endpoint] = resolveEndpointFromMethod( + endpointOrMethodConfig + ); + const operation = openApiSpec.paths?.[endpoint]?.[methodType]; + + if (!operation) { + return null; + } + + const methodPath = `${currentPath}/${methodName}`; + + return ( + + ); + })} + + {/* Subresources (recursive) */} + {Object.entries(resource.subresources ?? {}).map( + ([subresourceName, subresource]) => ( + + ) + )} + + {/* Schema/Model Sections */} + {Object.entries(models).map(([modelName, modelReference]) => { + const schema: OpenAPIV3.SchemaObject | undefined = JSONPointer.get( + openApiSpec, + modelReference.replace("#", "") + ); + + if (!schema) { + return null; + } + + const schemaPath = `${currentPath}/schemas/${modelName}`; + + return ( + + ); + })} + + ); +} diff --git a/app/(api-reference)/api-reference-v2/optimized/static-endpoint-list.tsx b/app/(api-reference)/api-reference-v2/optimized/static-endpoint-list.tsx new file mode 100644 index 000000000..45823f181 --- /dev/null +++ b/app/(api-reference)/api-reference-v2/optimized/static-endpoint-list.tsx @@ -0,0 +1,58 @@ +/** + * Static endpoint list - Server Component + * + * Renders a list of API endpoints without client-side interactivity. + */ + +import { Box, Stack } from "@telegraph/layout"; +import { Text, Code } from "@telegraph/typography"; + +interface Endpoint { + methodName: string; + methodType: string; + endpoint: string; +} + +interface Props { + endpoints: Endpoint[]; +} + +const METHOD_COLORS: Record = { + get: "text-green-600 bg-green-50", + post: "text-blue-600 bg-blue-50", + put: "text-orange-600 bg-orange-50", + delete: "text-red-600 bg-red-50", + patch: "text-purple-600 bg-purple-50", +}; + +export function StaticEndpointList({ endpoints }: Props) { + return ( + + {endpoints.map(({ methodName, methodType, endpoint }, index) => ( +
0 ? "border-t border-gray-200" : "" + }`} + > + + {methodType.toUpperCase()} + + + {endpoint} + +
+ ))} +
+ ); +} diff --git a/app/(api-reference)/api-reference-v2/optimized/static-method-content.tsx b/app/(api-reference)/api-reference-v2/optimized/static-method-content.tsx new file mode 100644 index 000000000..eaf997d63 --- /dev/null +++ b/app/(api-reference)/api-reference-v2/optimized/static-method-content.tsx @@ -0,0 +1,292 @@ +/** + * Static method content - Server Component with Client Islands + * + * This renders method documentation. Most content is static HTML + * rendered on the server. Interactive parts use client component islands. + */ + +import type { OpenAPIV3 } from "@scalar/openapi-types"; +import { Box } from "@telegraph/layout"; +import { Heading, Text, Code } from "@telegraph/typography"; + +// Client component for interactive response expansion +import { ExpandableResponseProperties } from "./expandable-response"; + +interface Props { + methodName: string; + methodType: "get" | "post" | "put" | "delete"; + endpoint: string; + operation: OpenAPIV3.OperationObject; + path: string; + baseUrl: string; + schemaReferences: Record; +} + +const METHOD_COLORS: Record = { + get: "text-green-600", + post: "text-blue-600", + put: "text-orange-600", + delete: "text-red-600", +}; + +export function StaticMethodContent({ + methodName, + methodType, + endpoint, + operation, + path, + baseUrl, + schemaReferences, +}: Props) { + const parameters = (operation.parameters || []) as OpenAPIV3.ParameterObject[]; + const pathParameters = parameters.filter((p) => p.in === "path"); + const queryParameters = parameters.filter((p) => p.in === "query"); + + const requestBody: OpenAPIV3.SchemaObject | undefined = + operation.requestBody?.content?.["application/json"]?.schema; + + const responseSchemas = Object.values(operation.responses || {}) + .map((r) => r.content?.["application/json"]?.schema) + .filter((r): r is OpenAPIV3.SchemaObject => !!r) + .map((schema) => (schema.allOf ? schema.allOf[0] : schema)); + + const rateLimit = operation["x-ratelimit-tier"]; + const isBeta = operation["x-beta"]; + + return ( + +
+ {/* Content column */} +
+ {/* Title */} + + {operation.summary} + + + {/* Description */} + {operation.description && ( + + {operation.description} + + )} + + {/* Beta badge */} + {isBeta && ( +
+ + 🚧 This endpoint is currently in beta. + +
+ )} + + {/* Endpoint */} + + Endpoint + +
+ + {methodType.toUpperCase()} + + {endpoint} +
+ + {/* Rate limit */} + {rateLimit && ( +
+ + Rate limit + + + Tier: {rateLimit} + +
+ )} + + {/* Path parameters */} + {pathParameters.length > 0 && ( +
+ + Path parameters + + +
+ )} + + {/* Query parameters */} + {queryParameters.length > 0 && ( +
+ + Query parameters + + +
+ )} + + {/* Request body */} + {requestBody && ( +
+ + Request body + + +
+ )} + + {/* Response */} +
+ + Returns + + {responseSchemas.length > 0 ? ( + responseSchemas.map((schema, i) => ( +
+
+ {schema.title && ( + + {schema.title} + + )} +
+ {schema.description && ( + + {schema.description} + + )} + {/* Client component for expandable properties */} + {schema.properties && ( + + )} +
+ )) + ) : ( + + No content + + )} +
+
+ + {/* Example column */} +
+ {/* Request example */} +
+
+ {operation.summary} (example) +
+
+              
+                {`curl -X ${methodType.toUpperCase()} ${baseUrl}${endpoint} \\
+  -H "Authorization: Bearer sk_test_12345" \\
+  -H "Content-Type: application/json"`}
+                {requestBody?.example &&
+                  ` \\
+  -d '${JSON.stringify(requestBody.example, null, 2)}'`}
+              
+            
+
+ + {/* Response example */} + {responseSchemas[0]?.example && ( +
+
+ Response +
+
+                {JSON.stringify(responseSchemas[0].example, null, 2)}
+              
+
+ )} +
+
+
+ ); +} + +/** + * Static parameter list - renders parameter documentation + */ +function ParameterList({ + parameters, +}: { + parameters: OpenAPIV3.ParameterObject[]; +}) { + return ( +
+ {parameters.map((param) => ( +
+
+ {param.name} + {param.schema?.type && ( + {param.schema.type} + )} + {param.required && ( + required + )} +
+ {param.description && ( + + {param.description} + + )} +
+ ))} +
+ ); +} + +/** + * Static schema property list - renders schema properties + */ +function SchemaPropertyList({ + schema, + schemaReferences, +}: { + schema: OpenAPIV3.SchemaObject; + schemaReferences: Record; +}) { + const properties = schema.properties || {}; + const required = schema.required || []; + + return ( +
+ {Object.entries(properties).map(([name, prop]) => { + const propSchema = prop as OpenAPIV3.SchemaObject; + const isRequired = required.includes(name); + + return ( +
+
+ {name} + {propSchema.type && ( + {propSchema.type} + )} + {isRequired && ( + required + )} +
+ {propSchema.description && ( + + {propSchema.description} + + )} +
+ ); + })} +
+ ); +} diff --git a/app/(api-reference)/api-reference-v2/optimized/static-schema-content.tsx b/app/(api-reference)/api-reference-v2/optimized/static-schema-content.tsx new file mode 100644 index 000000000..b9ef8b80a --- /dev/null +++ b/app/(api-reference)/api-reference-v2/optimized/static-schema-content.tsx @@ -0,0 +1,123 @@ +/** + * Static schema content - Server Component + * + * Renders schema/model documentation entirely on the server. + */ + +import type { OpenAPIV3 } from "@scalar/openapi-types"; +import { Box } from "@telegraph/layout"; +import { Heading, Text } from "@telegraph/typography"; + +interface Props { + modelName: string; + schema: OpenAPIV3.SchemaObject; + path: string; + schemaReferences: Record; +} + +export function StaticSchemaContent({ + modelName, + schema, + path, + schemaReferences, +}: Props) { + const properties = schema.properties || {}; + const required = schema.required || []; + + return ( + +
+ {/* Content column */} +
+ + {schema.title || modelName} + + + {schema.description && ( + + {schema.description} + + )} + + + Attributes + + +
+ {Object.entries(properties).map(([name, prop]) => { + const propSchema = prop as OpenAPIV3.SchemaObject; + const isRequired = required.includes(name); + + // Get type display string + let typeDisplay = propSchema.type || "any"; + if (propSchema.type === "array" && propSchema.items) { + const itemType = (propSchema.items as OpenAPIV3.SchemaObject).type || "any"; + typeDisplay = `${itemType}[]`; + } + if (propSchema.enum) { + typeDisplay = "enum"; + } + + return ( +
+
+ {name} + {typeDisplay} + {isRequired && ( + + required + + )} + {propSchema.nullable && ( + nullable + )} +
+ {propSchema.description && ( + + {propSchema.description} + + )} + {propSchema.enum && ( +
+ + Values:{" "} + {propSchema.enum.map((v) => ( + + {String(v)} + + ))} + +
+ )} +
+ ); + })} +
+
+ + {/* Example column */} +
+ {schema.example && ( +
+
+ {schema.title || modelName} +
+
+                {JSON.stringify(schema.example, null, 2)}
+              
+
+ )} +
+
+
+ ); +} diff --git a/app/(api-reference)/api-reference-v2/optimized/static-section.tsx b/app/(api-reference)/api-reference-v2/optimized/static-section.tsx new file mode 100644 index 000000000..7c5bb844c --- /dev/null +++ b/app/(api-reference)/api-reference-v2/optimized/static-section.tsx @@ -0,0 +1,50 @@ +/** + * Static section wrapper - Server Component + * + * This renders the section structure without client-side state. + * It's designed to be used in server components. + */ + +import { ReactNode } from "react"; +import { Box, Stack } from "@telegraph/layout"; +import { Heading, Text } from "@telegraph/typography"; + +interface Props { + title?: string; + path: string; + description?: string; + children?: ReactNode; +} + +export function StaticSection({ title, path, description, children }: Props) { + return ( + +
+ {/* Content column */} +
+ {title && ( + + {title} + + )} + {description && ( + + {description} + + )} +
+ + {/* Example column */} + {children && ( +
{children}
+ )} +
+
+ ); +} diff --git a/app/(api-reference)/api-reference-v2/page.tsx b/app/(api-reference)/api-reference-v2/page.tsx new file mode 100644 index 000000000..f45a73db6 --- /dev/null +++ b/app/(api-reference)/api-reference-v2/page.tsx @@ -0,0 +1,103 @@ +/** + * API Reference Overview Page (Server Component) + * + * This page loads the OpenAPI spec on the server and renders the full + * API reference. The spec data never leaves the server - only the + * rendered HTML is sent to the client. + * + * Key differences from Pages Router: + * - No getStaticProps - data loading happens in the component + * - Spec is cached via React cache() - deduplicated across renders + * - Client components are imported for interactive features + */ + +import { Suspense } from "react"; +import fs from "fs/promises"; +import { serialize } from "next-mdx-remote/serialize"; +import remarkGfm from "remark-gfm"; +import rehypeMdxCodeProps from "rehype-mdx-code-props"; + +import { + getOpenApiSpec, + getStainlessSpec, + buildSchemaReferences, +} from "../../../lib/openApiSpec.server"; +import { CONTENT_DIR } from "../../../lib/content.server"; +import { + RESOURCE_ORDER, + API_REFERENCE_OVERVIEW_CONTENT, +} from "../../../data/sidebars/apiOverviewSidebar"; + +import { ApiReferencePageClient } from "./api-reference-client"; + +// Generate static page at build time +export const dynamic = "force-static"; + +// Revalidate every hour (optional - for ISR) +export const revalidate = 3600; + +export const metadata = { + title: "API reference | Knock Docs", + description: "Complete reference documentation for the Knock API.", +}; + +async function getPreContentMdx() { + const preContent = await fs.readFile( + `${CONTENT_DIR}/__api-reference/content.mdx`, + "utf-8" + ); + + return serialize(preContent, { + parseFrontmatter: true, + mdxOptions: { + remarkPlugins: [remarkGfm], + rehypePlugins: [rehypeMdxCodeProps], + }, + }); +} + +export default async function ApiReferencePage() { + // These calls are cached - subsequent calls return the same promise + const [openApiSpec, stainlessSpec, preContentMdx, schemaReferences] = + await Promise.all([ + getOpenApiSpec("api"), + getStainlessSpec("api"), + getPreContentMdx(), + buildSchemaReferences("api", "/api-reference-v2"), + ]); + + const baseUrl = stainlessSpec.environments.production; + + return ( + }> + + + ); +} + +function ApiReferenceLoadingSkeleton() { + return ( +
+
+
+
+
+
+ {[...Array(5)].map((_, i) => ( +
+ ))} +
+
+
+ ); +} diff --git a/app/(api-reference)/layout.tsx b/app/(api-reference)/layout.tsx new file mode 100644 index 000000000..54fee6400 --- /dev/null +++ b/app/(api-reference)/layout.tsx @@ -0,0 +1,33 @@ +/** + * Shared layout for API reference pages. + * + * This layout wraps both /api-reference and /mapi-reference pages. + * It provides the common page structure while allowing each route + * to load its own spec data. + */ + +import { Inter } from "next/font/google"; + +// Import global styles - these are shared with the Pages Router +import "@algolia/autocomplete-theme-classic"; +import "../../styles/index.css"; +import "../../styles/global.css"; +import "../../styles/responsive.css"; + +import { Providers } from "./providers"; + +const inter = Inter({ subsets: ["latin"], display: "swap" }); + +export default function ApiReferenceLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/app/(api-reference)/providers.tsx b/app/(api-reference)/providers.tsx new file mode 100644 index 000000000..848411aa1 --- /dev/null +++ b/app/(api-reference)/providers.tsx @@ -0,0 +1,55 @@ +"use client"; + +/** + * Client-side providers for App Router API reference pages. + * + * These wrap all API reference pages with necessary context providers + * that require client-side state (event emitter, AI chat, etc.) + */ + +import { + EventEmitterContext, + useEventEmitterInstance, +} from "@byteclaw/use-event-emitter"; +import { usePathname } from "next/navigation"; +import { useEffect } from "react"; + +import { InkeepModalProvider } from "../../components/AiChatButton"; +import { AskAiProvider } from "../../components/AskAiContext"; +import AskAiSidebar from "../../components/AskAiSidebar"; +import * as analytics from "../../lib/analytics"; +import { initAttribution } from "../../lib/attribution"; +import { setClearbitPath } from "../../lib/clearbit"; +import * as gtag from "../../lib/gtag"; + +export function Providers({ children }: { children: React.ReactNode }) { + const eventEmitter = useEventEmitterInstance(); + const pathname = usePathname(); + + // Initialize attribution tracking on mount + useEffect(() => { + initAttribution(); + }, []); + + // Track page views on route change + useEffect(() => { + gtag.pageview(pathname as unknown as URL); + setClearbitPath(pathname as unknown as URL); + analytics.page(); + initAttribution(); + }, [pathname]); + + return ( + + +
+ + {children} + + {analytics.SEGMENT_WRITE_KEY && } +
+ +
+
+ ); +} diff --git a/lib/openApiSpec.server.ts b/lib/openApiSpec.server.ts new file mode 100644 index 000000000..ca470afff --- /dev/null +++ b/lib/openApiSpec.server.ts @@ -0,0 +1,275 @@ +/** + * Server-side OpenAPI spec loading with React cache() for App Router. + * + * This module provides cached access to the OpenAPI and Stainless specs. + * The specs are loaded once per request/build and cached in memory. + * + * Key benefits over the Pages Router approach: + * 1. Specs never leave the server - not serialized to page props + * 2. React cache() dedupes requests within the same render + * 3. Each page can extract only what it needs + */ + +import { cache } from "react"; +import { dereference } from "@scalar/openapi-parser"; +import deepmerge from "deepmerge"; +import { readFile } from "fs/promises"; +import safeStringify from "safe-stringify"; +import { parse } from "yaml"; +import type { OpenAPIV3 } from "@scalar/openapi-types"; +import JSONPointer from "jsonpointer"; + +// Re-export types from the original module +export type { + StainlessResource, + StainlessConfig, +} from "./openApiSpec"; + +import type { StainlessConfig, StainlessResource } from "./openApiSpec"; + +type SpecName = "api" | "mapi"; + +/** + * Cached loader for the full dereferenced OpenAPI spec. + * Uses React's cache() to dedupe within a single render pass. + */ +export const getOpenApiSpec = cache( + async (specName: SpecName): Promise => { + const spec = await readFile( + `./data/specs/${specName}/openapi.yml`, + "utf8" + ); + const jsonSpec = parse(spec); + const { schema } = await dereference(jsonSpec); + + // Use safe-stringify to handle circular references + return JSON.parse(safeStringify(schema)); + } +); + +/** + * Cached loader for the Stainless config with customizations merged. + */ +export const getStainlessSpec = cache( + async (specName: SpecName): Promise => { + const [specFile, customizationsFile] = await Promise.all([ + readFile(`./data/specs/${specName}/stainless.yml`, "utf8"), + readFile(`./data/specs/${specName}/customizations.yml`, "utf8"), + ]); + + const stainlessSpec = parse(specFile); + const customizations = parse(customizationsFile); + + return deepmerge(stainlessSpec, customizations); + } +); + +/** + * Get the base URL for API requests from the Stainless config. + */ +export async function getBaseUrl(specName: SpecName): Promise { + const stainlessSpec = await getStainlessSpec(specName); + return stainlessSpec.environments.production; +} + +/** + * Get a specific resource from the Stainless config. + */ +export async function getResource( + specName: SpecName, + resourceName: string +): Promise { + const stainlessSpec = await getStainlessSpec(specName); + return stainlessSpec.resources[resourceName]; +} + +/** + * Get all resource names in order for a spec. + */ +export async function getResourceOrder(specName: SpecName): Promise { + const stainlessSpec = await getStainlessSpec(specName); + return Object.keys(stainlessSpec.resources); +} + +/** + * Resolve an endpoint reference from a Stainless method config. + */ +export function resolveEndpointFromMethod( + endpointOrMethodConfig: string | { endpoint: string } +): [string, string] { + const endpointReference = + typeof endpointOrMethodConfig === "string" + ? endpointOrMethodConfig + : endpointOrMethodConfig.endpoint; + + const [methodType, endpoint] = endpointReference.split(" "); + return [methodType, endpoint]; +} + +/** + * Get the OpenAPI operation object for a specific method. + */ +export async function getMethodOperation( + specName: SpecName, + resourceName: string, + methodName: string +): Promise<{ + operation: OpenAPIV3.OperationObject; + methodType: string; + endpoint: string; +} | null> { + const [openApiSpec, stainlessSpec] = await Promise.all([ + getOpenApiSpec(specName), + getStainlessSpec(specName), + ]); + + const resource = stainlessSpec.resources[resourceName]; + if (!resource?.methods?.[methodName]) { + return null; + } + + const methodConfig = resource.methods[methodName]; + const [methodType, endpoint] = resolveEndpointFromMethod(methodConfig); + const operation = openApiSpec.paths?.[endpoint]?.[methodType]; + + if (!operation) { + return null; + } + + return { operation, methodType, endpoint }; +} + +/** + * Get a schema by reference from the OpenAPI spec. + */ +export async function getSchemaByRef( + specName: SpecName, + schemaRef: string +): Promise { + const openApiSpec = await getOpenApiSpec(specName); + return JSONPointer.get(openApiSpec, schemaRef.replace("#", "")); +} + +/** + * Build schema references for linking between schema types. + * This maps schema titles to their URL paths. + */ +export async function buildSchemaReferences( + specName: SpecName, + basePath: string +): Promise> { + const [openApiSpec, stainlessSpec] = await Promise.all([ + getOpenApiSpec(specName), + getStainlessSpec(specName), + ]); + + const schemaReferences: Record = {}; + + function processResource( + resource: StainlessResource, + resourcePath: string + ) { + if (resource.models) { + Object.entries(resource.models).forEach(([modelName, modelRef]) => { + const schema: OpenAPIV3.SchemaObject | undefined = JSONPointer.get( + openApiSpec, + modelRef.replace("#", "") + ); + + const title = schema?.title ?? modelName; + + if (schema) { + schemaReferences[title] = `${resourcePath}/schemas/${modelName}`; + schemaReferences[`${title}[]`] = `${resourcePath}/schemas/${modelName}`; + } + }); + } + + if (resource.subresources) { + Object.entries(resource.subresources).forEach( + ([subresourceName, subresource]) => { + processResource(subresource, `${resourcePath}/${subresourceName}`); + } + ); + } + } + + Object.entries(stainlessSpec.resources).forEach(([resourceName, resource]) => { + processResource(resource, `${basePath}/${resourceName}`); + }); + + return schemaReferences; +} + +/** + * Generate all static params for API reference pages. + * Used with generateStaticParams in App Router. + */ +export async function generateAllApiReferencePaths( + specName: SpecName +): Promise< + Array<{ + type: "resource" | "method" | "schema"; + resource: string; + slug?: string[]; + }> +> { + const stainlessSpec = await getStainlessSpec(specName); + const paths: Array<{ + type: "resource" | "method" | "schema"; + resource: string; + slug?: string[]; + }> = []; + + function processResource( + resource: StainlessResource, + resourceName: string, + parentPath: string[] = [] + ) { + const currentPath = [...parentPath, resourceName]; + + // Resource overview page + paths.push({ + type: "resource", + resource: resourceName, + slug: parentPath.length > 0 ? currentPath : undefined, + }); + + // Method pages + if (resource.methods) { + Object.keys(resource.methods).forEach((methodName) => { + paths.push({ + type: "method", + resource: resourceName, + slug: [...currentPath, methodName], + }); + }); + } + + // Schema pages + if (resource.models) { + Object.keys(resource.models).forEach((modelName) => { + paths.push({ + type: "schema", + resource: resourceName, + slug: [...currentPath, "schemas", modelName], + }); + }); + } + + // Subresources + if (resource.subresources) { + Object.entries(resource.subresources).forEach( + ([subresourceName, subresource]) => { + processResource(subresource, subresourceName, currentPath); + } + ); + } + } + + Object.entries(stainlessSpec.resources).forEach(([resourceName, resource]) => { + processResource(resource, resourceName); + }); + + return paths; +} From 66aa1c010aae924833550231290c1c6b17dfab38 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 31 Jan 2026 10:41:04 +0000 Subject: [PATCH 02/24] Add comprehensive API reference App Router migration plan Replace proof-of-concept code with a detailed migration plan document that covers: - Problem statement and root cause analysis - Solution overview with App Router and Server Components - Complete architecture design with directory structure and data flow - Six migration phases with estimated effort - Detailed implementation tasks with code examples and acceptance criteria - Component breakdown (reuse, refactor, new) - Testing strategy (unit, integration, E2E, visual regression) - Rollback plan - Success criteria (performance, functionality, DX, SEO) The plan provides a roadmap for migrating the API reference pages from Pages Router (with 2-5MB HTML payloads) to App Router with Server Components (targeting <100KB payloads). Co-authored-by: chris --- .cursor/api-reference-migration-plan.md | 1290 +++++++++++++++++ .../api-reference-v2/api-reference-client.tsx | 109 -- .../api-reference-context.tsx | 60 - .../api-reference-method-client.tsx | 283 ---- .../api-reference-section-client.tsx | 170 --- .../optimized/expandable-response.tsx | 97 -- .../api-reference-v2/optimized/page-shell.tsx | 143 -- .../api-reference-v2/optimized/page.tsx | 113 -- .../optimized/pre-content.tsx | 17 - .../optimized/resource-section.tsx | 139 -- .../optimized/static-endpoint-list.tsx | 58 - .../optimized/static-method-content.tsx | 292 ---- .../optimized/static-schema-content.tsx | 123 -- .../optimized/static-section.tsx | 50 - app/(api-reference)/api-reference-v2/page.tsx | 103 -- app/(api-reference)/layout.tsx | 33 - app/(api-reference)/providers.tsx | 55 - lib/openApiSpec.server.ts | 275 ---- 18 files changed, 1290 insertions(+), 2120 deletions(-) create mode 100644 .cursor/api-reference-migration-plan.md delete mode 100644 app/(api-reference)/api-reference-v2/api-reference-client.tsx delete mode 100644 app/(api-reference)/api-reference-v2/api-reference-context.tsx delete mode 100644 app/(api-reference)/api-reference-v2/api-reference-method-client.tsx delete mode 100644 app/(api-reference)/api-reference-v2/api-reference-section-client.tsx delete mode 100644 app/(api-reference)/api-reference-v2/optimized/expandable-response.tsx delete mode 100644 app/(api-reference)/api-reference-v2/optimized/page-shell.tsx delete mode 100644 app/(api-reference)/api-reference-v2/optimized/page.tsx delete mode 100644 app/(api-reference)/api-reference-v2/optimized/pre-content.tsx delete mode 100644 app/(api-reference)/api-reference-v2/optimized/resource-section.tsx delete mode 100644 app/(api-reference)/api-reference-v2/optimized/static-endpoint-list.tsx delete mode 100644 app/(api-reference)/api-reference-v2/optimized/static-method-content.tsx delete mode 100644 app/(api-reference)/api-reference-v2/optimized/static-schema-content.tsx delete mode 100644 app/(api-reference)/api-reference-v2/optimized/static-section.tsx delete mode 100644 app/(api-reference)/api-reference-v2/page.tsx delete mode 100644 app/(api-reference)/layout.tsx delete mode 100644 app/(api-reference)/providers.tsx delete mode 100644 lib/openApiSpec.server.ts diff --git a/.cursor/api-reference-migration-plan.md b/.cursor/api-reference-migration-plan.md new file mode 100644 index 000000000..cdd141296 --- /dev/null +++ b/.cursor/api-reference-migration-plan.md @@ -0,0 +1,1290 @@ +# API reference App Router migration plan + +This document provides a comprehensive plan for migrating the API reference pages from the Pages Router to the App Router with Server Components. The goal is to eliminate build and runtime issues caused by loading the entire OpenAPI spec into page props. + +## Table of contents + +1. [Problem statement](#problem-statement) +2. [Solution overview](#solution-overview) +3. [Architecture design](#architecture-design) +4. [Migration phases](#migration-phases) +5. [Detailed implementation tasks](#detailed-implementation-tasks) +6. [Component breakdown](#component-breakdown) +7. [Testing strategy](#testing-strategy) +8. [Rollback plan](#rollback-plan) +9. [Success criteria](#success-criteria) + +--- + +## Problem statement + +### Current issues + +The current API reference implementation has several architectural problems: + +1. **Massive page payload.** The entire dereferenced OpenAPI spec (~600KB for API, ~500KB for MAPI as raw YAML) is passed through `getStaticProps`. After dereferencing and JSON serialization, this can expand to 2-5MB embedded in the HTML. + +2. **Build-time memory pressure.** The `@scalar/openapi-parser` dereference operation resolves all `$ref` pointers inline, which can consume significant memory for specs with circular references. + +3. **Long hydration times.** React must hydrate the massive props object on page load, blocking interactivity. + +4. **Poor Core Web Vitals.** Large HTML payloads negatively impact First Contentful Paint (FCP) and Largest Contentful Paint (LCP). + +5. **Single-page architecture limitations.** The current approach uses URL rewrites to serve a single static page for all API reference paths, meaning every path loads the full spec. + +### Root cause + +The fundamental issue is that `getStaticProps` serializes all returned data into the page HTML. The OpenAPI spec is large, and passing it as props means every page visitor downloads the entire spec even if they only view one endpoint. + +--- + +## Solution overview + +### App Router with Server Components + +The App Router with React Server Components solves these problems by: + +1. **Server-only data.** Server Components can load and process data that never leaves the server. The OpenAPI spec is processed on the server and only the rendered HTML is sent to the client. + +2. **React `cache()` for deduplication.** The spec is loaded once per request/build and cached in memory, shared across all components that need it. + +3. **Streaming and Suspense.** Individual sections can stream to the client as they're ready, improving Time to First Byte (TTFB). + +4. **Client component islands.** Only interactive elements (expandable sections, code example toggles) require client-side JavaScript. + +### Expected outcomes + +| Metric | Current | After migration | +|--------|---------|-----------------| +| HTML payload size | 2-5MB | ~50-100KB | +| JavaScript hydration | Full spec | Minimal (islands only) | +| Build memory | High | Reduced (cached) | +| Time to Interactive | Slow | Fast | + +--- + +## Architecture design + +### Directory structure + +``` +app/ +├── (api-reference)/ # Route group (no URL segment) +│ ├── layout.tsx # Shared layout with providers +│ ├── providers.tsx # Client-side providers +│ │ +│ ├── api-reference/ # /api-reference routes +│ │ ├── page.tsx # Overview page (Server Component) +│ │ ├── loading.tsx # Loading skeleton +│ │ ├── [resource]/ # Dynamic resource routes +│ │ │ ├── page.tsx # Resource overview +│ │ │ ├── [method]/ # Dynamic method routes +│ │ │ │ └── page.tsx # Method detail page +│ │ │ └── schemas/ +│ │ │ └── [schema]/ +│ │ │ └── page.tsx # Schema detail page +│ │ └── components/ # API reference specific components +│ │ ├── resource-section.tsx # Server Component +│ │ ├── method-content.tsx # Server Component +│ │ ├── schema-content.tsx # Server Component +│ │ ├── endpoint-list.tsx # Server Component +│ │ ├── parameter-list.tsx # Server Component +│ │ ├── property-list.tsx # Server Component +│ │ ├── expandable-properties.tsx # Client Component (island) +│ │ ├── code-example-tabs.tsx # Client Component (island) +│ │ └── sidebar-nav.tsx # Client Component (island) +│ │ +│ └── mapi-reference/ # /mapi-reference routes +│ └── ... (same structure as api-reference) +│ +lib/ +├── openapi/ # OpenAPI utilities +│ ├── loader.ts # Cached spec loading +│ ├── types.ts # TypeScript types +│ ├── helpers.ts # Utility functions +│ └── sidebar.ts # Sidebar generation +``` + +### Data flow + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ BUILD / REQUEST │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ openapi.yml │───▶│ loader.ts │ │ +│ │ stainless.yml │ │ (React cache) │ │ +│ └──────────────────┘ └────────┬─────────┘ │ +│ │ │ +│ Parsed & cached in memory │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ Server Components │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ +│ │ │ page.tsx │ │ resource- │ │ method- │ │ │ +│ │ │ (overview) │ │ section.tsx │ │ content.tsx │ │ │ +│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ +│ │ │ │ │ │ │ +│ │ │ Extract relevant slice │ │ │ +│ │ ▼ ▼ ▼ │ │ +│ │ ┌─────────────────────────────────────────────────────┐ │ │ +│ │ │ Rendered HTML │ │ │ +│ │ └─────────────────────────────────────────────────────┘ │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ │ │ +└───────────────────────────────────┼──────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ CLIENT │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Static HTML │ │ +│ │ (No OpenAPI spec data - just rendered content) │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ + │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ Client Component Islands │ │ +│ │ ┌───────────────────┐ ┌───────────────────┐ │ │ +│ │ │ expandable- │ │ code-example- │ │ │ +│ │ │ properties.tsx │ │ tabs.tsx │ │ │ +│ │ │ (toggle state) │ │ (language state) │ │ │ +│ │ └───────────────────┘ └───────────────────┘ │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### Server vs Client component split + +| Component | Type | Reason | +|-----------|------|--------| +| `page.tsx` | Server | Loads spec, renders static content | +| `resource-section.tsx` | Server | Renders resource overview | +| `method-content.tsx` | Server | Renders endpoint documentation | +| `schema-content.tsx` | Server | Renders schema/model documentation | +| `parameter-list.tsx` | Server | Renders parameter tables | +| `property-list.tsx` | Server | Renders property tables | +| `endpoint-list.tsx` | Server | Renders endpoint summary list | +| `expandable-properties.tsx` | Client | Toggle expand/collapse state | +| `code-example-tabs.tsx` | Client | Language selection state | +| `sidebar-nav.tsx` | Client | Active state, scroll tracking | +| `providers.tsx` | Client | Analytics, AI chat context | + +--- + +## Migration phases + +### Phase 1: Foundation (estimated: 1-2 days) + +Set up the core infrastructure for the App Router migration. + +**Deliverables:** +- Cached OpenAPI spec loader +- Shared layout and providers +- Basic routing structure +- Type definitions + +### Phase 2: Server components (estimated: 2-3 days) + +Create the core Server Components that render API documentation. + +**Deliverables:** +- Resource section component +- Method content component +- Schema content component +- Parameter and property list components +- Endpoint list component + +### Phase 3: Client component islands (estimated: 1-2 days) + +Extract interactive features into minimal Client Components. + +**Deliverables:** +- Expandable response properties +- Code example language tabs +- Sidebar navigation with active state + +### Phase 4: Page assembly (estimated: 1-2 days) + +Wire up the pages and implement static generation. + +**Deliverables:** +- Overview page +- Resource pages +- Method pages +- Schema pages +- `generateStaticParams` for all routes + +### Phase 5: Polish and parity (estimated: 2-3 days) + +Ensure feature parity with the current implementation. + +**Deliverables:** +- Mobile sidebar +- Hash navigation +- Scroll behavior +- Breadcrumbs +- Content actions (copy link, ask AI) +- SEO metadata + +### Phase 6: Testing and rollout (estimated: 2-3 days) + +Comprehensive testing and gradual rollout. + +**Deliverables:** +- Visual regression testing +- Performance benchmarking +- Staged rollout via feature flag or redirect +- Monitoring and rollback capability + +--- + +## Detailed implementation tasks + +### Phase 1: Foundation + +#### Task 1.1: Create cached OpenAPI loader + +Create `lib/openapi/loader.ts`: + +```typescript +import { cache } from "react"; +import { dereference } from "@scalar/openapi-parser"; +import { readFile } from "fs/promises"; +import { parse } from "yaml"; +import safeStringify from "safe-stringify"; +import deepmerge from "deepmerge"; +import type { OpenAPIV3 } from "@scalar/openapi-types"; + +type SpecName = "api" | "mapi"; + +/** + * Cached loader for the full dereferenced OpenAPI spec. + * Uses React's cache() to dedupe within a single render pass. + */ +export const getOpenApiSpec = cache( + async (specName: SpecName): Promise => { + const spec = await readFile( + `./data/specs/${specName}/openapi.yml`, + "utf8" + ); + const { schema } = await dereference(parse(spec)); + return JSON.parse(safeStringify(schema)); + } +); + +/** + * Cached loader for the Stainless config with customizations. + */ +export const getStainlessSpec = cache( + async (specName: SpecName): Promise => { + const [specFile, customizationsFile] = await Promise.all([ + readFile(`./data/specs/${specName}/stainless.yml`, "utf8"), + readFile(`./data/specs/${specName}/customizations.yml`, "utf8"), + ]); + return deepmerge(parse(specFile), parse(customizationsFile)); + } +); +``` + +**Acceptance criteria:** +- [ ] Specs are loaded once per build/request +- [ ] Subsequent calls return cached data +- [ ] TypeScript types are properly exported + +#### Task 1.2: Create type definitions + +Create `lib/openapi/types.ts`: + +```typescript +import type { OpenAPIV3 } from "@scalar/openapi-types"; + +export type SpecName = "api" | "mapi"; + +export type StainlessMethodConfig = + | string + | { type: "http"; endpoint: string; positional_params?: string[] }; + +export interface StainlessResource { + name?: string; + description?: string; + models?: Record; + methods?: Record; + subresources?: Record; +} + +export interface StainlessConfig { + resources: Record; + environments: Record; +} + +export interface MethodData { + methodName: string; + methodType: "get" | "post" | "put" | "delete" | "patch"; + endpoint: string; + operation: OpenAPIV3.OperationObject; +} + +export interface SchemaData { + modelName: string; + schema: OpenAPIV3.SchemaObject; +} + +export interface ResourceData { + resourceName: string; + resource: StainlessResource; + methods: MethodData[]; + schemas: SchemaData[]; + subresources: ResourceData[]; +} +``` + +**Acceptance criteria:** +- [ ] All types match existing data structures +- [ ] Types are exported for use in components + +#### Task 1.3: Create helper functions + +Create `lib/openapi/helpers.ts`: + +```typescript +import type { OpenAPIV3 } from "@scalar/openapi-types"; +import JSONPointer from "jsonpointer"; +import type { + StainlessMethodConfig, + StainlessResource, + StainlessConfig, + MethodData, + SchemaData, + SpecName +} from "./types"; +import { getOpenApiSpec, getStainlessSpec } from "./loader"; + +/** + * Resolve endpoint from Stainless method config. + */ +export function resolveEndpoint( + config: StainlessMethodConfig +): [string, string] { + const endpoint = typeof config === "string" ? config : config.endpoint; + const [method, path] = endpoint.split(" "); + return [method.toLowerCase(), path]; +} + +/** + * Get operation from OpenAPI spec for a given endpoint. + */ +export function getOperation( + spec: OpenAPIV3.Document, + methodType: string, + endpoint: string +): OpenAPIV3.OperationObject | undefined { + return spec.paths?.[endpoint]?.[methodType]; +} + +/** + * Get schema by $ref from OpenAPI spec. + */ +export function getSchemaByRef( + spec: OpenAPIV3.Document, + ref: string +): OpenAPIV3.SchemaObject | undefined { + return JSONPointer.get(spec, ref.replace("#", "")); +} + +/** + * Build schema references map for cross-linking. + */ +export async function buildSchemaReferences( + specName: SpecName, + basePath: string +): Promise> { + const [spec, stainless] = await Promise.all([ + getOpenApiSpec(specName), + getStainlessSpec(specName), + ]); + + const references: Record = {}; + + function processResource(resource: StainlessResource, path: string) { + if (resource.models) { + Object.entries(resource.models).forEach(([modelName, ref]) => { + const schema = getSchemaByRef(spec, ref); + const title = schema?.title ?? modelName; + references[title] = `${path}/schemas/${modelName}`; + references[`${title}[]`] = `${path}/schemas/${modelName}`; + }); + } + if (resource.subresources) { + Object.entries(resource.subresources).forEach(([name, sub]) => { + processResource(sub, `${path}/${name}`); + }); + } + } + + Object.entries(stainless.resources).forEach(([name, resource]) => { + processResource(resource, `${basePath}/${name}`); + }); + + return references; +} + +/** + * Get all methods for a resource. + */ +export function getResourceMethods( + spec: OpenAPIV3.Document, + resource: StainlessResource +): MethodData[] { + if (!resource.methods) return []; + + return Object.entries(resource.methods) + .map(([methodName, config]) => { + const [methodType, endpoint] = resolveEndpoint(config); + const operation = getOperation(spec, methodType, endpoint); + if (!operation) return null; + return { methodName, methodType, endpoint, operation } as MethodData; + }) + .filter((m): m is MethodData => m !== null); +} + +/** + * Get all schemas for a resource. + */ +export function getResourceSchemas( + spec: OpenAPIV3.Document, + resource: StainlessResource +): SchemaData[] { + if (!resource.models) return []; + + return Object.entries(resource.models) + .map(([modelName, ref]) => { + const schema = getSchemaByRef(spec, ref); + if (!schema) return null; + return { modelName, schema }; + }) + .filter((s): s is SchemaData => s !== null); +} +``` + +**Acceptance criteria:** +- [ ] All helper functions are pure and testable +- [ ] Functions work with cached spec data +- [ ] Type safety is maintained + +#### Task 1.4: Create shared layout + +Create `app/(api-reference)/layout.tsx`: + +```typescript +import { Inter } from "next/font/google"; +import { Providers } from "./providers"; + +import "@algolia/autocomplete-theme-classic"; +import "../../styles/index.css"; +import "../../styles/global.css"; +import "../../styles/responsive.css"; + +const inter = Inter({ subsets: ["latin"], display: "swap" }); + +export default function ApiReferenceLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} +``` + +**Acceptance criteria:** +- [ ] Layout loads global styles +- [ ] Font is configured +- [ ] Providers wrap children + +#### Task 1.5: Create client providers + +Create `app/(api-reference)/providers.tsx`: + +```typescript +"use client"; + +import { useEffect } from "react"; +import { usePathname } from "next/navigation"; +import { + EventEmitterContext, + useEventEmitterInstance, +} from "@byteclaw/use-event-emitter"; +import { InkeepModalProvider } from "../../components/AiChatButton"; +import { AskAiProvider } from "../../components/AskAiContext"; +import AskAiSidebar from "../../components/AskAiSidebar"; +import * as analytics from "../../lib/analytics"; +import { initAttribution } from "../../lib/attribution"; +import { setClearbitPath } from "../../lib/clearbit"; +import * as gtag from "../../lib/gtag"; + +export function Providers({ children }: { children: React.ReactNode }) { + const eventEmitter = useEventEmitterInstance(); + const pathname = usePathname(); + + useEffect(() => { + initAttribution(); + }, []); + + useEffect(() => { + gtag.pageview(pathname as unknown as URL); + setClearbitPath(pathname as unknown as URL); + analytics.page(); + initAttribution(); + }, [pathname]); + + return ( + + +
+ + {children} + + {analytics.SEGMENT_WRITE_KEY && } +
+ +
+
+ ); +} +``` + +**Acceptance criteria:** +- [ ] Analytics tracking works on navigation +- [ ] AI chat sidebar is available +- [ ] Event emitter context is provided + +--- + +### Phase 2: Server components + +#### Task 2.1: Create resource section component + +Create `app/(api-reference)/api-reference/components/resource-section.tsx`: + +This is a Server Component that renders a resource overview section. + +**Props:** +- `specName: SpecName` - Which spec to use +- `resourceName: string` - Resource identifier +- `basePath: string` - URL base path for links + +**Responsibilities:** +- Load resource data from cached spec +- Render resource title and description +- Render endpoint summary list +- Render each method section +- Render each schema section +- Recursively render subresources + +**Acceptance criteria:** +- [ ] No `"use client"` directive +- [ ] Uses only server-safe APIs +- [ ] Passes data to child components, not context + +#### Task 2.2: Create method content component + +Create `app/(api-reference)/api-reference/components/method-content.tsx`: + +This is a Server Component that renders a single API method. + +**Props:** +- `method: MethodData` - Method information +- `baseUrl: string` - API base URL for examples +- `schemaReferences: Record` - Schema link map + +**Sections to render:** +1. Title (operation.summary) +2. Description (operation.description) +3. Beta badge if applicable +4. Endpoint display (method + path) +5. Rate limit info if applicable +6. Path parameters table +7. Query parameters table +8. Request body properties +9. Response information with expandable properties (Client Component island) +10. Code examples with language tabs (Client Component island) + +**Acceptance criteria:** +- [ ] All static content rendered on server +- [ ] Interactive elements use Client Component islands +- [ ] Proper heading hierarchy (h2, h3) +- [ ] Accessible markup + +#### Task 2.3: Create schema content component + +Create `app/(api-reference)/api-reference/components/schema-content.tsx`: + +This is a Server Component that renders a schema/model definition. + +**Props:** +- `schema: SchemaData` - Schema information +- `schemaReferences: Record` - Schema link map + +**Sections to render:** +1. Title (schema.title) +2. Description +3. Attributes table with all properties +4. Example JSON + +**Acceptance criteria:** +- [ ] Properties rendered with type information +- [ ] Required fields marked +- [ ] Enum values displayed +- [ ] Example formatted with syntax highlighting + +#### Task 2.4: Create parameter list component + +Create `app/(api-reference)/api-reference/components/parameter-list.tsx`: + +Server Component for rendering parameter tables. + +**Props:** +- `parameters: OpenAPIV3.ParameterObject[]` +- `schemaReferences: Record` + +**Acceptance criteria:** +- [ ] Name, type, required status shown +- [ ] Description rendered as markdown +- [ ] Default values shown if present +- [ ] Enum values listed + +#### Task 2.5: Create property list component + +Create `app/(api-reference)/api-reference/components/property-list.tsx`: + +Server Component for rendering schema properties. + +**Props:** +- `schema: OpenAPIV3.SchemaObject` +- `schemaReferences: Record` +- `showRequired?: boolean` + +**Acceptance criteria:** +- [ ] Nested properties handled +- [ ] Array types shown correctly +- [ ] References linked to schema pages +- [ ] Nullable indicated + +#### Task 2.6: Create endpoint list component + +Create `app/(api-reference)/api-reference/components/endpoint-list.tsx`: + +Server Component for the endpoint summary in resource overviews. + +**Props:** +- `methods: MethodData[]` + +**Acceptance criteria:** +- [ ] Method badge with color coding +- [ ] Path displayed in monospace +- [ ] Links to method sections + +--- + +### Phase 3: Client component islands + +#### Task 3.1: Create expandable properties component + +Create `app/(api-reference)/api-reference/components/expandable-properties.tsx`: + +```typescript +"use client"; + +import { useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { ChevronDown, ChevronRight } from "lucide-react"; + +interface Props { + title: string; + children: React.ReactNode; + defaultExpanded?: boolean; +} + +export function ExpandableProperties({ + title, + children, + defaultExpanded = false, +}: Props) { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + + return ( +
+ + + + {isExpanded && ( + +
+ {children} +
+
+ )} +
+
+ ); +} +``` + +**Acceptance criteria:** +- [ ] Smooth expand/collapse animation +- [ ] Accessible button with aria-expanded +- [ ] Children rendered only when expanded (optional optimization) + +#### Task 3.2: Create code example tabs component + +Create `app/(api-reference)/api-reference/components/code-example-tabs.tsx`: + +```typescript +"use client"; + +import { useState } from "react"; + +interface Props { + examples: Record; + title?: string; +} + +const LANGUAGE_LABELS: Record = { + curl: "cURL", + javascript: "JavaScript", + typescript: "TypeScript", + python: "Python", + ruby: "Ruby", + go: "Go", + java: "Java", + csharp: "C#", + php: "PHP", +}; + +export function CodeExampleTabs({ examples, title }: Props) { + const languages = Object.keys(examples); + const [activeLanguage, setActiveLanguage] = useState(languages[0]); + + if (languages.length === 0) return null; + + return ( +
+ {title && ( +
+ {title} +
+ )} +
+ {languages.map((lang) => ( + + ))} +
+
+        {examples[activeLanguage]}
+      
+
+ ); +} +``` + +**Acceptance criteria:** +- [ ] Language tabs switch content +- [ ] Active tab visually indicated +- [ ] Code syntax highlighted +- [ ] Horizontal scroll for long lines + +#### Task 3.3: Create sidebar navigation component + +Create `app/(api-reference)/api-reference/components/sidebar-nav.tsx`: + +This is a Client Component that handles: +- Active section tracking based on scroll position +- Collapse/expand for nested items +- Hash navigation on click + +**Props:** +- `sections: SidebarSection[]` - Sidebar structure +- `basePath: string` - Base URL path + +**Acceptance criteria:** +- [ ] Active section highlighted on scroll +- [ ] Nested sections collapsible +- [ ] Smooth scroll to section on click +- [ ] URL hash updated on navigation +- [ ] Works with same-page navigation + +--- + +### Phase 4: Page assembly + +#### Task 4.1: Create overview page + +Create `app/(api-reference)/api-reference/page.tsx`: + +```typescript +import { Suspense } from "react"; +import { getOpenApiSpec, getStainlessSpec } from "@/lib/openapi/loader"; +import { buildSchemaReferences } from "@/lib/openapi/helpers"; +import { RESOURCE_ORDER } from "@/data/sidebars/apiOverviewSidebar"; +import { PageShell } from "./components/page-shell"; +import { ResourceSection } from "./components/resource-section"; +import { PreContent } from "./components/pre-content"; + +export const dynamic = "force-static"; +export const revalidate = 3600; + +export const metadata = { + title: "API reference | Knock Docs", + description: "Complete reference documentation for the Knock API.", +}; + +export default async function ApiReferencePage() { + const [spec, stainless] = await Promise.all([ + getOpenApiSpec("api"), + getStainlessSpec("api"), + ]); + + const schemaReferences = await buildSchemaReferences("api", "/api-reference"); + const baseUrl = stainless.environments.production; + + return ( + + + + {RESOURCE_ORDER.map((resourceName) => ( + } + > + + + ))} + + ); +} +``` + +**Acceptance criteria:** +- [ ] Page renders all resources +- [ ] Each resource section can suspend independently +- [ ] Metadata set correctly +- [ ] Static generation enabled + +#### Task 4.2: Create resource page with generateStaticParams + +Create `app/(api-reference)/api-reference/[resource]/page.tsx`: + +```typescript +import { getStainlessSpec } from "@/lib/openapi/loader"; +import { RESOURCE_ORDER } from "@/data/sidebars/apiOverviewSidebar"; +import { ResourceSection } from "../components/resource-section"; + +export async function generateStaticParams() { + return RESOURCE_ORDER.map((resource) => ({ resource })); +} + +export async function generateMetadata({ params }) { + const stainless = await getStainlessSpec("api"); + const resource = stainless.resources[params.resource]; + + return { + title: `${resource?.name || params.resource} | API reference | Knock Docs`, + description: resource?.description || `API reference for ${params.resource}`, + }; +} + +export default async function ResourcePage({ params }) { + // ... render resource overview +} +``` + +**Acceptance criteria:** +- [ ] All resource paths generated at build +- [ ] Metadata generated per resource +- [ ] 404 for unknown resources + +#### Task 4.3: Create method page + +Create `app/(api-reference)/api-reference/[resource]/[method]/page.tsx`: + +```typescript +export async function generateStaticParams() { + const stainless = await getStainlessSpec("api"); + const params = []; + + Object.entries(stainless.resources).forEach(([resource, data]) => { + if (data.methods) { + Object.keys(data.methods).forEach((method) => { + params.push({ resource, method }); + }); + } + }); + + return params; +} +``` + +**Acceptance criteria:** +- [ ] All method paths generated at build +- [ ] Renders single method documentation +- [ ] Links to related schemas work + +#### Task 4.4: Create schema page + +Create `app/(api-reference)/api-reference/[resource]/schemas/[schema]/page.tsx`: + +**Acceptance criteria:** +- [ ] All schema paths generated at build +- [ ] Renders schema with all properties +- [ ] Example JSON displayed + +--- + +### Phase 5: Polish and parity + +#### Task 5.1: Implement mobile sidebar + +Ensure the mobile sidebar component works with App Router navigation. + +**Acceptance criteria:** +- [ ] Opens/closes correctly +- [ ] Navigation works +- [ ] Closes on route change + +#### Task 5.2: Implement hash navigation + +Ensure clicking sidebar items scrolls to the correct section and updates the URL hash. + +**Acceptance criteria:** +- [ ] Smooth scroll to sections +- [ ] URL hash reflects current section +- [ ] Browser back/forward works +- [ ] Direct links to hashes work + +#### Task 5.3: Implement scroll tracking + +Track scroll position to highlight the active sidebar item. + +**Acceptance criteria:** +- [ ] Intersection Observer used for performance +- [ ] Active item updated on scroll +- [ ] Works with nested sections + +#### Task 5.4: Implement content actions + +Port the "Copy link" and "Ask AI" buttons. + +**Acceptance criteria:** +- [ ] Copy link copies correct URL with hash +- [ ] Ask AI opens with current context +- [ ] Buttons positioned correctly + +#### Task 5.5: Port breadcrumbs + +Ensure breadcrumbs work with App Router. + +**Acceptance criteria:** +- [ ] Correct hierarchy shown +- [ ] Links work +- [ ] Updates on navigation + +#### Task 5.6: SEO and metadata + +Ensure proper SEO for all pages. + +**Acceptance criteria:** +- [ ] Unique title per page +- [ ] Description for each page +- [ ] Canonical URLs set +- [ ] Open Graph tags present + +--- + +### Phase 6: Testing and rollout + +#### Task 6.1: Visual regression testing + +Compare screenshots of old and new implementations. + +**Acceptance criteria:** +- [ ] All sections render identically +- [ ] Code blocks styled correctly +- [ ] Tables formatted properly +- [ ] Responsive layouts work + +#### Task 6.2: Performance benchmarking + +Measure and compare performance metrics. + +**Metrics to measure:** +- HTML payload size +- Time to First Byte (TTFB) +- First Contentful Paint (FCP) +- Largest Contentful Paint (LCP) +- Time to Interactive (TTI) +- Total Blocking Time (TBT) + +**Acceptance criteria:** +- [ ] HTML payload reduced by >90% +- [ ] LCP improved +- [ ] TTI improved + +#### Task 6.3: Staged rollout + +Roll out to a percentage of traffic first. + +**Options:** +1. Feature flag with percentage rollout +2. Subdomain preview (e.g., api-preview.docs.knock.app) +3. URL-based opt-in (e.g., ?new-api-reference=true) + +**Acceptance criteria:** +- [ ] Rollout mechanism in place +- [ ] Monitoring dashboards set up +- [ ] Quick rollback capability + +#### Task 6.4: Remove old implementation + +Once validated, remove the Pages Router implementation. + +**Acceptance criteria:** +- [ ] Old pages removed +- [ ] Redirects in place if needed +- [ ] No dead code remaining + +--- + +## Component breakdown + +### Existing components to reuse + +These components can be reused with minimal changes: + +| Component | Path | Changes needed | +|-----------|------|----------------| +| `CodeBlock` | `components/ui/CodeBlock.tsx` | None | +| `Callout` | `components/ui/Callout.tsx` | None | +| `RateLimit` | `components/ui/RateLimit.tsx` | None | +| `Endpoints` | `components/ui/Endpoints.tsx` | None | +| `PageHeader` | `components/ui/PageHeader.tsx` | None | + +### Existing components to refactor + +These components need Server/Client splitting: + +| Component | Current | After | +|-----------|---------|-------| +| `ApiReferenceSection` | Client | Server + Client islands | +| `ApiReferenceMethod` | Client | Server + Client islands | +| `SchemaProperties` | Client | Server + Client island for expandable | +| `MultiLangExample` | Client | Client (language state) | +| `Sidebar` | Client | Server for structure, Client for state | + +### New components to create + +| Component | Type | Purpose | +|-----------|------|---------| +| `PageShell` | Client | Layout with sidebar state | +| `ResourceSection` | Server | Resource overview rendering | +| `MethodContent` | Server | Method documentation | +| `SchemaContent` | Server | Schema documentation | +| `ExpandableProperties` | Client | Expand/collapse UI | +| `CodeExampleTabs` | Client | Language selection | +| `SidebarNav` | Client | Navigation with active state | + +--- + +## Testing strategy + +### Unit tests + +Test helper functions in isolation: + +```typescript +// lib/openapi/helpers.test.ts +describe("resolveEndpoint", () => { + it("parses string endpoint format", () => { + const result = resolveEndpoint("get /users/{id}"); + expect(result).toEqual(["get", "/users/{id}"]); + }); + + it("parses object endpoint format", () => { + const result = resolveEndpoint({ endpoint: "post /users" }); + expect(result).toEqual(["post", "/users"]); + }); +}); +``` + +### Integration tests + +Test page rendering with mock data: + +```typescript +// app/(api-reference)/api-reference/page.test.tsx +describe("ApiReferencePage", () => { + it("renders all resource sections", async () => { + const page = await render(); + expect(page.getByRole("heading", { name: "Workflows" })).toBeVisible(); + expect(page.getByRole("heading", { name: "Users" })).toBeVisible(); + }); +}); +``` + +### E2E tests + +Test full user flows: + +```typescript +// e2e/api-reference.spec.ts +test("navigate to method documentation", async ({ page }) => { + await page.goto("/api-reference"); + await page.click("text=Trigger workflow"); + await expect(page).toHaveURL(/\/api-reference\/workflows\/trigger/); + await expect(page.locator("h2")).toContainText("Trigger workflow"); +}); +``` + +### Visual regression tests + +Use Playwright or Percy for screenshot comparison: + +```typescript +test("method documentation visual", async ({ page }) => { + await page.goto("/api-reference/users/get"); + await expect(page).toHaveScreenshot("users-get-method.png"); +}); +``` + +--- + +## Rollback plan + +### Immediate rollback + +If critical issues are discovered after rollout: + +1. Revert the `next.config.js` routing changes +2. The old Pages Router implementation continues to work +3. App Router pages become inaccessible but don't break the site + +### Gradual rollback + +If issues are discovered during staged rollout: + +1. Reduce rollout percentage to 0% +2. Investigate issues +3. Fix and re-deploy +4. Gradually increase rollout percentage + +### Data rollback + +No data migration is involved, so no data rollback is needed. + +--- + +## Success criteria + +### Performance + +- [ ] HTML payload size < 100KB (down from 2-5MB) +- [ ] LCP < 2.5s (Good threshold) +- [ ] TTI < 3.8s (Good threshold) +- [ ] TBT < 200ms (Good threshold) + +### Functionality + +- [ ] All API endpoints documented +- [ ] All schemas documented +- [ ] Code examples work in all languages +- [ ] Search/navigation works +- [ ] Mobile responsive + +### Developer experience + +- [ ] Build time not increased significantly +- [ ] Local development hot reload works +- [ ] Type safety maintained +- [ ] Code is well-documented + +### SEO + +- [ ] All pages indexed by search engines +- [ ] Unique titles and descriptions +- [ ] No duplicate content issues + +--- + +## Appendix: Decision log + +### Why App Router over other solutions? + +| Alternative | Pros | Cons | Decision | +|-------------|------|------|----------| +| Pre-split JSON files | Minimal changes | Still requires client fetch | Rejected | +| API routes for data | Keeps Pages Router | Adds complexity, latency | Rejected | +| App Router | Best performance, future-proof | Migration effort | **Selected** | + +### Why not split the spec files? + +With Server Components, the spec stays on the server. Splitting would add complexity without significant benefit for specs of this size (~1MB total). + +### Why client component islands? + +Some features require client-side state: +- Expand/collapse toggle +- Language selection for code examples +- Scroll position tracking + +Rather than making entire sections client components, we extract only the interactive parts as islands within server-rendered content. + +--- + +## Appendix: Reference links + +- [Next.js App Router documentation](https://nextjs.org/docs/app) +- [React Server Components RFC](https://github.com/reactjs/rfcs/pull/188) +- [Streaming SSR with Suspense](https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming) +- [React cache() function](https://react.dev/reference/react/cache) diff --git a/app/(api-reference)/api-reference-v2/api-reference-client.tsx b/app/(api-reference)/api-reference-v2/api-reference-client.tsx deleted file mode 100644 index 31ca212b9..000000000 --- a/app/(api-reference)/api-reference-v2/api-reference-client.tsx +++ /dev/null @@ -1,109 +0,0 @@ -"use client"; - -/** - * Client component for the API reference page. - * - * This receives the pre-rendered data from the server component - * and handles client-side interactivity like navigation and state. - * - * IMPORTANT: In a fully optimized implementation, you would NOT pass - * the full openApiSpec here. Instead, you'd: - * 1. Render static content in the server component - * 2. Only pass minimal data needed for client interactivity - * - * This example shows a transitional approach that maintains compatibility - * with existing components while demonstrating the pattern. - */ - -import { MDXRemote, MDXRemoteSerializeResult } from "next-mdx-remote"; -import { usePathname } from "next/navigation"; -import { OpenAPIV3 } from "@scalar/openapi-types"; - -import { MDX_COMPONENTS } from "../../../lib/mdxComponents"; -import { StainlessConfig } from "../../../lib/openApiSpec"; -import { SidebarSection } from "../../../data/types"; -import { getSidebarContent } from "../../../components/ui/ApiReference/helpers"; -import Meta from "../../../components/Meta"; -import { Page as TelegraphPage } from "../../../components/ui/Page"; -import { ContentActions } from "../../../components/ui/ContentActions"; -import { ApiReferenceProvider } from "./api-reference-context"; -import { ApiReferenceSectionClient } from "./api-reference-section-client"; - -interface Props { - name: string; - openApiSpec: OpenAPIV3.Document; - stainlessSpec: StainlessConfig; - preContentMdx: MDXRemoteSerializeResult; - resourceOrder: string[]; - preSidebarContent?: SidebarSection[]; - baseUrl: string; - schemaReferences: Record; - basePath: string; -} - -export function ApiReferencePageClient({ - name, - openApiSpec, - stainlessSpec, - preContentMdx, - resourceOrder, - preSidebarContent, - baseUrl, - schemaReferences, - basePath, -}: Props) { - const pathname = usePathname(); - - const sidebarContent = getSidebarContent( - openApiSpec, - stainlessSpec, - resourceOrder, - basePath, - preSidebarContent - ); - - return ( - - - - } - /> - - - - - - } - /> - - - {resourceOrder.map((resourceName) => ( - - ))} - - - - - - ); -} diff --git a/app/(api-reference)/api-reference-v2/api-reference-context.tsx b/app/(api-reference)/api-reference-v2/api-reference-context.tsx deleted file mode 100644 index 4441fa196..000000000 --- a/app/(api-reference)/api-reference-v2/api-reference-context.tsx +++ /dev/null @@ -1,60 +0,0 @@ -"use client"; - -/** - * Client-side context for API reference components. - * - * This is similar to the existing ApiReferenceContext but designed - * for the App Router. The key difference is that the data is passed - * from the server component rather than loaded via getStaticProps. - */ - -import { createContext, useContext, ReactNode } from "react"; -import { OpenAPIV3 } from "@scalar/openapi-types"; -import { StainlessConfig } from "../../../lib/openApiSpec"; - -interface ApiReferenceContextType { - openApiSpec: OpenAPIV3.Document; - stainlessConfig: StainlessConfig; - baseUrl: string; - schemaReferences: Record; -} - -const ApiReferenceContext = createContext( - undefined -); - -interface ApiReferenceProviderProps { - children: ReactNode; - openApiSpec: OpenAPIV3.Document; - stainlessConfig: StainlessConfig; - baseUrl: string; - schemaReferences: Record; -} - -export function ApiReferenceProvider({ - children, - openApiSpec, - stainlessConfig, - baseUrl, - schemaReferences, -}: ApiReferenceProviderProps) { - return ( - - {children} - - ); -} - -export function useApiReference() { - const context = useContext(ApiReferenceContext); - if (context === undefined) { - throw new Error( - "useApiReference must be used within an ApiReferenceProvider" - ); - } - return context; -} - -export default ApiReferenceContext; diff --git a/app/(api-reference)/api-reference-v2/api-reference-method-client.tsx b/app/(api-reference)/api-reference-v2/api-reference-method-client.tsx deleted file mode 100644 index 7daf7deb7..000000000 --- a/app/(api-reference)/api-reference-v2/api-reference-method-client.tsx +++ /dev/null @@ -1,283 +0,0 @@ -"use client"; - -/** - * Client component for rendering API method details. - * - * This handles the interactive elements like expandable response properties. - */ - -import type { OpenAPIV3 } from "@scalar/openapi-types"; -import { useState } from "react"; -import Markdown from "react-markdown"; -import { AnimatePresence, motion } from "framer-motion"; - -import { Box } from "@telegraph/layout"; -import { Code, Heading } from "@telegraph/typography"; - -import { Callout } from "../../../components/ui/Callout"; -import RateLimit from "../../../components/ui/RateLimit"; -import { ContentColumn, ExampleColumn, Section } from "../../../components/ui/ApiSections"; -import { CodeBlock } from "../../../components/ui/CodeBlock"; -import { Endpoint } from "../../../components/ui/Endpoints"; -import MultiLangExample from "../../../components/ui/ApiReference/MultiLangExample"; -import OperationParameters from "../../../components/ui/ApiReference/OperationParameters/OperationParameters"; -import { SchemaProperties } from "../../../components/ui/ApiReference/SchemaProperties"; -import { PropertyRow } from "../../../components/ui/ApiReference/SchemaProperties/PropertyRow"; -import { - augmentSnippetsWithCurlRequest, - formatResponseStatusCodes, - resolveResponseSchemas, -} from "../../../components/ui/ApiReference/helpers"; -import { useApiReference } from "./api-reference-context"; - -interface Props { - methodName: string; - methodType: "get" | "post" | "put" | "delete"; - endpoint: string; - path?: string; - mdPath?: string; -} - -export function ApiReferenceMethodClient({ - methodName, - methodType, - endpoint, - path, - mdPath, -}: Props) { - const { openApiSpec, baseUrl, schemaReferences } = useApiReference(); - const [isResponseExpanded, setIsResponseExpanded] = useState(false); - const method = openApiSpec.paths?.[endpoint]?.[methodType]; - - if (!method) { - return null; - } - - const parameters = method.parameters || []; - - const pathParameters = parameters.filter( - (p) => p.in === "path" - ) as OpenAPIV3.ParameterObject[]; - const queryParameters = parameters.filter( - (p) => p.in === "query" - ) as OpenAPIV3.ParameterObject[]; - - const responseSchemas: OpenAPIV3.SchemaObject[] = resolveResponseSchemas(method); - - const requestBody: OpenAPIV3.SchemaObject | undefined = - method.requestBody?.content?.["application/json"]?.schema; - - const rateLimit = method?.["x-ratelimit-tier"] ?? null; - const isIdempotent = method?.["x-idempotent"] ?? false; - const isRetentionSubject = method?.["x-retention-policy"] ?? false; - const isBeta = method?.["x-beta"] ?? false; - - return ( -
- - {method.description ?? ""} - {isBeta && ( - - This endpoint is currently in beta. If you'd like early access, - or this is blocking your adoption of Knock, please{" "} - - get in touch - - . - - } - /> - )} - - - Endpoint - - - - - {rateLimit && ( - - - Rate limit - - - - )} - - {pathParameters.length > 0 && ( - <> - - Path parameters - - - - )} - - {queryParameters.length > 0 && ( - <> - - Query parameters - - - - )} - - {requestBody && ( - <> - - Request body - - - - )} - - - Returns - - - {responseSchemas.length > 0 && - responseSchemas.map((responseSchema) => ( - - - - - {responseSchema.title} - - - - {responseSchema.description ?? ""} - - - {responseSchema.properties && ( - <> - setIsResponseExpanded(!isResponseExpanded)} - > - {isResponseExpanded ? "Hide properties" : "Show properties"} - - - - - - - - - - - )} - - - ))} - - {responseSchemas.length === 0 && ( - - {formatResponseStatusCodes(method).map((formattedStatus, index) => ( - - {formattedStatus} - - ))} - - )} - - - - {responseSchemas.map( - (responseSchema) => - responseSchema?.example && ( - - {JSON.stringify(responseSchema?.example, null, 2)} - - ) - )} - -
- ); -} diff --git a/app/(api-reference)/api-reference-v2/api-reference-section-client.tsx b/app/(api-reference)/api-reference-v2/api-reference-section-client.tsx deleted file mode 100644 index 663354792..000000000 --- a/app/(api-reference)/api-reference-v2/api-reference-section-client.tsx +++ /dev/null @@ -1,170 +0,0 @@ -"use client"; - -/** - * Client component for rendering API reference sections. - * - * This is a client-side version of the ApiReferenceSection component - * that works with the App Router context setup. - */ - -import React from "react"; -import type { OpenAPIV3 } from "@scalar/openapi-types"; -import Markdown from "react-markdown"; -import JSONPointer from "jsonpointer"; - -import { Box } from "@telegraph/layout"; -import { Heading } from "@telegraph/typography"; - -import { StainlessResource } from "../../../lib/openApiSpec"; -import { ContentColumn, ExampleColumn, Section } from "../../../components/ui/ApiSections"; -import { Endpoint, Endpoints } from "../../../components/ui/Endpoints"; -import { CodeBlock } from "../../../components/ui/CodeBlock"; -import { SchemaProperties } from "../../../components/ui/ApiReference/SchemaProperties"; -import { resolveEndpointFromMethod } from "../../../components/ui/ApiReference/helpers"; -import { useApiReference } from "./api-reference-context"; -import { ApiReferenceMethodClient } from "./api-reference-method-client"; - -interface Props { - resourceName: string; - resource: StainlessResource; - path?: string; - basePath: string; -} - -export function ApiReferenceSectionClient({ - resourceName, - resource, - path, - basePath, -}: Props) { - const { openApiSpec } = useApiReference(); - const methods = resource.methods || {}; - const models = resource.models || {}; - const currentPath = path ?? `/${resourceName}`; - - // Generate markdown path for the resource overview - const resourceMdPath = `/${basePath}${currentPath}/index.md`; - - return ( - <> - -
- - {resource.description && ( - {resource.description} - )} - - - {Object.entries(methods).length > 0 && ( - - {Object.entries(methods).map( - ([methodName, endpointOrMethodConfig]) => { - const [methodType, endpoint] = resolveEndpointFromMethod( - endpointOrMethodConfig - ); - - return ( - - ); - } - )} - - )} - -
-
- - {Object.entries(methods).map(([methodName, endpointOrMethodConfig]) => { - const [methodType, endpoint] = resolveEndpointFromMethod( - endpointOrMethodConfig - ); - - const methodPath = `${currentPath}/${methodName}`; - const methodMdPath = `/${basePath}${currentPath}/${methodName}.md`; - - return ( - - - - ); - })} - - {Object.entries(resource.subresources ?? {}).map( - ([subresourceName, subresource]) => { - return ( - - ); - } - )} - - {Object.entries(models).map(([modelName, modelReference]) => { - const schema: OpenAPIV3.SchemaObject | undefined = JSONPointer.get( - openApiSpec, - modelReference.replace("#", "") - ); - - if (!schema) { - return null; - } - - const schemaPath = `${currentPath}/schemas/${modelName}`; - const schemaMdPath = `/${basePath}${currentPath}/schemas/${modelName}.md`; - - return ( - -
- - {schema.description && ( - {schema.description} - )} - - - Attributes - - - - - - {JSON.stringify(schema.example, null, 2)} - - -
-
- ); - })} - - ); -} diff --git a/app/(api-reference)/api-reference-v2/optimized/expandable-response.tsx b/app/(api-reference)/api-reference-v2/optimized/expandable-response.tsx deleted file mode 100644 index b23757ab6..000000000 --- a/app/(api-reference)/api-reference-v2/optimized/expandable-response.tsx +++ /dev/null @@ -1,97 +0,0 @@ -"use client"; - -/** - * Client component for expandable response properties. - * - * This is a "client island" within the server-rendered content. - * It receives only the data it needs for its specific functionality. - */ - -import { useState } from "react"; -import type { OpenAPIV3 } from "@scalar/openapi-types"; -import { AnimatePresence, motion } from "framer-motion"; -import { ChevronDown, ChevronRight } from "lucide-react"; - -interface Props { - schema: OpenAPIV3.SchemaObject; - schemaReferences: Record; -} - -export function ExpandableResponseProperties({ - schema, - schemaReferences, -}: Props) { - const [isExpanded, setIsExpanded] = useState(false); - - const properties = schema.properties || {}; - const required = schema.required || []; - - if (Object.keys(properties).length === 0) { - return null; - } - - return ( -
- - - - {isExpanded && ( - -
-
- {Object.entries(properties).map(([name, prop]) => { - const propSchema = prop as OpenAPIV3.SchemaObject; - const isRequired = required.includes(name); - - let typeDisplay = propSchema.type || "any"; - if (propSchema.type === "array" && propSchema.items) { - const itemType = - (propSchema.items as OpenAPIV3.SchemaObject).type || "any"; - typeDisplay = `${itemType}[]`; - } - - return ( -
-
- - {name} - - - {typeDisplay} - - {isRequired && ( - required - )} -
- {propSchema.description && ( -

- {propSchema.description} -

- )} -
- ); - })} -
-
-
- )} -
-
- ); -} diff --git a/app/(api-reference)/api-reference-v2/optimized/page-shell.tsx b/app/(api-reference)/api-reference-v2/optimized/page-shell.tsx deleted file mode 100644 index c2c3c11d0..000000000 --- a/app/(api-reference)/api-reference-v2/optimized/page-shell.tsx +++ /dev/null @@ -1,143 +0,0 @@ -"use client"; - -/** - * Page shell component that provides the layout structure. - * - * This is a client component because it uses hooks for sidebar state, - * but it receives minimal data - just what's needed for the sidebar. - */ - -import { ReactNode } from "react"; -import { StainlessConfig } from "../../../../lib/openApiSpec"; -import { SidebarSection } from "../../../../data/types"; -import { Page as TelegraphPage } from "../../../../components/ui/Page"; -import { ContentActions } from "../../../../components/ui/ContentActions"; -import Meta from "../../../../components/Meta"; - -interface Props { - children: ReactNode; - name: string; - basePath: string; - stainlessSpec: StainlessConfig; - preSidebarContent?: SidebarSection[]; - resourceOrder: string[]; -} - -/** - * Build sidebar content from stainless spec. - * This is a simplified version that doesn't need the full OpenAPI spec. - */ -function buildSimpleSidebarContent( - stainlessSpec: StainlessConfig, - resourceOrder: string[], - basePath: string, - preSidebarContent?: SidebarSection[] -): SidebarSection[] { - const resourceSections = resourceOrder.map((resourceName) => { - const resource = stainlessSpec.resources[resourceName]; - - const pages: Array<{ title: string; slug: string; pages?: Array<{ title: string; slug: string }> }> = [ - { title: "Overview", slug: "/" }, - ]; - - // Add methods - if (resource.methods) { - Object.entries(resource.methods).forEach(([methodName]) => { - pages.push({ - title: methodName, - slug: `/${methodName}`, - }); - }); - } - - // Add subresources - if (resource.subresources) { - Object.entries(resource.subresources).forEach( - ([subresourceName, subresource]) => { - const subPages: Array<{ title: string; slug: string }> = [ - { title: "Overview", slug: "/" }, - ]; - - if (subresource.methods) { - Object.keys(subresource.methods).forEach((methodName) => { - subPages.push({ - title: methodName, - slug: `/${methodName}`, - }); - }); - } - - pages.push({ - title: subresource.name || subresourceName, - slug: `/${subresourceName}`, - pages: subPages, - }); - } - ); - } - - // Add models/schemas - if (resource.models) { - pages.push({ - title: "Object definitions", - slug: "/schemas", - pages: Object.keys(resource.models).map((modelName) => ({ - title: modelName, - slug: `/${modelName}`, - })), - }); - } - - return { - title: resource.name || resourceName, - slug: `/${basePath}/${resourceName}`, - pages, - }; - }); - - return (preSidebarContent || []).concat(resourceSections); -} - -export function PageShell({ - children, - name, - basePath, - stainlessSpec, - preSidebarContent, - resourceOrder, -}: Props) { - const sidebarContent = buildSimpleSidebarContent( - stainlessSpec, - resourceOrder, - basePath, - preSidebarContent - ); - - return ( - - - - } - /> - - - - - } - /> - {children} - - - - ); -} diff --git a/app/(api-reference)/api-reference-v2/optimized/page.tsx b/app/(api-reference)/api-reference-v2/optimized/page.tsx deleted file mode 100644 index 8eff29261..000000000 --- a/app/(api-reference)/api-reference-v2/optimized/page.tsx +++ /dev/null @@ -1,113 +0,0 @@ -/** - * OPTIMIZED API Reference Page (Full Server Component Approach) - * - * This demonstrates the ideal end-state where the OpenAPI spec - * stays entirely on the server. The spec is processed during - * rendering and only the final HTML is sent to the client. - * - * Benefits: - * - Zero JS payload for static content - * - Spec never serialized to client - * - Can use streaming for faster TTFB - * - Memory-efficient for large specs - * - * Trade-offs: - * - Interactive features need careful client/server splitting - * - More granular component architecture required - */ - -import { Suspense } from "react"; -import fs from "fs/promises"; -import { serialize } from "next-mdx-remote/serialize"; -import remarkGfm from "remark-gfm"; -import rehypeMdxCodeProps from "rehype-mdx-code-props"; - -import { - getOpenApiSpec, - getStainlessSpec, - buildSchemaReferences, - resolveEndpointFromMethod, -} from "../../../../lib/openApiSpec.server"; -import { CONTENT_DIR } from "../../../../lib/content.server"; -import { - RESOURCE_ORDER, - API_REFERENCE_OVERVIEW_CONTENT, -} from "../../../../data/sidebars/apiOverviewSidebar"; - -import { PageShell } from "./page-shell"; -import { ResourceSection } from "./resource-section"; -import { PreContent } from "./pre-content"; - -export const dynamic = "force-static"; -export const revalidate = 3600; - -export const metadata = { - title: "API reference (Optimized) | Knock Docs", - description: "Complete reference documentation for the Knock API.", -}; - -export default async function OptimizedApiReferencePage() { - // Load specs on server - these never leave the server - const [openApiSpec, stainlessSpec] = await Promise.all([ - getOpenApiSpec("api"), - getStainlessSpec("api"), - ]); - - const baseUrl = stainlessSpec.environments.production; - const schemaReferences = await buildSchemaReferences( - "api", - "/api-reference-v2/optimized" - ); - - // Load pre-content MDX - const preContent = await fs.readFile( - `${CONTENT_DIR}/__api-reference/content.mdx`, - "utf-8" - ); - const preContentMdx = await serialize(preContent, { - parseFrontmatter: true, - mdxOptions: { - remarkPlugins: [remarkGfm], - rehypePlugins: [rehypeMdxCodeProps], - }, - }); - - return ( - - {/* Pre-content is client component for MDX interactivity */} - - - {/* Each resource section streams independently */} - {RESOURCE_ORDER.map((resourceName) => ( - } - > - - - ))} - - ); -} - -function ResourceSectionSkeleton({ name }: { name: string }) { - return ( -
-
-
-
- ); -} diff --git a/app/(api-reference)/api-reference-v2/optimized/pre-content.tsx b/app/(api-reference)/api-reference-v2/optimized/pre-content.tsx deleted file mode 100644 index 2e4dbc98e..000000000 --- a/app/(api-reference)/api-reference-v2/optimized/pre-content.tsx +++ /dev/null @@ -1,17 +0,0 @@ -"use client"; - -/** - * Client component for rendering MDX pre-content. - * MDX requires client-side rendering for interactive components. - */ - -import { MDXRemote, MDXRemoteSerializeResult } from "next-mdx-remote"; -import { MDX_COMPONENTS } from "../../../../lib/mdxComponents"; - -interface Props { - mdx: MDXRemoteSerializeResult; -} - -export function PreContent({ mdx }: Props) { - return ; -} diff --git a/app/(api-reference)/api-reference-v2/optimized/resource-section.tsx b/app/(api-reference)/api-reference-v2/optimized/resource-section.tsx deleted file mode 100644 index 0883dfd3a..000000000 --- a/app/(api-reference)/api-reference-v2/optimized/resource-section.tsx +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Server Component for rendering a resource section. - * - * This is a SERVER component - it runs only on the server. - * The OpenAPI spec is processed here and converted to HTML. - * Only the HTML is sent to the client. - * - * For interactive elements (like expandable properties), we use - * client component islands within the server-rendered content. - */ - -import type { OpenAPIV3 } from "@scalar/openapi-types"; -import JSONPointer from "jsonpointer"; - -import { Box } from "@telegraph/layout"; -import { Heading } from "@telegraph/typography"; - -import { StainlessResource } from "../../../../lib/openApiSpec"; -import { resolveEndpointFromMethod } from "../../../../lib/openApiSpec.server"; - -// Static display components (server-safe) -import { StaticSection } from "./static-section"; -import { StaticEndpointList } from "./static-endpoint-list"; -import { StaticMethodContent } from "./static-method-content"; -import { StaticSchemaContent } from "./static-schema-content"; - -interface Props { - resourceName: string; - resource: StainlessResource; - openApiSpec: OpenAPIV3.Document; - baseUrl: string; - schemaReferences: Record; - basePath: string; - parentPath?: string; -} - -export function ResourceSection({ - resourceName, - resource, - openApiSpec, - baseUrl, - schemaReferences, - basePath, - parentPath = "", -}: Props) { - const methods = resource.methods || {}; - const models = resource.models || {}; - const currentPath = parentPath ? `${parentPath}/${resourceName}` : `/${resourceName}`; - - // Build endpoint list for the overview - const endpoints = Object.entries(methods).map( - ([methodName, endpointOrMethodConfig]) => { - const [methodType, endpoint] = resolveEndpointFromMethod( - endpointOrMethodConfig - ); - return { methodName, methodType, endpoint }; - } - ); - - return ( - <> - {/* Resource Overview Section */} - - {endpoints.length > 0 && } - - - {/* Method Sections */} - {Object.entries(methods).map(([methodName, endpointOrMethodConfig]) => { - const [methodType, endpoint] = resolveEndpointFromMethod( - endpointOrMethodConfig - ); - const operation = openApiSpec.paths?.[endpoint]?.[methodType]; - - if (!operation) { - return null; - } - - const methodPath = `${currentPath}/${methodName}`; - - return ( - - ); - })} - - {/* Subresources (recursive) */} - {Object.entries(resource.subresources ?? {}).map( - ([subresourceName, subresource]) => ( - - ) - )} - - {/* Schema/Model Sections */} - {Object.entries(models).map(([modelName, modelReference]) => { - const schema: OpenAPIV3.SchemaObject | undefined = JSONPointer.get( - openApiSpec, - modelReference.replace("#", "") - ); - - if (!schema) { - return null; - } - - const schemaPath = `${currentPath}/schemas/${modelName}`; - - return ( - - ); - })} - - ); -} diff --git a/app/(api-reference)/api-reference-v2/optimized/static-endpoint-list.tsx b/app/(api-reference)/api-reference-v2/optimized/static-endpoint-list.tsx deleted file mode 100644 index 45823f181..000000000 --- a/app/(api-reference)/api-reference-v2/optimized/static-endpoint-list.tsx +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Static endpoint list - Server Component - * - * Renders a list of API endpoints without client-side interactivity. - */ - -import { Box, Stack } from "@telegraph/layout"; -import { Text, Code } from "@telegraph/typography"; - -interface Endpoint { - methodName: string; - methodType: string; - endpoint: string; -} - -interface Props { - endpoints: Endpoint[]; -} - -const METHOD_COLORS: Record = { - get: "text-green-600 bg-green-50", - post: "text-blue-600 bg-blue-50", - put: "text-orange-600 bg-orange-50", - delete: "text-red-600 bg-red-50", - patch: "text-purple-600 bg-purple-50", -}; - -export function StaticEndpointList({ endpoints }: Props) { - return ( - - {endpoints.map(({ methodName, methodType, endpoint }, index) => ( -
0 ? "border-t border-gray-200" : "" - }`} - > - - {methodType.toUpperCase()} - - - {endpoint} - -
- ))} -
- ); -} diff --git a/app/(api-reference)/api-reference-v2/optimized/static-method-content.tsx b/app/(api-reference)/api-reference-v2/optimized/static-method-content.tsx deleted file mode 100644 index eaf997d63..000000000 --- a/app/(api-reference)/api-reference-v2/optimized/static-method-content.tsx +++ /dev/null @@ -1,292 +0,0 @@ -/** - * Static method content - Server Component with Client Islands - * - * This renders method documentation. Most content is static HTML - * rendered on the server. Interactive parts use client component islands. - */ - -import type { OpenAPIV3 } from "@scalar/openapi-types"; -import { Box } from "@telegraph/layout"; -import { Heading, Text, Code } from "@telegraph/typography"; - -// Client component for interactive response expansion -import { ExpandableResponseProperties } from "./expandable-response"; - -interface Props { - methodName: string; - methodType: "get" | "post" | "put" | "delete"; - endpoint: string; - operation: OpenAPIV3.OperationObject; - path: string; - baseUrl: string; - schemaReferences: Record; -} - -const METHOD_COLORS: Record = { - get: "text-green-600", - post: "text-blue-600", - put: "text-orange-600", - delete: "text-red-600", -}; - -export function StaticMethodContent({ - methodName, - methodType, - endpoint, - operation, - path, - baseUrl, - schemaReferences, -}: Props) { - const parameters = (operation.parameters || []) as OpenAPIV3.ParameterObject[]; - const pathParameters = parameters.filter((p) => p.in === "path"); - const queryParameters = parameters.filter((p) => p.in === "query"); - - const requestBody: OpenAPIV3.SchemaObject | undefined = - operation.requestBody?.content?.["application/json"]?.schema; - - const responseSchemas = Object.values(operation.responses || {}) - .map((r) => r.content?.["application/json"]?.schema) - .filter((r): r is OpenAPIV3.SchemaObject => !!r) - .map((schema) => (schema.allOf ? schema.allOf[0] : schema)); - - const rateLimit = operation["x-ratelimit-tier"]; - const isBeta = operation["x-beta"]; - - return ( - -
- {/* Content column */} -
- {/* Title */} - - {operation.summary} - - - {/* Description */} - {operation.description && ( - - {operation.description} - - )} - - {/* Beta badge */} - {isBeta && ( -
- - 🚧 This endpoint is currently in beta. - -
- )} - - {/* Endpoint */} - - Endpoint - -
- - {methodType.toUpperCase()} - - {endpoint} -
- - {/* Rate limit */} - {rateLimit && ( -
- - Rate limit - - - Tier: {rateLimit} - -
- )} - - {/* Path parameters */} - {pathParameters.length > 0 && ( -
- - Path parameters - - -
- )} - - {/* Query parameters */} - {queryParameters.length > 0 && ( -
- - Query parameters - - -
- )} - - {/* Request body */} - {requestBody && ( -
- - Request body - - -
- )} - - {/* Response */} -
- - Returns - - {responseSchemas.length > 0 ? ( - responseSchemas.map((schema, i) => ( -
-
- {schema.title && ( - - {schema.title} - - )} -
- {schema.description && ( - - {schema.description} - - )} - {/* Client component for expandable properties */} - {schema.properties && ( - - )} -
- )) - ) : ( - - No content - - )} -
-
- - {/* Example column */} -
- {/* Request example */} -
-
- {operation.summary} (example) -
-
-              
-                {`curl -X ${methodType.toUpperCase()} ${baseUrl}${endpoint} \\
-  -H "Authorization: Bearer sk_test_12345" \\
-  -H "Content-Type: application/json"`}
-                {requestBody?.example &&
-                  ` \\
-  -d '${JSON.stringify(requestBody.example, null, 2)}'`}
-              
-            
-
- - {/* Response example */} - {responseSchemas[0]?.example && ( -
-
- Response -
-
-                {JSON.stringify(responseSchemas[0].example, null, 2)}
-              
-
- )} -
-
-
- ); -} - -/** - * Static parameter list - renders parameter documentation - */ -function ParameterList({ - parameters, -}: { - parameters: OpenAPIV3.ParameterObject[]; -}) { - return ( -
- {parameters.map((param) => ( -
-
- {param.name} - {param.schema?.type && ( - {param.schema.type} - )} - {param.required && ( - required - )} -
- {param.description && ( - - {param.description} - - )} -
- ))} -
- ); -} - -/** - * Static schema property list - renders schema properties - */ -function SchemaPropertyList({ - schema, - schemaReferences, -}: { - schema: OpenAPIV3.SchemaObject; - schemaReferences: Record; -}) { - const properties = schema.properties || {}; - const required = schema.required || []; - - return ( -
- {Object.entries(properties).map(([name, prop]) => { - const propSchema = prop as OpenAPIV3.SchemaObject; - const isRequired = required.includes(name); - - return ( -
-
- {name} - {propSchema.type && ( - {propSchema.type} - )} - {isRequired && ( - required - )} -
- {propSchema.description && ( - - {propSchema.description} - - )} -
- ); - })} -
- ); -} diff --git a/app/(api-reference)/api-reference-v2/optimized/static-schema-content.tsx b/app/(api-reference)/api-reference-v2/optimized/static-schema-content.tsx deleted file mode 100644 index b9ef8b80a..000000000 --- a/app/(api-reference)/api-reference-v2/optimized/static-schema-content.tsx +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Static schema content - Server Component - * - * Renders schema/model documentation entirely on the server. - */ - -import type { OpenAPIV3 } from "@scalar/openapi-types"; -import { Box } from "@telegraph/layout"; -import { Heading, Text } from "@telegraph/typography"; - -interface Props { - modelName: string; - schema: OpenAPIV3.SchemaObject; - path: string; - schemaReferences: Record; -} - -export function StaticSchemaContent({ - modelName, - schema, - path, - schemaReferences, -}: Props) { - const properties = schema.properties || {}; - const required = schema.required || []; - - return ( - -
- {/* Content column */} -
- - {schema.title || modelName} - - - {schema.description && ( - - {schema.description} - - )} - - - Attributes - - -
- {Object.entries(properties).map(([name, prop]) => { - const propSchema = prop as OpenAPIV3.SchemaObject; - const isRequired = required.includes(name); - - // Get type display string - let typeDisplay = propSchema.type || "any"; - if (propSchema.type === "array" && propSchema.items) { - const itemType = (propSchema.items as OpenAPIV3.SchemaObject).type || "any"; - typeDisplay = `${itemType}[]`; - } - if (propSchema.enum) { - typeDisplay = "enum"; - } - - return ( -
-
- {name} - {typeDisplay} - {isRequired && ( - - required - - )} - {propSchema.nullable && ( - nullable - )} -
- {propSchema.description && ( - - {propSchema.description} - - )} - {propSchema.enum && ( -
- - Values:{" "} - {propSchema.enum.map((v) => ( - - {String(v)} - - ))} - -
- )} -
- ); - })} -
-
- - {/* Example column */} -
- {schema.example && ( -
-
- {schema.title || modelName} -
-
-                {JSON.stringify(schema.example, null, 2)}
-              
-
- )} -
-
-
- ); -} diff --git a/app/(api-reference)/api-reference-v2/optimized/static-section.tsx b/app/(api-reference)/api-reference-v2/optimized/static-section.tsx deleted file mode 100644 index 7c5bb844c..000000000 --- a/app/(api-reference)/api-reference-v2/optimized/static-section.tsx +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Static section wrapper - Server Component - * - * This renders the section structure without client-side state. - * It's designed to be used in server components. - */ - -import { ReactNode } from "react"; -import { Box, Stack } from "@telegraph/layout"; -import { Heading, Text } from "@telegraph/typography"; - -interface Props { - title?: string; - path: string; - description?: string; - children?: ReactNode; -} - -export function StaticSection({ title, path, description, children }: Props) { - return ( - -
- {/* Content column */} -
- {title && ( - - {title} - - )} - {description && ( - - {description} - - )} -
- - {/* Example column */} - {children && ( -
{children}
- )} -
-
- ); -} diff --git a/app/(api-reference)/api-reference-v2/page.tsx b/app/(api-reference)/api-reference-v2/page.tsx deleted file mode 100644 index f45a73db6..000000000 --- a/app/(api-reference)/api-reference-v2/page.tsx +++ /dev/null @@ -1,103 +0,0 @@ -/** - * API Reference Overview Page (Server Component) - * - * This page loads the OpenAPI spec on the server and renders the full - * API reference. The spec data never leaves the server - only the - * rendered HTML is sent to the client. - * - * Key differences from Pages Router: - * - No getStaticProps - data loading happens in the component - * - Spec is cached via React cache() - deduplicated across renders - * - Client components are imported for interactive features - */ - -import { Suspense } from "react"; -import fs from "fs/promises"; -import { serialize } from "next-mdx-remote/serialize"; -import remarkGfm from "remark-gfm"; -import rehypeMdxCodeProps from "rehype-mdx-code-props"; - -import { - getOpenApiSpec, - getStainlessSpec, - buildSchemaReferences, -} from "../../../lib/openApiSpec.server"; -import { CONTENT_DIR } from "../../../lib/content.server"; -import { - RESOURCE_ORDER, - API_REFERENCE_OVERVIEW_CONTENT, -} from "../../../data/sidebars/apiOverviewSidebar"; - -import { ApiReferencePageClient } from "./api-reference-client"; - -// Generate static page at build time -export const dynamic = "force-static"; - -// Revalidate every hour (optional - for ISR) -export const revalidate = 3600; - -export const metadata = { - title: "API reference | Knock Docs", - description: "Complete reference documentation for the Knock API.", -}; - -async function getPreContentMdx() { - const preContent = await fs.readFile( - `${CONTENT_DIR}/__api-reference/content.mdx`, - "utf-8" - ); - - return serialize(preContent, { - parseFrontmatter: true, - mdxOptions: { - remarkPlugins: [remarkGfm], - rehypePlugins: [rehypeMdxCodeProps], - }, - }); -} - -export default async function ApiReferencePage() { - // These calls are cached - subsequent calls return the same promise - const [openApiSpec, stainlessSpec, preContentMdx, schemaReferences] = - await Promise.all([ - getOpenApiSpec("api"), - getStainlessSpec("api"), - getPreContentMdx(), - buildSchemaReferences("api", "/api-reference-v2"), - ]); - - const baseUrl = stainlessSpec.environments.production; - - return ( - }> - - - ); -} - -function ApiReferenceLoadingSkeleton() { - return ( -
-
-
-
-
-
- {[...Array(5)].map((_, i) => ( -
- ))} -
-
-
- ); -} diff --git a/app/(api-reference)/layout.tsx b/app/(api-reference)/layout.tsx deleted file mode 100644 index 54fee6400..000000000 --- a/app/(api-reference)/layout.tsx +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Shared layout for API reference pages. - * - * This layout wraps both /api-reference and /mapi-reference pages. - * It provides the common page structure while allowing each route - * to load its own spec data. - */ - -import { Inter } from "next/font/google"; - -// Import global styles - these are shared with the Pages Router -import "@algolia/autocomplete-theme-classic"; -import "../../styles/index.css"; -import "../../styles/global.css"; -import "../../styles/responsive.css"; - -import { Providers } from "./providers"; - -const inter = Inter({ subsets: ["latin"], display: "swap" }); - -export default function ApiReferenceLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( - - - {children} - - - ); -} diff --git a/app/(api-reference)/providers.tsx b/app/(api-reference)/providers.tsx deleted file mode 100644 index 848411aa1..000000000 --- a/app/(api-reference)/providers.tsx +++ /dev/null @@ -1,55 +0,0 @@ -"use client"; - -/** - * Client-side providers for App Router API reference pages. - * - * These wrap all API reference pages with necessary context providers - * that require client-side state (event emitter, AI chat, etc.) - */ - -import { - EventEmitterContext, - useEventEmitterInstance, -} from "@byteclaw/use-event-emitter"; -import { usePathname } from "next/navigation"; -import { useEffect } from "react"; - -import { InkeepModalProvider } from "../../components/AiChatButton"; -import { AskAiProvider } from "../../components/AskAiContext"; -import AskAiSidebar from "../../components/AskAiSidebar"; -import * as analytics from "../../lib/analytics"; -import { initAttribution } from "../../lib/attribution"; -import { setClearbitPath } from "../../lib/clearbit"; -import * as gtag from "../../lib/gtag"; - -export function Providers({ children }: { children: React.ReactNode }) { - const eventEmitter = useEventEmitterInstance(); - const pathname = usePathname(); - - // Initialize attribution tracking on mount - useEffect(() => { - initAttribution(); - }, []); - - // Track page views on route change - useEffect(() => { - gtag.pageview(pathname as unknown as URL); - setClearbitPath(pathname as unknown as URL); - analytics.page(); - initAttribution(); - }, [pathname]); - - return ( - - -
- - {children} - - {analytics.SEGMENT_WRITE_KEY && } -
- -
-
- ); -} diff --git a/lib/openApiSpec.server.ts b/lib/openApiSpec.server.ts deleted file mode 100644 index ca470afff..000000000 --- a/lib/openApiSpec.server.ts +++ /dev/null @@ -1,275 +0,0 @@ -/** - * Server-side OpenAPI spec loading with React cache() for App Router. - * - * This module provides cached access to the OpenAPI and Stainless specs. - * The specs are loaded once per request/build and cached in memory. - * - * Key benefits over the Pages Router approach: - * 1. Specs never leave the server - not serialized to page props - * 2. React cache() dedupes requests within the same render - * 3. Each page can extract only what it needs - */ - -import { cache } from "react"; -import { dereference } from "@scalar/openapi-parser"; -import deepmerge from "deepmerge"; -import { readFile } from "fs/promises"; -import safeStringify from "safe-stringify"; -import { parse } from "yaml"; -import type { OpenAPIV3 } from "@scalar/openapi-types"; -import JSONPointer from "jsonpointer"; - -// Re-export types from the original module -export type { - StainlessResource, - StainlessConfig, -} from "./openApiSpec"; - -import type { StainlessConfig, StainlessResource } from "./openApiSpec"; - -type SpecName = "api" | "mapi"; - -/** - * Cached loader for the full dereferenced OpenAPI spec. - * Uses React's cache() to dedupe within a single render pass. - */ -export const getOpenApiSpec = cache( - async (specName: SpecName): Promise => { - const spec = await readFile( - `./data/specs/${specName}/openapi.yml`, - "utf8" - ); - const jsonSpec = parse(spec); - const { schema } = await dereference(jsonSpec); - - // Use safe-stringify to handle circular references - return JSON.parse(safeStringify(schema)); - } -); - -/** - * Cached loader for the Stainless config with customizations merged. - */ -export const getStainlessSpec = cache( - async (specName: SpecName): Promise => { - const [specFile, customizationsFile] = await Promise.all([ - readFile(`./data/specs/${specName}/stainless.yml`, "utf8"), - readFile(`./data/specs/${specName}/customizations.yml`, "utf8"), - ]); - - const stainlessSpec = parse(specFile); - const customizations = parse(customizationsFile); - - return deepmerge(stainlessSpec, customizations); - } -); - -/** - * Get the base URL for API requests from the Stainless config. - */ -export async function getBaseUrl(specName: SpecName): Promise { - const stainlessSpec = await getStainlessSpec(specName); - return stainlessSpec.environments.production; -} - -/** - * Get a specific resource from the Stainless config. - */ -export async function getResource( - specName: SpecName, - resourceName: string -): Promise { - const stainlessSpec = await getStainlessSpec(specName); - return stainlessSpec.resources[resourceName]; -} - -/** - * Get all resource names in order for a spec. - */ -export async function getResourceOrder(specName: SpecName): Promise { - const stainlessSpec = await getStainlessSpec(specName); - return Object.keys(stainlessSpec.resources); -} - -/** - * Resolve an endpoint reference from a Stainless method config. - */ -export function resolveEndpointFromMethod( - endpointOrMethodConfig: string | { endpoint: string } -): [string, string] { - const endpointReference = - typeof endpointOrMethodConfig === "string" - ? endpointOrMethodConfig - : endpointOrMethodConfig.endpoint; - - const [methodType, endpoint] = endpointReference.split(" "); - return [methodType, endpoint]; -} - -/** - * Get the OpenAPI operation object for a specific method. - */ -export async function getMethodOperation( - specName: SpecName, - resourceName: string, - methodName: string -): Promise<{ - operation: OpenAPIV3.OperationObject; - methodType: string; - endpoint: string; -} | null> { - const [openApiSpec, stainlessSpec] = await Promise.all([ - getOpenApiSpec(specName), - getStainlessSpec(specName), - ]); - - const resource = stainlessSpec.resources[resourceName]; - if (!resource?.methods?.[methodName]) { - return null; - } - - const methodConfig = resource.methods[methodName]; - const [methodType, endpoint] = resolveEndpointFromMethod(methodConfig); - const operation = openApiSpec.paths?.[endpoint]?.[methodType]; - - if (!operation) { - return null; - } - - return { operation, methodType, endpoint }; -} - -/** - * Get a schema by reference from the OpenAPI spec. - */ -export async function getSchemaByRef( - specName: SpecName, - schemaRef: string -): Promise { - const openApiSpec = await getOpenApiSpec(specName); - return JSONPointer.get(openApiSpec, schemaRef.replace("#", "")); -} - -/** - * Build schema references for linking between schema types. - * This maps schema titles to their URL paths. - */ -export async function buildSchemaReferences( - specName: SpecName, - basePath: string -): Promise> { - const [openApiSpec, stainlessSpec] = await Promise.all([ - getOpenApiSpec(specName), - getStainlessSpec(specName), - ]); - - const schemaReferences: Record = {}; - - function processResource( - resource: StainlessResource, - resourcePath: string - ) { - if (resource.models) { - Object.entries(resource.models).forEach(([modelName, modelRef]) => { - const schema: OpenAPIV3.SchemaObject | undefined = JSONPointer.get( - openApiSpec, - modelRef.replace("#", "") - ); - - const title = schema?.title ?? modelName; - - if (schema) { - schemaReferences[title] = `${resourcePath}/schemas/${modelName}`; - schemaReferences[`${title}[]`] = `${resourcePath}/schemas/${modelName}`; - } - }); - } - - if (resource.subresources) { - Object.entries(resource.subresources).forEach( - ([subresourceName, subresource]) => { - processResource(subresource, `${resourcePath}/${subresourceName}`); - } - ); - } - } - - Object.entries(stainlessSpec.resources).forEach(([resourceName, resource]) => { - processResource(resource, `${basePath}/${resourceName}`); - }); - - return schemaReferences; -} - -/** - * Generate all static params for API reference pages. - * Used with generateStaticParams in App Router. - */ -export async function generateAllApiReferencePaths( - specName: SpecName -): Promise< - Array<{ - type: "resource" | "method" | "schema"; - resource: string; - slug?: string[]; - }> -> { - const stainlessSpec = await getStainlessSpec(specName); - const paths: Array<{ - type: "resource" | "method" | "schema"; - resource: string; - slug?: string[]; - }> = []; - - function processResource( - resource: StainlessResource, - resourceName: string, - parentPath: string[] = [] - ) { - const currentPath = [...parentPath, resourceName]; - - // Resource overview page - paths.push({ - type: "resource", - resource: resourceName, - slug: parentPath.length > 0 ? currentPath : undefined, - }); - - // Method pages - if (resource.methods) { - Object.keys(resource.methods).forEach((methodName) => { - paths.push({ - type: "method", - resource: resourceName, - slug: [...currentPath, methodName], - }); - }); - } - - // Schema pages - if (resource.models) { - Object.keys(resource.models).forEach((modelName) => { - paths.push({ - type: "schema", - resource: resourceName, - slug: [...currentPath, "schemas", modelName], - }); - }); - } - - // Subresources - if (resource.subresources) { - Object.entries(resource.subresources).forEach( - ([subresourceName, subresource]) => { - processResource(subresource, subresourceName, currentPath); - } - ); - } - } - - Object.entries(stainlessSpec.resources).forEach(([resourceName, resource]) => { - processResource(resource, resourceName); - }); - - return paths; -} From 80ff02c42bc14e370421901f6f6618f9d57b71f9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 31 Jan 2026 10:43:10 +0000 Subject: [PATCH 03/24] Fix formatting in migration plan document Co-authored-by: chris --- .cursor/api-reference-migration-plan.md | 181 +++++++++++++++--------- 1 file changed, 112 insertions(+), 69 deletions(-) diff --git a/.cursor/api-reference-migration-plan.md b/.cursor/api-reference-migration-plan.md index cdd141296..a07786d6c 100644 --- a/.cursor/api-reference-migration-plan.md +++ b/.cursor/api-reference-migration-plan.md @@ -54,12 +54,12 @@ The App Router with React Server Components solves these problems by: ### Expected outcomes -| Metric | Current | After migration | -|--------|---------|-----------------| -| HTML payload size | 2-5MB | ~50-100KB | +| Metric | Current | After migration | +| -------------------- | --------- | ---------------------- | +| HTML payload size | 2-5MB | ~50-100KB | | JavaScript hydration | Full spec | Minimal (islands only) | -| Build memory | High | Reduced (cached) | -| Time to Interactive | Slow | Fast | +| Build memory | High | Reduced (cached) | +| Time to Interactive | Slow | Fast | --- @@ -160,19 +160,19 @@ lib/ ### Server vs Client component split -| Component | Type | Reason | -|-----------|------|--------| -| `page.tsx` | Server | Loads spec, renders static content | -| `resource-section.tsx` | Server | Renders resource overview | -| `method-content.tsx` | Server | Renders endpoint documentation | -| `schema-content.tsx` | Server | Renders schema/model documentation | -| `parameter-list.tsx` | Server | Renders parameter tables | -| `property-list.tsx` | Server | Renders property tables | -| `endpoint-list.tsx` | Server | Renders endpoint summary list | -| `expandable-properties.tsx` | Client | Toggle expand/collapse state | -| `code-example-tabs.tsx` | Client | Language selection state | -| `sidebar-nav.tsx` | Client | Active state, scroll tracking | -| `providers.tsx` | Client | Analytics, AI chat context | +| Component | Type | Reason | +| --------------------------- | ------ | ---------------------------------- | +| `page.tsx` | Server | Loads spec, renders static content | +| `resource-section.tsx` | Server | Renders resource overview | +| `method-content.tsx` | Server | Renders endpoint documentation | +| `schema-content.tsx` | Server | Renders schema/model documentation | +| `parameter-list.tsx` | Server | Renders parameter tables | +| `property-list.tsx` | Server | Renders property tables | +| `endpoint-list.tsx` | Server | Renders endpoint summary list | +| `expandable-properties.tsx` | Client | Toggle expand/collapse state | +| `code-example-tabs.tsx` | Client | Language selection state | +| `sidebar-nav.tsx` | Client | Active state, scroll tracking | +| `providers.tsx` | Client | Analytics, AI chat context | --- @@ -183,6 +183,7 @@ lib/ Set up the core infrastructure for the App Router migration. **Deliverables:** + - Cached OpenAPI spec loader - Shared layout and providers - Basic routing structure @@ -193,6 +194,7 @@ Set up the core infrastructure for the App Router migration. Create the core Server Components that render API documentation. **Deliverables:** + - Resource section component - Method content component - Schema content component @@ -204,6 +206,7 @@ Create the core Server Components that render API documentation. Extract interactive features into minimal Client Components. **Deliverables:** + - Expandable response properties - Code example language tabs - Sidebar navigation with active state @@ -213,6 +216,7 @@ Extract interactive features into minimal Client Components. Wire up the pages and implement static generation. **Deliverables:** + - Overview page - Resource pages - Method pages @@ -224,6 +228,7 @@ Wire up the pages and implement static generation. Ensure feature parity with the current implementation. **Deliverables:** + - Mobile sidebar - Hash navigation - Scroll behavior @@ -236,6 +241,7 @@ Ensure feature parity with the current implementation. Comprehensive testing and gradual rollout. **Deliverables:** + - Visual regression testing - Performance benchmarking - Staged rollout via feature flag or redirect @@ -268,13 +274,10 @@ type SpecName = "api" | "mapi"; */ export const getOpenApiSpec = cache( async (specName: SpecName): Promise => { - const spec = await readFile( - `./data/specs/${specName}/openapi.yml`, - "utf8" - ); + const spec = await readFile(`./data/specs/${specName}/openapi.yml`, "utf8"); const { schema } = await dereference(parse(spec)); return JSON.parse(safeStringify(schema)); - } + }, ); /** @@ -287,11 +290,12 @@ export const getStainlessSpec = cache( readFile(`./data/specs/${specName}/customizations.yml`, "utf8"), ]); return deepmerge(parse(specFile), parse(customizationsFile)); - } + }, ); ``` **Acceptance criteria:** + - [ ] Specs are loaded once per build/request - [ ] Subsequent calls return cached data - [ ] TypeScript types are properly exported @@ -344,6 +348,7 @@ export interface ResourceData { ``` **Acceptance criteria:** + - [ ] All types match existing data structures - [ ] Types are exported for use in components @@ -354,13 +359,13 @@ Create `lib/openapi/helpers.ts`: ```typescript import type { OpenAPIV3 } from "@scalar/openapi-types"; import JSONPointer from "jsonpointer"; -import type { - StainlessMethodConfig, - StainlessResource, +import type { + StainlessMethodConfig, + StainlessResource, StainlessConfig, MethodData, SchemaData, - SpecName + SpecName, } from "./types"; import { getOpenApiSpec, getStainlessSpec } from "./loader"; @@ -368,7 +373,7 @@ import { getOpenApiSpec, getStainlessSpec } from "./loader"; * Resolve endpoint from Stainless method config. */ export function resolveEndpoint( - config: StainlessMethodConfig + config: StainlessMethodConfig, ): [string, string] { const endpoint = typeof config === "string" ? config : config.endpoint; const [method, path] = endpoint.split(" "); @@ -381,7 +386,7 @@ export function resolveEndpoint( export function getOperation( spec: OpenAPIV3.Document, methodType: string, - endpoint: string + endpoint: string, ): OpenAPIV3.OperationObject | undefined { return spec.paths?.[endpoint]?.[methodType]; } @@ -391,7 +396,7 @@ export function getOperation( */ export function getSchemaByRef( spec: OpenAPIV3.Document, - ref: string + ref: string, ): OpenAPIV3.SchemaObject | undefined { return JSONPointer.get(spec, ref.replace("#", "")); } @@ -401,7 +406,7 @@ export function getSchemaByRef( */ export async function buildSchemaReferences( specName: SpecName, - basePath: string + basePath: string, ): Promise> { const [spec, stainless] = await Promise.all([ getOpenApiSpec(specName), @@ -438,7 +443,7 @@ export async function buildSchemaReferences( */ export function getResourceMethods( spec: OpenAPIV3.Document, - resource: StainlessResource + resource: StainlessResource, ): MethodData[] { if (!resource.methods) return []; @@ -457,7 +462,7 @@ export function getResourceMethods( */ export function getResourceSchemas( spec: OpenAPIV3.Document, - resource: StainlessResource + resource: StainlessResource, ): SchemaData[] { if (!resource.models) return []; @@ -472,6 +477,7 @@ export function getResourceSchemas( ``` **Acceptance criteria:** + - [ ] All helper functions are pure and testable - [ ] Functions work with cached spec data - [ ] Type safety is maintained @@ -507,6 +513,7 @@ export default function ApiReferenceLayout({ ``` **Acceptance criteria:** + - [ ] Layout loads global styles - [ ] Font is configured - [ ] Providers wrap children @@ -564,6 +571,7 @@ export function Providers({ children }: { children: React.ReactNode }) { ``` **Acceptance criteria:** + - [ ] Analytics tracking works on navigation - [ ] AI chat sidebar is available - [ ] Event emitter context is provided @@ -579,11 +587,13 @@ Create `app/(api-reference)/api-reference/components/resource-section.tsx`: This is a Server Component that renders a resource overview section. **Props:** + - `specName: SpecName` - Which spec to use - `resourceName: string` - Resource identifier - `basePath: string` - URL base path for links **Responsibilities:** + - Load resource data from cached spec - Render resource title and description - Render endpoint summary list @@ -592,6 +602,7 @@ This is a Server Component that renders a resource overview section. - Recursively render subresources **Acceptance criteria:** + - [ ] No `"use client"` directive - [ ] Uses only server-safe APIs - [ ] Passes data to child components, not context @@ -603,11 +614,13 @@ Create `app/(api-reference)/api-reference/components/method-content.tsx`: This is a Server Component that renders a single API method. **Props:** + - `method: MethodData` - Method information - `baseUrl: string` - API base URL for examples - `schemaReferences: Record` - Schema link map **Sections to render:** + 1. Title (operation.summary) 2. Description (operation.description) 3. Beta badge if applicable @@ -620,6 +633,7 @@ This is a Server Component that renders a single API method. 10. Code examples with language tabs (Client Component island) **Acceptance criteria:** + - [ ] All static content rendered on server - [ ] Interactive elements use Client Component islands - [ ] Proper heading hierarchy (h2, h3) @@ -632,16 +646,19 @@ Create `app/(api-reference)/api-reference/components/schema-content.tsx`: This is a Server Component that renders a schema/model definition. **Props:** + - `schema: SchemaData` - Schema information - `schemaReferences: Record` - Schema link map **Sections to render:** + 1. Title (schema.title) 2. Description 3. Attributes table with all properties 4. Example JSON **Acceptance criteria:** + - [ ] Properties rendered with type information - [ ] Required fields marked - [ ] Enum values displayed @@ -654,10 +671,12 @@ Create `app/(api-reference)/api-reference/components/parameter-list.tsx`: Server Component for rendering parameter tables. **Props:** + - `parameters: OpenAPIV3.ParameterObject[]` - `schemaReferences: Record` **Acceptance criteria:** + - [ ] Name, type, required status shown - [ ] Description rendered as markdown - [ ] Default values shown if present @@ -670,11 +689,13 @@ Create `app/(api-reference)/api-reference/components/property-list.tsx`: Server Component for rendering schema properties. **Props:** + - `schema: OpenAPIV3.SchemaObject` - `schemaReferences: Record` - `showRequired?: boolean` **Acceptance criteria:** + - [ ] Nested properties handled - [ ] Array types shown correctly - [ ] References linked to schema pages @@ -687,9 +708,11 @@ Create `app/(api-reference)/api-reference/components/endpoint-list.tsx`: Server Component for the endpoint summary in resource overviews. **Props:** + - `methods: MethodData[]` **Acceptance criteria:** + - [ ] Method badge with color coding - [ ] Path displayed in monospace - [ ] Links to method sections @@ -758,6 +781,7 @@ export function ExpandableProperties({ ``` **Acceptance criteria:** + - [ ] Smooth expand/collapse animation - [ ] Accessible button with aria-expanded - [ ] Children rendered only when expanded (optional optimization) @@ -825,6 +849,7 @@ export function CodeExampleTabs({ examples, title }: Props) { ``` **Acceptance criteria:** + - [ ] Language tabs switch content - [ ] Active tab visually indicated - [ ] Code syntax highlighted @@ -835,15 +860,18 @@ export function CodeExampleTabs({ examples, title }: Props) { Create `app/(api-reference)/api-reference/components/sidebar-nav.tsx`: This is a Client Component that handles: + - Active section tracking based on scroll position - Collapse/expand for nested items - Hash navigation on click **Props:** + - `sections: SidebarSection[]` - Sidebar structure - `basePath: string` - Base URL path **Acceptance criteria:** + - [ ] Active section highlighted on scroll - [ ] Nested sections collapsible - [ ] Smooth scroll to section on click @@ -887,12 +915,9 @@ export default async function ApiReferencePage() { return ( - + {RESOURCE_ORDER.map((resourceName) => ( - } - > + }> 90% - [ ] LCP improved - [ ] TTI improved @@ -1077,11 +1116,13 @@ Measure and compare performance metrics. Roll out to a percentage of traffic first. **Options:** + 1. Feature flag with percentage rollout 2. Subdomain preview (e.g., api-preview.docs.knock.app) 3. URL-based opt-in (e.g., ?new-api-reference=true) **Acceptance criteria:** + - [ ] Rollout mechanism in place - [ ] Monitoring dashboards set up - [ ] Quick rollback capability @@ -1091,6 +1132,7 @@ Roll out to a percentage of traffic first. Once validated, remove the Pages Router implementation. **Acceptance criteria:** + - [ ] Old pages removed - [ ] Redirects in place if needed - [ ] No dead code remaining @@ -1103,37 +1145,37 @@ Once validated, remove the Pages Router implementation. These components can be reused with minimal changes: -| Component | Path | Changes needed | -|-----------|------|----------------| -| `CodeBlock` | `components/ui/CodeBlock.tsx` | None | -| `Callout` | `components/ui/Callout.tsx` | None | -| `RateLimit` | `components/ui/RateLimit.tsx` | None | -| `Endpoints` | `components/ui/Endpoints.tsx` | None | -| `PageHeader` | `components/ui/PageHeader.tsx` | None | +| Component | Path | Changes needed | +| ------------ | ------------------------------ | -------------- | +| `CodeBlock` | `components/ui/CodeBlock.tsx` | None | +| `Callout` | `components/ui/Callout.tsx` | None | +| `RateLimit` | `components/ui/RateLimit.tsx` | None | +| `Endpoints` | `components/ui/Endpoints.tsx` | None | +| `PageHeader` | `components/ui/PageHeader.tsx` | None | ### Existing components to refactor These components need Server/Client splitting: -| Component | Current | After | -|-----------|---------|-------| -| `ApiReferenceSection` | Client | Server + Client islands | -| `ApiReferenceMethod` | Client | Server + Client islands | -| `SchemaProperties` | Client | Server + Client island for expandable | -| `MultiLangExample` | Client | Client (language state) | -| `Sidebar` | Client | Server for structure, Client for state | +| Component | Current | After | +| --------------------- | ------- | -------------------------------------- | +| `ApiReferenceSection` | Client | Server + Client islands | +| `ApiReferenceMethod` | Client | Server + Client islands | +| `SchemaProperties` | Client | Server + Client island for expandable | +| `MultiLangExample` | Client | Client (language state) | +| `Sidebar` | Client | Server for structure, Client for state | ### New components to create -| Component | Type | Purpose | -|-----------|------|---------| -| `PageShell` | Client | Layout with sidebar state | -| `ResourceSection` | Server | Resource overview rendering | -| `MethodContent` | Server | Method documentation | -| `SchemaContent` | Server | Schema documentation | -| `ExpandableProperties` | Client | Expand/collapse UI | -| `CodeExampleTabs` | Client | Language selection | -| `SidebarNav` | Client | Navigation with active state | +| Component | Type | Purpose | +| ---------------------- | ------ | ---------------------------- | +| `PageShell` | Client | Layout with sidebar state | +| `ResourceSection` | Server | Resource overview rendering | +| `MethodContent` | Server | Method documentation | +| `SchemaContent` | Server | Schema documentation | +| `ExpandableProperties` | Client | Expand/collapse UI | +| `CodeExampleTabs` | Client | Language selection | +| `SidebarNav` | Client | Navigation with active state | --- @@ -1261,11 +1303,11 @@ No data migration is involved, so no data rollback is needed. ### Why App Router over other solutions? -| Alternative | Pros | Cons | Decision | -|-------------|------|------|----------| -| Pre-split JSON files | Minimal changes | Still requires client fetch | Rejected | -| API routes for data | Keeps Pages Router | Adds complexity, latency | Rejected | -| App Router | Best performance, future-proof | Migration effort | **Selected** | +| Alternative | Pros | Cons | Decision | +| -------------------- | ------------------------------ | --------------------------- | ------------ | +| Pre-split JSON files | Minimal changes | Still requires client fetch | Rejected | +| API routes for data | Keeps Pages Router | Adds complexity, latency | Rejected | +| App Router | Best performance, future-proof | Migration effort | **Selected** | ### Why not split the spec files? @@ -1274,6 +1316,7 @@ With Server Components, the spec stays on the server. Splitting would add comple ### Why client component islands? Some features require client-side state: + - Expand/collapse toggle - Language selection for code examples - Scroll position tracking From 92a42cfb14b992cf306d27bb6f1394d4d671155b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 1 Feb 2026 05:34:12 +0000 Subject: [PATCH 04/24] Update migration plan: Multi-page with ISR approach Replace App Router/RSC approach with Pages Router compatible solution: - Split API reference into one page per resource (12 pages) - Use ISR for incremental updates without full rebuilds - Reduce page payload from 2-5MB to 50-150KB per resource - Keep Telegraph components working as-is Key changes in the plan: - Detailed page structure with [resource].tsx dynamic route - Data loading strategy with partial spec extraction - Component refactoring to accept props instead of context - Navigation and UX considerations - Step-by-step migration instructions - Testing checklist and rollout strategy Co-authored-by: chris --- .cursor/api-reference-migration-plan.md | 1658 ++++++++--------------- 1 file changed, 574 insertions(+), 1084 deletions(-) diff --git a/.cursor/api-reference-migration-plan.md b/.cursor/api-reference-migration-plan.md index a07786d6c..7366317da 100644 --- a/.cursor/api-reference-migration-plan.md +++ b/.cursor/api-reference-migration-plan.md @@ -1,18 +1,18 @@ -# API reference App Router migration plan +# API reference migration plan: Multi-page with ISR -This document provides a comprehensive plan for migrating the API reference pages from the Pages Router to the App Router with Server Components. The goal is to eliminate build and runtime issues caused by loading the entire OpenAPI spec into page props. +This document outlines the plan for migrating the API reference from a single large page to multiple smaller pages using Incremental Static Regeneration (ISR). ## Table of contents 1. [Problem statement](#problem-statement) 2. [Solution overview](#solution-overview) -3. [Architecture design](#architecture-design) -4. [Migration phases](#migration-phases) -5. [Detailed implementation tasks](#detailed-implementation-tasks) -6. [Component breakdown](#component-breakdown) -7. [Testing strategy](#testing-strategy) -8. [Rollback plan](#rollback-plan) -9. [Success criteria](#success-criteria) +3. [Page structure options](#page-structure-options) +4. [Recommended architecture](#recommended-architecture) +5. [Implementation plan](#implementation-plan) +6. [Data loading strategy](#data-loading-strategy) +7. [Navigation and UX](#navigation-and-ux) +8. [Migration steps](#migration-steps) +9. [Testing and rollout](#testing-and-rollout) --- @@ -20,1314 +20,804 @@ This document provides a comprehensive plan for migrating the API reference page ### Current issues -The current API reference implementation has several architectural problems: +1. **Massive page payload.** The entire dereferenced OpenAPI spec (~2-5MB) is embedded in the HTML via `getStaticProps`. -1. **Massive page payload.** The entire dereferenced OpenAPI spec (~600KB for API, ~500KB for MAPI as raw YAML) is passed through `getStaticProps`. After dereferencing and JSON serialization, this can expand to 2-5MB embedded in the HTML. +2. **Slow hydration.** React must hydrate the full spec on page load, blocking interactivity. -2. **Build-time memory pressure.** The `@scalar/openapi-parser` dereference operation resolves all `$ref` pointers inline, which can consume significant memory for specs with circular references. +3. **Build memory pressure.** Dereferencing large specs with circular references consumes significant memory. -3. **Long hydration times.** React must hydrate the massive props object on page load, blocking interactivity. +4. **Single page for all content.** URL rewrites serve the same static HTML for all `/api-reference/*` paths. -4. **Poor Core Web Vitals.** Large HTML payloads negatively impact First Contentful Paint (FCP) and Largest Contentful Paint (LCP). +### Constraints -5. **Single-page architecture limitations.** The current approach uses URL rewrites to serve a single static page for all API reference paths, meaning every path loads the full spec. - -### Root cause - -The fundamental issue is that `getStaticProps` serializes all returned data into the page HTML. The OpenAPI spec is large, and passing it as props means every page visitor downloads the entire spec even if they only view one endpoint. +- **Telegraph components do not support RSC.** App Router with Server Components would require rewriting the styling layer. +- **Must work with Pages Router.** The solution must be compatible with the current Next.js Pages Router architecture. --- ## Solution overview -### App Router with Server Components - -The App Router with React Server Components solves these problems by: - -1. **Server-only data.** Server Components can load and process data that never leaves the server. The OpenAPI spec is processed on the server and only the rendered HTML is sent to the client. - -2. **React `cache()` for deduplication.** The spec is loaded once per request/build and cached in memory, shared across all components that need it. +Split the API reference into multiple smaller static pages, each containing only the data needed for that specific resource. Use ISR to keep pages fresh without full rebuilds. -3. **Streaming and Suspense.** Individual sections can stream to the client as they're ready, improving Time to First Byte (TTFB). +### Key benefits -4. **Client component islands.** Only interactive elements (expandable sections, code example toggles) require client-side JavaScript. - -### Expected outcomes - -| Metric | Current | After migration | -| -------------------- | --------- | ---------------------- | -| HTML payload size | 2-5MB | ~50-100KB | -| JavaScript hydration | Full spec | Minimal (islands only) | -| Build memory | High | Reduced (cached) | -| Time to Interactive | Slow | Fast | +| Aspect | Current | After migration | +| -------------- | ---------------------- | ---------------------------- | +| HTML per page | 2-5MB (full spec) | 50-150KB (single resource) | +| Build strategy | Single page, full spec | Multiple pages, partial spec | +| Updates | Full rebuild | ISR per page | +| Hydration | Full spec | Resource data only | --- -## Architecture design +## Page structure options -### Directory structure - -``` -app/ -├── (api-reference)/ # Route group (no URL segment) -│ ├── layout.tsx # Shared layout with providers -│ ├── providers.tsx # Client-side providers -│ │ -│ ├── api-reference/ # /api-reference routes -│ │ ├── page.tsx # Overview page (Server Component) -│ │ ├── loading.tsx # Loading skeleton -│ │ ├── [resource]/ # Dynamic resource routes -│ │ │ ├── page.tsx # Resource overview -│ │ │ ├── [method]/ # Dynamic method routes -│ │ │ │ └── page.tsx # Method detail page -│ │ │ └── schemas/ -│ │ │ └── [schema]/ -│ │ │ └── page.tsx # Schema detail page -│ │ └── components/ # API reference specific components -│ │ ├── resource-section.tsx # Server Component -│ │ ├── method-content.tsx # Server Component -│ │ ├── schema-content.tsx # Server Component -│ │ ├── endpoint-list.tsx # Server Component -│ │ ├── parameter-list.tsx # Server Component -│ │ ├── property-list.tsx # Server Component -│ │ ├── expandable-properties.tsx # Client Component (island) -│ │ ├── code-example-tabs.tsx # Client Component (island) -│ │ └── sidebar-nav.tsx # Client Component (island) -│ │ -│ └── mapi-reference/ # /mapi-reference routes -│ └── ... (same structure as api-reference) -│ -lib/ -├── openapi/ # OpenAPI utilities -│ ├── loader.ts # Cached spec loading -│ ├── types.ts # TypeScript types -│ ├── helpers.ts # Utility functions -│ └── sidebar.ts # Sidebar generation -``` +### Option A: One page per resource -### Data flow +Each resource (users, workflows, etc.) gets its own page containing the resource overview, all methods, and all schemas. ``` -┌─────────────────────────────────────────────────────────────────────┐ -│ BUILD / REQUEST │ -├─────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌──────────────────┐ ┌──────────────────┐ │ -│ │ openapi.yml │───▶│ loader.ts │ │ -│ │ stainless.yml │ │ (React cache) │ │ -│ └──────────────────┘ └────────┬─────────┘ │ -│ │ │ -│ Parsed & cached in memory │ -│ │ │ -│ ▼ │ -│ ┌──────────────────────────────────────────────────────────────┐ │ -│ │ Server Components │ │ -│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ -│ │ │ page.tsx │ │ resource- │ │ method- │ │ │ -│ │ │ (overview) │ │ section.tsx │ │ content.tsx │ │ │ -│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ -│ │ │ │ │ │ │ -│ │ │ Extract relevant slice │ │ │ -│ │ ▼ ▼ ▼ │ │ -│ │ ┌─────────────────────────────────────────────────────┐ │ │ -│ │ │ Rendered HTML │ │ │ -│ │ └─────────────────────────────────────────────────────┘ │ │ -│ └──────────────────────────────────────────────────────────────┘ │ -│ │ │ -└───────────────────────────────────┼──────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ CLIENT │ -├─────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────────────────────────────────────────────────────┐ │ -│ │ Static HTML │ │ -│ │ (No OpenAPI spec data - just rendered content) │ │ -│ └─────────────────────────────────────────────────────────────┘ │ -│ + │ -│ ┌──────────────────────────────────────────────────────────────┐ │ -│ │ Client Component Islands │ │ -│ │ ┌───────────────────┐ ┌───────────────────┐ │ │ -│ │ │ expandable- │ │ code-example- │ │ │ -│ │ │ properties.tsx │ │ tabs.tsx │ │ │ -│ │ │ (toggle state) │ │ (language state) │ │ │ -│ │ └───────────────────┘ └───────────────────┘ │ │ -│ └──────────────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────┘ +pages/api-reference/ +├── index.tsx # Overview + intro content +├── [resource].tsx # Dynamic route for each resource ``` -### Server vs Client component split - -| Component | Type | Reason | -| --------------------------- | ------ | ---------------------------------- | -| `page.tsx` | Server | Loads spec, renders static content | -| `resource-section.tsx` | Server | Renders resource overview | -| `method-content.tsx` | Server | Renders endpoint documentation | -| `schema-content.tsx` | Server | Renders schema/model documentation | -| `parameter-list.tsx` | Server | Renders parameter tables | -| `property-list.tsx` | Server | Renders property tables | -| `endpoint-list.tsx` | Server | Renders endpoint summary list | -| `expandable-properties.tsx` | Client | Toggle expand/collapse state | -| `code-example-tabs.tsx` | Client | Language selection state | -| `sidebar-nav.tsx` | Client | Active state, scroll tracking | -| `providers.tsx` | Client | Analytics, AI chat context | - ---- - -## Migration phases - -### Phase 1: Foundation (estimated: 1-2 days) - -Set up the core infrastructure for the App Router migration. - -**Deliverables:** - -- Cached OpenAPI spec loader -- Shared layout and providers -- Basic routing structure -- Type definitions +**Pros:** -### Phase 2: Server components (estimated: 2-3 days) +- Simple routing (12 resource pages + overview) +- Each page is self-contained +- Maintains scroll experience within a resource +- Easier to implement -Create the core Server Components that render API documentation. +**Cons:** -**Deliverables:** +- Resource pages can still be moderately large (100-300KB for resources with many methods) +- All methods load even if user only wants one -- Resource section component -- Method content component -- Schema content component -- Parameter and property list components -- Endpoint list component +### Option B: One page per method -### Phase 3: Client component islands (estimated: 1-2 days) +Every method gets its own dedicated page. -Extract interactive features into minimal Client Components. - -**Deliverables:** - -- Expandable response properties -- Code example language tabs -- Sidebar navigation with active state - -### Phase 4: Page assembly (estimated: 1-2 days) +``` +pages/api-reference/ +├── index.tsx # Overview +├── [resource]/ +│ ├── index.tsx # Resource overview +│ ├── [method].tsx # Individual method +│ └── schemas/ +│ └── [schema].tsx # Individual schema +``` -Wire up the pages and implement static generation. +**Pros:** -**Deliverables:** +- Smallest possible pages (~20-50KB each) +- Maximum granularity for caching +- Fast navigation to specific endpoints -- Overview page -- Resource pages -- Method pages -- Schema pages -- `generateStaticParams` for all routes +**Cons:** -### Phase 5: Polish and parity (estimated: 2-3 days) +- Many more pages to generate (100+ pages) +- Loses the scroll-through experience +- More complex navigation +- Users need more clicks to browse -Ensure feature parity with the current implementation. +### Option C: Hybrid - Resource pages with linked schemas -**Deliverables:** +Resource pages contain the overview and all methods. Schemas are separate pages linked from method responses. -- Mobile sidebar -- Hash navigation -- Scroll behavior -- Breadcrumbs -- Content actions (copy link, ask AI) -- SEO metadata +``` +pages/api-reference/ +├── index.tsx # Overview +├── [resource]/ +│ ├── index.tsx # Resource overview + all methods +│ └── schemas/ +│ └── [schema].tsx # Individual schema +``` -### Phase 6: Testing and rollout (estimated: 2-3 days) +**Pros:** -Comprehensive testing and gradual rollout. +- Balanced page sizes +- Methods stay together (common browsing pattern) +- Schemas separately addressable -**Deliverables:** +**Cons:** -- Visual regression testing -- Performance benchmarking -- Staged rollout via feature flag or redirect -- Monitoring and rollback capability +- More complex than Option A +- Schema pages add navigation complexity --- -## Detailed implementation tasks +## Recommended architecture -### Phase 1: Foundation +**Option A: One page per resource** is recommended for the initial migration. -#### Task 1.1: Create cached OpenAPI loader +### Rationale -Create `lib/openapi/loader.ts`: - -```typescript -import { cache } from "react"; -import { dereference } from "@scalar/openapi-parser"; -import { readFile } from "fs/promises"; -import { parse } from "yaml"; -import safeStringify from "safe-stringify"; -import deepmerge from "deepmerge"; -import type { OpenAPIV3 } from "@scalar/openapi-types"; +1. **Simplest implementation.** Fewest routing changes, minimal new pages. +2. **Good payload reduction.** 2-5MB → 50-150KB per page (90%+ reduction). +3. **Preserves scroll experience.** Users can scroll through all methods in a resource. +4. **Easy to enhance later.** Can split further into per-method pages if needed. -type SpecName = "api" | "mapi"; +### Page structure -/** - * Cached loader for the full dereferenced OpenAPI spec. - * Uses React's cache() to dedupe within a single render pass. - */ -export const getOpenApiSpec = cache( - async (specName: SpecName): Promise => { - const spec = await readFile(`./data/specs/${specName}/openapi.yml`, "utf8"); - const { schema } = await dereference(parse(spec)); - return JSON.parse(safeStringify(schema)); - }, -); - -/** - * Cached loader for the Stainless config with customizations. - */ -export const getStainlessSpec = cache( - async (specName: SpecName): Promise => { - const [specFile, customizationsFile] = await Promise.all([ - readFile(`./data/specs/${specName}/stainless.yml`, "utf8"), - readFile(`./data/specs/${specName}/customizations.yml`, "utf8"), - ]); - return deepmerge(parse(specFile), parse(customizationsFile)); - }, -); +``` +pages/ +├── api-reference/ +│ ├── index.tsx # Overview page (intro, auth, errors, etc.) +│ └── [resource].tsx # Resource page (overview + methods + schemas) +│ +├── mapi-reference/ +│ ├── index.tsx # Management API overview +│ └── [resource].tsx # Management API resources ``` -**Acceptance criteria:** - -- [ ] Specs are loaded once per build/request -- [ ] Subsequent calls return cached data -- [ ] TypeScript types are properly exported - -#### Task 1.2: Create type definitions - -Create `lib/openapi/types.ts`: - -```typescript -import type { OpenAPIV3 } from "@scalar/openapi-types"; - -export type SpecName = "api" | "mapi"; - -export type StainlessMethodConfig = - | string - | { type: "http"; endpoint: string; positional_params?: string[] }; +### URL structure -export interface StainlessResource { - name?: string; - description?: string; - models?: Record; - methods?: Record; - subresources?: Record; -} +| URL | Content | +| -------------------------- | ----------------------------------------------- | +| `/api-reference` | Overview, authentication, errors, etc. | +| `/api-reference/users` | Users resource with all methods and schemas | +| `/api-reference/workflows` | Workflows resource with all methods and schemas | +| `/api-reference/messages` | Messages resource with all methods and schemas | +| ... | ... | -export interface StainlessConfig { - resources: Record; - environments: Record; -} +### Handling subresources -export interface MethodData { - methodName: string; - methodType: "get" | "post" | "put" | "delete" | "patch"; - endpoint: string; - operation: OpenAPIV3.OperationObject; -} +Resources with subresources (like `users` with `feeds`, `guides`) will include the subresource content on the parent resource page: -export interface SchemaData { - modelName: string; - schema: OpenAPIV3.SchemaObject; -} - -export interface ResourceData { - resourceName: string; - resource: StainlessResource; - methods: MethodData[]; - schemas: SchemaData[]; - subresources: ResourceData[]; -} +``` +/api-reference/users +├── Users overview +├── Users methods (get, list, update, delete, merge, etc.) +├── Users > Feeds methods +├── Users > Guides methods +└── Users schemas ``` -**Acceptance criteria:** - -- [ ] All types match existing data structures -- [ ] Types are exported for use in components +This keeps related content together and avoids deep nesting. -#### Task 1.3: Create helper functions +--- -Create `lib/openapi/helpers.ts`: +## Implementation plan -```typescript -import type { OpenAPIV3 } from "@scalar/openapi-types"; -import JSONPointer from "jsonpointer"; -import type { - StainlessMethodConfig, - StainlessResource, - StainlessConfig, - MethodData, - SchemaData, - SpecName, -} from "./types"; -import { getOpenApiSpec, getStainlessSpec } from "./loader"; +### Phase 1: Data loading refactor -/** - * Resolve endpoint from Stainless method config. - */ -export function resolveEndpoint( - config: StainlessMethodConfig, -): [string, string] { - const endpoint = typeof config === "string" ? config : config.endpoint; - const [method, path] = endpoint.split(" "); - return [method.toLowerCase(), path]; -} +Create utilities to load partial spec data for a single resource. -/** - * Get operation from OpenAPI spec for a given endpoint. - */ -export function getOperation( - spec: OpenAPIV3.Document, - methodType: string, - endpoint: string, -): OpenAPIV3.OperationObject | undefined { - return spec.paths?.[endpoint]?.[methodType]; -} +#### Task 1.1: Create resource-specific loader -/** - * Get schema by $ref from OpenAPI spec. - */ -export function getSchemaByRef( - spec: OpenAPIV3.Document, - ref: string, -): OpenAPIV3.SchemaObject | undefined { - return JSONPointer.get(spec, ref.replace("#", "")); -} +```typescript +// lib/openApiSpec.ts - add new functions /** - * Build schema references map for cross-linking. + * Load only the data needed for a specific resource page. + * This avoids loading the full spec into page props. */ -export async function buildSchemaReferences( - specName: SpecName, - basePath: string, -): Promise> { - const [spec, stainless] = await Promise.all([ - getOpenApiSpec(specName), - getStainlessSpec(specName), +export async function getResourcePageData( + specName: "api" | "mapi", + resourceName: string, +): Promise { + const [openApiSpec, stainlessSpec] = await Promise.all([ + readOpenApiSpec(specName), + readStainlessSpec(specName), ]); - const references: Record = {}; - - function processResource(resource: StainlessResource, path: string) { - if (resource.models) { - Object.entries(resource.models).forEach(([modelName, ref]) => { - const schema = getSchemaByRef(spec, ref); - const title = schema?.title ?? modelName; - references[title] = `${path}/schemas/${modelName}`; - references[`${title}[]`] = `${path}/schemas/${modelName}`; - }); - } - if (resource.subresources) { - Object.entries(resource.subresources).forEach(([name, sub]) => { - processResource(sub, `${path}/${name}`); - }); - } + const resource = stainlessSpec.resources[resourceName]; + if (!resource) { + return null; } - Object.entries(stainless.resources).forEach(([name, resource]) => { - processResource(resource, `${basePath}/${name}`); - }); + // Extract only the paths/schemas needed for this resource + const methods = extractMethodsForResource(openApiSpec, resource); + const schemas = extractSchemasForResource(openApiSpec, resource); + const subresources = extractSubresources(openApiSpec, resource); - return references; + return { + resourceName, + resource, + methods, + schemas, + subresources, + baseUrl: stainlessSpec.environments.production, + }; } /** - * Get all methods for a resource. + * Extract only the OpenAPI operations needed for a resource's methods. */ -export function getResourceMethods( +function extractMethodsForResource( spec: OpenAPIV3.Document, resource: StainlessResource, ): MethodData[] { if (!resource.methods) return []; - return Object.entries(resource.methods) - .map(([methodName, config]) => { - const [methodType, endpoint] = resolveEndpoint(config); - const operation = getOperation(spec, methodType, endpoint); - if (!operation) return null; - return { methodName, methodType, endpoint, operation } as MethodData; - }) - .filter((m): m is MethodData => m !== null); + return Object.entries(resource.methods).map(([methodName, config]) => { + const [methodType, endpoint] = resolveEndpoint(config); + const operation = spec.paths?.[endpoint]?.[methodType]; + + return { + methodName, + methodType, + endpoint, + operation, + }; + }); } /** - * Get all schemas for a resource. + * Extract only the schemas referenced by a resource. */ -export function getResourceSchemas( +function extractSchemasForResource( spec: OpenAPIV3.Document, resource: StainlessResource, ): SchemaData[] { if (!resource.models) return []; - return Object.entries(resource.models) - .map(([modelName, ref]) => { - const schema = getSchemaByRef(spec, ref); - if (!schema) return null; - return { modelName, schema }; - }) - .filter((s): s is SchemaData => s !== null); + return Object.entries(resource.models).map(([modelName, ref]) => { + const schema = JSONPointer.get(spec, ref.replace("#", "")); + return { modelName, schema }; + }); } ``` -**Acceptance criteria:** - -- [ ] All helper functions are pure and testable -- [ ] Functions work with cached spec data -- [ ] Type safety is maintained - -#### Task 1.4: Create shared layout - -Create `app/(api-reference)/layout.tsx`: +#### Task 1.2: Create sidebar data loader ```typescript -import { Inter } from "next/font/google"; -import { Providers } from "./providers"; - -import "@algolia/autocomplete-theme-classic"; -import "../../styles/index.css"; -import "../../styles/global.css"; -import "../../styles/responsive.css"; - -const inter = Inter({ subsets: ["latin"], display: "swap" }); +/** + * Load just the sidebar structure without full spec data. + * Used for navigation on all pages. + */ +export async function getSidebarData( + specName: "api" | "mapi", +): Promise { + const stainlessSpec = await readStainlessSpec(specName); -export default function ApiReferenceLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( - - - {children} - - - ); + return { + resources: Object.entries(stainlessSpec.resources).map( + ([name, resource]) => ({ + name, + title: resource.name || name, + methodCount: Object.keys(resource.methods || {}).length, + hasSubresources: !!resource.subresources, + }), + ), + }; } ``` -**Acceptance criteria:** - -- [ ] Layout loads global styles -- [ ] Font is configured -- [ ] Providers wrap children - -#### Task 1.5: Create client providers +### Phase 2: Create new pages -Create `app/(api-reference)/providers.tsx`: +#### Task 2.1: Create overview page ```typescript -"use client"; +// pages/api-reference/index.tsx -import { useEffect } from "react"; -import { usePathname } from "next/navigation"; -import { - EventEmitterContext, - useEventEmitterInstance, -} from "@byteclaw/use-event-emitter"; -import { InkeepModalProvider } from "../../components/AiChatButton"; -import { AskAiProvider } from "../../components/AskAiContext"; -import AskAiSidebar from "../../components/AskAiSidebar"; -import * as analytics from "../../lib/analytics"; -import { initAttribution } from "../../lib/attribution"; -import { setClearbitPath } from "../../lib/clearbit"; -import * as gtag from "../../lib/gtag"; - -export function Providers({ children }: { children: React.ReactNode }) { - const eventEmitter = useEventEmitterInstance(); - const pathname = usePathname(); - - useEffect(() => { - initAttribution(); - }, []); - - useEffect(() => { - gtag.pageview(pathname as unknown as URL); - setClearbitPath(pathname as unknown as URL); - analytics.page(); - initAttribution(); - }, [pathname]); +import { GetStaticProps } from "next"; +import { getSidebarData } from "@/lib/openApiSpec"; +import { serialize } from "next-mdx-remote/serialize"; +import fs from "fs"; +export default function ApiReferenceOverview({ sidebarData, overviewContent }) { return ( - - -
- - {children} - - {analytics.SEGMENT_WRITE_KEY && } -
- -
-
+ + + + + ); } -``` - -**Acceptance criteria:** - -- [ ] Analytics tracking works on navigation -- [ ] AI chat sidebar is available -- [ ] Event emitter context is provided - ---- - -### Phase 2: Server components - -#### Task 2.1: Create resource section component - -Create `app/(api-reference)/api-reference/components/resource-section.tsx`: - -This is a Server Component that renders a resource overview section. - -**Props:** - -- `specName: SpecName` - Which spec to use -- `resourceName: string` - Resource identifier -- `basePath: string` - URL base path for links - -**Responsibilities:** - -- Load resource data from cached spec -- Render resource title and description -- Render endpoint summary list -- Render each method section -- Render each schema section -- Recursively render subresources - -**Acceptance criteria:** - -- [ ] No `"use client"` directive -- [ ] Uses only server-safe APIs -- [ ] Passes data to child components, not context - -#### Task 2.2: Create method content component -Create `app/(api-reference)/api-reference/components/method-content.tsx`: +export const getStaticProps: GetStaticProps = async () => { + const sidebarData = await getSidebarData("api"); -This is a Server Component that renders a single API method. - -**Props:** - -- `method: MethodData` - Method information -- `baseUrl: string` - API base URL for examples -- `schemaReferences: Record` - Schema link map - -**Sections to render:** - -1. Title (operation.summary) -2. Description (operation.description) -3. Beta badge if applicable -4. Endpoint display (method + path) -5. Rate limit info if applicable -6. Path parameters table -7. Query parameters table -8. Request body properties -9. Response information with expandable properties (Client Component island) -10. Code examples with language tabs (Client Component island) - -**Acceptance criteria:** - -- [ ] All static content rendered on server -- [ ] Interactive elements use Client Component islands -- [ ] Proper heading hierarchy (h2, h3) -- [ ] Accessible markup - -#### Task 2.3: Create schema content component - -Create `app/(api-reference)/api-reference/components/schema-content.tsx`: - -This is a Server Component that renders a schema/model definition. - -**Props:** - -- `schema: SchemaData` - Schema information -- `schemaReferences: Record` - Schema link map - -**Sections to render:** - -1. Title (schema.title) -2. Description -3. Attributes table with all properties -4. Example JSON - -**Acceptance criteria:** - -- [ ] Properties rendered with type information -- [ ] Required fields marked -- [ ] Enum values displayed -- [ ] Example formatted with syntax highlighting - -#### Task 2.4: Create parameter list component - -Create `app/(api-reference)/api-reference/components/parameter-list.tsx`: - -Server Component for rendering parameter tables. - -**Props:** - -- `parameters: OpenAPIV3.ParameterObject[]` -- `schemaReferences: Record` - -**Acceptance criteria:** - -- [ ] Name, type, required status shown -- [ ] Description rendered as markdown -- [ ] Default values shown if present -- [ ] Enum values listed - -#### Task 2.5: Create property list component - -Create `app/(api-reference)/api-reference/components/property-list.tsx`: + const overviewMdx = fs.readFileSync( + `${CONTENT_DIR}/__api-reference/content.mdx`, + "utf-8", + ); + const overviewContent = await serialize(overviewMdx, { + parseFrontmatter: true, + mdxOptions: { + remarkPlugins: [remarkGfm], + rehypePlugins: [rehypeMdxCodeProps], + }, + }); -Server Component for rendering schema properties. + return { + props: { + sidebarData, + overviewContent, + }, + revalidate: 3600, // ISR: revalidate every hour + }; +}; +``` -**Props:** +#### Task 2.2: Create dynamic resource page -- `schema: OpenAPIV3.SchemaObject` -- `schemaReferences: Record` -- `showRequired?: boolean` +```typescript +// pages/api-reference/[resource].tsx -**Acceptance criteria:** +import { GetStaticPaths, GetStaticProps } from "next"; +import { + getResourcePageData, + getSidebarData, + getResourceOrder, +} from "@/lib/openApiSpec"; + +export default function ResourcePage({ + sidebarData, + resourceData, + schemaReferences, +}) { + const { resourceName, resource, methods, schemas, subresources, baseUrl } = + resourceData; -- [ ] Nested properties handled -- [ ] Array types shown correctly -- [ ] References linked to schema pages -- [ ] Nullable indicated + return ( + + {/* Resource Overview */} + + + {/* Methods */} + {methods.map((method) => ( + + ))} -#### Task 2.6: Create endpoint list component + {/* Subresources */} + {subresources.map((subresource) => ( + + ))} -Create `app/(api-reference)/api-reference/components/endpoint-list.tsx`: + {/* Schemas */} + {schemas.length > 0 && ( + + )} + + ); +} -Server Component for the endpoint summary in resource overviews. +export const getStaticPaths: GetStaticPaths = async () => { + const resourceOrder = await getResourceOrder("api"); -**Props:** + return { + paths: resourceOrder.map((resource) => ({ + params: { resource }, + })), + fallback: false, // 404 for unknown resources + }; +}; -- `methods: MethodData[]` +export const getStaticProps: GetStaticProps = async ({ params }) => { + const resourceName = params.resource as string; -**Acceptance criteria:** + const [sidebarData, resourceData] = await Promise.all([ + getSidebarData("api"), + getResourcePageData("api", resourceName), + ]); -- [ ] Method badge with color coding -- [ ] Path displayed in monospace -- [ ] Links to method sections + if (!resourceData) { + return { notFound: true }; + } ---- + // Build schema references for this resource (for linking) + const schemaReferences = buildSchemaReferencesForResource( + resourceData, + `/api-reference/${resourceName}`, + ); -### Phase 3: Client component islands + return { + props: { + sidebarData, + resourceData, + schemaReferences, + }, + revalidate: 3600, // ISR: revalidate every hour + }; +}; +``` -#### Task 3.1: Create expandable properties component +### Phase 3: Create shared layout -Create `app/(api-reference)/api-reference/components/expandable-properties.tsx`: +#### Task 3.1: Create ApiReferenceLayout component ```typescript -"use client"; - -import { useState } from "react"; -import { AnimatePresence, motion } from "framer-motion"; -import { ChevronDown, ChevronRight } from "lucide-react"; +// components/ApiReferenceLayout.tsx interface Props { - title: string; children: React.ReactNode; - defaultExpanded?: boolean; + sidebarData: SidebarData; + currentResource?: string; } -export function ExpandableProperties({ - title, +export function ApiReferenceLayout({ children, - defaultExpanded = false, + sidebarData, + currentResource, }: Props) { - const [isExpanded, setIsExpanded] = useState(defaultExpanded); - return ( -
- - - - {isExpanded && ( - -
- {children} -
-
- )} -
-
+ + + + } + /> + + + + {children} + + + ); } -``` -**Acceptance criteria:** +function buildSidebarContent( + sidebarData: SidebarData, + currentResource?: string, +): SidebarSection[] { + return [ + // Overview section + { + title: "API reference", + slug: "/api-reference", + pages: [ + { slug: "/", title: "Overview" }, + { slug: "/authentication", title: "Authentication" }, + { slug: "/errors", title: "Errors" }, + // ... other overview pages + ], + }, + // Resource sections + ...sidebarData.resources.map((resource) => ({ + title: resource.title, + slug: `/api-reference/${resource.name}`, + isActive: resource.name === currentResource, + pages: [], // Methods shown on the resource page, not in sidebar + })), + ]; +} +``` -- [ ] Smooth expand/collapse animation -- [ ] Accessible button with aria-expanded -- [ ] Children rendered only when expanded (optional optimization) +### Phase 4: Component refactoring -#### Task 3.2: Create code example tabs component +#### Task 4.1: Refactor ResourceSection -Create `app/(api-reference)/api-reference/components/code-example-tabs.tsx`: +Update to work with pre-extracted data instead of pulling from context. ```typescript -"use client"; - -import { useState } from "react"; +// components/ResourceOverview.tsx interface Props { - examples: Record; - title?: string; + resource: StainlessResource; + methods: MethodData[]; } -const LANGUAGE_LABELS: Record = { - curl: "cURL", - javascript: "JavaScript", - typescript: "TypeScript", - python: "Python", - ruby: "Ruby", - go: "Go", - java: "Java", - csharp: "C#", - php: "PHP", -}; - -export function CodeExampleTabs({ examples, title }: Props) { - const languages = Object.keys(examples); - const [activeLanguage, setActiveLanguage] = useState(languages[0]); - - if (languages.length === 0) return null; - +export function ResourceOverview({ resource, methods }: Props) { return ( -
- {title && ( -
- {title} -
- )} -
- {languages.map((lang) => ( - - ))} -
-
-        {examples[activeLanguage]}
-      
-
+
+ + {resource.description && {resource.description}} + + + + {methods.map((method) => ( + + ))} + + +
); } ``` -**Acceptance criteria:** - -- [ ] Language tabs switch content -- [ ] Active tab visually indicated -- [ ] Code syntax highlighted -- [ ] Horizontal scroll for long lines - -#### Task 3.3: Create sidebar navigation component - -Create `app/(api-reference)/api-reference/components/sidebar-nav.tsx`: - -This is a Client Component that handles: - -- Active section tracking based on scroll position -- Collapse/expand for nested items -- Hash navigation on click - -**Props:** - -- `sections: SidebarSection[]` - Sidebar structure -- `basePath: string` - Base URL path - -**Acceptance criteria:** - -- [ ] Active section highlighted on scroll -- [ ] Nested sections collapsible -- [ ] Smooth scroll to section on click -- [ ] URL hash updated on navigation -- [ ] Works with same-page navigation - ---- - -### Phase 4: Page assembly - -#### Task 4.1: Create overview page - -Create `app/(api-reference)/api-reference/page.tsx`: +#### Task 4.2: Refactor MethodSection ```typescript -import { Suspense } from "react"; -import { getOpenApiSpec, getStainlessSpec } from "@/lib/openapi/loader"; -import { buildSchemaReferences } from "@/lib/openapi/helpers"; -import { RESOURCE_ORDER } from "@/data/sidebars/apiOverviewSidebar"; -import { PageShell } from "./components/page-shell"; -import { ResourceSection } from "./components/resource-section"; -import { PreContent } from "./components/pre-content"; - -export const dynamic = "force-static"; -export const revalidate = 3600; - -export const metadata = { - title: "API reference | Knock Docs", - description: "Complete reference documentation for the Knock API.", -}; +// components/MethodSection.tsx -export default async function ApiReferencePage() { - const [spec, stainless] = await Promise.all([ - getOpenApiSpec("api"), - getStainlessSpec("api"), - ]); +interface Props { + method: MethodData; + baseUrl: string; + schemaReferences: Record; +} - const schemaReferences = await buildSchemaReferences("api", "/api-reference"); - const baseUrl = stainless.environments.production; +export function MethodSection({ method, baseUrl, schemaReferences }: Props) { + const { methodName, methodType, endpoint, operation } = method; + if (!operation) return null; + + // Component now receives data directly instead of from context return ( - - - - {RESOURCE_ORDER.map((resourceName) => ( - }> - - - ))} - +
+ {/* ... existing method content ... */} +
); } ``` -**Acceptance criteria:** - -- [ ] Page renders all resources -- [ ] Each resource section can suspend independently -- [ ] Metadata set correctly -- [ ] Static generation enabled +--- -#### Task 4.2: Create resource page with generateStaticParams +## Data loading strategy -Create `app/(api-reference)/api-reference/[resource]/page.tsx`: +### What changes -```typescript -import { getStainlessSpec } from "@/lib/openapi/loader"; -import { RESOURCE_ORDER } from "@/data/sidebars/apiOverviewSidebar"; -import { ResourceSection } from "../components/resource-section"; +| Aspect | Current | After migration | +| ----------------- | ----------------------------- | --------------------------------- | +| Spec loading | Full spec in `getStaticProps` | Partial extraction per resource | +| Context | Full spec in React context | No context needed (data in props) | +| Schema references | Built from full spec | Built per-resource | +| Sidebar | Built from full spec | Separate lightweight loader | -export async function generateStaticParams() { - return RESOURCE_ORDER.map((resource) => ({ resource })); -} +### ISR configuration -export async function generateMetadata({ params }) { - const stainless = await getStainlessSpec("api"); - const resource = stainless.resources[params.resource]; +```typescript +export const getStaticProps: GetStaticProps = async ({ params }) => { + // ... load data ... return { - title: `${resource?.name || params.resource} | API reference | Knock Docs`, - description: - resource?.description || `API reference for ${params.resource}`, + props: { + /* ... */ + }, + revalidate: 3600, // Revalidate every hour }; -} - -export default async function ResourcePage({ params }) { - // ... render resource overview -} +}; ``` -**Acceptance criteria:** - -- [ ] All resource paths generated at build -- [ ] Metadata generated per resource -- [ ] 404 for unknown resources +**Why 1 hour?** -#### Task 4.3: Create method page +- API specs don't change frequently +- Allows updates without full redeploy +- Can be adjusted based on update frequency -Create `app/(api-reference)/api-reference/[resource]/[method]/page.tsx`: +**Alternative: On-demand revalidation** ```typescript -export async function generateStaticParams() { - const stainless = await getStainlessSpec("api"); - const params = []; - - Object.entries(stainless.resources).forEach(([resource, data]) => { - if (data.methods) { - Object.keys(data.methods).forEach((method) => { - params.push({ resource, method }); - }); - } - }); +// pages/api/revalidate.ts +export default async function handler(req, res) { + const { secret, path } = req.query; + + if (secret !== process.env.REVALIDATION_SECRET) { + return res.status(401).json({ message: "Invalid secret" }); + } - return params; + await res.revalidate(path); + return res.json({ revalidated: true }); } ``` -**Acceptance criteria:** - -- [ ] All method paths generated at build -- [ ] Renders single method documentation -- [ ] Links to related schemas work - -#### Task 4.4: Create schema page - -Create `app/(api-reference)/api-reference/[resource]/schemas/[schema]/page.tsx`: - -**Acceptance criteria:** - -- [ ] All schema paths generated at build -- [ ] Renders schema with all properties -- [ ] Example JSON displayed +Call this endpoint when specs are updated to immediately refresh pages. --- -### Phase 5: Polish and parity +## Navigation and UX -#### Task 5.1: Implement mobile sidebar +### Sidebar behavior -Ensure the mobile sidebar component works with App Router navigation. +- **Overview pages** listed at top (Authentication, Errors, etc.) +- **Resources** listed below, each links to `/api-reference/[resource]` +- **Active resource** highlighted in sidebar +- **Methods** accessible via in-page anchor links, not sidebar expansion -**Acceptance criteria:** +### In-page navigation -- [ ] Opens/closes correctly -- [ ] Navigation works -- [ ] Closes on route change +Each resource page has: -#### Task 5.2: Implement hash navigation +1. **Resource overview** with endpoint list +2. **Method sections** with anchor IDs (`#trigger`, `#cancel`, etc.) +3. **Schema sections** at the bottom +4. **On-this-page navigation** (optional) for jumping between methods -Ensure clicking sidebar items scrolls to the correct section and updates the URL hash. +### Cross-resource linking -**Acceptance criteria:** +Schema references that link to other resources: -- [ ] Smooth scroll to sections -- [ ] URL hash reflects current section -- [ ] Browser back/forward works -- [ ] Direct links to hashes work - -#### Task 5.3: Implement scroll tracking - -Track scroll position to highlight the active sidebar item. - -**Acceptance criteria:** - -- [ ] Intersection Observer used for performance -- [ ] Active item updated on scroll -- [ ] Works with nested sections - -#### Task 5.4: Implement content actions - -Port the "Copy link" and "Ask AI" buttons. - -**Acceptance criteria:** - -- [ ] Copy link copies correct URL with hash -- [ ] Ask AI opens with current context -- [ ] Buttons positioned correctly - -#### Task 5.5: Port breadcrumbs - -Ensure breadcrumbs work with App Router. - -**Acceptance criteria:** - -- [ ] Correct hierarchy shown -- [ ] Links work -- [ ] Updates on navigation - -#### Task 5.6: SEO and metadata - -Ensure proper SEO for all pages. - -**Acceptance criteria:** - -- [ ] Unique title per page -- [ ] Description for each page -- [ ] Canonical URLs set -- [ ] Open Graph tags present - ---- - -### Phase 6: Testing and rollout - -#### Task 6.1: Visual regression testing - -Compare screenshots of old and new implementations. - -**Acceptance criteria:** - -- [ ] All sections render identically -- [ ] Code blocks styled correctly -- [ ] Tables formatted properly -- [ ] Responsive layouts work - -#### Task 6.2: Performance benchmarking - -Measure and compare performance metrics. - -**Metrics to measure:** - -- HTML payload size -- Time to First Byte (TTFB) -- First Contentful Paint (FCP) -- Largest Contentful Paint (LCP) -- Time to Interactive (TTI) -- Total Blocking Time (TBT) - -**Acceptance criteria:** - -- [ ] HTML payload reduced by >90% -- [ ] LCP improved -- [ ] TTI improved - -#### Task 6.3: Staged rollout - -Roll out to a percentage of traffic first. - -**Options:** - -1. Feature flag with percentage rollout -2. Subdomain preview (e.g., api-preview.docs.knock.app) -3. URL-based opt-in (e.g., ?new-api-reference=true) - -**Acceptance criteria:** - -- [ ] Rollout mechanism in place -- [ ] Monitoring dashboards set up -- [ ] Quick rollback capability +```typescript +// Current: /api-reference/users/schemas/User +// After: /api-reference/users#user-schema -#### Task 6.4: Remove old implementation +// Or if schemas are very large, separate pages: +// /api-reference/users/schemas/user +``` -Once validated, remove the Pages Router implementation. +### URL compatibility -**Acceptance criteria:** +Add redirects for old hash-based URLs: -- [ ] Old pages removed -- [ ] Redirects in place if needed -- [ ] No dead code remaining +```javascript +// next.config.js +async redirects() { + return [ + // Old: /api-reference#users-get + // New: /api-reference/users#get + // Handle via client-side detection if hash-based + ]; +} +``` --- -## Component breakdown - -### Existing components to reuse +## Migration steps -These components can be reused with minimal changes: +### Step 1: Create data loading utilities -| Component | Path | Changes needed | -| ------------ | ------------------------------ | -------------- | -| `CodeBlock` | `components/ui/CodeBlock.tsx` | None | -| `Callout` | `components/ui/Callout.tsx` | None | -| `RateLimit` | `components/ui/RateLimit.tsx` | None | -| `Endpoints` | `components/ui/Endpoints.tsx` | None | -| `PageHeader` | `components/ui/PageHeader.tsx` | None | +1. Add `getResourcePageData()` function +2. Add `getSidebarData()` function +3. Add `getResourceOrder()` function +4. Test extraction with one resource -### Existing components to refactor +### Step 2: Create new page structure -These components need Server/Client splitting: +1. Create `pages/api-reference/index.tsx` (overview) +2. Create `pages/api-reference/[resource].tsx` (dynamic resource) +3. Create `ApiReferenceLayout` component +4. Test with one resource -| Component | Current | After | -| --------------------- | ------- | -------------------------------------- | -| `ApiReferenceSection` | Client | Server + Client islands | -| `ApiReferenceMethod` | Client | Server + Client islands | -| `SchemaProperties` | Client | Server + Client island for expandable | -| `MultiLangExample` | Client | Client (language state) | -| `Sidebar` | Client | Server for structure, Client for state | - -### New components to create - -| Component | Type | Purpose | -| ---------------------- | ------ | ---------------------------- | -| `PageShell` | Client | Layout with sidebar state | -| `ResourceSection` | Server | Resource overview rendering | -| `MethodContent` | Server | Method documentation | -| `SchemaContent` | Server | Schema documentation | -| `ExpandableProperties` | Client | Expand/collapse UI | -| `CodeExampleTabs` | Client | Language selection | -| `SidebarNav` | Client | Navigation with active state | - ---- +### Step 3: Refactor components -## Testing strategy +1. Update `ResourceOverview` to accept props +2. Update `MethodSection` to accept props +3. Update `SchemaSection` to accept props +4. Remove `ApiReferenceContext` dependency -### Unit tests +### Step 4: Remove URL rewrites -Test helper functions in isolation: +Update `next.config.js`: -```typescript -// lib/openapi/helpers.test.ts -describe("resolveEndpoint", () => { - it("parses string endpoint format", () => { - const result = resolveEndpoint("get /users/{id}"); - expect(result).toEqual(["get", "/users/{id}"]); - }); - - it("parses object endpoint format", () => { - const result = resolveEndpoint({ endpoint: "post /users" }); - expect(result).toEqual(["post", "/users"]); - }); -}); +```javascript +// Remove these rewrites: +// { +// source: "/api-reference/:path+", +// destination: "/api-reference", +// }, ``` -### Integration tests - -Test page rendering with mock data: - -```typescript -// app/(api-reference)/api-reference/page.test.tsx -describe("ApiReferencePage", () => { - it("renders all resource sections", async () => { - const page = await render(); - expect(page.getByRole("heading", { name: "Workflows" })).toBeVisible(); - expect(page.getByRole("heading", { name: "Users" })).toBeVisible(); - }); -}); +### Step 5: Add redirects + +```javascript +// Add redirects for backwards compatibility +{ + source: "/api-reference/overview", + destination: "/api-reference", + permanent: true, +}, +{ + source: "/api-reference/overview/:path", + destination: "/api-reference#:path", + permanent: true, +}, ``` -### E2E tests +### Step 6: Repeat for MAPI -Test full user flows: +Apply the same changes to `/mapi-reference`. -```typescript -// e2e/api-reference.spec.ts -test("navigate to method documentation", async ({ page }) => { - await page.goto("/api-reference"); - await page.click("text=Trigger workflow"); - await expect(page).toHaveURL(/\/api-reference\/workflows\/trigger/); - await expect(page.locator("h2")).toContainText("Trigger workflow"); -}); -``` - -### Visual regression tests +### Step 7: Remove old implementation -Use Playwright or Percy for screenshot comparison: - -```typescript -test("method documentation visual", async ({ page }) => { - await page.goto("/api-reference/users/get"); - await expect(page).toHaveScreenshot("users-get-method.png"); -}); -``` +1. Delete old single-page implementation +2. Remove unused context +3. Clean up old components --- -## Rollback plan - -### Immediate rollback +## Testing and rollout -If critical issues are discovered after rollout: +### Testing checklist -1. Revert the `next.config.js` routing changes -2. The old Pages Router implementation continues to work -3. App Router pages become inaccessible but don't break the site +- [ ] All resources render correctly +- [ ] All methods display with correct data +- [ ] All schemas display with correct data +- [ ] Sidebar navigation works +- [ ] In-page anchor links work +- [ ] Mobile sidebar works +- [ ] ISR revalidation works +- [ ] Page sizes are reduced (measure with DevTools) +- [ ] Hydration is faster (measure with Lighthouse) -### Gradual rollback +### Performance validation -If issues are discovered during staged rollout: +Measure before and after: -1. Reduce rollout percentage to 0% -2. Investigate issues -3. Fix and re-deploy -4. Gradually increase rollout percentage +| Metric | Target | +| ---------------------- | ------------------------ | +| HTML size | <150KB per resource page | +| LCP | <2.5s | +| TTI | <3.8s | +| Lighthouse Performance | >90 | -### Data rollback +### Rollout strategy -No data migration is involved, so no data rollback is needed. +1. **Deploy to preview** - Test on Vercel preview deployment +2. **Internal testing** - Team reviews all resource pages +3. **Gradual rollout** - Use feature flag or A/B test if needed +4. **Full rollout** - Remove old implementation +5. **Monitor** - Watch for 404s, performance issues --- -## Success criteria - -### Performance - -- [ ] HTML payload size < 100KB (down from 2-5MB) -- [ ] LCP < 2.5s (Good threshold) -- [ ] TTI < 3.8s (Good threshold) -- [ ] TBT < 200ms (Good threshold) +## Appendix: File changes summary -### Functionality +### New files -- [ ] All API endpoints documented -- [ ] All schemas documented -- [ ] Code examples work in all languages -- [ ] Search/navigation works -- [ ] Mobile responsive +| File | Purpose | +| ------------------------------------- | --------------- | +| `pages/api-reference/index.tsx` | Overview page | +| `pages/api-reference/[resource].tsx` | Resource pages | +| `pages/mapi-reference/index.tsx` | MAPI overview | +| `pages/mapi-reference/[resource].tsx` | MAPI resources | +| `components/ApiReferenceLayout.tsx` | Shared layout | +| `components/ResourceOverview.tsx` | Resource header | +| `components/MethodSection.tsx` | Method display | +| `components/SchemasSection.tsx` | Schema display | -### Developer experience +### Modified files -- [ ] Build time not increased significantly -- [ ] Local development hot reload works -- [ ] Type safety maintained -- [ ] Code is well-documented +| File | Changes | +| ------------------------------------- | ------------------------------ | +| `lib/openApiSpec.ts` | Add partial loading functions | +| `next.config.js` | Remove rewrites, add redirects | +| `data/sidebars/apiOverviewSidebar.ts` | May need updates | -### SEO +### Deleted files -- [ ] All pages indexed by search engines -- [ ] Unique titles and descriptions -- [ ] No duplicate content issues +| File | Reason | +| ---------------------------------------------------- | ------------------------- | +| `components/ui/ApiReference/ApiReferenceContext.tsx` | No longer needed | +| Old single-page components | Replaced by new structure | --- -## Appendix: Decision log +## Appendix: Example page data size -### Why App Router over other solutions? +Estimated sizes for resource pages (JSON props): -| Alternative | Pros | Cons | Decision | -| -------------------- | ------------------------------ | --------------------------- | ------------ | -| Pre-split JSON files | Minimal changes | Still requires client fetch | Rejected | -| API routes for data | Keeps Pages Router | Adds complexity, latency | Rejected | -| App Router | Best performance, future-proof | Migration effort | **Selected** | - -### Why not split the spec files? - -With Server Components, the spec stays on the server. Splitting would add complexity without significant benefit for specs of this size (~1MB total). - -### Why client component islands? - -Some features require client-side state: - -- Expand/collapse toggle -- Language selection for code examples -- Scroll position tracking - -Rather than making entire sections client components, we extract only the interactive parts as islands within server-rendered content. - ---- +| Resource | Methods | Schemas | Estimated size | +| --------------- | ------- | ------- | -------------- | +| users | 15 | 5 | ~80KB | +| workflows | 5 | 8 | ~60KB | +| messages | 8 | 6 | ~70KB | +| objects | 12 | 4 | ~65KB | +| tenants | 4 | 2 | ~30KB | +| schedules | 6 | 3 | ~40KB | +| channels | 3 | 2 | ~25KB | +| audiences | 4 | 3 | ~35KB | +| bulk_operations | 3 | 2 | ~25KB | +| providers | 2 | 1 | ~20KB | +| recipients | 2 | 4 | ~30KB | +| $shared | 0 | 2 | ~15KB | -## Appendix: Reference links +**Total pages:** 12 resource pages + 2 overview pages = 14 pages -- [Next.js App Router documentation](https://nextjs.org/docs/app) -- [React Server Components RFC](https://github.com/reactjs/rfcs/pull/188) -- [Streaming SSR with Suspense](https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming) -- [React cache() function](https://react.dev/reference/react/cache) +**Compare to current:** 1 page with 2-5MB payload From 031a8897f150ac1c66ba75422a71952cdae5c496 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 1 Feb 2026 14:25:21 +0000 Subject: [PATCH 05/24] Update migration plan: Individual pages per method with clean URLs Changed approach from one-page-per-resource to individual pages for each method and schema with proper URL paths: - /api-reference/users/get instead of /api-reference/users#get - /api-reference/users/schemas/user for schema pages - Catch-all route [...slug].tsx handles methods, subresources, schemas Key updates: - New URL structure with ~133 pages for API, ~100 for MAPI - Path generation utilities for getStaticPaths - Individual loaders for method, schema, and resource overview pages - Sidebar shows full navigation tree - Breadcrumb navigation on all pages - Page sizes reduced to 20-50KB per method page This gives clean, shareable URLs while maintaining small page sizes and ISR for incremental updates. Co-authored-by: chris --- .cursor/api-reference-migration-plan.md | 1099 +++++++++++++---------- 1 file changed, 618 insertions(+), 481 deletions(-) diff --git a/.cursor/api-reference-migration-plan.md b/.cursor/api-reference-migration-plan.md index 7366317da..f2cf25a61 100644 --- a/.cursor/api-reference-migration-plan.md +++ b/.cursor/api-reference-migration-plan.md @@ -1,18 +1,17 @@ # API reference migration plan: Multi-page with ISR -This document outlines the plan for migrating the API reference from a single large page to multiple smaller pages using Incremental Static Regeneration (ISR). +This document outlines the plan for migrating the API reference from a single large page to multiple smaller pages using Incremental Static Regeneration (ISR). Each method and schema gets its own URL path for clean, shareable links. ## Table of contents 1. [Problem statement](#problem-statement) 2. [Solution overview](#solution-overview) -3. [Page structure options](#page-structure-options) -4. [Recommended architecture](#recommended-architecture) -5. [Implementation plan](#implementation-plan) -6. [Data loading strategy](#data-loading-strategy) -7. [Navigation and UX](#navigation-and-ux) -8. [Migration steps](#migration-steps) -9. [Testing and rollout](#testing-and-rollout) +3. [Recommended architecture](#recommended-architecture) +4. [Implementation plan](#implementation-plan) +5. [Data loading strategy](#data-loading-strategy) +6. [Navigation and UX](#navigation-and-ux) +7. [Migration steps](#migration-steps) +8. [Testing and rollout](#testing-and-rollout) --- @@ -37,166 +36,178 @@ This document outlines the plan for migrating the API reference from a single la ## Solution overview -Split the API reference into multiple smaller static pages, each containing only the data needed for that specific resource. Use ISR to keep pages fresh without full rebuilds. +Split the API reference into individual pages for each resource, method, and schema. Use clean URL paths (`/api-reference/users/get`) instead of hash fragments. Use ISR to keep pages fresh without full rebuilds. ### Key benefits -| Aspect | Current | After migration | -| -------------- | ---------------------- | ---------------------------- | -| HTML per page | 2-5MB (full spec) | 50-150KB (single resource) | -| Build strategy | Single page, full spec | Multiple pages, partial spec | -| Updates | Full rebuild | ISR per page | -| Hydration | Full spec | Resource data only | +| Aspect | Current | After migration | +| -------------- | ------------------------- | ---------------------------- | +| HTML per page | 2-5MB (full spec) | 20-50KB (single method) | +| URL structure | Hash-based (`#users-get`) | Path-based (`/users/get`) | +| Build strategy | Single page, full spec | Multiple pages, partial spec | +| Updates | Full rebuild | ISR per page | +| Hydration | Full spec | Single method data only | --- -## Page structure options - -### Option A: One page per resource - -Each resource (users, workflows, etc.) gets its own page containing the resource overview, all methods, and all schemas. - -``` -pages/api-reference/ -├── index.tsx # Overview + intro content -├── [resource].tsx # Dynamic route for each resource -``` - -**Pros:** - -- Simple routing (12 resource pages + overview) -- Each page is self-contained -- Maintains scroll experience within a resource -- Easier to implement - -**Cons:** - -- Resource pages can still be moderately large (100-300KB for resources with many methods) -- All methods load even if user only wants one - -### Option B: One page per method +## Recommended architecture -Every method gets its own dedicated page. +### Page structure ``` pages/api-reference/ -├── index.tsx # Overview +├── index.tsx # Overview (intro, auth, errors) ├── [resource]/ -│ ├── index.tsx # Resource overview -│ ├── [method].tsx # Individual method +│ ├── index.tsx # Resource overview + endpoint list +│ ├── [method].tsx # Individual method page │ └── schemas/ -│ └── [schema].tsx # Individual schema +│ └── [schema].tsx # Individual schema page ``` -**Pros:** +### URL structure -- Smallest possible pages (~20-50KB each) -- Maximum granularity for caching -- Fast navigation to specific endpoints +| URL | Content | +| ----------------------------------- | -------------------------------------------------- | +| `/api-reference` | Overview, authentication, errors, pagination, etc. | +| `/api-reference/users` | Users resource overview with endpoint list | +| `/api-reference/users/get` | Get user method documentation | +| `/api-reference/users/list` | List users method documentation | +| `/api-reference/users/update` | Update user method documentation | +| `/api-reference/users/schemas/user` | User schema definition | +| `/api-reference/workflows` | Workflows resource overview | +| `/api-reference/workflows/trigger` | Trigger workflow method | +| ... | ... | -**Cons:** +### Handling subresources -- Many more pages to generate (100+ pages) -- Loses the scroll-through experience -- More complex navigation -- Users need more clicks to browse +Subresources use nested paths. The `[method].tsx` catch-all handles the nesting: -### Option C: Hybrid - Resource pages with linked schemas +| URL | Content | +| ----------------------------------------- | ------------------------ | +| `/api-reference/users/feeds/list_items` | List feed items method | +| `/api-reference/users/guides/get_channel` | Get guide channel method | -Resource pages contain the overview and all methods. Schemas are separate pages linked from method responses. +To handle this, use a catch-all route: ``` pages/api-reference/ -├── index.tsx # Overview +├── index.tsx ├── [resource]/ -│ ├── index.tsx # Resource overview + all methods -│ └── schemas/ -│ └── [schema].tsx # Individual schema +│ ├── index.tsx +│ ├── [...slug].tsx # Catches /method, /subresource/method, /schemas/name ``` -**Pros:** - -- Balanced page sizes -- Methods stay together (common browsing pattern) -- Schemas separately addressable +### Estimated page counts -**Cons:** +| Type | Count | Example | +| ------------------ | -------- | ----------------------------------- | +| Overview | 1 | `/api-reference` | +| Resource overviews | 12 | `/api-reference/users` | +| Method pages | ~80 | `/api-reference/users/get` | +| Schema pages | ~40 | `/api-reference/users/schemas/user` | +| **Total** | **~133** | | -- More complex than Option A -- Schema pages add navigation complexity - ---- +### Page size estimates -## Recommended architecture +| Page type | Estimated size | +| ----------------- | -------------- | +| Overview | ~30KB | +| Resource overview | ~15KB | +| Method page | ~20-40KB | +| Schema page | ~10-20KB | -**Option A: One page per resource** is recommended for the initial migration. +**Compare to current:** Single page with 2-5MB payload -### Rationale +--- -1. **Simplest implementation.** Fewest routing changes, minimal new pages. -2. **Good payload reduction.** 2-5MB → 50-150KB per page (90%+ reduction). -3. **Preserves scroll experience.** Users can scroll through all methods in a resource. -4. **Easy to enhance later.** Can split further into per-method pages if needed. +## Implementation plan -### Page structure +### Phase 1: Data loading refactor -``` -pages/ -├── api-reference/ -│ ├── index.tsx # Overview page (intro, auth, errors, etc.) -│ └── [resource].tsx # Resource page (overview + methods + schemas) -│ -├── mapi-reference/ -│ ├── index.tsx # Management API overview -│ └── [resource].tsx # Management API resources -``` +Create utilities to load data for individual pages. -### URL structure +#### Task 1.1: Create method-specific loader -| URL | Content | -| -------------------------- | ----------------------------------------------- | -| `/api-reference` | Overview, authentication, errors, etc. | -| `/api-reference/users` | Users resource with all methods and schemas | -| `/api-reference/workflows` | Workflows resource with all methods and schemas | -| `/api-reference/messages` | Messages resource with all methods and schemas | -| ... | ... | +```typescript +// lib/openApiSpec.ts - add new functions -### Handling subresources +/** + * Load data for a single method page. + */ +export async function getMethodPageData( + specName: "api" | "mapi", + resourceName: string, + methodName: string, +): Promise { + const [openApiSpec, stainlessSpec] = await Promise.all([ + readOpenApiSpec(specName), + readStainlessSpec(specName), + ]); -Resources with subresources (like `users` with `feeds`, `guides`) will include the subresource content on the parent resource page: + const resource = stainlessSpec.resources[resourceName]; + if (!resource?.methods?.[methodName]) { + return null; + } -``` -/api-reference/users -├── Users overview -├── Users methods (get, list, update, delete, merge, etc.) -├── Users > Feeds methods -├── Users > Guides methods -└── Users schemas -``` + const methodConfig = resource.methods[methodName]; + const [methodType, endpoint] = resolveEndpoint(methodConfig); + const operation = openApiSpec.paths?.[endpoint]?.[methodType]; -This keeps related content together and avoids deep nesting. + if (!operation) { + return null; + } ---- + return { + resourceName, + resourceTitle: resource.name || resourceName, + methodName, + methodType, + endpoint, + operation, + baseUrl: stainlessSpec.environments.production, + }; +} -## Implementation plan +/** + * Load data for a single schema page. + */ +export async function getSchemaPageData( + specName: "api" | "mapi", + resourceName: string, + schemaName: string, +): Promise { + const [openApiSpec, stainlessSpec] = await Promise.all([ + readOpenApiSpec(specName), + readStainlessSpec(specName), + ]); -### Phase 1: Data loading refactor + const resource = stainlessSpec.resources[resourceName]; + if (!resource?.models?.[schemaName]) { + return null; + } -Create utilities to load partial spec data for a single resource. + const schemaRef = resource.models[schemaName]; + const schema = JSONPointer.get(openApiSpec, schemaRef.replace("#", "")); -#### Task 1.1: Create resource-specific loader + if (!schema) { + return null; + } -```typescript -// lib/openApiSpec.ts - add new functions + return { + resourceName, + resourceTitle: resource.name || resourceName, + schemaName, + schema, + }; +} /** - * Load only the data needed for a specific resource page. - * This avoids loading the full spec into page props. + * Load data for a resource overview page. */ -export async function getResourcePageData( +export async function getResourceOverviewData( specName: "api" | "mapi", resourceName: string, -): Promise { +): Promise { const [openApiSpec, stainlessSpec] = await Promise.all([ readOpenApiSpec(specName), readStainlessSpec(specName), @@ -207,85 +218,207 @@ export async function getResourcePageData( return null; } - // Extract only the paths/schemas needed for this resource - const methods = extractMethodsForResource(openApiSpec, resource); - const schemas = extractSchemasForResource(openApiSpec, resource); - const subresources = extractSubresources(openApiSpec, resource); + // Build list of methods with just summary info (not full operation) + const methods = Object.entries(resource.methods || {}).map( + ([methodName, config]) => { + const [methodType, endpoint] = resolveEndpoint(config); + const operation = openApiSpec.paths?.[endpoint]?.[methodType]; + return { + methodName, + methodType, + endpoint, + summary: operation?.summary || methodName, + }; + }, + ); + + // Build list of schemas with just name/title + const schemas = Object.entries(resource.models || {}).map( + ([schemaName, ref]) => { + const schema = JSONPointer.get(openApiSpec, ref.replace("#", "")); + return { + schemaName, + title: schema?.title || schemaName, + }; + }, + ); + + // Build subresource info + const subresources = Object.entries(resource.subresources || {}).map( + ([subName, subResource]) => ({ + name: subName, + title: subResource.name || subName, + methodCount: Object.keys(subResource.methods || {}).length, + }), + ); return { resourceName, - resource, + resource: { + name: resource.name, + description: resource.description, + }, methods, schemas, subresources, - baseUrl: stainlessSpec.environments.production, }; } +``` + +#### Task 1.2: Create path generation utilities +```typescript /** - * Extract only the OpenAPI operations needed for a resource's methods. + * Generate all static paths for API reference pages. */ -function extractMethodsForResource( - spec: OpenAPIV3.Document, - resource: StainlessResource, -): MethodData[] { - if (!resource.methods) return []; - - return Object.entries(resource.methods).map(([methodName, config]) => { - const [methodType, endpoint] = resolveEndpoint(config); - const operation = spec.paths?.[endpoint]?.[methodType]; +export async function getAllApiReferencePaths( + specName: "api" | "mapi", +): Promise> { + const stainlessSpec = await readStainlessSpec(specName); + const paths: Array<{ params: { resource: string; slug?: string[] } }> = []; + + function processResource( + resource: StainlessResource, + resourceName: string, + parentSlug: string[] = [], + ) { + // Resource overview (no slug) + if (parentSlug.length === 0) { + paths.push({ params: { resource: resourceName } }); + } + + // Method pages + if (resource.methods) { + Object.keys(resource.methods).forEach((methodName) => { + paths.push({ + params: { + resource: resourceName, + slug: [...parentSlug, methodName], + }, + }); + }); + } + + // Schema pages + if (resource.models) { + Object.keys(resource.models).forEach((schemaName) => { + paths.push({ + params: { + resource: resourceName, + slug: [...parentSlug, "schemas", schemaName], + }, + }); + }); + } + + // Subresources (recursive) + if (resource.subresources) { + Object.entries(resource.subresources).forEach( + ([subName, subResource]) => { + // Subresource overview + paths.push({ + params: { + resource: resourceName, + slug: [...parentSlug, subName], + }, + }); + + // Subresource methods and schemas + processResource(subResource, resourceName, [...parentSlug, subName]); + }, + ); + } + } - return { - methodName, - methodType, - endpoint, - operation, - }; - }); -} + Object.entries(stainlessSpec.resources).forEach( + ([resourceName, resource]) => { + processResource(resource, resourceName); + }, + ); -/** - * Extract only the schemas referenced by a resource. - */ -function extractSchemasForResource( - spec: OpenAPIV3.Document, - resource: StainlessResource, -): SchemaData[] { - if (!resource.models) return []; - - return Object.entries(resource.models).map(([modelName, ref]) => { - const schema = JSONPointer.get(spec, ref.replace("#", "")); - return { modelName, schema }; - }); + return paths; } ``` -#### Task 1.2: Create sidebar data loader +#### Task 1.3: Create sidebar data loader ```typescript /** - * Load just the sidebar structure without full spec data. - * Used for navigation on all pages. + * Load sidebar structure for navigation. + * Includes links to all resources, methods, and schemas. */ export async function getSidebarData( specName: "api" | "mapi", ): Promise { const stainlessSpec = await readStainlessSpec(specName); + const basePath = specName === "api" ? "/api-reference" : "/mapi-reference"; + + function buildResourceSidebar( + resource: StainlessResource, + resourceName: string, + pathPrefix: string, + ): SidebarSection { + const pages: SidebarPage[] = []; + + // Methods + if (resource.methods) { + Object.keys(resource.methods).forEach((methodName) => { + pages.push({ + slug: `${pathPrefix}/${methodName}`, + title: methodName, + }); + }); + } + + // Subresources + if (resource.subresources) { + Object.entries(resource.subresources).forEach( + ([subName, subResource]) => { + pages.push({ + slug: `${pathPrefix}/${subName}`, + title: subResource.name || subName, + pages: Object.keys(subResource.methods || {}).map((methodName) => ({ + slug: `${pathPrefix}/${subName}/${methodName}`, + title: methodName, + })), + }); + }, + ); + } + + // Schemas + if (resource.models && Object.keys(resource.models).length > 0) { + pages.push({ + slug: `${pathPrefix}/schemas`, + title: "Schemas", + pages: Object.keys(resource.models).map((schemaName) => ({ + slug: `${pathPrefix}/schemas/${schemaName}`, + title: schemaName, + })), + }); + } - return { - resources: Object.entries(stainlessSpec.resources).map( - ([name, resource]) => ({ - name, - title: resource.name || name, - methodCount: Object.keys(resource.methods || {}).length, - hasSubresources: !!resource.subresources, - }), - ), - }; + return { + title: resource.name || resourceName, + slug: pathPrefix, + pages, + }; + } + + const resources = Object.entries(stainlessSpec.resources).map( + ([resourceName, resource]) => + buildResourceSidebar( + resource, + resourceName, + `${basePath}/${resourceName}`, + ), + ); + + return { resources }; } ``` -### Phase 2: Create new pages +### Phase 2: Create page files #### Task 2.1: Create overview page @@ -294,8 +427,6 @@ export async function getSidebarData( import { GetStaticProps } from "next"; import { getSidebarData } from "@/lib/openApiSpec"; -import { serialize } from "next-mdx-remote/serialize"; -import fs from "fs"; export default function ApiReferenceOverview({ sidebarData, overviewContent }) { return ( @@ -312,80 +443,58 @@ export default function ApiReferenceOverview({ sidebarData, overviewContent }) { export const getStaticProps: GetStaticProps = async () => { const sidebarData = await getSidebarData("api"); - - const overviewMdx = fs.readFileSync( - `${CONTENT_DIR}/__api-reference/content.mdx`, - "utf-8", - ); - const overviewContent = await serialize(overviewMdx, { - parseFrontmatter: true, - mdxOptions: { - remarkPlugins: [remarkGfm], - rehypePlugins: [rehypeMdxCodeProps], - }, - }); + const overviewContent = await loadOverviewMdx(); return { - props: { - sidebarData, - overviewContent, - }, - revalidate: 3600, // ISR: revalidate every hour + props: { sidebarData, overviewContent }, + revalidate: 3600, }; }; ``` -#### Task 2.2: Create dynamic resource page +#### Task 2.2: Create resource overview page ```typescript -// pages/api-reference/[resource].tsx +// pages/api-reference/[resource]/index.tsx import { GetStaticPaths, GetStaticProps } from "next"; import { - getResourcePageData, + getResourceOverviewData, getSidebarData, getResourceOrder, } from "@/lib/openApiSpec"; -export default function ResourcePage({ - sidebarData, - resourceData, - schemaReferences, -}) { - const { resourceName, resource, methods, schemas, subresources, baseUrl } = - resourceData; - +export default function ResourceOverviewPage({ sidebarData, resourceData }) { return ( - {/* Resource Overview */} - + - {/* Methods */} - {methods.map((method) => ( - - ))} + {/* Endpoint list */} + {/* Subresources */} - {subresources.map((subresource) => ( - 0 && ( + - ))} + )} {/* Schemas */} - {schemas.length > 0 && ( - + {resourceData.schemas.length > 0 && ( + )} ); @@ -393,177 +502,242 @@ export default function ResourcePage({ export const getStaticPaths: GetStaticPaths = async () => { const resourceOrder = await getResourceOrder("api"); - return { paths: resourceOrder.map((resource) => ({ params: { resource }, })), - fallback: false, // 404 for unknown resources + fallback: false, }; }; export const getStaticProps: GetStaticProps = async ({ params }) => { const resourceName = params.resource as string; - const [sidebarData, resourceData] = await Promise.all([ getSidebarData("api"), - getResourcePageData("api", resourceName), + getResourceOverviewData("api", resourceName), ]); if (!resourceData) { return { notFound: true }; } - // Build schema references for this resource (for linking) - const schemaReferences = buildSchemaReferencesForResource( - resourceData, - `/api-reference/${resourceName}`, + return { + props: { sidebarData, resourceData }, + revalidate: 3600, + }; +}; +``` + +#### Task 2.3: Create catch-all page for methods and schemas + +```typescript +// pages/api-reference/[resource]/[...slug].tsx + +import { GetStaticPaths, GetStaticProps } from "next"; +import { + getAllApiReferencePaths, + getMethodPageData, + getSchemaPageData, + getSidebarData, +} from "@/lib/openApiSpec"; + +type PageType = "method" | "schema" | "subresource"; + +export default function ApiReferenceDynamicPage({ + pageType, + sidebarData, + data, + schemaReferences, +}) { + if (pageType === "schema") { + return ( + + + + ); + } + + // Method page (default) + return ( + + + + ); +} + +export const getStaticPaths: GetStaticPaths = async () => { + const paths = await getAllApiReferencePaths("api"); + // Filter to only paths with slug (not resource overview) + const slugPaths = paths.filter((p) => p.params.slug); + return { paths: slugPaths, fallback: false }; +}; + +export const getStaticProps: GetStaticProps = async ({ params }) => { + const resourceName = params.resource as string; + const slug = params.slug as string[]; + + const sidebarData = await getSidebarData("api"); + + // Determine page type from slug + // /users/schemas/user -> schema page + // /users/get -> method page + // /users/feeds/list_items -> subresource method page + + if (slug.includes("schemas")) { + const schemaIndex = slug.indexOf("schemas"); + const schemaName = slug[schemaIndex + 1]; + + const data = await getSchemaPageData("api", resourceName, schemaName); + if (!data) return { notFound: true }; + + const schemaReferences = await buildSchemaReferences("api"); + + return { + props: { + pageType: "schema", + sidebarData, + data, + schemaReferences, + }, + revalidate: 3600, + }; + } + + // Method page (possibly in subresource) + const methodName = slug[slug.length - 1]; + const subresourcePath = slug.slice(0, -1); + + const data = await getMethodPageData( + "api", + resourceName, + methodName, + subresourcePath, ); + if (!data) return { notFound: true }; + + const schemaReferences = await buildSchemaReferences("api"); + return { props: { + pageType: "method", sidebarData, - resourceData, + data, schemaReferences, }, - revalidate: 3600, // ISR: revalidate every hour + revalidate: 3600, }; }; ``` -### Phase 3: Create shared layout +### Phase 3: Create page components -#### Task 3.1: Create ApiReferenceLayout component +#### Task 3.1: Create MethodPage component ```typescript -// components/ApiReferenceLayout.tsx +// components/api-reference/MethodPage.tsx interface Props { - children: React.ReactNode; - sidebarData: SidebarData; - currentResource?: string; + data: MethodPageData; + schemaReferences: Record; } -export function ApiReferenceLayout({ - children, - sidebarData, - currentResource, -}: Props) { +export function MethodPage({ data, schemaReferences }: Props) { + const { operation, methodType, endpoint, baseUrl } = data; + return ( - - + - - } + + - - - - {children} - - - + ); } - -function buildSidebarContent( - sidebarData: SidebarData, - currentResource?: string, -): SidebarSection[] { - return [ - // Overview section - { - title: "API reference", - slug: "/api-reference", - pages: [ - { slug: "/", title: "Overview" }, - { slug: "/authentication", title: "Authentication" }, - { slug: "/errors", title: "Errors" }, - // ... other overview pages - ], - }, - // Resource sections - ...sidebarData.resources.map((resource) => ({ - title: resource.title, - slug: `/api-reference/${resource.name}`, - isActive: resource.name === currentResource, - pages: [], // Methods shown on the resource page, not in sidebar - })), - ]; -} ``` -### Phase 4: Component refactoring - -#### Task 4.1: Refactor ResourceSection - -Update to work with pre-extracted data instead of pulling from context. +#### Task 3.2: Create SchemaPage component ```typescript -// components/ResourceOverview.tsx +// components/api-reference/SchemaPage.tsx interface Props { - resource: StainlessResource; - methods: MethodData[]; + data: SchemaPageData; + schemaReferences: Record; } -export function ResourceOverview({ resource, methods }: Props) { +export function SchemaPage({ data, schemaReferences }: Props) { + const { schema, schemaName } = data; + return ( -
- - {resource.description && {resource.description}} - - - - {methods.map((method) => ( - - ))} - - -
+ <> + + + + ); } ``` -#### Task 4.2: Refactor MethodSection +#### Task 3.3: Create EndpointList component ```typescript -// components/MethodSection.tsx +// components/api-reference/EndpointList.tsx interface Props { - method: MethodData; - baseUrl: string; - schemaReferences: Record; + methods: Array<{ + methodName: string; + methodType: string; + endpoint: string; + summary: string; + }>; + basePath: string; } -export function MethodSection({ method, baseUrl, schemaReferences }: Props) { - const { methodName, methodType, endpoint, operation } = method; - - if (!operation) return null; - - // Component now receives data directly instead of from context +export function EndpointList({ methods, basePath }: Props) { return ( -
- {/* ... existing method content ... */} -
+
+ {methods.map((method) => ( + + +
+ {method.endpoint} +

{method.summary}

+
+ + ))} +
); } ``` @@ -576,17 +750,15 @@ export function MethodSection({ method, baseUrl, schemaReferences }: Props) { | Aspect | Current | After migration | | ----------------- | ----------------------------- | --------------------------------- | -| Spec loading | Full spec in `getStaticProps` | Partial extraction per resource | +| Spec loading | Full spec in `getStaticProps` | Load only needed operation/schema | | Context | Full spec in React context | No context needed (data in props) | -| Schema references | Built from full spec | Built per-resource | +| Schema references | Built from full spec | Built once, shared across pages | | Sidebar | Built from full spec | Separate lightweight loader | ### ISR configuration ```typescript export const getStaticProps: GetStaticProps = async ({ params }) => { - // ... load data ... - return { props: { /* ... */ @@ -596,75 +768,66 @@ export const getStaticProps: GetStaticProps = async ({ params }) => { }; ``` -**Why 1 hour?** - -- API specs don't change frequently -- Allows updates without full redeploy -- Can be adjusted based on update frequency - -**Alternative: On-demand revalidation** +**On-demand revalidation (optional)** ```typescript // pages/api/revalidate.ts export default async function handler(req, res) { - const { secret, path } = req.query; + const { secret, paths } = req.body; if (secret !== process.env.REVALIDATION_SECRET) { return res.status(401).json({ message: "Invalid secret" }); } - await res.revalidate(path); + // Revalidate specific paths + await Promise.all(paths.map((path) => res.revalidate(path))); + return res.json({ revalidated: true }); } ``` -Call this endpoint when specs are updated to immediately refresh pages. - --- ## Navigation and UX ### Sidebar behavior -- **Overview pages** listed at top (Authentication, Errors, etc.) -- **Resources** listed below, each links to `/api-reference/[resource]` -- **Active resource** highlighted in sidebar -- **Methods** accessible via in-page anchor links, not sidebar expansion +- **Overview section** at top with general topics +- **Resources** listed as collapsible sections +- **Methods** listed under each resource +- **Schemas** grouped under "Schemas" subsection +- **Current page** highlighted in sidebar -### In-page navigation +### Breadcrumb navigation -Each resource page has: +Every page shows breadcrumbs: -1. **Resource overview** with endpoint list -2. **Method sections** with anchor IDs (`#trigger`, `#cancel`, etc.) -3. **Schema sections** at the bottom -4. **On-this-page navigation** (optional) for jumping between methods +``` +API reference > Users > Get user +API reference > Users > Schemas > User +API reference > Workflows > Trigger workflow +``` -### Cross-resource linking +### Previous/Next navigation -Schema references that link to other resources: +Add footer navigation to move between methods: ```typescript -// Current: /api-reference/users/schemas/User -// After: /api-reference/users#user-schema - -// Or if schemas are very large, separate pages: -// /api-reference/users/schemas/user +// In MethodPage component + ``` -### URL compatibility +### Cross-resource linking -Add redirects for old hash-based URLs: +Schema references link to schema pages: -```javascript -// next.config.js -async redirects() { - return [ - // Old: /api-reference#users-get - // New: /api-reference/users#get - // Handle via client-side detection if hash-based - ]; -} +```typescript +// In MethodContent, when showing return type +User +// Links to /api-reference/users/schemas/user ``` --- @@ -673,62 +836,55 @@ async redirects() { ### Step 1: Create data loading utilities -1. Add `getResourcePageData()` function -2. Add `getSidebarData()` function -3. Add `getResourceOrder()` function -4. Test extraction with one resource +1. Add `getMethodPageData()` function +2. Add `getSchemaPageData()` function +3. Add `getResourceOverviewData()` function +4. Add `getAllApiReferencePaths()` function +5. Add `getSidebarData()` function +6. Test with one resource -### Step 2: Create new page structure +### Step 2: Create new page files -1. Create `pages/api-reference/index.tsx` (overview) -2. Create `pages/api-reference/[resource].tsx` (dynamic resource) -3. Create `ApiReferenceLayout` component -4. Test with one resource +1. Create `pages/api-reference/index.tsx` +2. Create `pages/api-reference/[resource]/index.tsx` +3. Create `pages/api-reference/[resource]/[...slug].tsx` +4. Create page components (MethodPage, SchemaPage, etc.) +5. Test all paths generate correctly -### Step 3: Refactor components +### Step 3: Create shared components -1. Update `ResourceOverview` to accept props -2. Update `MethodSection` to accept props -3. Update `SchemaSection` to accept props -4. Remove `ApiReferenceContext` dependency +1. Create `ApiReferenceLayout` component +2. Create `EndpointList` component +3. Create `MethodContent` component (refactor from existing) +4. Create `SchemaContent` component (refactor from existing) +5. Create breadcrumb navigation -### Step 4: Remove URL rewrites - -Update `next.config.js`: +### Step 4: Update next.config.js ```javascript -// Remove these rewrites: +// Remove old rewrites // { // source: "/api-reference/:path+", // destination: "/api-reference", // }, -``` -### Step 5: Add redirects - -```javascript -// Add redirects for backwards compatibility -{ - source: "/api-reference/overview", - destination: "/api-reference", - permanent: true, -}, -{ - source: "/api-reference/overview/:path", - destination: "/api-reference#:path", - permanent: true, -}, +// Add redirects for old URLs if needed +async redirects() { + return [ + // If you had hash-based URLs that were indexed + ]; +} ``` -### Step 6: Repeat for MAPI +### Step 5: Repeat for MAPI -Apply the same changes to `/mapi-reference`. +Apply the same structure to `/mapi-reference`. -### Step 7: Remove old implementation +### Step 6: Remove old implementation -1. Delete old single-page implementation -2. Remove unused context -3. Clean up old components +1. Delete `pages/api-reference/index.tsx` (old single page) +2. Remove `ApiReferenceContext` +3. Clean up unused components --- @@ -736,88 +892,69 @@ Apply the same changes to `/mapi-reference`. ### Testing checklist -- [ ] All resources render correctly -- [ ] All methods display with correct data -- [ ] All schemas display with correct data -- [ ] Sidebar navigation works -- [ ] In-page anchor links work +- [ ] All resource overview pages render +- [ ] All method pages render with correct data +- [ ] All schema pages render with correct data +- [ ] Sidebar navigation highlights current page +- [ ] Breadcrumbs work correctly +- [ ] Cross-resource schema links work - [ ] Mobile sidebar works - [ ] ISR revalidation works -- [ ] Page sizes are reduced (measure with DevTools) -- [ ] Hydration is faster (measure with Lighthouse) +- [ ] 404 pages for invalid paths +- [ ] Page sizes are small (check with DevTools) ### Performance validation -Measure before and after: - -| Metric | Target | -| ---------------------- | ------------------------ | -| HTML size | <150KB per resource page | -| LCP | <2.5s | -| TTI | <3.8s | -| Lighthouse Performance | >90 | +| Metric | Target | +| ------------------------- | ------ | +| HTML size per method page | <50KB | +| HTML size per schema page | <25KB | +| LCP | <2.5s | +| TTI | <3.8s | +| Lighthouse Performance | >90 | ### Rollout strategy -1. **Deploy to preview** - Test on Vercel preview deployment -2. **Internal testing** - Team reviews all resource pages -3. **Gradual rollout** - Use feature flag or A/B test if needed -4. **Full rollout** - Remove old implementation -5. **Monitor** - Watch for 404s, performance issues +1. **Deploy to preview** - Test on Vercel preview +2. **Verify all pages** - Script to check all generated paths +3. **Compare with current** - Ensure no missing content +4. **Deploy to production** - Remove old implementation +5. **Monitor** - Watch for 404s, broken links --- -## Appendix: File changes summary +## Appendix: File structure summary ### New files -| File | Purpose | -| ------------------------------------- | --------------- | -| `pages/api-reference/index.tsx` | Overview page | -| `pages/api-reference/[resource].tsx` | Resource pages | -| `pages/mapi-reference/index.tsx` | MAPI overview | -| `pages/mapi-reference/[resource].tsx` | MAPI resources | -| `components/ApiReferenceLayout.tsx` | Shared layout | -| `components/ResourceOverview.tsx` | Resource header | -| `components/MethodSection.tsx` | Method display | -| `components/SchemasSection.tsx` | Schema display | - -### Modified files - -| File | Changes | -| ------------------------------------- | ------------------------------ | -| `lib/openApiSpec.ts` | Add partial loading functions | -| `next.config.js` | Remove rewrites, add redirects | -| `data/sidebars/apiOverviewSidebar.ts` | May need updates | - -### Deleted files - -| File | Reason | -| ---------------------------------------------------- | ------------------------- | -| `components/ui/ApiReference/ApiReferenceContext.tsx` | No longer needed | -| Old single-page components | Replaced by new structure | - ---- - -## Appendix: Example page data size - -Estimated sizes for resource pages (JSON props): +``` +pages/api-reference/ +├── index.tsx # Overview page +├── [resource]/ +│ ├── index.tsx # Resource overview +│ └── [...slug].tsx # Methods and schemas + +components/api-reference/ +├── ApiReferenceLayout.tsx # Shared layout +├── MethodPage.tsx # Method page component +├── SchemaPage.tsx # Schema page component +├── MethodContent.tsx # Method documentation +├── SchemaContent.tsx # Schema documentation +├── EndpointList.tsx # List of endpoints +├── SchemaList.tsx # List of schemas +├── SubresourceList.tsx # List of subresources +└── MethodBadge.tsx # GET/POST/etc badge + +lib/ +├── openApiSpec.ts # Add new loader functions +``` -| Resource | Methods | Schemas | Estimated size | -| --------------- | ------- | ------- | -------------- | -| users | 15 | 5 | ~80KB | -| workflows | 5 | 8 | ~60KB | -| messages | 8 | 6 | ~70KB | -| objects | 12 | 4 | ~65KB | -| tenants | 4 | 2 | ~30KB | -| schedules | 6 | 3 | ~40KB | -| channels | 3 | 2 | ~25KB | -| audiences | 4 | 3 | ~35KB | -| bulk_operations | 3 | 2 | ~25KB | -| providers | 2 | 1 | ~20KB | -| recipients | 2 | 4 | ~30KB | -| $shared | 0 | 2 | ~15KB | +### Estimated page counts -**Total pages:** 12 resource pages + 2 overview pages = 14 pages +| Spec | Resources | Methods | Schemas | Total pages | +| --------- | --------- | ------- | ------- | ----------- | +| API | 12 | ~80 | ~40 | ~133 | +| MAPI | ~10 | ~60 | ~30 | ~100 | +| **Total** | | | | **~233** | -**Compare to current:** 1 page with 2-5MB payload +Build time should remain reasonable since each page loads minimal data. From c23355fff132181ce9b269fbd353fc6f53bb770a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 1 Feb 2026 15:10:30 +0000 Subject: [PATCH 06/24] feat: add Phase 1 data loading utilities for multi-page API reference Add new functions to lib/openApiSpec.ts for loading data for individual API reference pages: - getMethodPageData(): Load data for a single method page - getSchemaPageData(): Load data for a single schema page - getResourceOverviewData(): Load data for resource overview pages - getAllApiReferencePaths(): Generate all static paths for API reference - getSidebarData(): Load sidebar structure for navigation - getResourceOrder(): Get ordered list of resource names - buildSchemaReferences(): Build map of schema names to URL paths - resolveEndpoint(): Parse method config into HTTP method and endpoint Also adds TypeScript types for: - MethodPageData, SchemaPageData, ResourceOverviewData - MethodSummary, SchemaSummary, SubresourceSummary - SidebarPage, SidebarSection, SidebarData - ApiReferencePath, SpecName This is Phase 1 of the API reference migration plan. Co-authored-by: chris --- lib/openApiSpec.ts | 683 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 671 insertions(+), 12 deletions(-) diff --git a/lib/openApiSpec.ts b/lib/openApiSpec.ts index 8f2e39d95..fa63808b1 100644 --- a/lib/openApiSpec.ts +++ b/lib/openApiSpec.ts @@ -1,9 +1,15 @@ import { dereference } from "@scalar/openapi-parser"; +import { OpenAPIV3 } from "@scalar/openapi-types"; import deepmerge from "deepmerge"; import { readFile } from "fs/promises"; +import JSONPointer from "jsonpointer"; import safeStringify from "safe-stringify"; import { parse } from "yaml"; +// ============================================================================ +// Stainless Spec Types +// ============================================================================ + type StainlessResourceMethod = | string | { @@ -17,15 +23,7 @@ type StainlessResource = { description?: string; models?: Record; methods?: Record; - subresources?: Record< - string, - { - name?: string; - description?: string; - models?: Record; - methods?: Record; - } - >; + subresources?: Record; }; interface StainlessConfig { @@ -35,17 +33,143 @@ interface StainlessConfig { environments: Record; } +// ============================================================================ +// Page Data Types (for multi-page API reference) +// ============================================================================ + +/** + * Data for a single method page (e.g., /api-reference/users/get) + */ +type MethodPageData = { + resourceName: string; + resourceTitle: string; + methodName: string; + methodType: string; + endpoint: string; + operation: OpenAPIV3.OperationObject; + baseUrl: string; + // Subresource path if this method is in a subresource (e.g., ["feeds"]) + subresourcePath?: string[]; +}; + +/** + * Data for a single schema page (e.g., /api-reference/users/schemas/user) + */ +type SchemaPageData = { + resourceName: string; + resourceTitle: string; + schemaName: string; + schemaRef: string; + schema: OpenAPIV3.SchemaObject; + // Subresource path if this schema is in a subresource + subresourcePath?: string[]; +}; + +/** + * Summary info for a method in resource overview + */ +type MethodSummary = { + methodName: string; + methodType: string; + endpoint: string; + summary: string; +}; + +/** + * Summary info for a schema in resource overview + */ +type SchemaSummary = { + schemaName: string; + title: string; +}; + +/** + * Summary info for a subresource in resource overview + */ +type SubresourceSummary = { + name: string; + title: string; + methodCount: number; +}; + +/** + * Data for a resource overview page (e.g., /api-reference/users) + */ +type ResourceOverviewData = { + resourceName: string; + resource: { + name?: string; + description?: string; + }; + methods: MethodSummary[]; + schemas: SchemaSummary[]; + subresources: SubresourceSummary[]; +}; + +/** + * Sidebar page entry + */ +type SidebarPage = { + slug: string; + title: string; + pages?: SidebarPage[]; +}; + +/** + * Sidebar section for a resource + */ +type SidebarSection = { + title: string; + slug: string; + pages: SidebarPage[]; +}; + +/** + * Complete sidebar data for API reference navigation + */ +type SidebarData = { + resources: SidebarSection[]; +}; + +// ============================================================================ +// Spec Name Type +// ============================================================================ + +type SpecName = "api" | "mapi"; + +// ============================================================================ +// Helper Functions +// ============================================================================ + function yamlToJson(yaml: string) { const json = parse(yaml); return json; } +/** + * Resolve endpoint configuration to [methodType, endpoint] tuple. + * Handles both string format ("get /v1/users") and object format ({ endpoint: "get /v1/users" }) + */ +function resolveEndpoint( + methodConfig: StainlessResourceMethod, +): [string, string] { + const endpointString = + typeof methodConfig === "string" ? methodConfig : methodConfig.endpoint; + + const [methodType, endpoint] = endpointString.split(" "); + return [methodType.toLowerCase(), endpoint]; +} + +// ============================================================================ +// Spec Loading Functions +// ============================================================================ + async function readOpenApiSpec(specName: string) { const spec = await readFile(`./data/specs/${specName}/openapi.yml`, "utf8"); const jsonSpec = yamlToJson(spec); const { schema } = await dereference(jsonSpec); - return JSON.parse(safeStringify(schema)); + return JSON.parse(safeStringify(schema)) as OpenAPIV3.Document; } async function readStainlessSpec(specName: string): Promise { @@ -65,5 +189,540 @@ async function readSpecCustomizations(specName: string) { return customizations; } -export type { StainlessResource, StainlessConfig }; -export { readOpenApiSpec, readStainlessSpec }; +// ============================================================================ +// Resource Order +// ============================================================================ + +/** + * Get the ordered list of resource names for a spec. + * This determines the order resources appear in the sidebar. + */ +async function getResourceOrder(specName: SpecName): Promise { + const stainlessSpec = await readStainlessSpec(specName); + // Return all resource keys from the spec + // For consistent ordering, we can sort alphabetically or maintain spec order + return Object.keys(stainlessSpec.resources); +} + +// ============================================================================ +// Method Page Data Loader +// ============================================================================ + +/** + * Navigate to a subresource using a path array. + * Returns the subresource at the given path, or undefined if not found. + */ +function getSubresource( + resource: StainlessResource, + subresourcePath: string[], +): StainlessResource | undefined { + let current: StainlessResource | undefined = resource; + + for (const pathSegment of subresourcePath) { + if (!current?.subresources?.[pathSegment]) { + return undefined; + } + current = current.subresources[pathSegment]; + } + + return current; +} + +/** + * Load data for a single method page. + * Supports methods in both top-level resources and subresources. + */ +async function getMethodPageData( + specName: SpecName, + resourceName: string, + methodName: string, + subresourcePath: string[] = [], +): Promise { + const [openApiSpec, stainlessSpec] = await Promise.all([ + readOpenApiSpec(specName), + readStainlessSpec(specName), + ]); + + const resource = stainlessSpec.resources[resourceName]; + if (!resource) { + return null; + } + + // Navigate to the target resource (may be a subresource) + const targetResource = + subresourcePath.length > 0 + ? getSubresource(resource, subresourcePath) + : resource; + + if (!targetResource?.methods?.[methodName]) { + return null; + } + + const methodConfig = targetResource.methods[methodName]; + const [methodType, endpoint] = resolveEndpoint(methodConfig); + const operation = openApiSpec.paths?.[endpoint]?.[ + methodType as keyof OpenAPIV3.PathItemObject + ] as OpenAPIV3.OperationObject | undefined; + + if (!operation) { + return null; + } + + // Determine the resource title (use parent resource name for subresources) + const resourceTitle = resource.name || resourceName; + + return { + resourceName, + resourceTitle, + methodName, + methodType, + endpoint, + operation, + baseUrl: stainlessSpec.environments.production, + subresourcePath: subresourcePath.length > 0 ? subresourcePath : undefined, + }; +} + +// ============================================================================ +// Schema Page Data Loader +// ============================================================================ + +/** + * Load data for a single schema page. + * Supports schemas in both top-level resources and subresources. + */ +async function getSchemaPageData( + specName: SpecName, + resourceName: string, + schemaName: string, + subresourcePath: string[] = [], +): Promise { + const [openApiSpec, stainlessSpec] = await Promise.all([ + readOpenApiSpec(specName), + readStainlessSpec(specName), + ]); + + const resource = stainlessSpec.resources[resourceName]; + if (!resource) { + return null; + } + + // Navigate to the target resource (may be a subresource) + const targetResource = + subresourcePath.length > 0 + ? getSubresource(resource, subresourcePath) + : resource; + + if (!targetResource?.models?.[schemaName]) { + return null; + } + + const schemaRef = targetResource.models[schemaName]; + const schema = JSONPointer.get( + openApiSpec, + schemaRef.replace("#", ""), + ) as OpenAPIV3.SchemaObject | undefined; + + if (!schema) { + return null; + } + + const resourceTitle = resource.name || resourceName; + + return { + resourceName, + resourceTitle, + schemaName, + schemaRef, + schema, + subresourcePath: subresourcePath.length > 0 ? subresourcePath : undefined, + }; +} + +// ============================================================================ +// Resource Overview Data Loader +// ============================================================================ + +/** + * Load data for a resource overview page. + * Includes list of methods, schemas, and subresources with summary info. + */ +async function getResourceOverviewData( + specName: SpecName, + resourceName: string, + subresourcePath: string[] = [], +): Promise { + const [openApiSpec, stainlessSpec] = await Promise.all([ + readOpenApiSpec(specName), + readStainlessSpec(specName), + ]); + + const resource = stainlessSpec.resources[resourceName]; + if (!resource) { + return null; + } + + // Navigate to the target resource (may be a subresource) + const targetResource = + subresourcePath.length > 0 + ? getSubresource(resource, subresourcePath) + : resource; + + if (!targetResource) { + return null; + } + + // Build list of methods with summary info + const methods: MethodSummary[] = Object.entries( + targetResource.methods || {}, + ).map(([methodName, config]) => { + const [methodType, endpoint] = resolveEndpoint(config); + const operation = openApiSpec.paths?.[endpoint]?.[ + methodType as keyof OpenAPIV3.PathItemObject + ] as OpenAPIV3.OperationObject | undefined; + return { + methodName, + methodType, + endpoint, + summary: operation?.summary || methodName, + }; + }); + + // Build list of schemas with name/title + const schemas: SchemaSummary[] = Object.entries( + targetResource.models || {}, + ).map(([schemaName, ref]) => { + const schema = JSONPointer.get( + openApiSpec, + ref.replace("#", ""), + ) as OpenAPIV3.SchemaObject | undefined; + return { + schemaName, + title: schema?.title || schemaName, + }; + }); + + // Build subresource info + const subresources: SubresourceSummary[] = Object.entries( + targetResource.subresources || {}, + ).map(([subName, subResource]) => ({ + name: subName, + title: subResource.name || subName, + methodCount: Object.keys(subResource.methods || {}).length, + })); + + return { + resourceName, + resource: { + name: targetResource.name, + description: targetResource.description, + }, + methods, + schemas, + subresources, + }; +} + +// ============================================================================ +// Path Generation for Static Paths +// ============================================================================ + +type ApiReferencePath = { + params: { + resource: string; + slug?: string[]; + }; +}; + +/** + * Generate all static paths for API reference pages. + * Used by getStaticPaths to generate all method, schema, and subresource pages. + */ +async function getAllApiReferencePaths( + specName: SpecName, +): Promise { + const stainlessSpec = await readStainlessSpec(specName); + const paths: ApiReferencePath[] = []; + + function processResource( + resource: StainlessResource, + resourceName: string, + parentSlug: string[] = [], + ) { + // Resource overview (no slug for top-level, has slug for subresources) + if (parentSlug.length === 0) { + paths.push({ params: { resource: resourceName } }); + } else { + // Subresource overview + paths.push({ + params: { + resource: resourceName, + slug: parentSlug, + }, + }); + } + + // Method pages + if (resource.methods) { + Object.keys(resource.methods).forEach((methodName) => { + paths.push({ + params: { + resource: resourceName, + slug: [...parentSlug, methodName], + }, + }); + }); + } + + // Schema pages + if (resource.models) { + Object.keys(resource.models).forEach((schemaName) => { + paths.push({ + params: { + resource: resourceName, + slug: [...parentSlug, "schemas", schemaName], + }, + }); + }); + } + + // Subresources (recursive) + if (resource.subresources) { + Object.entries(resource.subresources).forEach( + ([subName, subResource]) => { + // Process subresource methods, schemas, and nested subresources + processResource(subResource, resourceName, [...parentSlug, subName]); + }, + ); + } + } + + Object.entries(stainlessSpec.resources).forEach( + ([resourceName, resource]) => { + processResource(resource, resourceName); + }, + ); + + return paths; +} + +// ============================================================================ +// Sidebar Data Loader +// ============================================================================ + +/** + * Build sidebar pages for a resource (recursively handles subresources) + */ +function buildResourceSidebarPages( + resource: StainlessResource, + openApiSpec: OpenAPIV3.Document, + pathPrefix: string, +): SidebarPage[] { + const pages: SidebarPage[] = []; + + // Methods + if (resource.methods) { + Object.entries(resource.methods).forEach(([methodName, methodConfig]) => { + const [methodType, endpoint] = resolveEndpoint(methodConfig); + const operation = openApiSpec.paths?.[endpoint]?.[ + methodType as keyof OpenAPIV3.PathItemObject + ] as OpenAPIV3.OperationObject | undefined; + + pages.push({ + slug: `${pathPrefix}/${methodName}`, + title: operation?.summary || methodName, + }); + }); + } + + // Subresources + if (resource.subresources) { + Object.entries(resource.subresources).forEach( + ([subName, subResource]) => { + const subPages = buildResourceSidebarPages( + subResource, + openApiSpec, + `${pathPrefix}/${subName}`, + ); + + pages.push({ + slug: `${pathPrefix}/${subName}`, + title: subResource.name || subName, + pages: subPages, + }); + }, + ); + } + + // Schemas + if (resource.models && Object.keys(resource.models).length > 0) { + const schemaPages: SidebarPage[] = Object.entries(resource.models).map( + ([schemaName, schemaRef]) => { + const schema = JSONPointer.get( + openApiSpec, + schemaRef.replace("#", ""), + ) as OpenAPIV3.SchemaObject | undefined; + + return { + slug: `${pathPrefix}/schemas/${schemaName}`, + title: schema?.title || schemaName, + }; + }, + ); + + pages.push({ + slug: `${pathPrefix}/schemas`, + title: "Object definitions", + pages: schemaPages, + }); + } + + return pages; +} + +/** + * Load sidebar structure for navigation. + * Includes links to all resources, methods, and schemas. + */ +async function getSidebarData(specName: SpecName): Promise { + const [openApiSpec, stainlessSpec] = await Promise.all([ + readOpenApiSpec(specName), + readStainlessSpec(specName), + ]); + + const basePath = specName === "api" ? "/api-reference" : "/mapi-reference"; + + const resources: SidebarSection[] = Object.entries( + stainlessSpec.resources, + ).map(([resourceName, resource]) => { + const pathPrefix = `${basePath}/${resourceName}`; + + return { + title: resource.name || resourceName, + slug: pathPrefix, + pages: buildResourceSidebarPages(resource, openApiSpec, pathPrefix), + }; + }); + + return { resources }; +} + +// ============================================================================ +// Schema References Builder +// ============================================================================ + +/** + * Build a map of schema names to their URL paths. + * Used for cross-linking schemas in method documentation. + */ +function buildSchemaReferencesForResource( + resource: StainlessResource, + openApiSpec: OpenAPIV3.Document, + basePath: string, +): Record { + const schemaReferences: Record = {}; + + if (resource.models) { + Object.entries(resource.models).forEach(([modelName, modelRef]) => { + const schema = JSONPointer.get( + openApiSpec, + modelRef.replace("#", ""), + ) as OpenAPIV3.SchemaObject | undefined; + + const title = schema?.title ?? modelName; + + if (schema) { + schemaReferences[title] = `${basePath}/schemas/${modelName}`; + // Also map array types + schemaReferences[`${title}[]`] = `${basePath}/schemas/${modelName}`; + } + }); + } + + if (resource.subresources) { + Object.entries(resource.subresources).forEach( + ([subresourceName, subresource]) => { + Object.assign( + schemaReferences, + buildSchemaReferencesForResource( + subresource, + openApiSpec, + `${basePath}/${subresourceName}`, + ), + ); + }, + ); + } + + return schemaReferences; +} + +/** + * Build complete schema references map for all resources. + */ +async function buildSchemaReferences( + specName: SpecName, +): Promise> { + const [openApiSpec, stainlessSpec] = await Promise.all([ + readOpenApiSpec(specName), + readStainlessSpec(specName), + ]); + + const basePath = specName === "api" ? "/api-reference" : "/mapi-reference"; + const schemaReferences: Record = {}; + + Object.entries(stainlessSpec.resources).forEach( + ([resourceName, resource]) => { + Object.assign( + schemaReferences, + buildSchemaReferencesForResource( + resource, + openApiSpec, + `${basePath}/${resourceName}`, + ), + ); + }, + ); + + return schemaReferences; +} + +// ============================================================================ +// Exports +// ============================================================================ + +export type { + StainlessResource, + StainlessResourceMethod, + StainlessConfig, + MethodPageData, + SchemaPageData, + ResourceOverviewData, + MethodSummary, + SchemaSummary, + SubresourceSummary, + SidebarPage, + SidebarSection, + SidebarData, + ApiReferencePath, + SpecName, +}; + +export { + // Existing exports + readOpenApiSpec, + readStainlessSpec, + // New helper + resolveEndpoint, + // New page data loaders + getMethodPageData, + getSchemaPageData, + getResourceOverviewData, + // Path generation + getAllApiReferencePaths, + getResourceOrder, + // Sidebar data + getSidebarData, + // Schema references + buildSchemaReferences, +}; From f15f1e093a5b85feb751b970f9e6eadf15cfe0c5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 1 Feb 2026 15:16:26 +0000 Subject: [PATCH 07/24] feat: add Phase 2 and 3 - API reference page components and routes Add new components for multi-page API reference: - ApiReferenceLayout: Shared layout with sidebar navigation - MethodPage: Individual method page component - SchemaPage: Individual schema page component - ResourceOverviewPage: Resource overview with endpoint list - EndpointList: List of endpoints with HTTP method badges Add new page routes: - pages/api-reference/[resource]/index.tsx: Resource overview pages - pages/api-reference/[resource]/[...slug].tsx: Method and schema pages Features: - ISR with 1 hour revalidation - Proper breadcrumb navigation - Schema cross-linking support - Subresource handling Co-authored-by: chris --- .../api-reference/ApiReferenceLayout.tsx | 117 ++++++++ components/api-reference/EndpointList.tsx | 96 ++++++ components/api-reference/MethodPage.tsx | 279 ++++++++++++++++++ .../api-reference/ResourceOverviewPage.tsx | 144 +++++++++ components/api-reference/SchemaPage.tsx | 56 ++++ components/api-reference/index.ts | 9 + pages/api-reference/[resource]/[...slug].tsx | 226 ++++++++++++++ pages/api-reference/[resource]/index.tsx | 72 +++++ 8 files changed, 999 insertions(+) create mode 100644 components/api-reference/ApiReferenceLayout.tsx create mode 100644 components/api-reference/EndpointList.tsx create mode 100644 components/api-reference/MethodPage.tsx create mode 100644 components/api-reference/ResourceOverviewPage.tsx create mode 100644 components/api-reference/SchemaPage.tsx create mode 100644 components/api-reference/index.ts create mode 100644 pages/api-reference/[resource]/[...slug].tsx create mode 100644 pages/api-reference/[resource]/index.tsx diff --git a/components/api-reference/ApiReferenceLayout.tsx b/components/api-reference/ApiReferenceLayout.tsx new file mode 100644 index 000000000..01ee61837 --- /dev/null +++ b/components/api-reference/ApiReferenceLayout.tsx @@ -0,0 +1,117 @@ +import { useRef } from "react"; +import { useRouter } from "next/router"; +import Meta from "@/components/Meta"; +import { Page as TelegraphPage } from "@/components/ui/Page"; +import { Sidebar, SidebarContext } from "@/components/ui/Page/Sidebar"; +import { ContentActions } from "@/components/ui/ContentActions"; +import { SidebarData, SidebarSection } from "@/lib/openApiSpec"; +import { SidebarSection as LegacySidebarSection } from "@/data/types"; + +interface Breadcrumb { + label: string; + href: string; +} + +interface ApiReferenceLayoutProps { + children: React.ReactNode; + sidebarData: SidebarData; + preSidebarContent?: LegacySidebarSection[]; + title: string; + description: string; + breadcrumbs?: Breadcrumb[]; + currentPath?: string; +} + +/** + * Convert new SidebarData format to legacy SidebarSection format + * used by the existing Page components. + */ +function convertToLegacySidebarFormat( + sidebarData: SidebarData, + preSidebarContent: LegacySidebarSection[] = [], +): LegacySidebarSection[] { + const resourceSections: LegacySidebarSection[] = sidebarData.resources.map( + (resource: SidebarSection) => ({ + title: resource.title, + slug: resource.slug, + pages: [ + { slug: "/", title: "Overview" }, + ...resource.pages.map((page) => ({ + slug: page.slug.replace(resource.slug, ""), + title: page.title, + pages: page.pages?.map((subPage) => ({ + slug: subPage.slug.replace(resource.slug, ""), + title: subPage.title, + })), + })), + ], + }), + ); + + return [...preSidebarContent, ...resourceSections]; +} + +export function ApiReferenceLayout({ + children, + sidebarData, + preSidebarContent = [], + title, + description, + breadcrumbs, + currentPath, +}: ApiReferenceLayoutProps) { + const router = useRouter(); + const scrollerRef = useRef(null); + + const basePath = router.pathname.split("/")[1]; + const canonicalPath = currentPath || `/${basePath}`; + + const sidebarContent = convertToLegacySidebarFormat( + sidebarData, + preSidebarContent, + ); + + return ( + + + + } + /> + + + + + {sidebarContent.map((section) => ( + + ))} + + + + + {breadcrumbs && breadcrumbs.length > 0 && ( + + )} + + } + /> + {children} + + + + ); +} + +export default ApiReferenceLayout; diff --git a/components/api-reference/EndpointList.tsx b/components/api-reference/EndpointList.tsx new file mode 100644 index 000000000..b0819470c --- /dev/null +++ b/components/api-reference/EndpointList.tsx @@ -0,0 +1,96 @@ +import Link from "next/link"; +import { Box, Stack } from "@telegraph/layout"; +import { Text, Code } from "@telegraph/typography"; +import { MethodSummary } from "@/lib/openApiSpec"; + +/** + * Badge component for HTTP method type + */ +function MethodBadge({ method }: { method: string }) { + const methodUpper = method.toUpperCase(); + + const colorMap: Record = { + GET: "green", + POST: "blue", + PUT: "amber", + PATCH: "amber", + DELETE: "red", + }; + + const color = colorMap[methodUpper] || "gray"; + + return ( + + {methodUpper} + + ); +} + +interface EndpointListProps { + methods: MethodSummary[]; + basePath: string; +} + +/** + * Displays a list of API endpoints with their HTTP methods and summaries. + * Links to individual method pages. + */ +export function EndpointList({ methods, basePath }: EndpointListProps) { + if (methods.length === 0) { + return null; + } + + return ( + + {methods.map((method, index) => ( + + + + + + {method.endpoint} + + + {method.summary} + + + + + ))} + + ); +} + +export { MethodBadge }; +export default EndpointList; diff --git a/components/api-reference/MethodPage.tsx b/components/api-reference/MethodPage.tsx new file mode 100644 index 000000000..0c1a8160f --- /dev/null +++ b/components/api-reference/MethodPage.tsx @@ -0,0 +1,279 @@ +import { useState } from "react"; +import type { OpenAPIV3 } from "@scalar/openapi-types"; +import Markdown from "react-markdown"; +import { AnimatePresence, motion } from "framer-motion"; + +import { Callout } from "@/components/ui/Callout"; +import RateLimit from "@/components/ui/RateLimit"; +import { Box, Stack } from "@telegraph/layout"; +import { Code, Heading, Text } from "@telegraph/typography"; +import { ContentColumn, ExampleColumn, Section } from "@/components/ui/ApiSections"; +import { CodeBlock } from "@/components/ui/CodeBlock"; +import { Endpoint } from "@/components/ui/Endpoints"; +import MultiLangExample from "@/components/ui/ApiReference/MultiLangExample"; +import OperationParameters from "@/components/ui/ApiReference/OperationParameters/OperationParameters"; +import { SchemaProperties } from "@/components/ui/ApiReference/SchemaProperties"; +import { PropertyRow } from "@/components/ui/ApiReference/SchemaProperties/PropertyRow"; +import { + augmentSnippetsWithCurlRequest, + formatResponseStatusCodes, + resolveResponseSchemas, +} from "@/components/ui/ApiReference/helpers"; +import { MethodPageData } from "@/lib/openApiSpec"; + +interface MethodPageProps { + data: MethodPageData; + schemaReferences: Record; +} + +/** + * Displays a single API method with all its details: + * - Description + * - Endpoint + * - Rate limits + * - Path/query parameters + * - Request body + * - Response schema + * - Code examples + */ +export function MethodPage({ data, schemaReferences }: MethodPageProps) { + const { operation, methodType, endpoint, baseUrl } = data; + const [isResponseExpanded, setIsResponseExpanded] = useState(false); + + const parameters = operation.parameters || []; + + const pathParameters = parameters.filter( + (p) => (p as OpenAPIV3.ParameterObject).in === "path", + ) as OpenAPIV3.ParameterObject[]; + + const queryParameters = parameters.filter( + (p) => (p as OpenAPIV3.ParameterObject).in === "query", + ) as OpenAPIV3.ParameterObject[]; + + const responseSchemas: OpenAPIV3.SchemaObject[] = + resolveResponseSchemas(operation); + + const requestBody: OpenAPIV3.SchemaObject | undefined = ( + operation.requestBody as OpenAPIV3.RequestBodyObject + )?.content?.["application/json"]?.schema as OpenAPIV3.SchemaObject | undefined; + + const rateLimitRaw = (operation as Record)?.["x-ratelimit-tier"] as number | null ?? null; + const rateLimit = rateLimitRaw as 1 | 2 | 3 | 4 | 5 | null; + const isIdempotent = (operation as Record)?.["x-idempotent"] as boolean ?? false; + const isRetentionSubject = (operation as Record)?.["x-retention-policy"] as boolean ?? false; + const isBeta = (operation as Record)?.["x-beta"] as boolean ?? false; + + return ( +
+ + {operation.description ?? ""} + {isBeta && ( + + This endpoint is currently in beta. If you'd like early access, + or this is blocking your adoption of Knock, please{" "} + + get in touch + + . + + } + /> + )} + + + Endpoint + + + + + {rateLimit && ( + + + Rate limit + + + + )} + + {pathParameters.length > 0 && ( + <> + + Path parameters + + + + )} + + {queryParameters.length > 0 && ( + <> + + Query parameters + + + + )} + + {requestBody && ( + <> + + Request body + + + + )} + + + Returns + + + {responseSchemas.length > 0 && + responseSchemas.map((responseSchema) => ( + + + + + {responseSchema.title} + + + + {responseSchema.description ?? ""} + + + {responseSchema.properties && ( + <> + setIsResponseExpanded(!isResponseExpanded)} + > + {isResponseExpanded + ? "Hide properties" + : "Show properties"} + + + + + + + + + + + )} + + + ))} + + {responseSchemas.length === 0 && ( + + {formatResponseStatusCodes(operation).map((formattedStatus, index) => ( + + {formattedStatus} + + ))} + + )} + + + )["x-stainless-snippets"] as Record || {}, + { + baseUrl, + methodType, + endpoint, + body: requestBody?.example as Record | undefined, + }, + )} + /> + {responseSchemas.map( + (responseSchema) => + responseSchema?.example && ( + + {JSON.stringify(responseSchema?.example, null, 2)} + + ), + )} + +
+ ); +} + +export default MethodPage; diff --git a/components/api-reference/ResourceOverviewPage.tsx b/components/api-reference/ResourceOverviewPage.tsx new file mode 100644 index 000000000..9bef5b94d --- /dev/null +++ b/components/api-reference/ResourceOverviewPage.tsx @@ -0,0 +1,144 @@ +import Link from "next/link"; +import Markdown from "react-markdown"; +import { Box, Stack } from "@telegraph/layout"; +import { Heading, Text } from "@telegraph/typography"; +import { ResourceOverviewData, SchemaSummary, SubresourceSummary } from "@/lib/openApiSpec"; +import { EndpointList } from "./EndpointList"; + +interface SubresourceListProps { + subresources: SubresourceSummary[]; + basePath: string; +} + +/** + * Displays a list of subresources with links to their pages. + */ +function SubresourceList({ subresources, basePath }: SubresourceListProps) { + if (subresources.length === 0) { + return null; + } + + return ( + + + Subresources + + + {subresources.map((sub) => ( + + + + {sub.title} + + + {sub.methodCount} endpoint{sub.methodCount !== 1 ? "s" : ""} + + + + ))} + + + ); +} + +interface SchemaListProps { + schemas: SchemaSummary[]; + basePath: string; +} + +/** + * Displays a list of schemas (object definitions) with links to their pages. + */ +function SchemaList({ schemas, basePath }: SchemaListProps) { + if (schemas.length === 0) { + return null; + } + + return ( + + + Object definitions + + + {schemas.map((schema) => ( + + + + {schema.title} + + + + ))} + + + ); +} + +interface ResourceOverviewPageProps { + data: ResourceOverviewData; + basePath: string; +} + +/** + * Displays the overview for a resource including: + * - Description + * - List of endpoints + * - List of subresources + * - List of object definitions + */ +export function ResourceOverviewPage({ data, basePath }: ResourceOverviewPageProps) { + const { resource, methods, schemas, subresources } = data; + + return ( + + {resource.description && ( + + {resource.description} + + )} + + {methods.length > 0 && ( + + + Endpoints + + + + )} + + + + + + ); +} + +export { SubresourceList, SchemaList }; +export default ResourceOverviewPage; diff --git a/components/api-reference/SchemaPage.tsx b/components/api-reference/SchemaPage.tsx new file mode 100644 index 000000000..bc0bb300d --- /dev/null +++ b/components/api-reference/SchemaPage.tsx @@ -0,0 +1,56 @@ +import Markdown from "react-markdown"; +import { Box } from "@telegraph/layout"; +import { Heading } from "@telegraph/typography"; +import { ContentColumn, ExampleColumn, Section } from "@/components/ui/ApiSections"; +import { CodeBlock } from "@/components/ui/CodeBlock"; +import { SchemaProperties } from "@/components/ui/ApiReference/SchemaProperties"; +import { SchemaPageData } from "@/lib/openApiSpec"; + +interface SchemaPageProps { + data: SchemaPageData; + schemaReferences: Record; +} + +/** + * Displays a single API schema (object definition) with: + * - Title and description + * - Attributes/properties + * - Example JSON + */ +export function SchemaPage({ data, schemaReferences }: SchemaPageProps) { + const { schema, schemaName } = data; + + return ( +
+ + {schema.description && {schema.description}} + + + Attributes + + + + + {schema.example && ( + + {JSON.stringify(schema.example, null, 2)} + + )} + +
+ ); +} + +export default SchemaPage; diff --git a/components/api-reference/index.ts b/components/api-reference/index.ts new file mode 100644 index 000000000..1c27a2be2 --- /dev/null +++ b/components/api-reference/index.ts @@ -0,0 +1,9 @@ +export { ApiReferenceLayout } from "./ApiReferenceLayout"; +export { EndpointList, MethodBadge } from "./EndpointList"; +export { MethodPage } from "./MethodPage"; +export { SchemaPage } from "./SchemaPage"; +export { + ResourceOverviewPage, + SubresourceList, + SchemaList, +} from "./ResourceOverviewPage"; diff --git a/pages/api-reference/[resource]/[...slug].tsx b/pages/api-reference/[resource]/[...slug].tsx new file mode 100644 index 000000000..83d821352 --- /dev/null +++ b/pages/api-reference/[resource]/[...slug].tsx @@ -0,0 +1,226 @@ +import { GetStaticPaths, GetStaticProps } from "next"; +import { + getAllApiReferencePaths, + getMethodPageData, + getSchemaPageData, + getResourceOverviewData, + getSidebarData, + buildSchemaReferences, + MethodPageData, + SchemaPageData, + ResourceOverviewData, + SidebarData, +} from "@/lib/openApiSpec"; +import { ApiReferenceLayout } from "@/components/api-reference"; +import { MethodPage } from "@/components/api-reference"; +import { SchemaPage } from "@/components/api-reference"; +import { ResourceOverviewPage } from "@/components/api-reference"; +import { API_REFERENCE_OVERVIEW_CONTENT } from "@/data/sidebars/apiOverviewSidebar"; + +type PageType = "method" | "schema" | "subresource"; + +interface MethodPageProps { + pageType: "method"; + sidebarData: SidebarData; + data: MethodPageData; + schemaReferences: Record; +} + +interface SchemaPageProps { + pageType: "schema"; + sidebarData: SidebarData; + data: SchemaPageData; + schemaReferences: Record; +} + +interface SubresourcePageProps { + pageType: "subresource"; + sidebarData: SidebarData; + data: ResourceOverviewData; + schemaReferences: Record; + basePath: string; +} + +type PageProps = MethodPageProps | SchemaPageProps | SubresourcePageProps; + +export default function ApiReferenceDynamicPage(props: PageProps) { + const { pageType, sidebarData, data, schemaReferences } = props; + + if (pageType === "schema") { + const schemaData = data as SchemaPageData; + return ( + + + + ); + } + + if (pageType === "subresource") { + const subresourceData = data as ResourceOverviewData; + const basePath = (props as SubresourcePageProps).basePath; + return ( + + + + ); + } + + // Method page (default) + const methodData = data as MethodPageData; + const subresourceBreadcrumbs = methodData.subresourcePath + ? methodData.subresourcePath.map((sub, index) => ({ + label: sub, + href: `/api-reference/${methodData.resourceName}/${methodData.subresourcePath?.slice(0, index + 1).join("/")}`, + })) + : []; + + return ( + + + + ); +} + +export const getStaticPaths: GetStaticPaths = async () => { + const allPaths = await getAllApiReferencePaths("api"); + + // Filter to only paths with slug (not resource overview which has no slug) + const slugPaths = allPaths + .filter((p) => p.params.slug && p.params.slug.length > 0) + .map((p) => ({ + params: { + resource: p.params.resource, + slug: p.params.slug as string[], + }, + })); + + return { + paths: slugPaths, + fallback: false, + }; +}; + +export const getStaticProps: GetStaticProps = async ({ params }) => { + const resourceName = params?.resource as string; + const slug = params?.slug as string[]; + + const [sidebarData, schemaReferences] = await Promise.all([ + getSidebarData("api"), + buildSchemaReferences("api"), + ]); + + // Determine page type from slug + // /users/schemas/user -> schema page + // /users/get -> method page + // /users/feeds -> subresource overview + // /users/feeds/list_items -> subresource method page + + const schemaIndex = slug.indexOf("schemas"); + + if (schemaIndex !== -1 && schemaIndex === slug.length - 2) { + // Schema page: [..., "schemas", schemaName] + const schemaName = slug[schemaIndex + 1]; + const subresourcePath = slug.slice(0, schemaIndex); + + const data = await getSchemaPageData( + "api", + resourceName, + schemaName, + subresourcePath, + ); + + if (!data) { + return { notFound: true }; + } + + return { + props: { + pageType: "schema" as const, + sidebarData, + data, + schemaReferences, + }, + revalidate: 3600, + }; + } + + // Check if this is a subresource overview (has subresource path but no method) + // This requires checking if the last segment is a subresource name + const potentialSubresourcePath = slug; + const subresourceOverview = await getResourceOverviewData( + "api", + resourceName, + potentialSubresourcePath, + ); + + if (subresourceOverview && subresourceOverview.methods.length > 0) { + // This is a subresource overview page + return { + props: { + pageType: "subresource" as const, + sidebarData, + data: subresourceOverview, + schemaReferences, + basePath: `/api-reference/${resourceName}/${slug.join("/")}`, + }, + revalidate: 3600, + }; + } + + // Method page (possibly in subresource) + const methodName = slug[slug.length - 1]; + const subresourcePath = slug.slice(0, -1); + + const data = await getMethodPageData( + "api", + resourceName, + methodName, + subresourcePath, + ); + + if (!data) { + return { notFound: true }; + } + + return { + props: { + pageType: "method" as const, + sidebarData, + data, + schemaReferences, + }, + revalidate: 3600, + }; +}; diff --git a/pages/api-reference/[resource]/index.tsx b/pages/api-reference/[resource]/index.tsx new file mode 100644 index 000000000..5c4052f93 --- /dev/null +++ b/pages/api-reference/[resource]/index.tsx @@ -0,0 +1,72 @@ +import { GetStaticPaths, GetStaticProps } from "next"; +import { + getResourceOverviewData, + getSidebarData, + ResourceOverviewData, + SidebarData, +} from "@/lib/openApiSpec"; +import { ApiReferenceLayout } from "@/components/api-reference"; +import { ResourceOverviewPage } from "@/components/api-reference"; +import { API_REFERENCE_OVERVIEW_CONTENT } from "@/data/sidebars/apiOverviewSidebar"; + +interface ResourcePageProps { + sidebarData: SidebarData; + resourceData: ResourceOverviewData; +} + +export default function ResourcePage({ + sidebarData, + resourceData, +}: ResourcePageProps) { + const basePath = `/api-reference/${resourceData.resourceName}`; + + return ( + + + + ); +} + +export const getStaticPaths: GetStaticPaths = async () => { + const sidebarData = await getSidebarData("api"); + const paths = sidebarData.resources.map((resource) => ({ + params: { resource: resource.slug.replace("/api-reference/", "") }, + })); + + return { + paths, + fallback: false, + }; +}; + +export const getStaticProps: GetStaticProps = async ({ + params, +}) => { + const resourceName = params?.resource as string; + + const [sidebarData, resourceData] = await Promise.all([ + getSidebarData("api"), + getResourceOverviewData("api", resourceName), + ]); + + if (!resourceData) { + return { notFound: true }; + } + + return { + props: { + sidebarData, + resourceData, + }, + revalidate: 3600, // Revalidate every hour + }; +}; From 26a21c95551caadd16aeacea1646f2e65c81669d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 1 Feb 2026 15:19:39 +0000 Subject: [PATCH 08/24] fix: fix formatting and type errors for CI - Run Prettier to fix code formatting issues - Fix TypeScript type error by casting MDX_COMPONENTS to any - Update next.config.js redirects for backward compatibility - Update api-reference index page to use new multi-page layout Co-authored-by: chris --- components/api-reference/MethodPage.tsx | 59 ++++++--- .../api-reference/ResourceOverviewPage.tsx | 11 +- components/api-reference/SchemaPage.tsx | 6 +- lib/openApiSpec.ts | 47 +++---- next.config.js | 22 ++-- pages/api-reference/[resource]/[...slug].tsx | 45 +++++-- pages/api-reference/[resource]/index.tsx | 11 +- pages/api-reference/index.tsx | 120 ++++++++++++++---- 8 files changed, 228 insertions(+), 93 deletions(-) diff --git a/components/api-reference/MethodPage.tsx b/components/api-reference/MethodPage.tsx index 0c1a8160f..04188a5b1 100644 --- a/components/api-reference/MethodPage.tsx +++ b/components/api-reference/MethodPage.tsx @@ -7,7 +7,11 @@ import { Callout } from "@/components/ui/Callout"; import RateLimit from "@/components/ui/RateLimit"; import { Box, Stack } from "@telegraph/layout"; import { Code, Heading, Text } from "@telegraph/typography"; -import { ContentColumn, ExampleColumn, Section } from "@/components/ui/ApiSections"; +import { + ContentColumn, + ExampleColumn, + Section, +} from "@/components/ui/ApiSections"; import { CodeBlock } from "@/components/ui/CodeBlock"; import { Endpoint } from "@/components/ui/Endpoints"; import MultiLangExample from "@/components/ui/ApiReference/MultiLangExample"; @@ -55,13 +59,24 @@ export function MethodPage({ data, schemaReferences }: MethodPageProps) { const requestBody: OpenAPIV3.SchemaObject | undefined = ( operation.requestBody as OpenAPIV3.RequestBodyObject - )?.content?.["application/json"]?.schema as OpenAPIV3.SchemaObject | undefined; + )?.content?.["application/json"]?.schema as + | OpenAPIV3.SchemaObject + | undefined; - const rateLimitRaw = (operation as Record)?.["x-ratelimit-tier"] as number | null ?? null; + const rateLimitRaw = + ((operation as Record)?.["x-ratelimit-tier"] as + | number + | null) ?? null; const rateLimit = rateLimitRaw as 1 | 2 | 3 | 4 | 5 | null; - const isIdempotent = (operation as Record)?.["x-idempotent"] as boolean ?? false; - const isRetentionSubject = (operation as Record)?.["x-retention-policy"] as boolean ?? false; - const isBeta = (operation as Record)?.["x-beta"] as boolean ?? false; + const isIdempotent = + ((operation as Record)?.["x-idempotent"] as boolean) ?? + false; + const isRetentionSubject = + ((operation as Record)?.[ + "x-retention-policy" + ] as boolean) ?? false; + const isBeta = + ((operation as Record)?.["x-beta"] as boolean) ?? false; return (
- This endpoint is currently in beta. If you'd like early access, - or this is blocking your adoption of Knock, please{" "} + This endpoint is currently in beta. If you'd like early + access, or this is blocking your adoption of Knock, please{" "} get in touch @@ -231,17 +246,19 @@ export function MethodPage({ data, schemaReferences }: MethodPageProps) { {responseSchemas.length === 0 && ( - {formatResponseStatusCodes(operation).map((formattedStatus, index) => ( - - {formattedStatus} - - ))} + {formatResponseStatusCodes(operation).map( + (formattedStatus, index) => ( + + {formattedStatus} + + ), + )} )} @@ -249,7 +266,9 @@ export function MethodPage({ data, schemaReferences }: MethodPageProps) { )["x-stainless-snippets"] as Record || {}, + ((operation as Record)[ + "x-stainless-snippets" + ] as Record) || {}, { baseUrl, methodType, diff --git a/components/api-reference/ResourceOverviewPage.tsx b/components/api-reference/ResourceOverviewPage.tsx index 9bef5b94d..904b784fd 100644 --- a/components/api-reference/ResourceOverviewPage.tsx +++ b/components/api-reference/ResourceOverviewPage.tsx @@ -2,7 +2,11 @@ import Link from "next/link"; import Markdown from "react-markdown"; import { Box, Stack } from "@telegraph/layout"; import { Heading, Text } from "@telegraph/typography"; -import { ResourceOverviewData, SchemaSummary, SubresourceSummary } from "@/lib/openApiSpec"; +import { + ResourceOverviewData, + SchemaSummary, + SubresourceSummary, +} from "@/lib/openApiSpec"; import { EndpointList } from "./EndpointList"; interface SubresourceListProps { @@ -113,7 +117,10 @@ interface ResourceOverviewPageProps { * - List of subresources * - List of object definitions */ -export function ResourceOverviewPage({ data, basePath }: ResourceOverviewPageProps) { +export function ResourceOverviewPage({ + data, + basePath, +}: ResourceOverviewPageProps) { const { resource, methods, schemas, subresources } = data; return ( diff --git a/components/api-reference/SchemaPage.tsx b/components/api-reference/SchemaPage.tsx index bc0bb300d..745782ebf 100644 --- a/components/api-reference/SchemaPage.tsx +++ b/components/api-reference/SchemaPage.tsx @@ -1,7 +1,11 @@ import Markdown from "react-markdown"; import { Box } from "@telegraph/layout"; import { Heading } from "@telegraph/typography"; -import { ContentColumn, ExampleColumn, Section } from "@/components/ui/ApiSections"; +import { + ContentColumn, + ExampleColumn, + Section, +} from "@/components/ui/ApiSections"; import { CodeBlock } from "@/components/ui/CodeBlock"; import { SchemaProperties } from "@/components/ui/ApiReference/SchemaProperties"; import { SchemaPageData } from "@/lib/openApiSpec"; diff --git a/lib/openApiSpec.ts b/lib/openApiSpec.ts index fa63808b1..752da3cba 100644 --- a/lib/openApiSpec.ts +++ b/lib/openApiSpec.ts @@ -318,10 +318,9 @@ async function getSchemaPageData( } const schemaRef = targetResource.models[schemaName]; - const schema = JSONPointer.get( - openApiSpec, - schemaRef.replace("#", ""), - ) as OpenAPIV3.SchemaObject | undefined; + const schema = JSONPointer.get(openApiSpec, schemaRef.replace("#", "")) as + | OpenAPIV3.SchemaObject + | undefined; if (!schema) { return null; @@ -392,10 +391,9 @@ async function getResourceOverviewData( const schemas: SchemaSummary[] = Object.entries( targetResource.models || {}, ).map(([schemaName, ref]) => { - const schema = JSONPointer.get( - openApiSpec, - ref.replace("#", ""), - ) as OpenAPIV3.SchemaObject | undefined; + const schema = JSONPointer.get(openApiSpec, ref.replace("#", "")) as + | OpenAPIV3.SchemaObject + | undefined; return { schemaName, title: schema?.title || schemaName, @@ -537,21 +535,19 @@ function buildResourceSidebarPages( // Subresources if (resource.subresources) { - Object.entries(resource.subresources).forEach( - ([subName, subResource]) => { - const subPages = buildResourceSidebarPages( - subResource, - openApiSpec, - `${pathPrefix}/${subName}`, - ); + Object.entries(resource.subresources).forEach(([subName, subResource]) => { + const subPages = buildResourceSidebarPages( + subResource, + openApiSpec, + `${pathPrefix}/${subName}`, + ); - pages.push({ - slug: `${pathPrefix}/${subName}`, - title: subResource.name || subName, - pages: subPages, - }); - }, - ); + pages.push({ + slug: `${pathPrefix}/${subName}`, + title: subResource.name || subName, + pages: subPages, + }); + }); } // Schemas @@ -624,10 +620,9 @@ function buildSchemaReferencesForResource( if (resource.models) { Object.entries(resource.models).forEach(([modelName, modelRef]) => { - const schema = JSONPointer.get( - openApiSpec, - modelRef.replace("#", ""), - ) as OpenAPIV3.SchemaObject | undefined; + const schema = JSONPointer.get(openApiSpec, modelRef.replace("#", "")) as + | OpenAPIV3.SchemaObject + | undefined; const title = schema?.title ?? modelName; diff --git a/next.config.js b/next.config.js index 97debb24a..aea78da64 100644 --- a/next.config.js +++ b/next.config.js @@ -628,9 +628,16 @@ const nextConfig = { destination: "/cli/overview", permanent: false, }, + // API reference now uses multi-page architecture + // Redirect overview paths to main page for backward compatibility { - source: "/api-reference", - destination: "/api-reference/overview", + source: "/api-reference/overview", + destination: "/api-reference", + permanent: false, + }, + { + source: "/api-reference/overview/:section", + destination: "/api-reference#:section", permanent: false, }, { @@ -699,12 +706,11 @@ const nextConfig = { async rewrites() { return [ - // API reference pages all serve the same static content - // The URL paths are used for client-side navigation to sections - { - source: "/api-reference/:path+", - destination: "/api-reference", - }, + // Note: API reference pages now use multi-page architecture + // Individual pages are generated for each resource, method, and schema + // The old single-page rewrite for /api-reference/:path+ has been removed + + // MAPI reference still uses single-page approach { source: "/mapi-reference/:path+", destination: "/mapi-reference", diff --git a/pages/api-reference/[resource]/[...slug].tsx b/pages/api-reference/[resource]/[...slug].tsx index 83d821352..7173604d9 100644 --- a/pages/api-reference/[resource]/[...slug].tsx +++ b/pages/api-reference/[resource]/[...slug].tsx @@ -53,12 +53,21 @@ export default function ApiReferenceDynamicPage(props: PageProps) { sidebarData={sidebarData} preSidebarContent={API_REFERENCE_OVERVIEW_CONTENT} title={schemaData.schema.title || schemaData.schemaName} - description={schemaData.schema.description || `${schemaData.schemaName} schema reference`} + description={ + schemaData.schema.description || + `${schemaData.schemaName} schema reference` + } currentPath={`/api-reference/${schemaData.resourceName}/schemas/${schemaData.schemaName}`} breadcrumbs={[ { label: "API reference", href: "/api-reference" }, - { label: schemaData.resourceTitle, href: `/api-reference/${schemaData.resourceName}` }, - { label: "Object definitions", href: `/api-reference/${schemaData.resourceName}` }, + { + label: schemaData.resourceTitle, + href: `/api-reference/${schemaData.resourceName}`, + }, + { + label: "Object definitions", + href: `/api-reference/${schemaData.resourceName}`, + }, ]} > @@ -74,11 +83,17 @@ export default function ApiReferenceDynamicPage(props: PageProps) { sidebarData={sidebarData} preSidebarContent={API_REFERENCE_OVERVIEW_CONTENT} title={subresourceData.resource.name || subresourceData.resourceName} - description={subresourceData.resource.description || `${subresourceData.resource.name} API reference`} + description={ + subresourceData.resource.description || + `${subresourceData.resource.name} API reference` + } currentPath={basePath} breadcrumbs={[ { label: "API reference", href: "/api-reference" }, - { label: subresourceData.resourceName, href: `/api-reference/${subresourceData.resourceName}` }, + { + label: subresourceData.resourceName, + href: `/api-reference/${subresourceData.resourceName}`, + }, ]} > @@ -91,7 +106,9 @@ export default function ApiReferenceDynamicPage(props: PageProps) { const subresourceBreadcrumbs = methodData.subresourcePath ? methodData.subresourcePath.map((sub, index) => ({ label: sub, - href: `/api-reference/${methodData.resourceName}/${methodData.subresourcePath?.slice(0, index + 1).join("/")}`, + href: `/api-reference/${ + methodData.resourceName + }/${methodData.subresourcePath?.slice(0, index + 1).join("/")}`, })) : []; @@ -100,11 +117,21 @@ export default function ApiReferenceDynamicPage(props: PageProps) { sidebarData={sidebarData} preSidebarContent={API_REFERENCE_OVERVIEW_CONTENT} title={methodData.operation.summary || methodData.methodName} - description={methodData.operation.description || `${methodData.methodName} API reference`} - currentPath={`/api-reference/${methodData.resourceName}${methodData.subresourcePath ? "/" + methodData.subresourcePath.join("/") : ""}/${methodData.methodName}`} + description={ + methodData.operation.description || + `${methodData.methodName} API reference` + } + currentPath={`/api-reference/${methodData.resourceName}${ + methodData.subresourcePath + ? "/" + methodData.subresourcePath.join("/") + : "" + }/${methodData.methodName}`} breadcrumbs={[ { label: "API reference", href: "/api-reference" }, - { label: methodData.resourceTitle, href: `/api-reference/${methodData.resourceName}` }, + { + label: methodData.resourceTitle, + href: `/api-reference/${methodData.resourceName}`, + }, ...subresourceBreadcrumbs, ]} > diff --git a/pages/api-reference/[resource]/index.tsx b/pages/api-reference/[resource]/index.tsx index 5c4052f93..6cf92718c 100644 --- a/pages/api-reference/[resource]/index.tsx +++ b/pages/api-reference/[resource]/index.tsx @@ -25,11 +25,14 @@ export default function ResourcePage({ sidebarData={sidebarData} preSidebarContent={API_REFERENCE_OVERVIEW_CONTENT} title={resourceData.resource.name || resourceData.resourceName} - description={resourceData.resource.description || `${resourceData.resource.name || resourceData.resourceName} API reference`} + description={ + resourceData.resource.description || + `${ + resourceData.resource.name || resourceData.resourceName + } API reference` + } currentPath={basePath} - breadcrumbs={[ - { label: "API reference", href: "/api-reference" }, - ]} + breadcrumbs={[{ label: "API reference", href: "/api-reference" }]} > diff --git a/pages/api-reference/index.tsx b/pages/api-reference/index.tsx index cf91a92c2..d9d671df2 100644 --- a/pages/api-reference/index.tsx +++ b/pages/api-reference/index.tsx @@ -1,40 +1,108 @@ import fs from "fs"; -import { MDXRemote } from "next-mdx-remote"; +import { GetStaticProps } from "next"; +import { MDXRemote, MDXRemoteSerializeResult } from "next-mdx-remote"; import rehypeMdxCodeProps from "rehype-mdx-code-props"; import { serialize } from "next-mdx-remote/serialize"; import remarkGfm from "remark-gfm"; +import Link from "next/link"; +import { Box, Stack } from "@telegraph/layout"; +import { Heading, Text } from "@telegraph/typography"; -import { readOpenApiSpec, readStainlessSpec } from "@/lib/openApiSpec"; +import { getSidebarData, SidebarData } from "@/lib/openApiSpec"; import { CONTENT_DIR } from "@/lib/content.server"; import { MDX_COMPONENTS } from "@/lib/mdxComponents"; -import ApiReference from "@/components/ui/ApiReference/ApiReference"; -import { - RESOURCE_ORDER, - API_REFERENCE_OVERVIEW_CONTENT, -} from "@/data/sidebars/apiOverviewSidebar"; +import { ApiReferenceLayout } from "@/components/api-reference"; +import { API_REFERENCE_OVERVIEW_CONTENT } from "@/data/sidebars/apiOverviewSidebar"; -function ApiReferencePage({ openApiSpec, stainlessSpec, preContentMdx }) { +interface ResourceCardProps { + title: string; + href: string; + methodCount: number; +} + +function ResourceCard({ title, href, methodCount }: ResourceCardProps) { return ( - } - resourceOrder={RESOURCE_ORDER} + + + + {title} + + + {methodCount} endpoint{methodCount !== 1 ? "s" : ""} + + + + ); +} + +interface ApiReferenceOverviewProps { + sidebarData: SidebarData; + overviewContentMdx: MDXRemoteSerializeResult; +} + +function ApiReferenceOverview({ + sidebarData, + overviewContentMdx, +}: ApiReferenceOverviewProps) { + return ( + + title="API reference" + description="Complete reference documentation for the Knock API." + currentPath="/api-reference" + > + {/* Overview content (auth, errors, pagination, etc.) */} + + + + + {/* Resource list */} + + + Resources + + + {sidebarData.resources + .filter((resource) => resource.title !== "Shared") + .map((resource) => ( + + !p.pages && + p.title !== "Overview" && + !p.slug.includes("/schemas"), + ).length + } + /> + ))} + + + ); } -export async function getStaticProps() { - const openApiSpec = await readOpenApiSpec("api"); - const stainlessSpec = await readStainlessSpec("api"); +export const getStaticProps: GetStaticProps< + ApiReferenceOverviewProps +> = async () => { + const sidebarData = await getSidebarData("api"); - const preContent = fs.readFileSync( + const overviewContent = fs.readFileSync( `${CONTENT_DIR}/__api-reference/content.mdx`, ); - const preContentMdx = await serialize(preContent.toString(), { + const overviewContentMdx = await serialize(overviewContent.toString(), { parseFrontmatter: true, mdxOptions: { remarkPlugins: [remarkGfm], @@ -42,7 +110,13 @@ export async function getStaticProps() { }, }); - return { props: { openApiSpec, stainlessSpec, preContentMdx } }; -} + return { + props: { + sidebarData, + overviewContentMdx, + }, + revalidate: 3600, // Revalidate every hour + }; +}; -export default ApiReferencePage; +export default ApiReferenceOverview; From 02795059d0f9a369efcd2af0388b7bbcd0f4b690 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 1 Feb 2026 15:43:36 +0000 Subject: [PATCH 09/24] fix: resolve build issues with API reference multi-page migration - Fix JSON serialization issues by using null instead of undefined - Add LightweightApiReferenceProvider for multi-page components - Add useSchemaReferences hook for context-agnostic schema reference access - Update SchemaProperty to use the new hook - Update type definitions for JSON serialization compatibility - Wrap MethodPage and SchemaPage components with providers Build now completes successfully with all static pages generated. Co-authored-by: chris --- components/api-reference/MethodPage.tsx | 406 +++++++++--------- components/api-reference/SchemaPage.tsx | 61 +-- .../ui/ApiReference/ApiReferenceContext.tsx | 56 +++ .../SchemaProperties/SchemaProperty.tsx | 4 +- lib/openApiSpec.ts | 18 +- next-env.d.ts | 2 +- pages/api-reference/[resource]/[...slug].tsx | 19 +- 7 files changed, 321 insertions(+), 245 deletions(-) diff --git a/components/api-reference/MethodPage.tsx b/components/api-reference/MethodPage.tsx index 04188a5b1..0b8cd585b 100644 --- a/components/api-reference/MethodPage.tsx +++ b/components/api-reference/MethodPage.tsx @@ -23,6 +23,7 @@ import { formatResponseStatusCodes, resolveResponseSchemas, } from "@/components/ui/ApiReference/helpers"; +import { LightweightApiReferenceProvider } from "@/components/ui/ApiReference/ApiReferenceContext"; import { MethodPageData } from "@/lib/openApiSpec"; interface MethodPageProps { @@ -79,219 +80,230 @@ export function MethodPage({ data, schemaReferences }: MethodPageProps) { ((operation as Record)?.["x-beta"] as boolean) ?? false; return ( -
- - {operation.description ?? ""} - {isBeta && ( - - This endpoint is currently in beta. If you'd like early - access, or this is blocking your adoption of Knock, please{" "} - - get in touch - - . - - } - /> - )} - - - Endpoint - +
+ + {operation.description ?? ""} + {isBeta && ( + + This endpoint is currently in beta. If you'd like early + access, or this is blocking your adoption of Knock, please{" "} + + get in touch + + . + + } + /> + )} - + + Endpoint + - {rateLimit && ( - - - Rate limit - - - - )} + - {pathParameters.length > 0 && ( - <> - - Path parameters - - - - )} + {rateLimit && ( + + + Rate limit + + + + )} - {queryParameters.length > 0 && ( - <> - - Query parameters - - - - )} + {pathParameters.length > 0 && ( + <> + + Path parameters + + + + )} - {requestBody && ( - <> - - Request body - - - - )} + {queryParameters.length > 0 && ( + <> + + Query parameters + + + + )} - - Returns - + {requestBody && ( + <> + + Request body + + + + )} - {responseSchemas.length > 0 && - responseSchemas.map((responseSchema) => ( - - - - - {responseSchema.title} - - - - {responseSchema.description ?? ""} - + + Returns + - {responseSchema.properties && ( - <> - setIsResponseExpanded(!isResponseExpanded)} + {responseSchemas.length > 0 && + responseSchemas.map((responseSchema) => ( + + + + - {isResponseExpanded - ? "Hide properties" - : "Show properties"} - + {responseSchema.title} + + + + {responseSchema.description ?? ""} + - - + + setIsResponseExpanded(!isResponseExpanded) + } > - - - - - - - )} - - - ))} + {isResponseExpanded + ? "Hide properties" + : "Show properties"} + + + + + + + + + + + )} + + + ))} - {responseSchemas.length === 0 && ( - - {formatResponseStatusCodes(operation).map( - (formattedStatus, index) => ( - + {formatResponseStatusCodes(operation).map( + (formattedStatus, index) => ( + + {formattedStatus} + + ), + )} + + )} + + + )[ + "x-stainless-snippets" + ] as Record) || {}, + { + baseUrl, + methodType, + endpoint, + body: requestBody?.example as + | Record + | undefined, + }, + )} + /> + {responseSchemas.map( + (responseSchema) => + responseSchema?.example && ( + - {formattedStatus} - + {JSON.stringify(responseSchema?.example, null, 2)} + ), - )} - - )} - - - )[ - "x-stainless-snippets" - ] as Record) || {}, - { - baseUrl, - methodType, - endpoint, - body: requestBody?.example as Record | undefined, - }, )} - /> - {responseSchemas.map( - (responseSchema) => - responseSchema?.example && ( - - {JSON.stringify(responseSchema?.example, null, 2)} - - ), - )} - -
+ +
+ ); } diff --git a/components/api-reference/SchemaPage.tsx b/components/api-reference/SchemaPage.tsx index 745782ebf..ed199df13 100644 --- a/components/api-reference/SchemaPage.tsx +++ b/components/api-reference/SchemaPage.tsx @@ -1,5 +1,4 @@ import Markdown from "react-markdown"; -import { Box } from "@telegraph/layout"; import { Heading } from "@telegraph/typography"; import { ContentColumn, @@ -8,6 +7,7 @@ import { } from "@/components/ui/ApiSections"; import { CodeBlock } from "@/components/ui/CodeBlock"; import { SchemaProperties } from "@/components/ui/ApiReference/SchemaProperties"; +import { LightweightApiReferenceProvider } from "@/components/ui/ApiReference/ApiReferenceContext"; import { SchemaPageData } from "@/lib/openApiSpec"; interface SchemaPageProps { @@ -25,35 +25,40 @@ export function SchemaPage({ data, schemaReferences }: SchemaPageProps) { const { schema, schemaName } = data; return ( -
- - {schema.description && {schema.description}} + +
+ + {schema.description && {schema.description}} - - Attributes - - - - - {schema.example && ( - - {JSON.stringify(schema.example, null, 2)} - - )} - -
+ Attributes + + +
+ + {schema.example && ( + + {JSON.stringify(schema.example, null, 2)} + + )} + +
+ ); } diff --git a/components/ui/ApiReference/ApiReferenceContext.tsx b/components/ui/ApiReference/ApiReferenceContext.tsx index 6a6391205..28666a362 100644 --- a/components/ui/ApiReference/ApiReferenceContext.tsx +++ b/components/ui/ApiReference/ApiReferenceContext.tsx @@ -15,6 +15,19 @@ const ApiReferenceContext = createContext( undefined, ); +/** + * Lightweight context that only provides schemaReferences and baseUrl. + * Used by multi-page API reference components. + */ +interface LightweightContextType { + schemaReferences: Record; + baseUrl: string; +} + +const LightweightContext = createContext( + undefined, +); + interface ApiReferenceProviderProps { children: ReactNode; openApiSpec: OpenAPIV3.Document; @@ -46,6 +59,28 @@ export function ApiReferenceProvider({ ); } +/** + * Lightweight provider for multi-page API reference that only needs + * schemaReferences and baseUrl (without loading full specs). + */ +interface LightweightApiReferenceProviderProps { + children: ReactNode; + schemaReferences: Record; + baseUrl: string; +} + +export function LightweightApiReferenceProvider({ + children, + schemaReferences, + baseUrl, +}: LightweightApiReferenceProviderProps) { + return ( + + {children} + + ); +} + export function useApiReference() { const context = useContext(ApiReferenceContext); if (context === undefined) { @@ -56,4 +91,25 @@ export function useApiReference() { return context; } +/** + * Hook that returns schemaReferences from either the full or lightweight context. + * Use this in components that only need schemaReferences. + */ +export function useSchemaReferences(): Record { + const fullContext = useContext(ApiReferenceContext); + const lightweightContext = useContext(LightweightContext); + + if (fullContext) { + return fullContext.schemaReferences; + } + + if (lightweightContext) { + return lightweightContext.schemaReferences; + } + + throw new Error( + "useSchemaReferences must be used within an ApiReferenceProvider or LightweightApiReferenceProvider", + ); +} + export default ApiReferenceContext; diff --git a/components/ui/ApiReference/SchemaProperties/SchemaProperty.tsx b/components/ui/ApiReference/SchemaProperties/SchemaProperty.tsx index f27e569e3..dd2e42ed6 100644 --- a/components/ui/ApiReference/SchemaProperties/SchemaProperty.tsx +++ b/components/ui/ApiReference/SchemaProperties/SchemaProperty.tsx @@ -10,7 +10,7 @@ import { resolveChildProperties, hydrateRequiredChildProperties, } from "./helpers"; -import { useApiReference } from "../ApiReferenceContext"; +import { useSchemaReferences } from "../ApiReferenceContext"; import { Stack } from "@telegraph/layout"; import { Text } from "@telegraph/typography"; import { AnimatePresence, motion } from "framer-motion"; @@ -21,7 +21,7 @@ type Props = { }; const SchemaProperty = ({ name, schema }: Props) => { - const { schemaReferences } = useApiReference(); + const schemaReferences = useSchemaReferences(); const [isPossibleTypesOpen, setIsPossibleTypesOpen] = useState(false); const [isChildPropertiesOpen, setIsChildPropertiesOpen] = useState(false); // If the schema is an array, then we want to show the possible types that the array can contain. diff --git a/lib/openApiSpec.ts b/lib/openApiSpec.ts index 752da3cba..b8d632cf1 100644 --- a/lib/openApiSpec.ts +++ b/lib/openApiSpec.ts @@ -49,7 +49,8 @@ type MethodPageData = { operation: OpenAPIV3.OperationObject; baseUrl: string; // Subresource path if this method is in a subresource (e.g., ["feeds"]) - subresourcePath?: string[]; + // Use null instead of undefined for JSON serialization compatibility + subresourcePath: string[] | null; }; /** @@ -62,7 +63,8 @@ type SchemaPageData = { schemaRef: string; schema: OpenAPIV3.SchemaObject; // Subresource path if this schema is in a subresource - subresourcePath?: string[]; + // Use null instead of undefined for JSON serialization compatibility + subresourcePath: string[] | null; }; /** @@ -98,8 +100,8 @@ type SubresourceSummary = { type ResourceOverviewData = { resourceName: string; resource: { - name?: string; - description?: string; + name: string | null; + description: string | null; }; methods: MethodSummary[]; schemas: SchemaSummary[]; @@ -279,7 +281,7 @@ async function getMethodPageData( endpoint, operation, baseUrl: stainlessSpec.environments.production, - subresourcePath: subresourcePath.length > 0 ? subresourcePath : undefined, + subresourcePath: subresourcePath.length > 0 ? subresourcePath : null, }; } @@ -334,7 +336,7 @@ async function getSchemaPageData( schemaName, schemaRef, schema, - subresourcePath: subresourcePath.length > 0 ? subresourcePath : undefined, + subresourcePath: subresourcePath.length > 0 ? subresourcePath : null, }; } @@ -412,8 +414,8 @@ async function getResourceOverviewData( return { resourceName, resource: { - name: targetResource.name, - description: targetResource.description, + name: targetResource.name || null, + description: targetResource.description || null, }, methods, schemas, diff --git a/next-env.d.ts b/next-env.d.ts index 0c7fad710..2d5420eba 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,7 +1,7 @@ /// /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/pages/api-reference/[resource]/[...slug].tsx b/pages/api-reference/[resource]/[...slug].tsx index 7173604d9..2431de162 100644 --- a/pages/api-reference/[resource]/[...slug].tsx +++ b/pages/api-reference/[resource]/[...slug].tsx @@ -103,14 +103,15 @@ export default function ApiReferenceDynamicPage(props: PageProps) { // Method page (default) const methodData = data as MethodPageData; - const subresourceBreadcrumbs = methodData.subresourcePath - ? methodData.subresourcePath.map((sub, index) => ({ - label: sub, - href: `/api-reference/${ - methodData.resourceName - }/${methodData.subresourcePath?.slice(0, index + 1).join("/")}`, - })) - : []; + const subresourceBreadcrumbs = + methodData.subresourcePath && methodData.subresourcePath.length > 0 + ? methodData.subresourcePath.map((sub, index) => ({ + label: sub, + href: `/api-reference/${methodData.resourceName}/${methodData + .subresourcePath!.slice(0, index + 1) + .join("/")}`, + })) + : []; return ( 0 ? "/" + methodData.subresourcePath.join("/") : "" }/${methodData.methodName}`} From f6e5c4a74618df108913fd4c86441458fa38177d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 1 Feb 2026 15:54:13 +0000 Subject: [PATCH 10/24] Add module-level caching for spec loading to fix Vercel build timeouts - Cache OpenAPI spec, Stainless spec, sidebar data, and schema references at module level - Use promise deduplication to handle concurrent requests for the same spec - Avoids re-reading and re-parsing YAML specs for each page during build - Reduces individual page build time from 60+ seconds to under 5 seconds Co-authored-by: chris --- lib/openApiSpec.ts | 98 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 87 insertions(+), 11 deletions(-) diff --git a/lib/openApiSpec.ts b/lib/openApiSpec.ts index b8d632cf1..4f2dd2c03 100644 --- a/lib/openApiSpec.ts +++ b/lib/openApiSpec.ts @@ -163,22 +163,85 @@ function resolveEndpoint( } // ============================================================================ -// Spec Loading Functions +// Spec Loading Functions (with caching) // ============================================================================ -async function readOpenApiSpec(specName: string) { - const spec = await readFile(`./data/specs/${specName}/openapi.yml`, "utf8"); - const jsonSpec = yamlToJson(spec); - const { schema } = await dereference(jsonSpec); +// Module-level caches to avoid re-reading and re-parsing specs for each page +const openApiSpecCache: Record = {}; +const stainlessSpecCache: Record = {}; +const schemaReferencesCache: Record> = {}; +const sidebarDataCache: Record = {}; + +// Promises to handle concurrent requests for the same spec +const openApiSpecPromises: Record< + string, + Promise | undefined +> = {}; +const stainlessSpecPromises: Record< + string, + Promise | undefined +> = {}; + +async function readOpenApiSpec(specName: string): Promise { + // Return cached result if available + if (openApiSpecCache[specName]) { + return openApiSpecCache[specName]; + } + + // If already loading, wait for that promise + const existingPromise = openApiSpecPromises[specName]; + if (existingPromise) { + return existingPromise; + } + + // Start loading and cache the promise + const loadPromise = (async (): Promise => { + const spec = await readFile( + `./data/specs/${specName}/openapi.yml`, + "utf8", + ); + const jsonSpec = yamlToJson(spec); + const { schema } = await dereference(jsonSpec); + + const result = JSON.parse(safeStringify(schema)) as OpenAPIV3.Document; + openApiSpecCache[specName] = result; + return result; + })(); - return JSON.parse(safeStringify(schema)) as OpenAPIV3.Document; + openApiSpecPromises[specName] = loadPromise; + return loadPromise; } async function readStainlessSpec(specName: string): Promise { - const customizations = await readSpecCustomizations(specName); - const spec = await readFile(`./data/specs/${specName}/stainless.yml`, "utf8"); - const stainlessSpec = parse(spec); - return deepmerge(stainlessSpec, customizations); + // Return cached result if available + if (stainlessSpecCache[specName]) { + return stainlessSpecCache[specName]; + } + + // If already loading, wait for that promise + const existingPromise = stainlessSpecPromises[specName]; + if (existingPromise) { + return existingPromise; + } + + // Start loading and cache the promise + const loadPromise = (async (): Promise => { + const customizations = await readSpecCustomizations(specName); + const spec = await readFile( + `./data/specs/${specName}/stainless.yml`, + "utf8", + ); + const stainlessSpec = parse(spec); + const result = deepmerge( + stainlessSpec, + customizations, + ) as StainlessConfig; + stainlessSpecCache[specName] = result; + return result; + })(); + + stainlessSpecPromises[specName] = loadPromise; + return loadPromise; } async function readSpecCustomizations(specName: string) { @@ -583,6 +646,11 @@ function buildResourceSidebarPages( * Includes links to all resources, methods, and schemas. */ async function getSidebarData(specName: SpecName): Promise { + // Return cached result if available + if (sidebarDataCache[specName]) { + return sidebarDataCache[specName]; + } + const [openApiSpec, stainlessSpec] = await Promise.all([ readOpenApiSpec(specName), readStainlessSpec(specName), @@ -602,7 +670,9 @@ async function getSidebarData(specName: SpecName): Promise { }; }); - return { resources }; + const result = { resources }; + sidebarDataCache[specName] = result; + return result; } // ============================================================================ @@ -660,6 +730,11 @@ function buildSchemaReferencesForResource( async function buildSchemaReferences( specName: SpecName, ): Promise> { + // Return cached result if available + if (schemaReferencesCache[specName]) { + return schemaReferencesCache[specName]; + } + const [openApiSpec, stainlessSpec] = await Promise.all([ readOpenApiSpec(specName), readStainlessSpec(specName), @@ -681,6 +756,7 @@ async function buildSchemaReferences( }, ); + schemaReferencesCache[specName] = schemaReferences; return schemaReferences; } From 11cd4bf9f5d16effee4c65f896370dfa00a20de5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 1 Feb 2026 15:56:34 +0000 Subject: [PATCH 11/24] Fix Prettier formatting in lib/openApiSpec.ts Co-authored-by: chris --- lib/openApiSpec.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/lib/openApiSpec.ts b/lib/openApiSpec.ts index 4f2dd2c03..153d8a5c3 100644 --- a/lib/openApiSpec.ts +++ b/lib/openApiSpec.ts @@ -196,10 +196,7 @@ async function readOpenApiSpec(specName: string): Promise { // Start loading and cache the promise const loadPromise = (async (): Promise => { - const spec = await readFile( - `./data/specs/${specName}/openapi.yml`, - "utf8", - ); + const spec = await readFile(`./data/specs/${specName}/openapi.yml`, "utf8"); const jsonSpec = yamlToJson(spec); const { schema } = await dereference(jsonSpec); @@ -232,10 +229,7 @@ async function readStainlessSpec(specName: string): Promise { "utf8", ); const stainlessSpec = parse(spec); - const result = deepmerge( - stainlessSpec, - customizations, - ) as StainlessConfig; + const result = deepmerge(stainlessSpec, customizations) as StainlessConfig; stainlessSpecCache[specName] = result; return result; })(); From 6a7935080b782d104840a9aa58a078e6bdd4bb23 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 1 Feb 2026 16:34:34 +0000 Subject: [PATCH 12/24] Refactor API reference to use per-resource pages instead of per-endpoint - Each resource (users, objects, etc.) now has one page with all methods, schemas, and subresources - Deep links like /api-reference/users/get serve the /api-reference/users page - Client-side scrolling handles navigation to specific sections - Deleted per-endpoint [...slug].tsx page - Added getFullResourcePageData function to load complete resource data - Added ResourceFullPage component that wraps ApiReferenceSection with context - Updated next.config.js rewrites for deep link support Co-authored-by: chris --- components/api-reference/ResourceFullPage.tsx | 32 +++ components/api-reference/index.ts | 1 + lib/openApiSpec.ts | 52 ++++ next.config.js | 10 +- pages/api-reference/[resource]/[...slug].tsx | 254 ------------------ pages/api-reference/[resource]/index.tsx | 12 +- 6 files changed, 98 insertions(+), 263 deletions(-) create mode 100644 components/api-reference/ResourceFullPage.tsx delete mode 100644 pages/api-reference/[resource]/[...slug].tsx diff --git a/components/api-reference/ResourceFullPage.tsx b/components/api-reference/ResourceFullPage.tsx new file mode 100644 index 000000000..fe3fadb43 --- /dev/null +++ b/components/api-reference/ResourceFullPage.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { ApiReferenceProvider } from "@/components/ui/ApiReference/ApiReferenceContext"; +import { ApiReferenceSection } from "@/components/ui/ApiReference"; +import { FullResourcePageData } from "@/lib/openApiSpec"; +import { useInitialScrollState } from "@/components/ui/Page/helpers"; + +interface ResourceFullPageProps { + data: FullResourcePageData; + basePath: string; +} + +/** + * Renders a full resource page with all methods, schemas, and subresources. + * This component wraps the existing ApiReferenceSection with the proper context. + */ +export function ResourceFullPage({ data, basePath }: ResourceFullPageProps) { + // Handle scroll to hash on initial load + useInitialScrollState(); + + return ( + + + + ); +} diff --git a/components/api-reference/index.ts b/components/api-reference/index.ts index 1c27a2be2..8069d3fc4 100644 --- a/components/api-reference/index.ts +++ b/components/api-reference/index.ts @@ -7,3 +7,4 @@ export { SubresourceList, SchemaList, } from "./ResourceOverviewPage"; +export { ResourceFullPage } from "./ResourceFullPage"; diff --git a/lib/openApiSpec.ts b/lib/openApiSpec.ts index 153d8a5c3..b46d7ee07 100644 --- a/lib/openApiSpec.ts +++ b/lib/openApiSpec.ts @@ -754,6 +754,56 @@ async function buildSchemaReferences( return schemaReferences; } +// ============================================================================ +// Full Resource Page Data (for per-resource pages) +// ============================================================================ + +/** + * Data for a full resource page that renders all methods, schemas, and subresources + */ +type FullResourcePageData = { + resourceName: string; + resource: StainlessResource; + openApiSpec: OpenAPIV3.Document; + stainlessConfig: StainlessConfig; + baseUrl: string; + schemaReferences: Record; +}; + +/** + * Load all data needed to render a full resource page. + * This includes the resource definition and the OpenAPI spec for resolving operations/schemas. + */ +async function getFullResourcePageData( + specName: SpecName, + resourceName: string, +): Promise { + const [openApiSpec, stainlessSpec, schemaReferences] = await Promise.all([ + readOpenApiSpec(specName), + readStainlessSpec(specName), + buildSchemaReferences(specName), + ]); + + const resource = stainlessSpec.resources[resourceName]; + if (!resource) { + return null; + } + + const baseUrl = + stainlessSpec.environments[ + specName === "api" ? "production" : "production" + ] || ""; + + return { + resourceName, + resource, + openApiSpec, + stainlessConfig: stainlessSpec, + baseUrl, + schemaReferences, + }; +} + // ============================================================================ // Exports // ============================================================================ @@ -773,6 +823,7 @@ export type { SidebarData, ApiReferencePath, SpecName, + FullResourcePageData, }; export { @@ -785,6 +836,7 @@ export { getMethodPageData, getSchemaPageData, getResourceOverviewData, + getFullResourcePageData, // Path generation getAllApiReferencePaths, getResourceOrder, diff --git a/next.config.js b/next.config.js index aea78da64..fb319cac6 100644 --- a/next.config.js +++ b/next.config.js @@ -706,9 +706,13 @@ const nextConfig = { async rewrites() { return [ - // Note: API reference pages now use multi-page architecture - // Individual pages are generated for each resource, method, and schema - // The old single-page rewrite for /api-reference/:path+ has been removed + // API reference: deep links within resources serve the resource page + // e.g. /api-reference/users/get -> serves /api-reference/users + // The page handles scrolling to the correct section client-side + { + source: "/api-reference/:resource/:path+", + destination: "/api-reference/:resource", + }, // MAPI reference still uses single-page approach { diff --git a/pages/api-reference/[resource]/[...slug].tsx b/pages/api-reference/[resource]/[...slug].tsx deleted file mode 100644 index 2431de162..000000000 --- a/pages/api-reference/[resource]/[...slug].tsx +++ /dev/null @@ -1,254 +0,0 @@ -import { GetStaticPaths, GetStaticProps } from "next"; -import { - getAllApiReferencePaths, - getMethodPageData, - getSchemaPageData, - getResourceOverviewData, - getSidebarData, - buildSchemaReferences, - MethodPageData, - SchemaPageData, - ResourceOverviewData, - SidebarData, -} from "@/lib/openApiSpec"; -import { ApiReferenceLayout } from "@/components/api-reference"; -import { MethodPage } from "@/components/api-reference"; -import { SchemaPage } from "@/components/api-reference"; -import { ResourceOverviewPage } from "@/components/api-reference"; -import { API_REFERENCE_OVERVIEW_CONTENT } from "@/data/sidebars/apiOverviewSidebar"; - -type PageType = "method" | "schema" | "subresource"; - -interface MethodPageProps { - pageType: "method"; - sidebarData: SidebarData; - data: MethodPageData; - schemaReferences: Record; -} - -interface SchemaPageProps { - pageType: "schema"; - sidebarData: SidebarData; - data: SchemaPageData; - schemaReferences: Record; -} - -interface SubresourcePageProps { - pageType: "subresource"; - sidebarData: SidebarData; - data: ResourceOverviewData; - schemaReferences: Record; - basePath: string; -} - -type PageProps = MethodPageProps | SchemaPageProps | SubresourcePageProps; - -export default function ApiReferenceDynamicPage(props: PageProps) { - const { pageType, sidebarData, data, schemaReferences } = props; - - if (pageType === "schema") { - const schemaData = data as SchemaPageData; - return ( - - - - ); - } - - if (pageType === "subresource") { - const subresourceData = data as ResourceOverviewData; - const basePath = (props as SubresourcePageProps).basePath; - return ( - - - - ); - } - - // Method page (default) - const methodData = data as MethodPageData; - const subresourceBreadcrumbs = - methodData.subresourcePath && methodData.subresourcePath.length > 0 - ? methodData.subresourcePath.map((sub, index) => ({ - label: sub, - href: `/api-reference/${methodData.resourceName}/${methodData - .subresourcePath!.slice(0, index + 1) - .join("/")}`, - })) - : []; - - return ( - 0 - ? "/" + methodData.subresourcePath.join("/") - : "" - }/${methodData.methodName}`} - breadcrumbs={[ - { label: "API reference", href: "/api-reference" }, - { - label: methodData.resourceTitle, - href: `/api-reference/${methodData.resourceName}`, - }, - ...subresourceBreadcrumbs, - ]} - > - - - ); -} - -export const getStaticPaths: GetStaticPaths = async () => { - const allPaths = await getAllApiReferencePaths("api"); - - // Filter to only paths with slug (not resource overview which has no slug) - const slugPaths = allPaths - .filter((p) => p.params.slug && p.params.slug.length > 0) - .map((p) => ({ - params: { - resource: p.params.resource, - slug: p.params.slug as string[], - }, - })); - - return { - paths: slugPaths, - fallback: false, - }; -}; - -export const getStaticProps: GetStaticProps = async ({ params }) => { - const resourceName = params?.resource as string; - const slug = params?.slug as string[]; - - const [sidebarData, schemaReferences] = await Promise.all([ - getSidebarData("api"), - buildSchemaReferences("api"), - ]); - - // Determine page type from slug - // /users/schemas/user -> schema page - // /users/get -> method page - // /users/feeds -> subresource overview - // /users/feeds/list_items -> subresource method page - - const schemaIndex = slug.indexOf("schemas"); - - if (schemaIndex !== -1 && schemaIndex === slug.length - 2) { - // Schema page: [..., "schemas", schemaName] - const schemaName = slug[schemaIndex + 1]; - const subresourcePath = slug.slice(0, schemaIndex); - - const data = await getSchemaPageData( - "api", - resourceName, - schemaName, - subresourcePath, - ); - - if (!data) { - return { notFound: true }; - } - - return { - props: { - pageType: "schema" as const, - sidebarData, - data, - schemaReferences, - }, - revalidate: 3600, - }; - } - - // Check if this is a subresource overview (has subresource path but no method) - // This requires checking if the last segment is a subresource name - const potentialSubresourcePath = slug; - const subresourceOverview = await getResourceOverviewData( - "api", - resourceName, - potentialSubresourcePath, - ); - - if (subresourceOverview && subresourceOverview.methods.length > 0) { - // This is a subresource overview page - return { - props: { - pageType: "subresource" as const, - sidebarData, - data: subresourceOverview, - schemaReferences, - basePath: `/api-reference/${resourceName}/${slug.join("/")}`, - }, - revalidate: 3600, - }; - } - - // Method page (possibly in subresource) - const methodName = slug[slug.length - 1]; - const subresourcePath = slug.slice(0, -1); - - const data = await getMethodPageData( - "api", - resourceName, - methodName, - subresourcePath, - ); - - if (!data) { - return { notFound: true }; - } - - return { - props: { - pageType: "method" as const, - sidebarData, - data, - schemaReferences, - }, - revalidate: 3600, - }; -}; diff --git a/pages/api-reference/[resource]/index.tsx b/pages/api-reference/[resource]/index.tsx index 6cf92718c..5b372d5be 100644 --- a/pages/api-reference/[resource]/index.tsx +++ b/pages/api-reference/[resource]/index.tsx @@ -1,17 +1,17 @@ import { GetStaticPaths, GetStaticProps } from "next"; import { - getResourceOverviewData, + getFullResourcePageData, getSidebarData, - ResourceOverviewData, + FullResourcePageData, SidebarData, } from "@/lib/openApiSpec"; import { ApiReferenceLayout } from "@/components/api-reference"; -import { ResourceOverviewPage } from "@/components/api-reference"; +import { ResourceFullPage } from "@/components/api-reference"; import { API_REFERENCE_OVERVIEW_CONTENT } from "@/data/sidebars/apiOverviewSidebar"; interface ResourcePageProps { sidebarData: SidebarData; - resourceData: ResourceOverviewData; + resourceData: FullResourcePageData; } export default function ResourcePage({ @@ -34,7 +34,7 @@ export default function ResourcePage({ currentPath={basePath} breadcrumbs={[{ label: "API reference", href: "/api-reference" }]} > - + ); } @@ -58,7 +58,7 @@ export const getStaticProps: GetStaticProps = async ({ const [sidebarData, resourceData] = await Promise.all([ getSidebarData("api"), - getResourceOverviewData("api", resourceName), + getFullResourcePageData("api", resourceName), ]); if (!resourceData) { From 50ff49c868a33a92ef793ff6e09c7647997c95b6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 1 Feb 2026 16:48:52 +0000 Subject: [PATCH 13/24] Fix client-side navigation for API reference per-resource pages - Extended SidebarContext with currentResourcePath to track which resource page we're on - Updated NavItem to use same-page routing only for links within the current resource - Links to other resources now use standard navigation - This fixes the 'resourceName undefined' error when clicking sidebar links Co-authored-by: chris --- .../api-reference/ApiReferenceLayout.tsx | 19 ++++++++++++++----- components/ui/NavItem.tsx | 14 +++++++++++--- components/ui/Page/Sidebar.tsx | 3 +++ 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/components/api-reference/ApiReferenceLayout.tsx b/components/api-reference/ApiReferenceLayout.tsx index 01ee61837..c98fa441c 100644 --- a/components/api-reference/ApiReferenceLayout.tsx +++ b/components/api-reference/ApiReferenceLayout.tsx @@ -71,6 +71,13 @@ export function ApiReferenceLayout({ preSidebarContent, ); + // For per-resource pages, currentPath is the resource base path (e.g., /api-reference/users) + // This enables same-page routing for links within the current resource + const sidebarContextValue = { + samePageRouting: true, + currentResourcePath: currentPath, + }; + return ( + + + } /> - + {sidebarContent.map((section) => ( diff --git a/components/ui/NavItem.tsx b/components/ui/NavItem.tsx index 192ec31e6..dbdafb575 100644 --- a/components/ui/NavItem.tsx +++ b/components/ui/NavItem.tsx @@ -30,12 +30,20 @@ const NavItem = ({ className, ...textProps }: NavItemProps) => { - const { samePageRouting } = useSidebar(); + const { samePageRouting, currentResourcePath } = useSidebar(); const { isOpen: isMobileSidebarOpen, closeSidebar: closeMobileSidebar } = useMobileSidebar(); + // Determine if this link should use same-page routing (scroll to element) + // If currentResourcePath is set, only use same-page routing for links within that resource + const shouldUseSamePageRouting = samePageRouting + ? currentResourcePath + ? href.startsWith(currentResourcePath) + : true + : false; + const onClick = (e: React.MouseEvent) => { - if (samePageRouting) { + if (shouldUseSamePageRouting) { e.preventDefault(); highlightResource(href, { moveToItem: true }); } else { @@ -47,7 +55,7 @@ const NavItem = ({ }; // Next.js is really annoying if you have prefetch={true} so let's just NOT - const prefetchProps = samePageRouting ? { prefetch: false } : {}; + const prefetchProps = shouldUseSamePageRouting ? { prefetch: false } : {}; const textPropsWithoutStyle = { ...textProps }; delete textPropsWithoutStyle.style; diff --git a/components/ui/Page/Sidebar.tsx b/components/ui/Page/Sidebar.tsx index 219330819..1990e987b 100644 --- a/components/ui/Page/Sidebar.tsx +++ b/components/ui/Page/Sidebar.tsx @@ -23,6 +23,9 @@ import { TgphComponentProps } from "@telegraph/helpers"; interface SidebarContextType { samePageRouting: boolean; + // For per-resource pages, this is the base path of the current resource + // e.g., "/api-reference/users" - used to determine if a link is on the same page + currentResourcePath?: string; } export const SidebarContext = createContext({ From 43a312f76606ad30c72467ba154d4568abce7f56 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 1 Feb 2026 17:03:53 +0000 Subject: [PATCH 14/24] Fix navigation issues in API reference 1. Fix same-page routing logic to be more precise: - Only use scroll-to-element for links that exactly match or are sub-paths - Prevents /api-reference from matching /api-reference/overview/... links 2. Add guard for undefined resourceData during client-side transitions - Prevents 'Cannot read properties of undefined' error Co-authored-by: chris --- components/ui/NavItem.tsx | 14 +++++++++----- pages/api-reference/[resource]/index.tsx | 5 +++++ 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/components/ui/NavItem.tsx b/components/ui/NavItem.tsx index dbdafb575..6cf0b558c 100644 --- a/components/ui/NavItem.tsx +++ b/components/ui/NavItem.tsx @@ -35,13 +35,17 @@ const NavItem = ({ useMobileSidebar(); // Determine if this link should use same-page routing (scroll to element) - // If currentResourcePath is set, only use same-page routing for links within that resource - const shouldUseSamePageRouting = samePageRouting - ? currentResourcePath - ? href.startsWith(currentResourcePath) - : true + // If currentResourcePath is set, only use same-page routing for links that: + // 1. Exactly match currentResourcePath (e.g., /api-reference/users) + // 2. OR are sub-paths of currentResourcePath (e.g., /api-reference/users/get) + // This prevents /api-reference matching /api-reference/overview/... or /api-reference/users + const isWithinCurrentResource = currentResourcePath + ? href === currentResourcePath || + href.startsWith(currentResourcePath + "/") : false; + const shouldUseSamePageRouting = samePageRouting && isWithinCurrentResource; + const onClick = (e: React.MouseEvent) => { if (shouldUseSamePageRouting) { e.preventDefault(); diff --git a/pages/api-reference/[resource]/index.tsx b/pages/api-reference/[resource]/index.tsx index 5b372d5be..746aab30d 100644 --- a/pages/api-reference/[resource]/index.tsx +++ b/pages/api-reference/[resource]/index.tsx @@ -18,6 +18,11 @@ export default function ResourcePage({ sidebarData, resourceData, }: ResourcePageProps) { + // Guard against undefined resourceData during client-side transitions + if (!resourceData) { + return null; + } + const basePath = `/api-reference/${resourceData.resourceName}`; return ( From 1b9c5a00dbf41ba3be191b8d315adc4cd0a2e785 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 1 Feb 2026 17:06:13 +0000 Subject: [PATCH 15/24] Fix Prettier formatting in NavItem.tsx Co-authored-by: chris --- components/ui/NavItem.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/components/ui/NavItem.tsx b/components/ui/NavItem.tsx index 6cf0b558c..632311c17 100644 --- a/components/ui/NavItem.tsx +++ b/components/ui/NavItem.tsx @@ -40,8 +40,7 @@ const NavItem = ({ // 2. OR are sub-paths of currentResourcePath (e.g., /api-reference/users/get) // This prevents /api-reference matching /api-reference/overview/... or /api-reference/users const isWithinCurrentResource = currentResourcePath - ? href === currentResourcePath || - href.startsWith(currentResourcePath + "/") + ? href === currentResourcePath || href.startsWith(currentResourcePath + "/") : false; const shouldUseSamePageRouting = samePageRouting && isWithinCurrentResource; From cd8a8f4ba3147a80ce6f7033368da508e2e2c900 Mon Sep 17 00:00:00 2001 From: Chris Bell Date: Sun, 1 Feb 2026 14:51:04 -0500 Subject: [PATCH 16/24] feat: rework styling --- .tool-versions | 2 +- .../api-reference/ApiReferenceLayout.tsx | 3 +- components/api-reference/EndpointList.tsx | 96 ------ components/api-reference/MethodPage.tsx | 310 ------------------ components/api-reference/ResourceFullPage.tsx | 1 - .../api-reference/ResourceOverviewPage.tsx | 151 --------- components/api-reference/SchemaPage.tsx | 65 ---- components/api-reference/index.ts | 8 - components/ui/Accordion.tsx | 9 +- .../ApiReferenceMethod/ApiReferenceMethod.tsx | 307 ++++++++--------- .../ApiReferenceSection.tsx | 42 ++- .../SchemaProperties/PropertyRow.tsx | 8 +- components/ui/ApiSections.tsx | 107 +++--- components/ui/Autocomplete.tsx | 46 ++- components/ui/Endpoints.tsx | 20 +- components/ui/OverviewContent/Section.tsx | 2 +- components/ui/Page.tsx | 12 +- components/ui/Page/Sidebar.tsx | 5 +- components/ui/Page/helpers.ts | 7 +- components/ui/RateLimit.tsx | 2 +- components/ui/SectionHeading.tsx | 16 +- content/__api-reference/content.mdx | 2 + next-env.d.ts | 2 +- next.config.js | 12 +- pages/_app.tsx | 40 ++- pages/api-reference/[resource]/index.tsx | 13 +- pages/api-reference/index.tsx | 62 +--- .../api-reference/overview/[[...section]].tsx | 97 ++++++ pages/index.tsx | 2 +- styles/global.css | 14 +- 30 files changed, 432 insertions(+), 1031 deletions(-) delete mode 100644 components/api-reference/EndpointList.tsx delete mode 100644 components/api-reference/MethodPage.tsx delete mode 100644 components/api-reference/ResourceOverviewPage.tsx delete mode 100644 components/api-reference/SchemaPage.tsx create mode 100644 pages/api-reference/overview/[[...section]].tsx diff --git a/.tool-versions b/.tool-versions index a93e70893..5d7898e38 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -nodejs 20.19.3 +nodejs 24.13.0 yarn 1.22.10 diff --git a/components/api-reference/ApiReferenceLayout.tsx b/components/api-reference/ApiReferenceLayout.tsx index c98fa441c..268da2b91 100644 --- a/components/api-reference/ApiReferenceLayout.tsx +++ b/components/api-reference/ApiReferenceLayout.tsx @@ -39,8 +39,9 @@ function convertToLegacySidebarFormat( ...resource.pages.map((page) => ({ slug: page.slug.replace(resource.slug, ""), title: page.title, + // subPage slugs need to be relative to their parent page, not the resource pages: page.pages?.map((subPage) => ({ - slug: subPage.slug.replace(resource.slug, ""), + slug: subPage.slug.replace(page.slug, ""), title: subPage.title, })), })), diff --git a/components/api-reference/EndpointList.tsx b/components/api-reference/EndpointList.tsx deleted file mode 100644 index b0819470c..000000000 --- a/components/api-reference/EndpointList.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import Link from "next/link"; -import { Box, Stack } from "@telegraph/layout"; -import { Text, Code } from "@telegraph/typography"; -import { MethodSummary } from "@/lib/openApiSpec"; - -/** - * Badge component for HTTP method type - */ -function MethodBadge({ method }: { method: string }) { - const methodUpper = method.toUpperCase(); - - const colorMap: Record = { - GET: "green", - POST: "blue", - PUT: "amber", - PATCH: "amber", - DELETE: "red", - }; - - const color = colorMap[methodUpper] || "gray"; - - return ( - - {methodUpper} - - ); -} - -interface EndpointListProps { - methods: MethodSummary[]; - basePath: string; -} - -/** - * Displays a list of API endpoints with their HTTP methods and summaries. - * Links to individual method pages. - */ -export function EndpointList({ methods, basePath }: EndpointListProps) { - if (methods.length === 0) { - return null; - } - - return ( - - {methods.map((method, index) => ( - - - - - - {method.endpoint} - - - {method.summary} - - - - - ))} - - ); -} - -export { MethodBadge }; -export default EndpointList; diff --git a/components/api-reference/MethodPage.tsx b/components/api-reference/MethodPage.tsx deleted file mode 100644 index 0b8cd585b..000000000 --- a/components/api-reference/MethodPage.tsx +++ /dev/null @@ -1,310 +0,0 @@ -import { useState } from "react"; -import type { OpenAPIV3 } from "@scalar/openapi-types"; -import Markdown from "react-markdown"; -import { AnimatePresence, motion } from "framer-motion"; - -import { Callout } from "@/components/ui/Callout"; -import RateLimit from "@/components/ui/RateLimit"; -import { Box, Stack } from "@telegraph/layout"; -import { Code, Heading, Text } from "@telegraph/typography"; -import { - ContentColumn, - ExampleColumn, - Section, -} from "@/components/ui/ApiSections"; -import { CodeBlock } from "@/components/ui/CodeBlock"; -import { Endpoint } from "@/components/ui/Endpoints"; -import MultiLangExample from "@/components/ui/ApiReference/MultiLangExample"; -import OperationParameters from "@/components/ui/ApiReference/OperationParameters/OperationParameters"; -import { SchemaProperties } from "@/components/ui/ApiReference/SchemaProperties"; -import { PropertyRow } from "@/components/ui/ApiReference/SchemaProperties/PropertyRow"; -import { - augmentSnippetsWithCurlRequest, - formatResponseStatusCodes, - resolveResponseSchemas, -} from "@/components/ui/ApiReference/helpers"; -import { LightweightApiReferenceProvider } from "@/components/ui/ApiReference/ApiReferenceContext"; -import { MethodPageData } from "@/lib/openApiSpec"; - -interface MethodPageProps { - data: MethodPageData; - schemaReferences: Record; -} - -/** - * Displays a single API method with all its details: - * - Description - * - Endpoint - * - Rate limits - * - Path/query parameters - * - Request body - * - Response schema - * - Code examples - */ -export function MethodPage({ data, schemaReferences }: MethodPageProps) { - const { operation, methodType, endpoint, baseUrl } = data; - const [isResponseExpanded, setIsResponseExpanded] = useState(false); - - const parameters = operation.parameters || []; - - const pathParameters = parameters.filter( - (p) => (p as OpenAPIV3.ParameterObject).in === "path", - ) as OpenAPIV3.ParameterObject[]; - - const queryParameters = parameters.filter( - (p) => (p as OpenAPIV3.ParameterObject).in === "query", - ) as OpenAPIV3.ParameterObject[]; - - const responseSchemas: OpenAPIV3.SchemaObject[] = - resolveResponseSchemas(operation); - - const requestBody: OpenAPIV3.SchemaObject | undefined = ( - operation.requestBody as OpenAPIV3.RequestBodyObject - )?.content?.["application/json"]?.schema as - | OpenAPIV3.SchemaObject - | undefined; - - const rateLimitRaw = - ((operation as Record)?.["x-ratelimit-tier"] as - | number - | null) ?? null; - const rateLimit = rateLimitRaw as 1 | 2 | 3 | 4 | 5 | null; - const isIdempotent = - ((operation as Record)?.["x-idempotent"] as boolean) ?? - false; - const isRetentionSubject = - ((operation as Record)?.[ - "x-retention-policy" - ] as boolean) ?? false; - const isBeta = - ((operation as Record)?.["x-beta"] as boolean) ?? false; - - return ( - -
- - {operation.description ?? ""} - {isBeta && ( - - This endpoint is currently in beta. If you'd like early - access, or this is blocking your adoption of Knock, please{" "} - - get in touch - - . - - } - /> - )} - - - Endpoint - - - - - {rateLimit && ( - - - Rate limit - - - - )} - - {pathParameters.length > 0 && ( - <> - - Path parameters - - - - )} - - {queryParameters.length > 0 && ( - <> - - Query parameters - - - - )} - - {requestBody && ( - <> - - Request body - - - - )} - - - Returns - - - {responseSchemas.length > 0 && - responseSchemas.map((responseSchema) => ( - - - - - {responseSchema.title} - - - - {responseSchema.description ?? ""} - - - {responseSchema.properties && ( - <> - - setIsResponseExpanded(!isResponseExpanded) - } - > - {isResponseExpanded - ? "Hide properties" - : "Show properties"} - - - - - - - - - - - )} - - - ))} - - {responseSchemas.length === 0 && ( - - {formatResponseStatusCodes(operation).map( - (formattedStatus, index) => ( - - {formattedStatus} - - ), - )} - - )} - - - )[ - "x-stainless-snippets" - ] as Record) || {}, - { - baseUrl, - methodType, - endpoint, - body: requestBody?.example as - | Record - | undefined, - }, - )} - /> - {responseSchemas.map( - (responseSchema) => - responseSchema?.example && ( - - {JSON.stringify(responseSchema?.example, null, 2)} - - ), - )} - -
-
- ); -} - -export default MethodPage; diff --git a/components/api-reference/ResourceFullPage.tsx b/components/api-reference/ResourceFullPage.tsx index fe3fadb43..ec03cdc73 100644 --- a/components/api-reference/ResourceFullPage.tsx +++ b/components/api-reference/ResourceFullPage.tsx @@ -1,4 +1,3 @@ -import React from "react"; import { ApiReferenceProvider } from "@/components/ui/ApiReference/ApiReferenceContext"; import { ApiReferenceSection } from "@/components/ui/ApiReference"; import { FullResourcePageData } from "@/lib/openApiSpec"; diff --git a/components/api-reference/ResourceOverviewPage.tsx b/components/api-reference/ResourceOverviewPage.tsx deleted file mode 100644 index 904b784fd..000000000 --- a/components/api-reference/ResourceOverviewPage.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import Link from "next/link"; -import Markdown from "react-markdown"; -import { Box, Stack } from "@telegraph/layout"; -import { Heading, Text } from "@telegraph/typography"; -import { - ResourceOverviewData, - SchemaSummary, - SubresourceSummary, -} from "@/lib/openApiSpec"; -import { EndpointList } from "./EndpointList"; - -interface SubresourceListProps { - subresources: SubresourceSummary[]; - basePath: string; -} - -/** - * Displays a list of subresources with links to their pages. - */ -function SubresourceList({ subresources, basePath }: SubresourceListProps) { - if (subresources.length === 0) { - return null; - } - - return ( - - - Subresources - - - {subresources.map((sub) => ( - - - - {sub.title} - - - {sub.methodCount} endpoint{sub.methodCount !== 1 ? "s" : ""} - - - - ))} - - - ); -} - -interface SchemaListProps { - schemas: SchemaSummary[]; - basePath: string; -} - -/** - * Displays a list of schemas (object definitions) with links to their pages. - */ -function SchemaList({ schemas, basePath }: SchemaListProps) { - if (schemas.length === 0) { - return null; - } - - return ( - - - Object definitions - - - {schemas.map((schema) => ( - - - - {schema.title} - - - - ))} - - - ); -} - -interface ResourceOverviewPageProps { - data: ResourceOverviewData; - basePath: string; -} - -/** - * Displays the overview for a resource including: - * - Description - * - List of endpoints - * - List of subresources - * - List of object definitions - */ -export function ResourceOverviewPage({ - data, - basePath, -}: ResourceOverviewPageProps) { - const { resource, methods, schemas, subresources } = data; - - return ( - - {resource.description && ( - - {resource.description} - - )} - - {methods.length > 0 && ( - - - Endpoints - - - - )} - - - - - - ); -} - -export { SubresourceList, SchemaList }; -export default ResourceOverviewPage; diff --git a/components/api-reference/SchemaPage.tsx b/components/api-reference/SchemaPage.tsx deleted file mode 100644 index ed199df13..000000000 --- a/components/api-reference/SchemaPage.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import Markdown from "react-markdown"; -import { Heading } from "@telegraph/typography"; -import { - ContentColumn, - ExampleColumn, - Section, -} from "@/components/ui/ApiSections"; -import { CodeBlock } from "@/components/ui/CodeBlock"; -import { SchemaProperties } from "@/components/ui/ApiReference/SchemaProperties"; -import { LightweightApiReferenceProvider } from "@/components/ui/ApiReference/ApiReferenceContext"; -import { SchemaPageData } from "@/lib/openApiSpec"; - -interface SchemaPageProps { - data: SchemaPageData; - schemaReferences: Record; -} - -/** - * Displays a single API schema (object definition) with: - * - Title and description - * - Attributes/properties - * - Example JSON - */ -export function SchemaPage({ data, schemaReferences }: SchemaPageProps) { - const { schema, schemaName } = data; - - return ( - -
- - {schema.description && {schema.description}} - - - Attributes - - - - - {schema.example && ( - - {JSON.stringify(schema.example, null, 2)} - - )} - -
-
- ); -} - -export default SchemaPage; diff --git a/components/api-reference/index.ts b/components/api-reference/index.ts index 8069d3fc4..98d4480b0 100644 --- a/components/api-reference/index.ts +++ b/components/api-reference/index.ts @@ -1,10 +1,2 @@ export { ApiReferenceLayout } from "./ApiReferenceLayout"; -export { EndpointList, MethodBadge } from "./EndpointList"; -export { MethodPage } from "./MethodPage"; -export { SchemaPage } from "./SchemaPage"; -export { - ResourceOverviewPage, - SubresourceList, - SchemaList, -} from "./ResourceOverviewPage"; export { ResourceFullPage } from "./ResourceFullPage"; diff --git a/components/ui/Accordion.tsx b/components/ui/Accordion.tsx index 0c4646c93..fddf7d2d8 100644 --- a/components/ui/Accordion.tsx +++ b/components/ui/Accordion.tsx @@ -8,7 +8,7 @@ import { ChevronRight } from "lucide-react"; const AccordionGroup = ({ children }) => (
{children} @@ -37,8 +37,7 @@ const Accordion = ({ onClick={() => setOpen(!open)} aria-controls={title + "Children"} aria-expanded={open} - py="8" - px="8" + p="6" w="full" justifyContent="flex-start" alignItems="center" @@ -54,10 +53,10 @@ const Accordion = ({ flexShrink: 0, }} /> - + + {method.description ?? ""} + {isBeta && ( + + This endpoint is currently in beta. If you'd like early + access, or this is blocking your adoption of Knock, please{" "} + + get in touch + + . + + } + /> + )} + + } isIdempotent={isIdempotent} isRetentionSubject={isRetentionSubject} path={path} mdPath={mdPath} > - {method.description ?? ""} - {isBeta && ( - - This endpoint is currently in beta. If you'd like early access, - or this is blocking your adoption of Knock, please{" "} - - get in touch - - . - - } - /> - )} - - - Endpoint - - - - - {rateLimit && ( - - - Rate limit + + + + Endpoint - - - )} + + + + {rateLimit && ( + + + + )} + + + + {pathParameters.length > 0 && ( + + + Path parameters + + + + )} - {pathParameters.length > 0 && ( - <> - - Path parameters - - - - )} + {queryParameters.length > 0 && ( + + + Query parameters + + + + )} - {queryParameters.length > 0 && ( - <> - - Query parameters - - - - )} + {requestBody && ( + + + Request body + + + + )} - {requestBody && ( - <> - - Request body + + + Returns - - - )} - - Returns - - - {responseSchemas.length > 0 && - responseSchemas.map((responseSchema) => ( - - - - - {responseSchema.title} - - - - {responseSchema.description ?? ""} - - - {responseSchema.properties && ( - <> - setIsResponseExpanded(!isResponseExpanded)} - > - {isResponseExpanded - ? "Hide properties" - : "Show properties"} - - - - 0 && + responseSchemas.map((responseSchema) => ( + + + + - - - - - - + {responseSchema.title} + + + + {responseSchema.description ?? ""} + + + {responseSchema.properties && ( + <> + + setIsResponseExpanded(!isResponseExpanded) + } + > + {isResponseExpanded + ? "Hide properties" + : "Show properties"} + + + + + + + + + + + )} + + + ))} + + {responseSchemas.length === 0 && ( + + {formatResponseStatusCodes(method).map( + (formattedStatus, index) => ( + + {formattedStatus} + + ), )} - - - ))} - - {responseSchemas.length === 0 && ( - - {formatResponseStatusCodes(method).map((formattedStatus, index) => ( - - {formattedStatus} - - ))} - - )} + + )} + + -
+
{resource.description} + } + path={basePath} + mdPath={resourceMdPath} + > - {resource.description && ( - {resource.description} - )} - - {Object.entries(methods).length > 0 && ( {Object.entries(methods).map( @@ -66,7 +68,7 @@ function ApiReferenceSection({ resourceName, resource, path }: Props) { )} )} - +
@@ -124,25 +126,19 @@ function ApiReferenceSection({ resourceName, resource, path }: Props) {
{schema.description} + } path={schemaPath} mdPath={schemaMdPath} > - {schema.description && ( - {schema.description} - )} - - - Attributes - - + + + Attributes + + + ( ); const Wrapper = ({ children }) => { - return {children}; + return ( + + {children} + + ); }; const Container = ({ children }) => { return ( {children} diff --git a/components/ui/ApiSections.tsx b/components/ui/ApiSections.tsx index b774e2c0b..be2586de3 100644 --- a/components/ui/ApiSections.tsx +++ b/components/ui/ApiSections.tsx @@ -4,21 +4,28 @@ import { Text } from "@telegraph/typography"; import { highlightResource } from "./Page/helpers"; import Link from "next/link"; import { ContentActions } from "./ContentActions"; +import { Tag } from "@telegraph/tag"; export const Section = ({ title, + description, children, isIdempotent = false, isRetentionSubject = false, path = undefined, mdPath, + slug: _slug, // Explicitly destructure to prevent passing to DOM + direction: _direction, // Some sections pass this, prevent passing to DOM }: { title?: string; + description?: React.ReactNode; children: React.ReactNode; isIdempotent?: boolean; isRetentionSubject?: boolean; path?: string; - mdPath?: string; // New prop type + mdPath?: string; + slug?: string; + direction?: string; }) => { const onRetentionClick = (e: React.MouseEvent) => { e.preventDefault(); @@ -42,70 +49,46 @@ export const Section = ({ py="16" data-resource-path={path} > - {title && ( - <> - {isIdempotent && ( - - - - Idempotent - - - - )} - {isRetentionSubject && ( - - - - Retention policy applied - - - - )} - - - {title} - - {mdPath && } - - - )} + + {isIdempotent && ( + + + Idempotent + + + )} + {isRetentionSubject && ( + + + Retention policy applied + + + )} + + + + + {title} + + {description && {description}} + + {mdPath && {}} + @@ -116,22 +99,20 @@ export const Section = ({ }; export const ContentColumn = ({ children }) => ( - - {children} + + {children} ); export const ExampleColumn = ({ children }) => ( {children} diff --git a/components/ui/Autocomplete.tsx b/components/ui/Autocomplete.tsx index 20fd9710b..f11c398c3 100644 --- a/components/ui/Autocomplete.tsx +++ b/components/ui/Autocomplete.tsx @@ -645,30 +645,28 @@ const Autocomplete = () => { /> } TrailingComponent={ - <> - {autocompleteState?.query ? ( -
+
@@ -457,4 +458,5 @@ Knock uses standard [HTTP response codes](https://developer.mozilla.org/en-US/We />
+
diff --git a/next-env.d.ts b/next-env.d.ts index 2d5420eba..0c7fad710 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,7 +1,7 @@ /// /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.js b/next.config.js index fb319cac6..7f8f1b7f5 100644 --- a/next.config.js +++ b/next.config.js @@ -628,16 +628,10 @@ const nextConfig = { destination: "/cli/overview", permanent: false, }, - // API reference now uses multi-page architecture - // Redirect overview paths to main page for backward compatibility + // Redirect /api-reference to /api-reference/overview { - source: "/api-reference/overview", - destination: "/api-reference", - permanent: false, - }, - { - source: "/api-reference/overview/:section", - destination: "/api-reference#:section", + source: "/api-reference", + destination: "/api-reference/overview", permanent: false, }, { diff --git a/pages/_app.tsx b/pages/_app.tsx index accf1464d..45ba7ebac 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -5,7 +5,7 @@ import { import { Inter } from "next/font/google"; import { useRouter } from "next/router"; import { useRemoteRefresh } from "next-remote-refresh/hook"; -import { useEffect } from "react"; +import { useEffect, Component as ReactComponent, ErrorInfo } from "react"; import { InkeepModalProvider } from "../components/AiChatButton"; import { AskAiProvider } from "../components/AskAiContext"; @@ -22,6 +22,40 @@ import "../styles/responsive.css"; const inter = Inter({ subsets: ["latin"], display: "swap" }); +/** + * Error boundary to gracefully catch and display React errors + * instead of crashing the entire page. + */ +class ErrorBoundary extends ReactComponent< + { children: React.ReactNode }, + { hasError: boolean; error: Error | null } +> { + constructor(props: { children: React.ReactNode }) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error) { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + // Log error to console for debugging + console.error("ErrorBoundary caught an error:", error, errorInfo); + } + + render() { + if (this.state.hasError) { + return ( +
+ Error: {this.state.error?.message} +
+ ); + } + return this.props.children; + } +} + function App({ Component, pageProps }) { const router = useRouter(); const eventEmitter = useEventEmitterInstance(); @@ -53,7 +87,9 @@ function App({ Component, pageProps }) {
- + + + {analytics.SEGMENT_WRITE_KEY && }
diff --git a/pages/api-reference/[resource]/index.tsx b/pages/api-reference/[resource]/index.tsx index 746aab30d..f1625e662 100644 --- a/pages/api-reference/[resource]/index.tsx +++ b/pages/api-reference/[resource]/index.tsx @@ -24,18 +24,17 @@ export default function ResourcePage({ } const basePath = `/api-reference/${resourceData.resourceName}`; + const title = resourceData.resource.name || resourceData.resourceName; + const description = `Complete reference documentation for the ${title} resource.`; return ( diff --git a/pages/api-reference/index.tsx b/pages/api-reference/index.tsx index d9d671df2..2a0961597 100644 --- a/pages/api-reference/index.tsx +++ b/pages/api-reference/index.tsx @@ -4,9 +4,7 @@ import { MDXRemote, MDXRemoteSerializeResult } from "next-mdx-remote"; import rehypeMdxCodeProps from "rehype-mdx-code-props"; import { serialize } from "next-mdx-remote/serialize"; import remarkGfm from "remark-gfm"; -import Link from "next/link"; -import { Box, Stack } from "@telegraph/layout"; -import { Heading, Text } from "@telegraph/typography"; +import { Box } from "@telegraph/layout"; import { getSidebarData, SidebarData } from "@/lib/openApiSpec"; import { CONTENT_DIR } from "@/lib/content.server"; @@ -14,34 +12,6 @@ import { MDX_COMPONENTS } from "@/lib/mdxComponents"; import { ApiReferenceLayout } from "@/components/api-reference"; import { API_REFERENCE_OVERVIEW_CONTENT } from "@/data/sidebars/apiOverviewSidebar"; -interface ResourceCardProps { - title: string; - href: string; - methodCount: number; -} - -function ResourceCard({ title, href, methodCount }: ResourceCardProps) { - return ( - - - - {title} - - - {methodCount} endpoint{methodCount !== 1 ? "s" : ""} - - - - ); -} - interface ApiReferenceOverviewProps { sidebarData: SidebarData; overviewContentMdx: MDXRemoteSerializeResult; @@ -57,38 +27,10 @@ function ApiReferenceOverview({ preSidebarContent={API_REFERENCE_OVERVIEW_CONTENT} title="API reference" description="Complete reference documentation for the Knock API." - currentPath="/api-reference" > - {/* Overview content (auth, errors, pagination, etc.) */} - + - - {/* Resource list */} - - - Resources - - - {sidebarData.resources - .filter((resource) => resource.title !== "Shared") - .map((resource) => ( - - !p.pages && - p.title !== "Overview" && - !p.slug.includes("/schemas"), - ).length - } - /> - ))} - - ); } diff --git a/pages/api-reference/overview/[[...section]].tsx b/pages/api-reference/overview/[[...section]].tsx new file mode 100644 index 000000000..7ac7a9e9f --- /dev/null +++ b/pages/api-reference/overview/[[...section]].tsx @@ -0,0 +1,97 @@ +import fs from "fs"; +import { GetStaticPaths, GetStaticProps } from "next"; +import { MDXRemote, MDXRemoteSerializeResult } from "next-mdx-remote"; +import rehypeMdxCodeProps from "rehype-mdx-code-props"; +import { serialize } from "next-mdx-remote/serialize"; +import remarkGfm from "remark-gfm"; +import { Box } from "@telegraph/layout"; + +import { getSidebarData, SidebarData } from "@/lib/openApiSpec"; +import { CONTENT_DIR } from "@/lib/content.server"; +import { MDX_COMPONENTS } from "@/lib/mdxComponents"; +import { ApiReferenceLayout } from "@/components/api-reference"; +import { API_REFERENCE_OVERVIEW_CONTENT } from "@/data/sidebars/apiOverviewSidebar"; + +interface ApiReferenceOverviewProps { + sidebarData: SidebarData; + overviewContentMdx: MDXRemoteSerializeResult; +} + +function ApiReferenceOverview({ + sidebarData, + overviewContentMdx, +}: ApiReferenceOverviewProps) { + return ( + + + + + + ); +} + +export const getStaticPaths: GetStaticPaths = async () => { + // Generate paths for all overview sections from the sidebar config + const overviewPages = API_REFERENCE_OVERVIEW_CONTENT[0]?.pages || []; + + const paths = [ + // Base overview path (no section) + { params: { section: [] } }, + // All section paths + ...overviewPages.map((page) => ({ + params: { + section: page.slug === "/" ? [] : [page.slug.replace(/^\//, "")], + }, + })), + ]; + + // Remove duplicates (the "/" slug creates a duplicate of the base path) + const uniquePaths = paths.filter( + (path, index, self) => + index === + self.findIndex( + (p) => + JSON.stringify(p.params.section) === + JSON.stringify(path.params.section), + ), + ); + + return { + paths: uniquePaths, + fallback: false, + }; +}; + +export const getStaticProps: GetStaticProps< + ApiReferenceOverviewProps +> = async () => { + const sidebarData = await getSidebarData("api"); + + const overviewContent = fs.readFileSync( + `${CONTENT_DIR}/__api-reference/content.mdx`, + ); + + const overviewContentMdx = await serialize(overviewContent.toString(), { + parseFrontmatter: true, + mdxOptions: { + remarkPlugins: [remarkGfm], + rehypePlugins: [rehypeMdxCodeProps], + }, + }); + + return { + props: { + sidebarData, + overviewContentMdx, + }, + revalidate: 3600, // Revalidate every hour + }; +}; + +export default ApiReferenceOverview; diff --git a/pages/index.tsx b/pages/index.tsx index f48540c17..f582d1132 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -71,7 +71,7 @@ export default function Home() { diff --git a/styles/global.css b/styles/global.css index 8c3acc9af..f67eabdff 100644 --- a/styles/global.css +++ b/styles/global.css @@ -12,7 +12,10 @@ color: var(--tgph-gray-12) !important; } -.tgraph-content h1, .tgraph-content h2, .tgraph-content h3, .tgraph-content h4 { +.tgraph-content h1, +.tgraph-content h2, +.tgraph-content h3, +.tgraph-content h4 { color: var(--tgph-gray-12) !important; } @@ -26,6 +29,15 @@ margin-bottom: var(--tgph-spacing-4); } +.tgraph-content ul, +.tgraph-content ol { + margin-left: var(--tgph-spacing-4); + list-style-type: disc; + display: flex; + flex-direction: column; + gap: var(--tgph-spacing-2); +} + .tgraph-content > div, .tgraph-content > img, .tgraph-content > blockquote { From 7f0028d4960c54877a92d0770cf1794eac187ea3 Mon Sep 17 00:00:00 2001 From: Chris Bell Date: Sun, 1 Feb 2026 15:17:20 -0500 Subject: [PATCH 17/24] chore: apply same changes to mapi ref --- data/specs/mapi/customizations.yml | 4 + next.config.js | 6 +- pages/mapi-reference/[resource]/index.tsx | 81 ++++++++++++++++ pages/mapi-reference/index.tsx | 70 +++++++------ .../overview/[[...section]].tsx | 97 +++++++++++++++++++ 5 files changed, 225 insertions(+), 33 deletions(-) create mode 100644 pages/mapi-reference/[resource]/index.tsx create mode 100644 pages/mapi-reference/overview/[[...section]].tsx diff --git a/data/specs/mapi/customizations.yml b/data/specs/mapi/customizations.yml index 31e38334d..19ec993f6 100644 --- a/data/specs/mapi/customizations.yml +++ b/data/specs/mapi/customizations.yml @@ -36,6 +36,10 @@ resources: name: Channels description: |- Channels are the delivery mechanisms for your notifications. + channel_groups: + name: Channel groups + description: |- + Channel groups are a way to group channels together to use as a single destination, or to conditionally apply rules to determine which channel(s) should be used. partials: name: Partials description: |- diff --git a/next.config.js b/next.config.js index 7f8f1b7f5..4403867a4 100644 --- a/next.config.js +++ b/next.config.js @@ -707,11 +707,9 @@ const nextConfig = { source: "/api-reference/:resource/:path+", destination: "/api-reference/:resource", }, - - // MAPI reference still uses single-page approach { - source: "/mapi-reference/:path+", - destination: "/mapi-reference", + source: "/mapi-reference/:resource/:path+", + destination: "/mapi-reference/:resource", }, // CLI reference pages all serve the same static content { diff --git a/pages/mapi-reference/[resource]/index.tsx b/pages/mapi-reference/[resource]/index.tsx new file mode 100644 index 000000000..f44b54e04 --- /dev/null +++ b/pages/mapi-reference/[resource]/index.tsx @@ -0,0 +1,81 @@ +import { GetStaticPaths, GetStaticProps } from "next"; +import { + getFullResourcePageData, + getSidebarData, + FullResourcePageData, + SidebarData, +} from "@/lib/openApiSpec"; +import { ApiReferenceLayout } from "@/components/api-reference"; +import { ResourceFullPage } from "@/components/api-reference"; +import { MAPI_REFERENCE_OVERVIEW_CONTENT } from "@/data/sidebars/mapiOverviewSidebar"; + +interface ResourcePageProps { + sidebarData: SidebarData; + resourceData: FullResourcePageData; +} + +export default function ResourcePage({ + sidebarData, + resourceData, +}: ResourcePageProps) { + // Guard against undefined resourceData during client-side transitions + if (!resourceData) { + return null; + } + + const basePath = `/mapi-reference/${resourceData.resourceName}`; + const title = resourceData.resource.name || resourceData.resourceName; + const description = `Complete reference documentation for the ${title} resource.`; + + return ( + + + + ); +} + +export const getStaticPaths: GetStaticPaths = async () => { + const sidebarData = await getSidebarData("mapi"); + const paths = sidebarData.resources.map((resource) => ({ + params: { resource: resource.slug.replace("/mapi-reference/", "") }, + })); + + return { + paths, + fallback: false, + }; +}; + +export const getStaticProps: GetStaticProps = async ({ + params, +}) => { + const resourceName = params?.resource as string; + + const [sidebarData, resourceData] = await Promise.all([ + getSidebarData("mapi"), + getFullResourcePageData("mapi", resourceName), + ]); + + if (!resourceData) { + return { notFound: true }; + } + + return { + props: { + sidebarData, + resourceData, + }, + revalidate: 3600, // Revalidate every hour + }; +}; diff --git a/pages/mapi-reference/index.tsx b/pages/mapi-reference/index.tsx index c381a6486..ae7925df4 100644 --- a/pages/mapi-reference/index.tsx +++ b/pages/mapi-reference/index.tsx @@ -1,44 +1,50 @@ import fs from "fs"; -import { MDXRemote } from "next-mdx-remote"; +import { GetStaticProps } from "next"; +import { MDXRemote, MDXRemoteSerializeResult } from "next-mdx-remote"; import rehypeMdxCodeProps from "rehype-mdx-code-props"; import { serialize } from "next-mdx-remote/serialize"; import remarkGfm from "remark-gfm"; +import { Box } from "@telegraph/layout"; -import { readOpenApiSpec, readStainlessSpec } from "../../lib/openApiSpec"; -import ApiReference from "../../components/ui/ApiReference/ApiReference"; -import { CONTENT_DIR } from "../../lib/content.server"; +import { getSidebarData, SidebarData } from "@/lib/openApiSpec"; +import { CONTENT_DIR } from "@/lib/content.server"; import { MDX_COMPONENTS } from "@/lib/mdxComponents"; -import { - MAPI_REFERENCE_OVERVIEW_CONTENT, - RESOURCE_ORDER, -} from "../../data/sidebars/mapiOverviewSidebar"; - -function ManagementApiReferenceNew({ - openApiSpec, - stainlessSpec, - preContentMdx, -}) { +import { ApiReferenceLayout } from "@/components/api-reference"; +import { MAPI_REFERENCE_OVERVIEW_CONTENT } from "@/data/sidebars/mapiOverviewSidebar"; + +interface MapiReferenceOverviewProps { + sidebarData: SidebarData; + overviewContentMdx: MDXRemoteSerializeResult; +} + +function MapiReferenceOverview({ + sidebarData, + overviewContentMdx, +}: MapiReferenceOverviewProps) { return ( - } - resourceOrder={RESOURCE_ORDER} + + title="Management API reference" + description="Complete reference documentation for the Knock Management API." + > + + + + ); } -export async function getStaticProps() { - const openApiSpec = await readOpenApiSpec("mapi"); - const stainlessSpec = await readStainlessSpec("mapi"); +export const getStaticProps: GetStaticProps< + MapiReferenceOverviewProps +> = async () => { + const sidebarData = await getSidebarData("mapi"); - const preContent = fs.readFileSync( + const overviewContent = fs.readFileSync( `${CONTENT_DIR}/__mapi-reference/content.mdx`, ); - const preContentMdx = await serialize(preContent.toString(), { + const overviewContentMdx = await serialize(overviewContent.toString(), { parseFrontmatter: true, mdxOptions: { remarkPlugins: [remarkGfm], @@ -46,7 +52,13 @@ export async function getStaticProps() { }, }); - return { props: { openApiSpec, stainlessSpec, preContentMdx } }; -} + return { + props: { + sidebarData, + overviewContentMdx, + }, + revalidate: 3600, // Revalidate every hour + }; +}; -export default ManagementApiReferenceNew; +export default MapiReferenceOverview; diff --git a/pages/mapi-reference/overview/[[...section]].tsx b/pages/mapi-reference/overview/[[...section]].tsx new file mode 100644 index 000000000..68633486a --- /dev/null +++ b/pages/mapi-reference/overview/[[...section]].tsx @@ -0,0 +1,97 @@ +import fs from "fs"; +import { GetStaticPaths, GetStaticProps } from "next"; +import { MDXRemote, MDXRemoteSerializeResult } from "next-mdx-remote"; +import rehypeMdxCodeProps from "rehype-mdx-code-props"; +import { serialize } from "next-mdx-remote/serialize"; +import remarkGfm from "remark-gfm"; +import { Box } from "@telegraph/layout"; + +import { getSidebarData, SidebarData } from "@/lib/openApiSpec"; +import { CONTENT_DIR } from "@/lib/content.server"; +import { MDX_COMPONENTS } from "@/lib/mdxComponents"; +import { ApiReferenceLayout } from "@/components/api-reference"; +import { MAPI_REFERENCE_OVERVIEW_CONTENT } from "@/data/sidebars/mapiOverviewSidebar"; + +interface MapiReferenceOverviewProps { + sidebarData: SidebarData; + overviewContentMdx: MDXRemoteSerializeResult; +} + +function MapiReferenceOverview({ + sidebarData, + overviewContentMdx, +}: MapiReferenceOverviewProps) { + return ( + + + + + + ); +} + +export const getStaticPaths: GetStaticPaths = async () => { + // Generate paths for all overview sections from the sidebar config + const overviewPages = MAPI_REFERENCE_OVERVIEW_CONTENT[0]?.pages || []; + + const paths = [ + // Base overview path (no section) + { params: { section: [] } }, + // All section paths + ...overviewPages.map((page) => ({ + params: { + section: page.slug === "/" ? [] : [page.slug.replace(/^\//, "")], + }, + })), + ]; + + // Remove duplicates (the "/" slug creates a duplicate of the base path) + const uniquePaths = paths.filter( + (path, index, self) => + index === + self.findIndex( + (p) => + JSON.stringify(p.params.section) === + JSON.stringify(path.params.section), + ), + ); + + return { + paths: uniquePaths, + fallback: false, + }; +}; + +export const getStaticProps: GetStaticProps< + MapiReferenceOverviewProps +> = async () => { + const sidebarData = await getSidebarData("mapi"); + + const overviewContent = fs.readFileSync( + `${CONTENT_DIR}/__mapi-reference/content.mdx`, + ); + + const overviewContentMdx = await serialize(overviewContent.toString(), { + parseFrontmatter: true, + mdxOptions: { + remarkPlugins: [remarkGfm], + rehypePlugins: [rehypeMdxCodeProps], + }, + }); + + return { + props: { + sidebarData, + overviewContentMdx, + }, + revalidate: 3600, // Revalidate every hour + }; +}; + +export default MapiReferenceOverview; From 45aba68bf1ad82a5c855b9badf982ddd3dcec538 Mon Sep 17 00:00:00 2001 From: Chris Bell Date: Mon, 2 Feb 2026 10:29:27 -0500 Subject: [PATCH 18/24] fix: code review fixes --- .cursor/api-reference-migration-plan.md | 960 ------------------------ components/ui/ApiSections.tsx | 1 - lib/openApiSpec.ts | 5 +- pages/_app.tsx | 40 +- 4 files changed, 3 insertions(+), 1003 deletions(-) delete mode 100644 .cursor/api-reference-migration-plan.md diff --git a/.cursor/api-reference-migration-plan.md b/.cursor/api-reference-migration-plan.md deleted file mode 100644 index f2cf25a61..000000000 --- a/.cursor/api-reference-migration-plan.md +++ /dev/null @@ -1,960 +0,0 @@ -# API reference migration plan: Multi-page with ISR - -This document outlines the plan for migrating the API reference from a single large page to multiple smaller pages using Incremental Static Regeneration (ISR). Each method and schema gets its own URL path for clean, shareable links. - -## Table of contents - -1. [Problem statement](#problem-statement) -2. [Solution overview](#solution-overview) -3. [Recommended architecture](#recommended-architecture) -4. [Implementation plan](#implementation-plan) -5. [Data loading strategy](#data-loading-strategy) -6. [Navigation and UX](#navigation-and-ux) -7. [Migration steps](#migration-steps) -8. [Testing and rollout](#testing-and-rollout) - ---- - -## Problem statement - -### Current issues - -1. **Massive page payload.** The entire dereferenced OpenAPI spec (~2-5MB) is embedded in the HTML via `getStaticProps`. - -2. **Slow hydration.** React must hydrate the full spec on page load, blocking interactivity. - -3. **Build memory pressure.** Dereferencing large specs with circular references consumes significant memory. - -4. **Single page for all content.** URL rewrites serve the same static HTML for all `/api-reference/*` paths. - -### Constraints - -- **Telegraph components do not support RSC.** App Router with Server Components would require rewriting the styling layer. -- **Must work with Pages Router.** The solution must be compatible with the current Next.js Pages Router architecture. - ---- - -## Solution overview - -Split the API reference into individual pages for each resource, method, and schema. Use clean URL paths (`/api-reference/users/get`) instead of hash fragments. Use ISR to keep pages fresh without full rebuilds. - -### Key benefits - -| Aspect | Current | After migration | -| -------------- | ------------------------- | ---------------------------- | -| HTML per page | 2-5MB (full spec) | 20-50KB (single method) | -| URL structure | Hash-based (`#users-get`) | Path-based (`/users/get`) | -| Build strategy | Single page, full spec | Multiple pages, partial spec | -| Updates | Full rebuild | ISR per page | -| Hydration | Full spec | Single method data only | - ---- - -## Recommended architecture - -### Page structure - -``` -pages/api-reference/ -├── index.tsx # Overview (intro, auth, errors) -├── [resource]/ -│ ├── index.tsx # Resource overview + endpoint list -│ ├── [method].tsx # Individual method page -│ └── schemas/ -│ └── [schema].tsx # Individual schema page -``` - -### URL structure - -| URL | Content | -| ----------------------------------- | -------------------------------------------------- | -| `/api-reference` | Overview, authentication, errors, pagination, etc. | -| `/api-reference/users` | Users resource overview with endpoint list | -| `/api-reference/users/get` | Get user method documentation | -| `/api-reference/users/list` | List users method documentation | -| `/api-reference/users/update` | Update user method documentation | -| `/api-reference/users/schemas/user` | User schema definition | -| `/api-reference/workflows` | Workflows resource overview | -| `/api-reference/workflows/trigger` | Trigger workflow method | -| ... | ... | - -### Handling subresources - -Subresources use nested paths. The `[method].tsx` catch-all handles the nesting: - -| URL | Content | -| ----------------------------------------- | ------------------------ | -| `/api-reference/users/feeds/list_items` | List feed items method | -| `/api-reference/users/guides/get_channel` | Get guide channel method | - -To handle this, use a catch-all route: - -``` -pages/api-reference/ -├── index.tsx -├── [resource]/ -│ ├── index.tsx -│ ├── [...slug].tsx # Catches /method, /subresource/method, /schemas/name -``` - -### Estimated page counts - -| Type | Count | Example | -| ------------------ | -------- | ----------------------------------- | -| Overview | 1 | `/api-reference` | -| Resource overviews | 12 | `/api-reference/users` | -| Method pages | ~80 | `/api-reference/users/get` | -| Schema pages | ~40 | `/api-reference/users/schemas/user` | -| **Total** | **~133** | | - -### Page size estimates - -| Page type | Estimated size | -| ----------------- | -------------- | -| Overview | ~30KB | -| Resource overview | ~15KB | -| Method page | ~20-40KB | -| Schema page | ~10-20KB | - -**Compare to current:** Single page with 2-5MB payload - ---- - -## Implementation plan - -### Phase 1: Data loading refactor - -Create utilities to load data for individual pages. - -#### Task 1.1: Create method-specific loader - -```typescript -// lib/openApiSpec.ts - add new functions - -/** - * Load data for a single method page. - */ -export async function getMethodPageData( - specName: "api" | "mapi", - resourceName: string, - methodName: string, -): Promise { - const [openApiSpec, stainlessSpec] = await Promise.all([ - readOpenApiSpec(specName), - readStainlessSpec(specName), - ]); - - const resource = stainlessSpec.resources[resourceName]; - if (!resource?.methods?.[methodName]) { - return null; - } - - const methodConfig = resource.methods[methodName]; - const [methodType, endpoint] = resolveEndpoint(methodConfig); - const operation = openApiSpec.paths?.[endpoint]?.[methodType]; - - if (!operation) { - return null; - } - - return { - resourceName, - resourceTitle: resource.name || resourceName, - methodName, - methodType, - endpoint, - operation, - baseUrl: stainlessSpec.environments.production, - }; -} - -/** - * Load data for a single schema page. - */ -export async function getSchemaPageData( - specName: "api" | "mapi", - resourceName: string, - schemaName: string, -): Promise { - const [openApiSpec, stainlessSpec] = await Promise.all([ - readOpenApiSpec(specName), - readStainlessSpec(specName), - ]); - - const resource = stainlessSpec.resources[resourceName]; - if (!resource?.models?.[schemaName]) { - return null; - } - - const schemaRef = resource.models[schemaName]; - const schema = JSONPointer.get(openApiSpec, schemaRef.replace("#", "")); - - if (!schema) { - return null; - } - - return { - resourceName, - resourceTitle: resource.name || resourceName, - schemaName, - schema, - }; -} - -/** - * Load data for a resource overview page. - */ -export async function getResourceOverviewData( - specName: "api" | "mapi", - resourceName: string, -): Promise { - const [openApiSpec, stainlessSpec] = await Promise.all([ - readOpenApiSpec(specName), - readStainlessSpec(specName), - ]); - - const resource = stainlessSpec.resources[resourceName]; - if (!resource) { - return null; - } - - // Build list of methods with just summary info (not full operation) - const methods = Object.entries(resource.methods || {}).map( - ([methodName, config]) => { - const [methodType, endpoint] = resolveEndpoint(config); - const operation = openApiSpec.paths?.[endpoint]?.[methodType]; - return { - methodName, - methodType, - endpoint, - summary: operation?.summary || methodName, - }; - }, - ); - - // Build list of schemas with just name/title - const schemas = Object.entries(resource.models || {}).map( - ([schemaName, ref]) => { - const schema = JSONPointer.get(openApiSpec, ref.replace("#", "")); - return { - schemaName, - title: schema?.title || schemaName, - }; - }, - ); - - // Build subresource info - const subresources = Object.entries(resource.subresources || {}).map( - ([subName, subResource]) => ({ - name: subName, - title: subResource.name || subName, - methodCount: Object.keys(subResource.methods || {}).length, - }), - ); - - return { - resourceName, - resource: { - name: resource.name, - description: resource.description, - }, - methods, - schemas, - subresources, - }; -} -``` - -#### Task 1.2: Create path generation utilities - -```typescript -/** - * Generate all static paths for API reference pages. - */ -export async function getAllApiReferencePaths( - specName: "api" | "mapi", -): Promise> { - const stainlessSpec = await readStainlessSpec(specName); - const paths: Array<{ params: { resource: string; slug?: string[] } }> = []; - - function processResource( - resource: StainlessResource, - resourceName: string, - parentSlug: string[] = [], - ) { - // Resource overview (no slug) - if (parentSlug.length === 0) { - paths.push({ params: { resource: resourceName } }); - } - - // Method pages - if (resource.methods) { - Object.keys(resource.methods).forEach((methodName) => { - paths.push({ - params: { - resource: resourceName, - slug: [...parentSlug, methodName], - }, - }); - }); - } - - // Schema pages - if (resource.models) { - Object.keys(resource.models).forEach((schemaName) => { - paths.push({ - params: { - resource: resourceName, - slug: [...parentSlug, "schemas", schemaName], - }, - }); - }); - } - - // Subresources (recursive) - if (resource.subresources) { - Object.entries(resource.subresources).forEach( - ([subName, subResource]) => { - // Subresource overview - paths.push({ - params: { - resource: resourceName, - slug: [...parentSlug, subName], - }, - }); - - // Subresource methods and schemas - processResource(subResource, resourceName, [...parentSlug, subName]); - }, - ); - } - } - - Object.entries(stainlessSpec.resources).forEach( - ([resourceName, resource]) => { - processResource(resource, resourceName); - }, - ); - - return paths; -} -``` - -#### Task 1.3: Create sidebar data loader - -```typescript -/** - * Load sidebar structure for navigation. - * Includes links to all resources, methods, and schemas. - */ -export async function getSidebarData( - specName: "api" | "mapi", -): Promise { - const stainlessSpec = await readStainlessSpec(specName); - const basePath = specName === "api" ? "/api-reference" : "/mapi-reference"; - - function buildResourceSidebar( - resource: StainlessResource, - resourceName: string, - pathPrefix: string, - ): SidebarSection { - const pages: SidebarPage[] = []; - - // Methods - if (resource.methods) { - Object.keys(resource.methods).forEach((methodName) => { - pages.push({ - slug: `${pathPrefix}/${methodName}`, - title: methodName, - }); - }); - } - - // Subresources - if (resource.subresources) { - Object.entries(resource.subresources).forEach( - ([subName, subResource]) => { - pages.push({ - slug: `${pathPrefix}/${subName}`, - title: subResource.name || subName, - pages: Object.keys(subResource.methods || {}).map((methodName) => ({ - slug: `${pathPrefix}/${subName}/${methodName}`, - title: methodName, - })), - }); - }, - ); - } - - // Schemas - if (resource.models && Object.keys(resource.models).length > 0) { - pages.push({ - slug: `${pathPrefix}/schemas`, - title: "Schemas", - pages: Object.keys(resource.models).map((schemaName) => ({ - slug: `${pathPrefix}/schemas/${schemaName}`, - title: schemaName, - })), - }); - } - - return { - title: resource.name || resourceName, - slug: pathPrefix, - pages, - }; - } - - const resources = Object.entries(stainlessSpec.resources).map( - ([resourceName, resource]) => - buildResourceSidebar( - resource, - resourceName, - `${basePath}/${resourceName}`, - ), - ); - - return { resources }; -} -``` - -### Phase 2: Create page files - -#### Task 2.1: Create overview page - -```typescript -// pages/api-reference/index.tsx - -import { GetStaticProps } from "next"; -import { getSidebarData } from "@/lib/openApiSpec"; - -export default function ApiReferenceOverview({ sidebarData, overviewContent }) { - return ( - - - - - - ); -} - -export const getStaticProps: GetStaticProps = async () => { - const sidebarData = await getSidebarData("api"); - const overviewContent = await loadOverviewMdx(); - - return { - props: { sidebarData, overviewContent }, - revalidate: 3600, - }; -}; -``` - -#### Task 2.2: Create resource overview page - -```typescript -// pages/api-reference/[resource]/index.tsx - -import { GetStaticPaths, GetStaticProps } from "next"; -import { - getResourceOverviewData, - getSidebarData, - getResourceOrder, -} from "@/lib/openApiSpec"; - -export default function ResourceOverviewPage({ sidebarData, resourceData }) { - return ( - - - - {/* Endpoint list */} - - - {/* Subresources */} - {resourceData.subresources.length > 0 && ( - - )} - - {/* Schemas */} - {resourceData.schemas.length > 0 && ( - - )} - - ); -} - -export const getStaticPaths: GetStaticPaths = async () => { - const resourceOrder = await getResourceOrder("api"); - return { - paths: resourceOrder.map((resource) => ({ - params: { resource }, - })), - fallback: false, - }; -}; - -export const getStaticProps: GetStaticProps = async ({ params }) => { - const resourceName = params.resource as string; - const [sidebarData, resourceData] = await Promise.all([ - getSidebarData("api"), - getResourceOverviewData("api", resourceName), - ]); - - if (!resourceData) { - return { notFound: true }; - } - - return { - props: { sidebarData, resourceData }, - revalidate: 3600, - }; -}; -``` - -#### Task 2.3: Create catch-all page for methods and schemas - -```typescript -// pages/api-reference/[resource]/[...slug].tsx - -import { GetStaticPaths, GetStaticProps } from "next"; -import { - getAllApiReferencePaths, - getMethodPageData, - getSchemaPageData, - getSidebarData, -} from "@/lib/openApiSpec"; - -type PageType = "method" | "schema" | "subresource"; - -export default function ApiReferenceDynamicPage({ - pageType, - sidebarData, - data, - schemaReferences, -}) { - if (pageType === "schema") { - return ( - - - - ); - } - - // Method page (default) - return ( - - - - ); -} - -export const getStaticPaths: GetStaticPaths = async () => { - const paths = await getAllApiReferencePaths("api"); - // Filter to only paths with slug (not resource overview) - const slugPaths = paths.filter((p) => p.params.slug); - return { paths: slugPaths, fallback: false }; -}; - -export const getStaticProps: GetStaticProps = async ({ params }) => { - const resourceName = params.resource as string; - const slug = params.slug as string[]; - - const sidebarData = await getSidebarData("api"); - - // Determine page type from slug - // /users/schemas/user -> schema page - // /users/get -> method page - // /users/feeds/list_items -> subresource method page - - if (slug.includes("schemas")) { - const schemaIndex = slug.indexOf("schemas"); - const schemaName = slug[schemaIndex + 1]; - - const data = await getSchemaPageData("api", resourceName, schemaName); - if (!data) return { notFound: true }; - - const schemaReferences = await buildSchemaReferences("api"); - - return { - props: { - pageType: "schema", - sidebarData, - data, - schemaReferences, - }, - revalidate: 3600, - }; - } - - // Method page (possibly in subresource) - const methodName = slug[slug.length - 1]; - const subresourcePath = slug.slice(0, -1); - - const data = await getMethodPageData( - "api", - resourceName, - methodName, - subresourcePath, - ); - - if (!data) return { notFound: true }; - - const schemaReferences = await buildSchemaReferences("api"); - - return { - props: { - pageType: "method", - sidebarData, - data, - schemaReferences, - }, - revalidate: 3600, - }; -}; -``` - -### Phase 3: Create page components - -#### Task 3.1: Create MethodPage component - -```typescript -// components/api-reference/MethodPage.tsx - -interface Props { - data: MethodPageData; - schemaReferences: Record; -} - -export function MethodPage({ data, schemaReferences }: Props) { - const { operation, methodType, endpoint, baseUrl } = data; - - return ( - <> - - - - - ); -} -``` - -#### Task 3.2: Create SchemaPage component - -```typescript -// components/api-reference/SchemaPage.tsx - -interface Props { - data: SchemaPageData; - schemaReferences: Record; -} - -export function SchemaPage({ data, schemaReferences }: Props) { - const { schema, schemaName } = data; - - return ( - <> - - - - - ); -} -``` - -#### Task 3.3: Create EndpointList component - -```typescript -// components/api-reference/EndpointList.tsx - -interface Props { - methods: Array<{ - methodName: string; - methodType: string; - endpoint: string; - summary: string; - }>; - basePath: string; -} - -export function EndpointList({ methods, basePath }: Props) { - return ( -
- {methods.map((method) => ( - - -
- {method.endpoint} -

{method.summary}

-
- - ))} -
- ); -} -``` - ---- - -## Data loading strategy - -### What changes - -| Aspect | Current | After migration | -| ----------------- | ----------------------------- | --------------------------------- | -| Spec loading | Full spec in `getStaticProps` | Load only needed operation/schema | -| Context | Full spec in React context | No context needed (data in props) | -| Schema references | Built from full spec | Built once, shared across pages | -| Sidebar | Built from full spec | Separate lightweight loader | - -### ISR configuration - -```typescript -export const getStaticProps: GetStaticProps = async ({ params }) => { - return { - props: { - /* ... */ - }, - revalidate: 3600, // Revalidate every hour - }; -}; -``` - -**On-demand revalidation (optional)** - -```typescript -// pages/api/revalidate.ts -export default async function handler(req, res) { - const { secret, paths } = req.body; - - if (secret !== process.env.REVALIDATION_SECRET) { - return res.status(401).json({ message: "Invalid secret" }); - } - - // Revalidate specific paths - await Promise.all(paths.map((path) => res.revalidate(path))); - - return res.json({ revalidated: true }); -} -``` - ---- - -## Navigation and UX - -### Sidebar behavior - -- **Overview section** at top with general topics -- **Resources** listed as collapsible sections -- **Methods** listed under each resource -- **Schemas** grouped under "Schemas" subsection -- **Current page** highlighted in sidebar - -### Breadcrumb navigation - -Every page shows breadcrumbs: - -``` -API reference > Users > Get user -API reference > Users > Schemas > User -API reference > Workflows > Trigger workflow -``` - -### Previous/Next navigation - -Add footer navigation to move between methods: - -```typescript -// In MethodPage component - -``` - -### Cross-resource linking - -Schema references link to schema pages: - -```typescript -// In MethodContent, when showing return type -User -// Links to /api-reference/users/schemas/user -``` - ---- - -## Migration steps - -### Step 1: Create data loading utilities - -1. Add `getMethodPageData()` function -2. Add `getSchemaPageData()` function -3. Add `getResourceOverviewData()` function -4. Add `getAllApiReferencePaths()` function -5. Add `getSidebarData()` function -6. Test with one resource - -### Step 2: Create new page files - -1. Create `pages/api-reference/index.tsx` -2. Create `pages/api-reference/[resource]/index.tsx` -3. Create `pages/api-reference/[resource]/[...slug].tsx` -4. Create page components (MethodPage, SchemaPage, etc.) -5. Test all paths generate correctly - -### Step 3: Create shared components - -1. Create `ApiReferenceLayout` component -2. Create `EndpointList` component -3. Create `MethodContent` component (refactor from existing) -4. Create `SchemaContent` component (refactor from existing) -5. Create breadcrumb navigation - -### Step 4: Update next.config.js - -```javascript -// Remove old rewrites -// { -// source: "/api-reference/:path+", -// destination: "/api-reference", -// }, - -// Add redirects for old URLs if needed -async redirects() { - return [ - // If you had hash-based URLs that were indexed - ]; -} -``` - -### Step 5: Repeat for MAPI - -Apply the same structure to `/mapi-reference`. - -### Step 6: Remove old implementation - -1. Delete `pages/api-reference/index.tsx` (old single page) -2. Remove `ApiReferenceContext` -3. Clean up unused components - ---- - -## Testing and rollout - -### Testing checklist - -- [ ] All resource overview pages render -- [ ] All method pages render with correct data -- [ ] All schema pages render with correct data -- [ ] Sidebar navigation highlights current page -- [ ] Breadcrumbs work correctly -- [ ] Cross-resource schema links work -- [ ] Mobile sidebar works -- [ ] ISR revalidation works -- [ ] 404 pages for invalid paths -- [ ] Page sizes are small (check with DevTools) - -### Performance validation - -| Metric | Target | -| ------------------------- | ------ | -| HTML size per method page | <50KB | -| HTML size per schema page | <25KB | -| LCP | <2.5s | -| TTI | <3.8s | -| Lighthouse Performance | >90 | - -### Rollout strategy - -1. **Deploy to preview** - Test on Vercel preview -2. **Verify all pages** - Script to check all generated paths -3. **Compare with current** - Ensure no missing content -4. **Deploy to production** - Remove old implementation -5. **Monitor** - Watch for 404s, broken links - ---- - -## Appendix: File structure summary - -### New files - -``` -pages/api-reference/ -├── index.tsx # Overview page -├── [resource]/ -│ ├── index.tsx # Resource overview -│ └── [...slug].tsx # Methods and schemas - -components/api-reference/ -├── ApiReferenceLayout.tsx # Shared layout -├── MethodPage.tsx # Method page component -├── SchemaPage.tsx # Schema page component -├── MethodContent.tsx # Method documentation -├── SchemaContent.tsx # Schema documentation -├── EndpointList.tsx # List of endpoints -├── SchemaList.tsx # List of schemas -├── SubresourceList.tsx # List of subresources -└── MethodBadge.tsx # GET/POST/etc badge - -lib/ -├── openApiSpec.ts # Add new loader functions -``` - -### Estimated page counts - -| Spec | Resources | Methods | Schemas | Total pages | -| --------- | --------- | ------- | ------- | ----------- | -| API | 12 | ~80 | ~40 | ~133 | -| MAPI | ~10 | ~60 | ~30 | ~100 | -| **Total** | | | | **~233** | - -Build time should remain reasonable since each page loads minimal data. diff --git a/components/ui/ApiSections.tsx b/components/ui/ApiSections.tsx index be2586de3..708780bbb 100644 --- a/components/ui/ApiSections.tsx +++ b/components/ui/ApiSections.tsx @@ -112,7 +112,6 @@ export const ExampleColumn = ({ children }) => ( flexWrap="wrap" w="full" gap="5" - position="sticky" className="md-no-left-padding" > {children} diff --git a/lib/openApiSpec.ts b/lib/openApiSpec.ts index b46d7ee07..0682892e5 100644 --- a/lib/openApiSpec.ts +++ b/lib/openApiSpec.ts @@ -789,10 +789,7 @@ async function getFullResourcePageData( return null; } - const baseUrl = - stainlessSpec.environments[ - specName === "api" ? "production" : "production" - ] || ""; + const baseUrl = stainlessSpec.environments["production"] || ""; return { resourceName, diff --git a/pages/_app.tsx b/pages/_app.tsx index 45ba7ebac..accf1464d 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -5,7 +5,7 @@ import { import { Inter } from "next/font/google"; import { useRouter } from "next/router"; import { useRemoteRefresh } from "next-remote-refresh/hook"; -import { useEffect, Component as ReactComponent, ErrorInfo } from "react"; +import { useEffect } from "react"; import { InkeepModalProvider } from "../components/AiChatButton"; import { AskAiProvider } from "../components/AskAiContext"; @@ -22,40 +22,6 @@ import "../styles/responsive.css"; const inter = Inter({ subsets: ["latin"], display: "swap" }); -/** - * Error boundary to gracefully catch and display React errors - * instead of crashing the entire page. - */ -class ErrorBoundary extends ReactComponent< - { children: React.ReactNode }, - { hasError: boolean; error: Error | null } -> { - constructor(props: { children: React.ReactNode }) { - super(props); - this.state = { hasError: false, error: null }; - } - - static getDerivedStateFromError(error: Error) { - return { hasError: true, error }; - } - - componentDidCatch(error: Error, errorInfo: ErrorInfo) { - // Log error to console for debugging - console.error("ErrorBoundary caught an error:", error, errorInfo); - } - - render() { - if (this.state.hasError) { - return ( -
- Error: {this.state.error?.message} -
- ); - } - return this.props.children; - } -} - function App({ Component, pageProps }) { const router = useRouter(); const eventEmitter = useEventEmitterInstance(); @@ -87,9 +53,7 @@ function App({ Component, pageProps }) {
- - - + {analytics.SEGMENT_WRITE_KEY && }
From e613ed3163b137970738435109a28e56ab014c77 Mon Sep 17 00:00:00 2001 From: Chris Bell Date: Mon, 2 Feb 2026 11:21:01 -0500 Subject: [PATCH 19/24] chore: always default to prefetch false --- components/ui/NavItem.tsx | 5 +---- next-env.d.ts | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/components/ui/NavItem.tsx b/components/ui/NavItem.tsx index 632311c17..92527f711 100644 --- a/components/ui/NavItem.tsx +++ b/components/ui/NavItem.tsx @@ -57,16 +57,13 @@ const NavItem = ({ } }; - // Next.js is really annoying if you have prefetch={true} so let's just NOT - const prefetchProps = shouldUseSamePageRouting ? { prefetch: false } : {}; - const textPropsWithoutStyle = { ...textProps }; delete textPropsWithoutStyle.style; return ( /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From 1688d91ef1e00d917a465010de3451e3deeed026 Mon Sep 17 00:00:00 2001 From: Chris Bell Date: Mon, 2 Feb 2026 11:38:03 -0500 Subject: [PATCH 20/24] fix: clutching at straws and adding in hacks --- lib/openApiSpec.ts | 1 + pages/_app.tsx | 12 ++++++++++-- pages/api-reference/[resource]/index.tsx | 10 +++++++++- pages/mapi-reference/[resource]/index.tsx | 10 +++++++++- 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/lib/openApiSpec.ts b/lib/openApiSpec.ts index 0682892e5..3a8bf76b8 100644 --- a/lib/openApiSpec.ts +++ b/lib/openApiSpec.ts @@ -785,6 +785,7 @@ async function getFullResourcePageData( ]); const resource = stainlessSpec.resources[resourceName]; + if (!resource) { return null; } diff --git a/pages/_app.tsx b/pages/_app.tsx index accf1464d..a92049061 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -19,10 +19,11 @@ import "@algolia/autocomplete-theme-classic"; import "../styles/index.css"; import "../styles/global.css"; import "../styles/responsive.css"; +import App, { AppContext, AppInitialProps, AppProps } from "next/app"; const inter = Inter({ subsets: ["latin"], display: "swap" }); -function App({ Component, pageProps }) { +function MyApp({ Component, pageProps }) { const router = useRouter(); const eventEmitter = useEventEmitterInstance(); @@ -63,4 +64,11 @@ function App({ Component, pageProps }) { ); } -export default App; +MyApp.getInitialProps = async ( + context: AppContext, +): Promise => { + const ctx = await App.getInitialProps(context); + return { ...ctx }; +}; + +export default MyApp; diff --git a/pages/api-reference/[resource]/index.tsx b/pages/api-reference/[resource]/index.tsx index f1625e662..d8a7d1d39 100644 --- a/pages/api-reference/[resource]/index.tsx +++ b/pages/api-reference/[resource]/index.tsx @@ -20,6 +20,8 @@ export default function ResourcePage({ }: ResourcePageProps) { // Guard against undefined resourceData during client-side transitions if (!resourceData) { + // [cjb] This is an insane hack but I'm not sure what else to do + window.location.reload(); return null; } @@ -58,7 +60,13 @@ export const getStaticPaths: GetStaticPaths = async () => { export const getStaticProps: GetStaticProps = async ({ params, }) => { - const resourceName = params?.resource as string; + const resourceName = Array.isArray(params?.resource) + ? params.resource[0] + : params?.resource; + + if (!resourceName) { + return { notFound: true }; + } const [sidebarData, resourceData] = await Promise.all([ getSidebarData("api"), diff --git a/pages/mapi-reference/[resource]/index.tsx b/pages/mapi-reference/[resource]/index.tsx index f44b54e04..2f8d81304 100644 --- a/pages/mapi-reference/[resource]/index.tsx +++ b/pages/mapi-reference/[resource]/index.tsx @@ -20,6 +20,8 @@ export default function ResourcePage({ }: ResourcePageProps) { // Guard against undefined resourceData during client-side transitions if (!resourceData) { + // [cjb] This is an insane hack but I'm not sure what else to do + window.location.reload(); return null; } @@ -60,7 +62,13 @@ export const getStaticPaths: GetStaticPaths = async () => { export const getStaticProps: GetStaticProps = async ({ params, }) => { - const resourceName = params?.resource as string; + const resourceName = Array.isArray(params?.resource) + ? params.resource[0] + : params?.resource; + + if (!resourceName) { + return { notFound: true }; + } const [sidebarData, resourceData] = await Promise.all([ getSidebarData("mapi"), From 2a2eb3c34a988ff180bd16ab569931e2f74e3c35 Mon Sep 17 00:00:00 2001 From: Chris Bell Date: Mon, 2 Feb 2026 11:42:11 -0500 Subject: [PATCH 21/24] fix: try serializing content --- pages/api-reference/[resource]/index.tsx | 4 +++- pages/mapi-reference/[resource]/index.tsx | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pages/api-reference/[resource]/index.tsx b/pages/api-reference/[resource]/index.tsx index d8a7d1d39..3feb5df6e 100644 --- a/pages/api-reference/[resource]/index.tsx +++ b/pages/api-reference/[resource]/index.tsx @@ -77,10 +77,12 @@ export const getStaticProps: GetStaticProps = async ({ return { notFound: true }; } + const serializableData = JSON.parse(JSON.stringify(resourceData)); + return { props: { sidebarData, - resourceData, + resourceData: serializableData, }, revalidate: 3600, // Revalidate every hour }; diff --git a/pages/mapi-reference/[resource]/index.tsx b/pages/mapi-reference/[resource]/index.tsx index 2f8d81304..7be9fdc74 100644 --- a/pages/mapi-reference/[resource]/index.tsx +++ b/pages/mapi-reference/[resource]/index.tsx @@ -79,10 +79,12 @@ export const getStaticProps: GetStaticProps = async ({ return { notFound: true }; } + const serializableData = JSON.parse(JSON.stringify(resourceData)); + return { props: { sidebarData, - resourceData, + resourceData: serializableData, }, revalidate: 3600, // Revalidate every hour }; From 6f170aa7cdcfb665e8b2aff3ab6f7a6908eceb00 Mon Sep 17 00:00:00 2001 From: Chris Bell Date: Mon, 2 Feb 2026 12:29:28 -0500 Subject: [PATCH 22/24] feat: split openapi spec --- .gitignore | 3 + components/api-reference/ResourceFullPage.tsx | 15 +- .../ui/ApiReference/ApiReferenceContext.tsx | 88 +++- lib/openApiSpec.ts | 45 ++ package.json | 5 +- pages/api-reference/[resource]/index.tsx | 14 +- pages/mapi-reference/[resource]/index.tsx | 14 +- scripts/splitOpenApiSpec.ts | 447 ++++++++++++++++++ 8 files changed, 592 insertions(+), 39 deletions(-) create mode 100644 scripts/splitOpenApiSpec.ts diff --git a/.gitignore b/.gitignore index da3375271..ddacb179d 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,7 @@ yarn-error.log* /public/llms-full.txt /public/**/*.md +# Split OpenAPI spec files (generated by scripts/splitOpenApiSpec.ts) +/data/specs/*/resources/ + tsconfig.tsbuildinfo diff --git a/components/api-reference/ResourceFullPage.tsx b/components/api-reference/ResourceFullPage.tsx index ec03cdc73..6bb3954c5 100644 --- a/components/api-reference/ResourceFullPage.tsx +++ b/components/api-reference/ResourceFullPage.tsx @@ -1,31 +1,28 @@ -import { ApiReferenceProvider } from "@/components/ui/ApiReference/ApiReferenceContext"; +import { SplitApiReferenceProvider } from "@/components/ui/ApiReference/ApiReferenceContext"; import { ApiReferenceSection } from "@/components/ui/ApiReference"; -import { FullResourcePageData } from "@/lib/openApiSpec"; +import { SplitResourceData } from "@/lib/openApiSpec"; import { useInitialScrollState } from "@/components/ui/Page/helpers"; interface ResourceFullPageProps { - data: FullResourcePageData; + data: SplitResourceData; basePath: string; } /** * Renders a full resource page with all methods, schemas, and subresources. - * This component wraps the existing ApiReferenceSection with the proper context. + * Uses split resource data for optimal page size. */ export function ResourceFullPage({ data, basePath }: ResourceFullPageProps) { // Handle scroll to hash on initial load useInitialScrollState(); return ( - + - + ); } diff --git a/components/ui/ApiReference/ApiReferenceContext.tsx b/components/ui/ApiReference/ApiReferenceContext.tsx index 28666a362..52eab55e6 100644 --- a/components/ui/ApiReference/ApiReferenceContext.tsx +++ b/components/ui/ApiReference/ApiReferenceContext.tsx @@ -1,6 +1,6 @@ -import { createContext, useContext, ReactNode } from "react"; +import { createContext, useContext, ReactNode, useMemo } from "react"; import { OpenAPIV3 } from "@scalar/openapi-types"; -import { StainlessConfig } from "../../../lib/openApiSpec"; +import { StainlessConfig, SplitResourceData } from "../../../lib/openApiSpec"; import { buildSchemaReferences } from "./helpers"; import { useRouter } from "next/router"; @@ -11,10 +11,24 @@ interface ApiReferenceContextType { schemaReferences: Record; } +/** + * Context type for split resource data. + * Uses a minimal OpenAPI document structure with only the paths and schemas needed. + */ +interface SplitApiReferenceContextType { + openApiSpec: OpenAPIV3.Document; + baseUrl: string; + schemaReferences: Record; +} + const ApiReferenceContext = createContext( undefined, ); +const SplitApiReferenceContext = createContext< + SplitApiReferenceContextType | undefined +>(undefined); + /** * Lightweight context that only provides schemaReferences and baseUrl. * Used by multi-page API reference components. @@ -59,6 +73,44 @@ export function ApiReferenceProvider({ ); } +/** + * Provider for split resource data. + * Converts split data into a minimal OpenAPI document structure that components can use. + */ +interface SplitApiReferenceProviderProps { + children: ReactNode; + data: SplitResourceData; +} + +export function SplitApiReferenceProvider({ + children, + data, +}: SplitApiReferenceProviderProps) { + // Build a minimal OpenAPI document from the split data + const openApiSpec = useMemo(() => { + return { + openapi: "3.0.0", + info: { title: "", version: "" }, + paths: data.paths, + components: { + schemas: data.schemas, + }, + }; + }, [data.paths, data.schemas]); + + return ( + + {children} + + ); +} + /** * Lightweight provider for multi-page API reference that only needs * schemaReferences and baseUrl (without loading full specs). @@ -81,34 +133,50 @@ export function LightweightApiReferenceProvider({ ); } +/** + * Hook that returns the API reference context. + * Works with both the full ApiReferenceProvider and SplitApiReferenceProvider. + */ export function useApiReference() { - const context = useContext(ApiReferenceContext); - if (context === undefined) { - throw new Error( - "useApiReference must be used within an ApiReferenceProvider", - ); + const fullContext = useContext(ApiReferenceContext); + const splitContext = useContext(SplitApiReferenceContext); + + if (fullContext) { + return fullContext; + } + + if (splitContext) { + return splitContext; } - return context; + + throw new Error( + "useApiReference must be used within an ApiReferenceProvider or SplitApiReferenceProvider", + ); } /** - * Hook that returns schemaReferences from either the full or lightweight context. + * Hook that returns schemaReferences from any of the available contexts. * Use this in components that only need schemaReferences. */ export function useSchemaReferences(): Record { const fullContext = useContext(ApiReferenceContext); + const splitContext = useContext(SplitApiReferenceContext); const lightweightContext = useContext(LightweightContext); if (fullContext) { return fullContext.schemaReferences; } + if (splitContext) { + return splitContext.schemaReferences; + } + if (lightweightContext) { return lightweightContext.schemaReferences; } throw new Error( - "useSchemaReferences must be used within an ApiReferenceProvider or LightweightApiReferenceProvider", + "useSchemaReferences must be used within an ApiReferenceProvider, SplitApiReferenceProvider, or LightweightApiReferenceProvider", ); } diff --git a/lib/openApiSpec.ts b/lib/openApiSpec.ts index 3a8bf76b8..9b65e08a8 100644 --- a/lib/openApiSpec.ts +++ b/lib/openApiSpec.ts @@ -802,6 +802,49 @@ async function getFullResourcePageData( }; } +// ============================================================================ +// Split Resource Data (optimized per-resource data from pre-built files) +// ============================================================================ + +/** + * Pre-split resource data loaded from generated JSON files. + * Contains only the data needed to render a single resource page, + * significantly smaller than FullResourcePageData. + */ +type SplitResourceData = { + resourceName: string; + resource: StainlessResource; + paths: Record; + schemas: Record; + schemaReferences: Record; + baseUrl: string; +}; + +/** + * Load pre-split resource data from generated JSON file. + * These files are created by scripts/splitOpenApiSpec.ts during build. + * + * Falls back to getFullResourcePageData if the split file doesn't exist + * (e.g., during development before running split-specs). + */ +async function getSplitResourceData( + specName: SpecName, + resourceName: string, +): Promise { + const filePath = `./data/specs/${specName}/resources/${resourceName}.json`; + + try { + const data = await readFile(filePath, "utf8"); + return JSON.parse(data) as SplitResourceData; + } catch { + // File doesn't exist, likely running in dev without split-specs + console.warn( + `Split resource file not found: ${filePath}. Run 'yarn split-specs' to generate.`, + ); + return null; + } +} + // ============================================================================ // Exports // ============================================================================ @@ -822,6 +865,7 @@ export type { ApiReferencePath, SpecName, FullResourcePageData, + SplitResourceData, }; export { @@ -835,6 +879,7 @@ export { getSchemaPageData, getResourceOverviewData, getFullResourcePageData, + getSplitResourceData, // Path generation getAllApiReferencePaths, getResourceOrder, diff --git a/package.json b/package.json index 7446fb8d6..2a84ea312 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,9 @@ "generate-reference-md": "tsx scripts/generateApiMarkdown.ts", "index-apis": "tsx scripts/indexApisForSearch.ts", "open-api-to-md": "bash scripts/openApiToMd.sh", - "predev": "yarn generate-llms", - "prebuild": "yarn generate-llms && yarn index-apis" + "split-specs": "tsx scripts/splitOpenApiSpec.ts", + "predev": "yarn split-specs && yarn generate-llms", + "prebuild": "yarn split-specs && yarn generate-llms && yarn index-apis" }, "dependencies": { "@algolia/autocomplete-js": "^1.6.3", diff --git a/pages/api-reference/[resource]/index.tsx b/pages/api-reference/[resource]/index.tsx index 3feb5df6e..a9319b9d0 100644 --- a/pages/api-reference/[resource]/index.tsx +++ b/pages/api-reference/[resource]/index.tsx @@ -1,8 +1,8 @@ import { GetStaticPaths, GetStaticProps } from "next"; import { - getFullResourcePageData, + getSplitResourceData, getSidebarData, - FullResourcePageData, + SplitResourceData, SidebarData, } from "@/lib/openApiSpec"; import { ApiReferenceLayout } from "@/components/api-reference"; @@ -11,7 +11,7 @@ import { API_REFERENCE_OVERVIEW_CONTENT } from "@/data/sidebars/apiOverviewSideb interface ResourcePageProps { sidebarData: SidebarData; - resourceData: FullResourcePageData; + resourceData: SplitResourceData; } export default function ResourcePage({ @@ -20,8 +20,6 @@ export default function ResourcePage({ }: ResourcePageProps) { // Guard against undefined resourceData during client-side transitions if (!resourceData) { - // [cjb] This is an insane hack but I'm not sure what else to do - window.location.reload(); return null; } @@ -70,19 +68,17 @@ export const getStaticProps: GetStaticProps = async ({ const [sidebarData, resourceData] = await Promise.all([ getSidebarData("api"), - getFullResourcePageData("api", resourceName), + getSplitResourceData("api", resourceName), ]); if (!resourceData) { return { notFound: true }; } - const serializableData = JSON.parse(JSON.stringify(resourceData)); - return { props: { sidebarData, - resourceData: serializableData, + resourceData, }, revalidate: 3600, // Revalidate every hour }; diff --git a/pages/mapi-reference/[resource]/index.tsx b/pages/mapi-reference/[resource]/index.tsx index 7be9fdc74..da8f77a9b 100644 --- a/pages/mapi-reference/[resource]/index.tsx +++ b/pages/mapi-reference/[resource]/index.tsx @@ -1,8 +1,8 @@ import { GetStaticPaths, GetStaticProps } from "next"; import { - getFullResourcePageData, + getSplitResourceData, getSidebarData, - FullResourcePageData, + SplitResourceData, SidebarData, } from "@/lib/openApiSpec"; import { ApiReferenceLayout } from "@/components/api-reference"; @@ -11,7 +11,7 @@ import { MAPI_REFERENCE_OVERVIEW_CONTENT } from "@/data/sidebars/mapiOverviewSid interface ResourcePageProps { sidebarData: SidebarData; - resourceData: FullResourcePageData; + resourceData: SplitResourceData; } export default function ResourcePage({ @@ -20,8 +20,6 @@ export default function ResourcePage({ }: ResourcePageProps) { // Guard against undefined resourceData during client-side transitions if (!resourceData) { - // [cjb] This is an insane hack but I'm not sure what else to do - window.location.reload(); return null; } @@ -72,19 +70,17 @@ export const getStaticProps: GetStaticProps = async ({ const [sidebarData, resourceData] = await Promise.all([ getSidebarData("mapi"), - getFullResourcePageData("mapi", resourceName), + getSplitResourceData("mapi", resourceName), ]); if (!resourceData) { return { notFound: true }; } - const serializableData = JSON.parse(JSON.stringify(resourceData)); - return { props: { sidebarData, - resourceData: serializableData, + resourceData, }, revalidate: 3600, // Revalidate every hour }; diff --git a/scripts/splitOpenApiSpec.ts b/scripts/splitOpenApiSpec.ts new file mode 100644 index 000000000..262e419b9 --- /dev/null +++ b/scripts/splitOpenApiSpec.ts @@ -0,0 +1,447 @@ +/** + * Build-time script that splits the OpenAPI spec into per-resource JSON files. + * This reduces the data sent to each resource page from ~19k lines to just what's needed. + * + * Run with: yarn split-specs + */ + +import { dereference } from "@scalar/openapi-parser"; +import { OpenAPIV3 } from "@scalar/openapi-types"; +import deepmerge from "deepmerge"; +import { readFile, writeFile, mkdir } from "fs/promises"; +import JSONPointer from "jsonpointer"; +import safeStringify from "safe-stringify"; +import { parse } from "yaml"; + +// ============================================================================ +// Types +// ============================================================================ + +type StainlessResourceMethod = + | string + | { + type: "http"; + endpoint: string; + positional_params?: string[]; + }; + +type StainlessResource = { + name?: string; + description?: string; + models?: Record; + methods?: Record; + subresources?: Record; +}; + +interface StainlessConfig { + resources: { + [key: string]: StainlessResource; + }; + environments: Record; +} + +type SpecName = "api" | "mapi"; + +/** + * Data structure for a split resource file. + * Contains only the data needed to render a single resource page. + */ +export type SplitResourceData = { + resourceName: string; + resource: StainlessResource; + paths: Record; + schemas: Record; + schemaReferences: Record; + baseUrl: string; +}; + +// ============================================================================ +// Helpers +// ============================================================================ + +function yamlToJson(yaml: string) { + return parse(yaml); +} + +/** + * Resolve endpoint configuration to [methodType, endpoint] tuple. + */ +function resolveEndpoint( + methodConfig: StainlessResourceMethod, +): [string, string] { + const endpointString = + typeof methodConfig === "string" ? methodConfig : methodConfig.endpoint; + const [methodType, endpoint] = endpointString.split(" "); + return [methodType.toLowerCase(), endpoint]; +} + +// ============================================================================ +// Spec Loading +// ============================================================================ + +async function readOpenApiSpec(specName: string): Promise { + const spec = await readFile(`./data/specs/${specName}/openapi.yml`, "utf8"); + const jsonSpec = yamlToJson(spec); + const { schema } = await dereference(jsonSpec); + return JSON.parse(safeStringify(schema)) as OpenAPIV3.Document; +} + +async function readSpecCustomizations(specName: string) { + const spec = await readFile( + `./data/specs/${specName}/customizations.yml`, + "utf8", + ); + return parse(spec); +} + +async function readStainlessSpec(specName: string): Promise { + const customizations = await readSpecCustomizations(specName); + const spec = await readFile( + `./data/specs/${specName}/stainless.yml`, + "utf8", + ); + const stainlessSpec = parse(spec); + return deepmerge(stainlessSpec, customizations) as StainlessConfig; +} + +// ============================================================================ +// Path Extraction +// ============================================================================ + +/** + * Extract all endpoints used by a resource and its subresources. + */ +function extractPathsForResource( + resource: StainlessResource, + openApiSpec: OpenAPIV3.Document, +): Record { + const paths: Record = {}; + + // Extract paths from methods + if (resource.methods) { + for (const methodConfig of Object.values(resource.methods)) { + const [methodType, endpoint] = resolveEndpoint(methodConfig); + const pathItem = openApiSpec.paths?.[endpoint]; + + if (pathItem) { + // Only include the specific method we need, not the entire path object + if (!paths[endpoint]) { + paths[endpoint] = {}; + } + const method = methodType as keyof OpenAPIV3.PathItemObject; + if (pathItem[method]) { + (paths[endpoint] as Record)[method] = + pathItem[method]; + } + // Also include path-level parameters if they exist + if (pathItem.parameters) { + paths[endpoint].parameters = pathItem.parameters; + } + } + } + } + + // Recursively extract paths from subresources + if (resource.subresources) { + for (const subresource of Object.values(resource.subresources)) { + Object.assign(paths, extractPathsForResource(subresource, openApiSpec)); + } + } + + return paths; +} + +// ============================================================================ +// Schema Extraction (with dependency resolution) +// ============================================================================ + +/** + * Extract a schema name from a $ref string. + * Example: "#/components/schemas/User" -> "User" + */ +function getSchemaNameFromRef(ref: string): string | null { + const match = ref.match(/^#\/components\/schemas\/(.+)$/); + return match ? match[1] : null; +} + +/** + * Recursively find all schema references within a schema object. + * This handles nested objects, arrays, allOf, oneOf, anyOf, etc. + */ +function findSchemaReferences(obj: unknown, refs: Set): void { + if (!obj || typeof obj !== "object") { + return; + } + + if (Array.isArray(obj)) { + for (const item of obj) { + findSchemaReferences(item, refs); + } + return; + } + + const record = obj as Record; + + // Check for $ref + if (typeof record.$ref === "string") { + refs.add(record.$ref); + } + + // Recurse into all properties + for (const value of Object.values(record)) { + findSchemaReferences(value, refs); + } +} + +/** + * Extract schemas referenced by a resource and all their dependencies. + * Uses recursive resolution to include nested schema references. + */ +function extractSchemasForResource( + resource: StainlessResource, + openApiSpec: OpenAPIV3.Document, + collectedSchemas: Map = new Map(), + visited: Set = new Set(), +): Map { + // First, collect schemas directly referenced in models + if (resource.models) { + for (const modelRef of Object.values(resource.models)) { + extractSchemaWithDependencies( + modelRef, + openApiSpec, + collectedSchemas, + visited, + ); + } + } + + // Also extract schemas referenced in operation bodies and responses + if (resource.methods) { + for (const methodConfig of Object.values(resource.methods)) { + const [methodType, endpoint] = resolveEndpoint(methodConfig); + const operation = openApiSpec.paths?.[endpoint]?.[ + methodType as keyof OpenAPIV3.PathItemObject + ] as OpenAPIV3.OperationObject | undefined; + + if (operation) { + // Find all refs in the operation + const refs = new Set(); + findSchemaReferences(operation, refs); + + for (const ref of refs) { + extractSchemaWithDependencies( + ref, + openApiSpec, + collectedSchemas, + visited, + ); + } + } + } + } + + // Recursively process subresources + if (resource.subresources) { + for (const subresource of Object.values(resource.subresources)) { + extractSchemasForResource( + subresource, + openApiSpec, + collectedSchemas, + visited, + ); + } + } + + return collectedSchemas; +} + +/** + * Extract a single schema and all its dependencies recursively. + */ +function extractSchemaWithDependencies( + schemaRef: string, + openApiSpec: OpenAPIV3.Document, + collectedSchemas: Map, + visited: Set, +): void { + if (visited.has(schemaRef)) { + return; + } + visited.add(schemaRef); + + // Get the schema + const schema = JSONPointer.get(openApiSpec, schemaRef.replace("#", "")) as + | OpenAPIV3.SchemaObject + | undefined; + + if (!schema) { + return; + } + + // Extract schema name from ref + const schemaName = getSchemaNameFromRef(schemaRef); + if (schemaName) { + collectedSchemas.set(schemaName, schema); + } + + // Find all nested references in this schema + const nestedRefs = new Set(); + findSchemaReferences(schema, nestedRefs); + + // Recursively extract each referenced schema + for (const ref of nestedRefs) { + extractSchemaWithDependencies(ref, openApiSpec, collectedSchemas, visited); + } +} + +// ============================================================================ +// Schema References Builder +// ============================================================================ + +/** + * Build schema references map for a resource (used for cross-linking). + */ +function buildSchemaReferencesForResource( + resource: StainlessResource, + openApiSpec: OpenAPIV3.Document, + basePath: string, +): Record { + const schemaReferences: Record = {}; + + if (resource.models) { + for (const [modelName, modelRef] of Object.entries(resource.models)) { + const schema = JSONPointer.get(openApiSpec, modelRef.replace("#", "")) as + | OpenAPIV3.SchemaObject + | undefined; + + const title = schema?.title ?? modelName; + + if (schema) { + schemaReferences[title] = `${basePath}/schemas/${modelName}`; + schemaReferences[`${title}[]`] = `${basePath}/schemas/${modelName}`; + } + } + } + + if (resource.subresources) { + for (const [subresourceName, subresource] of Object.entries( + resource.subresources, + )) { + Object.assign( + schemaReferences, + buildSchemaReferencesForResource( + subresource, + openApiSpec, + `${basePath}/${subresourceName}`, + ), + ); + } + } + + return schemaReferences; +} + +/** + * Build complete schema references for all resources (for cross-resource linking). + */ +function buildAllSchemaReferences( + stainlessSpec: StainlessConfig, + openApiSpec: OpenAPIV3.Document, + basePath: string, +): Record { + const schemaReferences: Record = {}; + + for (const [resourceName, resource] of Object.entries( + stainlessSpec.resources, + )) { + Object.assign( + schemaReferences, + buildSchemaReferencesForResource( + resource, + openApiSpec, + `${basePath}/${resourceName}`, + ), + ); + } + + return schemaReferences; +} + +// ============================================================================ +// Main Split Function +// ============================================================================ + +async function splitSpec(specName: SpecName): Promise { + console.log(`Splitting ${specName} spec...`); + + const [openApiSpec, stainlessSpec] = await Promise.all([ + readOpenApiSpec(specName), + readStainlessSpec(specName), + ]); + + const basePath = + specName === "api" ? "/api-reference" : "/mapi-reference"; + const baseUrl = stainlessSpec.environments.production; + + // Build complete schema references for cross-resource linking + const allSchemaReferences = buildAllSchemaReferences( + stainlessSpec, + openApiSpec, + basePath, + ); + + // Create output directory + const outputDir = `./data/specs/${specName}/resources`; + await mkdir(outputDir, { recursive: true }); + + // Process each resource + for (const [resourceName, resource] of Object.entries( + stainlessSpec.resources, + )) { + console.log(` Processing ${resourceName}...`); + + // Extract paths for this resource + const paths = extractPathsForResource(resource, openApiSpec); + + // Extract schemas with dependencies + const schemasMap = extractSchemasForResource(resource, openApiSpec); + const schemas: Record = {}; + for (const [name, schema] of schemasMap) { + schemas[name] = schema; + } + + // Build the split resource data + const splitData: SplitResourceData = { + resourceName, + resource, + paths, + schemas, + schemaReferences: allSchemaReferences, + baseUrl, + }; + + // Write to file + const outputPath = `${outputDir}/${resourceName}.json`; + await writeFile(outputPath, JSON.stringify(splitData, null, 2)); + console.log(` -> ${outputPath}`); + } + + console.log(`Done splitting ${specName} spec.`); +} + +// ============================================================================ +// Entry Point +// ============================================================================ + +async function main() { + console.log("Splitting OpenAPI specs by resource...\n"); + + await Promise.all([splitSpec("api"), splitSpec("mapi")]); + + console.log("\nAll specs split successfully."); +} + +main().catch((error) => { + console.error("Error splitting specs:", error); + process.exit(1); +}); From ba393d263d083cf103a31973d21998c43c511aee Mon Sep 17 00:00:00 2001 From: Chris Bell Date: Mon, 2 Feb 2026 12:43:00 -0500 Subject: [PATCH 23/24] chore: disable prefetch for api refs --- components/api-reference/ApiReferenceLayout.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/components/api-reference/ApiReferenceLayout.tsx b/components/api-reference/ApiReferenceLayout.tsx index 268da2b91..0e6124c14 100644 --- a/components/api-reference/ApiReferenceLayout.tsx +++ b/components/api-reference/ApiReferenceLayout.tsx @@ -1,4 +1,4 @@ -import { useRef } from "react"; +import { useEffect, useRef } from "react"; import { useRouter } from "next/router"; import Meta from "@/components/Meta"; import { Page as TelegraphPage } from "@/components/ui/Page"; @@ -64,6 +64,15 @@ export function ApiReferenceLayout({ const router = useRouter(); const scrollerRef = useRef(null); + useEffect(() => { + const prefetch = router.prefetch; + router.prefetch = async () => {}; + + return () => { + router.prefetch = prefetch; + }; + }, [router]); + const basePath = router.pathname.split("/")[1]; const canonicalPath = currentPath || `/${basePath}`; From 1ee60681b5635a2955e0d3aebbb9dac6673f82ac Mon Sep 17 00:00:00 2001 From: Chris Bell Date: Mon, 2 Feb 2026 12:45:13 -0500 Subject: [PATCH 24/24] chore: prettier --- scripts/splitOpenApiSpec.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/scripts/splitOpenApiSpec.ts b/scripts/splitOpenApiSpec.ts index 262e419b9..a7a345c24 100644 --- a/scripts/splitOpenApiSpec.ts +++ b/scripts/splitOpenApiSpec.ts @@ -96,10 +96,7 @@ async function readSpecCustomizations(specName: string) { async function readStainlessSpec(specName: string): Promise { const customizations = await readSpecCustomizations(specName); - const spec = await readFile( - `./data/specs/${specName}/stainless.yml`, - "utf8", - ); + const spec = await readFile(`./data/specs/${specName}/stainless.yml`, "utf8"); const stainlessSpec = parse(spec); return deepmerge(stainlessSpec, customizations) as StainlessConfig; } @@ -379,8 +376,7 @@ async function splitSpec(specName: SpecName): Promise { readStainlessSpec(specName), ]); - const basePath = - specName === "api" ? "/api-reference" : "/mapi-reference"; + const basePath = specName === "api" ? "/api-reference" : "/mapi-reference"; const baseUrl = stainlessSpec.environments.production; // Build complete schema references for cross-resource linking