Skip to content

Commit 96cf077

Browse files
authored
✨ Add make_fence_rule() factory for configurable fence markers (#394)
Adds a `make_fence_rule()` factory function that generates fence parsing rules with configurable options, enabling reuse of the fence logic for custom marker characters (e.g. `:` for colon fences) without code duplication. ## Motivation The `colon_fence` plugin in `mdit-py-plugins` duplicates nearly all of the fence parsing logic, differing only in the marker character (`:` vs `` ` ``/`~`), token type, and info-string restrictions. This makes maintenance harder and prevents features like exact-match closing from being shared. ## Changes - **New `make_fence_rule()` factory** in fence.py with parameters: - `markers`: tuple of valid fence marker characters (default `("~", "\`")`) - `token_type`: token type name to emit (default `"fence"`) - `exact_match`: when `True`, closing fence must have **exactly** the same marker count as the opener (default `False`). This enables nested fences (e.g. `::::` wrapping `:::`). - `disallow_marker_in_info`: marker characters that reject the fence if found in the info string (default `("\`",)` per CommonMark) - `min_markers`: minimum marker count to form a fence (default `3`) - **`fence` remains a module-level export**: `fence = make_fence_rule()` — fully backwards compatible, identical behavior to the previous implementation. - **Exported from `markdown_it.rules_block`** — added `make_fence_rule` to `__all__`. ## Usage ```python from markdown_it import MarkdownIt from markdown_it.rules_block.fence import make_fence_rule # Colon fence (replaces mdit-py-plugins/colon_fence duplicated logic) md = MarkdownIt() md.block.ruler.before("fence", "colon_fence", make_fence_rule(markers=(":",), token_type="colon_fence", disallow_marker_in_info=())) # Override standard fence with exact-match closing (for MyST nested directives) md.block.ruler.at("fence", make_fence_rule(exact_match=True)) # Combine: all markers with exact matching md.block.ruler.at("fence", make_fence_rule( markers=("~", "`", ":"), exact_match=True)) ``` ## Performance No regression by design — configuration is captured in the closure at rule-creation time (no runtime dict lookups or extra branches in the hot path). The `closing_matcher` callable is resolved once at factory time. ## Tests 21 new tests covering: - Colon-fence-like marker behavior - Exact-match closing (nesting patterns) - `ruler.at()` override of standard fence - `min_markers` customization - `disallow_marker_in_info` variations Full existing test suite (981 tests) passes unchanged.
1 parent 3b4ff6d commit 96cf077

3 files changed

Lines changed: 374 additions & 67 deletions

File tree

markdown_it/rules_block/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@
88
"html_block",
99
"lheading",
1010
"list_block",
11+
"make_fence_rule",
1112
"paragraph",
1213
"reference",
1314
"table",
1415
)
1516

1617
from .blockquote import blockquote
1718
from .code import code
18-
from .fence import fence
19+
from .fence import fence, make_fence_rule
1920
from .heading import heading
2021
from .hr import hr
2122
from .html_block import html_block

markdown_it/rules_block/fence.py

Lines changed: 111 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,101 +1,146 @@
11
# fences (``` lang, ~~~ lang)
2+
from __future__ import annotations
3+
4+
from collections.abc import Callable
25
import logging
36

47
from .state_block import StateBlock
58

69
LOGGER = logging.getLogger(__name__)
710

811

9-
def fence(state: StateBlock, startLine: int, endLine: int, silent: bool) -> bool:
10-
LOGGER.debug("entering fence: %s, %s, %s, %s", state, startLine, endLine, silent)
12+
def make_fence_rule(
13+
*,
14+
markers: tuple[str, ...] = ("~", "`"),
15+
token_type: str = "fence",
16+
exact_match: bool = False,
17+
disallow_marker_in_info: tuple[str, ...] = ("`",),
18+
min_markers: int = 3,
19+
) -> Callable[[StateBlock, int, int, bool], bool]:
20+
"""Create a fence parsing rule with configurable options.
21+
22+
:param markers: Tuple of single characters that can be used as fence markers.
23+
:param token_type: The token type name to emit (e.g. "fence", "colon_fence").
24+
:param exact_match: If True, the closing fence must have exactly the same
25+
number of marker characters as the opening fence (not "at least as many").
26+
This enables nesting of fences with different marker counts.
27+
:param disallow_marker_in_info: Tuple of marker characters that are not allowed
28+
to appear in the info string. The check only applies when the actual opening
29+
marker is in this tuple (e.g. a tilde fence is unaffected by ``"`"`` being
30+
listed). Per CommonMark, backtick fences cannot have backticks in the info
31+
string. Use ``()`` to disable this restriction.
32+
:param min_markers: Minimum number of marker characters to form a fence.
33+
:return: A block rule function with signature
34+
``(state, startLine, endLine, silent) -> bool``.
35+
"""
36+
37+
closing_matcher: Callable[[int, int], bool]
38+
if exact_match:
39+
# closing code fence must have exactly the same number of markers as the opening one
40+
closing_matcher = lambda opening_len, closing_len: closing_len == opening_len # noqa: E731
41+
else:
42+
# closing code fence must be at least as long as the opening one
43+
closing_matcher = lambda opening_len, closing_len: closing_len >= opening_len # noqa: E731
1144

