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)";
+ }
+ >();
});
});