diff --git a/README.md b/README.md index 865def8abad..44f76dfa4c2 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,13 @@ API docs authors can preview their changes to one of the APIs by using the `-a` 1. Run `npm run gen-api -- -p -v -a `. 2. Execute `./start` and open up `http://localhost:3000`, as explained in the prior section. +### Addon docs authors: How to preview your changes + +Addon docs authors can preview guide and how-to changes by pointing the addon pipeline at a local Sphinx build: + +1. Run `npm run gen-addon -- -p --sphinx-artifact-folder `. +2. Execute `./start` and navigate to `http://localhost:3000/docs/addons/`. + ## Run quality checks We use multiple tools to ensure that documentation meets high standards. These tools will run automatically in your PR through CI, but it is much faster to run the checks locally. @@ -321,6 +328,43 @@ Additionally, If you are regenerating a dev version, then you can add `--dev` as In this case, no commit will be automatically created. +## Generate or update addon docs + +Addon docs for packages like are generated by a separate pipeline from the API pipeline. Content is published to `docs/addons/{pkg}/` rather than `docs/api/{pkg}/`. + +### Generate addon docs for a new release + +3. Run: + + ```sh + npm run gen-addon -- -p + ``` + + To regenerate all addon packages at once: + + ```sh + npm run gen-addon -- --all + ``` + + Or if you don't need to download but need to re-run the pipeline you can pass "--ship-download" to speed things up + + ```sh + npm run gen-addon -- --all --skip-download + ``` + +4. Open a pull request with the generated changes. + +### Add a new addon package + +1. Download the Sphinx HTML artifact from the package's GitHub Actions workflow (see the [CI artifact links](#initial-steps) in the API docs section). +2. Unzip the artifact and make sure there is no subfolder before the content. Name the zip to its minor version, e.g. `0.5.zip`, and upload it to the Box folder. + - Unzipping should produce a flat structure of the content: 0.5.zip -> index.html. NOT: 0.5.zip -> sphinx-output/index.html. +3. Add the package's Box artifact URL to `scripts/config/api-html-artifacts.json` (see how to get the shareable link [here](#Final-steps-for-the-rc1-release)) +4. Add the package name to `Pkg.ADDON_NAMES` in `scripts/js/lib/api/Pkg.ts` and add a `new Pkg(...)` with the correct `title`, `githubSlug`, and `language` in that file as well. +5. Run the pipeline for the new addon (`gen-api`/`gen-addon`) +6. Verify content with `npm run check:internal-links -- --current-apis`, `npm run check:spelling`, and `npm run check:markdown -- --apis` +7. Verify rendering with `./start --apis` + ## Generate new API docs Use this process when we want to publish new API docs, such as when we release a new version of a package like Qiskit SDK. @@ -354,21 +398,25 @@ All release types start with the following steps: 1. In Git, check out the main branch of `Qiskit/documentation` and pull any updates. Then, create a new Git branch. 2. Determine which documentation you want to generate (e.g., `qiskit` or `qiskit-ibm-runtime`) and its full version, e.g., `0.45.2` or `1.2.0rc1`. 3. Download a CI artifact with the project's documentation. To find this: + 1. Find the relevant GitHub Actions workflow for the project: + - Qiskit SDK: https://github.com/Qiskit/qiskit/actions/workflows/docs_deploy.yml - Runtime: https://github.com/Qiskit/qiskit-ibm-runtime/actions/workflows/docs.yml - Transpiler Service: https://github.com/Qiskit/qiskit-ibm-transpiler/actions/workflows/upload-docs.yml - - qiskit-addon-acq-tensor: https://github.com/Qiskit/qiskit-addon-aqc-tensor/actions/workflows/docs.yml + - qiskit-addon-aqc-tensor: https://github.com/Qiskit/qiskit-addon-aqc-tensor/actions/workflows/docs.yml - qiskit-addon-obp: https://github.com/Qiskit/qiskit-addon-obp/actions/workflows/docs.yml - qiskit-addon-mpf: https://github.com/Qiskit/qiskit-addon-mpf/actions/workflows/docs.yml - qiskit-addon-sqd: https://github.com/Qiskit/qiskit-addon-sqd/actions/workflows/docs.yml - qiskit-addon-cutting: https://github.com/Qiskit/qiskit-addon-cutting/actions/workflows/docs.yml - qiskit-addon-utils: https://github.com/Qiskit/qiskit-addon-utils/actions/workflows/docs.yml + 2. Find the run for the release by looking at the middle column with the blue text; look for the version number, like `0.45.2`. For the `rc1` release, look for `main` in blue text and a run description like "Prepare 2.3.0rc1". 3. Click the CI run name. (Not the middle column with the blue link!) 4. In the left navbar, it should show as selected the "Summary" page with the house. 5. Scroll down to "Artifacts" and look for the artifact related to documentation, such as `html_docs`. 6. Download the artifact by clicking on its name. + 4. On some operating systems, the downloaded zip file will be auto-expanded rather than staying a zip file. If this happens, compress it back to a zip file. On macOS, secondary-click on the folder in Finder and use the "Compress" option. 5. Rename the downloaded zip file with its minor-version number. For example, for the release `0.45.2`, rename `html_docs.zip` to `0.45.zip`. For release candidates (rc), use a value like `2.3-rc.zip`. 6. Upload the renamed zip file to https://ibm.ent.box.com/folder/246867452622. If this is a patch release, this step will overwrite the prior file; if it's the `rc1` release or a new minor version like `2.3.0`, it will be a new file. diff --git a/package.json b/package.json index b50efa782cb..e63ead4d1b5 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,8 @@ "check:qiskit-versions": "tsx scripts/js/commands/checkQiskitApiVersions.ts", "regen-apis": "tsx scripts/js/commands/api/regenerateApiDocs.ts", "gen-api": "tsx scripts/js/commands/api/updateApiDocs.ts", + "gen-addon": "tsx scripts/js/commands/api/updateAddonDocs.ts", + "postgen-addon": "./fix", "generate-historical-redirects": "tsx scripts/js/commands/api/generateHistoricalRedirects.ts", "save-internal-links": "tsx scripts/js/commands/saveInternalLinks.ts" }, diff --git a/scripts/ci/check-all-notebooks-are-tested.py b/scripts/ci/check-all-notebooks-are-tested.py index e10e3a3f5de..99704e70df5 100644 --- a/scripts/ci/check-all-notebooks-are-tested.py +++ b/scripts/ci/check-all-notebooks-are-tested.py @@ -15,6 +15,7 @@ they don't slip through our CI tests. """ +import fnmatch from pathlib import Path import tomllib import sys @@ -22,15 +23,21 @@ config_path = Path("scripts/config/notebook-testing.toml") config = tomllib.loads(config_path.read_text()) -categorized_notebooks = set() +categorized_notebooks: set[Path] = set() +categorized_globs: list[str] = [] for group in config["groups"].values(): for path in group.get("notebooks", []): - categorized_notebooks.add(Path(path)) + if "*" in path or "?" in path: + categorized_globs.append(path) + else: + categorized_notebooks.add(Path(path)) uncategorized = [ path for path in Path(".").glob("[!.]*/**/*.ipynb") - if not path.match("**/.ipynb_checkpoints/**") and path not in categorized_notebooks + if not path.match("**/.ipynb_checkpoints/**") + and path not in categorized_notebooks + and not any(fnmatch.fnmatch(str(path), g) for g in categorized_globs) ] if uncategorized: diff --git a/scripts/config/allowLists.ts b/scripts/config/allowLists.ts index 37fbb576702..cb73694dd93 100644 --- a/scripts/config/allowLists.ts +++ b/scripts/config/allowLists.ts @@ -21,6 +21,8 @@ export function ignoreTitleMismatch(filepath: string): boolean { return IGNORE_TITLE_MISMATCHES.includes(filepath); } +export const IMAGE_ALLOWLIST: Set = new Set([]); + const IGNORE_TITLE_MISMATCHES: string[] = [ "docs/guides/directed-execution-model.mdx", // ok "docs/guides/estimator-examples.ipynb", // ok diff --git a/scripts/config/notebook-testing.toml b/scripts/config/notebook-testing.toml index 057af9f6d13..4cea0dfe1df 100644 --- a/scripts/config/notebook-testing.toml +++ b/scripts/config/notebook-testing.toml @@ -137,6 +137,12 @@ notebooks = [ # Don't ever test the following notebooks [groups.exclude] notebooks = [ + # Addon notebooks require the addon packages and are not run in standard CI + "docs/addons/**/*.ipynb", + + # Test fixture — not real content + "scripts/js/lib/api/testdata/**/*.ipynb", + # This notebook contains undefined variables so can't run at all. "docs/guides/function-template-hamiltonian-simulation.ipynb", "docs/guides/function-template-chemistry-workflow.ipynb", diff --git a/scripts/js/commands/api/updateAddonDocs.ts b/scripts/js/commands/api/updateAddonDocs.ts new file mode 100644 index 00000000000..00e467e48dc --- /dev/null +++ b/scripts/js/commands/api/updateAddonDocs.ts @@ -0,0 +1,106 @@ +// 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 { readdir } from "fs/promises"; + +import yargs from "yargs/yargs"; +import { hideBin } from "yargs/helpers"; + +import { Pkg } from "../../lib/api/Pkg.js"; +import { runAddonDocsPipeline } from "../../lib/api/addonDocsPipeline.js"; +import { zxMain } from "../../lib/zx.js"; +import { parseMinorVersion } from "../../lib/apiVersions.js"; +import { deleteOutputDirs, prepareSphinxFolder } from "./updateDocsShared.js"; + +type Args = { + package?: string; + all: boolean; + skipDownload: boolean; + sphinxArtifactFolder?: string; +}; + +const readArgs = (): Args => { + return yargs(hideBin(process.argv)) + .version(false) + .option("package", { + alias: "p", + type: "string", + choices: Pkg.ADDON_NAMES, + description: "Which addon package to update", + }) + .option("all", { + type: "boolean", + default: false, + description: "Update all addon packages in parallel", + }) + .option("skip-download", { + alias: "s", + type: "boolean", + default: false, + description: "Skip downloading the Sphinx artifact", + }) + .option("sphinx-artifact-folder", { + type: "string", + description: "Use a local artifact folder instead of downloading", + }) + .check((argv) => { + if (!argv.all && !argv.package) { + throw new Error("Either --package or --all is required"); + } + return true; + }) + .parseSync() as unknown as Args; +}; + +async function resolveVersion(pkgName: string): Promise { + const entries = await readdir(`.sphinx-artifacts/${pkgName}`); + const versions = entries.filter((e) => /^\d/.test(e)).sort(); + if (versions.length === 0) { + throw new Error( + `No artifact versions found for ${pkgName}. Run without --skip-download first.`, + ); + } + return versions.at(-1)!; +} + +async function generateVersion(pkg: Pkg, args: Args): Promise { + const sphinxArtifactFolder = await prepareSphinxFolder(pkg, args); + await deleteOutputDirs(pkg, { + markdownDir: pkg.outputDir("docs/addons"), + imagesDir: pkg.outputDir("public/docs/images/addons"), + recursive: true, + }); + + console.log(`Run pipeline for ${pkg.name}:${pkg.versionWithoutPatch}`); + await runAddonDocsPipeline( + sphinxArtifactFolder, + "docs/addons", + "public/docs", + pkg, + ); +} + +async function generatePackage(pkgName: string, args: Args): Promise { + const version = await resolveVersion(pkgName); + const minorVersion = parseMinorVersion(version); + if (minorVersion === null) { + throw new Error(`Could not parse version ${version} for ${pkgName}`); + } + const pkg = await Pkg.fromArgs(pkgName, version, minorVersion, "latest"); + await generateVersion(pkg, args); +} + +zxMain(async () => { + const args = readArgs(); + const packages = args.all ? Pkg.ADDON_NAMES : [args.package!]; + await Promise.all(packages.map((pkgName) => generatePackage(pkgName, args))); +}); diff --git a/scripts/js/commands/api/updateApiDocs.ts b/scripts/js/commands/api/updateApiDocs.ts index edac31e251b..08d350e6f73 100644 --- a/scripts/js/commands/api/updateApiDocs.ts +++ b/scripts/js/commands/api/updateApiDocs.ts @@ -15,11 +15,10 @@ import { hideBin } from "yargs/helpers"; import { Pkg } from "../../lib/api/Pkg.js"; import { zxMain } from "../../lib/zx.js"; -import { parseMinorVersion, isValidVersion } from "../../lib/apiVersions.js"; -import { pathExists, rmFilesInFolder } from "../../lib/fs.js"; -import { downloadSphinxArtifact } from "../../lib/api/sphinxArtifacts.js"; -import { runConversionPipeline } from "../../lib/api/conversionPipeline.js"; +import { isValidVersion, parseMinorVersion } from "../../lib/apiVersions.js"; +import { runApiDocsPipeline } from "../../lib/api/apiDocsPipeline.js"; import { generateHistoricalRedirects } from "./generateHistoricalRedirects.js"; +import { deleteOutputDirs, prepareSphinxFolder } from "./updateDocsShared.js"; export interface Arguments { [x: string]: unknown; @@ -87,10 +86,14 @@ export async function generateVersion( args: Arguments, ): Promise { const sphinxArtifactFolder = await prepareSphinxFolder(pkg, args); - await deleteExistingFiles(pkg); + await deleteOutputDirs(pkg, { + markdownDir: pkg.apiOutputDir("docs"), + imagesDir: pkg.apiOutputDir("public/docs/images"), + recursive: false, + }); console.log(`Run pipeline for ${pkg.name}:${pkg.versionWithoutPatch}`); - await runConversionPipeline(sphinxArtifactFolder, "docs", "public/docs", pkg); + await runApiDocsPipeline(sphinxArtifactFolder, "docs", "public/docs", pkg); await generateHistoricalRedirects(); } @@ -112,43 +115,6 @@ export function determineMinorVersion(args: Arguments): string { return minorVersion; } -async function prepareSphinxFolder(pkg: Pkg, args: Arguments): Promise { - if (args.sphinxArtifactFolder) { - if (!(await pathExists(args.sphinxArtifactFolder))) { - throw new Error( - `Explicit artifact path '${args.sphinxArtifactFolder}' does not exist.`, - ); - } - return args.sphinxArtifactFolder; - } - const sphinxArtifactFolder = pkg.sphinxArtifactFolder(); - if ( - args.skipDownload && - (await pathExists(`${sphinxArtifactFolder}/artifact`)) - ) { - console.log( - `Skip downloading sources for ${pkg.name}:${pkg.versionWithoutPatch}`, - ); - } else { - await downloadSphinxArtifact(pkg, sphinxArtifactFolder); - } - return `${sphinxArtifactFolder}/artifact`; -} - -async function deleteExistingFiles(pkg: Pkg): Promise { - const markdownDir = pkg.outputDir("docs"); - if (await pathExists(markdownDir)) { - await rmFilesInFolder(markdownDir); - } - const imagesDir = pkg.outputDir("public/docs/images"); - if (await pathExists(imagesDir)) { - await rmFilesInFolder(imagesDir); - } - console.log( - `Deleted existing markdown & images for ${pkg.name}:${pkg.versionWithoutPatch}`, - ); -} - if (import.meta.url === `file://${process.argv[1]}`) { zxMain(async () => { const args = readArgs(); diff --git a/scripts/js/commands/api/updateDocsShared.ts b/scripts/js/commands/api/updateDocsShared.ts new file mode 100644 index 00000000000..10163ee73d5 --- /dev/null +++ b/scripts/js/commands/api/updateDocsShared.ts @@ -0,0 +1,87 @@ +// This code is a Qiskit project. +// +// (C) Copyright IBM 2026. +// +// 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. + +// Shared helpers for updateApiDocs.ts and updateAddonDocs.ts — they both +// take --package/--version, download (or reuse) a Sphinx artifact, and wipe +// an output directory before running a pipeline. + +import { $ } from "zx"; + +import { Pkg } from "../../lib/api/Pkg.js"; +import { pathExists, rmFilesInFolder } from "../../lib/fs.js"; +import { downloadSphinxArtifact } from "../../lib/api/sphinxArtifacts.js"; + +/** + * Resolve the Sphinx artifact folder: either a user-provided path, a reused + * prior download, or a fresh download from Box. + */ +export async function prepareSphinxFolder( + pkg: Pkg, + args: { skipDownload: boolean; sphinxArtifactFolder?: string }, +): Promise { + if (args.sphinxArtifactFolder) { + if (!(await pathExists(args.sphinxArtifactFolder))) { + throw new Error( + `Explicit artifact path '${args.sphinxArtifactFolder}' does not exist.`, + ); + } + return args.sphinxArtifactFolder; + } + const sphinxArtifactFolder = pkg.sphinxArtifactFolder(); + if ( + args.skipDownload && + (await pathExists(`${sphinxArtifactFolder}/artifact`)) + ) { + console.log( + `Skip downloading sources for ${pkg.name}:${pkg.versionWithoutPatch}`, + ); + } else { + await downloadSphinxArtifact(pkg, sphinxArtifactFolder); + } + return `${sphinxArtifactFolder}/artifact`; +} + +export interface DeleteOptions { + /** Markdown output directory (contents removed before pipeline runs). */ + markdownDir: string; + /** Image output directory (removed entirely before pipeline runs). */ + imagesDir: string; + /** + * If true, recursively wipe `markdownDir`; otherwise only delete top-level + * files (so sibling historical-version subfolders are preserved). + */ + recursive: boolean; +} + +/** Wipe output directories in preparation for a fresh pipeline run. */ +export async function deleteOutputDirs( + pkg: Pkg, + { markdownDir, imagesDir, recursive }: DeleteOptions, +): Promise { + if (await pathExists(markdownDir)) { + if (recursive) { + await $`rm -rf ${markdownDir}`; + } else { + await rmFilesInFolder(markdownDir); + } + } + if (await pathExists(imagesDir)) { + if (recursive) { + await $`rm -rf ${imagesDir}`; + } else { + await rmFilesInFolder(imagesDir); + } + } + console.log( + `Deleted existing docs & images for ${pkg.name}:${pkg.versionWithoutPatch}`, + ); +} diff --git a/scripts/js/commands/checkMarkdown.ts b/scripts/js/commands/checkMarkdown.ts index 0799c5d47b9..68449764c1a 100644 --- a/scripts/js/commands/checkMarkdown.ts +++ b/scripts/js/commands/checkMarkdown.ts @@ -20,6 +20,7 @@ import { collectHeadingTitleMismatch } from "../lib/markdownTitles.js"; import { parseMarkdown } from "../lib/markdownUtils.js"; import { checkMetadata } from "../lib/metadataChecker.js"; import { + IMAGE_ALLOWLIST, METADATA_ALLOWLIST, ignoreTitleMismatch, } from "../../config/allowLists.js"; @@ -50,7 +51,9 @@ async function main() { for (const file of files) { const { content, metadata } = await readMarkdownAndMetadata(file); const tree = parseMarkdown(content); - const imageErrors = collectInvalidImageErrors(tree); + const imageErrors = IMAGE_ALLOWLIST.has(file) + ? new Set() + : collectInvalidImageErrors(tree); const mismatchedTitleHeadingErrors = ignoreTitleMismatch(file) ? new Set() : collectHeadingTitleMismatch(tree, metadata); diff --git a/scripts/js/commands/checkQiskitBotFiles.ts b/scripts/js/commands/checkQiskitBotFiles.ts index e3cf1e42ac3..1db1794c6b9 100644 --- a/scripts/js/commands/checkQiskitBotFiles.ts +++ b/scripts/js/commands/checkQiskitBotFiles.ts @@ -51,6 +51,7 @@ const GLOBS = [ "{docs,learning}/**/*.{ipynb,mdx}", "!docs/api/**/*", "docs/api/functions/**", + "!docs/addons/**/*", ]; async function main() { diff --git a/scripts/js/lib/api/HtmlToMdResult.ts b/scripts/js/lib/api/HtmlToMdResult.ts index 4438b23aae2..85b10a92238 100644 --- a/scripts/js/lib/api/HtmlToMdResult.ts +++ b/scripts/js/lib/api/HtmlToMdResult.ts @@ -15,6 +15,8 @@ import { Metadata } from "./Metadata.js"; export type Image = { fileName: string; dest: string; + /** Path to the image relative to the artifact root (e.g. `_static/foo.svg` or `_images/foo.svg`) */ + originSrc: string; }; export type HtmlToMdResult = { diff --git a/scripts/js/lib/api/Notebooks.ts b/scripts/js/lib/api/Notebooks.ts new file mode 100644 index 00000000000..5870dcbc7e2 --- /dev/null +++ b/scripts/js/lib/api/Notebooks.ts @@ -0,0 +1,17 @@ +export type NotebookCell = { + id?: string; + cell_type: "code" | "markdown" | "raw"; + source: string | string[]; + metadata: Record; + outputs?: unknown[]; + execution_count?: number | null; +}; + +export type Notebook = { + nbformat: number; + nbformat_minor: number; + metadata: Record; + cells: NotebookCell[]; +}; + +export type NotebookWithUrl = Notebook & { url: string }; diff --git a/scripts/js/lib/api/Pkg.ts b/scripts/js/lib/api/Pkg.ts index 9b3b1145f86..27934e44a7d 100644 --- a/scripts/js/lib/api/Pkg.ts +++ b/scripts/js/lib/api/Pkg.ts @@ -57,18 +57,23 @@ export class Pkg { readonly kebabCaseAndShortenUrls: boolean; readonly artifactPackageName: string; readonly hasRootNamespaceFile: boolean; + /** Slugs of docs/tutorials/ notebooks to surface under this addon's tutorials route. */ - static VALID_NAMES = [ - "qiskit", - "qiskit-ibm-runtime", - "qiskit-ibm-transpiler", + static ADDON_NAMES = [ "qiskit-addon-aqc-tensor", "qiskit-addon-obp", "qiskit-addon-mpf", "qiskit-addon-sqd", "qiskit-addon-cutting", "qiskit-addon-utils", + ]; + + static VALID_NAMES = [ + "qiskit", + "qiskit-ibm-runtime", + "qiskit-ibm-transpiler", "qiskit-c", + ...Pkg.ADDON_NAMES, ]; constructor(kwargs: { @@ -255,6 +260,10 @@ export class Pkg { } outputDir(parentDir: string): string { + return join(parentDir, this.name); + } + + apiOutputDir(parentDir: string): string { let path = join(parentDir, "api", this.name); if (this.isHistorical()) { path = join(path, this.versionWithoutPatch); @@ -284,6 +293,10 @@ export class Pkg { return this.language === "C"; } + isAddon(): boolean { + return Pkg.ADDON_NAMES.includes(this.name); + } + isProblematicLegacyQiskit(): boolean { return this.name === "qiskit" && +this.versionWithoutPatch < 0.45; } diff --git a/scripts/js/lib/api/addonDocsPipeline.test.ts b/scripts/js/lib/api/addonDocsPipeline.test.ts new file mode 100644 index 00000000000..a381054b624 --- /dev/null +++ b/scripts/js/lib/api/addonDocsPipeline.test.ts @@ -0,0 +1,110 @@ +// This code is a Qiskit project. +// +// (C) Copyright IBM 2026. +// +// 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 os from "os"; +import path from "path"; +import { mkdtemp, readFile, mkdir, copyFile } from "fs/promises"; + +import { globby } from "globby"; +import { expect, test } from "@playwright/test"; + +import { runAddonDocsPipeline } from "./addonDocsPipeline.js"; +import { Pkg, ReleaseNotesConfig } from "./Pkg.js"; + +// Snapshot test for the addon non-API docs pipeline. If output changes +// intentionally, run `npm test -- --updateSnapshot`. +// +// The fixture under `testdata/qiskit-addon-smoke/` covers the pipeline's key +// concerns: article extraction, relative-apidocs link rewriting, cross-package +// stub resolution via `ObjectsInv.loadPublishedApis`, notebook processing, and +// image routing to `public/docs/images/addons/{pkg}/`. + +const FIXTURE_DIR = "scripts/js/lib/api/testdata/qiskit-addon-smoke"; +const PUBLISHED_APIS_SEED = + "scripts/js/lib/api/testdata/qiskit-addon-smoke-publishedapis"; + +test("qiskit-addon-smoke addon docs pipeline", async ({}, testInfo) => { + testInfo.snapshotSuffix = ""; + + const tmpDir = await mkdtemp(path.join(os.tmpdir(), "addon-smoke-")); + const docsBaseFolder = path.join(tmpDir, "docs", "addons"); + const publicBaseFolder = path.join(tmpDir, "public", "docs"); + + // Seed public/docs/api//objects.inv so that loadPublishedApis() + // finds a sibling package's inventory for cross-package stub resolution. + const seededInvDir = path.join(publicBaseFolder, "api", "qiskit-addon-other"); + await mkdir(seededInvDir, { recursive: true }); + await copyFile( + path.join(PUBLISHED_APIS_SEED, "api", "qiskit-addon-other", "objects.inv"), + path.join(seededInvDir, "objects.inv"), + ); + + const pkg = new Pkg({ + name: "qiskit-addon-smoke", + title: "Qiskit Addon Smoke", + githubSlug: "Qiskit/qiskit-addon-smoke", + version: "0.0.1", + versionWithoutPatch: "0.0", + type: "latest", + language: "Python", + releaseNotesConfig: new ReleaseNotesConfig({ enabled: false }), + kebabCaseAndShortenUrls: true, + }); + + await runAddonDocsPipeline( + FIXTURE_DIR, + docsBaseFolder, + publicBaseFolder, + pkg, + ); + + const markdownFolder = pkg.outputDir(docsBaseFolder); + + // Invariant: the addon pipeline must never write API-shaped output under its + // own docs root. Guards against the class of bug that produced the stray + // `docs/addons/api/qiskit-addon-obp/` directory in the repo. + const strayUnderAddonsApi = await globby([`${docsBaseFolder}/api/**`]); + expect( + strayUnderAddonsApi, + "addon pipeline must not write to docs/addons/api/**", + ).toEqual([]); + + // Invariant: addon images must land under `public/docs/images/addons/{pkg}/`, + // not under `public/docs/images/api/{pkg}/`. The inverse was the bug before + // `saveImages` was parameterized. + const imagesUnderApi = await globby([`${publicBaseFolder}/images/api/**`]); + expect( + imagesUnderApi, + "addon-run images must not land under public/docs/images/api/**", + ).toEqual([]); + + const imagesUnderAddons = await globby([ + `${publicBaseFolder}/images/addons/**`, + ]); + expect( + imagesUnderAddons.length, + "addon-run images must land under public/docs/images/addons/**", + ).toBeGreaterThan(0); + + // --- Snapshot of every generated file under the addon output folder --- + + const resultFiles = await globby([`${markdownFolder}/**`]); + expect( + resultFiles.length, + "pipeline should generate at least one file", + ).toBeGreaterThan(0); + for (const file of resultFiles) { + const contents = await readFile(file, "utf-8"); + const fileName = path.parse(file).name; + expect(contents).toMatchSnapshot(fileName); + } +}); diff --git a/scripts/js/lib/api/addonDocsPipeline.test.ts-snapshots/-toc b/scripts/js/lib/api/addonDocsPipeline.test.ts-snapshots/-toc new file mode 100644 index 00000000000..740d6ac7439 --- /dev/null +++ b/scripts/js/lib/api/addonDocsPipeline.test.ts-snapshots/-toc @@ -0,0 +1,35 @@ +{ + "parentUrl": "/docs/guides/addons", + "parentLabel": "Documentation", + "title": "Qiskit Addon Smoke 0.0.1", + "collapsed": true, + "children": [ + { + "title": "", + "children": [ + { + "title": "Home", + "url": "/docs/addons/qiskit-addon-smoke" + }, + { + "title": "Installation", + "url": "/docs/addons/qiskit-addon-smoke/install" + }, + { + "title": "Guides", + "children": [ + { + "title": "How to foo", + "url": "/docs/addons/qiskit-addon-smoke/how_tos/foo" + } + ] + }, + { + "title": "GitHub", + "url": "https://github.com/Qiskit/qiskit-addon-smoke" + } + ], + "collapsible": false + } + ] +} diff --git a/scripts/js/lib/api/addonDocsPipeline.test.ts-snapshots/foo b/scripts/js/lib/api/addonDocsPipeline.test.ts-snapshots/foo new file mode 100644 index 00000000000..ca192071926 --- /dev/null +++ b/scripts/js/lib/api/addonDocsPipeline.test.ts-snapshots/foo @@ -0,0 +1,13 @@ +--- +title: "How to foo" +description: "How to foo for the latest version of Qiskit Addon Smoke" +--- + +# How to foo + +This how-to references the [API docs index](/docs/api/qiskit-addon-smoke/index) and the [do\_thing](/docs/api/qiskit-addon-smoke/smoke-do-thing) symbol from the same package. + +It also links into another package via [other\_thing](/docs/api/qiskit-addon-other/other_thing). + +![A reused smoke diagram](/docs/images/addons/qiskit-addon-smoke/smoke-diagram.avif) + diff --git a/scripts/js/lib/api/addonDocsPipeline.test.ts-snapshots/index b/scripts/js/lib/api/addonDocsPipeline.test.ts-snapshots/index new file mode 100644 index 00000000000..32ce327413b --- /dev/null +++ b/scripts/js/lib/api/addonDocsPipeline.test.ts-snapshots/index @@ -0,0 +1,11 @@ +--- +title: "Qiskit Addon Smoke" +description: "Documentation for the latest version of Qiskit Addon Smoke" +--- + +# Qiskit Addon Smoke + +This is the smoke-test landing page. See the [installation instructions](install) or the [first how-to](how_tos/foo). + +![A smoke test diagram](/docs/images/addons/qiskit-addon-smoke/smoke-diagram.avif) + diff --git a/scripts/js/lib/api/addonDocsPipeline.test.ts-snapshots/install b/scripts/js/lib/api/addonDocsPipeline.test.ts-snapshots/install new file mode 100644 index 00000000000..147810b833d --- /dev/null +++ b/scripts/js/lib/api/addonDocsPipeline.test.ts-snapshots/install @@ -0,0 +1,9 @@ +--- +title: "Installation" +description: "Installation for the latest version of Qiskit Addon Smoke" +--- + +# Installation + +Install with `pip`. Back to the [home page](index). + diff --git a/scripts/js/lib/api/addonDocsPipeline.test.ts-snapshots/intro b/scripts/js/lib/api/addonDocsPipeline.test.ts-snapshots/intro new file mode 100644 index 00000000000..a2c40ee0d38 --- /dev/null +++ b/scripts/js/lib/api/addonDocsPipeline.test.ts-snapshots/intro @@ -0,0 +1,37 @@ +{ + "cells": [ + { + "id": "frontmatter", + "cell_type": "markdown", + "source": "---\ntitle: \"Intro tutorial\"\ndescription: \"Intro tutorial for the latest version of Qiskit Addon Smoke\"\n---", + "metadata": {} + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Intro tutorial\n", + "\n", + "This notebook references [do_thing](/docs/api/qiskit-addon-smoke/smoke-do-thing#smoke.do_thing) from the current package and [other_thing](/docs/api/qiskit-addon-other/other_thing) from a sibling package.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print('hello from the smoke tutorial')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/scripts/js/lib/api/addonDocsPipeline.ts b/scripts/js/lib/api/addonDocsPipeline.ts new file mode 100644 index 00000000000..b1b0d568fa2 --- /dev/null +++ b/scripts/js/lib/api/addonDocsPipeline.ts @@ -0,0 +1,173 @@ +// 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. + +// Pipeline for generating addon content pages from a Sphinx build artifact. +// Addon docs differ from API docs in several ways: +// - Content lives under docs/addons/{pkg}/ rather than docs/api/{pkg}/. +// - The Sphinx artifact contains how-to guides and explanations (HTML), +// Jupyter notebooks (.ipynb), and static images — but NOT apidoc stubs. +// - A _toc.json is generated by generateAddonToc (not generateToc) because +// the TOC shape for addons includes a back-link to the parent page and +// optional Tutorials and API reference sections. +// - When a notebook and an HTML file share the same base name, the notebook +// wins so we preserve code outputs rather than the rendered HTML version. + +import { writeFile } from "fs/promises"; + +import { globby } from "globby"; +import { mkdirp } from "mkdirp"; + +import { ObjectsInv } from "./objectsInv.js"; +import { Pkg } from "./Pkg.js"; +import { + convertHtmlToMarkdown, + copyImages, + postProcess, + writeMarkdownResults, +} from "./pipelineStages.js"; +import { + collectNotebookImages, + processNotebooks, + readNotebooks, + writeNotebooks, +} from "./notebookStages.js"; +import { DOCS_BASE_PATH } from "./paths.js"; +import { generateAddonToc } from "./generateAddonToc.js"; + +// Sphinx build artifacts that are never content. +const SPHINX_INTERNALS = [ + "_static/**", + "_sources/**", + "_downloads/**", + "_modules/**", + "genindex.html", + "py-modindex.html", + "search.html", + "objects.inv", +]; + +/** + * Run the full addon docs pipeline for one package version. + * + * @param artifactPath Root of the Sphinx HTML build (the unzipped artifact folder). + * @param docsBaseFolder Repo-relative output root for markdown, e.g. "docs/addons". + * @param publicBaseFolder Repo-relative output root for public assets, e.g. "public/docs". + * @param pkg Package metadata (name, version, tutorial slugs, etc.). + */ +export async function runAddonDocsPipeline( + artifactPath: string, + docsBaseFolder: string, + publicBaseFolder: string, + pkg: Pkg, +): Promise { + const allObjectInvs = await ObjectsInv.loadPublishedApis(publicBaseFolder); + const [files, outputPath, objectsInv] = await determineFilePaths( + artifactPath, + docsBaseFolder, + pkg, + ); + + // HTML → Markdown. The last argument (true) enables extracting frontmatter + // from the Sphinx HTML

