1from typing import List, Optional, Sequence
2
3from markdown_it import MarkdownIt
4from markdown_it.rules_block import StateBlock
5from markdown_it.rules_core import StateCore
6from markdown_it.rules_inline import StateInline
7from markdown_it.token import Token
8
9from mdit_py_plugins.utils import is_code_block
10
11from .parse import ParseError, parse
12
13
14def attrs_plugin(
15 md: MarkdownIt,
16 *,
17 after: Sequence[str] = ("image", "code_inline", "link_close", "span_close"),
18 spans: bool = False,
19 span_after: str = "link",
20) -> None:
21 """Parse inline attributes that immediately follow certain inline elements::
22
23 ![alt](https://image.com){#id .a b=c}
24
25 This syntax is inspired by
26 `Djot spans
27 <https://htmlpreview.github.io/?https://github.com/jgm/djot/blob/master/doc/syntax.html#inline-attributes>`_.
28
29 Inside the curly braces, the following syntax is possible:
30
31 - `.foo` specifies foo as a class.
32 Multiple classes may be given in this way; they will be combined.
33 - `#foo` specifies foo as an identifier.
34 An element may have only one identifier;
35 if multiple identifiers are given, the last one is used.
36 - `key="value"` or `key=value` specifies a key-value attribute.
37 Quotes are not needed when the value consists entirely of
38 ASCII alphanumeric characters or `_` or `:` or `-`.
39 Backslash escapes may be used inside quoted values.
40 - `%` begins a comment, which ends with the next `%` or the end of the attribute (`}`).
41
42 Multiple attribute blocks are merged.
43
44 :param md: The MarkdownIt instance to modify.
45 :param after: The names of inline elements after which attributes may be specified.
46 This plugin does not support attributes after emphasis, strikethrough or text elements,
47 which all require post-parse processing.
48 :param spans: If True, also parse attributes after spans of text, encapsulated by `[]`.
49 Note Markdown link references take precedence over this syntax.
50 :param span_after: The name of an inline rule after which spans may be specified.
51 """
52
53 def _attr_inline_rule(state: StateInline, silent: bool) -> bool:
54 if state.pending or not state.tokens:
55 return False
56 token = state.tokens[-1]
57 if token.type not in after:
58 return False
59 try:
60 new_pos, attrs = parse(state.src[state.pos :])
61 except ParseError:
62 return False
63 token_index = _find_opening(state.tokens, len(state.tokens) - 1)
64 if token_index is None:
65 return False
66 state.pos += new_pos + 1
67 if not silent:
68 attr_token = state.tokens[token_index]
69 if "class" in attrs and "class" in token.attrs:
70 attrs["class"] = f"{attr_token.attrs['class']} {attrs['class']}"
71 attr_token.attrs.update(attrs)
72 return True
73
74 if spans:
75 md.inline.ruler.after(span_after, "span", _span_rule)
76 if after:
77 md.inline.ruler.push("attr", _attr_inline_rule)
78
79
80def attrs_block_plugin(md: MarkdownIt) -> None:
81 """Parse block attributes.
82
83 Block attributes are attributes on a single line, with no other content.
84 They attach the specified attributes to the block below them::
85
86 {.a #b c=1}
87 A paragraph, that will be assigned the class ``a`` and the identifier ``b``.
88
89 Attributes can be stacked, with classes accumulating and lower attributes overriding higher::
90
91 {#a .a c=1}
92 {#b .b c=2}
93 A paragraph, that will be assigned the class ``a b c``, and the identifier ``b``.
94
95 This syntax is inspired by Djot block attributes.
96 """
97 md.block.ruler.before("fence", "attr", _attr_block_rule)
98 md.core.ruler.after("block", "attr", _attr_resolve_block_rule)
99
100
101def _find_opening(tokens: List[Token], index: int) -> Optional[int]:
102 """Find the opening token index, if the token is closing."""
103 if tokens[index].nesting != -1:
104 return index
105 level = 0
106 while index >= 0:
107 level += tokens[index].nesting
108 if level == 0:
109 return index
110 index -= 1
111 return None
112
113
114def _span_rule(state: StateInline, silent: bool) -> bool:
115 if state.src[state.pos] != "[":
116 return False
117
118 maximum = state.posMax
119 labelStart = state.pos + 1
120 labelEnd = state.md.helpers.parseLinkLabel(state, state.pos, False)
121
122 # parser failed to find ']', so it's not a valid span
123 if labelEnd < 0:
124 return False
125
126 pos = labelEnd + 1
127
128 # check not at end of inline
129 if pos >= maximum:
130 return False
131
132 try:
133 new_pos, attrs = parse(state.src[pos:])
134 except ParseError:
135 return False
136
137 pos += new_pos + 1
138
139 if not silent:
140 state.pos = labelStart
141 state.posMax = labelEnd
142 token = state.push("span_open", "span", 1)
143 token.attrs = attrs # type: ignore[assignment]
144 state.md.inline.tokenize(state)
145 token = state.push("span_close", "span", -1)
146
147 state.pos = pos
148 state.posMax = maximum
149 return True
150
151
152def _attr_block_rule(
153 state: StateBlock, startLine: int, endLine: int, silent: bool
154) -> bool:
155 """Find a block of attributes.
156
157 The block must be a single line that begins with a `{`, after three or less spaces,
158 and end with a `}` followed by any number if spaces.
159 """
160 if is_code_block(state, startLine):
161 return False
162
163 pos = state.bMarks[startLine] + state.tShift[startLine]
164 maximum = state.eMarks[startLine]
165
166 # if it doesn't start with a {, it's not an attribute block
167 if state.src[pos] != "{":
168 return False
169
170 # find first non-space character from the right
171 while maximum > pos and state.src[maximum - 1] in (" ", "\t"):
172 maximum -= 1
173 # if it doesn't end with a }, it's not an attribute block
174 if maximum <= pos:
175 return False
176 if state.src[maximum - 1] != "}":
177 return False
178
179 try:
180 new_pos, attrs = parse(state.src[pos:maximum])
181 except ParseError:
182 return False
183
184 # if the block was resolved earlier than expected, it's not an attribute block
185 # TODO this was not working in some instances, so I disabled it
186 # if (maximum - 1) != new_pos:
187 # return False
188
189 if silent:
190 return True
191
192 token = state.push("attrs_block", "", 0)
193 token.attrs = attrs # type: ignore[assignment]
194 token.map = [startLine, startLine + 1]
195
196 state.line = startLine + 1
197 return True
198
199
200def _attr_resolve_block_rule(state: StateCore) -> None:
201 """Find attribute block then move its attributes to the next block."""
202 i = 0
203 len_tokens = len(state.tokens)
204 while i < len_tokens:
205 if state.tokens[i].type != "attrs_block":
206 i += 1
207 continue
208
209 if i + 1 < len_tokens:
210 next_token = state.tokens[i + 1]
211
212 # classes are appended
213 if "class" in state.tokens[i].attrs and "class" in next_token.attrs:
214 state.tokens[i].attrs["class"] = (
215 f"{state.tokens[i].attrs['class']} {next_token.attrs['class']}"
216 )
217
218 if next_token.type == "attrs_block":
219 # subsequent attribute blocks take precedence, when merging
220 for key, value in state.tokens[i].attrs.items():
221 if key == "class" or key not in next_token.attrs:
222 next_token.attrs[key] = value
223 else:
224 # attribute block takes precedence over attributes in other blocks
225 next_token.attrs.update(state.tokens[i].attrs)
226
227 state.tokens.pop(i)
228 len_tokens -= 1