diff --git a/packages/platform/README.md b/packages/platform/README.md index c379dee..ac45691 100644 --- a/packages/platform/README.md +++ b/packages/platform/README.md @@ -213,7 +213,7 @@ Every exported shape and field has JSDoc. `npm run docs` validates it into `docs and `npm run copy-docs-local -- --target ` copies the result into `developers/references/platform-sdk/docs`. The pipeline's config for this package lives in `scripts/mintlify-post-processing/`, including `reference.ts`, the docs-only entry that combines -the client and server entrypoints into one reference. The Socket messages page is built in mintlify-docs from +the client, server and React entrypoints into one reference. The Socket messages page is built in mintlify-docs from the service's AsyncAPI document, not here. `PlatformSocketError` extends `Error` with `code` and optional `appId`. Original diff --git a/packages/platform/scripts/mintlify-post-processing/appended-articles.json b/packages/platform/scripts/mintlify-post-processing/appended-articles.json index 64f28f2..4d97018 100644 --- a/packages/platform/scripts/mintlify-post-processing/appended-articles.json +++ b/packages/platform/scripts/mintlify-post-processing/appended-articles.json @@ -17,21 +17,11 @@ "type-aliases/WebhookConfigurationErrorCode" ], "functions/useChat": [ - "interfaces/ChatOptions", "interfaces/UseChatResult", + "interfaces/ChatBackend", "type-aliases/ChatStatus", "type-aliases/ChatConnection", - "interfaces/ChatError" - ], - "interfaces/ChatBackend": [ - "interfaces/ChatApp", - "interfaces/ToolCallAnswer" - ], - "interfaces/ChatItem": [ - "interfaces/ChatStep", - "interfaces/ChatAnswer" - ], - "type-aliases/ChatQuestion": [ + "type-aliases/ChatQuestion", "interfaces/ChoiceQuestion", "interfaces/ChoiceEntry", "interfaces/SecretsQuestion", diff --git a/packages/platform/scripts/mintlify-post-processing/category-map.json b/packages/platform/scripts/mintlify-post-processing/category-map.json index 809b882..ab1a619 100644 --- a/packages/platform/scripts/mintlify-post-processing/category-map.json +++ b/packages/platform/scripts/mintlify-post-processing/category-map.json @@ -1,5 +1,6 @@ { "functions": "Clients", "interfaces": "Modules", - "type-aliases": "Modules" + "type-aliases": "Modules", + "functions/useChat": "React" } diff --git a/packages/platform/scripts/mintlify-post-processing/page-sections.json b/packages/platform/scripts/mintlify-post-processing/page-sections.json index 62cf557..f06a3a8 100644 --- a/packages/platform/scripts/mintlify-post-processing/page-sections.json +++ b/packages/platform/scripts/mintlify-post-processing/page-sections.json @@ -1,22 +1,64 @@ { "typesWithOwnMethods": { - "ChatSession": "Session methods" + "ChatSession": "Session methods", + "UseChatResult": "Returned actions", + "ChatBackend": "Backend calls" }, + "returnedState": { + "UseChatResult": "Returned state" + }, + "inlineTypes": [ + "ChoiceEntry", + "SecretField", + "ApprovalDetails" + ], "typeDefinitionGroups": [ - ["SocketSession", "PlatformEvent", "PlatformSnapshot"], + [ + "SocketSession", + "PlatformEvent", + "PlatformSnapshot" + ], { "title": "Errors", - "types": ["PlatformSocketError", "SessionErrorCode", "AppErrorCode"], - "nested": { "SessionErrorCode": "Session error codes", "AppErrorCode": "App error codes" } + "types": [ + "PlatformSocketError", + "SessionErrorCode", + "AppErrorCode" + ], + "nested": { + "SessionErrorCode": "Session error codes", + "AppErrorCode": "App error codes" + } }, - ["WebhookEvent", "UnknownWebhookEvent"], + [ + "WebhookEvent", + "UnknownWebhookEvent" + ], { "title": "Errors", - "types": ["WebhookVerificationError", "WebhookVerificationErrorCode", "WebhookConfigurationError", "WebhookConfigurationErrorCode"], + "types": [ + "WebhookVerificationError", + "WebhookVerificationErrorCode", + "WebhookConfigurationError", + "WebhookConfigurationErrorCode" + ], "nested": { "WebhookVerificationErrorCode": "Verification error codes", "WebhookConfigurationErrorCode": "Configuration error codes" } + }, + { + "title": "Questions", + "types": [ + "ChatQuestion", + "ChoiceQuestion", + "ChoiceEntry", + "SecretsQuestion", + "SecretField", + "ApprovalQuestion", + "ApprovalDetails" + ], + "intro": "ChatQuestion" } ] } diff --git a/packages/platform/scripts/mintlify-post-processing/types-to-delete-after-processing.json b/packages/platform/scripts/mintlify-post-processing/types-to-delete-after-processing.json new file mode 100644 index 0000000..05744a6 --- /dev/null +++ b/packages/platform/scripts/mintlify-post-processing/types-to-delete-after-processing.json @@ -0,0 +1,9 @@ +[ + "ChatOptions", + "ChatItem", + "ChatStep", + "ChatAnswer", + "ChatError", + "ChatApp", + "ToolCallAnswer" +] diff --git a/packages/platform/src/client/modules/chat.types.ts b/packages/platform/src/client/modules/chat.types.ts index 1653fbf..6543b6d 100644 --- a/packages/platform/src/client/modules/chat.types.ts +++ b/packages/platform/src/client/modules/chat.types.ts @@ -2,9 +2,8 @@ import type { PlatformEvent, PlatformSnapshot } from "./chat.events.types.js"; import type { PlatformSocketError } from "../errors.js"; /** - * The response body of [Create socket session](/api-reference/create-socket-session), which your - * `getSession()` callback returns. Pass the response through unchanged. The client reads the three - * fields below. + * The three fields of a [Create socket session](/api-reference/create-socket-session) response that + * the browser needs. Keep the response's `session_id` on your server. */ export interface SocketSession { /** Token that authenticates the socket. The client keeps it in memory and sends it only when the socket connects. */ @@ -20,13 +19,15 @@ export interface ChatInitOptions { /** * Returns a new socket session from your server. * - * Return the response body of [Create socket session](/api-reference/create-socket-session) as a - * [`SocketSession`](#socketsession), and return a new session on every call. The client calls + * Return the `session_token`, `socket_url`, and `socket_path` of a + * [Create socket session](/api-reference/create-socket-session) response as a + * [`SocketSession`](#socketsession), and keep its `session_id` on your server. Return a new + * session on every call. The client calls * `getSession()` on the first {@linkcode ChatSession.connect | connect()}, and again only when * the session expires or Base44 rejects its token. If a new session has a different `socket_url` * or `socket_path`, the client moves to it and rejoins every active subscription. * - * @returns The session, as your server received it. + * @returns The session's `session_token`, `socket_url`, and `socket_path`. */ getSession: () => Promise; /** @@ -55,8 +56,7 @@ export interface ChatModule { * * Pass a `getSession()` callback in the options. The client calls it to get a * [socket session](/developers/references/apps-api/sections/socket-sessions), which your server - * opens with [Create socket session](/api-reference/create-socket-session) and returns as the - * response body. Each session is independent and gets its own socket session. + * opens with [Create socket session](/api-reference/create-socket-session). Each session is independent and gets its own socket session. * * Call {@linkcode ChatSession.connect | connect()} on the returned session to open the socket. * diff --git a/packages/platform/src/react/chat.types.ts b/packages/platform/src/react/chat.types.ts index a79379b..4cc8836 100644 --- a/packages/platform/src/react/chat.types.ts +++ b/packages/platform/src/react/chat.types.ts @@ -21,25 +21,28 @@ export interface ToolCallAnswer { } /** - * The four calls your backend makes to Base44 for the chat. + * The functions you write for the `backend` option. The hook calls them when it needs Base44, and + * each one usually forwards to a route on your own server, which adds your credentials. * - * Each call wraps one Base44 API request with your credentials, so the browser never holds a - * workspace API key or an access token. Your backend can be written in any language. + * Keeping the Base44 requests on your server means the browser never holds a workspace API key or + * an access token. Your server can be written in any language. * - * - `createApp`: [Create app](/api-reference/create-app), with the first prompt as `initial_message`. - * - `openLiveSession`: [Create socket session](/api-reference/create-socket-session) with `{ app_ids: [appId] }`. - * Return the response body unchanged. It holds a session token, never the workspace API key. - * - `sendMessage`: [Send chat message](/api-reference/send-chat-message). - * - `submitToolCallInput`: [Submit tool-call input](/api-reference/submit-tool-call-input). Send an + * Each function's route calls one Base44 endpoint: + * - [`createApp()`](#createapp): [Create app](/api-reference/create-app), with the first prompt as `initial_message`. + * - [`openLiveSession()`](#openlivesession): [Create socket session](/api-reference/create-socket-session) with `{ app_ids: [appId] }`. + * Return its `session_token`, `socket_url`, and `socket_path`, and keep `session_id` on your server. + * None of them is the workspace API key. + * - [`sendMessage()`](#sendmessage): [Send chat message](/api-reference/send-chat-message). + * - [`submitToolCallInput()`](#submittoolcallinput): [Submit tool-call input](/api-reference/submit-tool-call-input). Send an * `X-Request-ID` built from the tool call ID, so a retry resumes the turn only once. * * Base44 keeps the Send chat message and Submit tool-call input requests open until the AI * finishes the turn. Resolve your call once Base44 accepts the request, not when it returns. The - * chat follows the rest live. A rejected promise from any call sets `error` on the {@link useChat | chat}. + * chat follows the rest live. A rejected promise from any call sets [`error`](#error). * * @example * ```typescript - * // Your four calls, behind your own API routes + * // Your four functions, behind your own API routes * async function post(path: string, body: unknown): Promise { * const response = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); * if (!response.ok) throw new Error(`${path} failed: ${response.status}`); @@ -67,12 +70,14 @@ export interface ChatBackend { * whenever the session expires. Sessions last one hour. * * @param appId - ID of the app to follow. - * @returns Promise resolving to the Create socket session response body, unchanged. + * @returns Promise resolving to the session. */ openLiveSession(appId: string): Promise; /** - * Sends a prompt to an existing app. Base44 queues a prompt sent while the AI is working. When the - * app has no queue, Base44 refuses it with a 409, so reject the promise. + * Sends a prompt to an existing app. Base44 queues a prompt sent while the AI is working. If Base44 + * refuses the prompt, for example because the app is busy with an operation that blocks new + * messages, reject the promise. The hook then sets [`error`](#error), so your UI can show the + * failure. * * @param appId - ID of the app. * @param content - The prompt. @@ -95,16 +100,21 @@ export interface ChatItem { id: string; /** Who wrote the message. */ role: "user" | "assistant"; - /** The message text, as raw markdown. It comes before the steps, and is empty when the message only holds tool calls. */ + /** The message text, as raw markdown. Show it before the message's steps. It's empty when the message only holds tool calls. */ text: string; - /** When the message was created, as an ISO timestamp. Base44's timestamps are UTC with no time zone. A pending prompt carries the browser's time. */ + /** + * When the message was created, as an ISO 8601 timestamp in UTC. A timestamp from Base44 has no `Z` + * or offset, so add a `Z` before parsing it with `new Date()`. A pending prompt has the browser's + * time, which already ends in `Z`. + */ createdAt: string; /** One step per tool call, in order. */ steps: ChatStep[]; /** - * Whether this is a prompt sent from this chat that Base44 hasn't confirmed yet. It shows at once, - * and Base44's own message replaces it. A failed send removes it. A prompt sent while the AI is - * working waits in Base44's queue and stays pending until it runs. + * Whether this is a stand-in for a prompt you just sent. When you call [`send()`](#send) or + * [`create()`](#create), the prompt appears in `items` right away as a pending item, and Base44's + * copy of the message replaces it once it arrives. A failed send removes it. A prompt sent while the + * AI is working waits in Base44's queue, so it stays pending until it runs. */ pending: boolean; } @@ -117,20 +127,24 @@ export interface ChatStep { tool: string; /** The file paths the tool call touched. */ files: string[]; - /** A line ready to show. It's Base44's description of the step when there is one, then the entity it touched, then the tool name and its files. */ + /** + * A line to show for the step. It's Base44's description of the step when there is one. Otherwise + * it's the tool name followed by the entity the step touched and its record count, such as + * `create_records Task (3)`, or by the step's files. + */ label: string; /** - * Where the tool call is. A `waiting` step waits on the user. A `stopped` step was declined, or the - * turn was stopped. + * The step's progress. A `waiting` step needs the user to answer its question. A `stopped` step was + * declined, or its turn was stopped. A status this release doesn't know counts as `done`. */ status: "running" | "waiting" | "done" | "stopped" | "error"; /** - * The question this step waits on, with the actions that answer it. Present only while the step is - * `waiting` in the last assistant message, which is the one that makes the chat wait. It goes away - * once answered, and is absent for a question kind this release doesn't know. + * The {@link ChatQuestion | question} the step needs answered, with the actions that answer it. It's present only on a + * `waiting` step in the latest assistant reply, and goes away once answered. A `waiting` step in an + * older message has no question. It's also absent for a question kind this release doesn't know. */ question?: ChatQuestion; - /** What the user answered on a choice question, once answered. It stays after the question closes, so the chat's history shows it. */ + /** The user's answers to a choice question, one for each question the step asked. It stays after the question closes, so you can show the answers in the chat's history. */ answered?: ChatAnswer[]; } @@ -145,14 +159,17 @@ export interface ChatAnswer { } /** - * An open question, with the actions that answer this question and no other. + * An open question the AI waits on. A `waiting` step carries it as its `question`, and + * [`status`](#status) becomes `waiting`. Check `kind` to see which of the three kinds it is. * - * Each kind carries only the actions that make sense for it, and every kind can be declined. The - * actions never throw. A failure sets `error` on the {@link useChat | chat}, and the question opens again. + * Each kind has only the actions that fit it, and every kind can be declined. Calling an action + * closes the question right away and sends the answer through your + * [`submitToolCallInput()`](#submittoolcallinput). The actions never throw. A failure sets + * [`error`](#error), and the question opens again. * - * The chat declines a question kind this release doesn't know, once per tool call, when Base44 - * names that kind on the last assistant message. If the decline fails, the chat's `error` says - * so, and the chat tries again after it reconnects. + * The hook declines a question kind this release doesn't know, once per tool call, when Base44 + * names that kind on the last assistant message. If the decline fails, [`error`](#error) says + * so, and the hook tries again after it reconnects. */ export type ChatQuestion = ChoiceQuestion | SecretsQuestion | ApprovalQuestion; @@ -177,30 +194,31 @@ export interface ChoiceQuestion { /** The questions, in order. */ questions: ChoiceEntry[]; /** - * Answers with the picked labels, and free text where the user wrote some. + * Answers with the picked labels, and free text where the user wrote some. For a question whose + * `multi` is `false`, only the first picked label counts. * * @param picked - One list of picked labels per question, in the order of `questions`. * @param texts - One free-text answer per question, in the same order. Use it for a `text_input` question, or as a note beside a pick. A question with no pick and no text is skipped. - * @returns Promise resolving once the answer is sent. + * @returns Promise resolving once Base44 accepts the answer, or once sending it fails and sets [`error`](#error). It never rejects. */ answer(picked: string[][], texts?: string[]): Promise; /** * Declines the question. The AI continues without the answer. * - * @returns Promise resolving once the decline is sent. + * @returns Promise resolving once Base44 accepts the decline, or once sending it fails and sets [`error`](#error). It never rejects. */ decline(): Promise; } /** A value the AI needs, such as an API key. */ export interface SecretField { - /** Name of the field, used as its key in `submit`. */ + /** Name of the field, which keys its value in [`submit()`](#submit). */ name: string; /** Where to get the value. */ description?: string; } -/** The AI needs secrets, such as API keys. The values go to Base44 and the chat keeps none of them. */ +/** The AI needs secrets, such as API keys. The values go to Base44, and the hook keeps none of them. */ export interface SecretsQuestion { /** Kind of question. */ kind: "secrets"; @@ -209,14 +227,14 @@ export interface SecretsQuestion { /** * Submits the values. * - * @param values - The values, keyed by field name. - * @returns Promise resolving once the values are sent. + * @param values - The values, keyed by each field's `name`. + * @returns Promise resolving once Base44 accepts the values, or once sending it fails and sets [`error`](#error). It never rejects. */ submit(values: Record): Promise; /** * Declines the question. The AI continues without the values. * - * @returns Promise resolving once the decline is sent. + * @returns Promise resolving once Base44 accepts the decline, or once sending it fails and sets [`error`](#error). It never rejects. */ decline(): Promise; } @@ -227,7 +245,7 @@ export interface ApprovalQuestion { kind: "approval"; /** Name of the tool that wants to run. */ action: string; - /** Why the action needs approval, when Base44 gives a reason. Empty otherwise. */ + /** Why the action needs approval. It's Base44's reason when there is one, otherwise the `summary` in `details`, and empty when neither is given. */ reason: string; /** The check that paused the action, such as `exec_tool_send_email` or `entity_rls_guard`. */ guard: string; @@ -236,13 +254,13 @@ export interface ApprovalQuestion { /** * Lets the action run. * - * @returns Promise resolving once the approval is sent. + * @returns Promise resolving once Base44 accepts the approval, or once sending it fails and sets [`error`](#error). It never rejects. */ approve(): Promise; /** * Declines the action. It never runs, and the AI continues. * - * @returns Promise resolving once the decline is sent. + * @returns Promise resolving once Base44 accepts the decline, or once sending it fails and sets [`error`](#error). It never rejects. */ decline(): Promise; } @@ -266,23 +284,25 @@ export interface ApprovalDetails { } /** - * What the AI is doing. When several hold at once, the first in this list wins. + * What the AI is doing. + * + * When several hold at once, the first in this list wins. * * - `error`: Base44 reports that the last build failed. - * - `waiting`: a question waits on the user. - * - `building`: the AI is working. - * - `submitted`: a prompt, a new app or an answer is on its way. It lasts until Base44 reports work, a reply or a queue change, or the call fails. - * - `ready`: none of the above. + * - `waiting`: A question waits for the user's answer. + * - `building`: The AI is working. + * - `submitted`: A prompt, a new app, or an answer is on its way. It lasts until Base44 reports work, a reply, or a queue change, or until the call fails. + * - `ready`: None of the above. */ export type ChatStatus = "ready" | "submitted" | "building" | "waiting" | "error"; /** * Whether the chat follows the app live. * - * - `connecting`: until the first snapshot arrives. - * - `connected`: after the first snapshot. - * - `disconnected`: live updates stopped for good. The session couldn't open, the retries ran out, - * or Base44 ended the session or the app's subscription. {@link UseChatResult.error} says why. + * - `connecting`: The chat waits for the app's first snapshot. + * - `connected`: The first snapshot arrived, and updates follow live. + * - `disconnected`: Live updates stopped for good. The session couldn't open, the retries ran out, + * or Base44 ended the session or the app's subscription. Check [`error`](#error) to see why. * * Watching another app, or mounting the chat again, starts over. */ @@ -290,14 +310,16 @@ export type ChatConnection = "connecting" | "connected" | "disconnected"; /** A failure of a call, of the live connection, or of the build. */ export interface ChatError { - /** What went wrong, for people. */ + /** A readable description of what went wrong, such as `The build failed`. */ message: string; /** - * What went wrong, for code. A connection error carries a `PlatformSocketErrorCode`, and a failed - * build carries `build_failed`. A failed call to your backend carries none. + * A value your code can check to handle the error, such as `build_failed` for a failed build. A + * connection error has one of the session or app error codes listed under the chat module's + * [Errors](/developers/references/platform-sdk/docs/interfaces/chat#errors). A failed call to + * your backend has no code. */ code?: string; - /** The original error, for logging. */ + /** The error the failure came from, such as the one your backend threw. Log it to debug the failure. It's absent when the hook found the failure itself, such as a failed build or a send with no app. */ cause?: unknown; } @@ -305,50 +327,71 @@ export interface ChatError { export interface ChatOptions { /** * ID of the app to follow, or `null` until an app exists. While it's `null`, the chat follows the - * app {@link UseChatResult.create} made. Any change to `appId` forgets that app. + * app that [`create()`](#create) made, so you don't have to pass the new ID back. + * Changing `appId` stops following that app, so setting it back to `null` starts a new chat. + * Switching to another app closes the old session and starts over. */ appId: string | null; - /** The four calls your backend makes to Base44. The chat reads the latest on every action, so an inline object works. */ + /** + * The functions the hook calls to reach Base44 through your backend, described in + * [Backend calls](#backend-calls). The hook uses the `backend` from the latest render, so you can + * pass an inline object without memoizing it. + */ backend: ChatBackend; /** - * Called with the new app after {@link UseChatResult.create} succeeds. The chat already follows it. - * Pass its ID back as `appId` to keep it in your own state. + * Called with the new app after [`create()`](#create) succeeds. The chat already follows the new + * app. To keep its ID in your own state, pass it back as `appId`. * * @param app - The app that was created. */ onAppCreated?: (app: ChatApp) => void; } -/** The chat that {@link useChat} returns. */ +/** + * The chat state and actions that `useChat()` returns. + * + * @groupDescription Properties + * The hook returns one object, of type `UseChatResult`. Its state is below, and its functions are + * under [Returned actions](#returned-actions). + * + * @groupDescription Methods + * Functions on the same object, which you call to act on the chat, as in `chat.send(prompt)`. They + * never throw. A failure sets [`error`](#error). + */ export interface UseChatResult { - /** The conversation, oldest first, with open questions bound to their steps. */ + /** The conversation, oldest first. Each open question appears with the step that asked it. */ items: ChatItem[]; /** What the AI is doing. */ status: ChatStatus; /** Whether the chat follows the app live. */ connection: ChatConnection; /** - * The last failure. It's set whenever `status` is `error`, and on any failed call or connection - * error. A failed call's error clears on the next action. A connection error stays until - * {@link UseChatResult.clearError}. A failed build stays while Base44 reports it. + * The failure to show, or `null`. It's set when a call fails, when the live connection fails, and + * whenever `status` is `error`. When more than one of these conditions is true, `error` holds the + * failed call first, then the connection error, then the failed build. A failed call's error + * clears on the next action, and a connection error stays until [`clearError()`](#clearerror). A + * failed build's error stays while Base44 reports it. Switching to another app clears it. */ error: ChatError | null; /** - * Sends a prompt to the current app. Base44 queues it while the AI is working. The prompt shows - * at once as a pending item, and the reply arrives through `items`. Without an app, it sets - * `error`. Use {@link UseChatResult.create} for the first prompt. + * Sends a prompt to the app the chat follows. The prompt appears in [`items`](#items) right away + * as a pending item, and the reply arrives there too. Base44 queues the prompt while the AI is + * working. If the chat doesn't follow an app yet, it sets [`error`](#error) instead. Use + * [`create()`](#create) for the first prompt. * * @param prompt - The prompt to send. - * @returns Promise resolving once Base44 accepts the prompt. It never rejects. + * @returns Promise resolving once Base44 accepts the prompt, or once the send fails and sets [`error`](#error). It never rejects. */ send(prompt: string): Promise; /** - * Creates an app from its first prompt and reports it through `onAppCreated`. + * Creates an app from its first prompt. The prompt appears in [`items`](#items) right away as a + * pending item. Once the app exists, the hook passes it to [`onAppCreated`](#onappcreated). If + * `appId` is `null`, the chat then follows the new app. Otherwise it stays on the app it follows. * * @param prompt - The first prompt, which describes the app. - * @returns Promise resolving once the app exists. It never rejects. + * @returns Promise resolving once the app exists, or once creating it fails and sets [`error`](#error). It never rejects. */ create(prompt: string): Promise; - /** Clears {@link UseChatResult.error}. A failed build's error stays while Base44 reports it. */ + /** Clears a failed call's or the connection's [`error`](#error). A failed build's error stays while Base44 reports it. */ clearError(): void; } diff --git a/packages/platform/src/react/useChat.ts b/packages/platform/src/react/useChat.ts index fb3f475..098f7f6 100644 --- a/packages/platform/src/react/useChat.ts +++ b/packages/platform/src/react/useChat.ts @@ -11,36 +11,33 @@ const BUILD_FAILED_ERROR: ChatError = { message: "The build failed", code: "buil const NEW_APP = "new-app"; /** - * Runs the AI chat for one app in your own UI. + * Runs an app's AI chat in your own React UI. Requires a browser environment and can't be used in + * the backend. * - * The hook renders nothing. It gives you the conversation and the actions, and you draw them: + * The hook is headless. It gives you the conversation and the [actions](#returned-actions), and your components render them: * - Follows the app live, and renews its socket session when it expires. - * - Turns Base44's messages into {@link ChatItem | items}, with each open question bound to the step it belongs to. - * - Reports what the AI is doing in {@link UseChatResult.status}, and the live connection in {@link UseChatResult.connection}. - * - Sends prompts, creates apps and answers questions through the {@link ChatBackend | backend} you pass, so no credential reaches the browser. - * - * The actions never throw. A failure sets {@link UseChatResult.error}, and a question whose answer - * failed opens again. The chat declines a question kind this release doesn't know. - * - * Requires a browser environment and can't be used in the backend. - * - * The chat follows `appId` when it's set. While `appId` is `null`, it follows the app - * {@link UseChatResult.create} made, so passing the new ID back is optional. Any change to `appId` - * forgets that app, so setting it back to `null` starts a new chat. Switching apps closes the old - * session and starts over. + * - Turns Base44's messages into [items](#items), and shows each open [question](#questions) with the step that asked it. + * - Reports what the AI is doing in [`status`](#status), and the live connection in [`connection`](#connection). + * - Sends prompts, creates apps, and answers questions through the {@link ChatBackend | backend} you pass, so no credential reaches the browser. * * @param options - The app to follow, your backend's calls, and what to do with a created app. - * @returns The conversation, its status, connection and error, and the actions `send`, `create` and `clearError`. + * @returns The chat's state, `items`, `status`, `connection`, and `error`, and its actions `send()`, `create()`, and `clearError()`. * * @example - * ```typescript - * // Draw the chat and answer an approval + * ```tsx + * // Render the chat and answer an approval + * import { useChat } from "@base44/platform/react"; + * import Markdown from "react-markdown"; + * + * // ... + * + * // Inside your component * const chat = useChat({ appId, backend, onAppCreated: selectApp }); * return ( * <> * {chat.items.map((item) => ( *
- * {item.text &&

{item.text}

} + * {item.text && {item.text}} * {item.steps.map((step) => * step.question?.kind === "approval" ? ( * diff --git a/packages/sdk/scripts/mintlify-post-processing/copy-to-local-docs.js b/packages/sdk/scripts/mintlify-post-processing/copy-to-local-docs.js index 9491258..0470d9f 100644 --- a/packages/sdk/scripts/mintlify-post-processing/copy-to-local-docs.js +++ b/packages/sdk/scripts/mintlify-post-processing/copy-to-local-docs.js @@ -130,9 +130,18 @@ function updateDocsJson(repoDir, sdkFiles) { }; const p = (kind, file) => `${prefix}${basePath}/${kind}/${file}`; - // Groups follow the category map's key order. + // Groups follow the category map's key order. A key that names one page, such as + // "functions/useChat", moves that page out of its kind's group into its own. + const pageGroups = Object.entries(categoryMap).filter(([key]) => key.includes("/")); + const movedPages = new Set(pageGroups.map(([key]) => key)); for (const [kind, group] of Object.entries(categoryMap)) { - if (sdkFiles[kind]?.length > 0) addToGroup(group, sdkFiles[kind].map((f) => p(kind, f))); + if (kind.includes("/")) { + const [pageKind, file] = kind.split("/"); + if (sdkFiles[pageKind]?.includes(file)) addToGroup(group, [p(pageKind, file)]); + continue; + } + const files = (sdkFiles[kind] ?? []).filter((f) => !movedPages.has(`${kind}/${f}`)); + if (files.length > 0) addToGroup(group, files.map((f) => p(kind, f))); } return Array.from(groupMap.entries()).map(([group, pages]) => ({ diff --git a/packages/sdk/scripts/mintlify-post-processing/file-processing/file-processing.js b/packages/sdk/scripts/mintlify-post-processing/file-processing/file-processing.js index fb56490..8521bd6 100755 --- a/packages/sdk/scripts/mintlify-post-processing/file-processing/file-processing.js +++ b/packages/sdk/scripts/mintlify-post-processing/file-processing/file-processing.js @@ -408,6 +408,20 @@ function generateDocsJson(docsContent) { } } + // A category map key that names one page, such as "functions/useChat", moves that page out of + // its kind's group into its own, after the kind groups. + for (const [key, groupName] of Object.entries(categoryMap)) { + if (!key.includes("/")) continue; + for (const group of groups) { + const index = group.pages.findIndex((page) => page.endsWith(`/${key}`)); + if (index === -1) continue; + const [page] = group.pages.splice(index, 1); + const target = groups.find((g) => g.group === groupName) ?? groups[groups.push({ group: groupName, expanded: true, pages: [] }) - 1]; + target.pages.push(page); + } + } + for (let i = groups.length - 1; i >= 0; i--) if (groups[i].pages.length === 0) groups.splice(i, 1); + // Find or create SDK Reference tab let sdkTab = template.navigation.tabs.find( (tab) => tab.tab === "SDK Reference" @@ -673,7 +687,7 @@ function prepareAppendedSection(appendPath) { return { section, headings: headingList }; } -function applyAppendedArticles(appendedArticles) { +function applyAppendedArticles(appendedArticles, inlinedTypeNames = new Set()) { const hosts = Object.keys(appendedArticles); if (hosts.length === 0) { return; @@ -714,6 +728,8 @@ function applyAppendedArticles(appendedArticles) { let effectiveAppendKey = appendKey; const appendParts = appendKey.split("/"); const appendName = appendParts[appendParts.length - 1]; + // Its values are in the fields that use it, so it gets no section of its own. + if (inlinedTypeNames.has(appendName)) continue; mergedTypeNames.push(appendName); const appendModuleRename = getModuleRename(appendName); if (appendModuleRename) { @@ -775,28 +791,33 @@ function applyAppendedArticles(appendedArticles) { hostContent = hostContent.trimEnd() + combinedSections + "\n"; hostContent = updatePanelWithHeadings(hostContent, collectedHeadings); - // Types merged onto this page no longer have their own file, so any link - // that still points at one — a `{@link Type.member}` cross-reference - // TypeDoc resolved to `.../Type.mdx#member`, or a plain `{@link Type}` - // resolved to `.../Type` with no fragment at all — now 404s. Rewrite - // every such link to a same-page anchor: `#member` when TypeDoc kept a - // fragment, otherwise `#` (Mintlify's own heading - // slug for a single-word PascalCase heading has no inserted hyphens). - for (const typeName of mergedTypeNames) { - const linkRegex = new RegExp( - `\\]\\((?:\\.\\./(?:interfaces|type-aliases|classes|functions)/)?${typeName}(?:\\.mdx|\\.md)?(#[\\w-]*)?\\)`, - "g" - ); - hostContent = hostContent.replace( - linkRegex, - (_match, anchor) => `](${anchor || `#${typeName.toLowerCase()}`})` - ); - } - + hostContent = linkMergedTypesInPage(hostContent, mergedTypeNames); fs.writeFileSync(hostPath, hostContent, "utf-8"); } } +/** + * Types merged onto a page no longer have their own file, so any link that still points at one — + * a `{@link Type.member}` cross-reference TypeDoc resolved to `.../Type.mdx#member`, or a plain + * `{@link Type}` resolved to `.../Type` with no fragment at all — now 404s. Rewrites every such + * link to a same-page anchor: `#member` when TypeDoc kept a fragment, otherwise + * `#` (Mintlify's own heading slug for a single-word PascalCase heading has + * no inserted hyphens). + */ +function linkMergedTypesInPage(content, typeNames) { + for (const typeName of typeNames) { + const linkRegex = new RegExp( + `\\]\\((?:\\.\\./(?:interfaces|type-aliases|classes|functions)/)?${typeName}(?:\\.mdx|\\.md)?(#[\\w-]*)?\\)`, + "g" + ); + content = content.replace( + linkRegex, + (_match, anchor) => `](${anchor || `#${typeName.toLowerCase()}`})` + ); + } + return content; +} + /** * Clean up method signatures and type parameter sections: * 1. Replace truncated generics (e.g., Pick<..., ...> → Pick) @@ -1435,8 +1456,16 @@ function applyTypeDeclarationLinking(dir) { // Appended types whose methods get their own section, by section title, and groups of appended // types nested under "## Type Definitions" (the first type of a group starts the section). A group // is a list of type names, or `{ "title": "...", "types": [...] }` for another section heading. -const { typesWithOwnMethods: TYPES_WITH_OWN_METHODS = {}, typeDefinitionGroups: TYPE_DEFINITION_GROUPS = [] } = - fs.existsSync(PAGE_SECTIONS_PATH) ? JSON.parse(fs.readFileSync(PAGE_SECTIONS_PATH, "utf-8")) : {}; +// A function's return type in `returnedState` also gets its properties a section, by title, in +// place of the function's Returns: one heading per property, as its methods have. A type in +// `inlineTypes` moves into the field that uses it, and a group's `intro` type opens its section +// with no heading of its own. +const { + typesWithOwnMethods: TYPES_WITH_OWN_METHODS = {}, + returnedState: RETURNED_STATE = {}, + inlineTypes: INLINE_TYPES = [], + typeDefinitionGroups: TYPE_DEFINITION_GROUPS = [], +} = fs.existsSync(PAGE_SECTIONS_PATH) ? JSON.parse(fs.readFileSync(PAGE_SECTIONS_PATH, "utf-8")) : {}; /** * Group intro sections (like "Built-in User Entity", "Generated Types") under an "Overview" heading @@ -1496,11 +1525,14 @@ function groupIntroSections(content) { return { content, modified: false }; } - // Find all ## headings between insert point and main section (these are intro sections) + // Find all ## headings between insert point and main section (these are intro sections). A + // function page whose return has its own sections keeps its Parameters and Returns beside them. + const hasReturnedState = Object.keys(RETURNED_STATE).some((type) => lines.includes(`## ${type}`)); + const keptHeadings = hasReturnedState ? ["## Parameters", "## Returns"] : []; const introHeadingIndices = []; for (let i = insertIndex; i < mainSectionIndex; i++) { const line = lines[i].trim(); - if (line.startsWith("## ") && !line.startsWith("### ")) { + if (line.startsWith("## ") && !line.startsWith("### ") && !keptHeadings.includes(line)) { introHeadingIndices.push(i); } } @@ -1578,6 +1610,7 @@ function groupTypeDefinitions(content) { for (const typeName of group.types) { section = section.replace(new RegExp(`^## ${typeName}$`, "m"), `### ${typeName}`); } + section = nestUnderGroupTypes(section, new Set(group.types)); // Any "##" heading still left in this slice belongs to one of the types // above it (e.g. "## Properties" from an appended interface with members, // since normalizeHeadings() clamps an appended article's own headings to @@ -1590,6 +1623,41 @@ function groupTypeDefinitions(content) { return { content: updatedContent, modified: true }; } +/** + * Inside a group, a type's own methods arrive at the level of the types themselves: a "## Methods" + * label, then "### method()" with its "#### Parameters" and "#### Returns". Nest them under their + * type instead. A "###" heading that isn't one of the group's types belongs to the type above it, + * so it moves down a level with everything under it, and the bare "Methods" label goes. + */ +function nestUnderGroupTypes(section, typeNames) { + const lines = section.split("\n"); + const result = []; + let inFence = false; + let inMember = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line.trim().startsWith("```")) inFence = !inFence; + const heading = inFence ? null : line.match(/^(#{2,6}) (.+)$/); + if (!heading) { + result.push(line); + continue; + } + const level = heading[1].length; + if (level <= 3) inMember = false; + if (level === 3 && typeNames.has(heading[2])) { + result.push(line); + continue; + } + if (level === 2 && heading[2] === "Methods") { + if (lines[i + 1]?.trim() === "") i++; + continue; + } + if (level === 3) inMember = true; + result.push(inMember && level < 6 ? `#${line}` : line); + } + return result.join("\n"); +} + /** * Rename and merge method sections for better TOC organization * - EntityHandler → Entity Handler Methods @@ -1835,7 +1903,17 @@ function mergeSectionWithMethods(content, filePath) { } } - if (methodsIndex !== -1) { + if (methodsIndex !== -1 && Object.hasOwn(RETURNED_STATE, typeName) && propertiesIndex !== -1) { + // A returned type's two groups become two sections, each opened by its + // @groupDescription: the properties' text, whose fields returnedStateSections() + // fills in from the function's Returns, and the methods. The type's own comment + // stays on the type. + const firstProperty = lines.findIndex((l, j) => j > propertiesIndex && j < methodsIndex && /^#{3,6} /.test(l)); + const propertiesIntro = lines.slice(propertiesIndex + 1, firstProperty === -1 ? methodsIndex : firstProperty); + lines.splice(i, methodsIndex - i + 1, + `## ${RETURNED_STATE[typeName]}`, ...propertiesIntro, `## ${TYPES_WITH_OWN_METHODS[typeName] ?? typeName}`); + modified = true; + } else if (methodsIndex !== -1) { lines[i] = `## ${TYPES_WITH_OWN_METHODS[typeName] ?? typeName}`; // Drop the ## Properties section too: the heading and its content sit // between the type heading and ## Methods. @@ -2192,6 +2270,112 @@ function cleanupTruncatedParamTypes(dir) { } } +/** + * A type alias page for a union of string literals, such as `type Status = "ready" | "error"`, as + * its values and its comment minus the first paragraph. The first paragraph sums up the type, and a + * field that uses the type has its own summary. Anything else, including a union whose comment + * documents each value as a field and so has no signature line, is null. + */ +function parseStringUnion(content, typeName) { + const signature = content.match(new RegExp(`^> \\*\\*${typeName}\\*\\* = (.+)$`, "m")); + if (!signature) return null; + const values = signature[1].split(" | ").map((part) => part.trim().match(/^`"([^"`]*)"`$/)?.[1]); + if (values.some((value) => value === undefined)) return null; + const comment = content.slice(signature.index + signature[0].length).trim(); + return { values, details: comment.split(/\n\s*\n/).slice(1).join("\n\n").trim() }; +} + +/** + * A field whose type is the union, with the union's comment after the field's own description. The + * type label keeps the union's name, for the import. A comment that doesn't name every value in + * backticks gets a line that lists them. + */ +function inlineStringUnions(content, unions) { + let modified = false; + for (const [typeName, { values, details }] of unions) { + const field = new RegExp( + `(<(ResponseField|ParamField) [^>]*type=")(?:${typeName}|\\[${typeName}\\]\\([^)]*\\))("[^>]*>)([\\s\\S]*?)\\n*()`, + "g" + ); + content = content.replace(field, (_match, open, _tag, attributes, description, close) => { + modified = true; + const namesEvery = values.every((value) => new RegExp(`\`'?${value}'?\``).test(details)); + const quoted = values.map((value) => `\`'${value}'\``); + const valueLine = namesEvery + ? "" + : `It's ${quoted.length > 2 ? `${quoted.slice(0, -1).join(", ")}, or ${quoted.at(-1)}` : quoted.join(" or ")}.`; + const added = [details, valueLine].filter(Boolean).join("\n\n"); + return `${open}${typeName}${attributes}\n\n${[description.trim(), added].filter(Boolean).join("\n\n")}\n\n${close}`; + }); + } + return { content, modified }; +} + +/** + * The string unions that some field uses, read from TypeDoc's pages before anything is removed. + * Their pages go, so their values show only in the fields, and the returned map lets the fields + * take them in once every field on a page has its final form. + */ +function collectInlinedStringUnions(contentDir) { + const aliasDir = path.join(contentDir, "type-aliases"); + if (!fs.existsSync(aliasDir)) return new Map(); + const unions = new Map(); + for (const file of fs.readdirSync(aliasDir)) { + if (!file.endsWith(".mdx")) continue; + const typeName = path.basename(file, ".mdx"); + const union = parseStringUnion(fs.readFileSync(path.join(aliasDir, file), "utf-8"), typeName); + if (union) unions.set(typeName, union); + } + + const used = new Set(); + const scan = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) scan(entryPath); + else if (entry.name.endsWith(".mdx")) { + const content = fs.readFileSync(entryPath, "utf-8"); + for (const typeName of unions.keys()) { + // A field's type attribute, or a property's signature line before it becomes a field. + const usage = new RegExp( + `type="${typeName}"|^> \\*\\*[\\w$]+\\*\\*\\??: (?:\\[\`?${typeName}\`?\\]\\([^)]*\\)|\`${typeName}\`)\\s*$`, + "m" + ); + if (usage.test(content)) used.add(typeName); + } + } + } + }; + scan(contentDir); + + const inlined = new Map([...unions].filter(([typeName]) => used.has(typeName))); + for (const typeName of inlined.keys()) { + fs.unlinkSync(path.join(aliasDir, `${typeName}.mdx`)); + console.log(`Removed (inlined into its fields): content/type-aliases/${typeName}.mdx`); + } + return inlined; +} + +function applyStringUnionInlining(dir, unions, appendedArticles) { + if (unions.size === 0) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) applyStringUnionInlining(entryPath, unions, appendedArticles); + else if (entry.name.endsWith(".mdx")) { + const result = inlineStringUnions(fs.readFileSync(entryPath, "utf-8"), unions); + if (!result.modified) continue; + // The comments come from TypeDoc's type-alias pages, so their links still carry the file + // extension and point at types this page may have merged in. + const hostKey = path.relative(CONTENT_DIR, entryPath).replace(/\.mdx$/, ""); + const merged = Object.entries(appendedArticles) + .filter(([host]) => host === hostKey || host.replace(/[^/]+$/, (name) => getModuleRename(name) ?? name) === hostKey) + .flatMap(([, list]) => list.map((key) => key.split("/").pop())); + const content = linkMergedTypesInPage(result.content.replace(/\]\(([^)#]+?)\.mdx?(#[^)]*)?\)/g, "]($1$2)"), merged); + fs.writeFileSync(entryPath, content, "utf-8"); + console.log(`Inlined string unions: ${path.relative(DOCS_DIR, entryPath)}`); + } + } +} + /** * Main function */ @@ -2551,7 +2735,146 @@ function propertiesAsFields(content, classPage) { .replace(/^#### Inherited from\n\n.*\n\n?/gm, "") .replace(/^#### Extends\n\n(- .*\n)+\n?/gm, "") .replace(classPage ? /^## Properties\n([\s\S]*?)(?=^## |(?![\s\S]))/m : /(?!)/, (section, body) => `## Properties\n\n${fieldsOf(body)}\n\n`) - .replace(/^#### Properties\n([\s\S]*?)(?=^#{1,3} |(?![\s\S]))/gm, (section, body) => `${fieldsOf(body)}\n\n`); + // A type's properties end at its first method ("name()"), which keeps its heading. + .replace(/^#### Properties\n([\s\S]*?)(?=^#{1,3} |^#{4,6} \S+\(\)$|(?![\s\S]))/gm, (section, body) => `${fieldsOf(body)}\n\n`); +} + +/** The outermost ResponseFields in a piece of a page, as name, type, and the markup inside each. */ +function topLevelResponseFields(text) { + const fields = []; + let depth = 0; + let start = 0; + let attributes = ""; + for (const match of text.matchAll(/]*)>|<\/ResponseField>/g)) { + if (match[1] !== undefined) { + if (depth === 0) { + start = match.index + match[0].length; + attributes = match[1]; + } + depth++; + } else if (--depth === 0) { + fields.push({ + name: attributes.match(/name="([^"]*)"/)?.[1] ?? "", + type: attributes.match(/type="([^"]*)"/)?.[1] ?? "", + inner: text.slice(start, match.index).trim(), + }); + } + } + return fields; +} + +/** + * A function whose return type is in page-sections.json `returnedState` returns an object of state + * and actions, and its page shows them as two sections instead of a Returns section: the + * properties under their own heading each, and the methods, which `typesWithOwnMethods` already + * gives a section. Each property keeps what Returns showed for it, its type and description and + * any fields of its own, and the Returns example moves to the end of the Overview. + */ +function returnedStateSections(content) { + for (const [typeName, title] of Object.entries(RETURNED_STATE)) { + if (!content.includes(`\n## ${title}\n`)) continue; + const returns = content.match(new RegExp(`^## Returns\\n\\n\`${typeName}\`\\n[\\s\\S]*?(?=^## )`, "m")); + if (!returns) continue; + const exampleAt = returns[0].search(/^#{3,4} Examples?$/m); + const example = exampleAt === -1 ? "" : returns[0].slice(exampleAt).trim().replace(/^#{3,4} /, "### "); + const unescaped = (type) => type.replace(/"/g, '"').replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&"); + const properties = topLevelResponseFields(exampleAt === -1 ? returns[0] : returns[0].slice(0, exampleAt)) + .map(({ name, type, inner }) => `### ${name}\n\n\`${unescaped(type)}\`\n\n${inner}`) + .join("\n\n***\n\n"); + + content = content.replace(returns[0], ""); + content = content.replace( + new RegExp(`^## ${title}\\n[\\s\\S]*?(?=^## )`, "m"), + (section) => `${section.trimEnd()}\n\n${properties}\n\n` + ); + if (example) { + content = content.replace(/^## Overview\n[\s\S]*?(?=^## )/m, (overview) => `${overview.trimEnd()}\n\n${example}\n\n`); + } + } + return content; +} + +function applyReturnedStateSections(dir) { + if (Object.keys(RETURNED_STATE).length === 0) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) applyReturnedStateSections(entryPath); + else if (entry.name.endsWith(".mdx")) { + const content = fs.readFileSync(entryPath, "utf-8"); + const converted = returnedStateSections(content); + if (converted !== content) fs.writeFileSync(entryPath, converted, "utf-8"); + } + } +} + +/** + * A type's section, from its heading to the next heading at its level or above: the heading + * level, the section's text, and where it starts. + */ +function typeSection(content, typeName) { + const heading = content.match(new RegExp(`^(#{2,3}) ${typeName}\\n`, "m")); + if (!heading) return null; + const level = heading[1].length; + const rest = content.slice(heading.index + heading[0].length); + const end = rest.search(new RegExp(`^#{1,${level}} `, "m")); + return { level, index: heading.index, text: content.slice(heading.index, heading.index + heading[0].length + (end === -1 ? rest.length : end)) }; +} + +/** + * Each type in page-sections.json `inlineTypes` that a field on the page uses, as `type="T"` or + * `type="T[]"`, moves into that field as a Properties accordion, the way Returns shows a nested + * type, and its own section goes. The field describes it, so the type's comment goes too. + */ +function inlineTypesIntoFields(content, typeNames = INLINE_TYPES) { + for (const typeName of typeNames) { + const section = typeSection(content, typeName); + if (!section) continue; + const fieldsAt = section.text.indexOf("]*type="${typeName}(?:\\[\\])?"[^>]*>)([\\s\\S]*?)\\n*()`); + const field = content.match(user); + // Only a field with no fields of its own takes the type in. + if (!field || field[2].includes(" + `${open}${body.trimEnd()}\n\n\n\n${fields}\n\n\n\n${close}`); + } + return content; +} + +/** + * A type definition group's `intro` type opens the group's section: its heading goes, so its text + * follows the section heading, and each type its signature names that has a heading on the page + * is linked to it. + */ +function groupIntroTypes(content, groups = TYPE_DEFINITION_GROUPS) { + for (const group of groups) { + const intro = !Array.isArray(group) && group.intro; + if (!intro) continue; + const section = typeSection(content, intro); + if (!section) continue; + const headings = new Set([...content.matchAll(/^#{2,4} (\w+)$/gm)].map((m) => m[1])); + const text = section.text + .replace(/^#{2,3} .*\n+(?:\*{3}\n+)?/, "") + .replace(new RegExp(`^> \\*\\*${intro}\\*\\* = .*$`, "m"), (signature) => + signature.replace(/`(\w+)`/g, (code, name) => (headings.has(name) && name !== intro ? `[\`${name}\`](#${name.toLowerCase()})` : code))); + content = content.replace(section.text, text); + } + return content; +} + +function applyInlineTypesAndGroupIntros(dir) { + if (INLINE_TYPES.length === 0 && !TYPE_DEFINITION_GROUPS.some((group) => !Array.isArray(group) && group.intro)) return; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) applyInlineTypesAndGroupIntros(entryPath); + else if (entry.name.endsWith(".mdx")) { + const content = fs.readFileSync(entryPath, "utf-8"); + const converted = groupIntroTypes(inlineTypesIntoFields(content)); + if (converted !== content) fs.writeFileSync(entryPath, converted, "utf-8"); + } + } } function applyPropertiesAsFields(dir) { @@ -2706,6 +3029,71 @@ function linkReturnTypes(content) { }); } +/** + * The anchor Mintlify gives a heading: lowercase, with backticks and parentheses dropped, dots and + * spaces as hyphens, and underscores kept, so `pull_request.changed` becomes `pull_request-changed`. + */ +function headingAnchor(text) { + return text + .toLowerCase() + .replace(/[`()]/g, "") + .replace(/[.\s]+/g, "-") + .replace(/[^a-z0-9_-]/g, "") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +/** + * Point every in-page link at a heading or explicit anchor that exists. A type whose section was renamed + * (page-sections.json `typesWithOwnMethods`) is linked by its new heading. A link to anything + * else the page no longer has, such as a property now shown as a field, keeps its text and loses + * the link, so it can't jump nowhere. + */ +function fixInPageAnchors(content) { + const anchors = new Set(); + let inFence = false; + for (const line of content.split("\n")) { + if (line.trim().startsWith("```")) inFence = !inFence; + if (inFence) continue; + const heading = line.match(/^#{1,6} (.+)$/); + if (heading) anchors.add(headingAnchor(heading[1])); + // An explicit anchor, such as an overload's label + for (const [, id] of line.matchAll(/\bid="([^"]+)"/g)) anchors.add(id); + } + // Mintlify anchors a field as `param-`. Only a name that one field on the page uses is + // a safe target, because a repeated name gets numbered anchors in page order. + const fieldCounts = new Map(); + for (const [, name] of content.matchAll(/<(?:ResponseField|ParamField) (?:name|body)="([^"]+)"/g)) { + fieldCounts.set(name, (fieldCounts.get(name) ?? 0) + 1); + } + const renamed = new Map([ + ...Object.entries(TYPES_WITH_OWN_METHODS).map(([type, title]) => [headingAnchor(type), headingAnchor(title ?? type)]), + // A group's intro type has no heading of its own, so a link to it goes to the group's. + ...TYPE_DEFINITION_GROUPS.filter((group) => !Array.isArray(group) && group.intro && group.title) + .map((group) => [headingAnchor(group.intro), headingAnchor(group.title)]), + ]); + return content.replace(/\[([^\]]+)\]\(#([^)\s]+)\)/g, (link, text, anchor) => { + if (anchors.has(anchor)) return link; + const target = renamed.get(anchor); + if (target && anchors.has(target)) return `[${text}](#${target})`; + const field = [...fieldCounts].find(([name, count]) => count === 1 && headingAnchor(name) === anchor); + if (field) return `[${text}](#param-${headingAnchor(field[0])})`; + return text; + }); +} + +function applyInPageAnchorFixes(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) applyInPageAnchorFixes(entryPath); + else if (entry.name.endsWith(".mdx")) { + const content = fs.readFileSync(entryPath, "utf-8"); + const fixed = fixInPageAnchors(content); + if (fixed !== content) fs.writeFileSync(entryPath, fixed, "utf-8"); + } + } +} + function applyReturnTypeLinks(dir) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const entryPath = path.join(dir, entry.name); @@ -2731,6 +3119,9 @@ function main() { const linkedTypeNames = getLinkedTypeNames(); const exposedTypeNames = getTypesToExpose(); + // Read the string unions that fields use before their pages are removed or appended + const inlinedUnions = collectInlinedStringUnions(CONTENT_DIR); + // First, perform module renames (EntitiesModule -> entities, etc.) performModuleRenames(DOCS_DIR); @@ -2739,7 +3130,7 @@ function main() { // Append configured articles const appendedArticles = loadAppendedArticlesConfig(); - applyAppendedArticles(appendedArticles); + applyAppendedArticles(appendedArticles, new Set(inlinedUnions.keys())); // Add clickable links for types in ParamFields addTypeLinksToParamFields(DOCS_DIR); @@ -2785,8 +3176,22 @@ function main() { // Link type names in Type Declarations sections to their corresponding headings applyTypeDeclarationLinking(DOCS_DIR); + // Put each string union's values in the fields that use it, now that every field has its final form + applyStringUnionInlining(CONTENT_DIR, inlinedUnions, appendedArticles); + + // A returned object of state and actions: its properties take the place of the function's + // Returns. After the inlining, so each property keeps the values its field was given. + applyReturnedStateSections(DOCS_DIR); + + // Types inlined into the field that uses them, and groups that open with their intro type + applyInlineTypesAndGroupIntros(DOCS_DIR); + // Remove links to types that aren't exposed (would 404) - applyNonExposedTypeLinkRemoval(DOCS_DIR, exposedTypeNames, loadTypesToDelete()); + applyNonExposedTypeLinkRemoval( + DOCS_DIR, + exposedTypeNames, + new Set([...loadTypesToDelete(), ...inlinedUnions.keys()]) + ); // Delete types that should not appear in navigation but were needed for inline rendering deleteTypesAfterProcessing(DOCS_DIR); @@ -2795,6 +3200,9 @@ function main() { // that method ordering splits on. applyReturnTypeLinks(DOCS_DIR); + // Last, so every heading the page ends up with counts + applyInPageAnchorFixes(DOCS_DIR); + // Clean up the linked types file try { if (fs.existsSync(LINKED_TYPES_FILE)) { @@ -2820,4 +3228,14 @@ if (process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(__fi main(); } -export { removeNonExposedTypeLinks }; +export { + fixInPageAnchors, + groupIntroTypes, + inlineTypesIntoFields, + inlineStringUnions, + nestUnderGroupTypes, + parseStringUnion, + propertiesAsFields, + removeNonExposedTypeLinks, + topLevelResponseFields, +}; diff --git a/packages/sdk/src/modules/connectors.types.ts b/packages/sdk/src/modules/connectors.types.ts index efe0d90..9903745 100644 --- a/packages/sdk/src/modules/connectors.types.ts +++ b/packages/sdk/src/modules/connectors.types.ts @@ -94,7 +94,7 @@ export interface ConnectorApiRequest { export interface ConnectorApiResponse { /** `true` only when the upstream API returned a 2xx status. Proxy and upstream errors are `false`. */ success: boolean; - /** How far the call progressed. Only `not_sent` proves the provider did not execute it. */ + /** How far the call progressed. */ phase: ConnectorApiResponsePhase; /** The upstream HTTP status code, or `null` when no response was received. */ status: number | null; diff --git a/packages/sdk/tests/unit/typedoc-mintlify.test.ts b/packages/sdk/tests/unit/typedoc-mintlify.test.ts index 59a5032..dafafa1 100644 --- a/packages/sdk/tests/unit/typedoc-mintlify.test.ts +++ b/packages/sdk/tests/unit/typedoc-mintlify.test.ts @@ -5,7 +5,17 @@ import { Application, ReflectionKind } from "typedoc"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { nameCallableProperties } from "../../scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-content.js"; -import { removeNonExposedTypeLinks } from "../../scripts/mintlify-post-processing/file-processing/file-processing.js"; +import { + fixInPageAnchors, + groupIntroTypes, + inlineTypesIntoFields, + inlineStringUnions, + nestUnderGroupTypes, + parseStringUnion, + propertiesAsFields, + removeNonExposedTypeLinks, + topLevelResponseFields, +} from "../../scripts/mintlify-post-processing/file-processing/file-processing.js"; // Runs TypeDoc with the Mintlify plugin on a small fixture, so each case is checked on the pages // the plugin really writes. @@ -169,3 +179,217 @@ describe("removeNonExposedTypeLinks", () => { expect(removeNonExposedTypeLinks(content, exposed)).toEqual({ content, modified: false }); }); }); + +describe("nestUnderGroupTypes", () => { + test("nests a grouped type's methods under it, and drops the bare Methods label", () => { + const section = [ + "### Question", + "#### Properties", + "## Methods", + "", + "### answer()", + "#### Parameters", + "### Entry", + "#### Properties", + ].join("\n"); + + expect(nestUnderGroupTypes(section, new Set(["Question", "Entry"])).split("\n")).toEqual([ + "### Question", + "#### Properties", + "#### answer()", + "##### Parameters", + "### Entry", + "#### Properties", + ]); + }); +}); + +describe("propertiesAsFields", () => { + test("turns a type's properties into fields and keeps its methods as headings", () => { + const content = [ + "#### Properties", + "", + "#### kind", + "", + "> **kind**: `\"choice\"`", + "", + "Kind of question.", + "", + "***", + "", + "#### answer()", + "", + "Answers.", + ].join("\n"); + + const converted = propertiesAsFields(content, false); + expect(converted).toContain(``); + expect(converted).not.toContain('name="answer()"'); + const method = converted.search(/^#### answer\(\)$/m); + expect(method).toBeGreaterThan(converted.lastIndexOf("")); + }); +}); + +describe("parseStringUnion", () => { + test("reads the values and the comment after its summary paragraph", () => { + const page = [ + "***", + "", + '> **Status** = `"ready"` | `"error"`', + "", + "What the widget is doing.", + "", + "- `ready`: Nothing is wrong.", + "- `error`: The widget failed.", + ].join("\n"); + + expect(parseStringUnion(page, "Status")).toEqual({ + values: ["ready", "error"], + details: "- `ready`: Nothing is wrong.\n- `error`: The widget failed.", + }); + }); + + test("skips other aliases, and unions that document each value as a field", () => { + expect(parseStringUnion('> **Size** = `"s"` | `number`', "Size")).toBeNull(); + expect(parseStringUnion('Codes.\n\n', "Code")).toBeNull(); + }); +}); + +describe("inlineStringUnions", () => { + test("adds the union's comment to each field that uses it, keeping the type's name", () => { + const unions = new Map([["Status", { values: ["ready", "error"], details: "- `ready`: Fine.\n- `error`: Failed." }]]); + const content = [ + '', + "", + "What the widget is doing.", + "", + "", + "", + '', + "", + "", + ].join("\n"); + + const { content: inlined, modified } = inlineStringUnions(content, unions); + expect(modified).toBe(true); + expect(inlined).toContain( + '\n\nWhat the widget is doing.\n\n- `ready`: Fine.\n- `error`: Failed.\n\n' + ); + expect(inlined).toContain('\n\n'); + }); + + test("lists the values when the comment doesn't name them all", () => { + const unions = new Map([["Unit", { values: ["day", "week", "month"], details: "" }]]); + const content = '\n\nBilling unit.\n\n'; + + expect(inlineStringUnions(content, unions).content).toBe( + "\n\nBilling unit.\n\nIt's `'day'`, `'week'`, or `'month'`.\n\n" + ); + }); +}); + +describe("topLevelResponseFields", () => { + test("returns each outermost field with its nested fields inside", () => { + const content = [ + '', + '', + "The items.", + '', + '', + "ID.", + "", + "", + "", + '', + "Status.", + "", + "", + ].join("\n"); + + const fields = topLevelResponseFields(content); + expect(fields.map(({ name, type }) => [name, type])).toEqual([ + ["items", "Item[]"], + ["status", ""ready" | "error""], + ]); + expect(fields[0].inner).toContain(''); + }); +}); + +describe("inlineTypesIntoFields", () => { + test("moves a type's fields into the field that uses it, and drops its section", () => { + const content = [ + "## Questions", + "### Choice", + '', + "", + "The entries.", + "", + "", + "### Entry", + "", + "One entry of a Choice.", + "", + '', + "", + "The text.", + "", + "", + "### Other", + ].join("\n"); + + const inlined = inlineTypesIntoFields(content, ["Entry"]); + expect(inlined).not.toContain("### Entry"); + expect(inlined).not.toContain("One entry of a Choice."); + expect(inlined).toContain( + 'The entries.\n\n\n\n\n\nThe text.\n\n\n\n\n\n' + ); + expect(inlined).toContain("### Other"); + }); +}); + +describe("groupIntroTypes", () => { + test("opens the group's section with the intro type, and links the types it names", () => { + const content = ["## Questions", "", "### Question", "", "***", "", "> **Question** = `Choice` | `Unknown`", "", "An open question.", "", "### Choice", ""].join("\n"); + + expect(groupIntroTypes(content, [{ title: "Questions", intro: "Question", types: ["Question", "Choice"] }])).toBe( + ["## Questions", "", "> **Question** = [`Choice`](#choice) | `Unknown`", "", "An open question.", "", "### Choice", ""].join("\n") + ); + }); +}); + +describe("fixInPageAnchors", () => { + test("keeps links to headings and explicit anchors", () => { + const content = ["## Send", "", "See [send](#send) and [records](#list-records)."].join("\n"); + + expect(fixInPageAnchors(content)).toBe(content); + }); + + test("follows a type's section to its new heading", () => { + const content = ["## Entity Handler Methods", "See [EntityHandler](#entityhandler)."].join("\n"); + + expect(fixInPageAnchors(content)).toContain("[EntityHandler](#entity-handler-methods)"); + }); + + test("unlinks an anchor the page doesn't have, and leaves other links alone", () => { + const content = "The [status](#status) and the [guide](/guide#status)."; + + expect(fixInPageAnchors(content)).toBe("The status and the [guide](/guide#status)."); + }); + + test("matches Mintlify's anchors for headings with dots and underscores", () => { + const content = ["### `pull_request.changed`", "See [the event](#pull_request-changed)."].join("\n"); + + expect(fixInPageAnchors(content)).toBe(content); + }); + + test("links to a field only when one field on the page has that name", () => { + const content = [ + '', + '', + '', + "See [error](#error) and [status](#status).", + ].join("\n"); + + expect(fixInPageAnchors(content)).toContain("See [error](#param-error) and status."); + }); +});