Skip to content

Commit 3071bac

Browse files
committed
Provide option for more relaxed fenced code headers
Resolves #2544
1 parent 677a863 commit 3071bac

File tree

3 files changed

+195
-6
lines changed

3 files changed

+195
-6
lines changed

docs/src/markdown/extensions/superfences.md

+49
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_header` [option][#options] is a feature that can be enabled which will cause SuperFences to treat fenced
620+
code headers in a more releaxed 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.

pymdownx/superfences.py

+55-6
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
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)
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

+91
Original file line numberDiff line numberDiff line change
@@ -1634,3 +1634,94 @@ def test_broken_brace(self):
16341634
''',
16351635
True
16361636
)
1637+
1638+
1639+
class TestHighlightRelaxedHeaders(util.MdCase):
1640+
"""Test releaxed header cases."""
1641+
1642+
extension = ['pymdownx.highlight', 'pymdownx.superfences', 'attr_list']
1643+
extension_configs = {
1644+
'pymdownx.superfences': {
1645+
'relaxed_headers': True
1646+
}
1647+
}
1648+
1649+
def test_bad_option(self):
1650+
"""Test bad options."""
1651+
1652+
self.check_markdown(
1653+
r'''
1654+
```pycon bad="My title"
1655+
>>> import test
1656+
```
1657+
''',
1658+
r'''
1659+
<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>
1660+
</code></pre></div>
1661+
''', # noqa: E501
1662+
True
1663+
)
1664+
1665+
def test_bad_attribute(self):
1666+
"""Test bad attribute."""
1667+
1668+
self.check_markdown(
1669+
r'''
1670+
```pycon {.class <nonsense>!!!}
1671+
>>> import test
1672+
```
1673+
''',
1674+
r'''
1675+
<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>
1676+
</code></pre></div>
1677+
''', # noqa: E501
1678+
True
1679+
)
1680+
1681+
def test_bad_attribute_full(self):
1682+
"""Test bad attribute full."""
1683+
1684+
self.check_markdown(
1685+
r'''
1686+
```{.pycon .class <nonsense>!!!}
1687+
>>> import test
1688+
```
1689+
''',
1690+
r'''
1691+
<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>
1692+
</code></pre></div>
1693+
''', # noqa: E501
1694+
True
1695+
)
1696+
1697+
def test_bad_header(self):
1698+
"""Test bad header."""
1699+
1700+
self.check_markdown(
1701+
r'''
1702+
```pycon [nonsense]
1703+
>>> import test
1704+
```
1705+
''',
1706+
r'''
1707+
<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>
1708+
</code></pre></div>
1709+
''', # noqa: E501
1710+
True
1711+
)
1712+
1713+
def test_nonsense(self):
1714+
"""Test only nonsense."""
1715+
1716+
self.check_markdown(
1717+
r'''
1718+
```[nonsense]
1719+
>>> import test
1720+
```
1721+
''',
1722+
r'''
1723+
<div class="highlight"><pre><span></span><code>&gt;&gt;&gt; import test
1724+
</code></pre></div>
1725+
''',
1726+
True
1727+
)

0 commit comments

Comments
 (0)