Skip to content

Two converter bugs break internal links for qiskit-fermions (and any qiskit* package with a C API) #5685

Description

While investigating the failing Lint job on #5639 (Qiskit Fermions 0.2.0), I traced the ~90 check:internal-links errors to three causes. One is already fixed on main (see the note at the end). The other two are bugs in the API conversion pipeline.

Both are verified empirically against the generated MDX on the PR branch and against real docutils output; details and validation below.


Bug 1 — pkgOutputDir.replace("qiskit", "qiskit-c") corrupts any package name starting with qiskit

File: scripts/js/lib/api/updateLinks.ts:126 (in normalizeUrl, the cdoc/ branch)

What happens

When a page links to the C API via a relative cdoc/… path, the output directory is derived by string substitution:

return `${kwargs.pkgOutputDir.replace("qiskit", "qiskit-c")}/${pageAndHash}`;

String.prototype.replace with a string pattern replaces the first occurrence, so for any package whose name merely starts with qiskit, the insertion lands in the middle of the name:

pkgOutputDir result correct?
/docs/api/qiskit /docs/api/qiskit-c yes
/docs/api/qiskit-fermions /docs/api/qiskit-c-fermions no — should be /docs/api/qiskit-fermions-c

This accounts for roughly 45 of the failures on #5639, e.g.:

❌ Could not find link '/docs/api/qiskit-c-fermions/qf-ferm-op#qf_ferm_op_set_groups'. Appears in:
    docs/api/qiskit-fermions/release-notes.mdx    ❓ Did you mean '/docs/api/qiskit-fermions-c/qf-ferm-op#qf_ferm_op_set_groups'?

The checker's own "did you mean" suggestion is the correct target in every case.

Suggested fix

Both C packages already follow one convention — the C package is named {artifactPackageName}-c (qiskit → qiskit-c, qiskit-fermions → qiskit-fermions-c) — so the sibling name can be built by appending a suffix to the package name rather than by substring surgery on the path.

-  if (url.startsWith(`${C_API_BASE_PATH}/`)) {
-    const [pageWithHtml, hash] = removePrefix(url, `${C_API_BASE_PATH}/`).split(
-      "#",
-    );
-    const page = removeSuffix(pageWithHtml, ".html");
-    // Strip the Sphinx C domain prefix (e.g. `c.qk_circuit_new` → `qk_circuit_new`)
-    const normalizedHash = hash ? removePrefix(hash, "c.") : undefined;
-    const pageAndHash = normalizedHash ? `${page}#${normalizedHash}` : page;
-    return `${kwargs.pkgOutputDir.replace("qiskit", "qiskit-c")}/${pageAndHash}`;
-  }
+  if (url.startsWith(`${C_API_BASE_PATH}/`)) {
+    const [pageWithHtml, hash] = removePrefix(url, `${C_API_BASE_PATH}/`).split(
+      "#",
+    );
+    const page = removeSuffix(pageWithHtml, ".html");
+    // Strip the Sphinx C domain prefix (e.g. `c.qk_circuit_new` → `qk_circuit_new`)
+    const normalizedHash = hash ? removePrefix(hash, "c.") : undefined;
+    const pageAndHash = normalizedHash ? `${page}#${normalizedHash}` : page;
+    // The C API package is always named `{pythonPkgName}-c` (`qiskit` → `qiskit-c`,
+    // `qiskit-fermions` → `qiskit-fermions-c`).  Replace the final path segment rather
+    // than substituting inside it: a plain `.replace("qiskit", "qiskit-c")` rewrites the
+    // *first* match, turning `qiskit-fermions` into `qiskit-c-fermions`.
+    const cApiOutputDir = path.posix.join(
+      path.posix.dirname(kwargs.pkgOutputDir),
+      `${kwargs.pkgName}-c`,
+    );
+    return `${cApiOutputDir}/${pageAndHash}`;
+  }

path is already imported in this module (import path from "node:path", line 13), and kwargs.pkgName is already part of the normalizeUrl signature, so no plumbing changes are needed.

I verified the replacement produces /docs/api/qiskit-c for qiskit (unchanged behaviour) and /docs/api/qiskit-fermions-c for qiskit-fermions.

If you would rather keep the name in one place, an equivalent option is a Pkg accessor — e.g. cApiName() returning `${this.artifactPackageName}-c` — and passing it through kwargs. That also removes the implicit coupling between Pkg.fromArgs's hardcoded "qiskit-c" / "qiskit-fermions-c" entries and this call site.

Suggested regression test

In scripts/js/lib/api/updateLinks.test.ts, alongside the existing qiskit-c cases:

