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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions app/(api-reference)/api-reference/[[...slug]]/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export default function Loading() {
return (
<div className="p-6">
<div className="flex flex-col gap-6">
<div className="h-12 w-64 bg-gray-200 rounded animate-pulse" />
<div className="h-6 w-96 bg-gray-100 rounded animate-pulse" />
<div className="flex flex-col gap-4 mt-8">
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-48 w-full bg-gray-100 rounded animate-pulse"
/>
))}
</div>
</div>
</div>
);
}
146 changes: 146 additions & 0 deletions app/(api-reference)/api-reference/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { Suspense } from "react";
import { getStainlessSpec } from "../../../../lib/openapi/loader";
import {
buildSchemaReferences,
getSidebarContent,
} from "../../../../lib/openapi/helpers";
import {
RESOURCE_ORDER,
API_REFERENCE_OVERVIEW_CONTENT,
} from "../../../../data/sidebars/apiOverviewSidebar";
import {
PageShell,
ResourceSection,
PreContent,
ResourceSectionSkeleton,
} from "../components";
import type { Metadata } from "next";

export const dynamic = "force-static";
export const revalidate = 3600;

export async function generateMetadata(): Promise<Metadata> {
return {
title: "API reference | Knock Docs",
description: "Complete reference documentation for the Knock API.",
alternates: {
canonical: "/api-reference",
},
};
}

// Generate all possible static paths
export async function generateStaticParams() {
const stainless = await getStainlessSpec("api");
const paths: { slug: string[] }[] = [];

// Root path (no slug)
paths.push({ slug: [] });

// Overview paths
API_REFERENCE_OVERVIEW_CONTENT.forEach((section) => {
if (section.pages) {
section.pages.forEach((page) => {
const slug = page.slug.replace(/^\//, "").split("/").filter(Boolean);
if (slug.length > 0) {
paths.push({ slug: ["overview", ...slug] });
}
});
}
});
paths.push({ slug: ["overview"] });

// Resource paths
function addResourcePaths(
resourceName: string,
resource: (typeof stainless.resources)[string],
basePath: string[] = [],
) {
const resourcePath = [...basePath, resourceName];
paths.push({ slug: resourcePath });

// Method paths
if (resource.methods) {
Object.keys(resource.methods).forEach((methodName) => {
paths.push({ slug: [...resourcePath, methodName] });
});
}

// Schema paths
if (resource.models) {
paths.push({ slug: [...resourcePath, "schemas"] });
Object.keys(resource.models).forEach((modelName) => {
paths.push({ slug: [...resourcePath, "schemas", modelName] });
});
}

// Subresource paths
if (resource.subresources) {
Object.entries(resource.subresources).forEach(
([subName, subResource]) => {
addResourcePaths(subName, subResource, resourcePath);
},
);
}
}

RESOURCE_ORDER.forEach((resourceName) => {
const resource = stainless.resources[resourceName];
if (resource) {
addResourcePaths(resourceName, resource);
}
});

return paths;
}

export default async function ApiReferencePage() {
const stainless = await getStainlessSpec("api");
const schemaReferences = await buildSchemaReferences("api", "/api-reference");
const baseUrl = stainless.environments.production;

// Transform sidebar content for compatibility
const sidebarContent = await getSidebarContent(
"api",
RESOURCE_ORDER,
"/api-reference",
API_REFERENCE_OVERVIEW_CONTENT.map((section) => ({
...section,
title: section.title || "",
})),
);

// Transform to SidebarSection format
const transformedSidebar = sidebarContent.map((section) => ({
title: section.title,
slug: section.slug,
pages: section.pages || [],
sidebarMenuDefaultOpen:
API_REFERENCE_OVERVIEW_CONTENT.find((s) => s.slug === section.slug)
?.sidebarMenuDefaultOpen ?? false,
}));

return (
<PageShell
sidebarContent={transformedSidebar}
title="API reference"
description="Complete reference documentation for the Knock API."
>
<Suspense fallback={<ResourceSectionSkeleton />}>
<PreContent specName="api" />
</Suspense>

{RESOURCE_ORDER.map((resourceName) => (
<Suspense key={resourceName} fallback={<ResourceSectionSkeleton />}>
<ResourceSection
specName="api"
resourceName={resourceName}
basePath="/api-reference"
baseUrl={baseUrl}
schemaReferences={schemaReferences}
/>
</Suspense>
))}
</PageShell>
);
}
171 changes: 171 additions & 0 deletions app/(api-reference)/api-reference/components/api-sections.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
"use client";

import { Box, Stack } from "@telegraph/layout";
import { Text, Heading } from "@telegraph/typography";
import Link from "next/link";
import { SectionHeadingAppRouter } from "./section-heading";
import { ContentActionsAppRouter } from "./content-actions";

export const Section = ({
title,
children,
isIdempotent = false,
isRetentionSubject = false,
path,
mdPath,
}: {
title?: string;
children: React.ReactNode;
isIdempotent?: boolean;
isRetentionSubject?: boolean;
path?: string;
mdPath?: string;
}) => {
const handleRetentionClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
const element = document.querySelector(
'[data-resource-path="/overview/data-retention"]',
);
if (element) {
element.scrollIntoView({ behavior: "smooth" });
} else {
window.location.href = "/api-reference/overview/data-retention";
}
};

const handleIdempotentClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
e.preventDefault();
const element = document.querySelector(
'[data-resource-path="/overview/idempotent-requests"]',
);
if (element) {
element.scrollIntoView({ behavior: "smooth" });
} else {
window.location.href = "/api-reference/overview/idempotent-requests";
}
};

