From c2b84fb3905ab2d4e322539c6ab325cc1c8ca324 Mon Sep 17 00:00:00 2001 From: amitbardos Date: Wed, 21 Oct 2026 10:32:24 +0300 Subject: [PATCH 1/2] fix(docs): returns and property headings the Mintlify pipeline got wrong - A plain return no longer takes its field name from the word before "object", "instance" or "value" in its description, which named fields "the" and "an". - An interface method's Returns keeps its @returns text when its type expands into properties. About 40 app SDK methods and two platform ones had lost it. - Code spans in comments are no longer wrapped in a second pair of backticks, which also let a function's @returns text show twice. - A function-typed property under Properties gets a #### heading like its siblings, with its Parameters and Returns nested under it, and keeps a void return type. - Links to types deleted after processing are unlinked, whatever their text. - onAppCreated documents its app parameter. Co-Authored-By: Claude Opus 5.5 --- packages/platform/src/react/chat.types.ts | 2 + .../file-processing/file-processing.js | 71 ++++++++++++++----- .../typedoc-mintlify-linked-types.js | 3 +- .../typedoc-mintlify-returns.js | 29 ++++---- .../sdk/tests/unit/typedoc-returns.test.ts | 40 ++++++++++- 5 files changed, 111 insertions(+), 34 deletions(-) diff --git a/packages/platform/src/react/chat.types.ts b/packages/platform/src/react/chat.types.ts index fc8caeab..bbddcaa9 100644 --- a/packages/platform/src/react/chat.types.ts +++ b/packages/platform/src/react/chat.types.ts @@ -313,6 +313,8 @@ export interface Base44ChatOptions { /** * Called with the new app after {@link Base44Chat.create} succeeds. The chat already follows it. * Pass its ID back as `appId` to keep it in your own state. + * + * @param app - The app that was created. */ onAppCreated?: (app: Base44App) => void; } 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 30328e45..06b0026d 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 @@ -1280,6 +1280,8 @@ function demoteNonCallableHeadings(content) { const lines = content.split("\n"); let inFence = false; let modified = false; + let section = null; + let inCallableProperty = false; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const trimmed = line.trim(); @@ -1290,6 +1292,27 @@ function demoteNonCallableHeadings(content) { if (inFence) { continue; } + if (line.startsWith("## ")) { + section = line.slice(3).trim(); + } + if (line.startsWith("### ") || line.startsWith("## ") || trimmed === "***") { + inCallableProperty = false; + } + // A property whose type is a function renders as "### name()?", like a method. Under + // "## Properties" it is a property: give it the same #### heading as its siblings, without + // the parentheses, and nest its Parameters and Returns under it. + if (inCallableProperty && /^#### (Parameters|Returns)$/.test(line)) { + lines[i] = `#${line}`; + modified = true; + continue; + } + const callableProperty = line.match(/^### (.+?)\(\)(\??)$/); + if (callableProperty && section === "Properties") { + lines[i] = `#### ${callableProperty[1]}${callableProperty[2]}`; + inCallableProperty = true; + modified = true; + continue; + } // Demote ### headings that don't have parentheses (not methods) to #### if (line.startsWith("### ")) { const headingText = line.slice(4).trim(); @@ -1996,7 +2019,7 @@ function applyTypeDefinitionGrouping(dir) { * Remove links to types that are not in the exposed types list. * Converts [TypeName](path) or [`TypeName`](path) to just TypeName or `TypeName`. */ -function removeNonExposedTypeLinks(content, exposedTypeNames) { +function removeNonExposedTypeLinks(content, exposedTypeNames, deletedTypeNames = new Set()) { // Match markdown links where the link text is a type name (with or without backticks) // Pattern: [TypeName](some/path/TypeName) or [`TypeName`](some/path/TypeName) const linkRegex = /\[(`?)([A-Z][A-Za-z0-9]*)\1\]\(([^)]+)\)/g; @@ -2027,19 +2050,31 @@ function removeNonExposedTypeLinks(content, exposedTypeNames) { } ); - return { content: updatedContent, modified }; + // A type deleted after processing has no page, whatever the link's text says. + const withoutDeletedLinks = updatedContent.replace( + /\[([^\]]+)\]\(([^)\s]+)\)/g, + (match, text, linkPath) => { + if (/^https?:\/\//.test(linkPath)) return match; + const pathEnd = linkPath.split("#")[0].split("/").pop().replace(/\.mdx?$/, ""); + if (!deletedTypeNames.has(pathEnd)) return match; + modified = true; + return text; + } + ); + + return { content: withoutDeletedLinks, modified }; } /** * Apply non-exposed type link removal to all files */ -function applyNonExposedTypeLinkRemoval(dir, exposedTypeNames) { +function applyNonExposedTypeLinkRemoval(dir, exposedTypeNames, deletedTypeNames) { if (!fs.existsSync(dir)) return; const entries = fs.readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const entryPath = path.join(dir, entry.name); if (entry.isDirectory()) { - applyNonExposedTypeLinkRemoval(entryPath, exposedTypeNames); + applyNonExposedTypeLinkRemoval(entryPath, exposedTypeNames, deletedTypeNames); } else if ( entry.isFile() && (entry.name.endsWith(".mdx") || entry.name.endsWith(".md")) @@ -2047,7 +2082,8 @@ function applyNonExposedTypeLinkRemoval(dir, exposedTypeNames) { const content = fs.readFileSync(entryPath, "utf-8"); const { content: updated, modified } = removeNonExposedTypeLinks( content, - exposedTypeNames + exposedTypeNames, + deletedTypeNames ); if (modified) { fs.writeFileSync(entryPath, updated, "utf-8"); @@ -2182,22 +2218,23 @@ function cleanupTruncatedParamTypes(dir) { /** * Main function */ +/** The types listed in types-to-delete-after-processing.json. */ +function loadTypesToDelete() { + try { + const parsed = JSON.parse(fs.readFileSync(TYPES_TO_DELETE_PATH, "utf-8")); + return new Set(Array.isArray(parsed) ? parsed : []); + } catch (e) { + // No types to delete, that's fine + return new Set(); + } +} + /** * Delete types that should not appear in navigation but were needed for inline rendering. * These types are listed in types-to-delete-after-processing.json */ function deleteTypesAfterProcessing(docsDir) { - let typesToDelete = new Set(); - try { - const content = fs.readFileSync(TYPES_TO_DELETE_PATH, "utf-8"); - const parsed = JSON.parse(content); - if (Array.isArray(parsed)) { - typesToDelete = new Set(parsed); - } - } catch (e) { - // No types to delete, that's fine - return; - } + const typesToDelete = loadTypesToDelete(); if (typesToDelete.size === 0) { return; @@ -2772,7 +2809,7 @@ function main() { applyTypeDeclarationLinking(DOCS_DIR); // Remove links to types that aren't exposed (would 404) - applyNonExposedTypeLinkRemoval(DOCS_DIR, exposedTypeNames); + applyNonExposedTypeLinkRemoval(DOCS_DIR, exposedTypeNames, loadTypesToDelete()); // Delete types that should not appear in navigation but were needed for inline rendering deleteTypesAfterProcessing(DOCS_DIR); diff --git a/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-linked-types.js b/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-linked-types.js index 25ea43c9..a6382eff 100644 --- a/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-linked-types.js +++ b/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-linked-types.js @@ -847,7 +847,8 @@ function renderCommentPart(part, context) { case "text": return part.text || ""; case "code": - return part.text ? `\`${part.text}\`` : ""; + // TypeDoc keeps the backticks in a code part's text. + return part.text || ""; case "inline-tag": if (part.tag === "@link") { const linkText = (part.text || part.target?.name || "").trim(); diff --git a/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-returns.js b/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-returns.js index 7d2296b1..cedbe611 100644 --- a/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-returns.js +++ b/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-returns.js @@ -56,7 +56,8 @@ function renderCommentParts(parts) { case "text": return part.text || ""; case "code": - return part.text ? "`" + part.text + "`" : ""; + // TypeDoc keeps the backticks in a code part's text. + return part.text || ""; case "inline-tag": if (part.tag === "@link") { return (part.text || part.target?.name || "").trim(); @@ -500,7 +501,7 @@ function rewriteReturnSections(content, options) { descriptionParts.push(leadingText); } const returnsDescription = extractReturnsDescription(page); - if (returnsDescription) { + if (returnsDescription && !descriptionParts.includes(returnsDescription)) { descriptionParts.push(returnsDescription); } if (descriptionParts.length > 0) { @@ -584,11 +585,16 @@ function rewriteReturnSections(content, options) { if (fields.length === 0 && !indexSignature) { result.push(...sectionLines); } else { - // The section's own leading type link is the fallback when the signature gave no type. + // The section's own leading type is the fallback when the signature gave no type, as for a + // property whose type is a function. + const plainType = + fields.length === 1 && fields[0].name === "result" && fields[0].type !== "any" + ? fields[0].type + : null; const typeNameForDisplay = enrichTypeNameWithGenerics( extractedTypeName || returnTypeName, returnTypeFromSignature - ) || sectionContent.match(/^\[`([^`]+)`\]\(/)?.[1]; + ) || sectionContent.match(/^\[`([^`]+)`\]\(/)?.[1] || plainType; if (typeNameForDisplay) { result.push(""); result.push(`\`${typeNameForDisplay}\``); @@ -850,9 +856,10 @@ function parseReturnFields( }); } + // The section's own text is the @returns description; keep it above the properties. return { fields: resultFields, - leadingText: "", + leadingText: description, extractedTypeName: typeInfoToUse.typeName, // Pass the type name for display typeDescription: getLinkedTypeDescription(typeInfoToUse, context) || "", @@ -861,19 +868,11 @@ function parseReturnFields( } } - // Use 'result' as default name, or extract from description - let name = "result"; - if (description) { - // Check if description contains a type hint - const typeHint = description.match(/(\w+)\s+(?:instance|object|value)/i); - if (typeHint) { - name = typeHint[1].toLowerCase(); - } - } + // A plain return has no name of its own. "result" renders as its description alone. return { fields: [ { - name, + name: "result", type, description, optional: false, diff --git a/packages/sdk/tests/unit/typedoc-returns.test.ts b/packages/sdk/tests/unit/typedoc-returns.test.ts index 689945d1..4d8d5695 100644 --- a/packages/sdk/tests/unit/typedoc-returns.test.ts +++ b/packages/sdk/tests/unit/typedoc-returns.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from "vitest"; -import { extractSignatureInfo } from "../../scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-returns.js"; +import { + convertInterfaceMethodReturns, + extractSignatureInfo, +} from "../../scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-returns.js"; describe("TypeDoc return signature parsing", () => { test("recognizes method-level generics and their linked response type", () => { @@ -21,3 +24,38 @@ describe("TypeDoc return signature parsing", () => { }); }); }); + +describe("TypeDoc interface method returns", () => { + const page = { url: "interfaces/Example.mdx" }; + + test("a plain return renders its description, not a field named after a word in it", () => { + const content = [ + "> **submit**(`values`): `Promise`\\<`void`\\>", + "", + "#### Returns", + "", + "`Promise`\\<`void`\\>", + "", + "Promise resolving once the values are sent.", + ].join("\n"); + + const result = convertInterfaceMethodReturns(content, null, page, new Set(), () => {}); + + expect(result).not.toContain(" { + const content = [ + "> `optional` **onAppCreated**: (`app`) => `void`", + "", + "#### Returns", + "", + "`void`", + ].join("\n"); + + const result = convertInterfaceMethodReturns(content, null, page, new Set(), () => {}); + + expect(result).toMatch(/#### Returns\n\n`void`/); + }); +}); From 07c62c3313951ec9f1dcd97868d65f0e7df1fac4 Mon Sep 17 00:00:00 2001 From: amitbardos Date: Wed, 21 Oct 2026 12:33:31 +0300 Subject: [PATCH 2/2] fix(docs): find callable properties from the reflection, and test every fix on real output Review: the fixes matched markdown text, and weren't all covered. - A property whose type is a function is found from the page's reflection, in the TypeDoc plugin, not from a "## Properties" section and literal Parameters and Returns headings in post-processing. Its whole subsection moves down with it, whatever its headings are called, and nothing changes for pages without such properties. - The dedupe of a Returns section's text ignores how lines wrap, and also covers a plain return whose description is the @returns sentence, which showed it twice. - The plain return's name is one constant. - file-processing.js runs only as a script, so tests can import its helpers. tests/unit/typedoc-mintlify.test.ts runs TypeDoc with the plugin on a fixture with one case per fix and checks the pages it writes. Undoing any one fix fails a test. Both references generate byte-identical to before. Co-Authored-By: Claude Opus 5.5 --- .../file-processing/file-processing.js | 30 +-- .../typedoc-mintlify-content.js | 47 +++++ .../typedoc-plugin/typedoc-mintlify-plugin.js | 5 + .../typedoc-mintlify-returns.js | 24 ++- .../fixtures/typedoc-mintlify/tsconfig.json | 11 ++ .../unit/fixtures/typedoc-mintlify/widgets.ts | 67 +++++++ .../sdk/tests/unit/typedoc-mintlify.test.ts | 171 ++++++++++++++++++ 7 files changed, 324 insertions(+), 31 deletions(-) create mode 100644 packages/sdk/tests/unit/fixtures/typedoc-mintlify/tsconfig.json create mode 100644 packages/sdk/tests/unit/fixtures/typedoc-mintlify/widgets.ts create mode 100644 packages/sdk/tests/unit/typedoc-mintlify.test.ts 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 06b0026d..fb564907 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 @@ -1280,8 +1280,6 @@ function demoteNonCallableHeadings(content) { const lines = content.split("\n"); let inFence = false; let modified = false; - let section = null; - let inCallableProperty = false; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const trimmed = line.trim(); @@ -1292,27 +1290,6 @@ function demoteNonCallableHeadings(content) { if (inFence) { continue; } - if (line.startsWith("## ")) { - section = line.slice(3).trim(); - } - if (line.startsWith("### ") || line.startsWith("## ") || trimmed === "***") { - inCallableProperty = false; - } - // A property whose type is a function renders as "### name()?", like a method. Under - // "## Properties" it is a property: give it the same #### heading as its siblings, without - // the parentheses, and nest its Parameters and Returns under it. - if (inCallableProperty && /^#### (Parameters|Returns)$/.test(line)) { - lines[i] = `#${line}`; - modified = true; - continue; - } - const callableProperty = line.match(/^### (.+?)\(\)(\??)$/); - if (callableProperty && section === "Properties") { - lines[i] = `#### ${callableProperty[1]}${callableProperty[2]}`; - inCallableProperty = true; - modified = true; - continue; - } // Demote ### headings that don't have parentheses (not methods) to #### if (line.startsWith("### ")) { const headingText = line.slice(4).trim(); @@ -2838,4 +2815,9 @@ function main() { console.log(` Documentation directory: ${DOCS_DIR}`); } -main(); +// Run as a script; tests import the helpers without processing anything. +if (process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(__filename)) { + main(); +} + +export { removeNonExposedTypeLinks }; diff --git a/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-content.js b/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-content.js index 0398d063..be69fae3 100644 --- a/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-content.js +++ b/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-content.js @@ -2,6 +2,8 @@ * Content transformation functions (examples, frontmatter, etc.) */ +import { ReflectionKind } from "typedoc"; + /** * Add headings to CodeGroups that don't have them */ @@ -131,3 +133,48 @@ title: "${escapeYaml(title)}" return frontmatter + processedContent; } + +/** + * typedoc-plugin-markdown renders a property whose type is a function like a method: "name()" at + * the level of the other members, with its Parameters and Returns one level below. Post-processing + * then moves the other properties down a level and leaves these alone, because their heading has + * "()". Name it as the property it is, and move its own subsection down with it, so it reads and + * nests like its siblings. Which members are such properties comes from the reflection, not from + * the heading text. + */ +export function nameCallableProperties(content, model) { + const callable = new Set( + (model?.children ?? []) + .filter( + (child) => + child.kind === ReflectionKind.Property && + child.type?.type === "reflection" && + (child.type.declaration?.signatures?.length ?? 0) > 0 + ) + .map((child) => child.name) + ); + if (callable.size === 0) return content; + + const lines = content.split("\n"); + let inFence = false; + // The level of the callable property being rewritten, while inside its subsection. + let propertyLevel = null; + for (let i = 0; i < lines.length; i++) { + if (lines[i].trim().startsWith("```")) inFence = !inFence; + if (inFence) continue; + const heading = lines[i].match(/^(#{1,6}) (.*)$/); + if (!heading) continue; + const level = heading[1].length; + if (propertyLevel !== null && level > propertyLevel) { + if (level < 6) lines[i] = `#${lines[i]}`; + continue; + } + propertyLevel = null; + const property = heading[2].match(/^(\w+)\(\)(\??)$/); + if (property && callable.has(property[1])) { + lines[i] = `${heading[1]} ${property[1]}${property[2]}`; + propertyLevel = level; + } + } + return lines.join("\n"); +} diff --git a/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-plugin.js b/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-plugin.js index 8098a313..462e65ba 100644 --- a/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-plugin.js +++ b/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-plugin.js @@ -21,6 +21,7 @@ import { convertExamplesToCodeGroup, addMintlifyFrontmatter, addHeadingsToCodeGroups, + nameCallableProperties, } from "./typedoc-mintlify-content.js"; // Shared flag with the post-processing script so Panel output can be toggled. @@ -92,6 +93,10 @@ export function load(app) { ); } + // 2a. A property whose type is a function is a property, not a method. After step 2, which + // finds Parameters and Returns at their usual levels. + content = nameCallableProperties(content, page.model); + // 2a. Document each value of a literal union; typedoc-plugin-markdown drops their comments if (page.model?.kind === ReflectionKind.TypeAlias) { content = appendUnionValueFields(content, page.model.type); diff --git a/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-returns.js b/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-returns.js index cedbe611..4300d58b 100644 --- a/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-returns.js +++ b/packages/sdk/scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-returns.js @@ -10,6 +10,17 @@ import { } from "./typedoc-mintlify-linked-types.js"; import { escapeAttribute } from "./typedoc-mintlify-utils.js"; +// The name of a return with no fields of its own. Such a return renders as its description alone. +const PLAIN_RETURN = "result"; + +// Whether a Returns section already shows this text, ignoring how its lines wrap. The @returns +// sentence can reach a section more than once: as the section's own text, from the reflection, +// and as a plain return's description. +function isShown(text, shown) { + const normalize = (value) => value.replace(/\s+/g, " ").trim(); + return shown.some((part) => normalize(part) === normalize(text)); +} + const PRIMITIVE_TYPES = [ "any", "string", @@ -501,7 +512,7 @@ function rewriteReturnSections(content, options) { descriptionParts.push(leadingText); } const returnsDescription = extractReturnsDescription(page); - if (returnsDescription && !descriptionParts.includes(returnsDescription)) { + if (returnsDescription && !isShown(returnsDescription, descriptionParts)) { descriptionParts.push(returnsDescription); } if (descriptionParts.length > 0) { @@ -515,7 +526,7 @@ function rewriteReturnSections(content, options) { writeLinkedTypesFile, indexSignature ); - if (fieldsBlock) { + if (fieldsBlock && !isShown(fieldsBlock, descriptionParts)) { result.push(""); result.push(fieldsBlock); result.push(""); @@ -588,7 +599,7 @@ function rewriteReturnSections(content, options) { // The section's own leading type is the fallback when the signature gave no type, as for a // property whose type is a function. const plainType = - fields.length === 1 && fields[0].name === "result" && fields[0].type !== "any" + fields.length === 1 && fields[0].name === PLAIN_RETURN && fields[0].type !== "any" ? fields[0].type : null; const typeNameForDisplay = enrichTypeNameWithGenerics( @@ -617,7 +628,7 @@ function rewriteReturnSections(content, options) { writeLinkedTypesFile, indexSignature ); - if (fieldsBlock) { + if (fieldsBlock && !isShown(fieldsBlock, descriptionParts)) { result.push(""); result.push(fieldsBlock); result.push(""); @@ -868,11 +879,10 @@ function parseReturnFields( } } - // A plain return has no name of its own. "result" renders as its description alone. return { fields: [ { - name: "result", + name: PLAIN_RETURN, type, description, optional: false, @@ -1122,7 +1132,7 @@ function formatReturnFieldsOutput( const isSingleSimpleField = fields.length === 1 && - fields[0].name === "result" && + fields[0].name === PLAIN_RETURN && (!fields[0].nested || fields[0].nested.length === 0) && !indexSignature; diff --git a/packages/sdk/tests/unit/fixtures/typedoc-mintlify/tsconfig.json b/packages/sdk/tests/unit/fixtures/typedoc-mintlify/tsconfig.json new file mode 100644 index 00000000..83615791 --- /dev/null +++ b/packages/sdk/tests/unit/fixtures/typedoc-mintlify/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "skipLibCheck": true, + "noEmit": true + }, + "include": ["widgets.ts"] +} diff --git a/packages/sdk/tests/unit/fixtures/typedoc-mintlify/widgets.ts b/packages/sdk/tests/unit/fixtures/typedoc-mintlify/widgets.ts new file mode 100644 index 00000000..db925cde --- /dev/null +++ b/packages/sdk/tests/unit/fixtures/typedoc-mintlify/widgets.ts @@ -0,0 +1,67 @@ +// Input for typedoc-mintlify.test.ts: one case per thing the Mintlify pipeline once rendered wrong. + +/** A widget the API returns. */ +export interface Widget { + /** ID of the widget, such as `w_1`. */ + id: string; + /** Name of the widget. */ + name: string; +} + +/** Options for {@link makeWidgets}. */ +export interface WidgetOptions { + /** ID to start from. */ + start: string; + /** + * Called with each new widget. + * + * @param widget - The widget that was made. + */ + onWidget?: (widget: Widget) => void; +} + +/** Calls on widgets. */ +export interface Widgets { + /** + * Gets a widget. + * + * @param id - ID of the widget. + * @returns Promise resolving to the widget. + */ + get(id: string): Promise; + /** + * Saves values. + * + * @param values - The values, keyed by name. + * @returns Promise resolving once the values are sent. + */ + save(values: Record): Promise; + /** + * Describes a widget. + * + * @returns Promise resolving to a string or an object. + */ + describe(): Promise; +} + +/** + * Makes widgets. + * + * @param options - Where to start, and what to call for each widget. + * @returns The widgets, with `get` and `save`. + */ +export function makeWidgets(options: WidgetOptions): Widgets { + void options; + throw new Error("fixture"); +} + +/** + * Makes one widget. + * + * @param name - Name of the widget. + * @returns Promise resolving to the widget, with its `id`. + */ +export async function makeWidget(name: string): Promise { + void name; + throw new Error("fixture"); +} diff --git a/packages/sdk/tests/unit/typedoc-mintlify.test.ts b/packages/sdk/tests/unit/typedoc-mintlify.test.ts new file mode 100644 index 00000000..59a5032d --- /dev/null +++ b/packages/sdk/tests/unit/typedoc-mintlify.test.ts @@ -0,0 +1,171 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +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"; + +// Runs TypeDoc with the Mintlify plugin on a small fixture, so each case is checked on the pages +// the plugin really writes. +const sdkDir = path.resolve(import.meta.dirname, "../.."); +const fixtureDir = path.join(import.meta.dirname, "fixtures/typedoc-mintlify"); +let out: string; + +function page(name: string): string { + return readFileSync(path.join(out, `${name}.mdx`), "utf8"); +} + +/** The text of one Returns section: from its heading to the next heading or member separator. */ +function returnsOf(content: string, heading: string, after = ""): string { + const start = content.indexOf(heading, after ? content.indexOf(after) : 0); + expect(start, `${heading} after ${after || "the start"}`).toBeGreaterThanOrEqual(0); + const rest = content.slice(start + heading.length); + const end = rest.search(/^(#{1,6} |\*\*\*)/m); + return end === -1 ? rest : rest.slice(0, end); +} + +beforeAll(async () => { + out = mkdtempSync(path.join(tmpdir(), "typedoc-mintlify-")); + const app = await Application.bootstrapWithPlugins({ + entryPoints: [path.join(fixtureDir, "widgets.ts")], + tsconfig: path.join(fixtureDir, "tsconfig.json"), + plugin: [ + "typedoc-plugin-markdown", + path.join(sdkDir, "scripts/mintlify-post-processing/typedoc-plugin/typedoc-mintlify-plugin.js"), + ], + out, + fileExtension: ".mdx", + readme: "none", + hideBreadcrumbs: true, + disableSources: true, + hidePageTitle: true, + sort: ["source-order"], + entryFileName: "README.mdx", + logLevel: "Error", + }); + const project = await app.convert(); + if (!project) throw new Error("TypeDoc could not convert the fixture"); + await app.generateOutputs(project); +}, 60_000); + +afterAll(() => { + rmSync(out, { recursive: true, force: true }); +}); + +describe("Returns sections", () => { + test("a method whose return expands into properties keeps its @returns text", () => { + const returns = returnsOf(page("interfaces/Widgets"), "#### Returns", "### get()"); + + expect(returns).toContain("Promise resolving to the widget."); + expect(returns).toMatch(/\s+ID of the widget, such as `w_1`\./); + }); + + test("a plain return is its description, not a field named after a word in it", () => { + const widgets = page("interfaces/Widgets"); + + for (const [method, description] of [ + ["### save()", "Promise resolving once the values are sent."], + ["### describe()", "Promise resolving to a string or an object."], + ]) { + const returns = returnsOf(widgets, "#### Returns", method); + expect(returns).toContain(description); + expect(returns).not.toContain(" { + const returns = returnsOf(page("functions/makeWidgets"), "## Returns"); + + expect(returns.split("The widgets, with `get` and `save`.")).toHaveLength(2); + expect(returns).not.toContain("``"); + }); + + test("a function whose return expands into properties shows its @returns text once", () => { + const returns = returnsOf(page("functions/makeWidget"), "## Returns"); + + expect(returns.split("Promise resolving to the widget, with its `id`.")).toHaveLength(2); + expect(returns).toContain(' { + test("is headed as a property, with its own subsection nested under it", () => { + const options = page("interfaces/WidgetOptions"); + + expect(options).toMatch(/^### onWidget\?$/m); + expect(options).not.toContain("onWidget()"); + expect(returnsOf(options, "##### Returns", "### onWidget?")).toContain("`void`"); + expect(options).toMatch(/^##### Parameters$/m); + }); + + test("documents its parameter", () => { + const options = page("interfaces/WidgetOptions"); + + expect(options).toMatch(/\s+The widget that was made\./); + }); +}); + +describe("nameCallableProperties", () => { + const property = (name: string, callable: boolean) => ({ + name, + kind: ReflectionKind.Property, + type: callable ? { type: "reflection", declaration: { signatures: [{}] } } : { type: "intrinsic" }, + }); + + test("renames only the members the reflection says are function-typed properties", () => { + const model = { + children: [property("onDone", true), property("label", false), { name: "run", kind: ReflectionKind.Method }], + }; + const content = ["### onDone()?", "#### Parameters", "### run()", "#### Parameters", "### label"].join("\n"); + + expect(nameCallableProperties(content, model).split("\n")).toEqual([ + "### onDone?", + "##### Parameters", + "### run()", + "#### Parameters", + "### label", + ]); + }); + + test("ends the property's subsection at its next sibling, and skips code", () => { + const model = { children: [property("onDone", true)] }; + const content = ["### onDone()", "```md", "#### not a heading", "```", "#### Returns", "### next", "#### Returns"].join("\n"); + + expect(nameCallableProperties(content, model).split("\n")).toEqual([ + "### onDone", + "```md", + "#### not a heading", + "```", + "##### Returns", + "### next", + "#### Returns", + ]); + }); + + test("leaves a page without such properties untouched", () => { + expect(nameCallableProperties("### run()", undefined)).toBe("### run()"); + expect(nameCallableProperties("### run()", { children: [] })).toBe("### run()"); + }); +}); + +describe("removeNonExposedTypeLinks", () => { + const exposed = new Set(["Gone", "Kept"]); + + test("unlinks a type deleted after processing, whatever the link's text", () => { + const content = "The [`connection`](Gone) and [a page](../interfaces/Gone#field)."; + + expect(removeNonExposedTypeLinks(content, exposed, new Set(["Gone"]))).toEqual({ + content: "The `connection` and a page.", + modified: true, + }); + }); + + test("keeps links to pages that stay, and external links", () => { + const content = "See [Kept](Kept) and [Gone](https://example.com/Gone)."; + + expect(removeNonExposedTypeLinks(content, exposed, new Set(["Gone"]))).toEqual({ content, modified: false }); + expect(removeNonExposedTypeLinks(content, exposed)).toEqual({ content, modified: false }); + }); +});