diff --git a/docs/components/api-method.tsx b/docs/components/api-method.tsx index 752568ee82..e756417ddf 100644 --- a/docs/components/api-method.tsx +++ b/docs/components/api-method.tsx @@ -1,5 +1,8 @@ import { Link } from "lucide-react"; import type { JSX, ReactNode } from "react"; +import { Children, isValidElement } from "react"; +import type { ApiMethodOptions, ApiMethodProperty } from "@/lib/api-method"; +import { generateApiMethodExamples, parseApiMethod } from "@/lib/api-method"; import { cn } from "@/lib/utils"; import { ApiMethodTabs, @@ -11,181 +14,52 @@ import { Endpoint } from "./endpoint"; import { Button } from "./ui/button"; import { DynamicCodeBlock } from "./ui/dynamic-code-block"; -type Property = { - isOptional: boolean; - description: string | null; - propName: string; - type: string; - exampleValue: string | null; - comments: string | null; - isServerOnly: boolean; - path: string[]; - isNullable: boolean; - isClientOnly: boolean; -}; +const apiMethodTabs = [ + { + value: "client", + label: "Client", + icon: ( + <> + + + + ), + }, + { + value: "server", + label: "Server", + icon: ( + <> + + + + + ), + }, +] as const; -const placeholderProperty: Property = { - isOptional: false, - comments: null, - description: null, - exampleValue: null, - propName: "", - type: "", - isServerOnly: false, - path: [], - isNullable: false, - isClientOnly: false, -}; - -const indentationSpace = ` `; - -export const APIMethod = ({ - path, - isServerOnly, - isClientOnly, - isExternalOnly, - method, +export function APIMethod({ children, - noResult, - requireSession, - requireHeaders, - requireBearerToken, - headersComment, - note, - clientOnlyNote, - serverOnlyNote, - resultVariable = "data", - forceAsBody, - forceAsParam, - forceAsQuery, -}: { - /** - * Endpoint path - */ - path: string; - /** - * If enabled, we will add `headers` to the fetch options, indicating the given API method requires auth headers. - * - * @default false - */ - requireSession?: boolean; - /** - * If enabled, will add a bearer authorization header to the fetch options - * - * @default false - */ - requireBearerToken?: boolean; - /** - * If enabled, we will add `headers` to the fetch options without assuming the request is backed by a session. - * - * @default false - */ - requireHeaders?: boolean; - /** - * A custom comment to display above the generated `headers` line when `requireHeaders` is enabled. - */ - headersComment?: string; - /** - * The HTTP method to the endpoint - * - * @default "GET" - */ - method?: "POST" | "GET" | "DELETE" | "PUT"; - /** - * Wether the endpoint is server only or not. - * - * @default false - */ - isServerOnly?: boolean; - /** - * Wether the code example is client-only, thus meaning it's an endpoint. - * - * @default false - */ - isClientOnly?: boolean; - /** - * Wether the code example is meant for external consumers - */ - isExternalOnly?: boolean; - /** - * The `ts` codeblock which describes the API method. - * I recommend checking other parts of the Better-Auth docs which is using this component to get an idea of how to - * write out the children. - */ - children: JSX.Element; - /** - * If enabled, will remove the `const data = ` part, since this implies there will be no return data from the API method. - */ - noResult?: boolean; - /** - * A small note to display above the client-auth example code-block. - */ - clientOnlyNote?: string; - /** - * A small note to display above the server-auth example code-block. - */ - serverOnlyNote?: string; - /** - * A small note to display above both the client & server auth example code-blocks. - */ - note?: string; - /** - * The result output variable name. - * - * @default "data" - */ - resultVariable?: string; - /** - * Force the server auth API to use `body`, rather than auto choosing - */ - forceAsBody?: boolean; - /** - * Force the server auth API to use `query`, rather than auto choosing - */ - forceAsQuery?: boolean; - /** - * Force the server auth api to use `path`, rather than auto choosing - */ - forceAsParam?: boolean; -}) => { - const { props, functionName, code_prefix, code_suffix } = parseCode(children); - - const authClientMethodPath = pathToDotNotation(path); - - const clientBody = createClientBody({ - props, - method: method ?? "GET", - forceAsBody, - forceAsQuery, - forceAsParam, - }); - - const serverBody = createServerBody({ - props, - method: method ?? "GET", - requireSession: requireSession ?? false, - requireHeaders: requireHeaders ?? false, - requireBearerToken: requireBearerToken ?? false, - headersComment, - forceAsQuery, - forceAsParam, - forceAsBody, - }); - - const serverCodeBlock = ( - - ); + ...options +}: ApiMethodOptions & { children: ReactNode }) { + const { + path, + method, + isServerOnly, + isClientOnly, + isExternalOnly, + note, + clientOnlyNote, + serverOnlyNote, + } = options; + const definition = parseApiMethod(getTextContent(children)); + const examples = generateApiMethodExamples(definition, options); + const properties = definition.properties; const serverTabContent = (
{isClientOnly || isServerOnly ? null : ( - + )} {serverOnlyNote || note ? ( @@ -198,15 +72,19 @@ export const APIMethod = ({ ) : null} ) : null} -
- {serverCodeBlock} +
+ {isClientOnly ? (
This is a client-only endpoint
) : null}
- {!isClientOnly ? : null} + {!isClientOnly ? : null}
); @@ -230,31 +108,7 @@ export const APIMethod = ({ className="gap-0 w-full" > - {( - [ - { - value: "client", - label: "Client", - icon: ( - <> - - - - ), - }, - { - value: "server", - label: "Server", - icon: ( - <> - - - - - ), - }, - ] as const - ).map((tab) => ( + {apiMethodTabs.map((tab) => ( @@ -291,7 +145,7 @@ export const APIMethod = ({
{isServerOnly ? null : ( - + )} {clientOnlyNote || note ? ( @@ -304,15 +158,9 @@ export const APIMethod = ({ ) : null} ) : null} -
+
@@ -323,7 +171,7 @@ export const APIMethod = ({ ) : null}
{!isServerOnly ? ( - + ) : null}
@@ -333,60 +181,32 @@ export const APIMethod = ({ ); -}; - -function pathToDotNotation(input: string): string { - return input - .split("/") // split into segments - .filter(Boolean) // remove empty strings (from leading '/') - .map((segment) => - segment - .split("-") // split kebab-case - .map((word, i) => - i === 0 - ? word.toLowerCase() - : word.charAt(0).toUpperCase() + word.slice(1), - ) - .join(""), - ) - .join("."); } -function getChildren( - x: - | ({ props: { children: string } } | string) - | ({ props: { children: string } } | string)[], -): string[] { - if (Array.isArray(x)) { - const res = []; - for (const item of x) { - res.push(getChildren(item)); - } - return res.flat(); - } else { - if (typeof x === "string") return [x]; - return [x.props.children]; - } +function getTextContent(node: ReactNode): string { + return Children.toArray(node) + .map((child) => + isValidElement<{ children?: ReactNode }>(child) + ? getTextContent(child.props.children) + : String(child), + ) + .join(""); } function TypeTable({ - props, + properties, isServer, }: { - props: Property[]; + properties: ApiMethodProperty[]; isServer: boolean; }) { - if (!isServer && !props.filter((x) => !x.isServerOnly).length) return null; - if (isServer && !props.filter((x) => !x.isClientOnly).length) return null; - if (!props.length) return null; - - const filteredProps = props.filter( - (prop) => - !(prop.isServerOnly && isServer === false) && - !(prop.isClientOnly && isServer === true), + const visibleProperties = properties.filter( + (property) => + !(property.serverOnly && isServer === false) && + !(property.clientOnly && isServer === true), ); - if (!filteredProps.length) return null; + if (!visibleProperties.length) return null; return (
@@ -412,27 +232,27 @@ function TypeTable({ Parameters
- +
); } -function PropertyItem({ prop }: { prop: Property }) { +function PropertyItem({ property }: { property: ApiMethodProperty }) { return (
- {prop.propName} + {property.name} - {prop.type} - {prop.isNullable ? " | null" : ""} + {property.type} + {property.nullable ? " | null" : ""} - {!prop.isOptional && ( + {!property.optional && ( required )} - {prop.isServerOnly && ( + {property.serverOnly && ( server @@ -442,33 +262,38 @@ function PropertyItem({ prop }: { prop: Property }) { } function PropertyList({ - props, + properties, nested = false, }: { - props: Property[]; + properties: ApiMethodProperty[]; nested?: boolean; }) { - const groups: { prop: Property; children: Property[] }[] = []; - let i = 0; + const groups: { + property: ApiMethodProperty; + children: ApiMethodProperty[]; + }[] = []; + let propertyIndex = 0; - while (i < props.length) { - const prop = props[i]; - if (prop.type === "Object") { - const parentSegments = [...prop.path, prop.propName]; - const children: Property[] = []; - i++; + while (propertyIndex < properties.length) { + const property = properties[propertyIndex]; + if (property.type === "Object") { + const parentSegments = [...property.path, property.name]; + const children: ApiMethodProperty[] = []; + propertyIndex++; while ( - i < props.length && - props[i].path.length >= parentSegments.length && - parentSegments.every((seg, idx) => props[i].path[idx] === seg) + propertyIndex < properties.length && + properties[propertyIndex].path.length >= parentSegments.length && + parentSegments.every( + (segment, index) => properties[propertyIndex].path[index] === segment, + ) ) { - children.push(props[i]); - i++; + children.push(properties[propertyIndex]); + propertyIndex++; } - groups.push({ prop, children }); + groups.push({ property, children }); } else { - groups.push({ prop, children: [] }); - i++; + groups.push({ property, children: [] }); + propertyIndex++; } } @@ -476,21 +301,21 @@ function PropertyList({
{groups.map((group) => (
0 && "pb-3", )} > - - {group.prop.description && ( + + {group.property.description && (

- {tsxifyBackticks(group.prop.description)} + {tsxifyBackticks(group.property.description)}

)} {group.children.length > 0 && (
- +
)}
@@ -500,445 +325,28 @@ function PropertyList({ } function tsxifyBackticks(input: string): JSX.Element { - const parts = input.split(/(`[^`]+`)/g); // Split by backtick sections + const parts = input.split(/(`[^`]+`)/g); return ( <> {parts.map((part, index) => { if (part.startsWith("`") && part.endsWith("`")) { - const content = part.slice(1, -1); // remove backticks + const content = part.slice(1, -1); return {content}; - } else { - return {part}; } + return {part}; })} ); } -function parseCode(children: JSX.Element) { - // These two variables are essentially taking the `children` JSX shiki code, and converting them to - // an array string purely of it's code content. - const arrayOfJSXCode = children?.props.children.props.children.props.children - .map((x: any) => - x === "\n" ? { props: { children: { props: { children: "\n" } } } } : x, - ) - .map((x: any) => x.props.children) - .filter((x: any) => x != null); - const arrayOfCode: string[] = arrayOfJSXCode - .flatMap( - ( - x: { props: { children: string } } | { props: { children: string } }[], - ) => { - return getChildren(x); - }, - ) - .join("") - .split("\n"); - - const props: Property[] = []; - - let functionName: string = ""; - let currentJSDocDescription: string = ""; - - let withinApiMethodType = false; - let hasAlreadyDefinedApiMethodType = false; - let isServerOnly_ = false; - let isClientOnly_ = false; - const nestPath: string[] = []; // str arr segmented-path, eg: ["data", "metadata", "something"] - const serverOnlyPaths: string[] = []; // str arr full-path, eg: ["data.metadata.something"] - const clientOnlyPaths: string[] = []; // str arr full-path, eg: ["data.metadata.something"] - let isNullable = false; - - let code_prefix = ""; - let code_suffix = ""; - - for (let line of arrayOfCode) { - const originalLine = line; - line = line.trim(); - if (line === "}" && withinApiMethodType && !nestPath.length) { - withinApiMethodType = false; - hasAlreadyDefinedApiMethodType = true; - continue; - } else { - if (line === "}" && withinApiMethodType && nestPath.length) { - nestPath.pop(); - continue; - } - } - if ( - line.toLowerCase().startsWith("type") && - !hasAlreadyDefinedApiMethodType && - !withinApiMethodType - ) { - withinApiMethodType = true; - // Will grab the name of the API method function name from: - // type createOrganization = { - // ^^^^^^^^^^^^^^^^^^ - functionName = line.replace("type ", "").split("=")[0].trim(); - continue; - } - - if (!withinApiMethodType) { - if (!hasAlreadyDefinedApiMethodType) { - code_prefix += originalLine + "\n"; - } else { - code_suffix += "\n" + originalLine + ""; - } - continue; - } - if ( - line.startsWith("/*") || - line.startsWith("*") || - line.startsWith("*/") - ) { - if (line.startsWith("/*")) { - continue; - } else if (line.startsWith("*/")) { - continue; - } else { - if (line === "*" || line === "* ") continue; - line = line.replace("* ", ""); - if (line.trim() === "@serverOnly") { - isServerOnly_ = true; - continue; - } else if (line.trim() === "@nullable") { - isNullable = true; - continue; - } else if (line.trim() === "@clientOnly") { - isClientOnly_ = true; - continue; - } - currentJSDocDescription += line + " "; - } - } else { - // New property field - // Example: - // name: string = "My Organization", - let propName = line.split(":")[0].trim(); - const isOptional = propName.endsWith("?") ? true : false; - if (isOptional) propName = propName.slice(0, -1); // Remove `?` in propname. - let propType = line - .replace(propName, "") - .replace("?", "") - .replace(":", "") - .split("=")[0] - .trim()!; - - let isTheStartOfNest = false; - if (propType === "{") { - // This means that it's a nested object. - propType = `Object`; - isTheStartOfNest = true; - nestPath.push(propName); - if (isServerOnly_) { - serverOnlyPaths.push(nestPath.join(".")); - } - if (isClientOnly_) { - clientOnlyPaths.push(nestPath.join(".")); - } - } - - if (clientOnlyPaths.includes(nestPath.join("."))) { - isClientOnly_ = true; - } - - if (serverOnlyPaths.includes(nestPath.join("."))) { - isServerOnly_ = true; - } - - let exampleValue = !line.includes("=") - ? null - : line - .replace(propName, "") - .replace("?", "") - .replace(":", "") - .replace(propType, "") - .replace("=", "") - .trim(); - - if (exampleValue?.endsWith(",")) exampleValue = exampleValue.slice(0, -1); - - // const comments = - // line - // .replace(propName, "") - // .replace("?", "") - // .replace(":", "") - // .replace(propType, "") - // .replace("=", "") - // .replace(exampleValue || "IMPOSSIBLE_TO_REPLACE_!!!!", "") - // .split("//")[1] ?? null; - - const comments = null; - - const description = - currentJSDocDescription.length > 0 ? currentJSDocDescription : null; - if (description) { - currentJSDocDescription = ""; - } - - const property: Property = { - ...placeholderProperty, - description, - comments, - exampleValue, - isOptional, - propName, - type: propType, - isServerOnly: isServerOnly_, - isClientOnly: isClientOnly_, - path: isTheStartOfNest - ? nestPath.slice(0, nestPath.length - 1) - : nestPath.slice(), - isNullable: isNullable, - }; - - isServerOnly_ = false; - isClientOnly_ = false; - isNullable = false; - // console.log(property); - props.push(property); - } - } - - return { - functionName, - props, - code_prefix, - code_suffix, - }; -} - -/** - * Builds a property line with proper formatting and comments - */ -function buildPropertyLine( - prop: Property, - indentLevel: number, - additionalComments: string[] = [], -): string { - const comments: string[] = [...additionalComments]; - if (!prop.isOptional) comments.push("required"); - if (prop.comments) comments.push(prop.comments); - const addComment = comments.length > 0; - - const indent = indentationSpace.repeat(indentLevel); - const propValue = prop.exampleValue ? `: ${prop.exampleValue}` : ""; - const commentText = addComment ? ` // ${comments.join(", ")}` : ""; - - if (prop.type === "Object") { - // For object types, put comment after the opening brace - return `${indent}${prop.propName}${propValue}: {${commentText}\n`; - } else { - // For non-object types, put comment after the comma - return `${indent}${prop.propName}${propValue},${commentText}\n`; - } -} - -/** - * Determines if the client request should use query parameters - * - * - GET requests use query params by default, unless `forceAsBody` is true - * - Any request can be forced to use query params with `forceAsQuery` - */ -function shouldClientUseQueryParams( - method: string | undefined, - forceAsBody: boolean | undefined, - forceAsQuery: boolean | undefined, - forceAsParam: boolean | undefined, -): boolean { - if (forceAsQuery) return true; - if (forceAsBody) return false; - if (forceAsParam) return false; - return method === "GET"; -} - -function createClientBody({ - props, - method, - forceAsBody, - forceAsQuery, - forceAsParam, -}: { - props: Property[]; - method?: string; - forceAsBody?: boolean; - forceAsQuery?: boolean; - forceAsParam?: boolean; -}) { - const isQueryParam = shouldClientUseQueryParams( - method, - forceAsBody, - forceAsQuery, - forceAsParam, - ); - const baseIndentLevel = isQueryParam ? 2 : 1; - - let params = ``; - - let i = -1; - for (const prop of props) { - i++; - if (prop.isServerOnly) continue; - if (params === "") params += "{\n"; - - params += buildPropertyLine(prop, prop.path.length + baseIndentLevel); - - if ((props[i + 1]?.path?.length || 0) < prop.path.length) { - const diff = prop.path.length - (props[i + 1]?.path?.length || 0); - - for (const index of Array(diff) - .fill(0) - .map((_, i) => i) - .reverse()) { - params += `${indentationSpace.repeat(index + baseIndentLevel)}},\n`; - } - } - } - - if (params !== "") { - if (isQueryParam) { - // Wrap in query object for GET requests and when forceAsQuery is true - params = `{\n query: ${params} },\n}`; - } else { - params += "}"; - } - } - - return params; -} - -/** - * Determines if the server request should use query parameters - * - * - GET requests use query params by default, unless `forceAsBody` is true - * - Other methods (POST, PUT, DELETE) use body by default, unless `forceAsQuery` is true - */ -function shouldServerUseQueryParams( - method: string, - forceAsBody: boolean | undefined, - forceAsQuery: boolean | undefined, - forceAsParam: boolean | undefined, -): boolean { - if (forceAsQuery) return true; - if (forceAsBody) return false; - if (forceAsParam) return false; - return method === "GET"; -} - -function createServerBody({ - props, - requireSession, - requireHeaders, - requireBearerToken, - headersComment, - method, - forceAsBody, - forceAsParam, - forceAsQuery, -}: { - props: Property[]; - requireSession: boolean; - requireHeaders: boolean; - requireBearerToken: boolean; - headersComment: string | undefined; - method: string; - forceAsQuery: boolean | undefined; - forceAsParam: boolean | undefined; - forceAsBody: boolean | undefined; -}) { - const isQueryParam = shouldServerUseQueryParams( - method, - forceAsBody, - forceAsQuery, - forceAsParam, - ); - const clientOnlyProps = props.filter((x) => !x.isClientOnly); - - // Build properties content - let propertiesContent = ``; - let i = -1; - - for (const prop of props) { - i++; - if (prop.isClientOnly) continue; - if (propertiesContent === "") propertiesContent += "{\n"; - - // Check if this is a server-only nested property - const isNestedServerOnlyProp = - prop.isServerOnly && - !( - prop.path.length && - props.find( - (x) => - x.path.join(".") === - prop.path.slice(0, prop.path.length - 2).join(".") && - x.propName === prop.path[prop.path.length - 1], - ) - ); - - const additionalComments: string[] = []; - if (isNestedServerOnlyProp) additionalComments.push("server-only"); - - propertiesContent += buildPropertyLine( - prop, - prop.path.length + 2, - additionalComments, - ); - - if ((props[i + 1]?.path?.length || 0) < prop.path.length) { - const diff = prop.path.length - (props[i + 1]?.path?.length || 0); - - for (const index of Array(diff) - .fill(0) - .map((_, i) => i) - .reverse()) { - propertiesContent += `${indentationSpace.repeat(index + 2)}},\n`; - } - } - } - - if (propertiesContent !== "") propertiesContent += " },"; - - // Build fetch options - let fetchOptions = ""; - if (requireSession) { - fetchOptions += - "\n // This endpoint requires session cookies.\n headers: await headers(),"; - } else if (requireHeaders) { - fetchOptions += `\n // ${ - headersComment || - "Pass the current request headers so Better Auth can read and set cookies." - }\n headers: await headers(),`; - } - - if (requireBearerToken) { - fetchOptions += - "\n // This endpoint requires a bearer authentication token.\n headers: { authorization: 'Bearer ' },"; - } - - // Assemble final result - let result = ""; - if (clientOnlyProps.length > 0) { - result += "{\n"; - const paramType = isQueryParam ? "query" : forceAsParam ? "params" : "body"; - result += ` ${paramType}: ${propertiesContent}${fetchOptions}\n}`; - } else if (fetchOptions.length) { - result += `{${fetchOptions}\n}`; - } - - return result; -} - function Note({ children }: { children: ReactNode }) { return (
Notes -

- {children as any} -

+

{children}

); } diff --git a/docs/components/endpoint.tsx b/docs/components/endpoint.tsx index 37cfb9a383..aba0bd5c07 100644 --- a/docs/components/endpoint.tsx +++ b/docs/components/endpoint.tsx @@ -1,16 +1,16 @@ "use client"; +import type { ApiMethodHttpMethod } from "@/lib/api-method"; import { cn } from "@/lib/utils"; -type HttpMethod = "GET" | "POST" | "PUT" | "DELETE"; - -const methodColors: Record = { +const methodColors = { GET: "text-green-600 dark:text-green-500", POST: "text-yellow-600 dark:text-yellow-600", PUT: "text-blue-600 dark:text-blue-400", + PATCH: "text-cyan-600 dark:text-cyan-400", DELETE: "text-red-600 dark:text-red-400", -}; +} satisfies Record; -function Method({ method }: { method: HttpMethod }) { +function Method({ method }: { method: ApiMethodHttpMethod }) { return ( { + it("parses the shared API method definition", () => { + expect(definition.functionName).toBe("verifyTotp"); + expect(definition.typeDefinition).toContain("type verifyTotp"); + expect(definition.properties).toEqual([ + expect.objectContaining({ + name: "code", + exampleValue: '"123456"', + description: "The current one-time password.", + }), + expect.objectContaining({ + name: "metadata", + type: "Object", + serverOnly: true, + description: "Only available on the server.", + }), + expect.objectContaining({ + name: "ipAddress", + path: ["metadata"], + serverOnly: true, + }), + ]); + }); + + it("generates the same examples for UI and Markdown", () => { + const examples = generateApiMethodExamples(definition, { + path: "/two-factor/verify-totp", + method: "POST", + requireHeaders: true, + headersComment: "Forward the current request headers.", + }); + + expect(examples.client).toContain( + "await authClient.twoFactor.verifyTotp({", + ); + expect(examples.client).not.toContain("metadata"); + expect(examples.server).toContain("const data = await auth.api.verifyTotp"); + expect(examples.server).toContain("Forward the current request headers."); + expect(examples.server).toContain("headers: await headers()"); + expect(examples.server).toContain("metadata: {"); + }); + + it("uses query parameters and result aliases", () => { + const examples = generateApiMethodExamples(definition, { + path: "/admin/list-users", + method: "GET", + resultVariable: "users", + }); + + expect(examples.client).toContain("const { data: users, error } ="); + expect(examples.client).toContain("query:"); + expect(examples.server).toContain("const users ="); + expect(examples.server).toContain("query:"); + }); + + it("inherits visibility directives through nested properties", () => { + const nestedDefinition = parseApiMethod(`type updateMetadata = { + /** + * @serverOnly + */ + metadata?: { + audit?: { + ipAddress?: string = "127.0.0.1" + } + } +}`); + + expect( + nestedDefinition.properties + .filter((property) => + ["metadata", "audit", "ipAddress"].includes(property.name), + ) + .map((property) => property.serverOnly), + ).toEqual([true, true, true]); + }); + + it("closes nested properties after filtering hidden children", () => { + const clientDefinition = parseApiMethod(`type updateProfile = { + options?: { + visible?: string = "visible" + /** + * @serverOnly + */ + serverSecret?: string = "server" + } + name: string = "name" +}`); + const serverDefinition = parseApiMethod(`type updateProfile = { + options?: { + visible?: string = "visible" + /** + * @clientOnly + */ + clientSecret?: string = "client" + } + name: string = "name" +}`); + const clientExamples = generateApiMethodExamples(clientDefinition, { + path: "/update-profile", + method: "POST", + }); + const serverExamples = generateApiMethodExamples(serverDefinition, { + path: "/update-profile", + method: "POST", + }); + + expect(clientExamples.client).not.toContain("serverSecret"); + expect(clientExamples.client).toContain( + 'visible: "visible",\n },\n name:', + ); + expect(serverExamples.server).not.toContain("clientSecret"); + expect(serverExamples.server).toContain( + 'visible: "visible",\n },\n name:', + ); + }); + + it("merges bearer tokens with forwarded request headers", () => { + const examples = generateApiMethodExamples(definition, { + path: "/two-factor/verify-totp", + method: "POST", + requireHeaders: true, + requireBearerToken: true, + }); + + expect(examples.server.match(/headers:/g)).toHaveLength(1); + expect(examples.server).toContain("...(await headers())"); + expect(examples.server).toContain("authorization: 'Bearer '"); + }); + + it("closes objects when all nested properties are hidden", () => { + const clientDefinition = parseApiMethod(`type updateProfile = { + options?: { + /** + * @serverOnly + */ + secret?: string = "secret" + } + name: string = "name" +}`); + const serverDefinition = parseApiMethod(`type updateProfile = { + options?: { + /** + * @clientOnly + */ + secret?: string = "secret" + } + name: string = "name" +}`); + const options = { path: "/update-profile", method: "POST" } as const; + + expect( + generateApiMethodExamples(clientDefinition, options).client, + ).toContain("options: {\n },\n name:"); + expect( + generateApiMethodExamples(serverDefinition, options).server, + ).toContain("options: {\n },\n name:"); + }); +}); diff --git a/docs/lib/api-method.ts b/docs/lib/api-method.ts new file mode 100644 index 0000000000..e10e799624 --- /dev/null +++ b/docs/lib/api-method.ts @@ -0,0 +1,389 @@ +export const apiMethodHttpMethods = [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", +] as const; + +export type ApiMethodHttpMethod = (typeof apiMethodHttpMethods)[number]; + +export interface ApiMethodOptions { + path: string; + method?: ApiMethodHttpMethod; + isServerOnly?: boolean; + isClientOnly?: boolean; + isExternalOnly?: boolean; + noResult?: boolean; + requireSession?: boolean; + requireHeaders?: boolean; + requireBearerToken?: boolean; + headersComment?: string; + note?: string; + clientOnlyNote?: string; + serverOnlyNote?: string; + resultVariable?: string; + forceAsBody?: boolean; + forceAsParam?: boolean; + forceAsQuery?: boolean; +} + +export interface ApiMethodProperty { + optional: boolean; + description: string | null; + name: string; + type: string; + exampleValue: string | null; + serverOnly: boolean; + path: string[]; + nullable: boolean; + clientOnly: boolean; +} + +export interface ParsedApiMethod { + functionName: string; + properties: ApiMethodProperty[]; + codePrefix: string; + codeSuffix: string; + typeDefinition: string; +} + +const indentation = " "; +const typeDeclarationPattern = /^type\s+([A-Za-z_$][\w$]*)\s*=\s*\{$/; +const propertyPattern = /^([A-Za-z_$][\w$]*)(\?)?:\s*(.+)$/; + +function parsePropertyLine(line: string) { + const match = propertyPattern.exec(line); + if (!match) return null; + + const [, name, optional, declaration] = match; + const exampleSeparator = declaration.indexOf(" = "); + const type = ( + exampleSeparator === -1 + ? declaration + : declaration.slice(0, exampleSeparator) + ) + .trim() + .replace(/[;,]$/, ""); + const exampleValue = + exampleSeparator === -1 + ? null + : declaration + .slice(exampleSeparator + 3) + .trim() + .replace(/[;,]$/, ""); + + return { + name, + optional: optional === "?", + type, + exampleValue, + }; +} + +function readDirective(value: string, directive: string): string | null { + if (value !== directive && !value.startsWith(`${directive} `)) return null; + return value + .slice(directive.length) + .replace(/^\s*-\s*/, "") + .trim(); +} + +function isPathRestricted(restrictedPaths: Set, path: string[]) { + for (let length = 1; length <= path.length; length++) { + if (restrictedPaths.has(path.slice(0, length).join("."))) return true; + } + return false; +} + +export function parseApiMethod(source: string): ParsedApiMethod { + const lines = source.replaceAll("\r\n", "\n").trim().split("\n"); + const properties: ApiMethodProperty[] = []; + const nestedPath: string[] = []; + const serverOnlyPaths = new Set(); + const clientOnlyPaths = new Set(); + let functionName = ""; + let typeStart = -1; + let typeEnd = -1; + let description = ""; + let serverOnly = false; + let clientOnly = false; + let nullable = false; + + for (const [index, originalLine] of lines.entries()) { + const line = originalLine.trim(); + + if (typeStart === -1) { + const declaration = typeDeclarationPattern.exec(line); + if (!declaration) continue; + functionName = declaration[1]; + typeStart = index; + continue; + } + + if (/^}[,;]?$/.test(line)) { + if (nestedPath.length > 0) { + nestedPath.pop(); + continue; + } + typeEnd = index; + break; + } + + if (line.startsWith("/*") || line.startsWith("*/")) continue; + if (line.startsWith("*")) { + const value = line.slice(1).trim(); + if (!value) continue; + const serverOnlyDescription = readDirective(value, "@serverOnly"); + if (serverOnlyDescription !== null) { + serverOnly = true; + if (serverOnlyDescription) { + description += `${serverOnlyDescription} `; + } + continue; + } + const clientOnlyDescription = readDirective(value, "@clientOnly"); + if (clientOnlyDescription !== null) { + clientOnly = true; + if (clientOnlyDescription) { + description += `${clientOnlyDescription} `; + } + continue; + } + const nullableDescription = readDirective(value, "@nullable"); + if (nullableDescription !== null) { + nullable = true; + if (nullableDescription) description += `${nullableDescription} `; + continue; + } + description += `${value} `; + continue; + } + + if (!line) continue; + const parsed = parsePropertyLine(line); + if (!parsed) continue; + + const propertyPath = nestedPath.slice(); + const nested = parsed.type === "{"; + if (nested) { + nestedPath.push(parsed.name); + if (serverOnly) serverOnlyPaths.add(nestedPath.join(".")); + if (clientOnly) clientOnlyPaths.add(nestedPath.join(".")); + } + + properties.push({ + name: parsed.name, + optional: parsed.optional, + type: nested ? "Object" : parsed.type, + exampleValue: parsed.exampleValue, + description: description.trim() || null, + serverOnly: serverOnly || isPathRestricted(serverOnlyPaths, nestedPath), + clientOnly: clientOnly || isPathRestricted(clientOnlyPaths, nestedPath), + path: propertyPath, + nullable, + }); + + description = ""; + serverOnly = false; + clientOnly = false; + nullable = false; + } + + if (typeStart === -1 || typeEnd === -1) { + return { + functionName: "", + properties: [], + codePrefix: "", + codeSuffix: "", + typeDefinition: source.trim(), + }; + } + + const prefix = lines.slice(0, typeStart).join("\n").trim(); + const suffix = lines + .slice(typeEnd + 1) + .join("\n") + .trim(); + + return { + functionName, + properties, + codePrefix: prefix ? `${prefix}\n` : "", + codeSuffix: suffix ? `\n${suffix}` : "", + typeDefinition: lines.slice(typeStart, typeEnd + 1).join("\n"), + }; +} + +function pathToMethodName(path: string): string { + return path + .split("/") + .filter((segment) => segment.length > 0) + .map((segment) => + segment + .split("-") + .map((word, index) => + index === 0 + ? word.toLowerCase() + : word.charAt(0).toUpperCase() + word.slice(1), + ) + .join(""), + ) + .join("."); +} + +function buildPropertyLine( + property: ApiMethodProperty, + indentLevel: number, + additionalComments: string[] = [], +): string { + const comments = [...additionalComments]; + if (!property.optional) comments.push("required"); + + const indent = indentation.repeat(indentLevel); + const value = property.exampleValue ? `: ${property.exampleValue}` : ""; + const comment = comments.length > 0 ? ` // ${comments.join(", ")}` : ""; + + return property.type === "Object" + ? `${indent}${property.name}${value}: {${comment}\n` + : `${indent}${property.name}${value},${comment}\n`; +} + +function usesQuery( + method: ApiMethodHttpMethod, + options: Pick< + ApiMethodOptions, + "forceAsBody" | "forceAsParam" | "forceAsQuery" + >, +): boolean { + if (options.forceAsQuery) return true; + if (options.forceAsBody || options.forceAsParam) return false; + return method === "GET"; +} + +function closeNestedProperties( + properties: ApiMethodProperty[], + index: number, + baseIndentLevel: number, +): string { + const property = properties[index]; + const currentDepth = + property.path.length + (property.type === "Object" ? 1 : 0); + const nextDepth = properties[index + 1]?.path.length ?? 0; + if (nextDepth >= currentDepth) return ""; + + let result = ""; + for (let depth = currentDepth - 1; depth >= nextDepth; depth--) { + result += `${indentation.repeat(depth + baseIndentLevel)}},\n`; + } + return result; +} + +function createClientBody( + properties: ApiMethodProperty[], + method: ApiMethodHttpMethod, + options: ApiMethodOptions, +): string { + const query = usesQuery(method, options); + const baseIndentLevel = query ? 2 : 1; + const clientProperties = properties.filter( + (property) => !property.serverOnly, + ); + let params = ""; + + for (const [index, property] of clientProperties.entries()) { + if (!params) params = "{\n"; + params += buildPropertyLine( + property, + property.path.length + baseIndentLevel, + ); + params += closeNestedProperties(clientProperties, index, baseIndentLevel); + } + + if (!params) return ""; + if (query) return `{\n query: ${params} },\n}`; + return `${params}}`; +} + +function createServerBody( + properties: ApiMethodProperty[], + method: ApiMethodHttpMethod, + options: ApiMethodOptions, +): string { + const query = usesQuery(method, options); + const serverProperties = properties.filter( + (property) => !property.clientOnly, + ); + let propertiesContent = ""; + + for (const [index, property] of serverProperties.entries()) { + if (!propertiesContent) propertiesContent = "{\n"; + + const parentPath = property.path.slice(0, -1).join("."); + const inheritedServerOnly = + property.serverOnly && + !properties.some( + (candidate) => + candidate.path.join(".") === parentPath && + candidate.name === property.path.at(-1), + ); + propertiesContent += buildPropertyLine( + property, + property.path.length + 2, + inheritedServerOnly ? ["server-only"] : [], + ); + propertiesContent += closeNestedProperties(serverProperties, index, 2); + } + + if (propertiesContent) propertiesContent += " },"; + + let headers = ""; + const requestHeadersRequired = + options.requireSession || options.requireHeaders; + if (options.requireSession) { + headers += "\n // This endpoint requires session cookies."; + } else if (options.requireHeaders) { + headers += `\n // ${ + options.headersComment ?? + "Pass the current request headers so Better Auth can read and set cookies." + }`; + } + if (options.requireBearerToken) { + headers += "\n // This endpoint requires a bearer authentication token."; + } + if (requestHeadersRequired && options.requireBearerToken) { + headers += + "\n headers: {\n ...(await headers()),\n authorization: 'Bearer ',\n },"; + } else if (requestHeadersRequired) { + headers += "\n headers: await headers(),"; + } else if (options.requireBearerToken) { + headers += "\n headers: { authorization: 'Bearer ' },"; + } + + if (serverProperties.length > 0) { + const parameter = query + ? "query" + : options.forceAsParam + ? "params" + : "body"; + return `{\n ${parameter}: ${propertiesContent}${headers}\n}`; + } + return headers ? `{${headers}\n}` : ""; +} + +export function generateApiMethodExamples( + definition: ParsedApiMethod, + options: ApiMethodOptions, +) { + const method = options.method ?? "GET"; + const resultVariable = options.resultVariable ?? "data"; + const clientResult = options.noResult + ? "" + : `const { data${resultVariable === "data" ? "" : `: ${resultVariable}`}, error } = `; + const serverResult = options.noResult ? "" : `const ${resultVariable} = `; + + return { + client: `${definition.codePrefix}${clientResult}await authClient.${pathToMethodName(options.path)}(${createClientBody(definition.properties, method, options)});${definition.codeSuffix}`, + server: `${definition.codePrefix}${serverResult}await auth.api.${definition.functionName}(${createServerBody(definition.properties, method, options)});${definition.codeSuffix}`, + }; +} diff --git a/docs/lib/llm-text.test.ts b/docs/lib/llm-text.test.ts index 707d205259..1f1460054b 100644 --- a/docs/lib/llm-text.test.ts +++ b/docs/lib/llm-text.test.ts @@ -6,6 +6,7 @@ import { getLLMsIndexTitle, getMarkdownPageUrl, getRootLLMsIndex, + renderApiMethodMarkdown, } from "./llm-text"; describe("LLM routes", () => { @@ -48,6 +49,68 @@ describe("LLM routes", () => { expect(getMarkdownPageUrl("/llms.txt")).toBe("/llms.txt"); }); + it("renders API method placeholders with all endpoint metadata", () => { + const markdown = renderApiMethodMarkdown({ + name: "APIMethod", + attributes: { + path: "/admin/list-users", + method: "GET", + requireHeaders: null, + headersComment: "Forward the request headers.", + resultVariable: { + value: '"users"', + }, + }, + children: `\`\`\`ts +type listUsers = { + limit?: number = 100 +} +\`\`\``, + }); + + expect(markdown).toContain("**Endpoint:** `GET /admin/list-users`"); + expect(markdown).toContain("const { data: users, error } ="); + expect(markdown).toContain("const users = await auth.api.listUsers"); + expect(markdown).toContain("Forward the request headers."); + expect(markdown).not.toContain(" { + const markdown = renderApiMethodMarkdown({ + name: "APIMethod", + attributes: { + path: "/api-key/verify", + method: "POST", + isServerOnly: null, + }, + children: `\`\`\`ts +type verifyApiKey = { + key: string = "secret" +} +\`\`\``, + }); + + expect(markdown).not.toContain("### Client Side"); + expect(markdown).toContain("### Server Side"); + }); + + it("renders PATCH API methods", () => { + const markdown = renderApiMethodMarkdown({ + name: "APIMethod", + attributes: { + path: "/scim/v2/Users/:id", + method: "PATCH", + }, + children: `\`\`\`ts +type patchUser = { + id: string = "user-id" +} +\`\`\``, + }); + + expect(markdown).toContain("**Endpoint:** `PATCH /scim/v2/Users/:id`"); + }); + it("returns a useful Markdown not-found response", () => { const content = getLLMNotFound("/docs/missing.md"); diff --git a/docs/lib/llm-text.ts b/docs/lib/llm-text.ts index d449cf3bbc..d74a91ade0 100644 --- a/docs/lib/llm-text.ts +++ b/docs/lib/llm-text.ts @@ -1,264 +1,102 @@ +import type { PlaceholderData } from "fumadocs-core/mdx-plugins/remark-llms.runtime"; +import { renderPlaceholder } from "fumadocs-core/mdx-plugins/remark-llms.runtime"; import type { Item, Node } from "fumadocs-core/page-tree"; import type { InferPageType } from "fumadocs-core/source"; import { llms } from "fumadocs-core/source"; +import * as z from "zod"; +import { + apiMethodHttpMethods, + generateApiMethodExamples, + parseApiMethod, +} from "./api-method"; import type { DocsVersion } from "./docs-versions"; import { scopeDocsContent } from "./scope-docs-content"; import type { source } from "./source"; -type PropertyDefinition = { - name: string; - type: string; - required: boolean; - description: string; - exampleValue: string; - isServerOnly: boolean; - isClientOnly: boolean; -}; - const docsPathPattern = /^\/docs(?:\/|$)/; const productionUrl = new URL("https://better-auth.com"); const llmsDescription = "The most comprehensive authentication framework for TypeScript"; +const expressionStringSchema = z + .object({ value: z.string() }) + .transform(({ value }) => z.string().parse(JSON.parse(value))); +const stringAttributeSchema = z.union([z.string(), expressionStringSchema]); +const markerAttributeSchema = z + .null() + .optional() + .transform((value) => value === null); +const apiMethodAttributesSchema = z.object({ + path: stringAttributeSchema.pipe(z.string().startsWith("/")), + method: stringAttributeSchema + .pipe(z.enum(apiMethodHttpMethods)) + .optional() + .default("GET"), + isServerOnly: markerAttributeSchema, + isClientOnly: markerAttributeSchema, + isExternalOnly: markerAttributeSchema, + noResult: markerAttributeSchema, + requireSession: markerAttributeSchema, + requireHeaders: markerAttributeSchema, + requireBearerToken: markerAttributeSchema, + headersComment: stringAttributeSchema.optional(), + note: stringAttributeSchema.optional(), + clientOnlyNote: stringAttributeSchema.optional(), + serverOnlyNote: stringAttributeSchema.optional(), + resultVariable: stringAttributeSchema.optional(), + forceAsBody: markerAttributeSchema, + forceAsParam: markerAttributeSchema, + forceAsQuery: markerAttributeSchema, +}); -function extractAPIMethods(rawContent: string): string { - const apiMethodRegex = /]+)>([\s\S]*?)<\/APIMethod>/g; +function unwrapCodeBlock(markdown: string): string { + const lines = markdown.trim().split("\n"); + if (lines[0]?.startsWith("```") && lines.at(-1)?.trim() === "```") { + return lines.slice(1, -1).join("\n"); + } + return markdown.trim(); +} - return rawContent.replace(apiMethodRegex, (match, attributes, content) => { - const pathMatch = attributes.match(/path="([^"]+)"/); - const methodMatch = attributes.match(/method="([^"]+)"/); - const requireSessionMatch = attributes.match(/requireSession/); - const noResultMatch = attributes.match(/noResult/); - const resultVariableMatch = attributes.match(/resultVariable="([^"]+)"/); - const forceAsBodyMatch = attributes.match(/forceAsBody/); - const forceAsQueryMatch = attributes.match(/forceAsQuery/); +function formatNote(note: string | undefined): string { + return note ? `> **Note:** ${note}\n\n` : ""; +} - const path = pathMatch ? pathMatch[1] : ""; - const method = methodMatch ? methodMatch[1] : "GET"; - const requireSession = !!requireSessionMatch; - const noResult = !!noResultMatch; - const resultVariable = resultVariableMatch - ? resultVariableMatch[1] - : "data"; - const forceAsBody = !!forceAsBodyMatch; - const forceAsQuery = !!forceAsQueryMatch; +export function renderApiMethodMarkdown({ + attributes, + children, +}: PlaceholderData): string { + const options = apiMethodAttributesSchema.parse(attributes); + const definition = parseApiMethod(unwrapCodeBlock(children)); + if (!definition.functionName) return children; - const typeMatch = content.match(/type\s+(\w+)\s*=\s*\{([\s\S]*?)\}/); - if (!typeMatch) { - return match; - } + const examples = generateApiMethodExamples(definition, options); + const sections = [`**Endpoint:** \`${options.method} ${options.path}\``]; + if (options.note) sections.push(formatNote(options.note).trimEnd()); - const functionName = typeMatch[1]; - const typeBody = typeMatch[2]; - - const properties = parseTypeBody(typeBody); - - const clientCode = generateClientCode(functionName, properties, path); - const serverCode = generateServerCode( - functionName, - properties, - method, - requireSession, - forceAsBody, - forceAsQuery, - noResult, - resultVariable, + if (!options.isServerOnly && !options.isExternalOnly) { + sections.push( + `### Client Side\n\n${formatNote(options.clientOnlyNote)}\`\`\`ts\n${examples.client}\n\`\`\``, ); - - return ` -### Client Side - -\`\`\`ts -${clientCode} -\`\`\` - -### Server Side - -\`\`\`ts -${serverCode} -\`\`\` - -### Type Definition - -\`\`\`ts -type ${functionName} = {${typeBody} -} -\`\`\` -`; - }); -} - -function parseTypeBody(typeBody: string): PropertyDefinition[] { - const properties: PropertyDefinition[] = []; - - const lines = typeBody.split("\n"); - - for (const line of lines) { - const trimmed = line.trim(); - - if (!trimmed || trimmed.startsWith("//") || trimmed.startsWith("/*")) - continue; - const propMatch = trimmed.match( - /^(\w+)(\?)?:\s*(.+?)(\s*=\s*["']([^"']+)["'])?(\s*\/\/\s*(.+))?$/, + } + if (!options.isClientOnly) { + sections.push( + `### Server Side\n\n${formatNote(options.serverOnlyNote)}\`\`\`ts\n${examples.server}\n\`\`\``, ); - if (propMatch) { - const [, name, optional, type, , exampleValue, , description] = propMatch; - - let cleanType = type.trim(); - const cleanExampleValue = exampleValue || ""; - - cleanType = cleanType.replace(/,$/, ""); - - properties.push({ - name, - type: cleanType, - required: !optional, - description: description || "", - exampleValue: cleanExampleValue, - isServerOnly: false, - isClientOnly: false, - }); - } } - - return properties; -} - -// Generate client code example -function generateClientCode( - functionName: string, - properties: PropertyDefinition[], - path: string, -): string { - if (!functionName || !path) { - return "// Unable to generate client code - missing function name or path"; - } - - const clientMethodPath = pathToDotNotation(path); - const body = createClientBody(properties); - - return `const { data, error } = await authClient.${clientMethodPath}(${body});`; -} - -// Generate server code example -function generateServerCode( - functionName: string, - properties: PropertyDefinition[], - method: string, - requireSession: boolean, - forceAsBody: boolean, - forceAsQuery: boolean, - noResult: boolean, - resultVariable: string, -): string { - if (!functionName) { - return "// Unable to generate server code - missing function name"; - } - - const body = createServerBody( - properties, - method, - requireSession, - forceAsBody, - forceAsQuery, + sections.push( + `### Type Definition\n\n\`\`\`ts\n${definition.typeDefinition}\n\`\`\``, ); - return `${noResult ? "" : `const ${resultVariable} = `}await auth.api.${functionName}(${body});`; -} - -function pathToDotNotation(input: string): string { - return input - .split("/") - .filter(Boolean) - .map((segment) => - segment - .split("-") - .map((word, i) => - i === 0 - ? word.toLowerCase() - : word.charAt(0).toUpperCase() + word.slice(1), - ) - .join(""), - ) - .join("."); -} - -function createClientBody(props: PropertyDefinition[]): string { - if (props.length === 0) return "{}"; - - let body = "{\n"; - - for (const prop of props) { - if (prop.isServerOnly) continue; - - let comment = ""; - if (!prop.required || prop.description) { - const comments: string[] = []; - if (!prop.required) comments.push("optional"); - if (prop.description) comments.push(prop.description); - comment = ` // ${comments.join(", ")}`; - } - - body += ` ${prop.name}${prop.exampleValue ? `: ${prop.exampleValue}` : ""}${prop.type === "Object" ? ": {}" : ""},${comment}\n`; - } - - body += "}"; - return body; -} - -function createServerBody( - props: PropertyDefinition[], - method: string, - requireSession: boolean, - forceAsBody: boolean, - forceAsQuery: boolean, -): string { - const relevantProps = props.filter((x) => !x.isClientOnly); - - if (relevantProps.length === 0 && !requireSession) { - return "{}"; - } - - let serverBody = "{\n"; - - if (relevantProps.length > 0) { - const bodyKey = - (method === "POST" || forceAsBody) && !forceAsQuery ? "body" : "query"; - serverBody += ` ${bodyKey}: {\n`; - - for (const prop of relevantProps) { - let comment = ""; - if (!prop.required || prop.description) { - const comments: string[] = []; - if (!prop.required) comments.push("optional"); - if (prop.description) comments.push(prop.description); - comment = ` // ${comments.join(", ")}`; - } - - serverBody += ` ${prop.name}${prop.exampleValue ? `: ${prop.exampleValue}` : ""}${prop.type === "Object" ? ": {}" : ""},${comment}\n`; - } - - serverBody += " }"; - } - - if (requireSession) { - if (relevantProps.length > 0) serverBody += ","; - serverBody += - "\n // This endpoint requires session cookies.\n headers: await headers()"; - } - - serverBody += "\n}"; - return serverBody; + return sections.join("\n\n"); } export async function getLLMText( docPage: InferPageType, version?: DocsVersion, ): Promise { - const pageData = docPage.data as { - getText: (type: string) => Promise; - }; - const mdContent = await pageData.getText("processed"); - const renderedContent = extractAPIMethods(mdContent); + const mdContent = await docPage.data.getText("processed"); + const renderedContent = await renderPlaceholder(mdContent, { + APIMethod: renderApiMethodMarkdown, + }); const processedContent = version && version.id !== "latest" ? scopeDocsContent(renderedContent, version) diff --git a/docs/source.config.ts b/docs/source.config.ts index d2a265d567..2caf6751e4 100644 --- a/docs/source.config.ts +++ b/docs/source.config.ts @@ -1,3 +1,4 @@ +import type { LLMsOptions } from "fumadocs-core/mdx-plugins/remark-llms"; import { pageSchema } from "fumadocs-core/source/schema"; import { defineCollections, @@ -17,12 +18,16 @@ const docsPageSchema = pageSchema.extend({ sidebarTitle: z.string().min(1).optional(), }); +const processedMarkdownOptions = { + mdxAsPlaceholder: ["APIMethod"], +} satisfies LLMsOptions; + export const docs = defineDocs({ dir: "./content/docs", docs: { schema: docsPageSchema, postprocess: { - includeProcessedMarkdown: true, + includeProcessedMarkdown: processedMarkdownOptions, }, async: true, }, @@ -33,7 +38,7 @@ export const docsV16 = defineDocs({ docs: { schema: docsPageSchema, postprocess: { - includeProcessedMarkdown: true, + includeProcessedMarkdown: processedMarkdownOptions, }, async: true, },