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'
+ '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
'