diff --git a/website/README.md b/website/README.md index 4234180a6b0..99bce7b665b 100644 --- a/website/README.md +++ b/website/README.md @@ -157,6 +157,75 @@ For an overview of how to leverage React components in MDX documentation, see [h React components should be saved under `src/components/...`. They can be imported in other components, pages, and documents via `@site/src/components/...`. +##### Linking to API documentation + +Use `PackageLink` and `ApiLink` to link to generated API documentation from an MDX document. +These components select the API documentation for the active documentation version. + +Import the components that the document uses: + +```tsx +import { ApiLink, PackageLink } from "@site/src/components/shortLinks"; +``` + +Use the unscoped package name with both components. +Use a TSDoc declaration reference for the `api` value of `ApiLink`: + +```mdx +See and . +``` + +Add `newApi` when the current published API model does not contain a new package or API. +The component renders its content as inline code until the target exists. +The component writes a build debug message when it renders inline code. +When the target exists, the component renders a link. +`newApi` can be removed once the target exists. + +```mdx + + + +``` + +The `newApi` shorthand is equivalent to `newApi={true}`. +Invalid references and ambiguous references remain build errors. +Use a TSDoc selector to resolve an ambiguous API kind, such as `(NewApi:class)` or `(NewApi:interface)`. + +For an API rename, set `api` to an object with `previous` and `new` declaration references: + +```mdx + +``` + +The component tries `new` first. +It uses `previous` while the published model contains only the old API. +When child content is omitted, the component displays the name of the API that resolves. + +```mdx + +``` + +For a package rename, set `package` to an object with `previous` and `new` names: + +```mdx + +``` + +The component uses `previous` while the published model contains only the old package. + +```mdx + +``` + +All transition modes permit explicit rich child content. +The component preserves the child content when it renders a link or inline code: + +```mdx + + **New API** + +``` + #### Comments A common pattern for adding inline comments in `.md` files looks like: diff --git a/website/docs/data-structures/tree/schema-evolution/feature-flag-schema-upgrades.mdx b/website/docs/data-structures/tree/schema-evolution/feature-flag-schema-upgrades.mdx index 265baa27bd4..8063c1aa8a1 100644 --- a/website/docs/data-structures/tree/schema-evolution/feature-flag-schema-upgrades.mdx +++ b/website/docs/data-structures/tree/schema-evolution/feature-flag-schema-upgrades.mdx @@ -167,8 +167,7 @@ const config = new TreeViewConfiguration({ schema: Board }); Once a document's stored schema has been upgraded, it cannot be downgraded. However, rolling back a feature flag is safe. With the feature flag rolled back, new documents and any documents that haven't been upgraded yet will not have the upgrade enabled. -{/* TODO: link directly to `isStagedUpgradeEnabled` property once it has been released. */} -You can use 's `isStagedUpgradeEnabled` property to check whether a document has already been upgraded and conditionally include that upgrade token for those documents. +You can use to check whether a document has already been upgraded and conditionally include that upgrade token for those documents. Disabling the feature flag prevents **new** documents from being upgraded, but documents that have already been upgraded retain the new schema. This means: diff --git a/website/src/apiLinkReference.ts b/website/src/apiLinkReference.ts index 09ed2511ecf..b7e41324c74 100644 --- a/website/src/apiLinkReference.ts +++ b/website/src/apiLinkReference.ts @@ -185,6 +185,26 @@ export interface ResolvedApiLink { readonly defaultText: string; } +/** + * The result of trying to resolve an API declaration reference that is not documented. + */ +export interface UnresolvedApiLink { + /** + * Indicates that the API declaration reference did not resolve. + */ + readonly found: false; + + /** + * The declaration's dotted member path with selector syntax omitted. + */ + readonly defaultText: string; +} + +/** + * The result of trying to resolve an API declaration reference. + */ +export type ApiLinkResolution = (ResolvedApiLink & { readonly found: true }) | UnresolvedApiLink; + /** * Resolves an API declaration reference from one version's API link manifest. */ @@ -193,11 +213,28 @@ export function resolveApiLinkTarget( packageName: string, api: string, ): ResolvedApiLink { + const result = tryResolveApiLinkTarget(manifest, packageName, api); + if (!result.found) { + throw new Error(`No API documentation found for "${packageName}/${api}".`); + } + return { target: result.target, defaultText: result.defaultText }; +} + +/** + * Tries to resolve an API declaration reference from one version's API link manifest. + * + * @remarks Parsing errors, unsupported selectors, and ambiguous references remain errors. + */ +export function tryResolveApiLinkTarget( + manifest: Readonly, + packageName: string, + api: string, +): ApiLinkResolution { const referencePath = parseApiReference(api); const apiName = referencePath.map((segment) => segment.name).join("."); const candidates = manifest[packageName]?.[apiName]; if (candidates === undefined) { - throw new Error(`No API documentation found for "${packageName}/${api}".`); + return { found: false, defaultText: apiName }; } let matchingCandidates = candidates.filter((candidate) => @@ -207,7 +244,7 @@ export function resolveApiLinkTarget( ), ); if (matchingCandidates.length === 0) { - throw new Error(`No API documentation found for "${packageName}/${api}".`); + return { found: false, defaultText: apiName }; } const requestedOverload = referencePath.at(-1)?.overloadIndex; @@ -216,7 +253,7 @@ export function resolveApiLinkTarget( (candidate) => candidate.path.at(-1)?.overloadIndex === requestedOverload, ); if (matchingCandidates.length === 0) { - throw new Error(`No API documentation found for "${packageName}/${api}".`); + return { found: false, defaultText: apiName }; } } @@ -241,15 +278,15 @@ export function resolveApiLinkTarget( (candidate) => candidate.path.at(-1)?.overloadIndex === 1, ); if (overloadOne !== undefined) { - return { target: overloadOne, defaultText: apiName }; + return { found: true, target: overloadOne, defaultText: apiName }; } } const target = matchingCandidates[0]; if (target === undefined) { - throw new Error(`No API documentation found for "${packageName}/${api}".`); + return { found: false, defaultText: apiName }; } - return { target, defaultText: apiName }; + return { found: true, target, defaultText: apiName }; } function parseApiReference(api: string): readonly ApiReferenceSegment[] { diff --git a/website/src/components/shortLinks.tsx b/website/src/components/shortLinks.tsx index 92b5f03173b..e927873d811 100644 --- a/website/src/components/shortLinks.tsx +++ b/website/src/components/shortLinks.tsx @@ -3,15 +3,33 @@ * Licensed under the MIT License. */ -import { useActivePluginAndVersion } from "@docusaurus/plugin-content-docs/client"; +import { + type GlobalVersion, + useActivePluginAndVersion, +} from "@docusaurus/plugin-content-docs/client"; import { usePluginData } from "@docusaurus/useGlobalData"; import type { ReactNode } from "react"; import { type ApiLinkManifests, apiLinkManifestPluginName } from "../apiLinkManifest"; -import { type ApiDeclarationReference, resolveApiLinkTarget } from "../apiLinkReference"; +import { type ApiDeclarationReference, tryResolveApiLinkTarget } from "../apiLinkReference"; import type { SiteVersion } from "../utilityTypes"; -// TODO: how will versioning interact with these? +const emittedTransitionDiagnostics = new Set(); + +/** + * The package names used while API documentation transitions through a package rename. + */ +export interface PackageLinkRename { + /** + * The package name in the current published API documentation. + */ + previous: string; + + /** + * The new package name that will replace {@link PackageLinkRename.previous}. + */ + new: string; +} /** * {@link PackageLink} input props. @@ -19,40 +37,113 @@ import type { SiteVersion } from "../utilityTypes"; export interface PackageLinkProps { /** * Contents to display within the link. - * @defaultValue {@link PackageLinkProps.package} + * When omitted during a package rename, the new package name is displayed. */ children?: ReactNode; /** - * The unscoped name of the package whose API documentation is linked. + * The unscoped package name, or the previous and new names for a staged package rename. */ - package: string; + package: string | PackageLinkRename; - headingId?: string; + /** + * Permits the package to be absent from the published API documentation. + * + * @remarks Remove this prop when the package API documentation is available. + */ + newApi?: boolean; } /** * A convenient mechanism for linking to a package's API documentation. */ export function PackageLink({ - headingId, - package: packageName, + package: packageNameOrRename, children, + newApi = false, }: PackageLinkProps): JSX.Element { - const root = useLinkPathBase(); - const headingPostfix = headingId === undefined ? "" : `#${headingId}`; - return {children ?? packageName}; + const rename = typeof packageNameOrRename === "string" ? undefined : packageNameOrRename; + const packageName = + typeof packageNameOrRename === "string" + ? packageNameOrRename + : packageNameOrRename.previous; + const newPackageName = rename?.new; + const needsManifest = newApi || rename !== undefined; + const { activeVersion, manifest } = useApiLinkContext("PackageLink", needsManifest); + const root = `${activeVersion.path}/api/`; + if (!needsManifest) { + return {children ?? packageName}; + } + + if (manifest === undefined) { + throw new Error( + `No API link manifest found for documentation version "${activeVersion.name}".`, + ); + } + + const defaultText = newPackageName ?? packageName; + if (newPackageName !== undefined && manifest[newPackageName] !== undefined) { + warnOnce( + `PackageLink|rename|${activeVersion.name}|${newPackageName}`, + `[PackageLink] New package name "${newPackageName}" exists in API documentation version "${activeVersion.name}". Set package="${newPackageName}".`, + ); + return {children ?? defaultText}; + } + + if (manifest[packageName] !== undefined) { + if (rename === undefined && newApi) { + warnOnce( + `PackageLink|newApi|${activeVersion.name}|${packageName}`, + `[PackageLink] Package "${packageName}" exists in API documentation version "${activeVersion.name}". Remove the newApi prop.`, + ); + } else if (newPackageName !== undefined) { + debugOnce( + `PackageLink|rename-fallback|${activeVersion.name}|${packageName}|${newPackageName}`, + `[PackageLink] New package name "${newPackageName}" does not exist in API documentation version "${activeVersion.name}". Linking to previous package "${packageName}".`, + ); + } + return {children ?? defaultText}; + } + + if (newApi) { + debugOnce( + `PackageLink|code-fallback|${activeVersion.name}|${defaultText}`, + `[PackageLink] New package "${defaultText}" does not exist in API documentation version "${activeVersion.name}". Rendering inline code placeholder.`, + ); + return {children ?? defaultText}; + } + + return {children ?? defaultText}; +} + +/** + * The declaration references used while API documentation transitions through an API rename. + */ +export interface ApiLinkRename< + TPreviousApiSelector extends string = string, + TNewApiSelector extends string = string, +> { + /** + * The API declaration reference in the current published API documentation. + */ + previous: ApiDeclarationReference; + + /** + * The new API declaration reference that will replace {@link ApiLinkRename.previous}. + */ + new: ApiDeclarationReference; } /** * {@link ApiLink} input props. */ -export interface ApiLinkProps { +export interface ApiLinkProps< + TApiSelector extends string = string, + TNewApiSelector extends string = string, +> { /** * Contents to display within the link. - * When omitted, the API declaration reference is displayed without selectors. - * - * @defaultValue {@link ApiLinkProps.api} + * When omitted, the resolved API declaration reference is displayed without selectors. */ children?: ReactNode; @@ -62,16 +153,17 @@ export interface ApiLinkProps { package: string; /** - * A TSDoc-style declaration reference identifying the API item within the package. + * A TSDoc-style declaration reference, or the previous and new references for a staged API + * rename. */ - api: ApiDeclarationReference; + api: ApiDeclarationReference | ApiLinkRename; /** - * Overrides the generated heading ID for the target API item. + * Permits the API to be absent from the published API documentation. * - * @deprecated Use a qualified {@link ApiLinkProps.api} reference to link directly to a member. + * @remarks Remove this prop when the API documentation is available. */ - headingId?: string; + newApi?: boolean; } /** @@ -79,48 +171,115 @@ export interface ApiLinkProps { * * @throws If the requested API cannot be uniquely resolved in the active documentation version. */ -export function ApiLink({ - api, +export function ApiLink({ + api: apiOrRename, package: packageName, - headingId, + newApi = false, children, -}: ApiLinkProps): JSX.Element { - const activePluginAndVersion = useActivePluginAndVersion(); +}: ApiLinkProps): JSX.Element { + const { activeVersion, manifest } = useApiLinkContext("ApiLink", true); + if (manifest === undefined) { + throw new Error( + `No API link manifest found for documentation version "${activeVersion.name}".`, + ); + } + + const rename = typeof apiOrRename === "string" ? undefined : apiOrRename; + const api = typeof apiOrRename === "string" ? apiOrRename : apiOrRename.previous; + const newApiReference = rename?.new; + const replacementResult = + newApiReference === undefined + ? undefined + : tryResolveApiLinkTarget(manifest, packageName, newApiReference); + const result = tryResolveApiLinkTarget(manifest, packageName, api); + if (replacementResult?.found === true) { + warnOnce( + `ApiLink|rename|${activeVersion.name}|${packageName}|${newApiReference}`, + `[ApiLink] New API name "${packageName}/${newApiReference}" exists in API documentation version "${activeVersion.name}". Set api="${newApiReference}".`, + ); + return renderApiLink( + activeVersion.path, + replacementResult.target, + children ?? replacementResult.defaultText, + ); + } + + if (!result.found) { + if (newApi) { + const unresolvedApi = newApiReference ?? api; + debugOnce( + `ApiLink|code-fallback|${activeVersion.name}|${packageName}|${unresolvedApi}`, + `[ApiLink] New API "${packageName}/${unresolvedApi}" does not exist in API documentation version "${activeVersion.name}". Rendering inline code placeholder.`, + ); + return {children ?? replacementResult?.defaultText ?? result.defaultText}; + } + throw new Error(`No API documentation found for "${packageName}/${api}".`); + } + + if (rename === undefined && newApi) { + warnOnce( + `ApiLink|newApi|${activeVersion.name}|${packageName}|${api}`, + `[ApiLink] API "${packageName}/${api}" exists in API documentation version "${activeVersion.name}". Remove the newApi prop.`, + ); + } else if (newApiReference !== undefined) { + debugOnce( + `ApiLink|rename-fallback|${activeVersion.name}|${packageName}|${api}|${newApiReference}`, + `[ApiLink] New API name "${packageName}/${newApiReference}" does not exist in API documentation version "${activeVersion.name}". Linking to previous API "${packageName}/${api}".`, + ); + } + + return renderApiLink(activeVersion.path, result.target, children ?? result.defaultText); +} + +function renderApiLink( + versionPath: string, + target: { readonly documentPath: string; readonly headingId?: string }, + children: ReactNode, +): JSX.Element { + const headingPostfix = target.headingId === undefined ? "" : `#${target.headingId}`; + return {children}; +} + +function useApiLinkContext( + componentName: "ApiLink" | "PackageLink", + requireManifest: boolean, +): { + readonly activeVersion: GlobalVersion; + readonly manifest: ApiLinkManifests[SiteVersion] | undefined; +} { + const activeVersion = useActivePluginAndVersion()?.activeVersion; const manifests = usePluginData(apiLinkManifestPluginName, undefined, { - failfast: true, - }) as ApiLinkManifests; - const activeVersion = activePluginAndVersion?.activeVersion; + failfast: requireManifest, + }) as ApiLinkManifests | undefined; if (activeVersion === undefined) { - throw new Error("ApiLink must be rendered within a versioned Docusaurus document."); + throw new Error( + `${componentName} must be rendered within a versioned Docusaurus document.`, + ); } - const manifest = manifests[activeVersion.name as SiteVersion]; - if (manifest === undefined) { + const manifest = manifests?.[activeVersion.name as SiteVersion]; + if (requireManifest && manifest === undefined) { throw new Error( `No API link manifest found for documentation version "${activeVersion.name}".`, ); } + return { activeVersion, manifest }; +} - const { target, defaultText } = resolveApiLinkTarget(manifest, packageName, api); - const targetHeadingId = headingId ?? target.headingId; - const headingPostfix = targetHeadingId === undefined ? "" : `#${targetHeadingId}`; - return ( - - {children ?? defaultText} - - ); +function warnOnce(key: string, message: string): void { + logOnce(key, message, console.warn); } -/** - * Gets the base URI for a link to API docs. - * Accounts for versioning. - */ -function useLinkPathBase(): string { - const activeVersion = useActivePluginAndVersion()?.activeVersion; - if (activeVersion === undefined) { - throw new Error("PackageLink must be rendered within a versioned Docusaurus document."); +function debugOnce(key: string, message: string): void { + logOnce(key, message, console.debug); +} + +function logOnce(key: string, message: string, log: (message: string) => void): void { + if (typeof window !== "undefined" || emittedTransitionDiagnostics.has(key)) { + return; } - return `${activeVersion.path}/api/`; + emittedTransitionDiagnostics.add(key); + log(message); } /** diff --git a/website/test/unit/shortLinks.test.ts b/website/test/unit/shortLinks.test.ts index 4349b86f5f8..064dbd6eb1d 100644 --- a/website/test/unit/shortLinks.test.ts +++ b/website/test/unit/shortLinks.test.ts @@ -5,7 +5,7 @@ import type { GlobalVersion } from "@docusaurus/plugin-content-docs/client"; import { ApiItemKind } from "@fluid-tools/api-markdown-documenter"; -import type { ReactElement, ReactNode } from "react"; +import { createElement, type ReactElement, type ReactNode } from "react"; import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest"; const { useActivePluginAndVersion, usePluginData } = vi.hoisted(() => ({ @@ -106,6 +106,8 @@ function renderApiLink(props: ApiLinkProps): { href: string; children: ReactNode describe("PackageLink", () => { afterEach(() => { useActivePluginAndVersion.mockReset(); + usePluginData.mockReset(); + vi.restoreAllMocks(); }); it("uses the configured path for the active documentation version", () => { @@ -121,12 +123,116 @@ describe("PackageLink", () => { children: "example", }); }); + + it("renders inline code when a new package is not documented", () => { + useVersion("current", "/docs"); + useMockApiLinkManifests(); + const debug = vi.spyOn(console, "debug").mockImplementation(() => {}); + + const link = PackageLink({ package: "new-package", newApi: true }); + + expect({ type: link.type, children: link.props.children }).toEqual({ + type: "code", + children: "new-package", + }); + expect(debug).toHaveBeenCalledWith( + '[PackageLink] New package "new-package" does not exist in API documentation version "current". Rendering inline code placeholder.', + ); + }); + + it("preserves rich children when a new package is not documented", () => { + useVersion("current", "/docs"); + useMockApiLinkManifests(); + const children = createElement("strong", undefined, "New package"); + + const link = PackageLink({ package: "new-package", newApi: true, children }); + + expect(link.type).toBe("code"); + expect(link.props.children).toBe(children); + }); + + it("links and warns when a new package is documented", () => { + useVersion("current", "/docs"); + useMockApiLinkManifests(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const link = PackageLink({ package: "example", newApi: true }); + PackageLink({ package: "example", newApi: true }); + + expect({ type: link.type, href: link.props.href, children: link.props.children }).toEqual({ + type: "a", + href: "/docs/api/example", + children: "example", + }); + expect(warn).toHaveBeenCalledWith( + '[PackageLink] Package "example" exists in API documentation version "current". Remove the newApi prop.', + ); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it("falls back to the original package until its replacement is documented", () => { + useVersion("current", "/docs"); + useMockApiLinkManifests(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const debug = vi.spyOn(console, "debug").mockImplementation(() => {}); + + const link = PackageLink({ + package: { previous: "example", new: "replacement" }, + }); + + expect({ href: link.props.href, children: link.props.children }).toEqual({ + href: "/docs/api/example", + children: "replacement", + }); + expect(warn).not.toHaveBeenCalled(); + expect(debug).toHaveBeenCalledWith( + '[PackageLink] New package name "replacement" does not exist in API documentation version "current". Linking to previous package "example".', + ); + }); + + it("uses and warns about a documented replacement package", () => { + useVersion("current", "/docs"); + useMockApiLinkManifests(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const link = PackageLink({ + package: { previous: "old-package", new: "example" }, + }); + + expect({ href: link.props.href, children: link.props.children }).toEqual({ + href: "/docs/api/example", + children: "example", + }); + expect(warn).toHaveBeenCalledWith( + '[PackageLink] New package name "example" exists in API documentation version "current". Set package="example".', + ); + }); + + it("renders inline code when neither package rename target is documented", () => { + useVersion("current", "/docs"); + useMockApiLinkManifests(); + const debug = vi.spyOn(console, "debug").mockImplementation(() => {}); + + const link = PackageLink({ + package: { previous: "old-package", new: "renamed-package" }, + newApi: true, + }); + + expect({ type: link.type, children: link.props.children }).toEqual({ + type: "code", + children: "renamed-package", + }); + expect(debug).toHaveBeenCalledWith( + '[PackageLink] New package "renamed-package" does not exist in API documentation version "current". Rendering inline code placeholder.', + ); + }); }); describe("ApiLink", () => { afterEach(() => { useActivePluginAndVersion.mockReset(); usePluginData.mockReset(); + vi.restoreAllMocks(); }); it("uses the active version manifest and versioned path", () => { @@ -174,20 +280,142 @@ describe("ApiLink", () => { }); }); - it("allows a compatibility heading to override the manifest heading", () => { + it("renders inline code with rich children when a new API is not documented", () => { useVersion("current", "/docs"); useMockApiLinkManifests(); + const children = createElement("strong", undefined, "New API"); + const debug = vi.spyOn(console, "debug").mockImplementation(() => {}); - expect( - renderApiLink({ + const link = ApiLink({ package: "example", api: "Missing", newApi: true, children }); + + expect(link.type).toBe("code"); + expect(link.props.children).toBe(children); + expect(debug).toHaveBeenCalledWith( + '[ApiLink] New API "example/Missing" does not exist in API documentation version "current". Rendering inline code placeholder.', + ); + }); + + it("links and warns when a new API is documented", () => { + useVersion("current", "/docs"); + useMockApiLinkManifests(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const link = renderApiLink({ package: "example", api: "(Widget:class)", newApi: true }); + + expect(link).toEqual({ + href: "/docs/api/example/widget-class", + children: "Widget", + }); + expect(warn).toHaveBeenCalledWith( + '[ApiLink] API "example/(Widget:class)" exists in API documentation version "current". Remove the newApi prop.', + ); + }); + + it("falls back to the original API until its replacement is documented", () => { + useVersion("current", "/docs"); + useMockApiLinkManifests(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const debug = vi.spyOn(console, "debug").mockImplementation(() => {}); + + const link = renderApiLink({ + package: "example", + api: { previous: "(Widget:class)", new: "Replacement" }, + }); + + expect(link).toEqual({ + href: "/docs/api/example/widget-class", + children: "Widget", + }); + expect(warn).not.toHaveBeenCalled(); + expect(debug).toHaveBeenCalledWith( + '[ApiLink] New API name "example/Replacement" does not exist in API documentation version "current". Linking to previous API "example/(Widget:class)".', + ); + }); + + it("preserves rich children while an API rename is staged", () => { + useVersion("current", "/docs"); + useMockApiLinkManifests(); + const children = createElement("strong", undefined, "Renamed API"); + + const link = ApiLink({ + package: "example", + api: { previous: "(Widget:class)", new: "Replacement" }, + children, + }); + + expect(link.type).toBe("a"); + expect(link.props.children).toBe(children); + }); + + it("renders inline code when neither API rename target is documented", () => { + useVersion("current", "/docs"); + useMockApiLinkManifests(); + const debug = vi.spyOn(console, "debug").mockImplementation(() => {}); + + const link = ApiLink({ + package: "example", + api: { previous: "OldMissing", new: "NewMissing" }, + newApi: true, + }); + + expect({ type: link.type, children: link.props.children }).toEqual({ + type: "code", + children: "NewMissing", + }); + expect(debug).toHaveBeenCalledWith( + '[ApiLink] New API "example/NewMissing" does not exist in API documentation version "current". Rendering inline code placeholder.', + ); + }); + + it("uses and warns about a documented replacement API", () => { + useVersion("current", "/docs"); + useMockApiLinkManifests(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const link = renderApiLink({ + package: "example", + api: { previous: "Missing", new: "(Widget:class)" }, + }); + + expect(link).toEqual({ + href: "/docs/api/example/widget-class", + children: "Widget", + }); + expect(warn).toHaveBeenCalledWith( + '[ApiLink] New API name "example/(Widget:class)" exists in API documentation version "current". Set api="(Widget:class)".', + ); + }); + + it("does not let newApi hide an invalid replacement reference", () => { + useVersion("current", "/docs"); + useMockApiLinkManifests(); + + expect(() => + ApiLink({ package: "example", - api: "(Widget:class).run", - headingId: "legacy-heading", + api: { previous: "Missing", new: "(Widget:static)" as string }, + newApi: true, }), - ).toEqual({ - href: "/docs/api/example/widget-class#legacy-heading", - children: "Widget.run", - }); + ).toThrowError( + 'Unsupported selector "static" in API declaration reference "(Widget:static)".', + ); + }); + + it("does not let a replacement hide an invalid original reference", () => { + useVersion("current", "/docs"); + useMockApiLinkManifests(); + + expect(() => + ApiLink({ + package: "example", + api: { + previous: "(Widget:static)" as string, + new: "(Widget:class)", + }, + }), + ).toThrowError( + 'Unsupported selector "static" in API declaration reference "(Widget:static)".', + ); }); it("throws when rendered outside a versioned Docusaurus document", () => { @@ -278,5 +506,12 @@ describe("ApiDeclarationReference", () => { expectTypeOf>().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); + expectTypeOf["api"]>().toEqualTypeOf< + | "Widget" + | { + previous: "Widget"; + new: "(Replacement:class)"; + } + >(); }); });