Versions
- markdown-it-py 4.2.0, the latest release on PyPI.
- markdown-it-py
master at 6f58654 (2026-09-17) behaves the same.
- Python 3.14.4 on Windows 11. The code involved is platform-independent.
What happens
When a line has a [ or ![ that never closes, a valid code span after it can come out as plain text with its backticks visible. The CommonMark reference implementation renders it as code.
from markdown_it import MarkdownIt
md = MarkdownIt("commonmark")
print(md.render("[foo `bar` baz`"))
print(md.render("![alt `code` x`"))
print(md.render("[`a` `` x"))
Actual output (markdown-it-py 4.2.0 and master):
<p>[foo `bar` baz`</p>
<p>![alt `code` x`</p>
<p>[`a` `` x</p>
Expected output (the same as commonmark.js 0.31.2 and markdown-it 15.0.2):
<p>[foo <code>bar</code> baz`</p>
<p>![alt <code>code</code> x`</p>
<p>[<code>a</code> `` x</p>
The bracket alone causes it. With the [ removed, or with a ] added before the unmatched backtick run, the span is rendered as code again:
md.renderInline("`a` `` x") # '<code>a</code> `` x'
md.renderInline("[`a`] `` x") # '[<code>a</code>] `` x'
Cause
This comes from markdown-it JS and was reported there as markdown-it/markdown-it#1201. The JS fix is commit 5e9b1cc, "Rework backticks cache to remove side effects", released in markdown-it 15.0.1. The v14 line was not patched: markdown-it 14.3.2 still has the bug. markdown-it-py's port.yaml names JS 14.1.0.
In markdown_it/rules_inline/backticks.py, the rule remembers which backtick runs it has seen. It stores that memory in state.backticks and sets state.backticksScanned once it has scanned to the end of the line. That memory is only correct if the line is read once, from left to right. But parseLinkLabel looks ahead for the closing ] by running the inline rules silently through skipToken, and this lookahead shares the same state. The sequence is:
- The lookahead reads the code span, so the span's closing run is consumed and never recorded.
- A later unmatched run scans to the end of the line, which sets
backticksScanned = True while the record is incomplete.
- No
] is found, so the text is parsed for real. At the span's opening run, the check state.backticksScanned and state.backticks.get(openerLength, 0) <= start concludes there is no closing run, and the opening backticks are emitted as text.
Suggested fix
Port 5e9b1cc. It builds the table of the last run of each length from the whole string in a single pass the first time the rule runs, so how the parser walks the line no longer affects it. It also measures the whole of a run that straddles posMax. The commit adds five fixtures to commonmark_extras.txt. The first two are the examples above. The other three ([`](), a](/x)``b`` and ``[foo bar` baz``) already pass in markdown-it-py today, and they would guard the port against regressions.
Why it matters
Code spans bind more tightly than links (CommonMark 0.31.2, section 6.1). This input is valid, and the output differs from the specification. The bundled spec tests do not catch it because no spec example puts an unclosed label before an unmatched backtick run. The failure is silent: a tool that reads code_inline tokens, such as a linter checking paths or identifiers written in code spans, gets a text token and skips the span without any warning.
Related, lower priority
The same JS release also includes 988c82b, "don't strip spaces from all-space code spans". markdown-it-py already handles all-space spans correctly, but it decides whether to strip padding with len(token.content.strip()) > 0. Python's str.strip() also removes tabs, while CommonMark removes padding unless the content is entirely spaces. So ` \t ` renders as <code> \t </code>, where commonmark.js and markdown-it 15.0.2 give <code>\t</code>. This may deserve its own issue.
Versions
masterat 6f58654 (2026-09-17) behaves the same.What happens
When a line has a
[or![that never closes, a valid code span after it can come out as plain text with its backticks visible. The CommonMark reference implementation renders it as code.Actual output (markdown-it-py 4.2.0 and
master):Expected output (the same as commonmark.js 0.31.2 and markdown-it 15.0.2):
The bracket alone causes it. With the
[removed, or with a]added before the unmatched backtick run, the span is rendered as code again:Cause
This comes from markdown-it JS and was reported there as markdown-it/markdown-it#1201. The JS fix is commit 5e9b1cc, "Rework backticks cache to remove side effects", released in markdown-it 15.0.1. The v14 line was not patched: markdown-it 14.3.2 still has the bug. markdown-it-py's
port.yamlnames JS 14.1.0.In
markdown_it/rules_inline/backticks.py, the rule remembers which backtick runs it has seen. It stores that memory instate.backticksand setsstate.backticksScannedonce it has scanned to the end of the line. That memory is only correct if the line is read once, from left to right. ButparseLinkLabellooks ahead for the closing]by running the inline rules silently throughskipToken, and this lookahead shares the same state. The sequence is:backticksScanned = Truewhile the record is incomplete.]is found, so the text is parsed for real. At the span's opening run, the checkstate.backticksScanned and state.backticks.get(openerLength, 0) <= startconcludes there is no closing run, and the opening backticks are emitted as text.Suggested fix
Port 5e9b1cc. It builds the table of the last run of each length from the whole string in a single pass the first time the rule runs, so how the parser walks the line no longer affects it. It also measures the whole of a run that straddles
posMax. The commit adds five fixtures tocommonmark_extras.txt. The first two are the examples above. The other three ([`](),a](/x)``b`` and ``[foobar` baz``) already pass in markdown-it-py today, and they would guard the port against regressions.Why it matters
Code spans bind more tightly than links (CommonMark 0.31.2, section 6.1). This input is valid, and the output differs from the specification. The bundled spec tests do not catch it because no spec example puts an unclosed label before an unmatched backtick run. The failure is silent: a tool that reads
code_inlinetokens, such as a linter checking paths or identifiers written in code spans, gets atexttoken and skips the span without any warning.Related, lower priority
The same JS release also includes 988c82b, "don't strip spaces from all-space code spans". markdown-it-py already handles all-space spans correctly, but it decides whether to strip padding with
len(token.content.strip()) > 0. Python'sstr.strip()also removes tabs, while CommonMark removes padding unless the content is entirely spaces. So` \t `renders as<code> \t </code>, where commonmark.js and markdown-it 15.0.2 give<code>\t</code>. This may deserve its own issue.