1# Attribute List Extension for Python-Markdown
2# ============================================
3
4# Adds attribute list syntax. Inspired by
5# [Maruku](http://maruku.rubyforge.org/proposal.html#attribute_lists)'s
6# feature of the same name.
7
8# See https://Python-Markdown.github.io/extensions/attr_list
9# for documentation.
10
11# Original code Copyright 2011 [Waylan Limberg](https://github.com/waylan).
12
13# All changes Copyright 2011-2014 The Python Markdown Project
14
15# License: [BSD](https://opensource.org/licenses/bsd-license.php)
16
17"""
18Adds attribute list syntax to Python-Markdown.
19Inspired by
20[Maruku](http://maruku.rubyforge.org/proposal.html#attribute_lists)'s
21feature of the same name.
22
23See the [documentation](https://Python-Markdown.github.io/extensions/attr_list)
24for details.
25"""
26
27from __future__ import annotations
28from typing import TYPE_CHECKING
29
30from . import Extension
31from ..treeprocessors import Treeprocessor
32import re
33
34if TYPE_CHECKING: # pragma: no cover
35 from xml.etree.ElementTree import Element
36
37
38def _handle_double_quote(s, t):
39 k, v = t.split('=', 1)
40 return k, v.strip('"')
41
42
43def _handle_single_quote(s, t):
44 k, v = t.split('=', 1)
45 return k, v.strip("'")
46
47
48def _handle_key_value(s, t):
49 return t.split('=', 1)
50
51
52def _handle_word(s, t):
53 if t.startswith('.'):
54 return '.', t[1:]
55 if t.startswith('#'):
56 return 'id', t[1:]
57 return t, t
58
59
60_scanner = re.Scanner([
61 (r'[^ =}]+=".*?"', _handle_double_quote),
62 (r"[^ =}]+='.*?'", _handle_single_quote),
63 (r'[^ =}]+=[^ =}]+', _handle_key_value),
64 (r'[^ =}]+', _handle_word),
65 (r' ', None)
66])
67
68
69def get_attrs_and_remainder(attrs_string: str) -> tuple[list[tuple[str, str]], str]:
70 """ Parse attribute list and return a list of attribute tuples.
71
72 Additionally, return any text that remained after a curly brace. In typical cases, its presence
73 should mean that the input does not match the intended attribute list syntax.
74 """
75 attrs, remainder = _scanner.scan(attrs_string)
76 # To keep historic behavior, discard all unparsable text prior to '}'.
77 index = remainder.find('}')
78 remainder = remainder[index:] if index != -1 else ''
79 return attrs, remainder
80
81
82def get_attrs(str: str) -> list[tuple[str, str]]: # pragma: no cover
83 """ Soft-deprecated. Prefer `get_attrs_and_remainder`. """
84 return get_attrs_and_remainder(str)[0]
85
86
87def isheader(elem: Element) -> bool:
88 return elem.tag in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']
89
90
91NAME_RE = re.compile(r'[^A-Z_a-z\u00c0-\u00d6\u00d8-\u00f6\u00f8-\u02ff'
92 r'\u0370-\u037d\u037f-\u1fff\u200c-\u200d'
93 r'\u2070-\u218f\u2c00-\u2fef\u3001-\ud7ff'
94 r'\uf900-\ufdcf\ufdf0-\ufffd'
95 r'\:\-\.0-9\u00b7\u0300-\u036f\u203f-\u2040]+')
96
97
98def sanitize_name(name: str) -> str:
99 """
100 Sanitize name as 'an XML Name, minus the `:`.'
101 See <https://www.w3.org/TR/REC-xml-names/#NT-NCName>.
102 """
103 return NAME_RE.sub('_', name)
104
105
106class AttrListTreeprocessor(Treeprocessor):
107
108 BASE_RE = r'\{\:?[ ]*([^\}\n ][^\n]*)[ ]*\}'
109 HEADER_RE = re.compile(r'[ ]+{}[ ]*$'.format(BASE_RE))
110 BLOCK_RE = re.compile(r'\n[ ]*{}[ ]*$'.format(BASE_RE))
111 INLINE_RE = re.compile(r'^{}'.format(BASE_RE))
112 NAME_RE = NAME_RE
113
114 def run(self, doc: Element) -> None:
115 for elem in doc.iter():
116 if self.md.is_block_level(elem.tag):
117 # Block level: check for `attrs` on last line of text
118 RE = self.BLOCK_RE
119 if isheader(elem) or elem.tag in ['dt', 'td', 'th']:
120 # header, def-term, or table cell: check for attributes at end of element
121 RE = self.HEADER_RE
122 if len(elem) and elem.tag == 'li':
123 # special case list items. children may include a `ul` or `ol`.
124 pos = None
125 # find the `ul` or `ol` position
126 for i, child in enumerate(elem):
127 if child.tag in ['ul', 'ol']:
128 pos = i
129 break
130 if pos is None and elem[-1].tail:
131 # use tail of last child. no `ul` or `ol`.
132 m = RE.search(elem[-1].tail)
133 if m:
134 if not self.assign_attrs(elem, m.group(1), strict=True):
135 elem[-1].tail = elem[-1].tail[:m.start()]
136 elif pos is not None and pos > 0 and elem[pos-1].tail:
137 # use tail of last child before `ul` or `ol`
138 m = RE.search(elem[pos-1].tail)
139 if m:
140 if not self.assign_attrs(elem, m.group(1), strict=True):
141 elem[pos-1].tail = elem[pos-1].tail[:m.start()]
142 elif elem.text:
143 # use text. `ul` is first child.
144 m = RE.search(elem.text)
145 if m:
146 if not self.assign_attrs(elem, m.group(1), strict=True):
147 elem.text = elem.text[:m.start()]
148 elif len(elem) and elem[-1].tail:
149 # has children. Get from tail of last child
150 m = RE.search(elem[-1].tail)
151 if m:
152 if not self.assign_attrs(elem, m.group(1), strict=True):
153 elem[-1].tail = elem[-1].tail[:m.start()]
154 if isheader(elem):
155 # clean up trailing #s
156 elem[-1].tail = elem[-1].tail.rstrip('#').rstrip()
157 elif elem.text:
158 # no children. Get from text.
159 m = RE.search(elem.text)
160 if m:
161 if not self.assign_attrs(elem, m.group(1), strict=True):
162 elem.text = elem.text[:m.start()]
163 if isheader(elem):
164 # clean up trailing #s
165 elem.text = elem.text.rstrip('#').rstrip()
166 else:
167 # inline: check for `attrs` at start of tail
168 if elem.tail:
169 m = self.INLINE_RE.match(elem.tail)
170 if m:
171 remainder = self.assign_attrs(elem, m.group(1))
172 elem.tail = elem.tail[m.end():] + remainder
173
174 def assign_attrs(self, elem: Element, attrs_string: str, *, strict: bool = False) -> str:
175 """ Assign `attrs` to element.
176
177 If the `attrs_string` has an extra closing curly brace, the remaining text is returned.
178
179 The `strict` argument controls whether to still assign `attrs` if there is a remaining `}`.
180 """
181 attrs, remainder = get_attrs_and_remainder(attrs_string)
182 if strict and remainder:
183 return remainder
184
185 for k, v in attrs:
186 if k == '.':
187 # add to class
188 cls = elem.get('class')
189 if cls:
190 elem.set('class', '{} {}'.format(cls, v))
191 else:
192 elem.set('class', v)
193 else:
194 # assign attribute `k` with `v`
195 elem.set(self.sanitize_name(k), v)
196 # The text that we initially over-matched will be put back.
197 return remainder
198
199 def sanitize_name(self, name: str) -> str:
200 """
201 Sanitize name as 'an XML Name, minus the `:`.'
202 See <https://www.w3.org/TR/REC-xml-names/#NT-NCName>.
203 """
204 return sanitize_name(name)
205
206
207class AttrListExtension(Extension):
208 """ Attribute List extension for Python-Markdown """
209 def extendMarkdown(self, md):
210 """
211 Register the processor.
212
213 | Class Instance | Registry | Name | Priority |
214 | --------------------------------------------------------------------------- | ---------------------------------------------------------------- | ------ | :------: |
215 | [`AttrListTreeprocessor`][markdown.extensions.attr_list.AttrListTreeprocessor] | [`treeprocessors`][markdown.treeprocessors.build_treeprocessors] | `attr_list` | `8` |
216
217 """
218 # flake8: noqa: E501 202-204
219 md.treeprocessors.register(AttrListTreeprocessor(md), 'attr_list', 8)
220 md.registerExtension(self)
221
222
223def makeExtension(**kwargs): # pragma: no cover
224 return AttrListExtension(**kwargs)