Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

API generation script uses MDX components #1026

Merged
merged 29 commits into from
Apr 3, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
59bce17
API generation script uses Function MDX component
arnaucasau Mar 13, 2024
06b72cb
add short name and exceptions
arnaucasau Mar 13, 2024
66227d1
Add all MDX components: class, propery, method, attribute, function, …
arnaucasau Mar 19, 2024
2dba29e
remove h3 and fix signatures
arnaucasau Mar 19, 2024
f50931e
fix method component
arnaucasau Mar 20, 2024
902f7ea
remove backticks and add copyright
arnaucasau Mar 20, 2024
d8a2b95
add back <h3>s
arnaucasau Mar 20, 2024
096e98b
fix method h3
arnaucasau Mar 20, 2024
388accb
incorporate feedback
arnaucasau Mar 21, 2024
e662550
fix signature
arnaucasau Mar 21, 2024
ee08ec2
fix UpperCase
arnaucasau Mar 21, 2024
f966534
remove undefined early return
arnaucasau Mar 22, 2024
ce842a6
change attributeType to attributeTypeHint
arnaucasau Mar 22, 2024
e91d5fd
Merge branch 'main' into AC/generate-Function-component
arnaucasau Mar 25, 2024
4039c0c
fix tests and move github links to prepareProps()
arnaucasau Mar 26, 2024
4c1fe88
Fix inlining
arnaucasau Mar 26, 2024
2ead845
rename tree variable
arnaucasau Mar 26, 2024
a728ebb
escape quotes and remove the unnecessary loop
arnaucasau Mar 27, 2024
f7ed13f
Merge branch 'main' into AC/generate-Function-component
arnaucasau Mar 27, 2024
aceb889
fix snapshot
arnaucasau Mar 27, 2024
2d7c177
Merge branch 'AC/generate-Function-component' of https://github.com/a…
arnaucasau Mar 27, 2024
d3b8195
fix snapshot and move tagName
arnaucasau Mar 27, 2024
8ed2097
fix tests
arnaucasau Mar 27, 2024
03e2fe1
make id optional
arnaucasau Mar 28, 2024
7ceb9ff
fix tests
arnaucasau Mar 28, 2024
3c46a3b
add isDedicatedPage prop and fix historical versions
arnaucasau Apr 2, 2024
92b29d6
fix tests
arnaucasau Apr 2, 2024
5b1c16d
Update scripts/lib/api/mergeClassMembers.ts
arnaucasau Apr 3, 2024
7f67604
early return
arnaucasau Apr 3, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
361 changes: 361 additions & 0 deletions scripts/lib/api/generateMdxComponents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,361 @@
// This code is a Qiskit project.
arnaucasau marked this conversation as resolved.
Show resolved Hide resolved
//
// (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, Element } from "cheerio";
import { unified } from "unified";
import rehypeParse from "rehype-parse";
import rehypeRemark from "rehype-remark";
import remarkStringify from "remark-stringify";

import { ApiType } from "./Metadata";
import { getLastPartFromFullIdentifier } from "../stringUtils";

export type componentProps = {
arnaucasau marked this conversation as resolved.
Show resolved Hide resolved
id: string;
name?: string;
attributeType?: string;
attributeValue?: string;
githubSourceLink?: string;
signature?: string;
extraSignatures?: string[];
};

export async function processMdxComponent(
$: CheerioAPI,
$main: Cheerio<any>,
signatures: Cheerio<Element>[],
$dl: Cheerio<any>,
priorApiType: ApiType | undefined,
apiType: ApiType,
id: string,
): Promise<[string, string]> {
findByText($, $main, "em.property", apiType).remove();

const $firstSignature = signatures.shift()!;
const githubSourceLink = prepareGitHubLink(
$firstSignature,
apiType === "method",
);

const componentProps = prepareProps(
$,
$firstSignature,
$dl,
priorApiType,
arnaucasau marked this conversation as resolved.
Show resolved Hide resolved
apiType,
githubSourceLink,
id,
);

const extraProps = signatures
.map(($overloadedSignature) =>
prepareProps(
$,
$overloadedSignature,
$dl,
apiType,
apiType,
prepareGitHubLink($overloadedSignature, apiType === "method"),
id,
),
)
.flatMap((prop) => (prop ? prop : []));
arnaucasau marked this conversation as resolved.
Show resolved Hide resolved

if (componentProps) {
arnaucasau marked this conversation as resolved.
Show resolved Hide resolved
mergeProps(componentProps, extraProps);
return [
await generateMdxComponent(apiType, componentProps),
`</${apiType}>`,
];
}
return ["", ""];
}