test("normalizeUrl() rewrites cdoc/ links for a non-qiskit package", () => {
  expect(
    normalizeUrl(
      "cdoc/qf-ferm-op.html#c.qf_ferm_op_set_groups",
      {},
      new Set(),
      {
        kebabCaseAndShorten: true,
        pkgName: "qiskit-fermions",
        pkgOutputDir: "/docs/api/qiskit-fermions",
      },
    ),
  ).toEqual("/docs/api/qiskit-fermions-c/qf-ferm-op#qf_ferm_op_set_groups");
});

Bug 2 — handleFootnotes drops the anchors of named citations

File: scripts/js/lib/api/processHtml.ts:406-417

What happens

export function handleFootnotes($: CheerioAPI, $main: Cheerio<any>): void {
  $main
    .find(".footnote, .footnote-reference, .footnote dt.label")
    .toArray()
    .forEach((footnote) => {
      const $footnote = $(footnote);
      const id = $footnote.attr("id");
      if (id) {
        $footnote.before(`<span id="${id}" class="target"></span>`);
      }
    });
}

Docutils renders footnotes (.. [1]) and citations (.. [Label]) with different classes. The selector covers only the footnote classes, so a named citation's id is never preserved as a <span id> and the anchor disappears from the MDX.

Running the two forms through docutils (writer_name="html5") gives:

RST element id class matched by current selector?
.. [JW-maj] <div> jw-maj citation no
[JW-maj]_ <a> citation-reference-1 citation-reference no
.. [1] <aside> footnote-1 footnote brackets yes
[1]_ <a> footnote-reference-1 brackets no (but the definition is caught)

The result on #5639 is 12 errors, all on one page, where both ends of each citation dangle — docs/api/qiskit-fermions-c/qf-mappers-library.mdx:

❌ Could not find link '#jw-maj'.  ❌ Could not find link '#id6'.
❌ Could not find link '#jw-ferm'. ❌ Could not find link '#id3'.
…also #jw-edge/#id11, #gandon-edge/#id13, #jw-transfer/#id17, #gandon-transfer/#id19

In the generated MDX the reference points at #jw-maj (line 164) and the definition's back-link at #id6 (line 179), and neither id is emitted anywhere on the page.

The source RST is well-formed and each label is unique — the reference and its definition live in the same docstring (crates/cext/src/mappers/library/jordan_wigner.rs). Notably, our Python pages are unaffected because they use numeric [1]_ citations, which render as footnote and so survive; the named form is what breaks.

Suggested fix

 export function handleFootnotes($: CheerioAPI, $main: Cheerio<any>): void {
+  // Docutils renders footnotes (`.. [1]`) and citations (`.. [Label]`) with different
+  // classes: `footnote`/`footnote-reference` versus `citation`/`citation-reference`.
+  // Both carry the `id` that in-page links target, so both must be preserved.
   $main
-    .find(".footnote, .footnote-reference, .footnote dt.label")
+    .find(
+      ".footnote, .footnote-reference, .footnote dt.label, " +
+        ".citation, .citation-reference, .citation dt.label",
+    )
     .toArray()
     .forEach((footnote) => {
       const $footnote = $(footnote);
       const id = $footnote.attr("id");
       if (id) {
         $footnote.before(`<span id="${id}" class="target"></span>`);
       }
     });
 }

Against the docutils output above, the current selector preserves only footnote-1, while the proposed one additionally preserves jw-maj and citation-reference-1 — the two ids whose absence produces the errors.

Suggested regression test

In scripts/js/lib/api/processHtml.test.ts:

test("handleFootnotes() preserves ids of named citations", async () => {
  const html = `
    <div class="citation" id="jw-maj">
      <dt class="label">JW-maj</dt><dd>Jordan and Wigner.</dd>
    </div>`;
  // Expect a <span id="jw-maj" class="target"> to precede the citation,
  // matching the existing behaviour for `.. [1]` footnotes.
});

Not a bug — for completeness

The third group of failures on #5639 (~20 links of the form guides/grouping#grouping-explanation, resolving against docs/api/qiskit-fermions/ instead of docs/addons/qiskit-fermions/guides/) is already fixed on main by the guides/ rewrite rule in pipelineStages.ts:190-199, added in 8ab2896 ("test the qiskit-noise-learning subsite", #5603) on 2026-09-11 19:10 UTC. The artifact on that PR was generated shortly before that commit, so those links should disappear on regeneration — I confirmed the rule's regex rewrites our exact link correctly.

One small caveat on that rule, since it may matter for other subsites: it keys on the literal path segment guides/, so an upstream project whose explanatory pages live in a differently-named directory will still leak relative links.


AI Disclaimer

This summary was written with the assistance of Claude Opus 5.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions