diff --git a/.github/workflows/api-checks.yml b/.github/workflows/api-checks.yml index 093d6f0f2b2..ce5c77fc16a 100644 --- a/.github/workflows/api-checks.yml +++ b/.github/workflows/api-checks.yml @@ -44,5 +44,5 @@ jobs: run: npm run check:orphan-pages -- --apis - name: Check metadata run: npm run check:metadata -- --apis - - name: Check images - run: npm run check:images -- --apis + - name: Check markdown + run: npm run check:markdown -- --apis diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5ef39d6fb1d..3e08e9f6a82 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -35,8 +35,8 @@ jobs: - name: File metadata run: npm run check:metadata - - name: Check images - run: npm run check:images + - name: Check markdown + run: npm run check:markdown - name: Spellcheck run: npm run check:spelling - name: Check Qiskit bot config diff --git a/check b/check index c71b9c9f40d..b1ce76f2297 100755 --- a/check +++ b/check @@ -25,7 +25,7 @@ CHECKS = { "metadata": ["npm", "run", "check:metadata"], "patterns index pages": ["npm", "run", "check:patterns-index"], "tutorials index page": ["python3", "scripts/ci/check-tutorials-index.py"], - "images": ["npm", "run", "check:images"], + "images": ["npm", "run", "check:markdown"], "orphan pages": ["npm", "run", "check:orphan-pages"], "spelling": ["npm", "run", "check:spelling"], "internal links": ["npm", "run", "check:internal-links"], diff --git a/package.json b/package.json index df2ffe2bc5b..ddda2c0d18e 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "test": "playwright test", "typecheck": "tsc", "check": "npm run check:patterns-index && npm run check:qiskit-bot && npm run check:metadata && npm run check:images && npm run check:orphan-pages && npm run check:spelling && npm run check:internal-links", - "check:images": "tsx scripts/js/commands/checkImages.ts", + "check:markdown": "tsx scripts/js/commands/checkMarkdown.ts", "check:metadata": "tsx scripts/js/commands/checkMetadata.ts", "check:spelling": "tsx scripts/js/commands/checkSpelling.ts", "check:fmt": "prettier --check .", diff --git a/scripts/js/commands/checkImages.ts b/scripts/js/commands/checkImages.ts deleted file mode 100644 index 1085838860e..00000000000 --- a/scripts/js/commands/checkImages.ts +++ /dev/null @@ -1,82 +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 { globby } from "globby"; -import yargs from "yargs/yargs"; -import { hideBin } from "yargs/helpers"; -import { collectInvalidImageErrors } from "../lib/markdownImages.js"; -import { readMarkdown } from "../lib/markdownReader.js"; - -interface Arguments { - [x: string]: unknown; - apis: boolean; -} - -const readArgs = (): Arguments => { - return yargs(hideBin(process.argv)) - .version(false) - .option("apis", { - type: "boolean", - default: false, - description: - "Check the images in the current and dev versions of the API docs have alt text.", - }) - .parseSync(); -}; - -async function main() { - const args = readArgs(); - - const files = await determineContentFiles(args); - const fileErrors: string[] = []; - - for (const file of files) { - const markdown = await readMarkdown(file); - const imageErrors = await collectInvalidImageErrors(markdown); - - if (imageErrors.size) { - fileErrors.push( - `Error in file '${file}':\n\t- ${[...imageErrors].join("\n\t- ")}\n`, - ); - } - } - - if (fileErrors.length) { - fileErrors.forEach((error) => console.log(error)); - console.error( - "💔 Some images have problems. See https://github.com/Qiskit/documentation#images for instructions.\n", - ); - process.exit(1); - } - console.log("✅ All images are valid.\n"); -} - -async function determineContentFiles(args: Arguments): Promise { - // We always skip historical versions to simplify the code and to have a faster script. - // Even though it is possible for someone to add a new image without alt text to a - // historical version that wasn't in the original release, that's very unlikely. - // If it happens, it would probably be a backport from latest or dev, and the linter in - // the API repo should catch it. - // - // If an image is missed by the API repo's linter, it will still have an alt text defined, - // although it won't be very useful. That's because Sphinx auto-generates alt text. - const globs = [ - "{docs,learning}/**/*.{ipynb,mdx}", - args.apis ? "!docs/api/*/([0-9]*)/*.mdx" : "!docs/api/**/*.mdx", - // Remove when https://github.com/Qiskit/documentation/issues/2564 is fixed - `!docs/api/qiskit/release-notes/*.mdx`, - ]; - - return await globby(globs); -} - -main().then(() => process.exit()); diff --git a/scripts/js/commands/checkMarkdown.ts b/scripts/js/commands/checkMarkdown.ts new file mode 100644 index 00000000000..24c0f3af3d5 --- /dev/null +++ b/scripts/js/commands/checkMarkdown.ts @@ -0,0 +1,147 @@ +// 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 { globby } from "globby"; +import yargs from "yargs/yargs"; +import { hideBin } from "yargs/helpers"; + +import { collectInvalidImageErrors } from "../lib/markdownImages.js"; +import { readMarkdown } from "../lib/markdownReader.js"; +import { collectHeadingTitleMismatch } from "../lib/markdownTitles.js"; +import { parseMarkdown } from "../lib/markdownUtils"; + +const IGNORE_TITLE_MISMATCHES: string[] = [ + "docs/migration-guides/external-providers-primitives-v2.mdx", + "docs/migration-guides/local-simulators.mdx", + "docs/migration-guides/metapackage-migration.mdx", + "docs/migration-guides/qiskit-1.0-features.mdx", + "docs/migration-guides/qiskit-1.0-installation.mdx", + "docs/migration-guides/qiskit-algorithms-module.mdx", + "docs/migration-guides/qiskit-backend-primitives.mdx", + "docs/migration-guides/qiskit-backendv1-to-v2.mdx", + "docs/migration-guides/qiskit-opflow-module.mdx", + "docs/migration-guides/qiskit-runtime-from-ibm-provider.mdx", + "docs/migration-guides/qiskit-runtime-from-ibmq-provider.mdx", + "docs/migration-guides/qiskit-runtime-options.mdx", + "docs/guides/access-groups.mdx", + "docs/migration-guides/v2-primitives.mdx", + "docs/guides/execution-modes.mdx", + "docs/guides/install-qiskit-source.mdx", + "docs/guides/manage-cost.mdx", + "docs/guides/plans-overview.mdx", + "docs/guides/qiskit-addons-aqc.mdx", + "docs/guides/qiskit-addons-sqd.mdx", + "docs/guides/qiskit-code-assistant-vscode.mdx", + "docs/guides/qiskit-function-templates.mdx", + "docs/guides/serverless.mdx", + "docs/open-source/code-of-conduct.mdx", + "docs/open-source/create-a-provider.mdx", + "docs/support/execution-modes-faq.mdx", + "docs/support/faq.mdx", + "learning/index.mdx", + "learning/courses/basics-of-quantum-information/exam.mdx", + "learning/courses/basics-of-quantum-information/index.mdx", + "learning/courses/foundations-of-quantum-error-correction/index.mdx", + "learning/courses/fundamentals-of-quantum-algorithms/exam.mdx", + "learning/courses/fundamentals-of-quantum-algorithms/index.mdx", + "learning/courses/quantum-business-foundations/business-impacts.mdx", + "learning/courses/quantum-business-foundations/exam.mdx", + "learning/courses/quantum-business-foundations/quantum-computing-fundamentals.mdx", + "learning/courses/quantum-business-foundations/quantum-technology.mdx", + "learning/courses/quantum-business-foundations/start-your-quantum-journey.mdx", + "learning/courses/quantum-chem-with-vqe/exam.mdx", + "learning/courses/quantum-diagonalization-algorithms/exam.mdx", + "learning/courses/quantum-machine-learning/exam.mdx", + "learning/courses/quantum-safe-cryptography/exam.mdx", + "learning/courses/utility-scale-quantum-computing/classical-simulation.mdx", + "learning/courses/variational-algorithm-design/exam.mdx", +]; + +const allErrors: string[] = []; + +interface Arguments { + [x: string]: unknown; + apis: boolean; +} + +const readArgs = (): Arguments => { + return yargs(hideBin(process.argv)) + .version(false) + .option("apis", { + type: "boolean", + default: false, + description: + "Check files in the current and dev versions of the API docs have matching titles and metadata.", + }) + .parseSync(); +}; + +async function main() { + const args = readArgs(); + const files = await determineContentFiles(args); + + for (const file of files) { + const markdown = await readMarkdown(file); + const tree = parseMarkdown(markdown); + const imageErrors = await collectInvalidImageErrors(tree); + const mismatchedTitleHeadingErrors = + IGNORE_TITLE_MISMATCHES.includes(file) || file.startsWith("docs/api") + ? new Set() + : await collectHeadingTitleMismatch(tree); + + //Collect all errors for this file + const errorsInFile: string[] = [ + ...imageErrors, + ...mismatchedTitleHeadingErrors, + ]; + + if (errorsInFile.length) { + allErrors.push( + `Error in file '${file}':\n\t- ${errorsInFile.join("\n\t- ")}\n`, + ); + } + } + + // Final error report + if (allErrors.length) { + allErrors.forEach((error) => console.log(error)); + console.error( + "💔 Some issues were found in your Markdown files. Please fix them before proceeding.\n" + + "Image help: https://github.com/Qiskit/documentation#images\n" + + "Title/Heading help: https://github.com/Qiskit/documentation#titles-and-headings\n", + ); + process.exit(1); + } + + console.log("✅ All files passed validation.\n"); +} + +async function determineContentFiles(args: Arguments): Promise { + // We always skip historical versions to simplify the code and to have a faster script. + // Even though it is possible for someone to add a new image without alt text to a + // historical version that wasn't in the original release, that's very unlikely. + // If it happens, it would probably be a backport from latest or dev, and the linter in + // the API repo should catch it. + // + // If an image is missed by the API repo's linter, it will still have an alt text defined, + // although it won't be very useful. That's because Sphinx auto-generates alt text. + const globs = [ + "{docs,learning}/**/*.{ipynb,mdx}", + args.apis ? "!docs/api/*/([0-9]*)/*.mdx" : "!docs/api/**/*.mdx", + // Remove when https://github.com/Qiskit/documentation/issues/2564 is fixed + `!docs/api/qiskit/release-notes/*.mdx`, + ]; + + return await globby(globs); +} + +main().then(() => process.exit()); diff --git a/scripts/js/lib/markdownImages.test.ts b/scripts/js/lib/markdownImages.test.ts index 70e27a6affc..e519840ae5d 100644 --- a/scripts/js/lib/markdownImages.test.ts +++ b/scripts/js/lib/markdownImages.test.ts @@ -11,6 +11,19 @@ // that they have been altered from the originals. import { expect, test } from "@playwright/test"; +import { unified } from "unified"; +import remarkParse from "remark-parse"; +import remarkGfm from "remark-gfm"; +import remarkFrontmatter from "remark-frontmatter"; +import { Root } from "mdast"; + +function parseMarkdown(markdown: string): Root { + return unified() + .use(remarkParse) + .use(remarkGfm) + .use(remarkFrontmatter, ["yaml"]) + .parse(markdown); +} import { collectInvalidImageErrors } from "./markdownImages.js"; @@ -45,8 +58,12 @@ test("Test the finding of invalid images", async () => { ![And a valid SVG](/learning/images/valid.svg) + `; - const images = await collectInvalidImageErrors(markdown); + + const tree = parseMarkdown(markdown); + const images = await collectInvalidImageErrors(tree); + const correct_images = new Set([ "Convert 'img1.png' to AVIF. You can use the command `magick .png .avif`. If ImageMagick isn't preinstalled, you can get it from https://imagemagick.org/script/download.php. Then delete the old file and update the markdown to point to the new file.", "Convert 'img2.png' to AVIF. You can use the command `magick .png .avif`. If ImageMagick isn't preinstalled, you can get it from https://imagemagick.org/script/download.php. Then delete the old file and update the markdown to point to the new file.", diff --git a/scripts/js/lib/markdownImages.ts b/scripts/js/lib/markdownImages.ts index ea2c3b180c0..af2054c9c50 100644 --- a/scripts/js/lib/markdownImages.ts +++ b/scripts/js/lib/markdownImages.ts @@ -11,55 +11,37 @@ // that they have been altered from the originals. import { load } from "cheerio"; -import { unified } from "unified"; -import { Root } from "remark-mdx"; +import { Root } from "mdast"; import { visit } from "unist-util-visit"; -import remarkParse from "remark-parse"; -import remarkGfm from "remark-gfm"; -import remarkStringify from "remark-stringify"; import { last, split } from "lodash-es"; -export async function collectInvalidImageErrors( - markdown: string, -): Promise> { +export function collectInvalidImageErrors(tree: Root): Set { const imagesErrors = new Set(); - await unified() - .use(remarkParse) - .use(remarkGfm) - .use(() => (tree: Root) => { - visit(tree, "image", (node) => { - // Sphinx uses the image path as alt text if it wasn't defined using the - // :alt: option. - const imageName = last(split(node.url, "/")); - if (!node.alt || node.alt.endsWith(imageName!)) { - imagesErrors.add(`The image '${node.url}' does not have alt text.`); - } + visit(tree, "image", (node) => { + const imageName = last(split(node.url, "/")); + if (!node.alt || node.alt.endsWith(imageName!)) { + imagesErrors.add(`The image '${node.url}' does not have alt text.`); + } - // Ask to convert PNG and JPEG to AVIF - if (node.url.match(/\.(png|jpe?g)$/)) { - const urlWithAvifExtension = node.url.replace( - /\.(png|jpe?g)$/, - ".avif", - ); - imagesErrors.add( - `Convert '${imageName}' to AVIF. You can use the command \`magick .png .avif\`. ` + - `If ImageMagick isn't preinstalled, you can get it from https://imagemagick.org/script/download.php. ` + - `Then delete the old file and update the markdown to point to the new file.`, - ); - } - }); - visit(tree, "html", (node) => { - const $ = load(node.value); - if ($("img").length) { - imagesErrors.add( - `The image '${$("img").attr("src")}' uses an HTML tag instead of markdown syntax.`, - ); - } - }); - }) - .use(remarkStringify) - .process(markdown); + if (node.url.match(/\.(png|jpe?g)$/)) { + const urlWithAvifExtension = node.url.replace(/\.(png|jpe?g)$/, ".avif"); + imagesErrors.add( + `Convert '${imageName}' to AVIF. You can use the command \`magick .png .avif\`. ` + + `If ImageMagick isn't preinstalled, you can get it from https://imagemagick.org/script/download.php. ` + + `Then delete the old file and update the markdown to point to the new file.`, + ); + } + }); + + visit(tree, "html", (node) => { + const $ = load(node.value); + if ($("img").length) { + imagesErrors.add( + `The image '${$("img").attr("src")}' uses an HTML tag instead of markdown syntax.`, + ); + } + }); return imagesErrors; } diff --git a/scripts/js/lib/markdownTitles.test.ts b/scripts/js/lib/markdownTitles.test.ts new file mode 100644 index 00000000000..ba983d71167 --- /dev/null +++ b/scripts/js/lib/markdownTitles.test.ts @@ -0,0 +1,67 @@ +// This code is a Qiskit project. +// +// (C) Copyright IBM 2025. +// +// This code is licensed under the Apache License, Version 2.0. You may +// obtain a copy of this license in the LICENSE file in the root directory +// of this source tree or at http://www.apache.org/licenses/LICENSE-2.0. +// +// Any modifications or derivative works of this code must retain this +// copyright notice, and modified files need to carry a notice indicating +// that they have been altered from the originals. + +import { expect, test } from "@playwright/test"; + +import { collectHeadingTitleMismatch } from "./markdownTitles"; +import { parseMarkdown } from "./markdownUtils"; + +test("Test for matching titles and headings", async () => { + const markdown1 = `--- +title: My Awesome Guide +--- + +# My Awesome Guide + `; + const tree = parseMarkdown(markdown1); + const mismatched = await collectHeadingTitleMismatch(tree); + expect(mismatched).toEqual(new Set()); +}); + +test("Test to find mismatched titles and headings", async () => { + const markdown2 = `--- +title: Qiskit Doc +author: John +--- + +# Introduction + +This guide will walk you through everything.`; + + const tree = parseMarkdown(markdown2); + const mismatched2 = await collectHeadingTitleMismatch(tree); + + const result2 = new Set([ + `Mismatch: frontmatter title "Qiskit Doc" does not match heading "Introduction"`, + ]); + + expect(mismatched2).toEqual(result2); +}); + +test("Test to mismatched and complex titles and headings", async () => { + const markdown3 = `--- +title: My Awesome Guide +--- + +# This is a *Heading* + +This guide will walk you through everything.`; + + const tree = parseMarkdown(markdown3); + const mismatched3 = await collectHeadingTitleMismatch(tree); + + const result3 = new Set([ + `Mismatch: frontmatter title "My Awesome Guide" does not match heading "This is a Heading"`, + ]); + + expect(mismatched3).toEqual(result3); +}); diff --git a/scripts/js/lib/markdownTitles.ts b/scripts/js/lib/markdownTitles.ts new file mode 100644 index 00000000000..cb9776c1e4b --- /dev/null +++ b/scripts/js/lib/markdownTitles.ts @@ -0,0 +1,67 @@ +// This code is a Qiskit project. +// +// (C) Copyright IBM 2025. +// +// 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 { visit, EXIT } from "unist-util-visit"; +import { Root } from "mdast"; +import yaml from "js-yaml"; + +// Helper to recursively extract visible text from heading node +function extractText(node: any): string { + if (node.type === "text" || node.type === "inlineCode") { + return node.value; + } + + if (node.type === "link" && node.children) { + return node.children.map(extractText).join(" "); + } + + if (node.children && Array.isArray(node.children)) { + return node.children.map(extractText).join(" "); + } + + return ""; +} + +export async function collectHeadingTitleMismatch( + tree: Root, +): Promise> { + const mismatches = new Set(); + + let frontmatterTitle: string | undefined; + let headingText: string | undefined; + + // Extract frontmatter title + visit(tree, "yaml", (node: any) => { + const data = yaml.load(node.value); + if (typeof data === "object" && data !== null && "title" in data) { + frontmatterTitle = (data as any).title; + return EXIT; + } + }); + + // Extract first level-1 heading with full formatting + visit(tree, "heading", (node: any) => { + if (node.depth === 1 && !headingText) { + headingText = extractText(node).trim(); + return EXIT; + } + }); + + // Compare and collect mismatch + if (frontmatterTitle && headingText && frontmatterTitle !== headingText) { + mismatches.add( + `Mismatch: frontmatter title "${frontmatterTitle}" does not match heading "${headingText}"`, + ); + } + + return mismatches; +} diff --git a/scripts/js/lib/markdownUtils.ts b/scripts/js/lib/markdownUtils.ts new file mode 100644 index 00000000000..bd0c975e449 --- /dev/null +++ b/scripts/js/lib/markdownUtils.ts @@ -0,0 +1,25 @@ +// This code is a Qiskit project. +// +// (C) Copyright IBM 2025. +// +// 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 { unified } from "unified"; +import remarkParse from "remark-parse"; +import remarkGfm from "remark-gfm"; +import remarkFrontmatter from "remark-frontmatter"; +import { Root } from "mdast"; + +export function parseMarkdown(markdown: string): Root { + return unified() + .use(remarkParse) + .use(remarkGfm) + .use(remarkFrontmatter, ["yaml"]) + .parse(markdown); +}