// ------------------------------------------------------------------
// Prepare props for MDX components
// ------------------------------------------------------------------

function prepareProps(
$: CheerioAPI,
$child: Cheerio<Element>,
$dl: Cheerio<any>,
priorApiType: ApiType | undefined,
apiType: ApiType,
githubSourceLink: string,
id: string,
): componentProps | undefined {
const preparePropsPerApiType: Record<
arnaucasau marked this conversation as resolved.
Show resolved Hide resolved
string,
() => componentProps | undefined
> = {
class: () => prepareClassProps($child, githubSourceLink, id),
property: () =>
preparePropertyProps($child, $dl, priorApiType, githubSourceLink, id),
method: () =>
prepareMethodProps($, $child, $dl, priorApiType, githubSourceLink, id),
attribute: () =>
prepareAttributeProps($, $child, $dl, priorApiType, githubSourceLink, id),
function: () =>
prepareFunctionOrExceptionProps($, $child, $dl, id, githubSourceLink),
exception: () =>
prepareFunctionOrExceptionProps($, $child, $dl, id, githubSourceLink),
};

if (!(apiType in preparePropsPerApiType)) {
throw new Error(`Unhandled Python type: ${apiType}`);
}

return preparePropsPerApiType[apiType]();
}

function prepareClassProps(
$child: Cheerio<any>,
githubSourceLink: string,
id: string,
): componentProps {
return {
id,
signature: $child.html()!,
githubSourceLink,
};
}

function preparePropertyProps(
$child: Cheerio<any>,
$dl: Cheerio<any>,
priorApiType: string | undefined,
githubSourceLink: string,
id: string,
): componentProps | undefined {
if (!priorApiType && id) {
$dl.siblings("h1").text(getLastPartFromFullIdentifier(id));
}

const signature = $child.find("em").text()?.replace(/^:\s+/, "");
if (signature.trim().length === 0) return;
return {
id,
signature,
githubSourceLink,
};
}

function prepareMethodProps(
$: CheerioAPI,
$child: Cheerio<any>,
$dl: Cheerio<any>,
priorApiType: string | undefined,
githubSourceLink: string,
id: string,
): componentProps {
const props = {
id,
signature: $child.html()!,
githubSourceLink,
};
if (id && !priorApiType) {
$dl.siblings("h1").text(getLastPartFromFullIdentifier(id));
return props;
} else if ($child.attr("id")) {
$(`<h3>${getLastPartFromFullIdentifier(id)}</h3>`).insertBefore($dl);
}

return {
...props,
name: getLastPartFromFullIdentifier(id),
};
}

function prepareAttributeProps(
$: CheerioAPI,
$child: Cheerio<any>,
$dl: Cheerio<any>,
priorApiType: string | undefined,
githubSourceLink: string,
id: string,
): componentProps | undefined {
if (!priorApiType) {
if (id) {
$dl.siblings("h1").text(getLastPartFromFullIdentifier(id));
}

const signature = $child.find("em").text()?.replace(/^:\s+/, "");
if (signature.trim().length === 0) return;
return {
id,
signature,
githubSourceLink,
};
}

// Else, the attribute is embedded on the class
const text = $child.text();

// Index of the default value of the attribute
let equalIndex = text.indexOf("=");
if (equalIndex == -1) {
equalIndex = text.length;
}
// Index of the attribute's type. The type should be
// found before the default value
let colonIndex = text.slice(0, equalIndex).indexOf(":");
if (colonIndex == -1) {
colonIndex = text.length;
}

$(`<h3>${getLastPartFromFullIdentifier(id)}</h3>`).insertBefore($dl);
// The attributes have the following shape: name [: type] [= value]
const name = text.slice(0, Math.min(colonIndex, equalIndex)).trim();
const attributeType = text
.slice(Math.min(colonIndex + 1, equalIndex), equalIndex)
.trim();
const attributeValue = text.slice(equalIndex, text.length).trim();

return {
id,
name,
attributeType,
attributeValue,
};
}