rather than letting addFrontMatter.ts generate it. + const htmlFiles = files.filter((f) => f.endsWith(".html")); + const initialResults = await convertHtmlToMarkdown( + pkg, + artifactPath, + docsBaseFolder, + outputPath, + htmlFiles, + pkg.outputDir(`${DOCS_BASE_PATH}/images/addons`), + true, + ); + + const results = await postProcess( + pkg, + initialResults, + objectsInv, + allObjectInvs, + ); + await writeMarkdownResults(pkg, docsBaseFolder, results); + + // Notebooks. Images embedded in notebooks are collected separately so they + // can be passed to copyImages together with the HTML-derived images. + const notebookFiles = files.filter((f) => f.endsWith(".ipynb")); + const initialNotebooks = await readNotebooks( + artifactPath, + docsBaseFolder, + outputPath, + notebookFiles, + ); + const imageDestination = pkg.outputDir(`${DOCS_BASE_PATH}/images/addons`); + const notebookImages = collectNotebookImages( + initialNotebooks, + imageDestination, + ); + const notebooks = processNotebooks( + initialNotebooks, + objectsInv, + allObjectInvs, + pkg, + imageDestination, + ); + await writeNotebooks(pkg, docsBaseFolder, notebooks); + + // Copy all images (HTML-referenced + notebook-referenced) into public/. + await copyImages( + pkg, + artifactPath, + pkg.outputDir(`${publicBaseFolder}/images/addons`), + results, + notebookImages, + ); + + console.log("Generating addon toc"); + const toc = await generateAddonToc(pkg, artifactPath); + await writeFile( + `${outputPath}/_toc.json`, + JSON.stringify(toc, null, 2) + "\n", + ); +} + +async function determineFilePaths( + htmlPath: string, + docsBaseFolder: string, + pkg: Pkg, +): Promise<[string[], string, ObjectsInv]> { + const objectsInv = await ObjectsInv.fromFile(htmlPath, pkg.language); + + const allFiles = await globby(["**"], { + cwd: htmlPath, + ignore: [ + "apidocs/**", + "apidoc/**", + "stubs/**", + "tutorials/**", + "release-notes.html", + "release_notes.html", + ...SPHINX_INTERNALS, + ], + }); + + // Prefer .ipynb over .html when both exist for the same base path. + const notebookBases = new Set( + allFiles + .filter((f) => f.endsWith(".ipynb")) + .map((f) => f.slice(0, -".ipynb".length)), + ); + const files = allFiles.filter( + (f) => + !f.endsWith(".html") || !notebookBases.has(f.slice(0, -".html".length)), + ); + const outputPath = pkg.outputDir(docsBaseFolder); + await mkdirp(outputPath); + return [files, outputPath, objectsInv]; +} diff --git a/scripts/js/lib/api/conversionPipeline.test.ts b/scripts/js/lib/api/apiDocsPipeline.test.ts similarity index 95% rename from scripts/js/lib/api/conversionPipeline.test.ts rename to scripts/js/lib/api/apiDocsPipeline.test.ts index 90d1c91bd21..3fc28c73ce8 100644 --- a/scripts/js/lib/api/conversionPipeline.test.ts +++ b/scripts/js/lib/api/apiDocsPipeline.test.ts @@ -17,7 +17,7 @@ import { mkdtemp, readFile } from "fs/promises"; import { globby } from "globby"; import { expect, test } from "@playwright/test"; -import { runConversionPipeline } from "./conversionPipeline.js"; +import { runApiDocsPipeline } from "./apiDocsPipeline.js"; import { Pkg, ReleaseNotesConfig } from "./Pkg.js"; // This test uses snapshot testing (https://jestjs.io/docs/snapshot-testing#updating-snapshots). If the tests fail and the changes @@ -66,9 +66,9 @@ test("qiskit-sphinx-theme", async ({}, testInfo) => { releaseNotesConfig: new ReleaseNotesConfig({ enabled: false }), kebabCaseAndShortenUrls: false, }); - const markdownFolder = pkg.outputDir(docsBaseFolder); + const markdownFolder = pkg.apiOutputDir(docsBaseFolder); - await runConversionPipeline( + await runApiDocsPipeline( "scripts/js/lib/api/testdata/qiskit-sphinx-theme", docsBaseFolder, publicBaseFolder, diff --git a/scripts/js/lib/api/conversionPipeline.test.ts-snapshots/-package b/scripts/js/lib/api/apiDocsPipeline.test.ts-snapshots/-package similarity index 100% rename from scripts/js/lib/api/conversionPipeline.test.ts-snapshots/-package rename to scripts/js/lib/api/apiDocsPipeline.test.ts-snapshots/-package diff --git a/scripts/js/lib/api/conversionPipeline.test.ts-snapshots/-toc b/scripts/js/lib/api/apiDocsPipeline.test.ts-snapshots/-toc similarity index 100% rename from scripts/js/lib/api/conversionPipeline.test.ts-snapshots/-toc rename to scripts/js/lib/api/apiDocsPipeline.test.ts-snapshots/-toc diff --git a/scripts/js/lib/api/conversionPipeline.test.ts-snapshots/api-example.Electron b/scripts/js/lib/api/apiDocsPipeline.test.ts-snapshots/api-example.Electron similarity index 100% rename from scripts/js/lib/api/conversionPipeline.test.ts-snapshots/api-example.Electron rename to scripts/js/lib/api/apiDocsPipeline.test.ts-snapshots/api-example.Electron diff --git a/scripts/js/lib/api/conversionPipeline.test.ts-snapshots/api-example.my_function1 b/scripts/js/lib/api/apiDocsPipeline.test.ts-snapshots/api-example.my_function1 similarity index 100% rename from scripts/js/lib/api/conversionPipeline.test.ts-snapshots/api-example.my_function1 rename to scripts/js/lib/api/apiDocsPipeline.test.ts-snapshots/api-example.my_function1 diff --git a/scripts/js/lib/api/conversionPipeline.test.ts-snapshots/index b/scripts/js/lib/api/apiDocsPipeline.test.ts-snapshots/index similarity index 100% rename from scripts/js/lib/api/conversionPipeline.test.ts-snapshots/index rename to scripts/js/lib/api/apiDocsPipeline.test.ts-snapshots/index diff --git a/scripts/js/lib/api/conversionPipeline.test.ts-snapshots/inline-classes b/scripts/js/lib/api/apiDocsPipeline.test.ts-snapshots/inline-classes similarity index 100% rename from scripts/js/lib/api/conversionPipeline.test.ts-snapshots/inline-classes rename to scripts/js/lib/api/apiDocsPipeline.test.ts-snapshots/inline-classes diff --git a/scripts/js/lib/api/conversionPipeline.test.ts-snapshots/module b/scripts/js/lib/api/apiDocsPipeline.test.ts-snapshots/module similarity index 59% rename from scripts/js/lib/api/conversionPipeline.test.ts-snapshots/module rename to scripts/js/lib/api/apiDocsPipeline.test.ts-snapshots/module index c98fe8a279c..aaf48736d3d 100644 --- a/scripts/js/lib/api/conversionPipeline.test.ts-snapshots/module +++ b/scripts/js/lib/api/apiDocsPipeline.test.ts-snapshots/module @@ -20,14 +20,14 @@ Welcome to my super cool module! This is an example! -Testing internal references… [`Electron.compute_momentum()`](api_example.Electron#compute_momentum "api_example.Electron.compute_momentum"). +Testing internal references… [`Electron.compute_momentum()`](/docs/api/qiskit-sphinx-theme/api_example.Electron#compute_momentum "api_example.Electron.compute_momentum"). ## Contents -| | | -| ------------------------------------------------------------------------------------------------ | ----------------------------------- | -| [`Electron`](api_example.Electron "api_example.Electron")(\[size, name]) | A representation of an electron. | -| [`my_function1`](api_example.my_function1 "api_example.my_function1")(input1, input2\[, input3]) | A function that does awesome stuff. | +| | | +| ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------- | +| [`Electron`](/docs/api/qiskit-sphinx-theme/api_example.Electron "api_example.Electron")(\[size, name]) | A representation of an electron. | +| [`my_function1`](/docs/api/qiskit-sphinx-theme/api_example.my_function1 "api_example.my_function1")(input1, input2\[, input3]) | A function that does awesome stuff. | ## Functions diff --git a/scripts/js/lib/api/apiDocsPipeline.ts b/scripts/js/lib/api/apiDocsPipeline.ts new file mode 100644 index 00000000000..bdc4258b088 --- /dev/null +++ b/scripts/js/lib/api/apiDocsPipeline.ts @@ -0,0 +1,121 @@ +// 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 { writeFile } from "fs/promises"; + +import { mkdirp } from "mkdirp"; +import { globby } from "globby"; + +import { HtmlToMdResultWithUrl } from "./HtmlToMdResult.js"; +import { ObjectsInv } from "./objectsInv.js"; +import { Pkg } from "./Pkg.js"; +import { generateToc } from "./generateToc.js"; +import { maybeUpdateReleaseNotesFolder } from "./releaseNotes.js"; +import { + convertHtmlToMarkdown, + copyImages, + postProcess, + writeMarkdownResults, +} from "./pipelineStages.js"; +import { C_API_BASE_PATH, DOCS_BASE_PATH } from "./paths.js"; + +export async function runApiDocsPipeline( + artifactPath: string, + docsBaseFolder: string, + publicBaseFolder: string, + pkg: Pkg, +) { + const allObjectInvs = await ObjectsInv.loadPublishedApis(publicBaseFolder); + const [files, markdownPath, maybeObjectsInv] = await determineFilePaths( + artifactPath, + docsBaseFolder, + pkg, + ); + + const initialResults = await convertHtmlToMarkdown( + pkg, + artifactPath, + docsBaseFolder, + markdownPath, + files, + pkg.apiOutputDir(`${DOCS_BASE_PATH}/images`), + ); + + const results = await postProcess( + pkg, + initialResults, + maybeObjectsInv, + allObjectInvs, + ); + + // Warning: the sequence of operations often matters. + await writeMarkdownResults(pkg, docsBaseFolder, results); + // `publicBaseFolder` is passed as "public/docs"; images go under + // "public/docs/images/api/{pkg}". See also maybeObjectsInv.write below which + // uses pkg.apiOutputDir(publicBaseFolder) — the two outputs share the same + // `api/{pkg}` subtree inside publicBaseFolder. + await copyImages( + pkg, + artifactPath, + pkg.apiOutputDir(`${publicBaseFolder}/images`), + results, + ); + await maybeObjectsInv?.write(pkg.apiOutputDir(publicBaseFolder)); + await maybeUpdateReleaseNotesFolder(pkg, markdownPath); + await writeTocFile(pkg, markdownPath, results); + await writeVersionFile(pkg, markdownPath); +} + +async function determineFilePaths( + htmlPath: string, + docsBaseFolder: string, + pkg: Pkg, +): Promise<[string[], string, ObjectsInv | undefined]> { + const maybeObjectsInv = await (pkg.isProblematicLegacyQiskit() + ? undefined + : ObjectsInv.fromFile(htmlPath, pkg.language)); + + const extraFiles = pkg.isCApi() + ? [`${C_API_BASE_PATH}/**.html`, "apidocs/**.html"] + : ["apidocs/**.html", "apidoc/**.html", "stubs/**.html"]; + const files = await globby( + [...extraFiles, "release_notes.html", "release-notes.html"], + { + cwd: htmlPath, + }, + ); + const markdownPath = pkg.apiOutputDir(docsBaseFolder); + await mkdirp(markdownPath); + return [files, markdownPath, maybeObjectsInv]; +} + +async function writeTocFile( + pkg: Pkg, + markdownPath: string, + results: HtmlToMdResultWithUrl[], +): Promise { + console.log("Generating toc"); + const toc = generateToc(pkg, results); + await writeFile( + `${markdownPath}/_toc.json`, + JSON.stringify(toc, null, 2) + "\n", + ); +} + +async function writeVersionFile(pkg: Pkg, markdownPath: string): Promise { + console.log("Generating version file"); + const pkg_json = { name: pkg.name, version: pkg.version }; + await writeFile( + `${markdownPath}/_package.json`, + JSON.stringify(pkg_json, null, 2) + "\n", + ); +} diff --git a/scripts/js/lib/api/conversionPipeline.ts b/scripts/js/lib/api/conversionPipeline.ts deleted file mode 100644 index d672da35b17..00000000000 --- a/scripts/js/lib/api/conversionPipeline.ts +++ /dev/null @@ -1,212 +0,0 @@ -// 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 { join, parse, relative } from "path"; -import { readFile, writeFile } from "fs/promises"; - -import { mkdirp } from "mkdirp"; -import { globby } from "globby"; -import { uniqBy } from "lodash-es"; - -import { ObjectsInv } from "./objectsInv.js"; -import { sphinxHtmlToMarkdown } from "./htmlToMd.js"; -import { saveImages } from "./saveImages.js"; -import { generateToc } from "./generateToc.js"; -import { HtmlToMdResultWithUrl } from "./HtmlToMdResult.js"; -import { mergeClassMembers } from "./mergeClassMembers.js"; -import { normalizeResultUrls } from "./normalizeResultUrls.js"; -import { updateLinks } from "./updateLinks.js"; -import { specialCaseResults } from "./specialCaseResults.js"; -import addFrontMatter from "./addFrontMatter.js"; -import { dedupeHtmlIdsFromResults } from "./dedupeHtmlIds.js"; -import removeMathBlocksIndentation from "./removeMathBlocksIndentation.js"; -import { Pkg } from "./Pkg.js"; -import { - maybeUpdateReleaseNotesFolder, - handleReleaseNotesFile, -} from "./releaseNotes.js"; - -// This is the folder that contains all C API docs in the Sphinx artifact. -export const C_API_BASE_PATH = "cdoc" as const; - -export const DOCS_BASE_PATH = "/docs"; - -export async function runConversionPipeline( - htmlPath: string, - docsBaseFolder: string, - publicBaseFolder: string, - pkg: Pkg, -) { - const [files, markdownPath, maybeObjectsInv] = await determineFilePaths( - htmlPath, - docsBaseFolder, - pkg, - ); - let initialResults = await convertFilesToMarkdown( - pkg, - htmlPath, - docsBaseFolder, - markdownPath, - files, - ); - - const results = await postProcessResults( - pkg, - maybeObjectsInv, - initialResults, - ); - - // Warning: the sequence of operations often matters. - await writeMarkdownResults(pkg, docsBaseFolder, results); - await copyImages(pkg, htmlPath, "public", results); - await maybeObjectsInv?.write(pkg.outputDir(publicBaseFolder)); - await maybeUpdateReleaseNotesFolder(pkg, markdownPath); - await writeTocFile(pkg, markdownPath, results); - await writeVersionFile(pkg, markdownPath); -} - -async function determineFilePaths( - htmlPath: string, - docsBaseFolder: string, - pkg: Pkg, -): Promise<[string[], string, ObjectsInv | undefined]> { - const maybeObjectsInv = await (pkg.isProblematicLegacyQiskit() - ? undefined - : ObjectsInv.fromFile(htmlPath, pkg.language)); - - const extraFiles = pkg.isCApi() - ? [`${C_API_BASE_PATH}/**.html`] - : ["apidocs/**.html", "apidoc/**.html", "stubs/**.html"]; - const files = await globby( - [...extraFiles, "release_notes.html", "release-notes.html"], - { - cwd: htmlPath, - }, - ); - const markdownPath = pkg.outputDir(docsBaseFolder); - await mkdirp(markdownPath); - return [files, markdownPath, maybeObjectsInv]; -} - -async function convertFilesToMarkdown( - pkg: Pkg, - htmlPath: string, - docsBaseFolder: string, - markdownPath: string, - filePaths: string[], -): Promise { - const results = []; - for (const file of filePaths) { - const html = await readFile(join(htmlPath, file), "utf-8"); - const result = await sphinxHtmlToMarkdown({ - html, - fileName: file, - determineGithubUrl: pkg.determineGithubUrlFn(), - imageDestination: pkg.outputDir(`${DOCS_BASE_PATH}/images`), - releaseNotesTitle: pkg.releaseNotesTitle(), - hasSeparateReleaseNotes: pkg.hasSeparateReleaseNotes(), - isCApi: pkg.isCApi(), - hasRootNamespaceFile: pkg.hasRootNamespaceFile, - }); - - // Avoid creating an empty markdown file for HTML files without content - // (e.g. HTML redirects) - if (result.markdown == "") { - continue; - } - - const { dir, name } = parse(`${markdownPath}/${file}`); - let url = `/${relative(docsBaseFolder, dir)}/${name}`; - results.push({ ...result, url }); - } - return results; -} - -async function copyImages( - pkg: Pkg, - htmlPath: string, - publicBaseFolder: string, - results: HtmlToMdResultWithUrl[], -): Promise { - console.log("Saving images"); - const allImages = uniqBy( - results.flatMap((result) => result.images), - (image) => image.fileName, - ); - await saveImages(allImages, `${htmlPath}/_images`, publicBaseFolder, pkg); -} - -async function postProcessResults( - pkg: Pkg, - maybeObjectsInv: ObjectsInv | undefined, - initialResults: HtmlToMdResultWithUrl[], -): Promise { - const results = await mergeClassMembers(initialResults); - normalizeResultUrls(results, { - kebabCaseAndShorten: pkg.kebabCaseAndShortenUrls, - pkgName: pkg.name, - }); - specialCaseResults(results); - await updateLinks( - results, - { - kebabCaseAndShorten: pkg.kebabCaseAndShortenUrls, - pkgName: pkg.name, - pkgOutputDir: pkg.outputDir(DOCS_BASE_PATH), - }, - maybeObjectsInv, - ); - await dedupeHtmlIdsFromResults(results); - addFrontMatter(results, pkg); - removeMathBlocksIndentation(results); - return results; -} - -async function writeMarkdownResults( - pkg: Pkg, - docsBaseFolder: string, - results: HtmlToMdResultWithUrl[], -): Promise { - for (const result of results) { - let path = `${docsBaseFolder}${result.url}.mdx`; - if (path.endsWith("release-notes.mdx")) { - if (!pkg.releaseNotesConfig.enabled) continue; - - const shouldWriteResult = await handleReleaseNotesFile(result, pkg); - if (!shouldWriteResult) continue; - } - - await writeFile(path, result.markdown); - } -} - -async function writeTocFile( - pkg: Pkg, - markdownPath: string, - results: HtmlToMdResultWithUrl[], -): Promise { - console.log("Generating toc"); - const toc = generateToc(pkg, results); - await writeFile( - `${markdownPath}/_toc.json`, - JSON.stringify(toc, null, 2) + "\n", - ); -} - -async function writeVersionFile(pkg: Pkg, markdownPath: string): Promise { - console.log("Generating version file"); - const pkg_json = { name: pkg.name, version: pkg.version }; - await writeFile( - `${markdownPath}/_package.json`, - JSON.stringify(pkg_json, null, 2) + "\n", - ); -} diff --git a/scripts/js/lib/api/generateAddonToc.test.ts b/scripts/js/lib/api/generateAddonToc.test.ts new file mode 100644 index 00000000000..ffccda21e0a --- /dev/null +++ b/scripts/js/lib/api/generateAddonToc.test.ts @@ -0,0 +1,452 @@ +// This code is a Qiskit project. +// +// (C) Copyright IBM 2026. +// +// 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 os from "os"; +import path from "path"; +import { mkdtemp, mkdir, writeFile } from "fs/promises"; + +import { expect, test } from "@playwright/test"; + +import { generateAddonToc } from "./generateAddonToc.js"; +import { Pkg, ReleaseNotesConfig } from "./Pkg.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function makePkg(name = "my-addon", githubSlug?: string): Promise { + return new Pkg({ + name, + title: "My Addon", + githubSlug, + version: "1.2.0", + versionWithoutPatch: "1.2", + type: "latest", + language: "Python", + releaseNotesConfig: new ReleaseNotesConfig({ enabled: false }), + kebabCaseAndShortenUrls: true, + }); +} + +/** + * Builds a minimal Sphinx-style index.html with a sidebar tree. + * + * mainItems: top-level items in the primary
    toctree. + * Each item can be: + * - a string href (top-level page link) + * - { href, title } (top-level page with custom title) + * - { href, title, external: true } (external link) + * - { href, title, children: [{ href, title }] } (section with sub-pages) + * + * captionedSections: optional captioned toctree groups appended after the main list. + * Each is { caption: string; items: NavItem[] }. + */ +type NavChild = { href: string; title: string }; +type NavItem = + | string + | { href: string; title?: string; external?: boolean; children?: NavChild[] }; +type CaptionedSection = { caption: string; items: NavItem[] }; + +function makeNavLi(item: NavItem): string { + if (typeof item === "string") { + const title = item.replace(/\.html$/, "").replace(/[-_]/g, " "); + return `
  • ${title}
  • `; + } + const { + href, + title = href.replace(/\.html$/, ""), + external, + children, + } = item; + const cls = external ? "reference external" : "reference internal"; + if (children && children.length > 0) { + const l2s = children + .map( + (c) => + `
  • ${c.title}
  • `, + ) + .join("\n "); + return `
  • ${title} +
      ${l2s}
    +
  • `; + } + return `
  • ${title}
  • `; +} + +function makeIndexHtml( + mainItems: NavItem[], + captionedSections: CaptionedSection[] = [], +): string { + const mainLis = mainItems.map(makeNavLi).join("\n "); + const captionBlocks = captionedSections + .map(({ caption, items }) => { + const lis = items.map(makeNavLi).join("\n "); + return `

    ${caption}

    +
      + ${lis} +
    `; + }) + .join("\n"); + + return ` + + + + +`; +} + +/** Creates a temp artifact directory with an index.html. */ +async function makeTestDirs( + mainItems: NavItem[], + captionedSections: CaptionedSection[] = [], +): Promise<{ artifactDir: string }> { + const tmpDir = await mkdtemp(path.join(os.tmpdir(), "addon-toc-test-")); + const artifactDir = path.join(tmpDir, "artifact"); + await mkdir(artifactDir, { recursive: true }); + await writeFile( + path.join(artifactDir, "index.html"), + makeIndexHtml(mainItems, captionedSections), + "utf-8", + ); + return { artifactDir }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +test("minimal addon: only index and install, no captioned sections", async () => { + const { artifactDir } = await makeTestDirs([ + { href: "#", title: "My Addon" }, + { href: "install.html", title: "Installation" }, + ]); + + const pkg = await makePkg(); + const toc = await generateAddonToc(pkg, artifactDir); + + expect(toc).toEqual({ + parentUrl: "/docs/guides/addons", + parentLabel: "Documentation", + title: "My Addon 1.2.0", + collapsed: true, + children: [ + { + title: "", + collapsible: false, + children: [ + { title: "My Addon", url: "/docs/addons/my-addon" }, + { title: "Installation", url: "/docs/addons/my-addon/install" }, + ], + }, + ], + }); +}); + +test("full shape with Tutorials and API reference captions", async () => { + const { artifactDir } = await makeTestDirs( + [ + { href: "#", title: "My Addon" }, + { href: "install.html", title: "Installation" }, + { + href: "https://github.com/Qiskit/my-addon", + title: "GitHub", + external: true, + }, + ], + [ + { + caption: "Tutorials", + items: [ + { + href: "https://quantum.cloud.ibm.com/docs/en/tutorials/my-tutorial", + title: "My tutorial", + external: true, + }, + ], + }, + { + caption: "API reference", + items: [ + { + href: "https://quantum.cloud.ibm.com/docs/en/api/my-addon", + title: "Python API reference", + external: true, + }, + { href: "release-notes.html", title: "Release notes" }, + ], + }, + ], + ); + + const pkg = await makePkg(); + const toc = await generateAddonToc(pkg, artifactDir); + + expect(toc).toEqual({ + parentUrl: "/docs/guides/addons", + parentLabel: "Documentation", + title: "My Addon 1.2.0", + collapsed: true, + children: [ + { + title: "", + collapsible: false, + children: [ + { title: "My Addon", url: "/docs/addons/my-addon" }, + { title: "Installation", url: "/docs/addons/my-addon/install" }, + { title: "GitHub", url: "https://github.com/Qiskit/my-addon" }, + ], + }, + { + title: "Tutorials", + collapsible: false, + children: [ + { + title: "My tutorial", + url: "https://quantum.cloud.ibm.com/docs/en/tutorials/my-tutorial", + }, + ], + }, + { + title: "API reference", + collapsible: false, + children: [ + { + title: "Python API reference", + url: "https://quantum.cloud.ibm.com/docs/en/api/my-addon", + }, + ], + }, + ], + }); +}); + +test("API reference caption: external links pass through unchanged", async () => { + const { artifactDir } = await makeTestDirs( + [{ href: "#", title: "Home" }], + [ + { + caption: "API reference", + items: [ + { + href: "https://example.com/api/my-addon", + title: "Python API reference", + external: true, + }, + ], + }, + ], + ); + + const pkg = await makePkg(); + const toc = await generateAddonToc(pkg, artifactDir); + const apiSection = toc.children.find((c) => c.title === "API reference"); + + expect(apiSection?.children?.[0]).toEqual({ + title: "Python API reference", + url: "https://example.com/api/my-addon", + }); +}); + +test("release notes entry is always omitted", async () => { + const { artifactDir } = await makeTestDirs( + [{ href: "#", title: "Home" }], + [ + { + caption: "API reference", + items: [ + { + href: "https://example.com/api/my-addon", + title: "Python API reference", + external: true, + }, + { href: "release-notes.html", title: "Release notes" }, + ], + }, + ], + ); + + const pkg = await makePkg(); + const toc = await generateAddonToc(pkg, artifactDir); + const apiSection = toc.children.find((c) => c.title === "API reference"); + + expect(apiSection?.children).toHaveLength(1); + expect(apiSection?.children?.[0].url).toBe( + "https://example.com/api/my-addon", + ); +}); + +test("sidebar order is preserved exactly", async () => { + const { artifactDir } = await makeTestDirs([ + { href: "#", title: "Home" }, + { href: "install.html", title: "Install" }, + { href: "changelog.html", title: "Changelog" }, + { href: "faq.html", title: "FAQ" }, + ]); + + const pkg = await makePkg(); + const toc = await generateAddonToc(pkg, artifactDir); + const main = toc.children[0]; + + expect(main.children?.map((c) => c.title)).toEqual([ + "Home", + "Install", + "Changelog", + "FAQ", + ]); +}); + +test("subdirectory section preserves l2 children order from sidebar", async () => { + const { artifactDir } = await makeTestDirs([ + { href: "#", title: "Home" }, + { + href: "how_tos/index.html", + title: "Guides", + children: [ + { href: "how_tos/beta.html", title: "Beta guide" }, + { href: "how_tos/alpha.html", title: "Alpha guide" }, + { href: "how_tos/gamma.html", title: "Gamma guide" }, + ], + }, + ]); + + const pkg = await makePkg(); + const toc = await generateAddonToc(pkg, artifactDir); + const main = toc.children[0]; + + const guides = main.children?.find((c) => c.title === "Guides"); + expect(guides?.children?.map((c) => c.title)).toEqual([ + "Beta guide", + "Alpha guide", + "Gamma guide", + ]); +}); + +test("subdirectory section has correct child URLs", async () => { + const { artifactDir } = await makeTestDirs([ + { href: "#", title: "Home" }, + { + href: "how_tos/index.html", + title: "Guides", + children: [{ href: "how_tos/my_guide.html", title: "My Guide" }], + }, + ]); + + const pkg = await makePkg(); + const toc = await generateAddonToc(pkg, artifactDir); + const main = toc.children[0]; + + const guides = main.children?.find((c) => c.title === "Guides"); + expect(guides?.children?.[0].url).toBe( + "/docs/addons/my-addon/how_tos/my-guide", + ); +}); + +test("external link in main section is passed through unchanged", async () => { + const { artifactDir } = await makeTestDirs([ + { href: "#", title: "Home" }, + { + href: "https://github.com/Qiskit/my-addon", + title: "GitHub", + external: true, + }, + ]); + + const pkg = await makePkg(); + const toc = await generateAddonToc(pkg, artifactDir); + const main = toc.children[0]; + + expect(main.children?.at(-1)).toEqual({ + title: "GitHub", + url: "https://github.com/Qiskit/my-addon", + }); +}); + +test("href='#' maps to package root URL", async () => { + const { artifactDir } = await makeTestDirs([{ href: "#", title: "Home" }]); + + const pkg = await makePkg(); + const toc = await generateAddonToc(pkg, artifactDir); + const main = toc.children[0]; + + expect(main.children?.[0].url).toBe("/docs/addons/my-addon"); +}); + +test("captioned sections appear after the main section in order", async () => { + const { artifactDir } = await makeTestDirs( + [{ href: "#", title: "Home" }], + [ + { + caption: "Tutorials", + items: [ + { + href: "https://example.com/tut", + title: "A tutorial", + external: true, + }, + ], + }, + { + caption: "API reference", + items: [ + { + href: "https://example.com/api", + title: "Python API reference", + external: true, + }, + ], + }, + ], + ); + + const pkg = await makePkg(); + const toc = await generateAddonToc(pkg, artifactDir); + + expect(toc.children.map((c) => c.title)).toEqual([ + "", + "Tutorials", + "API reference", + ]); +}); + +test("missing index.html throws", async () => { + const tmpDir = await mkdtemp(path.join(os.tmpdir(), "addon-toc-test-")); + const artifactDir = path.join(tmpDir, "artifact"); + await mkdir(artifactDir, { recursive: true }); + + const pkg = await makePkg(); + await expect(generateAddonToc(pkg, artifactDir)).rejects.toThrow(); +}); + +test("subdirectory section has no url property on the section entry", async () => { + const { artifactDir } = await makeTestDirs([ + { href: "#", title: "Home" }, + { + href: "how_tos/index.html", + title: "Guides", + children: [{ href: "how_tos/guide.html", title: "A guide" }], + }, + ]); + + const pkg = await makePkg(); + const toc = await generateAddonToc(pkg, artifactDir); + const main = toc.children[0]; + + const guidesEntry = main.children?.find((c) => c.title === "Guides"); + expect(guidesEntry?.url).toBeUndefined(); + expect(guidesEntry?.children).toHaveLength(1); +}); diff --git a/scripts/js/lib/api/generateAddonToc.ts b/scripts/js/lib/api/generateAddonToc.ts new file mode 100644 index 00000000000..3c233c45226 --- /dev/null +++ b/scripts/js/lib/api/generateAddonToc.ts @@ -0,0 +1,177 @@ +// This code is a Qiskit project. +// +// (C) Copyright IBM 2026. +// +// 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. + +// Generates the _toc.json for an addon's content pages. +// +// Addon TOCs have a fixed shape built from the Sphinx sidebar tree in index.html: +// 1. A "main" section (unnamed) from the primary
      toctree. +// 2. Optional captioned sections (e.g. "Tutorials") from

      groups. +// 3. An "API reference" section: external items from the sphinx caption's

        +// (excluding "release notes") are replaced by a local /docs/api/{pkg} link, +// plus any release-notes entry from the sphinx caption. +// +// Called by addonDocsPipeline.ts; for API doc TOCs see generateToc.ts. + +import { readFile } from "fs/promises"; +import { join, parse } from "path/posix"; + +import { load } from "cheerio"; + +import { Pkg } from "./Pkg.js"; +import { TocEntry } from "./generateToc.js"; +import { DOCS_BASE_PATH } from "./paths.js"; +import { kebabCaseAndShortenPage } from "./normalizeResultUrls.js"; + +type AddonTocSection = TocEntry & { collapsible?: boolean }; + +type AddonToc = { + parentUrl: string; + parentLabel: string; + title: string; + collapsed: true; + children: AddonTocSection[]; +}; + +export async function generateAddonToc( + pkg: Pkg, + artifactPath: string, +): Promise { + const addonUrlBase = `${DOCS_BASE_PATH}/addons/${pkg.name}`; + + const { mainChildren, captionedSections } = await buildSectionsFromSidebar( + artifactPath, + pkg, + addonUrlBase, + ); + + const children: AddonTocSection[] = [ + { title: "", children: mainChildren, collapsible: false }, + ...captionedSections.map((section) => ({ + title: section.title, + collapsible: false as const, + children: section.children, + })), + ]; + + return { + parentUrl: "/docs/guides/addons", + parentLabel: "Documentation", + title: `${pkg.title} ${pkg.version}`, + collapsed: true, + children, + }; +} + +type SidebarSections = { + mainChildren: TocEntry[]; + captionedSections: Array<{ title: string; children: TocEntry[] }>; +}; + +/** + * Parses the Sphinx sidebar tree from index.html into the main (uncaptioned) + * section and any captioned sections (e.g. "Tutorials", "API reference"). + * + * The main section comes from the primary
          toctree. + * Captioned sections come from

          + sibling

            pairs. + */ +async function buildSectionsFromSidebar( + artifactPath: string, + pkg: Pkg, + addonUrlBase: string, +): Promise { + const html = await readFile(join(artifactPath, "index.html"), "utf-8"); + const $ = load(html); + + const mainChildren = parseTocUl( + $, + $(".sidebar-tree > ul.current"), + pkg, + addonUrlBase, + ); + + const captionedSections: Array<{ title: string; children: TocEntry[] }> = []; + $(".sidebar-tree > p.caption").each((_, caption) => { + const title = $(caption).find(".caption-text").text().trim(); + const ul = $(caption).next("ul"); + const children = parseTocUl($, ul, pkg, addonUrlBase); + if (title && children.length > 0) { + captionedSections.push({ title, children }); + } + }); + + return { mainChildren, captionedSections }; +} + +/** + * Converts a
              of toctree-l1 items into TocEntry objects. + * Top-level pages become flat entries; has-children items become sections. + */ +function parseTocUl( + $: ReturnType, + ul: ReturnType>, + pkg: Pkg, + addonUrlBase: string, +): TocEntry[] { + const entries: TocEntry[] = []; + + ul.children("li").each((_, l1) => { + const $l1 = $(l1); + const a = $l1.children("a").first(); + const href = a.attr("href") ?? ""; + const title = a.text().trim(); + + if (title.toLowerCase() === "release notes") return; + + // External link (e.g. GitHub, tutorial links) + if (a.hasClass("external")) { + entries.push({ title, url: href }); + return; + } + + // href="#" means "this page" (index.html) in Sphinx when current-page is active + if (href === "#") { + entries.push({ title, url: addonUrlBase }); + return; + } + + // Subdirectory section: has-children class with l2 items + if ($l1.hasClass("has-children")) { + const children: TocEntry[] = []; + $l1.find("ul > li > a").each((_, a2) => { + const childHref = $(a2).attr("href") ?? ""; + const dir = childHref.split("/")[0]; + const slug = hrefToSlug(childHref, pkg); + children.push({ + title: $(a2).text().trim(), + url: `${addonUrlBase}/${dir}/${slug}`, + }); + }); + if (children.length > 0) entries.push({ title, children }); + return; + } + + const slug = hrefToSlug(href, pkg); + // Top-level page (may be a subdirectory index like "explanation/index.html") + const path = href.includes("/") ? `${href.split("/")[0]}/${slug}` : slug; + entries.push({ title, url: `${addonUrlBase}/${path}` }); + }); + + return entries; +} + +/** Converts a Sphinx HTML href filename to the kebab-case slug used in the output MDX. */ +function hrefToSlug(href: string, pkg: Pkg): string { + const name = parse(href).name; + return pkg.kebabCaseAndShortenUrls + ? kebabCaseAndShortenPage(name, pkg.name) + : name; +} diff --git a/scripts/js/lib/api/generateToc.ts b/scripts/js/lib/api/generateToc.ts index 2001244c4b9..f89c5255e40 100644 --- a/scripts/js/lib/api/generateToc.ts +++ b/scripts/js/lib/api/generateToc.ts @@ -16,7 +16,7 @@ import { getLastPartFromFullIdentifier } from "../stringUtils.js"; import { HtmlToMdResultWithUrl } from "./HtmlToMdResult.js"; import { Pkg } from "./Pkg.js"; import type { TocGrouping } from "./TocGrouping.js"; -import { DOCS_BASE_PATH } from "./conversionPipeline.js"; +import { DOCS_BASE_PATH } from "./paths.js"; import { groupByMajorVersion } from "./releaseNotes.js"; export type TocEntry = { @@ -35,6 +35,8 @@ type Toc = { children: TocEntry[]; collapsed: boolean; untranslatable?: boolean; + parentUrl?: string; + parentLabel?: string; }; export function generateToc(pkg: Pkg, results: HtmlToMdResultWithUrl[]): Toc { @@ -71,6 +73,10 @@ export function generateToc(pkg: Pkg, results: HtmlToMdResultWithUrl[]): Toc { children: orderedEntries, collapsed: true, untranslatable: true, + ...(pkg.isAddon() && { + parentUrl: `/docs/addons/${pkg.name}`, + parentLabel: pkg.title, + }), }; } @@ -226,7 +232,7 @@ function ensureIndexPage( pkg: Pkg, tocModules: TocEntry[], ): TocEntry | undefined { - const docsFolder = pkg.outputDir(`${DOCS_BASE_PATH}/`); + const docsFolder = pkg.apiOutputDir(`${DOCS_BASE_PATH}/`); return tocModules.some((entry) => entry.url === docsFolder) ? undefined : { diff --git a/scripts/js/lib/api/htmlToMd.test.ts b/scripts/js/lib/api/htmlToMd.test.ts index 98839280a87..c998aac935e 100644 --- a/scripts/js/lib/api/htmlToMd.test.ts +++ b/scripts/js/lib/api/htmlToMd.test.ts @@ -79,30 +79,6 @@ You need to initialize your account before you can start using the Qiskit Runtim `); }); -// ------------------------------------------------------------------ -// Transform special characters -// ------------------------------------------------------------------ - -test("handle special characters: `<` and `{`", async () => { - expect( - await toMd(` -
              -

              For the full list of backend attributes, see the IBMBackend class documentation -<https://qiskit.org/documentation/apidoc/providers_models.html>

              -

              - -

              basis_fidelity (dict | float) – available strengths and fidelity of each. -Can be either (1) a dictionary mapping XX angle values to fidelity at that angle; or -(2) a single float f, interpreted as {pi: f, pi/2: f/2, pi/3: f/3}.

              -
              - `), - ) - .toEqual(`For the full list of backend attributes, see the IBMBackend class documentation \\<[https://qiskit.org/documentation/apidoc/providers\\_models.html](https://qiskit.org/documentation/apidoc/providers_models.html)> - -**basis\\_fidelity** (*dict | float*) – available strengths and fidelity of each. Can be either (1) a dictionary mapping XX angle values to fidelity at that angle; or (2) a single float f, interpreted as \\{pi: f, pi/2: f/2, pi/3: f/3}. -`); -}); - // ------------------------------------------------------------------ // Transform code blocks // ------------------------------------------------------------------ @@ -524,6 +500,31 @@ test("transform inline math", async () => { `); }); +test("escape pipe characters in math inside table cells", async () => { + expect( + await toMd(` +
              + + + + + + + + + + + +

              Gate(s)

              KAK angles

              Sampling overhead

              RXXGate

              \\((|\\theta/2|, 0, 0)\\)

              \\(\\left[1 + 2 \\left|\\sin(\\theta)\\right| \\right]^2\\)

              +
              + `), + ) + .toEqual(`| Gate(s) | KAK angles | Sampling overhead | +| ------- | ------------------------------ | ----------------------------------------------------------- | +| RXXGate | $(\\vert \\theta/2\\vert , 0, 0)$ | $\\left[1 + 2 \\left\\vert \\sin(\\theta)\\right\\vert \\right]^2$ | +`); +}); + test("transform block math", async () => { expect( await toMd(` diff --git a/scripts/js/lib/api/htmlToMd.ts b/scripts/js/lib/api/htmlToMd.ts index 7abf0b466bf..480f7ad8381 100644 --- a/scripts/js/lib/api/htmlToMd.ts +++ b/scripts/js/lib/api/htmlToMd.ts @@ -66,6 +66,7 @@ async function generateMarkdownFile( .use(rehypeRemark, { handlers, }) + .use(remarkEscapeMathPipesInTables) .use(remarkStringify, remarkStringifyOptions) .use(() => (root: Root) => { visit(root, "emphasis", mergeContiguousEmphasis); @@ -243,9 +244,10 @@ function buildAdmonition( handlers: Record, ): MdxJsxFlowElement { const titleNode = findNodeWithProperty(node.children, "admonition-title"); - const children: Array = without(node.children, titleNode).map( - (node: any) => toMdast(node, { handlers }), - ); + const children: Array = without( + node.children, + titleNode ?? undefined, + ).map((node: any) => toMdast(node, { handlers })); let type = "note"; if (nodeClasses.includes("warning")) { @@ -338,6 +340,16 @@ function buildMathExpression(node: any, type: "math" | "inlineMath"): any { return { type: type, value }; } +function remarkEscapeMathPipesInTables() { + return (root: Root) => { + visit(root, "tableCell", (cell: any) => { + visit(cell, "inlineMath", (mathNode: any) => { + mathNode.value = mathNode.value.replaceAll("|", "\\vert "); + }); + }); + }; +} + function buildApiComponent(h: H, node: any): any { const componentName = capitalize(node.tagName); diff --git a/scripts/js/lib/api/normalizeResultUrls.ts b/scripts/js/lib/api/normalizeResultUrls.ts index 375411fe6f1..f50679b8d09 100644 --- a/scripts/js/lib/api/normalizeResultUrls.ts +++ b/scripts/js/lib/api/normalizeResultUrls.ts @@ -13,7 +13,7 @@ import { kebabCase, initial, last } from "lodash-es"; import { HtmlToMdResultWithUrl } from "./HtmlToMdResult.js"; -import { C_API_BASE_PATH } from "./conversionPipeline.js"; +import { C_API_BASE_PATH } from "./paths.js"; import { removePart } from "../stringUtils.js"; export function kebabCaseAndShortenPage(page: string, pkgName: string): string { diff --git a/scripts/js/lib/api/notebookStages.ts b/scripts/js/lib/api/notebookStages.ts new file mode 100644 index 00000000000..914cbcbadc1 --- /dev/null +++ b/scripts/js/lib/api/notebookStages.ts @@ -0,0 +1,212 @@ +// This code is a Qiskit project. +// +// (C) Copyright IBM 2026. +// +// 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. + +// Jupyter notebook stages used by the addon docs pipeline. The API pipeline +// does not process notebooks today, but these stages live alongside the other +// shared stages so that any future pipeline needing notebook handling can +// reuse them without duplication. + +import { dirname, parse, relative } from "path"; +import { readFile, writeFile } from "fs/promises"; + +import { mkdirp } from "mkdirp"; +import { visit, EXIT } from "unist-util-visit"; + +import { Image } from "./HtmlToMdResult.js"; +import { ObjectsInv } from "./objectsInv.js"; +import { Pkg } from "./Pkg.js"; +import { kebabCaseAndShortenPage } from "./normalizeResultUrls.js"; +import { relativizeLink } from "./updateLinks.js"; +import { transformSpecialCaseUrl } from "./specialCaseResults.js"; +import { parseMarkdown, extractHeadingText } from "../markdownUtils.js"; +import { NotebookCell, NotebookWithUrl } from "./Notebooks.js"; + +export async function readNotebooks( + artifactPath: string, + docsBaseFolder: string, + outputPath: string, + filePaths: string[], +): Promise { + const results: NotebookWithUrl[] = []; + for (const file of filePaths) { + const raw = await readFile(`${artifactPath}/${file}`, "utf-8"); + const notebook = JSON.parse(raw); + const { dir, name } = parse(`${outputPath}/${file}`); + const url = `/${relative(docsBaseFolder, dir)}/${name}`; + results.push({ ...notebook, url }); + } + return results; +} + +/** + * Rewrite markdown-cell links in each notebook: relativize old doc URLs and + * resolve `qiskit.github.io/{pkg}/stubs/...` links via the published-API + * inventories. Then prepend a frontmatter cell with a title extracted from + * the first markdown h1. + */ +export function processNotebooks( + notebooks: NotebookWithUrl[], + objectsInv: ObjectsInv, + allInvs: Map, + pkg: Pkg, + imageDestination: string, +): NotebookWithUrl[] { + return notebooks.map((notebook) => { + const processedCells = notebook.cells.map((cell) => { + if (cell.cell_type !== "markdown") return cell; + const linked = rewriteNotebookLinks( + cell.source, + objectsInv, + allInvs, + imageDestination, + ); + const source = stripInlineStyles(linked); + return { ...cell, source }; + }); + + const frontmatterCell = buildFrontmatterCell(processedCells, pkg); + return { + ...notebook, + cells: frontmatterCell + ? [frontmatterCell, ...processedCells] + : processedCells, + }; + }); +} + +/** + * Extract images referenced in notebook markdown cells as `Image` objects + * so they can be passed to `copyImages` alongside HTML-derived images. + */ +export function collectNotebookImages( + notebooks: NotebookWithUrl[], + imageDestination: string, +): Image[] { + const seen = new Set(); + const images: Image[] = []; + for (const notebook of notebooks) { + for (const cell of notebook.cells) { + if (cell.cell_type !== "markdown") continue; + const text = Array.isArray(cell.source) + ? cell.source.join("") + : cell.source; + for (const match of text.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)) { + const src = match[1]; + if (src.startsWith("http://") || src.startsWith("https://")) continue; + const fileName = src.split("/").pop()!; + if (seen.has(fileName)) continue; + seen.add(fileName); + images.push({ + fileName, + dest: `${imageDestination}/${fileName}`, + originSrc: `_images/${fileName}`, + }); + } + } + } + return images; +} + +export async function writeNotebooks( + pkg: Pkg, + docsBaseFolder: string, + notebooks: NotebookWithUrl[], +): Promise { + for (const { url, ...notebook } of notebooks) { + const normalizedUrl = normalizeNotebookUrl(url, pkg); + const path = `${docsBaseFolder}${normalizedUrl}.ipynb`; + await mkdirp(dirname(path)); + await writeFile(path, JSON.stringify(notebook, null, 1)); + } +} + +function normalizeNotebookUrl(url: string, pkg: Pkg): string { + const parts = url.split("/"); + const filename = parts[parts.length - 1]; + const normalized = pkg.kebabCaseAndShortenUrls + ? kebabCaseAndShortenPage(filename, pkg.name) + : filename; + return transformSpecialCaseUrl([...parts.slice(0, -1), normalized].join("/")); +} + +function stripInlineStyles(source: string): string { + return source.replace(/(<[a-zA-Z][^>]*?)\s+style="[^"]*"/g, "$1"); +} + +function rewriteNotebookLinks( + source: string | string[], + objectsInv: ObjectsInv, + allInvs: Map, + imageDestination: string, +): string { + const rewrite = (line: string) => { + return line.replace( + /(!?)\[([^\]]*)\]\(([^)]+)\)/g, + (_match, bang, text, url) => { + if (bang === "!") { + return `![${text}](${rewriteNotebookImageSrc(url, imageDestination)})`; + } + const relativized = relativizeLink({ url, text }); + if (relativized) url = relativized.url; + const stub = objectsInv.resolveStubUrl(url, allInvs); + if (stub) url = stub; + return `[${text}](${url})`; + }, + ); + }; + + const rewritten = Array.isArray(source) + ? source.map(rewrite) + : rewrite(source); + return Array.isArray(rewritten) ? rewritten.join("") : rewritten; +} + +/** + * Rewrite Sphinx artifact-relative image paths (e.g. `../_static/images/foo.png`) + * to the public docs image destination. External URLs are left unchanged. + */ +function rewriteNotebookImageSrc( + src: string, + imageDestination: string, +): string { + if (src.startsWith("http://") || src.startsWith("https://")) return src; + return `${imageDestination}/${src.split("/").pop()!}`; +} + +function buildFrontmatterCell( + cells: NotebookCell[], + pkg: Pkg, +): NotebookCell | undefined { + for (const cell of cells) { + if (cell.cell_type !== "markdown") continue; + const text = Array.isArray(cell.source) + ? cell.source.join("") + : cell.source; + const tree = parseMarkdown(text); + let title: string | undefined; + visit(tree, "heading", (node: any) => { + if (node.depth === 1 && !title) { + title = extractHeadingText(node).trim(); + return EXIT; + } + }); + if (title) { + return { + id: "frontmatter", // hardcoded so the id doesn't change across runs + cell_type: "markdown", + source: `---\ntitle: "${title}"\ndescription: "${title} for the latest version of ${pkg.title}"\n---`, + metadata: {}, + }; + } + } + return undefined; +} diff --git a/scripts/js/lib/api/objectsInv.test.ts b/scripts/js/lib/api/objectsInv.test.ts index b1ac4845698..d906823c0a9 100644 --- a/scripts/js/lib/api/objectsInv.test.ts +++ b/scripts/js/lib/api/objectsInv.test.ts @@ -20,8 +20,11 @@ const TEMP_FOLDER = "scripts/js/lib/api/testdata/temp/"; test.describe("objects.inv", () => { test.afterAll(async () => { - if (await stat(TEMP_FOLDER + "objects.inv")) { + try { + await stat(TEMP_FOLDER + "objects.inv"); await unlink(TEMP_FOLDER + "objects.inv"); + } catch { + // file doesn't exist, nothing to clean up } }); @@ -35,22 +38,33 @@ test.describe("objects.inv", () => { "# The remainder of this file is compressed using zlib.\n", ); - const uriIndices = [10, 88, 107, 1419, 23575]; - // This test fails when you include / exclude entries, which shifts some array indices. - // Use the following code to find the new indices. - // console.log(objectsInv.entries.findLastIndex( e => { return e.uri.includes("index") })) - expect(uriIndices.map((i) => objectsInv.entries[i].uri)).toEqual([ + // std: entries that don't point into stubs/ or apidocs/ must be filtered + // out — they're RST structural labels that don't correspond to published + // pages and would produce false broken-link errors. + expect( + objectsInv.entries.some( + (e) => + e.domainAndRole.startsWith("std:") && + !e.uri.startsWith("stubs/") && + !e.uri.startsWith("apidocs/"), + ), + ).toBe(false); + + // Spot-check that specific API symbol entries are present. + const urisToFind = [ "stubs/qiskit.algorithms.AlgorithmJob.html#qiskit.algorithms.AlgorithmJob.job_id", "stubs/qiskit.algorithms.FasterAmplitudeEstimation.html#qiskit.algorithms.FasterAmplitudeEstimation.sampler", "stubs/qiskit.algorithms.Grover.html#qiskit.algorithms.Grover.quantum_instance", "apidoc/assembler.html#qiskit.assembler.disassemble", - "index.html", - ]); - const nameIndices = [23575, 24146]; - expect(nameIndices.map((i) => objectsInv.entries[i].dispname)).toEqual([ - "Qiskit 0.45 documentation", - "FakeOslo", - ]); + ]; + for (const uri of urisToFind) { + expect(objectsInv.entries.some((e) => e.uri === uri)).toBe(true); + } + + // Spot-check a known dispname. + expect(objectsInv.entries.some((e) => e.dispname === "FakeOslo")).toBe( + true, + ); }); test("write file and re-read matches original", async () => { diff --git a/scripts/js/lib/api/objectsInv.ts b/scripts/js/lib/api/objectsInv.ts index 93100542c9b..8d16ed65b32 100644 --- a/scripts/js/lib/api/objectsInv.ts +++ b/scripts/js/lib/api/objectsInv.ts @@ -16,7 +16,7 @@ import { join, dirname } from "path"; import { mkdirp } from "mkdirp"; import { removePrefix, removeSuffix } from "../stringUtils.js"; -import { C_API_BASE_PATH } from "./conversionPipeline.js"; +import { C_API_BASE_PATH } from "./paths.js"; import { PackageLanguage } from "./Pkg.js"; /** @@ -27,8 +27,8 @@ const ENTRIES_TO_EXCLUDE = [ /^genindex(\.html)?$/, /^py-modindex(\.html)?$/, /^search(\.html)?$/, - /^explanation(\.html)?(?=\/|#|$)/, - /^how_to(\.html)?(?=\/|#|$)/, + /^explanations?(\.html)?(?=\/|#|$)/, + /^how[-_]tos?(\.html)?(?=\/|#|$)/, /^tutorials(\.html)?(?=\/|#|$)/, /^migration_guides(\.html)?(?=\/|#|$)/, /^configuration(\.html)?(?=#|$)/, @@ -55,6 +55,21 @@ function shouldIncludeEntry( if (entry.name.startsWith("group__")) return false; if (entry.name.startsWith("struct_")) return false; + // std: entries are Sphinx RST cross-reference labels for document structure + // (page titles, section headings, etc.). They point to prose pages that are + // not published in this repo — only API symbol pages under stubs/ and + // apidocs/ are. Without this filter, the link checker would treat these as + // broken internal links. + if ( + entry.domainAndRole.startsWith("std:") && + !entry.uri.startsWith("apidocs/") && + !entry.uri.startsWith("stubs/") && + !entry.name.startsWith("/apidocs/") && + !entry.name.startsWith("/stubs/") + ) { + return false; + } + // This happens during link checking. if (packageLanguage === "any") return true; @@ -123,6 +138,7 @@ export class ObjectsInv { ): ObjectsInvEntry | null { // Regex from sphinx source // https://github.com/sphinx-doc/sphinx/blob/2f60b44999d7e610d932529784f082fc1c6af989/sphinx/util/inventory.py#L115-L116 + if (line.trim() === "") return null; const parts = line.match(/(.+?)\s+(\S+)\s+(-?\d+)\s+?(\S*)\s+(.*)/); if (parts == null || parts.length != 6) { console.warn(`Error parsing line of objects.inv: ${line}`); @@ -190,6 +206,68 @@ export class ObjectsInv { return uri; } + /** + * Load all published objects.inv files from public/docs/api/ and return + * a map of package name → ObjectsInv. These inventories have already been + * normalized by the API pipeline so URIs are ready to use directly. + */ + static async loadPublishedApis( + publicBaseFolder: string, + ): Promise> { + const { readdir } = await import("fs/promises"); + const apiDir = join(publicBaseFolder, "api"); + const map = new Map(); + let pkgDirs: string[]; + try { + pkgDirs = await readdir(apiDir); + } catch { + return map; + } + await Promise.all( + pkgDirs.map(async (pkgName) => { + try { + const inv = await ObjectsInv.fromFile(join(apiDir, pkgName), "any"); + map.set(pkgName, inv); + } catch { + // No objects.inv for this package — skip. + } + }), + ); + return map; + } + + /** + * Resolve a qiskit.github.io/{stubs,apidocs,apidoc}/ URL to an + * internal docs path using the package's inventory. + * + * Pass allInvs (from loadPublishedApis) for cross-package resolution. + * Requires updateUris() to have been called on same-package inventory first. + */ + resolveStubUrl( + url: string, + allObjectInvs?: Map, + ): string | undefined { + const match = url.match( + /^https:\/\/qiskit\.github\.io\/([^/]+)\/(stubs|apidocs|apidoc)\/([^"#)\s]+?)(?:\.html)?(#.*)?$/, + ); + if (!match) return undefined; + const [, pkg, kind, symbol, anchor = ""] = match; + const inv = allObjectInvs?.get(pkg) ?? this; + if (kind === "stubs") { + // The entry URI already points at the correct header anchor for the + // symbol — the source anchor (if any) is redundant and gets dropped. + const entry = inv.entries.find((e) => e.name === symbol); + return entry ? `/docs/api/${pkg}/${entry.uri}` : undefined; + } + // Apidocs URLs reference a whole page; the std:doc entry (keyed + // `/`) has the clean page-level URI, and the source anchor + // carries through to the rendered page. + const entry = + inv.entries.find((e) => e.name === `${kind}/${symbol}`) ?? + inv.entries.find((e) => e.name === symbol); + return entry ? `/docs/api/${pkg}/${entry.uri}${anchor}` : undefined; + } + updateUris(transformLink: (uri: string) => string): void { for (const entry of this.entries) { entry.uri = entry.uri.replace(/\.html/, ""); diff --git a/scripts/js/lib/api/paths.ts b/scripts/js/lib/api/paths.ts new file mode 100644 index 00000000000..09929108005 --- /dev/null +++ b/scripts/js/lib/api/paths.ts @@ -0,0 +1,17 @@ +// This code is a Qiskit project. +// +// (C) Copyright IBM 2026. +// +// 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. + +// Base path under which all generated docs live on the site. +export const DOCS_BASE_PATH = "/docs"; + +// Folder in the Sphinx artifact that contains all C API docs. +export const C_API_BASE_PATH = "cdoc" as const; diff --git a/scripts/js/lib/api/pipelineStages.ts b/scripts/js/lib/api/pipelineStages.ts new file mode 100644 index 00000000000..b125b755589 --- /dev/null +++ b/scripts/js/lib/api/pipelineStages.ts @@ -0,0 +1,214 @@ +// This code is a Qiskit project. +// +// (C) Copyright IBM 2026. +// +// 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. + +// Stages shared between the API docs pipeline (apiDocsPipeline.ts) and the +// addon docs pipeline (addonDocsPipeline.ts). Each pipeline orchestrates these +// stages differently; shared behavior lives here so the two pipelines do not +// drift. + +import { dirname, join, parse, relative } from "path"; +import { readFile, writeFile } from "fs/promises"; + +import { load } from "cheerio"; +import { mkdirp } from "mkdirp"; +import { uniqBy } from "lodash-es"; + +import { Image, HtmlToMdResultWithUrl } from "./HtmlToMdResult.js"; +import { ObjectsInv } from "./objectsInv.js"; +import { Pkg } from "./Pkg.js"; +import addFrontMatter from "./addFrontMatter.js"; +import { dedupeHtmlIdsFromResults } from "./dedupeHtmlIds.js"; +import { handleReleaseNotesFile } from "./releaseNotes.js"; +import { mergeClassMembers } from "./mergeClassMembers.js"; +import { normalizeResultUrls } from "./normalizeResultUrls.js"; +import { DOCS_BASE_PATH } from "./paths.js"; +import removeMathBlocksIndentation from "./removeMathBlocksIndentation.js"; +import { saveImages } from "./saveImages.js"; +import { specialCaseResults } from "./specialCaseResults.js"; +import { sphinxHtmlToMarkdown } from "./htmlToMd.js"; +import { updateLinks } from "./updateLinks.js"; + +export async function convertHtmlToMarkdown( + pkg: Pkg, + artifactPath: string, + docsBaseFolder: string, + outputPath: string, + filePaths: string[], + imageDestination: string, + extractfrontMatter?: boolean, +): Promise { + const results: HtmlToMdResultWithUrl[] = []; + for (const file of filePaths) { + const html = await readFile(join(artifactPath, file), "utf-8"); + const result = await sphinxHtmlToMarkdown({ + html, + fileName: file, + determineGithubUrl: pkg.determineGithubUrlFn(), + imageDestination, + releaseNotesTitle: pkg.releaseNotesTitle(), + hasSeparateReleaseNotes: pkg.hasSeparateReleaseNotes(), + isCApi: pkg.isCApi(), + hasRootNamespaceFile: pkg.hasRootNamespaceFile, + }); + + // Skip empty markdown (HTML redirects, etc.). + if (result.markdown == "") continue; + + const { dir, name } = parse(`${outputPath}/${file}`); + const url = `/${relative(docsBaseFolder, dir)}/${name}`; + + if (extractfrontMatter) { + // extracts front matter from html source rather than generating it in addFrontMatter.js + result.meta.hardcodedFrontmatter = extractHtmlFrontmatter(html, pkg, url); + } + results.push({ ...result, url }); + } + return results; +} + +/** + * Extract title/description frontmatter directly from the Sphinx HTML

              . + * Used by the addon pipeline (extractfrontMatter=true) so addon pages get + * human-readable titles rather than the auto-generated slugs that addFrontMatter.ts + * would derive from the URL. + */ +function extractHtmlFrontmatter(html: string, pkg: Pkg, url: string): string { + const $ = load(html); + const h1 = $("h1") + .first() + .clone() + .find("a.headerlink") + .remove() + .end() + .text() + .trim(); + const isRootIndex = url.endsWith(`/${pkg.name}/index`); + const description = isRootIndex + ? `Documentation for the latest version of ${pkg.title}` + : `${h1} for the latest version of ${pkg.title}`; + return [`title: "${h1}"`, `description: "${description}"`].join("\n"); +} + +/** + * Apply the shared post-processing pipeline to a set of results. + * Order is load-bearing — both pipelines call the stages in this sequence. + */ +export async function postProcess( + pkg: Pkg, + initialResults: HtmlToMdResultWithUrl[], + objectsInv: ObjectsInv | undefined, + allInvs?: Map, +): Promise { + const results = await mergeClassMembers(initialResults); + + normalizeResultUrls(results, { + kebabCaseAndShorten: pkg.kebabCaseAndShortenUrls, + pkgName: pkg.name, + }); + + specialCaseResults(results); + rewriteApiDocsLinks(results, pkg); + + await updateLinks( + results, + { + kebabCaseAndShorten: pkg.kebabCaseAndShortenUrls, + pkgName: pkg.name, + pkgOutputDir: pkg.apiOutputDir(DOCS_BASE_PATH), + }, + objectsInv, + allInvs, + ); + + await dedupeHtmlIdsFromResults(results); + removeMathBlocksIndentation(results); + addFrontMatter(results, pkg); + return results; +} + +function rewriteApiDocsLinks(results: HtmlToMdResultWithUrl[], pkg: Pkg) { + const apiBase = pkg.apiOutputDir(DOCS_BASE_PATH); + const githubIo = `https://qiskit.github.io/${pkg.name}`; + for (const result of results) { + result.markdown = result.markdown + .replace( + /\]\((?:\.\.\/)*?(apidocs|apidoc|stubs)\/([^)]+)\)/g, + `](${apiBase}/$2)`, + ) + // Release notes live under the API pipeline's output, even when + // referenced from addon guides/tutorials. Catches relative + // `release-notes.html`, github.io-absolute, and .html-less forms. + .replace( + new RegExp( + `\\]\\((?:${githubIo}/|(?:\\.\\./)*?)release[_-]notes(?:\\.html)?(#[^)]*)?\\)`, + "g", + ), + `](${apiBase}/release-notes$1)`, + ); + } +} + +// --------------------------------------------------------------------------- +// Writing outputs +// --------------------------------------------------------------------------- + +/** + * Write each markdown result to disk at `/.mdx`. + * Release notes are routed through `handleReleaseNotesFile` so the caller + * doesn't have to special-case them; the pipelines that don't produce release + * notes (addons excludes them via their glob) are unaffected. + */ +export async function writeMarkdownResults( + pkg: Pkg, + docsBaseFolder: string, + results: HtmlToMdResultWithUrl[], +) { + for (const result of results) { + const path = `${docsBaseFolder}${result.url}.mdx`; + if (path.endsWith("release-notes.mdx")) { + if (!pkg.releaseNotesConfig.enabled) continue; + + const shouldWriteResult = await handleReleaseNotesFile(result, pkg); + if (!shouldWriteResult) continue; + } + + await mkdirp(dirname(path)); + await writeFile(path, result.markdown); + } +} + +/** + * Copy images referenced by the results from the artifact's `_images/` folder + * into `destFolder`. The caller owns the destination path, which is how the + * API pipeline and the addon pipeline route images to different locations + * under `public/docs/images/`. + */ +export async function copyImages( + pkg: Pkg, + artifactPath: string, + destFolder: string, + results: HtmlToMdResultWithUrl[], + extraImages: Image[] = [], +) { + console.log("Saving images"); + const allImages = uniqBy( + [...results.flatMap((result) => result.images), ...extraImages], + (image) => image.fileName, + ); + await saveImages( + allImages, + `${artifactPath}/_images`, + destFolder, + pkg, + artifactPath, + ); +} diff --git a/scripts/js/lib/api/processHtml.test.ts b/scripts/js/lib/api/processHtml.test.ts index 656e97f3c01..871f332cec7 100644 --- a/scripts/js/lib/api/processHtml.test.ts +++ b/scripts/js/lib/api/processHtml.test.ts @@ -28,6 +28,7 @@ import { convertRubricsToHeaders, processMembersAndSetMeta, handleFootnotes, + expandTableRowspan, } from "./processHtml.js"; import { Metadata } from "./Metadata.js"; import { CheerioDoc } from "../testUtils.js"; @@ -37,15 +38,24 @@ test.describe("loadImages()", () => { const doc = CheerioDoc.load( `Logo`, ); - const images = loadImages(doc.$, doc.$main, "/my-images", false, false); + const images = loadImages( + doc.$, + doc.$main, + "/my-images", + false, + false, + "subdir/index.html", + ); expect(images).toEqual([ { fileName: "logo.png", dest: "/my-images/logo.avif", + originSrc: "_static/logo.png", }, { fileName: "view-page-source-icon.svg", dest: "/my-images/view-page-source-icon.svg", + originSrc: "_static/images/view-page-source-icon.svg", }, ]); doc.expectHtml( @@ -57,11 +67,19 @@ test.describe("loadImages()", () => { const doc = CheerioDoc.load( ``, ); - const images = loadImages(doc.$, doc.$main, "/my-images/0.45", true, false); + const images = loadImages( + doc.$, + doc.$main, + "/my-images/0.45", + true, + false, + "subdir/release-notes.html", + ); expect(images).toEqual([ { fileName: "view-page-source-icon.svg", dest: "/my-images/view-page-source-icon.svg", + originSrc: "_static/images/view-page-source-icon.svg", }, ]); doc.expectHtml(``); @@ -71,15 +89,69 @@ test.describe("loadImages()", () => { const doc = CheerioDoc.load( ``, ); - const images = loadImages(doc.$, doc.$main, "/my-images/0.45", true, true); + const images = loadImages( + doc.$, + doc.$main, + "/my-images/0.45", + true, + true, + "subdir/release-notes.html", + ); expect(images).toEqual([ { fileName: "view-page-source-icon.svg", dest: "/my-images/0.45/view-page-source-icon.svg", + originSrc: "_static/images/view-page-source-icon.svg", }, ]); doc.expectHtml(``); }); + + test("external image URLs are not rewritten", () => { + const doc = CheerioDoc.load( + `StarsLogo`, + ); + const images = loadImages( + doc.$, + doc.$main, + "/my-images", + false, + false, + "subdir/index.html", + ); + expect(images).toEqual([ + { + fileName: "logo.png", + dest: "/my-images/logo.avif", + originSrc: "_static/logo.png", + }, + ]); + doc.expectHtml( + `StarsLogo`, + ); + }); + + test("_static image (nbsphinx thumbnail) is resolved from artifact root", () => { + const doc = CheerioDoc.load( + ``, + ); + const images = loadImages( + doc.$, + doc.$main, + "/my-images", + false, + false, + "how-tos/index.html", + ); + expect(images).toEqual([ + { + fileName: "nbsphinx-no-thumbnail.svg", + dest: "/my-images/nbsphinx-no-thumbnail.svg", + originSrc: "_static/nbsphinx-no-thumbnail.svg", + }, + ]); + doc.expectHtml(``); + }); }); test("handleSphinxDesignCards()", () => { @@ -699,3 +771,31 @@ marked as builtins since they are not actually present in any include file this }); }); }); + +test.describe("expandTableRowspan()", () => { + test("duplicates rowspan cell into subsequent rows", () => { + const doc = CheerioDoc.load(` + + + + + +
              iSwapGateA49
              SwapGateB
              `); + expandTableRowspan(doc.$, doc.$main); + const rows = doc.$main.find("tr").toArray(); + expect( + doc + .$(rows[0]) + .find("td") + .map((_, el) => doc.$(el).text()) + .toArray(), + ).toEqual(["iSwapGate", "A", "49"]); + expect( + doc + .$(rows[1]) + .find("td") + .map((_, el) => doc.$(el).text()) + .toArray(), + ).toEqual(["SwapGate", "B", "49"]); + }); +}); diff --git a/scripts/js/lib/api/processHtml.ts b/scripts/js/lib/api/processHtml.ts index eafa4942eac..9bc662335ff 100644 --- a/scripts/js/lib/api/processHtml.ts +++ b/scripts/js/lib/api/processHtml.ts @@ -67,6 +67,7 @@ export async function processHtml( imageDestination, isReleaseNotes, hasSeparateReleaseNotes, + fileName, ); if (isReleaseNotes) { renameAllH1s($, releaseNotesTitle); @@ -88,6 +89,7 @@ export async function processHtml( removeColonSpans($main); handleFootnotes($, $main); preserveMathBlockWhitespace($, $main); + expandTableRowspan($, $main); const meta: Metadata = {}; await processMembersAndSetMeta($, $main, meta, { @@ -110,17 +112,28 @@ export function loadImages( imageDestination: string, isReleaseNotes: boolean, hasSeparateReleaseNotes: boolean, + htmlFileName: string, ): Image[] { return $main .find("img") .toArray() - .filter((img) => $(img).attr("src")) + .filter((img) => { + const src = $(img).attr("src"); + return src && !src.startsWith("http://") && !src.startsWith("https://"); + }) .map((img) => { const $img = $(img); + const src = $img.attr("src")!; - const fileName = $img.attr("src")!.split("/").pop()!; + const fileName = src.split("/").pop()!; const fileExtension = path.extname(fileName); + // Resolve the image's path relative to the artifact root so saveImages + // can find files in _static/ or other subdirectories, not just _images/. + const originSrc = path.normalize( + path.join(path.dirname(htmlFileName), src), + ); + // We convert PNG and JPG to AVIF for reduced file size. The image-copying // logic detects changed extensions and converts the files. let dest = [".png", ".jpg", ".jpeg"].includes(fileExtension) @@ -134,7 +147,7 @@ export function loadImages( } $img.attr("src", dest); - return { fileName, dest }; + return { fileName, dest, originSrc }; }); } @@ -241,10 +254,8 @@ function detectLanguage( ): string | null { const defaultLanguage = options.isCApi ? "c" : "python"; // Two levels up from `pre` should have class `highlight-` - const detectedLanguage = $pre - .parent() - .parent()[0] - .attribs.class.match(/(?<=highlight-)\w+/); + const grandparentClass = $pre.parent().parent()[0]?.attribs?.class ?? ""; + const detectedLanguage = grandparentClass.match(/(?<=highlight-)\w+/); if (!detectedLanguage) return defaultLanguage; const langName = detectedLanguage[0]; if (langName === "none") return null; @@ -526,6 +537,9 @@ export function preserveMathBlockWhitespace( .toArray() .map((el) => { const $el = $(el); + // Remove equation number labels — the anchor IDs are on the parent divs, + // not the eqno span, so links to equations still resolve correctly. + $el.find("span.eqno").remove(); $el.replaceWith(`
              ${$el.html()}
              `); }); } @@ -550,6 +564,26 @@ export function updateModuleHeadings($: CheerioAPI, $main: Cheerio): void { }); } +export function expandTableRowspan($: CheerioAPI, $main: Cheerio): void { + $main.find("td[rowspan], th[rowspan]").each((_, el) => { + const $el = $(el); + const span = parseInt($el.attr("rowspan") ?? "1", 10); + $el.removeAttr("rowspan"); + let $row = $el.closest("tr"); + const colIndex = $row.children("td,th").index($el); + for (let i = 1; i < span; i++) { + $row = $row.next("tr"); + const $cells = $row.children("td,th"); + const clone = $el.clone(); + if (colIndex >= $cells.length) { + $row.append(clone); + } else { + $cells.eq(colIndex).before(clone); + } + } + }); +} + function getApiType($dl: Cheerio): ApiObjectName | undefined { // Historical versions were generating properties incorrectly as methods. // We can fix this by looking at the modifier before the signature. diff --git a/scripts/js/lib/api/releaseNotes.ts b/scripts/js/lib/api/releaseNotes.ts index 4198fa987ac..5fa57d584a8 100644 --- a/scripts/js/lib/api/releaseNotes.ts +++ b/scripts/js/lib/api/releaseNotes.ts @@ -19,7 +19,7 @@ import transformLinks from "transform-markdown-links"; import { pathExists } from "../fs.js"; import type { Pkg } from "./Pkg.js"; import type { HtmlToMdResultWithUrl } from "./HtmlToMdResult.js"; -import { C_API_BASE_PATH, DOCS_BASE_PATH } from "./conversionPipeline.js"; +import { C_API_BASE_PATH, DOCS_BASE_PATH } from "./paths.js"; import { kebabCaseAndShortenPage } from "./normalizeResultUrls.js"; import { removePrefix } from "../stringUtils.js"; import { generateReleaseNotesEntry, TocEntry } from "./generateToc.js"; diff --git a/scripts/js/lib/api/saveImages.ts b/scripts/js/lib/api/saveImages.ts index 2cdff78ed94..da9e3c0b3ef 100644 --- a/scripts/js/lib/api/saveImages.ts +++ b/scripts/js/lib/api/saveImages.ts @@ -11,7 +11,7 @@ // that they have been altered from the originals. import { copyFile } from "fs/promises"; -import { extname } from "node:path"; +import { dirname, extname } from "node:path"; import pMap from "p-map"; import { $ } from "zx"; @@ -49,10 +49,10 @@ function skipReleaseNote(imgFileName: string, pkg: Pkg): boolean { export async function saveImages( images: Image[], originalImagesFolderPath: string, - publicBaseFolder: string, + destFolder: string, pkg: Pkg, + artifactPath?: string, ) { - const destFolder = pkg.outputDir(`${publicBaseFolder}/docs/images`); if (!(await pathExists(destFolder))) { await mkdirp(destFolder); } @@ -61,9 +61,22 @@ export async function saveImages( if (skipReleaseNote(img.fileName, pkg)) { return; } - const source = `${originalImagesFolderPath}/${img.fileName}`; - const dest = `${publicBaseFolder}/${img.dest}`; + // Prefer the resolved artifact-relative path (covers _static/, etc.), + // falling back to the legacy _images/ convention. + const source = + artifactPath && img.originSrc + ? `${artifactPath}/${img.originSrc}` + : `${originalImagesFolderPath}/${img.fileName}`; + // img.dest is set by loadImages() and includes the full image URL prefix + // (e.g. "/docs/images/api/qiskit/foo.avif"). We only need its basename to + // place the file inside destFolder. + const dest = `${destFolder}/${img.dest.split("/").pop()}`; + if (!(await pathExists(source))) { + console.warn(`Skipping missing image: ${source}`); + return; + } + await mkdirp(dirname(dest)); if (extname(source) === extname(dest)) { await copyFile(source, dest); } else { diff --git a/scripts/js/lib/api/specialCaseResults.ts b/scripts/js/lib/api/specialCaseResults.ts index b0f042c814c..f9abd3eb559 100644 --- a/scripts/js/lib/api/specialCaseResults.ts +++ b/scripts/js/lib/api/specialCaseResults.ts @@ -19,6 +19,7 @@ export function transformSpecialCaseUrl(url: string): string { .replace(/(?<=^|\/)release_notes(?=#|$)/g, "release-notes") .replace(/(?<=^|\/)terra(?=#|$)/g, "index") .replace(/(?<=^|\/)ibm-runtime(?=#|$)/g, "index") + .replace(/(?<=^|\/)main(?=#|$)/g, "index") ); } diff --git a/scripts/js/lib/api/testdata/qiskit-addon-smoke-publishedapis/api/qiskit-addon-other/objects.inv b/scripts/js/lib/api/testdata/qiskit-addon-smoke-publishedapis/api/qiskit-addon-other/objects.inv new file mode 100644 index 00000000000..f2dc4077d36 Binary files /dev/null and b/scripts/js/lib/api/testdata/qiskit-addon-smoke-publishedapis/api/qiskit-addon-other/objects.inv differ diff --git a/scripts/js/lib/api/testdata/qiskit-addon-smoke/_images/smoke-diagram.avif b/scripts/js/lib/api/testdata/qiskit-addon-smoke/_images/smoke-diagram.avif new file mode 100644 index 00000000000..0314f501c87 Binary files /dev/null and b/scripts/js/lib/api/testdata/qiskit-addon-smoke/_images/smoke-diagram.avif differ diff --git a/scripts/js/lib/api/testdata/qiskit-addon-smoke/how_tos/foo.html b/scripts/js/lib/api/testdata/qiskit-addon-smoke/how_tos/foo.html new file mode 100644 index 00000000000..f32c3484b7f --- /dev/null +++ b/scripts/js/lib/api/testdata/qiskit-addon-smoke/how_tos/foo.html @@ -0,0 +1,18 @@ + + + + + How to foo - smoke 0.0 + + + + + + diff --git a/scripts/js/lib/api/testdata/qiskit-addon-smoke/index.html b/scripts/js/lib/api/testdata/qiskit-addon-smoke/index.html new file mode 100644 index 00000000000..330d9989c15 --- /dev/null +++ b/scripts/js/lib/api/testdata/qiskit-addon-smoke/index.html @@ -0,0 +1,29 @@ + + + + + Qiskit Addon Smoke - smoke 0.0 + + + + + + + diff --git a/scripts/js/lib/api/testdata/qiskit-addon-smoke/install.html b/scripts/js/lib/api/testdata/qiskit-addon-smoke/install.html new file mode 100644 index 00000000000..6b94ccebc65 --- /dev/null +++ b/scripts/js/lib/api/testdata/qiskit-addon-smoke/install.html @@ -0,0 +1,16 @@ + + + + + Installation - smoke 0.0 + + + +
              +
              +

              Installation#

              +

              Install with pip. Back to the home page.

              +
              +
              + + diff --git a/scripts/js/lib/api/testdata/qiskit-addon-smoke/objects.inv b/scripts/js/lib/api/testdata/qiskit-addon-smoke/objects.inv new file mode 100644 index 00000000000..b7ec346b6b6 Binary files /dev/null and b/scripts/js/lib/api/testdata/qiskit-addon-smoke/objects.inv differ diff --git a/scripts/js/lib/api/testdata/qiskit-addon-smoke/tutorials/intro.ipynb b/scripts/js/lib/api/testdata/qiskit-addon-smoke/tutorials/intro.ipynb new file mode 100644 index 00000000000..bd6059d589b --- /dev/null +++ b/scripts/js/lib/api/testdata/qiskit-addon-smoke/tutorials/intro.ipynb @@ -0,0 +1,31 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Intro tutorial\n", + "\n", + "This notebook references [do_thing](https://qiskit.github.io/qiskit-addon-smoke/stubs/smoke.do_thing.html) from the current package and [other_thing](https://qiskit.github.io/qiskit-addon-other/stubs/other_thing.html) from a sibling package.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print('hello from the smoke tutorial')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/scripts/js/lib/api/updateLinks.ts b/scripts/js/lib/api/updateLinks.ts index 009f4cb5e57..cba4371c07a 100644 --- a/scripts/js/lib/api/updateLinks.ts +++ b/scripts/js/lib/api/updateLinks.ts @@ -26,9 +26,10 @@ import { removePart, removePrefix, removeSuffix } from "../stringUtils.js"; import { HtmlToMdResultWithUrl } from "./HtmlToMdResult.js"; import { remarkStringifyOptions } from "./commonParserConfig.js"; import { ObjectsInv } from "./objectsInv.js"; +import { Pkg } from "./Pkg.js"; import { transformSpecialCaseUrl } from "./specialCaseResults.js"; import { kebabCaseAndShortenPage } from "./normalizeResultUrls.js"; -import { DOCS_BASE_PATH } from "./conversionPipeline.js"; +import { DOCS_BASE_PATH } from "./paths.js"; export interface Link { url: string; // Where the link goes @@ -177,6 +178,8 @@ export function normalizeUrl( } export function relativizeLink(link: Link): Link | undefined { + rewriteQiskitAddonLinks(link); + const priorPrefixToNewPrefix = new Map([ ["https://qiskit.org/documentation/apidoc/", "/api/qiskit"], ["https://qiskit.org/documentation/stubs/", "/api/qiskit"], @@ -188,12 +191,12 @@ export function relativizeLink(link: Link): Link | undefined { const priorPrefix = Array.from(priorPrefixToNewPrefix.keys()).find((prefix) => link.url.startsWith(prefix), ); - if (!priorPrefix) { - return; - } + if (!priorPrefix) return; + let [url, anchor] = link.url.split("#"); url = removePrefix(url, priorPrefix); url = removeSuffix(url, ".html"); + if (anchor && anchor !== url) { url = `${url}#${anchor}`; } @@ -204,6 +207,25 @@ export function relativizeLink(link: Link): Link | undefined { return { url: `/${relativeUrl}`, text: newText }; } +function rewriteQiskitAddonLinks(link: Link) { + if (!link.url.startsWith("https://qiskit.github.io/")) return; + + // github.io stubs/apidocs URLs are looked up via objects.inv by the caller + if (/\/(stubs|apidocs|apidoc)\//.test(link.url)) return; + + const rest = removePrefix(link.url, "https://qiskit.github.io/"); + const [addonName, ...pathParts] = rest.split("#")[0].split("/"); + if (!addonName || !Pkg.ADDON_NAMES.includes(addonName)) return; + + const anchor = rest.includes("#") ? rest.split("#")[1] : undefined; + const pagePath = pathParts.map((s) => removeSuffix(s, ".html")).join("/"); + const url = anchor + ? `/docs/addons/${addonName}/${pagePath}#${anchor}` + : `/docs/addons/${addonName}/${pagePath}`; + const newText = link.url === link.text ? url : undefined; + return { url, text: newText }; +} + export async function updateLinks( results: HtmlToMdResultWithUrl[], kwargs: { @@ -212,6 +234,7 @@ export async function updateLinks( pkgOutputDir: string; }, maybeObjectsInv?: ObjectsInv, + allObjectInvs?: Map, ): Promise { const resultsByName = keyBy(results, (result) => result.meta.apiName!); const itemNames = new Set(keys(resultsByName)); @@ -254,7 +277,13 @@ export async function updateLinks( textNode.value = relativizedLink.text; } } - + const resolvedStub = maybeObjectsInv?.resolveStubUrl( + node.url, + allObjectInvs, + ); + if (resolvedStub) { + node.url = resolvedStub; + } node.url = normalizeUrl(node.url, resultsByName, itemNames, kwargs); }); }) diff --git a/scripts/js/lib/links/FileBatch.ts b/scripts/js/lib/links/FileBatch.ts index 03415c286ee..925a94be0cd 100644 --- a/scripts/js/lib/links/FileBatch.ts +++ b/scripts/js/lib/links/FileBatch.ts @@ -118,7 +118,10 @@ export function addLinksToMap( links: Set, linksToOriginFiles: Map, ): void { - const ignoreUrlsRegex = new RegExp(ALWAYS_IGNORED_URL_REGEXES.join("|"), "i"); + const ignoreUrlsRegex = + ALWAYS_IGNORED_URL_REGEXES.length > 0 + ? new RegExp(ALWAYS_IGNORED_URL_REGEXES.join("|"), "i") + : null; if (IGNORED_FILES.has(filePath)) return; links.forEach((link) => { if ( @@ -126,7 +129,7 @@ export function addLinksToMap( ALWAYS_IGNORED_URL_PREFIXES.some((prefix) => link.startsWith(prefix)) || ALWAYS_IGNORED_URL_SUFFIXES.some((suffix) => link.endsWith(suffix)) || FILES_TO_IGNORES[filePath]?.includes(link) || - ignoreUrlsRegex.test(link) + ignoreUrlsRegex?.test(link) ) { return; } diff --git a/scripts/js/lib/links/ignores.ts b/scripts/js/lib/links/ignores.ts index 6b5cd46bb41..ea6ef4e405c 100644 --- a/scripts/js/lib/links/ignores.ts +++ b/scripts/js/lib/links/ignores.ts @@ -234,22 +234,7 @@ export const ALWAYS_IGNORED_URLS = new Set([ // Always ignored URL regexes - be careful using this // ----------------------------------------------------------------------------------- -function _addonsObjectsInvRegexes(): string[] { - // Addons have non-API docs in their Sphinx build that translate into invalid links - // we should ignore - return ["how-tos", "how_tos", "install", "index", "explanations"].flatMap( - (path) => [ - // Latest version - `\/api\/qiskit-addon-[^\/]+\/${path}(\/.*|#.*|$)`, - // Historical versions - `\/api\/qiskit-addon-[^\/]+\/[0-9]+\.[0-9]{1,2}\/${path}(\/.*|#.*|$)`, - ], - ); -} - -export const ALWAYS_IGNORED_URL_REGEXES: string[] = [ - ..._addonsObjectsInvRegexes(), -]; +export const ALWAYS_IGNORED_URL_REGEXES: string[] = []; // ----------------------------------------------------------------------------------- // Always ignored URL suffixes - be careful using this @@ -593,8 +578,16 @@ function _qiskitCRegexes(): FilesToIgnores { }; } -const FILES_TO_IGNORES__SHOULD_FIX: FilesToIgnores = - mergeFilesToIgnores(_qiskitCRegexes()); +function _addonContentLinksToFix(): FilesToIgnores { + // These links point to old addon-repo tutorial slugs that no longer exist. + // The addon source docs need to be updated to use the new paths. + return {}; +} + +const FILES_TO_IGNORES__SHOULD_FIX: FilesToIgnores = mergeFilesToIgnores( + _qiskitCRegexes(), + _addonContentLinksToFix(), +); export const FILES_TO_IGNORES: FilesToIgnores = mergeFilesToIgnores( FILES_TO_IGNORES__EXPECTED, diff --git a/tox.ini b/tox.ini index 458d5c11ce6..b277fe6815f 100644 --- a/tox.ini +++ b/tox.ini @@ -22,10 +22,10 @@ commands = lint: squeaky --check --no-advice {posargs:docs learning} lint: python scripts/ci/check-for-version-info-cells.py lint: qiskit-docs-notebook-normalizer --check + fix: qiskit-docs-notebook-normalizer fix: squeaky {posargs:docs learning} fix: ruff format {posargs:docs learning} fix: ruff check --fix {posargs:docs learning} - fix: qiskit-docs-notebook-normalizer [testenv:tests] deps =