@@ -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,
},