return (
<Box
as="section"
borderBottom="px"
borderColor="gray-3"
py="16"
data-resource-path={path}
>
{title && (
<>
{isIdempotent && (
<Box mb="2">
<Text
as="span"
size="0"
bg="yellow-3"
py="1"
px="3"
color="black"
borderRadius="2"
>
<Link
href="/api-reference/overview/idempotent-requests"
onClick={handleIdempotentClick}
style={{ color: "inherit", textDecoration: "none" }}
>
Idempotent
</Link>
</Text>
</Box>
)}
{isRetentionSubject && (
<Box mb="2">
<Text
as="span"
size="0"
bg="yellow-3"
py="1"
px="3"
color="black"
borderRadius="2"
>
<Link
href="/api-reference/overview/data-retention"
onClick={handleRetentionClick}
style={{ color: "inherit", textDecoration: "none" }}
>
Retention policy applied
</Link>
</Text>
</Box>
)}
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
mb="6"
>
<SectionHeadingAppRouter tag="h2" path={path}>
{title}
</SectionHeadingAppRouter>
{mdPath && <ContentActionsAppRouter mdPath={mdPath} />}
</Stack>
</>
)}
<Stack
w="full"
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
justifyItems: "flex-start",
}}
className="md-one-column"
overflow="hidden"
>
{children}
</Stack>
</Box>
);
};

export const ContentColumn = ({ children }: { children: React.ReactNode }) => (
<Stack pr="3" w="full">
<Box w="full">{children}</Box>
</Stack>
);

export const ExampleColumn = ({ children }: { children: React.ReactNode }) => (
<Box position="relative" minW="0" w="full" data-example-column>
<Stack
mt="5"
pl="5"
flexDirection="column"
flexWrap="wrap"
w="full"
gap="5"
position="sticky"
style={{ top: "100px" }}
className="md-no-left-padding"
>
{children}
</Stack>
</Box>
);

export const ErrorExample = ({
title,
description,
}: {
title: string;
description: React.ReactNode;
}) => (
<div
data-error-example
className="flex-col pt-6 mt-6 border-gray-200 border-t dark:border-gray-700"
>
<span className="bg-code-background dark:bg-gray-800 text-code rounded text-sm font-normal py-0.75 px-1.5 font-mono inline-block">
{title}
</span>
<span className="block pt-0 mt-1 text-gray-800 dark:text-gray-200 text-sm">
{description}
</span>
</div>
);
57 changes: 57 additions & 0 deletions app/(api-reference)/api-reference/components/content-actions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"use client";

import { useRef, useState } from "react";
import { Button } from "@telegraph/button";
import { Stack } from "@telegraph/layout";
import { Icon } from "@telegraph/icon";
import { Check, Link2 } from "lucide-react";

interface ContentActionsProps {
mdPath?: string;
showOnMobile?: boolean;
style?: React.CSSProperties;
}

export function ContentActionsAppRouter({
mdPath,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused mdPath prop in ContentActionsAppRouter

Low Severity

The mdPath prop is declared in the interface and destructured in the component but never used in the function body. Either remove it or implement the intended functionality (e.g., edit on GitHub link).

Fix in Cursor Fix in Web

showOnMobile = false,
style,
}: ContentActionsProps) {
const [copied, setCopied] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);

const handleCopyLink = async () => {
const url = window.location.href;
await navigator.clipboard.writeText(url);
setCopied(true);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setCopied(false);
}, 2000);
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing timeout cleanup causes memory leak on unmount

Low Severity

The ContentActionsAppRouter component creates a timeout via timeoutRef but lacks a cleanup effect to clear it on unmount. If the component unmounts while a timeout is pending (e.g., user navigates away within 2 seconds of copying), the timeout will still fire and attempt to call setCopied(false) on an unmounted component. This causes a memory leak and React's "Can't perform a state update on an unmounted component" warning. The existing useClipboard hook in hooks/useClipboard.ts correctly implements this cleanup pattern with a useEffect return function.

Fix in Cursor Fix in Web


return (
<Stack
direction="row"
gap="1"
className={showOnMobile ? "" : "hidden md:flex"}
style={style}
>
<Button.Root
variant="ghost"
size="0"
onClick={handleCopyLink}
aria-label="Copy link"
>
<Icon
icon={copied ? Check : Link2}
size="2"
color={copied ? "green" : "gray"}
aria-hidden
/>
</Button.Root>
</Stack>
);
}
Loading
Loading