Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 23 additions & 3 deletions markdown/extensions/attr_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from typing import TYPE_CHECKING

from . import Extension
from ..serializers import BOOLEAN_ATTRIBUTES
from ..treeprocessors import Treeprocessor
import re

Expand All @@ -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([
Expand All @@ -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.
"""
Expand All @@ -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]

Expand Down Expand Up @@ -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)
Expand Down
8 changes: 7 additions & 1 deletion markdown/extensions/fenced_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == '.':
Expand Down
35 changes: 30 additions & 5 deletions markdown/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion markdown/treeprocessors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
3 changes: 0 additions & 3 deletions tests/extensions/attr_list.html
Comment thread
waylan marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ <h2 class="someclass" id="sextext2">This is another</h2>
A <a class="linkkyclass" href="http://example.com" title="A title.">link</a>
And a <strong class="nest">nested <a class="linky2" href="http://example.com" title="Some title">link</a></strong></p>
<h3 id="hash">This is a hash Header</h3>
<p bar="b az" baz="blah blah" foo="foo" title="I wasn't kidding!">And now some random attributes.</p>
<h3 id="hash2">No closing hash header</h3>
<p class="foo bar addme" id="overridden">Now test overrides</p>
<pre><code># A code block which contains attr_list syntax
Expand All @@ -15,7 +14,6 @@ <h3 id="hash2">No closing hash header</h3>
</code></pre>
<h3 id="hash3">No colon for compatibility with Headerid ext</h3>
<p id="the_end">Also a codespan: <code class="foo">{: .someclass}</code>.</p>
<h3 _:="{:" id="hash5">Bad Syntax</h3>
<ul>
<li class="item">Item1</li>
<li class="item">Item2<ul>
Expand Down Expand Up @@ -59,7 +57,6 @@ <h1 class="block">Definition <em>lists</em></h1>
<dd>Some dd</dd>
</dl>
<h1>Bad attributes</h1>
<p>Key without <em foo="foo">value</em></p>
<p>Value without <em>key</em></p>
<p>No <em>key or value</em></p>
<p><em>Weirdness</em></p>
Expand Down
7 changes: 0 additions & 7 deletions tests/extensions/attr_list.txt
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,6 @@ And a __nested [link][]{: .linky2}__{: .nest}

### This is a hash Header ### {: #hash}

And now some random attributes.
{:foo bar='b az' baz="blah blah" title="I wasn't kidding!" }

### No closing hash header {: #hash2}

Now test overrides
Expand All @@ -33,8 +30,6 @@ Now test overrides
Also a codespan: `{: .someclass}`{: .foo}.
{: #the_end}

### Bad Syntax { {: #hash5 }

* Item1
{: .item }
* Item2
Expand Down Expand Up @@ -78,8 +73,6 @@ DT2 {.term}

# Bad attributes

Key without *value*{ foo= }

Value without *key*{ =bar }

No *key or value*{ = }
Expand Down
168 changes: 168 additions & 0 deletions tests/test_syntax/extensions/test_attr_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,171 @@ def test_table_td(self):
),
extensions=['attr_list', 'tables']
)


class TestAttrListValuelessAttributes(TestCase):
""" Valueless (boolean-style) attributes are preserved as provided.

Only known boolean attributes keep the historic `key="key"`
representation, and the list of known attributes differs per
output format. See issue #1501.
"""

maxDiff = None
default_kwargs = {'extensions': ['attr_list']}

def test_valueless_non_boolean_preserved_xhtml(self):
self.assertMarkdownRenders(
'[download](file.txt){download}',
'<p><a download href="file.txt">download</a></p>'
)

def test_valueless_non_boolean_preserved_html(self):
self.assertMarkdownRenders(
'[download](file.txt){download}',
'<p><a download href="file.txt">download</a></p>',
output_format='html'
)

def test_valueless_non_boolean_consistent_across_formats(self):
# Previously `xhtml` rendered `download="download"` (which gives the
# downloaded file a default name) while `html` rendered bare
# `download` (which does not): the same source changed meaning with
# the output format.
for output_format in ['xhtml', 'html']:
self.assertMarkdownRenders(
'[download](file.txt){download}',
'<p><a download href="file.txt">download</a></p>',
output_format=output_format
)

def test_explicit_value_equal_to_name_preserved(self):
# An explicitly assigned value must not be dropped, even when it is
# equal to the attribute name and the attribute is not boolean.
for output_format in ['xhtml', 'html']:
self.assertMarkdownRenders(
'[download](file.txt){download="download"}',
'<p><a download="download" href="file.txt">download</a></p>',
output_format=output_format
)

def test_known_boolean_attribute_xhtml(self):
self.assertMarkdownRenders(
'[x](y){checked}',
'<p><a checked="checked" href="y">x</a></p>'
)

def test_known_boolean_attribute_html(self):
self.assertMarkdownRenders(
'[x](y){checked}',
'<p><a checked href="y">x</a></p>',
output_format='html'
)

def test_xhtml_only_boolean_attribute(self):
# `compact` is a boolean attribute in XHTML 1.0 but not in HTML.
self.assertMarkdownRenders(
'# Heading {compact}',
'<h1 compact="compact">Heading</h1>'
)
self.assertMarkdownRenders(
'# Heading {compact}',
'<h1 compact>Heading</h1>',
output_format='html'
)

def test_html_only_boolean_attribute(self):
# `open` is a boolean attribute in HTML but not in XHTML 1.0. It
# renders bare in both formats: in HTML as a known boolean, and in
# XHTML as a valueless attribute preserved as provided.
for output_format in ['xhtml', 'html']:
self.assertMarkdownRenders(
'[x](y){open}',
'<p><a href="y" open>x</a></p>',
output_format=output_format
)

def test_generic_valueless_attribute(self):
for output_format in ['xhtml', 'html']:
self.assertMarkdownRenders(
'[x](y){foo}',
'<p><a foo href="y">x</a></p>',
output_format=output_format
)

def test_valueless_attribute_on_block_element(self):
self.assertMarkdownRenders(
'# Heading {download}',
'<h1 download>Heading</h1>'
)

def test_get_attrs_and_remainder_marks_valueless_as_none(self):
from markdown.extensions.attr_list import get_attrs_and_remainder
attrs, remainder = get_attrs_and_remainder('download checked key=value .cls #the-id')
self.assertEqual(remainder, '')
self.assertIn(('download', None), attrs)
self.assertIn(('checked', None), attrs)
self.assertIn(['key', 'value'], attrs)
self.assertIn(('.', 'cls'), attrs)
self.assertIn(('id', 'the-id'), attrs)

def test_serializer_renders_none_value_as_bare_name(self):
import xml.etree.ElementTree as etree

from markdown.serializers import to_html_string, to_xhtml_string

elem = etree.Element('a')
elem.set('download', None)
elem.set('checked', 'checked')
elem.set('href', 'file.txt')
elem.text = 'x'
self.assertEqual(
to_html_string(elem),
'<a checked download href="file.txt">x</a>'
)
self.assertEqual(
to_xhtml_string(elem),
'<a checked="checked" download href="file.txt">x</a>'
)

def test_serializer_preserves_explicit_value_equal_to_name(self):
import xml.etree.ElementTree as etree

from markdown.serializers import to_html_string, to_xhtml_string

elem = etree.Element('a')
elem.set('download', 'download')
elem.text = 'x'
self.assertEqual(to_html_string(elem), '<a download="download">x</a>')
self.assertEqual(to_xhtml_string(elem), '<a download="download">x</a>')

def test_fenced_code_valueless_word(self):
# `fenced_code` shares the attribute parser: a valueless word
# keeps its historic treatment there (key used as its own value).
self.assertMarkdownRenders(
'``` { .python linenos }\nx = 1\n```',
'<pre><code class="language-python" linenos="linenos">x = 1\n</code></pre>',
extensions=['fenced_code', 'attr_list']
)

# The next three tests were moved here from the legacy fixture tests
# in `tests/extensions/attr_list.txt` because their expected output
# changed with the preservation of valueless attributes.

def test_valueless_attribute_among_valued_attributes(self):
self.assertMarkdownRenders(
"And now some random attributes.\n{:foo bar='b az' baz=\"blah blah\" title=\"I wasn't kidding!\" }",
'<p bar="b az" baz="blah blah" foo title="I wasn\'t kidding!">And now some random attributes.</p>'
)

def test_bad_syntax_header_with_valueless_attribute(self):
self.assertMarkdownRenders(
'### Bad Syntax { {: #hash5 }',
'<h3 _: id="hash5">Bad Syntax</h3>'
)

def test_key_without_value(self):
self.assertMarkdownRenders(
'Key without *value*{ foo= }',
'<p>Key without <em foo>value</em></p>'
)
Loading