Skip to content

Commit 277229c

Browse files
authored
✨ NEW: Add span parsing to inline attributes plugin (#55)
Update `attrs_plugin` to support non-self-closing syntaxes (like spans and links), and add parsing for text enclosed in `[]` as spans, e.g. `[my text]{#a .b}`.
1 parent 7588de2 commit 277229c

File tree

5 files changed

+199
-13
lines changed

5 files changed

+199
-13
lines changed

codecov.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ coverage:
22
status:
33
project:
44
default:
5-
target: 93%
5+
target: 92%
66
threshold: 0.2%
77
patch:
88
default:

mdit_py_plugins/attrs/index.py

Lines changed: 76 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,26 @@
1+
from typing import List, Optional
2+
13
from markdown_it import MarkdownIt
24
from markdown_it.rules_inline import StateInline
5+
from markdown_it.token import Token
36

47
from .parse import ParseError, parse
58

69

7-
def attrs_plugin(md: MarkdownIt, *, after=("image", "code_inline")):
10+
def attrs_plugin(
11+
md: MarkdownIt,
12+
*,
13+
after=("image", "code_inline", "link_close", "span_close"),
14+
spans=True,
15+
):
816
"""Parse inline attributes that immediately follow certain inline elements::
917
1018
![alt](https://image.com){#id .a b=c}
1119
20+
This syntax is inspired by
21+
`Djot spans
22+
<https://htmlpreview.github.io/?https://github.com/jgm/djot/blob/master/doc/syntax.html#inline-attributes>`_.
23+
1224
Inside the curly braces, the following syntax is possible:
1325
1426
- `.foo` specifies foo as a class.
@@ -22,14 +34,18 @@ def attrs_plugin(md: MarkdownIt, *, after=("image", "code_inline")):
2234
Backslash escapes may be used inside quoted values.
2335
- `%` begins a comment, which ends with the next `%` or the end of the attribute (`}`).
2436
25-
**Note:** This plugin is currently limited to "self-closing" elements,
26-
such as images and code spans. It does not work with links or emphasis.
37+
Multiple attribute blocks are merged.
2738
2839
:param md: The MarkdownIt instance to modify.
2940
:param after: The names of inline elements after which attributes may be specified.
41+
This plugin does not support attributes after emphasis, strikethrough or text elements,
42+
which all require post-parse processing.
43+
:param spans: If True, also parse attributes after spans of text, encapsulated by `[]`.
44+
Note Markdown link references take precedence over this syntax.
45+
3046
"""
3147

32-
def attr_rule(state: StateInline, silent: bool):
48+
def _attr_rule(state: StateInline, silent: bool):
3349
if state.pending or not state.tokens:
3450
return False
3551
token = state.tokens[-1]
@@ -39,12 +55,64 @@ def attr_rule(state: StateInline, silent: bool):
3955
new_pos, attrs = parse(state.src[state.pos :])
4056
except ParseError:
4157
return False
58+
token_index = _find_opening(state.tokens, len(state.tokens) - 1)
59+
if token_index is None:
60+
return False
4261
state.pos += new_pos + 1
4362
if not silent:
63+
attr_token = state.tokens[token_index]
4464
if "class" in attrs and "class" in token.attrs:
45-
attrs["class"] = f"{token.attrs['class']} {attrs['class']}"
46-
token.attrs.update(attrs)
47-
65+
attrs["class"] = f"{attr_token.attrs['class']} {attrs['class']}"
66+
attr_token.attrs.update(attrs)
4867
return True
4968

50-
md.inline.ruler.push("attr", attr_rule)
69+
if spans:
70+
md.inline.ruler.after("link", "span", _span_rule)
71+
md.inline.ruler.push("attr", _attr_rule)
72+
73+
74+
def _find_opening(tokens: List[Token], index: int) -> Optional[int]:
75+
"""Find the opening token index, if the token is closing."""
76+
if tokens[index].nesting != -1:
77+
return index
78+
level = 0
79+
while index >= 0:
80+
level += tokens[index].nesting
81+
if level == 0:
82+
return index
83+
index -= 1
84+
return None
85+
86+
87+
def _span_rule(state: StateInline, silent: bool):
88+
if state.srcCharCode[state.pos] != 0x5B: # /* [ */
89+
return False
90+
91+
maximum = state.posMax
92+
labelStart = state.pos + 1
93+
labelEnd = state.md.helpers.parseLinkLabel(state, state.pos, False)
94+
95+
# parser failed to find ']', so it's not a valid span
96+
if labelEnd < 0:
97+
return False
98+
99+
pos = labelEnd + 1
100+
101+
try:
102+
new_pos, attrs = parse(state.src[pos:])
103+
except ParseError:
104+
return False
105+
106+
pos += new_pos + 1
107+
108+
if not silent:
109+
state.pos = labelStart
110+
state.posMax = labelEnd
111+
token = state.push("span_open", "span", 1)
112+
token.attrs = attrs
113+
state.md.inline.tokenize(state)
114+
token = state.push("span_close", "span", -1)
115+
116+
state.pos = pos
117+
state.posMax = maximum
118+
return True

tests/fixtures/attrs.md

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
simple reference link
2+
.
3+
[text *emphasis*](a){#id .a}
4+
.
5+
<p><a href="a" id="id" class="a">text <em>emphasis</em></a></p>
6+
.
7+
8+
simple definition link
9+
.
10+
[a][]{#id .b}
11+
12+
[a]: /url
13+
.
14+
<p><a href="/url" id="id" class="b">a</a></p>
15+
.
16+
117
simple image
218
.
319
![a](b){#id .a b=c}
@@ -38,9 +54,109 @@ more
3854
more</p>
3955
.
4056

41-
combined
57+
merging attributes
4258
.
4359
![a](b){#a .a}{.b class=x other=h}{#x class="x g" other=a}
4460
.
4561
<p><img src="b" alt="a" id="x" class="a b x x g" other="a"></p>
4662
.
63+
64+
spans: simple
65+
.
66+
[a]{#id .b}c
67+
.
68+
<p><span id="id" class="b">a</span>c</p>
69+
.
70+
71+
spans: space between brace and attrs
72+
.
73+
[a] {.b}
74+
.
75+
<p>[a] {.b}</p>
76+
.
77+
78+
spans: escaped span start
79+
.
80+
\[a]{.b}
81+
.
82+
<p>[a]{.b}</p>
83+
.
84+
85+
spans: escaped span end
86+
.
87+
[a\]{.b}
88+
.
89+
<p>[a]{.b}</p>
90+
.
91+
92+
spans: escaped span attribute
93+
.
94+
[a]\{.b}
95+
.
96+
<p>[a]{.b}</p>
97+
.
98+
99+
spans: nested text syntax
100+
.
101+
[*a*]{.b}c
102+
.
103+
<p><span class="b"><em>a</em></span>c</p>
104+
.
105+
106+
spans: nested span
107+
.
108+
*[a]{.b}c*
109+
.
110+
<p><em><span class="b">a</span>c</em></p>
111+
.
112+
113+
spans: multi-line
114+
.
115+
x [a
116+
b]{#id
117+
b=c} y
118+
.
119+
<p>x <span id="id" b="c">a
120+
b</span> y</p>
121+
.
122+
123+
spans: nested spans
124+
.
125+
[[a]{.b}]{.c}
126+
.
127+
<p><span class="c"><span class="b">a</span></span></p>
128+
.
129+
130+
spans: short link takes precedence over span
131+
.
132+
[a]{#id .b}
133+
134+
[a]: /url
135+
.
136+
<p><a href="/url" id="id" class="b">a</a></p>
137+
.
138+
139+
spans: long link takes precedence over span
140+
.
141+
[a][a]{#id .b}
142+
143+
[a]: /url
144+
.
145+
<p><a href="/url" id="id" class="b">a</a></p>
146+
.
147+
148+
spans: link inside span
149+
.
150+
[[a]]{#id .b}
151+
152+
[a]: /url
153+
.
154+
<p><span id="id" class="b"><a href="/url">a</a></span></p>
155+
.
156+
157+
spans: merge attributes
158+
.
159+
[a]{#a .a}{#b .a .b other=c}{other=d}
160+
.
161+
<p><span id="b" class="a b" other="d">a</span></p>
162+
.

tests/fixtures/span.md

Whitespace-only changes.

tests/test_attrs.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66

77
from mdit_py_plugins.attrs import attrs_plugin
88

9-
FIXTURE_PATH = Path(__file__).parent.joinpath("fixtures", "attrs.md")
9+
FIXTURE_PATH = Path(__file__).parent.joinpath("fixtures")
1010

1111

12-
@pytest.mark.parametrize("line,title,input,expected", read_fixture_file(FIXTURE_PATH))
13-
def test_fixture(line, title, input, expected):
12+
@pytest.mark.parametrize(
13+
"line,title,input,expected", read_fixture_file(FIXTURE_PATH / "attrs.md")
14+
)
15+
def test_attrs(line, title, input, expected):
1416
md = MarkdownIt("commonmark").use(attrs_plugin)
1517
md.options["xhtmlOut"] = False
1618
text = md.render(input)

0 commit comments

Comments
 (0)