diff --git a/docs/changelog.md b/docs/changelog.md index 5dc7b0f8..44dcf618 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -27,6 +27,10 @@ See the [Contributing Guide](contributing.md) for details. and closing backticks did not match (#1668). * Escape the language name in the `codehilite` extension's non-Pygments output to maintain consistency with Pygments output. +* Preserve attributes without a value in the `attr_list` extension exactly as + provided, rather than rendering them as `key="key"`, so the same source + renders consistently under both the `html` and `xhtml` output formats + (#1501). ## [3.11.2] - 2026-10-19 diff --git a/markdown/extensions/attr_list.py b/markdown/extensions/attr_list.py index b3b8d951..060e4761 100644 --- a/markdown/extensions/attr_list.py +++ b/markdown/extensions/attr_list.py @@ -28,6 +28,7 @@ from typing import TYPE_CHECKING from . import Extension +from ..serializers import BOOLEAN_ATTRIBUTES from ..treeprocessors import Treeprocessor import re @@ -54,7 +55,12 @@ def _handle_word(s, t): return '.', t[1:] if t.startswith('#'): return 'id', t[1:] - return t, t + # A word without a value is a valueless attribute. It is represented + # internally with a value of `None` so that what the user provided can + # be preserved. Consumers which assign the attribute to an element are + # responsible for restoring the historic `key="key"` representation + # for known boolean attributes (see `AttrListTreeprocessor.assign_attrs`). + return t, None _scanner = re.Scanner([ @@ -66,9 +72,11 @@ def _handle_word(s, t): ]) -def get_attrs_and_remainder(attrs_string: str) -> tuple[list[tuple[str, str]], str]: +def get_attrs_and_remainder(attrs_string: str) -> tuple[list[tuple[str, str | None]], str]: """ Parse attribute list and return a list of attribute tuples. + Attributes provided without a value are returned with a value of `None`. + Additionally, return any text that remained after a curly brace. In typical cases, its presence should mean that the input does not match the intended attribute list syntax. """ @@ -79,7 +87,7 @@ def get_attrs_and_remainder(attrs_string: str) -> tuple[list[tuple[str, str]], s return attrs, remainder -def get_attrs(str: str) -> list[tuple[str, str]]: # pragma: no cover +def get_attrs(str: str) -> list[tuple[str, str | None]]: # pragma: no cover """ Soft-deprecated. Prefer `get_attrs_and_remainder`. """ return get_attrs_and_remainder(str)[0] @@ -190,6 +198,18 @@ def assign_attrs(self, elem: Element, attrs_string: str, *, strict: bool = False elem.set('class', '{} {}'.format(cls, v)) else: elem.set('class', v) + elif v is None: + # assign a valueless attribute. Maintain the historic + # `key="key"` representation only for known boolean + # attributes (which differ per output format). For any + # other attribute, preserve what the user provided by + # keeping the value as `None`, which the serializer + # renders as the bare attribute name. + name = self.sanitize_name(k) + if name.lower() in BOOLEAN_ATTRIBUTES.get(self.md.output_format, frozenset()): + elem.set(name, name) + else: + elem.set(name, None) else: # assign attribute `k` with `v` elem.set(self.sanitize_name(k), v) diff --git a/markdown/extensions/fenced_code.py b/markdown/extensions/fenced_code.py index fbdd015b..6e2ed6b1 100644 --- a/markdown/extensions/fenced_code.py +++ b/markdown/extensions/fenced_code.py @@ -171,12 +171,18 @@ def run(self, lines: list[str]) -> list[str]: break return text.split("\n") - def handle_attrs(self, attrs: Iterable[tuple[str, str]]) -> tuple[str, list[str], dict[str, Any]]: + def handle_attrs( + self, attrs: Iterable[tuple[str, str | None]] + ) -> tuple[str, list[str], dict[str, Any]]: """ Return tuple: `(id, [list, of, classes], {configs})` """ id = '' classes = [] configs = {} for k, v in attrs: + if v is None: + # A valueless attribute is a flag: preserve the + # historic behavior of using the key as its own value. + v = k if k == 'id': id = v elif k == '.': diff --git a/markdown/serializers.py b/markdown/serializers.py index 255092b4..69f031cc 100644 --- a/markdown/serializers.py +++ b/markdown/serializers.py @@ -55,6 +55,27 @@ RE_AMP = re.compile(r'&(?!(?:\#[0-9]+|\#x[0-9a-f]+|[0-9a-z]+);)', re.I) +# Known boolean attributes, whose presence alone carries the meaning and +# which may therefore be rendered in the minimized (valueless) form. The +# list of recognized attributes differs per output format: `xhtml` only +# recognizes the attributes declared boolean by the XHTML 1.0 spec, while +# `html` recognizes the boolean attributes of the HTML spec. Note that +# `download` is deliberately absent from both lists: it is only boolean +# when no value is assigned and a value changes its meaning. +BOOLEAN_ATTRIBUTES: dict[str, frozenset[str]] = { + 'html': frozenset([ + 'allowfullscreen', 'async', 'autofocus', 'autoplay', 'checked', + 'controls', 'default', 'defer', 'disabled', 'formnovalidate', + 'hidden', 'inert', 'ismap', 'itemscope', 'loop', 'multiple', 'muted', + 'nomodule', 'novalidate', 'open', 'playsinline', 'readonly', + 'required', 'reversed', 'selected', + ]), + 'xhtml': frozenset([ + 'checked', 'compact', 'declare', 'defer', 'disabled', 'ismap', + 'multiple', 'noresize', 'noshade', 'nowrap', 'readonly', 'selected', + ]), +} + def _raise_serialization_error(text: str) -> NoReturn: # pragma: no cover raise TypeError( @@ -162,13 +183,17 @@ def _serialize_html(write: Callable[[str], None], elem: Element, format: Literal if isinstance(v, QName): # Assume a text only `QName` v = v.text + if v is None: + # A valueless attribute is preserved exactly as it + # was provided: render the bare attribute name. + write(f" {k}") else: v = _escape_attrib_html(v) - if k == v and format == 'html': - # handle boolean attributes - write(f" {v}") - else: - write(f' {k}="{v}"') + if k == v and format == 'html' and k in BOOLEAN_ATTRIBUTES['html']: + # handle boolean attributes + write(f" {v}") + else: + write(f' {k}="{v}"') if namespace_uri: write(f' xmlns="{_escape_attrib(namespace_uri)}"') if format == "xhtml" and tag.lower() in HTML_EMPTY: diff --git a/markdown/treeprocessors.py b/markdown/treeprocessors.py index a148175f..63904b39 100644 --- a/markdown/treeprocessors.py +++ b/markdown/treeprocessors.py @@ -480,4 +480,7 @@ def run(self, root: etree.Element) -> None: elem.tail = self.unescape(elem.tail) # Unescape attribute values for key, value in elem.items(): - elem.set(key, self.unescape(value)) + if value is not None: + # A value of `None` is a valueless attribute, which + # has nothing to unescape. + elem.set(key, self.unescape(value)) diff --git a/tests/extensions/attr_list.html b/tests/extensions/attr_list.html index e7bfe0bf..600934c1 100644 --- a/tests/extensions/attr_list.html +++ b/tests/extensions/attr_list.html @@ -6,7 +6,6 @@

This is another

A link And a nested link

This is a hash Header

-

And now some random attributes.

No closing hash header

Now test overrides

# A code block which contains attr_list syntax
@@ -15,7 +14,6 @@ 

No closing hash header

No colon for compatibility with Headerid ext

Also a codespan: {: .someclass}.

-

Bad Syntax