From 3768927366ee65b413f43f88e73307f32987457c Mon Sep 17 00:00:00 2001 From: Tom Cobb Date: Mon, 6 Jul 2026 09:28:45 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Expand=20`$`=20anchor=20shorthan?= =?UTF-8?q?d=20verbatim=20(case-preserved),=20per=20Sphinx=20semantics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sphinx expands the `$` uri shorthand with the object name verbatim (sphinx/util/inventory.py: `location = location[:-1] + name`), and only ever *writes* `$` when the anchor ends with the name byte-for-byte. The previous `.toLowerCase().replace(/\s+/g, '-')` therefore mis-links every case-distinct pair when consuming a real Sphinx inventory: `re.Match` (class) collapses onto `re.match` (function), and a capitalized glossary term `Match std:term index.html#term-$` resolves to `#term-match` instead of its real anchor `#term-Match`. Fixes jupyter-book/mystmd#1758. Completes continuous-foundation/intersphinx#5 (which kept lowercasing std:label/std:term — but those rows can also only ever be `$`-compressed when anchor == name exactly, so verbatim is correct unconditionally). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01WsPd4nPWGBXmubv4ypuWs7 --- .changeset/dollar-anchor-case.md | 5 +++++ src/intersphinx.ts | 8 +++++--- src/inventory.spec.ts | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 .changeset/dollar-anchor-case.md diff --git a/.changeset/dollar-anchor-case.md b/.changeset/dollar-anchor-case.md new file mode 100644 index 0000000..3485940 --- /dev/null +++ b/.changeset/dollar-anchor-case.md @@ -0,0 +1,5 @@ +--- +'intersphinx': patch +--- + +Expand the `$` uri anchor shorthand with the object name verbatim (case-preserved), matching Sphinx semantics. Previously the expansion was lowercased and whitespace-replaced, which broke links to any case-distinct targets (e.g. `re.Match` vs `re.match` in the CPython inventory, and capitalized glossary terms like `#term-Match`). diff --git a/src/intersphinx.ts b/src/intersphinx.ts index fa5f1ca..95a3bdd 100644 --- a/src/intersphinx.ts +++ b/src/intersphinx.ts @@ -152,9 +152,11 @@ export class Inventory { resolvedLocation = resolvedLocation.slice(1); } if (resolvedLocation.endsWith('$')) { - // Maybe move this to the parse function only? - resolvedLocation = - resolvedLocation.slice(0, -1) + entry.name.toLowerCase().replace(/\s+/g, '-'); + // Sphinx semantics: `$` is replaced by the object name *verbatim*. + // Case must be preserved: e.g. `re.match` (function) and `re.Match` + // (class) are distinct objects with distinct anchors. + // https://github.com/sphinx-doc/sphinx/blob/v8.2.3/sphinx/util/inventory.py#L154 + resolvedLocation = resolvedLocation.slice(0, -1) + entry.name; } const resolvedDisplay = !entry.display || entry.display.trim() === '-' ? undefined : entry.display.trim(); diff --git a/src/inventory.spec.ts b/src/inventory.spec.ts index 3421e3a..84786df 100644 --- a/src/inventory.spec.ts +++ b/src/inventory.spec.ts @@ -22,4 +22,36 @@ describe('Test Inventory', () => { expect(entry?.location?.includes('abc.html')).toBe(true); expect(entry?.type).toBe('std:doc'); }); + test('`$` anchor shorthand expands to the name verbatim (Sphinx semantics)', () => { + const inv = new Inventory({ id: 'test', path: 'https://example.org' }); + // Sphinx only writes `$` when the anchor ends with the name byte-for-byte, + // so the expansion must preserve case exactly: + // re.match (function) and re.Match (class) are distinct targets. + inv.setEntry({ type: 'py:function', name: 're.match', location: 'library/re.html#$' }); + inv.setEntry({ type: 'py:class', name: 're.Match', location: 'library/re.html#$' }); + // A capitalized glossary term: Sphinx writes `Match std:term -1 index.html#term-$ -` + // and its real HTML anchor is `id="term-Match"` — also case-preserved. + inv.setEntry({ type: 'std:term', name: 'Match', location: 'index.html#term-$' }); + expect(inv.getEntry({ name: 're.match' })?.location).toBe( + 'https://example.org/library/re.html#re.match', + ); + expect(inv.getEntry({ name: 're.Match' })?.location).toBe( + 'https://example.org/library/re.html#re.Match', + ); + expect(inv.getEntry({ type: 'std:term', name: 'Match' })?.location).toBe( + 'https://example.org/index.html#term-Match', + ); + }); + test('Python inventory - case sensitive names', async () => { + // Python 3.11 includes both `class Match` and `def match()` as targets + const inv = new Inventory({ path: 'https://docs.python.org/3.11' }); + await inv.load(); + expect(inv._loaded).toBe(true); + const entryClass = inv.getEntry({ name: 're.Match' }); + expect(entryClass?.location?.endsWith('re.Match')).toBe(true); + expect(entryClass?.type).toBe('py:class'); + const entryFunc = inv.getEntry({ name: 're.match' }); + expect(entryFunc?.location?.endsWith('re.match')).toBe(true); + expect(entryFunc?.type).toBe('py:function'); + }); });