12-
haveEndMarker = False
13-
pos = state.bMarks[startLine] + state.tShift[startLine]
14-
maximum = state.eMarks[startLine]
45+
def _fence_rule(
46+
state: StateBlock, startLine: int, endLine: int, silent: bool
47+
) -> bool:
48+
LOGGER.debug(
49+
"entering fence: %s, %s, %s, %s", state, startLine, endLine, silent
50+
)
1551

16-
if state.is_code_block(startLine):
17-
return False
52+
haveEndMarker = False
53+
pos = state.bMarks[startLine] + state.tShift[startLine]
54+
maximum = state.eMarks[startLine]
1855

19-
if pos + 3 > maximum:
20-
return False
56+
if state.is_code_block(startLine):
57+
return False
2158

22-
marker = state.src[pos]
59+
if pos + min_markers > maximum:
60+
return False
2361

24-
if marker not in ("~", "`"):
25-
return False
62+
marker = state.src[pos]
2663

27-
# scan marker length
28-
mem = pos
29-
pos = state.skipCharsStr(pos, marker)
64+
if marker not in markers:
65+
return False
3066

31-
length = pos - mem
67+
# scan marker length
68+
mem = pos
69+
pos = state.skipCharsStr(pos, marker)
3270

33-
if length < 3:
34-
return False
71+
length = pos - mem
3572

36-
markup = state.src[mem:pos]
37-
params = state.src[pos:maximum]
73+
if length < min_markers:
74+
return False
3875

39-
if marker == "`" and marker in params:
40-
return False
76+
markup = state.src[mem:pos]
77+
params = state.src[pos:maximum]
4178

42-
# Since start is found, we can report success here in validation mode
43-
if silent:
44-
return True
79+
if marker in disallow_marker_in_info and marker in params:
80+
return False
4581

46-
# search end of block
47-
nextLine = startLine
82+
# Since start is found, we can report success here in validation mode
83+
if silent:
84+
return True
4885

49-
while True:
50-
nextLine += 1
51-
if nextLine >= endLine:
52-
# unclosed block should be autoclosed by end of document.
53-
# also block seems to be autoclosed by end of parent
54-
break
86+
# search end of block
87+
nextLine = startLine
5588

56-
pos = mem = state.bMarks[nextLine] + state.tShift[nextLine]
57-
maximum = state.eMarks[nextLine]
89+
while True:
90+
nextLine += 1
91+
if nextLine >= endLine:
92+
# unclosed block should be autoclosed by end of document.
93+
# also block seems to be autoclosed by end of parent
94+
break
5895

59-
if pos < maximum and state.sCount[nextLine] < state.blkIndent:
60-
# non-empty line with negative indent should stop the list:
61-
# - ```
62-
# test
63-
break
96+
pos = mem = state.bMarks[nextLine] + state.tShift[nextLine]
97+
maximum = state.eMarks[nextLine]
98+
99+
if pos < maximum and state.sCount[nextLine] < state.blkIndent:
100+
# non-empty line with negative indent should stop the list:
101+
# - ```
102+
# test
103+
break
64104

65-
try:
66-
if state.src[pos] != marker:
105+
try:
106+
if state.src[pos] != marker:
107+
continue
108+
except IndexError:
109+
break
110+
111+
if state.is_code_block(nextLine):
67112
continue
68-
except IndexError:
69-
break
70113

71-
if state.is_code_block(nextLine):
72-
continue
114+
pos = state.skipCharsStr(pos, marker)
73115

74-
pos = state.skipCharsStr(pos, marker)
116+
if not closing_matcher(length, pos - mem):
117+
continue
75118

76-
# closing code fence must be at least as long as the opening one
77-
if pos - mem < length:
78-
continue
119+
# make sure tail has spaces only
120+
pos = state.skipSpaces(pos)
121+
122+
if pos < maximum:
123+
continue
124+
125+
haveEndMarker = True
126+
# found!
127+
break
79128

80-
# make sure tail has spaces only
81-
pos = state.skipSpaces(pos)
129+
# If a fence has heading spaces, they should be removed from its inner block
130+
length = state.sCount[startLine]
82131

83-
if pos < maximum:
84-
continue
132+
state.line = nextLine + (1 if haveEndMarker else 0)
85133

86-
haveEndMarker = True
87-
# found!
88-
break
134+
token = state.push(token_type, "code", 0)
135+
token.info = params
136+
token.content = state.getLines(startLine + 1, nextLine, length, True)
137+
token.markup = markup
138+
token.map = [startLine, state.line]
89139

90-
# If a fence has heading spaces, they should be removed from its inner block
91-
length = state.sCount[startLine]
140+
return True
92141

93-
state.line = nextLine + (1 if haveEndMarker else 0)
142+
return _fence_rule
94143

95-
token = state.push("fence", "code", 0)
96-
token.info = params
97-
token.content = state.getLines(startLine + 1, nextLine, length, True)
98-
token.markup = markup
99-
token.map = [startLine, state.line]
100144

101-
return True
145+
#: The default fence rule (backtick and tilde markers, CommonMark compliant).
146+
fence = make_fence_rule()

0 commit comments

Comments
 (0)