function prepareFunctionOrExceptionProps(
$: CheerioAPI,
$child: Cheerio<any>,
$dl: Cheerio<any>,
id: string,
githubSourceLink: string,
): componentProps {
const props = {
id,
signature: $child.html()!,
githubSourceLink,
};

const pageHeading = $dl.siblings("h1").text();
if (id.endsWith(pageHeading) && pageHeading != "") {
// Page is already dedicated to apiType; no heading needed
return props;
}
$(`<h3>${getLastPartFromFullIdentifier(id)}</h3>`).insertBefore($dl);

return {
...props,
name: id.split(".").slice(-1)[0],
};
}

// ------------------------------------------------------------------
// Generate MDX components
// ------------------------------------------------------------------

export async function generateMdxComponent(
arnaucasau marked this conversation as resolved.
Show resolved Hide resolved
apiType: ApiType,
props: componentProps,
): Promise<string> {
const type = props.attributeType
? props.attributeType.replaceAll("'", "&#x27;")
arnaucasau marked this conversation as resolved.
Show resolved Hide resolved
: undefined;
const value = props.attributeValue
? props.attributeValue.replaceAll("'", "&#x27;")
arnaucasau marked this conversation as resolved.
Show resolved Hide resolved
: undefined;
const signature = await htmlSignatureToMd(props.signature!);
arnaucasau marked this conversation as resolved.
Show resolved Hide resolved
const extraSignatures: string[] = [];
for (const sig of props.extraSignatures ?? []) {
extraSignatures.push(`&#x27;${await htmlSignatureToMd(sig!)}&#x27;`);
}

return `<${apiType}
id='${props.id}'
name='${props.name}'
attributeType='${type}'
attributeValue='${value}'
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(
arnaucasau marked this conversation as resolved.
Show resolved Hide resolved
$child: Cheerio<any>,
isMethod: boolean,
): string {
const originalLink = $child.find(".viewcode-link").closest("a");
if (originalLink.length === 0) {
return "";
}
const href = originalLink.attr("href")!;
originalLink.first().remove();
return !isMethod || href.includes(".py#") ? href : "";
}

/**
* Find the element that both matches the `selector` and whose content is the same as `text`
*/
function findByText(
arnaucasau marked this conversation as resolved.
Show resolved Hide resolved
$: CheerioAPI,
$main: Cheerio<any>,
selector: string,
text: string,
): Cheerio<any> {
return $main.find(selector).filter((i, el) => $(el).text().trim() === text);
}

export function mergeProps(
componentProps: componentProps,
props: componentProps[],
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This name is a bit confusing. Maybe call it extraSignatures: ComponentProps[]? And call the function addExtraSignatures or mergeExtraSignatures?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's right. I changed for extraRawSignatures following the same advice as in rawSignatures 👍

): void {
componentProps.extraSignatures = [];
for (const prop of props) {
if (props.length == 0 || !prop.signature) {
arnaucasau marked this conversation as resolved.
Show resolved Hide resolved
continue;
}
componentProps.extraSignatures.push(prop.signature);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

componentProps.extraSignatures.push(
  ...extraSignatures
     .map((sigProps) => sigProps.signature)
     .filter((sig) => sig)
)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was my first idea, but the filter doesn't work very well with undefined or nulls. The return value is still string | undefined afterward.

Nonetheless, I like the idea, and I think we can make it more compact with flatMap:

componentProps.extraRawSignatures = [
  ...extraRawSignatures.flatMap((sigProps) => sigProps.rawSignature ?? []),
];

}
}

/**
* Converts a given HTML into markdown
*/
async function htmlSignatureToMd(signatureHtml: string): Promise<string> {
if (!signatureHtml) {
return "";
}

const html = `<span class="target" id=''/><p><code>${signatureHtml}</code></p>`;
const file = await unified()
.use(rehypeParse)
.use(rehypeRemark)
.use(remarkStringify)
.process(html);

return String(file)
arnaucasau marked this conversation as resolved.
Show resolved Hide resolved
.replaceAll("\n", "")
.replaceAll("'", "&#x27;")
.replace(/^`/, "")
.replace(/`$/, "");
}
Loading
Loading