diff --git a/scripts/lib/api/generateApiComponents.test.ts b/scripts/lib/api/generateApiComponents.test.ts
new file mode 100644
index 00000000000..d4e3d8e2290
--- /dev/null
+++ b/scripts/lib/api/generateApiComponents.test.ts
@@ -0,0 +1,202 @@
+// This code is a Qiskit project.
+//
+// (C) Copyright IBM 2024.
+//
+// This code is licensed under the Apache License, Version 2.0. You may
+// obtain a copy of this license in the LICENSE file in the root directory
+// of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+//
+// Any modifications or derivative works of this code must retain this
+// copyright notice, and modified files need to carry a notice indicating
+// that they have been altered from the originals.
+
+import { expect, test } from "@jest/globals";
+
+import {
+ ComponentProps,
+ prepareGitHubLink,
+ htmlSignatureToMd,
+ addExtraSignatures,
+ createOpeningTag,
+} from "./generateApiComponents";
+import { APOSTROPHE_HEX_CODE } from "../stringUtils";
+import { CheerioDoc } from "../testUtils";
+
+const RAW_SIGNATURE_EXAMPLE = `Estimator.run(circuits, observables, parameter_values=None, **kwargs)`;
+
+test("htmlSignatureToMd", async () => {
+ const result = await htmlSignatureToMd(RAW_SIGNATURE_EXAMPLE);
+ expect(result).toEqual(
+ `Estimator.run(circuits, observables, parameter_values=None, **kwargs)`,
+ );
+});
+
+describe("addExtraSignatures()", () => {
+ test("Function with overload signatures", () => {
+ const componentProps = {
+ id: "qiskit_ibm_runtime.Estimator.run",
+ rawSignature: "first signature",
+ };
+ const extraRawSignatures = [
+ {
+ id: "qiskit_ibm_runtime.Estimator.run",
+ rawSignature: "second signature",
+ },
+ ];
+
+ const resultExpected = {
+ id: "qiskit_ibm_runtime.Estimator.run",
+ rawSignature: "first signature",
+ extraRawSignatures: ["second signature"],
+ };
+ addExtraSignatures(componentProps, extraRawSignatures);
+ expect(componentProps).toEqual(resultExpected);
+ });
+
+ test("Function without overload signatures", () => {
+ const componentProps = {
+ id: "qiskit_ibm_runtime.Estimator.run",
+ rawSignature: "first signature",
+ };
+ const extraRawSignatures: ComponentProps[] = [];
+
+ addExtraSignatures(componentProps, extraRawSignatures);
+ expect(componentProps).toEqual(componentProps);
+ });
+});
+
+describe("createOpeningTag()", () => {
+ test("Create Function tag with some props", async () => {
+ const componentProps = {
+ id: "qiskit_ibm_runtime.Estimator.run",
+ name: "run",
+ rawSignature: RAW_SIGNATURE_EXAMPLE,
+ };
+
+ const tag = await createOpeningTag("Function", componentProps);
+ expect(tag).toEqual(`
+ `);
+ });
+
+ test("Create Function tag with overloaded signatures", async () => {
+ const componentProps = {
+ id: "qiskit_ibm_runtime.Estimator.run",
+ name: "run",
+ rawSignature: RAW_SIGNATURE_EXAMPLE,
+ extraRawSignatures: [RAW_SIGNATURE_EXAMPLE, RAW_SIGNATURE_EXAMPLE],
+ };
+
+ const tag = await createOpeningTag("Function", componentProps);
+ expect(tag).toEqual(`
+ `);
+ });
+
+ test("Create Attribute tag with default value and type hint", async () => {
+ const componentProps = {
+ id: "qiskit.circuit.QuantumCircuit.instance",
+ name: "instance",
+ attributeTypeHint: "str | None",
+ attributeValue: "None",
+ };
+
+ const tag = await createOpeningTag("Attribute", componentProps);
+ expect(tag).toEqual(`
+ `);
+ });
+
+ test("Create Class tag without props", async () => {
+ const componentProps = {
+ id: "qiskit.circuit.Sampler",
+ };
+
+ const tag = await createOpeningTag("Class", componentProps);
+ expect(tag).toEqual(`
+ `);
+ });
+});
+
+describe("prepareGitHubLink()", () => {
+ test("no link", () => {
+ const html = `None)`;
+ const doc = CheerioDoc.load(html);
+ const result = prepareGitHubLink(doc.$main, false);
+ expect(result).toEqual(undefined);
+ doc.expectHtml(html);
+ });
+
+ test("link from sphinx.ext.viewcode", () => {
+ const doc = CheerioDoc.load(
+ `None)[source]`,
+ );
+ const result = prepareGitHubLink(doc.$main, false);
+ expect(result).toEqual(`https://ibm.com/my_link`);
+ doc.expectHtml(
+ `None)`,
+ );
+ });
+
+ test("link from sphinx.ext.linkcode", () => {
+ const doc = CheerioDoc.load(
+ `None)[source]`,
+ );
+ const result = prepareGitHubLink(doc.$main, false);
+ expect(result).toEqual(
+ `https://github.com/Qiskit/qiskit/blob/stable/1.0/qiskit/utils/deprecation.py#L24-L101`,
+ );
+ doc.expectHtml(
+ `None)`,
+ );
+ });
+
+ test("method link only used when it has line numbers", () => {
+ const withLinesDoc = CheerioDoc.load(
+ `)[source]`,
+ );
+ const withoutLinesDoc = CheerioDoc.load(
+ `)[source]`,
+ );
+ const withLinesResult = prepareGitHubLink(withLinesDoc.$main, true);
+ const withoutLinesResult = prepareGitHubLink(withoutLinesDoc.$main, true);
+
+ expect(withLinesResult).toEqual(
+ `https://github.com/Qiskit/qiskit-ibm-provider/tree/stable/0.10/qiskit_ibm_provider/transpiler/passes/scheduling/block_base_padder.py#L91-L117`,
+ );
+ expect(withoutLinesResult).toEqual(undefined);
+
+ const strippedHtml = `)`;
+ withLinesDoc.expectHtml(strippedHtml);
+ withoutLinesDoc.expectHtml(strippedHtml);
+ });
+});
diff --git a/scripts/lib/api/generateApiComponents.ts b/scripts/lib/api/generateApiComponents.ts
new file mode 100644
index 00000000000..8e43bba2772
--- /dev/null
+++ b/scripts/lib/api/generateApiComponents.ts
@@ -0,0 +1,144 @@
+// This code is a Qiskit project.
+//
+// (C) Copyright IBM 2024.
+//
+// This code is licensed under the Apache License, Version 2.0. You may
+// obtain a copy of this license in the LICENSE file in the root directory
+// of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+//
+// Any modifications or derivative works of this code must retain this
+// copyright notice, and modified files need to carry a notice indicating
+// that they have been altered from the originals.
+
+import { CheerioAPI, Cheerio } from "cheerio";
+import { unified } from "unified";
+import rehypeParse from "rehype-parse";
+import rehypeRemark from "rehype-remark";
+import remarkStringify from "remark-stringify";
+
+import { APOSTROPHE_HEX_CODE } from "../stringUtils";
+
+export type ComponentProps = {
+ id: string;
+ name?: string;
+ attributeTypeHint?: string;
+ attributeValue?: string;
+ githubSourceLink?: string;
+ rawSignature?: string;
+ extraRawSignatures?: string[];
+};
+
+// ------------------------------------------------------------------
+// Generate MDX components
+// ------------------------------------------------------------------
+
+/**
+ * Creates the opening tag of the API components. The function sets all possible
+ * props values even if they are empty or undefined. All the props without value
+ * will be removed when generating the markdown file in `htmlToMd.ts`.
+ */
+export async function createOpeningTag(
+ tagName: string,
+ props: ComponentProps,
+): Promise {
+ const attributeTypeHint = props.attributeTypeHint?.replaceAll(
+ "'",
+ APOSTROPHE_HEX_CODE,
+ );
+ const attributeValue = props.attributeValue?.replaceAll(
+ "'",
+ APOSTROPHE_HEX_CODE,
+ );
+ const signature = await htmlSignatureToMd(props.rawSignature!);
+ const extraSignatures: string[] = [];
+ for (const sig of props.extraRawSignatures ?? []) {
+ extraSignatures.push(
+ `${APOSTROPHE_HEX_CODE}${await htmlSignatureToMd(
+ sig!,
+ )}${APOSTROPHE_HEX_CODE}`,
+ );
+ }
+
+ return `<${tagName}
+ id='${props.id}'
+ name='${props.name}'
+ attributeTypeHint='${attributeTypeHint}'
+ attributeValue='${attributeValue}'
+ github='${props.githubSourceLink}'
+ signature='${signature}'
+ extraSignatures='[${extraSignatures.join(", ")}]'
+ >
+ `;
+}
+
+/**
+ * Removes the original link from sphinx.ext.viewcode and returns the HTML string for our own link.
+ *
+ * This returns the HTML string, rather than directly inserting into the HTML, because the insertion
+ * logic is most easily handled by the calling code.
+ *
+ * This function works the same regardless of whether the Sphinx build used `sphinx.ext.viewcode`
+ * or `sphinx.ext.linkcode` because they have the same HTML structure.
+ *
+ * If the link corresponds to a method, we only return a link if it has line numbers included,
+ * which implies that the link came from `sphinx.ext.linkcode` rather than `sphinx.ext.viewcode`.
+ * That's because the owning class will already have a link to the relevant file; it's
+ * noisy and not helpful to repeat the same link without line numbers for the individual methods.
+ */
+export function prepareGitHubLink(
+ $child: Cheerio,
+ isMethod: boolean,
+): string | undefined {
+ const originalLink = $child.find(".viewcode-link").closest("a");
+ if (originalLink.length === 0) {
+ return undefined;
+ }
+ const href = originalLink.attr("href")!;
+ originalLink.first().remove();
+ return !isMethod || href.includes(".py#") ? href : undefined;
+}
+
+/**
+ * Find the element that both matches the `selector` and whose content is the same as `text`
+ */
+export function findByText(
+ $: CheerioAPI,
+ $main: Cheerio,
+ selector: string,
+ text: string,
+): Cheerio {
+ return $main.find(selector).filter((i, el) => $(el).text().trim() === text);
+}
+
+export function addExtraSignatures(
+ componentProps: ComponentProps,
+ extraRawSignatures: ComponentProps[],
+): void {
+ componentProps.extraRawSignatures = [
+ ...extraRawSignatures.flatMap((sigProps) => sigProps.rawSignature ?? []),
+ ];
+}
+
+/**
+ * Converts a given HTML into markdown
+ */
+export async function htmlSignatureToMd(
+ signatureHtml: string,
+): Promise {
+ if (!signatureHtml) {
+ return "";
+ }
+
+ const html = `${signatureHtml}
`;
+ const file = await unified()
+ .use(rehypeParse)
+ .use(rehypeRemark)
+ .use(remarkStringify)
+ .process(html);
+
+ return String(file)
+ .replaceAll("\n", "")
+ .replaceAll("'", APOSTROPHE_HEX_CODE)
+ .replace(/^`/, "")
+ .replace(/`$/, "");
+}
diff --git a/scripts/lib/api/processHtml.test.ts b/scripts/lib/api/processHtml.test.ts
index 8a6de44bab0..d73db331d9d 100644
--- a/scripts/lib/api/processHtml.test.ts
+++ b/scripts/lib/api/processHtml.test.ts
@@ -10,7 +10,6 @@
// copyright notice, and modified files need to carry a notice indicating
// that they have been altered from the originals.
-import { CheerioAPI, Cheerio, load as cheerioLoad } from "cheerio";
import { describe, expect, test } from "@jest/globals";
import {
@@ -26,37 +25,14 @@ import {
removeMatplotlibFigCaptions,
replaceViewcodeLinksWithGitHub,
convertRubricsToHeaders,
- prepareGitHubLink,
processMembersAndSetMeta,
} from "./processHtml";
import { Metadata } from "./Metadata";
-
-class Doc {
- readonly $: CheerioAPI;
- readonly $main: Cheerio;
-
- constructor($: CheerioAPI, $main: Cheerio) {
- this.$ = $;
- this.$main = $main;
- }
-
- static load(html: string): Doc {
- const $ = cheerioLoad(`${html}
`);
- return new Doc($, $("[role='main']"));
- }
-
- html(): string {
- return this.$main.html()!.trim();
- }
-
- expectHtml(expected: string): void {
- expect(this.html()).toEqual(expected.trim());
- }
-}
+import { CheerioDoc } from "../testUtils";
describe("loadImages()", () => {
test("normal file", () => {
- const doc = Doc.load(
+ const doc = CheerioDoc.load(
`
`,
);
const images = loadImages(doc.$, doc.$main, "/my-images", false);
@@ -76,7 +52,7 @@ describe("loadImages()", () => {
});
test("release note", () => {
- const doc = Doc.load(
+ const doc = CheerioDoc.load(
`
`,
);
const images = loadImages(doc.$, doc.$main, "/my-images/0.45", true);
@@ -91,7 +67,7 @@ describe("loadImages()", () => {
});
test("handleSphinxDesignCards()", () => {
- const doc = Doc.load(`
+ const doc = CheerioDoc.load(`