From ccfff72ea48a28f70fe4ddfe1a32ac48a3d4c783 Mon Sep 17 00:00:00 2001 From: Rahul Ranjan <11793455+rahul05ranjan@users.noreply.github.com> Date: Thu, 12 Nov 2026 20:45:27 +0000 Subject: [PATCH 1/3] Fix toc crash on a heading with a valueless id attribute The attr_list extension writes bare attributes (``{id}``) with a value of None. The toc extension's "do not override pre-existing ids" check only tested key membership, so a heading whose id attribute existed but carried no value skipped slug generation and later passed the None to re.sub through unescape(), raising an unhandled TypeError: markdown.markdown("#d {id}", extensions=[attr_list, toc]) TypeError: expected string or bytes-like object, got 'NoneType' A valueless id is no id at all, so toc now treats a falsy id like a missing one and generates a slug for it, exactly as for any other id-less heading. Fixes #1705 Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com> --- docs/changelog.md | 4 +++ markdown/extensions/toc.py | 5 +++- tests/test_syntax/extensions/test_toc.py | 36 ++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index 7926ab4aa..043bfa2d7 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -16,6 +16,10 @@ See the [Contributing Guide](contributing.md) for details. ### Fixed +* The `toc` extension no longer raises `TypeError` for a heading whose `id` + attribute is present but valueless, as the `attr_list` extension's + bare-attribute syntax (`{id}`) writes it: like a heading with no `id` at + all, it now gets a generated slug (#1705). * Fix for complex cases in new emphasis handling where text could be unexpectedly dropped (#1694). * Fix rule of 3 handling in new emphasis handling where an opener skipped by the rule could still be paired diff --git a/markdown/extensions/toc.py b/markdown/extensions/toc.py index ba65225c8..05570edcb 100644 --- a/markdown/extensions/toc.py +++ b/markdown/extensions/toc.py @@ -400,7 +400,10 @@ def run(self, doc: etree.Element) -> None: name = strip_tags(innerhtml) # Do not override pre-existing ids - if "id" not in el.attrib: + if not el.attrib.get("id"): + # The attr_list extension writes bare attributes with a + # value of None. A valueless id is no id at all, so a slug + # is generated for it like for any other id-less heading. el.attrib["id"] = unique(self.slugify(html.unescape(name), self.sep), used_ids, id_counters) data_toc_label = '' diff --git a/tests/test_syntax/extensions/test_toc.py b/tests/test_syntax/extensions/test_toc.py index 12a07d50e..adf525a03 100644 --- a/tests/test_syntax/extensions/test_toc.py +++ b/tests/test_syntax/extensions/test_toc.py @@ -22,6 +22,7 @@ from markdown.test_tools import TestCase from markdown import Markdown from markdown.extensions.toc import TocExtension, strip_tags, unique +from markdown.extensions.attr_list import AttrListExtension from markdown.extensions.nl2br import Nl2BrExtension @@ -1574,3 +1575,38 @@ def testDontStripHTMLEntities(self): strip_tags('foo < & < bar'), 'foo < & < bar' ) + + +class TestTOCWithAttrList(TestCase): + """The toc extension together with attr_list (#1705).""" + + maxDiff = None + default_kwargs = { + 'extensions': [TocExtension(), AttrListExtension()] + } + + def testBareIdAttribute(self): + # attr_list writes bare attributes with a value of None. A valueless + # id is no id at all, so toc generates a slug for it like for any + # other id-less heading instead of crashing with a TypeError. + self.assertMarkdownRenders( + '#d {id}', + '

d

' + ) + + def testBareIdAttributeWithTOCMarker(self): + self.assertMarkdownRenders( + self.dedent( + ''' + [TOC] + + # Heading One {id} + ''' + ), + '
\n' + '\n' # noqa + '
\n' + '

Heading One

' + ) From cfe337919eee629f6dde12d3641ed8d630f4f1e3 Mon Sep 17 00:00:00 2001 From: rahul05ranjan Date: Fri, 13 Nov 2026 16:02:50 +0530 Subject: [PATCH 2/3] Add attr and TypeError to the spelling dictionary The pyspelling lint reads comments and docstrings as plain words, so the "attr_list" name in the new toc.py comment and the "TypeError" mentioned in the new test docstring tripped the checkspelling job. --- .spell-dict | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.spell-dict b/.spell-dict index f70d2bc8d..24b1c2597 100644 --- a/.spell-dict +++ b/.spell-dict @@ -7,6 +7,7 @@ API Artem aspell Astropy +attr Babelmark backtick backticks @@ -141,6 +142,7 @@ svn Swartz Szakmeister Takhteyev +TypeError templating Tiago toc From 821ae37e22a438f18e83a54084150204d40464ae Mon Sep 17 00:00:00 2001 From: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com> Date: Sat, 14 Nov 2026 02:19:13 +0000 Subject: [PATCH 3/3] Use code spans rather than spelling dictionary entries "TypeError" is code and "attr_list" is the name of an extension, so per the style guides they belong in code spans (which the spellchecker ignores) rather than in .spell-dict. Drop the dictionary entries and wrap the words in code spans in the affected comments and docstrings. Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com> --- .spell-dict | 2 -- markdown/extensions/toc.py | 2 +- tests/test_syntax/extensions/test_toc.py | 9 +++++---- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/.spell-dict b/.spell-dict index 24b1c2597..f70d2bc8d 100644 --- a/.spell-dict +++ b/.spell-dict @@ -7,7 +7,6 @@ API Artem aspell Astropy -attr Babelmark backtick backticks @@ -142,7 +141,6 @@ svn Swartz Szakmeister Takhteyev -TypeError templating Tiago toc diff --git a/markdown/extensions/toc.py b/markdown/extensions/toc.py index 05570edcb..261f2af03 100644 --- a/markdown/extensions/toc.py +++ b/markdown/extensions/toc.py @@ -401,7 +401,7 @@ def run(self, doc: etree.Element) -> None: # Do not override pre-existing ids if not el.attrib.get("id"): - # The attr_list extension writes bare attributes with a + # The `attr_list` extension writes bare attributes with a # value of None. A valueless id is no id at all, so a slug # is generated for it like for any other id-less heading. el.attrib["id"] = unique(self.slugify(html.unescape(name), self.sep), used_ids, id_counters) diff --git a/tests/test_syntax/extensions/test_toc.py b/tests/test_syntax/extensions/test_toc.py index adf525a03..60fa87dad 100644 --- a/tests/test_syntax/extensions/test_toc.py +++ b/tests/test_syntax/extensions/test_toc.py @@ -1578,7 +1578,7 @@ def testDontStripHTMLEntities(self): class TestTOCWithAttrList(TestCase): - """The toc extension together with attr_list (#1705).""" + """The `toc` extension together with `attr_list` (#1705).""" maxDiff = None default_kwargs = { @@ -1586,9 +1586,10 @@ class TestTOCWithAttrList(TestCase): } def testBareIdAttribute(self): - # attr_list writes bare attributes with a value of None. A valueless - # id is no id at all, so toc generates a slug for it like for any - # other id-less heading instead of crashing with a TypeError. + # The `attr_list` extension writes bare attributes with a value of + # None. A valueless `id` is no id at all, so `toc` generates a slug + # for it like for any other id-less heading instead of raising a + # `TypeError`. self.assertMarkdownRenders( '#d {id}', '

d

'