Skip to content

🐛 FIX: attrs: KeyError on aliased image attributes, duplicate {#id} headings, new-tab link attributes - #1200

Open
chrisjsewell wants to merge 6 commits into
masterfrom
claude/keen-einstein-iyrapj
Open

🐛 FIX: attrs: KeyError on aliased image attributes, duplicate {#id} headings, new-tab link attributes#1200
chrisjsewell wants to merge 6 commits into
masterfrom
claude/keen-einstein-iyrapj

Conversation

@chrisjsewell

@chrisjsewell chrisjsewell commented Sep 9, 2026

Copy link
Copy Markdown
Member

Summary

  • [i](f.png){w=1x} (and {h=…}, {a=…}) aborted the build with KeyError: 'width'; an invalid value
    given by its short alias now warns, exactly as the long form already did.
  • Two headings carrying the same {#id} aborted the build with
    ValueError: list.remove(x): x not in list and wrote no page; they now emit docutils' ordinary
    Duplicate explicit target name warning, as duplicate ids on paragraphs already did. A single {#id}
    heading also stops silently losing that name.
  • myst_links_external_new_tab emitted rel="noreferer noopener" — one r, a token no browser
    recognises. It is now spelled noreferrer.
  • myst_links_external_new_tab overwrote an authored {target=…}/{rel=…}; the config values now apply
    only to keys the author did not supply.
  • The emphasize_lines range warning read out of range(1-3 — the closing parenthesis is restored.

Compatibility

  • Aliased image attributes: crash → warning. {w=1x}/{h=2y}/{a=middle} previously aborted
    sphinx-build (exit 2, no HTML) and the docutils CLI (exit 1). The long forms' message text is
    byte-unchanged — the new fixture row's expected output is a byte copy of the existing long-form row's.
    One further, deliberate change: where an alias and its long form are both given for the same
    attribute ({w=1x width=2x}), the alias's warning now reports the alias's own value ('1x') instead of
    the long form's ('2x', which is what it happened to print before). Both warnings are still emitted, and
    the long form's own message is unchanged. A fixture row pins it.
  • Duplicate {#id} headings: crash → warning. The build now completes and writes the page. The warning
    is docutils' own message under Sphinx's [docutils] subtype — character for character what the paragraph
    case already produced — so it is suppressible today with suppress_warnings = ["docutils"]. HTML ids are
    unchanged (measured: identical id sets with and without the fix, on docutils 0.21.2 and 0.22.4). A single
    {#id} heading now keeps that name in names instead of losing it to dupnames. The change is a
    measured no-op on docutils 0.23, which guards against the underlying self-collision itself.
  • noreferernoreferrer. The old value was inert, so nothing that worked stops working. Nothing in
    the docs mentioned either spelling.
  • Authored target/rel now win. Non-breaking for anyone not authoring them: with no attribute group
    the rendering is byte-identical. The two keys are independent. An authored empty value counts as
    supplied, so [a](url){rel=""} now renders rel="" where it previously rendered the configured value;
    an unquoted {rel=} does not parse as an attribute group at all, before or after. The option's help
    text — its only user-facing documentation — now states what it sets and that authored values win.
  • out of range(1-3). Warning text only. Note this now differs by one character from Sphinx's own
    line number spec is out of range(1-%d): %r, which is unbalanced too; the divergence is deliberate.
  • No other rendering changes. Exactly one pre-existing test line moves: the one fixture word
    noreferernoreferrer in tests/test_renderers/fixtures/myst-config.txt.

Tests

  • Aliased image attributes — new myst-config.txt row [attrs_inline_image_warnings_aliases]
    ([a](b){w=1x h=2x a=other }). Before: the row raised KeyError: 'width' inside copy_attributes.
    After: three Invalid '…' attribute value warnings, output byte-identical to the long-form sibling row.
    A second row [attrs_inline_image_warnings_alias_and_long] ([a](b){w=1x width=2x}) pins the
    alias-plus-long-form case: before, both warnings read '2x'; after, '1x' then '2x'. Both rows run
    Sphinx-less, so the docutils-only CI job covers them too (green on docutils 0.20 and 0.23).
  • Duplicate {#id} headings — new sourcedir tests/test_sphinx/sourcedirs/attrs_block_duplicate_ids/
    with three duplicate pairs (headings, paragraphs, and headings inside a {note} fence, i.e. the rubric
    path) plus test_attrs_block_duplicate_ids. Before: ValueError: list.remove(x): x not in list, no page.
    After: build succeeded, exactly three Duplicate explicit target name warnings, index.html written.
    Two attributes.md rows pin the doctree: before, <section dupnames="hid" ids="hid" names="first"> plus
    an INFO Duplicate implicit target name: "hid"., and <rubric dupnames="rid" … names="first">; after,
    <section ids="hid" names="hid first"> and <rubric ids="rid" level="2" names="rid first">.
    Note: the integration test passes without the fix on docutils 0.23, which already guards the
    self-collision. Every Sphinx-paired CI cell runs docutils < 0.23 (Sphinx 8 caps <0.22, Sphinx 9 caps
    <0.23), so the test is genuinely red in CI on the unfixed tree.
  • noreferrer — the existing [links-external-new-tab] row was updated to the correct spelling first;
    it then failed with -noreferrer / +noreferer, and passes after the source change.
  • Authored target/rel — new row [links-external-new-tab-authored] covering both keys authored,
    each key alone, and an autolink. Before: all four rendered rel="noreferrer noopener" target="_blank".
    After: rel="nofollow" target="_self", rel="noreferrer noopener" target="_self",
    rel="nofollow" target="_blank", rel="nofollow" target="_blank". The option's help text is pinned by a
    new assertion in tests/test_docutils.py::test_help_text (red with the old text, green with the new).
  • out of range(1-3) — new tests/test_renderers/test_parse_linenos.py, a unit test on the
    _parse_linenos static method (the message is unreachable without Sphinx, so a fixture row cannot pin
    it; the module skips itself when Sphinx is absent). Before: the regex did not match out of range(1-3.
    After: it matches, and a valid case still returns [2].

Gates

Python 3.11, four environments, all measured at the branch tip:

  • Sphinx 9.0.4 / docutils 0.22.4: tests/test_renderers 398 passed 8 skipped; tests/test_sphinx
    24 passed; the rest 831 passed 3 skipped. 1253 passed, 0 failed (master baseline 1245).
  • Sphinx 8.2.3 / docutils 0.21.2: 396 passed 10 skipped; 22 passed 2 skipped; 831 passed 3 skipped.
    1249 passed, 0 failed (master baseline 1241).
  • docutils 0.23, no Sphinx (the check-myst-docutils job's four files): 180 passed, 0 failed
    (baseline 177).
  • docutils 0.20, no Sphinx, same four files: 178 passed, 2 skipped, 0 failed.
  • pre-commit run --all-files: all hooks passed, exit 0.

Every delta is the new tests; the skip lists are unchanged from master in all four environments.

Changelog lines

  • 🐛 FIX: warn instead of raising KeyError on an invalid image attribute given by its alias ({w=…},
    {h=…}, {a=…})
  • 🐛 FIX: a duplicate {#id} on headings warns instead of aborting the build, and a single {#id} heading
    keeps its name
  • 🐛 FIX: myst_links_external_new_tab spells rel="noreferrer"
  • 🐛 FIX: myst_links_external_new_tab no longer overwrites an authored target or rel
  • 🐛 FIX: balance the parenthesis in the emphasize_lines out-of-range warning

Follow-ups noticed

  • render_link_url mutates the parser's own token stream (token.attrs) just to pass two values to
    copy_attributes three lines later — the only writes to token.attrs in the package. Passing the
    defaults to copy_attributes, or setting them on the reference node when absent, would be cleaner; that
    is a refactor, not a bug fix.
  • On docutils < 0.22 a rubric that receives a system message before its implicit name is computed gets the
    warning's text baked into names (measured on the unfixed tree with a non-crashing trigger:
    names="…\ (warning/2)\ duplicate\ explicit\ target\ name:\ "dupe".h"; absent on 0.22.4). Pre-existing
    and untouched here; computing the rubric's implicit text before noting the explicit target would fix it.
  • Sphinx's own line number spec is out of range(1-%d): %r (sphinx/directives/code.py:131,297,483 in
    8.2.3 and 9.0.4) is unbalanced in the same way.
  • tests/test_renderers/test_fixtures_docutils.py::test_docutils_roles[112-code] fails if
    tests/test_sphinx is collected before tests/test_renderers in one process (Sphinx's code role leaks
    a language="" default into the global docutils role registry). Pre-existing; never fires in CI or tox,
    which collect alphabetically.
  • Documenting attribute-group precedence (docs/syntax/optional.md) is blocked on an mdit-py-plugins
    release carrying the span/link class-accumulation fix: pyproject.toml pins
    mdit-py-plugins~=0.6,>=0.6.1, and 0.6.1 is the newest release on PyPI, so "classes accumulate" is not
    yet true for spans and links with the dependency as pinned.
  • CHANGELOG.md is deliberately untouched; the entries above are for the maintainer.

Out of scope by ruling

  • A newline inside a quoted attribute value truncates it to the first line — changing that changes accepted
    input.
  • The disabled "full line consumed" check in _attr_block_rule ({.a}{#b} on one line applies only the
    first group); re-enabling it turns such lines into paragraphs.
  • Keys that are not valid HTML attribute names ({-=v}, {1=v}) are accepted.
  • A span inside a link label ([a [b]{.c} d](u)) breaks the link.
  • Surfacing ParseError positions as warnings — a feature, not a fix.
  • attrs_block before a {directive} fence, or before an <img> under html_image, being dropped —
    feature-sized (the directive mapping exists for myst_fence_as_directive and could be reused).

`copy_attributes` rebinds `key` to the aliased (node) name before the
value is validated, but then interpolated `token.attrs[key]` into the
warning message. For a short form such as `![i](f.png){w=1x}` that key
does not exist on the token, so an invalid value raised
`KeyError: 'width'` and aborted the build instead of warning.

Interpolate the value already in hand; `token.attrs[key]` was only ever
a re-fetch of it. The message text is unchanged for the long forms, and
an alias now produces the same message as its long form.
`generate_heading_target` appended the title-derived name to the node and
called `note_implicit_target`, which re-registers every name already on
the node -- including the explicit `{#id}` name that `copy_attributes`
had just added and registered. The node therefore collided with itself:
docutils demoted the explicit name into `dupnames` while its name map
still pointed at the node, so a second heading carrying the same `{#id}`
raised `ValueError: list.remove(x): x not in list` and no page was
written. The same happened for headings rendered as rubrics inside a
directive.

Register only the newly derived implicit name, and restore the explicit
names afterwards. Duplicates now produce docutils' own
`Duplicate explicit target name` warning, exactly as duplicate ids on
paragraphs already did, and a single `{#id}` heading keeps that name
instead of losing it to `dupnames`. docutils 0.23 guards against the
self-collision on its own, so this only affects earlier versions --
which is every version a released Sphinx can be paired with.
`myst_links_external_new_tab` emitted `rel="noreferer noopener"`, with
one `r`. No browser recognises that token, so the referrer was sent
anyway and the option only ever delivered half of what it promised.

Spell it `noreferrer`. The single fixture that pinned the misspelling is
updated with it.
`render_link_url` wrote the config-driven `target` and `rel` onto the
token before `copy_attributes` read the attribute group, so an authored
`{target=_self rel=nofollow}` was silently replaced by
`target="_blank" rel="noreferrer noopener"`.

Apply the config values only for keys the author did not supply. The two
keys are independent, so authoring just one of them leaves the other on
its configured value. This is non-breaking for anyone not authoring
`target` or `rel` on an external link: with no attribute group the
rendering is unchanged.

The option's help text, which is the whole of its user-facing
documentation, now says which attributes it sets and that authored
values win.
An out-of-range `emphasize-lines` reported
`emphasize_lines: out of range(1-3` -- the closing parenthesis was
missing from the format string.

Sphinx's own equivalent message, `line number spec is out of
range(1-%d): %r`, is unbalanced too; this makes MyST's copy read
correctly rather than matching it.
Review follow-ups on the preceding five commits, tests only:

- `test_help_text` now asserts the `myst_links_external_new_tab` help
  string mentions `rel="noreferrer noopener"`, so reverting it is no
  longer invisible to the suite.
- A `myst-config.txt` row pins that when an alias and its long form are
  both given (`{w=1x width=2x}`), each warning reports its own value;
  before the alias fix the alias's warning reported the long form's value.
- The `heading with id` doctree row uses an H1 so it no longer carries the
  unrelated "headings start at H2" message; the load-bearing section line
  is unchanged.
- `test_parse_linenos.py` skips at module level when Sphinx is missing,
  since the helper it tests imports `sphinx.util` at call time.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants