Skip to content

Commit 0eb2047

Browse files
authored
Provide option for more relaxed fenced code headers (#2649)
* Provide option for more relaxed fenced code headers Resolves #2544 * Add more tests * Add changelog entry and fix spelling
1 parent 677a863 commit 0eb2047

File tree

6 files changed

+261
-7
lines changed

6 files changed

+261
-7
lines changed

docs/src/markdown/about/changelog.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
# Changelog
22

3-
## Unreleased
3+
## 10.15.0
44

5+
- **NEW**: SuperFences: Add `relaxed_headers` option which can tolerate bad content in the fenced code header. When
6+
enabled, code blocks with bad content in the header will likely still convert into code blocks, often respecting
7+
the specified language.
58
- **FIX**: Blocks: Fix some corner cases of nested blocks with lists.
69
- **FIX**: Tab and Tabbed: Fix a case where tabs could fail if `combine_header_slug` was enabled and there was no
710
header.

docs/src/markdown/extensions/superfences.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -611,6 +611,54 @@ import foo
611611
```
612612
///
613613

614+
## Relaxed Headers
615+
616+
/// new | New in 10.15
617+
///
618+
619+
The `relaxed_headers` [option][#options] is a feature that can be enabled which will cause SuperFences to treat fenced
620+
code headers in a more relaxed manner.
621+
622+
The default approach of SuperFences is to bail on processing a fenced code if the there is invalid content in a fenced
623+
header. This may be because you are processing fenced code blocks from other, incompatible Markdown parsers. Having a
624+
way to treat incompatible code blocks as code blocks, even if they have invalid syntax may be desirable.
625+
626+
As an example, some Markdown parsers may allow for specifying line highlights with `[number]`. This is not supported in
627+
SuperFences, but if we were processing such a block, with `relaxed_headers` enabled, we would treat it as an actual
628+
code block ignoring the invalid syntax instead of assuming the entire code block was invalid.
629+
630+
````text title="Bad Code Header Syntax"
631+
```python [3]
632+
import code
633+
634+
code.do_stuff()
635+
```
636+
````
637+
638+
/// html | div.result
639+
````md-render
640+
---
641+
extensions:
642+
- pymdownx.highlight
643+
- pymdownx.superfences
644+
645+
extension_configs:
646+
pymdownx.superfences:
647+
relaxed_headers: true
648+
---
649+
```python [3]
650+
import code
651+
652+
code.do_stuff()
653+
```
654+
````
655+
///
656+
657+
It should be noted that this mode does not always guarantee that your code language will be picked up or that relevant
658+
classes, IDs, attributes, or options will be recognized if invalid content is present, only that it will try and process
659+
the block as a code block. Additionally, a custom block that explicitly throws a `SuperFencesException` will still have
660+
that exception raised which will terminate Markdown processing.
661+
614662
## Custom Fences
615663

616664
SuperFences allows defining custom fences for special purposes. For instance, we could create special fences for
@@ -911,3 +959,4 @@ Option | Type | Default | Description
911959
`disable_indented_code_blocks` | bool | `#!py3 False` | Disables Python Markdown's indented code block parsing. This is nice if you only ever use fenced blocks.
912960
`custom_fences` | [dictionary] | `#!py3 []` | Custom fences.
913961
`preserve_tabs` | bool | `#!py3 False` | Experimental feature that preserves tabs in fenced code blocks.
962+
`relaxed_headers` | bool | `#!py3 False` | Enable relaxed fenced code headers that are more forgiving of invalid content in the fenced header.

docs/src/mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ markdown_extensions:
108108
- markdown.extensions.md_in_html:
109109
- pymdownx.superfences:
110110
preserve_tabs: true
111+
relaxed_headers: true
111112
custom_fences:
112113
# Mermaid diagrams
113114
- name: diagram

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ markdown_extensions:
108108
- markdown.extensions.md_in_html:
109109
- pymdownx.superfences:
110110
preserve_tabs: true
111+
relaxed_headers: true
111112
custom_fences:
112113
# Mermaid diagrams
113114
- name: diagram

pymdownx/superfences.py

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
2828
License: [BSD](http://www.opensource.org/licenses/bsd-license.php)
2929
"""
30-
3130
from markdown.extensions import Extension
3231
from markdown.preprocessors import Preprocessor
3332
from markdown.blockprocessors import CodeBlockProcessor
@@ -51,6 +50,9 @@
5150
(?:
5251
(?:[ \t]*[a-zA-Z][a-zA-Z0-9_]*(?:=(?P<quot>"|').*?(?P=quot))?)(?=[\t ]|$) # Options
5352
)+
53+
) |
54+
(?P<unrecognized>
55+
(?:([ \t]*[^\s]+)(?=[\t ]|$))+
5456
)
5557
)?[ \t]*$
5658
'''
@@ -231,7 +233,8 @@ def __init__(self, *args, **kwargs):
231233
"if nothing is set. - "
232234
"Default: ''"
233235
],
234-
'preserve_tabs': [False, "Preserve tabs in fences - Default: False"]
236+
'preserve_tabs': [False, "Preserve tabs in fences - Default: False"],
237+
'relaxed_headers': [False, "Relaxed fenced code headers - Default: False"]
235238
}
236239
super().__init__(*args, **kwargs)
237240

@@ -376,6 +379,7 @@ def get_hl_settings(self):
376379
css_class = self.config['css_class']
377380
self.css_class = css_class if css_class else config['css_class']
378381

382+
self.relaxed_headers = self.config.get('relaxed_headers', False)
379383
self.extend_pygments_lang = config.get('extend_pygments_lang', None)
380384
self.guess_lang = config['guess_lang']
381385
self.pygments_style = config['pygments_style']
@@ -594,9 +598,9 @@ def parse_options(self, m):
594598
self.formatter = None
595599
values = {}
596600
if string:
597-
for m in RE_OPTIONS.finditer(string):
598-
key = m.group('key')
599-
value = m.group('value')
601+
for m2 in RE_OPTIONS.finditer(string):
602+
key = m2.group('key')
603+
value = m2.group('value')
600604
if value is None:
601605
value = key
602606
values[key] = value
@@ -620,8 +624,48 @@ def parse_options(self, m):
620624
self.options = options
621625
break
622626

627+
if not okay and self.relaxed_headers:
628+
return self.handle_unrecognized(m)
629+
623630
return okay
624631

632+
def handle_unrecognized(self, m):
633+
"""Handle unrecognized code headers."""
634+
635+
okay = False
636+
if not self.relaxed_headers:
637+
return okay
638+
639+
if m.group('lang'):
640+
self.lang = m.group('lang')
641+
642+
self.options = {}
643+
self.attrs = {}
644+
self.formatter = None
645+
646+
# Run per language validator
647+
for entry in reversed(self.extension.superfences):
648+
if entry["test"](self.lang):
649+
options = {}
650+
attrs = {}
651+
validator = entry.get("validator", functools.partial(_validator, validator=default_validator))
652+
try:
653+
okay = validator(self.lang, {}, options, attrs, self.md)
654+
except SuperFencesException:
655+
raise
656+
except Exception:
657+
pass
658+
if okay:
659+
self.formatter = entry.get("formatter")
660+
self.options = options
661+
if self.attr_list:
662+
self.attrs = attrs
663+
break
664+
665+
if not okay:
666+
self.lang = None # pragma: no cover
667+
return True
668+
625669
def handle_attrs(self, m):
626670
"""Handle attribute list."""
627671

@@ -664,6 +708,9 @@ def handle_attrs(self, m):
664708
self.attrs = attrs
665709
break
666710

711+
if not okay and self.relaxed_headers:
712+
return self.handle_unrecognized(m) # pragma: no cover
713+
667714
return okay
668715

669716
def search_nested(self, lines):
@@ -683,7 +730,9 @@ def search_nested(self, lines):
683730
if m is not None:
684731

685732
# Parse options
686-
if m.group('attrs'):
733+
if m.group('unrecognized'):
734+
okay = self.handle_unrecognized(m)
735+
elif m.group('attrs'):
687736
okay = self.handle_attrs(m)
688737
else:
689738
okay = self.parse_options(m)

tests/test_extensions/test_superfences.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1634,3 +1634,154 @@ def test_broken_brace(self):
16341634
''',
16351635
True
16361636
)
1637+
1638+
1639+
class TestHighlightRelaxedHeaders(util.MdCase):
1640+
"""Test relaxed header cases."""
1641+
1642+
extension = ['pymdownx.highlight', 'pymdownx.superfences', 'attr_list']
1643+
extension_configs = {
1644+
'pymdownx.superfences': {
1645+
'relaxed_headers': True,
1646+
'custom_fences': [
1647+
{
1648+
'name': 'test',
1649+
'class': 'test',
1650+
'format': custom_format,
1651+
'validator': custom_validator_except
1652+
},
1653+
{
1654+
'name': 'test2',
1655+
'class': 'test',
1656+
'format': custom_format,
1657+
'validator': custom_validator_exploder
1658+
}
1659+
]
1660+
}
1661+
}
1662+
1663+
def test_custom_fail_exception(self):
1664+
"""Test custom fences forced exception."""
1665+
1666+
with self.assertRaises(SuperFencesException):
1667+
self.check_markdown(
1668+
r'''
1669+
```test [1]
1670+
test
1671+
```
1672+
''',
1673+
'',
1674+
True
1675+
)
1676+
1677+
def test_custom_fail_exception_relaxed(self):
1678+
"""Test custom fences relaxed forced exception."""
1679+
1680+
self.check_markdown(
1681+
r'''
1682+
```test2 [1]
1683+
test
1684+
```
1685+
''',
1686+
'''
1687+
<div class="highlight"><pre><span></span><code>test
1688+
</code></pre></div>
1689+
''',
1690+
True
1691+
)
1692+
1693+
def test_bad_lang(self):
1694+
"""Test bad language."""
1695+
1696+
self.check_markdown(
1697+
r'''
1698+
```bad
1699+
test
1700+
```
1701+
''',
1702+
'''
1703+
<div class="highlight"><pre><span></span><code>test
1704+
</code></pre></div>
1705+
''',
1706+
True
1707+
)
1708+
1709+
def test_bad_option(self):
1710+
"""Test bad options."""
1711+
1712+
self.check_markdown(
1713+
r'''
1714+
```pycon bad="My title"
1715+
>>> import test
1716+
```
1717+
''',
1718+
r'''
1719+
<div class="highlight"><pre><span></span><code><span class="gp">&gt;&gt;&gt; </span><span class="kn">import</span><span class="w"> </span><span class="nn">test</span>
1720+
</code></pre></div>
1721+
''', # noqa: E501
1722+
True
1723+
)
1724+
1725+
def test_bad_attribute(self):
1726+
"""Test bad attribute."""
1727+
1728+
self.check_markdown(
1729+
r'''
1730+
```pycon {.class <nonsense>!!!}
1731+
>>> import test
1732+
```
1733+
''',
1734+
r'''
1735+
<div class="class highlight"><pre><span></span><code><span class="gp">&gt;&gt;&gt; </span><span class="kn">import</span><span class="w"> </span><span class="nn">test</span>
1736+
</code></pre></div>
1737+
''', # noqa: E501
1738+
True
1739+
)
1740+
1741+
def test_bad_attribute_full(self):
1742+
"""Test bad attribute full."""
1743+
1744+
self.check_markdown(
1745+
r'''
1746+
```{.pycon .class <nonsense>!!!}
1747+
>>> import test
1748+
```
1749+
''',
1750+
r'''
1751+
<div class="class highlight"><pre><span></span><code><span class="gp">&gt;&gt;&gt; </span><span class="kn">import</span><span class="w"> </span><span class="nn">test</span>
1752+
</code></pre></div>
1753+
''', # noqa: E501
1754+
True
1755+
)
1756+
1757+
def test_bad_header(self):
1758+
"""Test bad header."""
1759+
1760+
self.check_markdown(
1761+
r'''
1762+
```pycon [nonsense]
1763+
>>> import test
1764+
```
1765+
''',
1766+
r'''
1767+
<div class="highlight"><pre><span></span><code><span class="gp">&gt;&gt;&gt; </span><span class="kn">import</span><span class="w"> </span><span class="nn">test</span>
1768+
</code></pre></div>
1769+
''', # noqa: E501
1770+
True
1771+
)
1772+
1773+
def test_nonsense(self):
1774+
"""Test only nonsense."""
1775+
1776+
self.check_markdown(
1777+
r'''
1778+
```[nonsense]
1779+
>>> import test
1780+
```
1781+
''',
1782+
r'''
1783+
<div class="highlight"><pre><span></span><code>&gt;&gt;&gt; import test
1784+
</code></pre></div>
1785+
''',
1786+
True
1787+
)

0 commit comments

Comments
 (0)