mdit-py-plugins 0.6.0__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/PKG-INFO +4 -4
  2. mdit_py_plugins-0.7.0/mdit_py_plugins/__init__.py +1 -0
  3. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/admon/index.py +7 -2
  4. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/anchors/index.py +17 -4
  5. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/attrs/index.py +95 -15
  6. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/attrs/parse.py +16 -2
  7. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/colon_fence.py +1 -1
  8. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/container/index.py +2 -2
  9. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/dollarmath/index.py +54 -11
  10. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/field_list/__init__.py +3 -2
  11. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/footnote/index.py +2 -2
  12. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/front_matter/index.py +10 -3
  13. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/gfm_autolink/index.py +6 -3
  14. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/myst_blocks/index.py +1 -1
  15. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/myst_role/index.py +3 -8
  16. mdit_py_plugins-0.7.0/mdit_py_plugins/section_ref/__init__.py +12 -0
  17. mdit_py_plugins-0.7.0/mdit_py_plugins/section_ref/index.py +137 -0
  18. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/tasklists/__init__.py +3 -1
  19. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/texmath/index.py +53 -33
  20. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/pyproject.toml +5 -3
  21. mdit_py_plugins-0.6.0/.coveragerc +0 -4
  22. mdit_py_plugins-0.6.0/.github/dependabot.yml +0 -19
  23. mdit_py_plugins-0.6.0/.github/workflows/copilot-setup-steps.yml +0 -43
  24. mdit_py_plugins-0.6.0/.github/workflows/tests.yml +0 -87
  25. mdit_py_plugins-0.6.0/.gitignore +0 -143
  26. mdit_py_plugins-0.6.0/.pre-commit-config.yaml +0 -34
  27. mdit_py_plugins-0.6.0/.readthedocs.yml +0 -17
  28. mdit_py_plugins-0.6.0/AGENTS.md +0 -373
  29. mdit_py_plugins-0.6.0/CHANGELOG.md +0 -155
  30. mdit_py_plugins-0.6.0/codecov.yml +0 -10
  31. mdit_py_plugins-0.6.0/mdit_py_plugins/__init__.py +0 -1
  32. mdit_py_plugins-0.6.0/tox.ini +0 -30
  33. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/LICENSE +0 -0
  34. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/README.md +0 -0
  35. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/admon/LICENSE +0 -0
  36. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/admon/__init__.py +0 -0
  37. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/admon/port.yaml +0 -0
  38. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/amsmath/__init__.py +0 -0
  39. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/anchors/__init__.py +0 -0
  40. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/attrs/__init__.py +0 -0
  41. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/container/LICENSE +0 -0
  42. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/container/README.md +0 -0
  43. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/container/__init__.py +0 -0
  44. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/container/port.yaml +0 -0
  45. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/deflist/LICENSE +0 -0
  46. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/deflist/README.md +0 -0
  47. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/deflist/__init__.py +0 -0
  48. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/deflist/index.py +0 -0
  49. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/deflist/port.yaml +0 -0
  50. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/dollarmath/__init__.py +0 -0
  51. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/footnote/LICENSE +0 -0
  52. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/footnote/__init__.py +0 -0
  53. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/footnote/port.yaml +0 -0
  54. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/front_matter/LICENSE +0 -0
  55. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/front_matter/__init__.py +0 -0
  56. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/front_matter/port.yaml +0 -0
  57. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/gfm/__init__.py +0 -0
  58. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/gfm_autolink/__init__.py +0 -0
  59. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/gfm_autolink/_match.py +0 -0
  60. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/myst_blocks/__init__.py +0 -0
  61. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/myst_role/__init__.py +0 -0
  62. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/py.typed +0 -0
  63. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/subscript/__init__.py +0 -0
  64. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/subscript/port.yaml +0 -0
  65. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/substitution.py +0 -0
  66. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/superscript/__init__.py +0 -0
  67. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/superscript/index.py +0 -0
  68. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/tasklists/port.yaml +0 -0
  69. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/texmath/LICENSE +0 -0
  70. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/texmath/README.md +0 -0
  71. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/texmath/__init__.py +0 -0
  72. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/texmath/port.yaml +0 -0
  73. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/utils.py +0 -0
  74. {mdit_py_plugins-0.6.0 → mdit_py_plugins-0.7.0}/mdit_py_plugins/wordcount/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mdit-py-plugins
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Collection of plugins for markdown-it-py
5
5
  Keywords: markdown,markdown-it,lexer,parser,development
6
6
  Author-email: Chris Sewell <chrisj_sewell@hotmail.com>
@@ -10,17 +10,17 @@ Classifier: Development Status :: 5 - Production/Stable
10
10
  Classifier: Intended Audience :: Developers
11
11
  Classifier: License :: OSI Approved :: MIT License
12
12
  Classifier: Programming Language :: Python :: 3
13
- Classifier: Programming Language :: Python :: 3.8
14
- Classifier: Programming Language :: Python :: 3.9
15
13
  Classifier: Programming Language :: Python :: 3.10
16
14
  Classifier: Programming Language :: Python :: 3.11
17
15
  Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
18
  Classifier: Programming Language :: Python :: Implementation :: CPython
19
19
  Classifier: Programming Language :: Python :: Implementation :: PyPy
20
20
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
21
  Classifier: Topic :: Text Processing :: Markup
22
22
  License-File: LICENSE
23
- Requires-Dist: markdown-it-py>=2.0.0,<5.0.0
23
+ Requires-Dist: markdown-it-py>=3.0.0,<5.0.0
24
24
  Requires-Dist: pre-commit ; extra == "code-style"
25
25
  Requires-Dist: myst-parser ; extra == "rtd"
26
26
  Requires-Dist: sphinx-book-theme ; extra == "rtd"
@@ -0,0 +1 @@
1
+ __version__ = "0.7.0"
@@ -20,7 +20,12 @@ if TYPE_CHECKING:
20
20
 
21
21
  def _get_multiple_tags(params: str) -> tuple[list[str], str]:
22
22
  """Check for multiple tags when the title is double quoted."""
23
- re_tags = re.compile(r'^\s*(?P<tokens>[^"]+)\s+"(?P<title>.*)"\S*$')
23
+ # This matches what r'^\s*(?P<tokens>[^"]+)\s+"(?P<title>.*)"\S*$' matches,
24
+ # but the tokens end on a non-space and only the last quote can close the title,
25
+ # so a long run of spaces or quotes cannot make it backtrack quadratically (#143).
26
+ re_tags = re.compile(
27
+ r'^\s*(?P<tokens>[^"\s](?:[^"]*[^"\s])?)\s+"(?P<title>.*)"[^\s"]*$'
28
+ )
24
29
  match = re_tags.match(params)
25
30
  if match:
26
31
  tags = match["tokens"].strip().split(" ")
@@ -183,7 +188,7 @@ def admonition(state: StateBlock, startLine: int, endLine: int, silent: bool) ->
183
188
  return True
184
189
 
185
190
 
186
- def admon_plugin(md: MarkdownIt, render: None | Callable[..., str] = None) -> None:
191
+ def admon_plugin(md: MarkdownIt, render: Callable[..., str] | None = None) -> None:
187
192
  """Plugin to use
188
193
  `python-markdown style admonitions
189
194
  <https://python-markdown.github.io/extensions/admonition>`_.
@@ -29,6 +29,10 @@ def anchors_plugin(
29
29
 
30
30
  <h1 id="title-string">Title String <a class="header-anchor" href="#title-string">¶</a></h1>
31
31
 
32
+ Explicit heading IDs (e.g. ``{#id}`` from ``attrs_block_plugin``) are preserved,
33
+ at any level, and generated IDs avoid collisions with them.
34
+ Explicit IDs are not de-duplicated, and an empty explicit ID counts as no ID.
35
+
32
36
  :param min_level: minimum header level to apply anchors
33
37
  :param max_level: maximum header level to apply anchors
34
38
  :param slug_func: function to convert title text to id slug.
@@ -66,7 +70,14 @@ def _make_anchors_func(
66
70
  permalinkSpace: bool,
67
71
  ) -> Callable[[StateCore], None]:
68
72
  def _anchor_func(state: StateCore) -> None:
69
- slugs: set[str] = set()
73
+ # reserve every explicit heading id (at any level) up front,
74
+ # so a generated slug never collides with one, whatever the order;
75
+ # an empty id is treated as absent
76
+ slugs: set[str] = {
77
+ str(heading_id)
78
+ for token in state.tokens
79
+ if token.type == "heading_open" and (heading_id := token.attrGet("id"))
80
+ }
70
81
  for idx, token in enumerate(state.tokens):
71
82
  if token.type != "heading_open":
72
83
  continue
@@ -80,8 +91,10 @@ def _make_anchors_func(
80
91
  for child in inline_token.children
81
92
  if child.type in ["text", "code_inline"]
82
93
  )
83
- slug = unique_slug(slug_func(title), slugs)
84
- token.attrSet("id", slug)
94
+ slug = token.attrGet("id")
95
+ if not slug:
96
+ slug = unique_slug(slug_func(title), slugs)
97
+ token.attrSet("id", slug)
85
98
 
86
99
  if permalink:
87
100
  link_open = Token(
@@ -116,7 +129,7 @@ def _make_anchors_func(
116
129
 
117
130
 
118
131
  def slugify(title: str) -> str:
119
- return re.sub(r"[^\w\u4e00-\u9fff\- ]", "", title.strip().lower().replace(" ", "-"))
132
+ return re.sub(r"[^\w\u4e00-\u9fff\- ]", "", title.lower().replace(" ", "-"))
120
133
 
121
134
 
122
135
  def unique_slug(slug: str, slugs: set[str]) -> str:
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from collections.abc import Sequence
4
4
  from functools import partial
5
+ import re
5
6
  from typing import Any
6
7
 
7
8
  from markdown_it import MarkdownIt
@@ -41,8 +42,14 @@ def attrs_plugin(
41
42
  - `key="value"` or `key=value` specifies a key-value attribute.
42
43
  Quotes are not needed when the value consists entirely of
43
44
  ASCII alphanumeric characters or `_` or `:` or `-`.
44
- Backslash escapes may be used inside quoted values.
45
- - `%` begins a comment, which ends with the next `%` or the end of the attribute (`}`).
45
+ Backslash escapes may be used inside quoted values,
46
+ to allow a ``"`` character within the value.
47
+ Note that the backslash is retained in the value:
48
+ ``{k="a\\"b"}`` gives ``k`` the value ``a\\"b``.
49
+ - `%` begins a comment, which ends with the next `%`.
50
+ If no further `%` occurs in the rest of the line (for a block attribute)
51
+ or of the inline content (for an inline attribute),
52
+ the comment instead ends at the next `}`, which also ends the attribute.
46
53
 
47
54
  Multiple attribute blocks are merged.
48
55
 
@@ -56,10 +63,24 @@ def attrs_plugin(
56
63
  :param allowed: A list of allowed attribute names.
57
64
  If not ``None``, any attributes not in this list will be removed
58
65
  and placed in the token's meta under the key "insecure_attrs".
66
+ If ``None`` (the default), event-handler attributes
67
+ (any name starting with ``on``, such as ``onclick``) are still removed,
68
+ as a baseline protection against script injection.
69
+ Other attributes, including ``style``, are kept.
70
+ Whatever ``allowed`` says, the URL-valued attributes
71
+ ``href``, ``src``, ``action``, ``formaction`` and ``xlink:href``
72
+ are removed when ``md.validateLink`` rejects their value
73
+ (for example a ``javascript:`` URL), as for Markdown links and images.
74
+ This is not a full sanitiser: to safely render untrusted input,
75
+ pass an explicit ``allowed`` list (e.g. ``("id", "class")``).
59
76
  """
60
77
 
61
78
  if spans:
62
- md.inline.ruler.after(span_after, "span", _span_rule)
79
+ md.inline.ruler.after(
80
+ span_after,
81
+ "span",
82
+ partial(_span_rule, allowed=None if allowed is None else set(allowed)),
83
+ )
63
84
  if after:
64
85
  md.inline.ruler.push(
65
86
  "attr",
@@ -91,6 +112,16 @@ def attrs_block_plugin(md: MarkdownIt, *, allowed: Sequence[str] | None = None)
91
112
  :param allowed: A list of allowed attribute names.
92
113
  If not ``None``, any attributes not in this list will be removed
93
114
  and placed in the token's meta under the key "insecure_attrs".
115
+ If ``None`` (the default), event-handler attributes
116
+ (any name starting with ``on``, such as ``onclick``) are still removed,
117
+ as a baseline protection against script injection.
118
+ Other attributes, including ``style``, are kept.
119
+ Whatever ``allowed`` says, the URL-valued attributes
120
+ ``href``, ``src``, ``action``, ``formaction`` and ``xlink:href``
121
+ are removed when ``md.validateLink`` rejects their value
122
+ (for example a ``javascript:`` URL), as for Markdown links and images.
123
+ This is not a full sanitiser: to safely render untrusted input,
124
+ pass an explicit ``allowed`` list (e.g. ``("id", "class")``).
94
125
  """
95
126
  md.block.ruler.before("fence", "attr", _attr_block_rule)
96
127
  md.core.ruler.after(
@@ -115,7 +146,9 @@ def _find_opening(tokens: Sequence[Token], index: int) -> int | None:
115
146
  return None
116
147
 
117
148
 
118
- def _span_rule(state: StateInline, silent: bool) -> bool:
149
+ def _span_rule(
150
+ state: StateInline, silent: bool, *, allowed: set[str] | None = None
151
+ ) -> bool:
119
152
  if state.src[state.pos] != "[":
120
153
  return False
121
154
 
@@ -144,7 +177,7 @@ def _span_rule(state: StateInline, silent: bool) -> bool:
144
177
  state.pos = labelStart
145
178
  state.posMax = labelEnd
146
179
  token = state.push("span_open", "span", 1)
147
- token.attrs = attrs # type: ignore[assignment]
180
+ _add_attrs(state.md, token, attrs, allowed)
148
181
  state.md.inline.tokenize(state)
149
182
  token = state.push("span_close", "span", -1)
150
183
 
@@ -175,9 +208,9 @@ def _attr_inline_rule(
175
208
  state.pos += new_pos + 1
176
209
  if not silent:
177
210
  attr_token = state.tokens[token_index]
178
- if "class" in attrs and "class" in token.attrs:
179
- attrs["class"] = f"{token.attrs['class']} {attrs['class']}"
180
- _add_attrs(attr_token, attrs, allowed)
211
+ if "class" in attrs and "class" in attr_token.attrs:
212
+ attrs["class"] = f"{attr_token.attrs['class']} {attrs['class']}"
213
+ _add_attrs(state.md, attr_token, attrs, allowed)
181
214
  return True
182
215
 
183
216
 
@@ -238,7 +271,8 @@ def _attr_resolve_block_rule(state: StateCore, *, allowed: set[str] | None) -> N
238
271
  i += 1
239
272
  continue
240
273
 
241
- if i + 1 < len_tokens:
274
+ # skip closing tokens; attributes would be rendered into the closing tag
275
+ if i + 1 < len_tokens and state.tokens[i + 1].nesting >= 0:
242
276
  next_token = state.tokens[i + 1]
243
277
 
244
278
  # classes are appended
@@ -253,23 +287,69 @@ def _attr_resolve_block_rule(state: StateCore, *, allowed: set[str] | None) -> N
253
287
  if key == "class" or key not in next_token.attrs:
254
288
  next_token.attrs[key] = value
255
289
  else:
256
- _add_attrs(next_token, state.tokens[i].attrs, allowed)
290
+ _add_attrs(state.md, next_token, state.tokens[i].attrs, allowed)
257
291
 
258
292
  state.tokens.pop(i)
259
293
  len_tokens -= 1
260
294
 
261
295
 
296
+ _URL_ATTRS = frozenset(("href", "src", "action", "formaction", "xlink:href"))
297
+ """Attributes whose value is a URL, checked with ``md.validateLink``."""
298
+
299
+ _URL_IGNORED_RE = re.compile(r"[\t\n\r]")
300
+ _URL_STRIP = "".join(chr(i) for i in range(0x21))
301
+
302
+
303
+ def _is_insecure_attr(key: str) -> bool:
304
+ """Return True for event-handler attributes (``on*``, e.g. ``onclick``).
305
+
306
+ They execute JavaScript, so they are removed by default
307
+ when no explicit ``allowed`` list is given.
308
+ """
309
+ return key.lower().startswith("on")
310
+
311
+
312
+ def _is_valid_url(md: MarkdownIt, value: Any) -> bool:
313
+ """Return True if ``md.validateLink`` accepts a URL-valued attribute.
314
+
315
+ Browsers ignore ASCII tabs and newlines anywhere in a URL,
316
+ and control characters and spaces around it, before reading its scheme
317
+ (so ``java<TAB>script:`` is a ``javascript:`` URL).
318
+ The value is emitted as written, so those characters are removed
319
+ before it is normalised and validated.
320
+ """
321
+ url = _URL_IGNORED_RE.sub("", str(value)).strip(_URL_STRIP)
322
+ return md.validateLink(md.normalizeLink(url))
323
+
324
+
262
325
  def _add_attrs(
326
+ md: MarkdownIt,
263
327
  token: Token,
264
328
  attrs: dict[str, Any],
265
329
  allowed: set[str] | None,
266
330
  ) -> None:
267
- """Add attributes to a token, skipping any disallowed attributes."""
268
- if allowed is not None and (
269
- disallowed := {k: v for k, v in attrs.items() if k not in allowed}
270
- ):
331
+ """Add attributes to a token, skipping any disallowed attributes.
332
+
333
+ When ``allowed`` is not ``None`` only those names are kept.
334
+ When it is ``None`` (the default) event-handler (``on*``) attributes
335
+ are still removed, so untrusted input cannot inject script.
336
+ In both cases, a URL-valued attribute (``href``, ``src``, ...)
337
+ is removed if ``md.validateLink`` rejects it.
338
+ Anything removed is preserved in ``token.meta["insecure_attrs"]``.
339
+ """
340
+ if allowed is not None:
341
+ disallowed = {k: v for k, v in attrs.items() if k not in allowed}
342
+ else:
343
+ disallowed = {k: v for k, v in attrs.items() if _is_insecure_attr(k)}
344
+ disallowed.update(
345
+ (k, v)
346
+ for k, v in attrs.items()
347
+ if k not in disallowed and k.lower() in _URL_ATTRS and not _is_valid_url(md, v)
348
+ )
349
+
350
+ if disallowed:
271
351
  token.meta["insecure_attrs"] = disallowed
272
- attrs = {k: v for k, v in attrs.items() if k in allowed}
352
+ attrs = {k: v for k, v in attrs.items() if k not in disallowed}
273
353
 
274
354
  # attributes takes precedence over existing attributes
275
355
  token.attrs.update(attrs)
@@ -100,17 +100,31 @@ def parse(string: str) -> tuple[int, dict[str, str]]:
100
100
  """Parse attributes from start of string.
101
101
 
102
102
  :returns: (length of parsed string, dict of attributes)
103
+ :raises ParseError: if the attributes are malformed,
104
+ or the string ends before the closing `}`
103
105
  """
104
106
  pos = 0
105
107
  state: State = State.START
106
108
  tokens = TokenState()
109
+ # found once, so that checking for a later `%` at each `}` does not
110
+ # rescan the rest of the string (which would be quadratic in its length)
111
+ last_percent = string.rfind("%")
107
112
  while pos < len(string):
108
- state = HANDLERS[state](string[pos], pos, tokens)
113
+ if (
114
+ state == State.SCANNING_COMMENT
115
+ and string[pos] == "}"
116
+ and pos > last_percent
117
+ ):
118
+ # a comment ends at the next `%`, but there is none left to close it,
119
+ # so this `}` ends both the comment and the attributes
120
+ state = State.DONE
121
+ else:
122
+ state = HANDLERS[state](string[pos], pos, tokens)
109
123
  if state == State.DONE:
110
124
  return pos, tokens.compile(string)
111
125
  pos = pos + 1
112
126
 
113
- return pos, tokens.compile(string)
127
+ raise ParseError("Attributes not terminated", pos)
114
128
 
115
129
 
116
130
  def handle_start(char: str, pos: int, tokens: TokenState) -> State:
@@ -153,7 +153,7 @@ def _render(
153
153
 
154
154
  return (
155
155
  "<pre><code"
156
- + (f' class="block-{block_name}" ' if block_name else "")
156
+ + (f' class="block-{escapeHtml(block_name)}" ' if block_name else "")
157
157
  + ">"
158
158
  + content
159
159
  + "</code></pre>\n"
@@ -21,8 +21,8 @@ def container_plugin(
21
21
  md: MarkdownIt,
22
22
  name: str,
23
23
  marker: str = ":",
24
- validate: None | Callable[[str, str], bool] = None,
25
- render: None | Callable[..., str] = None,
24
+ validate: Callable[[str, str], bool] | None = None,
25
+ render: Callable[..., str] | None = None,
26
26
  ) -> None:
27
27
  """Plugin ported from
28
28
  `markdown-it-container <https://github.com/markdown-it/markdown-it-container>`__.
@@ -49,9 +49,28 @@ def dollarmath_plugin(
49
49
  :param label_normalizer: Function to normalize the label,
50
50
  by default replaces whitespace with `-`
51
51
  :param renderer: Function to render content: `(str, {"display_mode": bool}) -> str`,
52
- by default escapes HTML
52
+ by default escapes HTML.
53
+ Its return value is placed inside the element as is,
54
+ so a custom renderer is responsible for escaping it (the default renderer does).
53
55
  :param label_renderer: Function to render labels, by default creates anchor
54
56
 
57
+ The plugin does not typeset the math.
58
+ Each token becomes an HTML element holding the rendered content,
59
+ and typesetting is left to a library such as MathJax or KaTeX:
60
+
61
+ - ``math_inline`` (``$a$``):
62
+ ``<span class="math inline">``, with ``display_mode=False``
63
+ - ``math_inline_double`` (``$$a$$`` inside a paragraph, with ``double_inline``):
64
+ ``<div class="math inline">``, with ``display_mode=True``
65
+ - ``math_block`` (``$$a$$`` on its own lines):
66
+ ``<div class="math block">``, with ``display_mode=True``
67
+ - ``math_block_label`` (``$$a$$ (eq1)``):
68
+ ``<div id="eq1" class="math block">``, starting with the label anchor,
69
+ with ``display_mode=True``
70
+
71
+ The tag says how to display the math (``div`` for display, ``span`` for inline),
72
+ and the class says where it was written (``inline`` inside a paragraph,
73
+ ``block`` on its own lines), as in executablebooks/markdown-it-dollarmath.
55
74
  """
56
75
  if label_normalizer is None:
57
76
  label_normalizer = lambda label: re.sub(r"\s+", "-", label) # noqa: E731
@@ -79,7 +98,7 @@ def dollarmath_plugin(
79
98
  if label_renderer is None:
80
99
  _label_renderer = ( # noqa: E731
81
100
  lambda label: (
82
- f'<a href="#{label}" class="mathlabel" title="Permalink to this equation">¶</a>'
101
+ f'<a href="#{escapeHtml(label)}" class="mathlabel" title="Permalink to this equation">¶</a>'
83
102
  )
84
103
  )
85
104
  else:
@@ -123,7 +142,7 @@ def dollarmath_plugin(
123
142
  env: EnvType,
124
143
  ) -> str:
125
144
  content = _renderer(str(tokens[idx].content).strip(), {"display_mode": True})
126
- _id = tokens[idx].info
145
+ _id = escapeHtml(tokens[idx].info)
127
146
  label = _label_renderer(tokens[idx].info)
128
147
  return f'<div id="{_id}" class="math block">\n{label}\n{content}\n</div>\n'
129
148
 
@@ -287,6 +306,22 @@ def math_inline_dollar(
287
306
  DOLLAR_EQNO_REV = re.compile(r"^\s*\)([^)$\r\n]+?)\(\s*\${2}")
288
307
 
289
308
 
309
+ def _math_block_content(
310
+ state: StateBlock, startLine: int, startPos: int, endLine: int, end: int
311
+ ) -> str:
312
+ """Extract math body, omitting parent container prefixes on each line."""
313
+ content_start = startPos + 2
314
+ if startLine == endLine:
315
+ return state.src[content_start:end]
316
+
317
+ parts: list[str] = [state.src[content_start : state.eMarks[startLine]], "\n"]
318
+ for line in range(startLine + 1, endLine):
319
+ parts.append(state.src[state.bMarks[line] : state.eMarks[line]])
320
+ parts.append("\n")
321
+ parts.append(state.src[state.bMarks[endLine] : end])
322
+ return "".join(parts)
323
+
324
+
290
325
  def math_block_dollar(
291
326
  allow_labels: bool = True,
292
327
  label_normalizer: Callable[[str], str] | None = None,
@@ -322,13 +357,21 @@ def math_block_dollar(
322
357
  if lineText.strip().endswith("$$"):
323
358
  haveEndMarker = True
324
359
  end = end - 2 - (len(lineText) - len(lineText.strip()))
325
- elif allow_labels:
326
- # reverse the line and match
327
- eqnoMatch = DOLLAR_EQNO_REV.match(lineText[::-1])
328
- if eqnoMatch:
329
- haveEndMarker = True
330
- label = eqnoMatch.group(1)[::-1]
331
- end = end - eqnoMatch.end()
360
+ else:
361
+ # a closing marker present but not at end of line and not a label
362
+ # suffix is ambiguous, so don't greedily scan subsequent lines for it
363
+ trailing_close = "$$" in lineText[2:]
364
+ if allow_labels:
365
+ # reverse the line and match
366
+ eqnoMatch = DOLLAR_EQNO_REV.match(lineText[::-1])
367
+ if eqnoMatch:
368
+ haveEndMarker = True
369
+ label = eqnoMatch.group(1)[::-1]
370
+ end = end - eqnoMatch.end()
371
+ elif trailing_close:
372
+ return False
373
+ elif trailing_close:
374
+ return False
332
375
 
333
376
  # search for end of block on subsequent line
334
377
  if not haveEndMarker:
@@ -365,7 +408,7 @@ def math_block_dollar(
365
408
 
366
409
  token = state.push("math_block_label" if label else "math_block", "math", 0)
367
410
  token.block = True
368
- token.content = state.src[startPos + 2 : end]
411
+ token.content = _math_block_content(state, startLine, startPos, nextLine, end)
369
412
  token.markup = "$$"
370
413
  token.map = [startLine, state.line]
371
414
  if label:
@@ -164,8 +164,9 @@ def _fieldlist_rule(
164
164
  while _line < endLine:
165
165
  # if start_of_content < end_of_content, then non-empty line
166
166
  if (state.bMarks[_line] + state.tShift[_line]) < state.eMarks[_line]:
167
- if state.tShift[_line] <= 0:
168
- # the line has no indent, so it's the end of the field
167
+ if state.tShift[_line] <= state.blkIndent:
168
+ # the line is not indented relative to the field marker,
169
+ # so it's the end of the field body
169
170
  break
170
171
  block_indent = (
171
172
  state.tShift[_line]
@@ -227,6 +227,8 @@ def footnote_inline(state: StateInline, silent: bool) -> bool:
227
227
  footnoteId = len(refs)
228
228
 
229
229
  tokens: list[Token] = []
230
+ # Reserve the ID before parsing content that may allocate more footnotes.
231
+ refs[footnoteId] = {"content": state.src[labelStart:labelEnd], "tokens": tokens}
230
232
  state.md.inline.parse(
231
233
  state.src[labelStart:labelEnd], state.md, state.env, tokens
232
234
  )
@@ -234,8 +236,6 @@ def footnote_inline(state: StateInline, silent: bool) -> bool:
234
236
  token = state.push("footnote_ref", "", 0)
235
237
  token.meta = {"id": footnoteId}
236
238
 
237
- refs[footnoteId] = {"content": state.src[labelStart:labelEnd], "tokens": tokens}
238
-
239
239
  state.pos = labelEnd + 1
240
240
  state.posMax = maximum
241
241
  return True
@@ -69,12 +69,19 @@ def _front_matter_rule(
69
69
  # unclosed block should be autoclosed by end of document.
70
70
  return False
71
71
 
72
- if state.src[start:maximum] == "...":
73
- break
74
-
75
72
  start = state.bMarks[nextLine] + state.tShift[nextLine]
76
73
  maximum = state.eMarks[nextLine]
77
74
 
75
+ if (
76
+ state.sCount[nextLine] == 0
77
+ and state.src.startswith("...", start)
78
+ and state.skipSpaces(start + 3) >= maximum
79
+ ):
80
+ # YAML document-end marker: `...` at column 0, trailing spaces allowed.
81
+ # It is checked on the current line and consumed, so it is not content.
82
+ auto_closed = True
83
+ break
84
+
78
85
  if start < maximum and state.sCount[nextLine] < state.blkIndent:
79
86
  # non-empty line with negative indent should stop the list:
80
87
  # - ```
@@ -159,7 +159,10 @@ def _www_inline_rule(state: StateInline, silent: bool) -> bool:
159
159
  label = src[pos : pos + length]
160
160
 
161
161
  if silent:
162
- return True
162
+ # Autolinks are only recognised when emitting tokens. When skipping
163
+ # silently (e.g. scanning a link label), report no match so the parser
164
+ # steps over this text as plain characters (#160).
165
+ return False
163
166
 
164
167
  full_url = state.md.normalizeLink(url)
165
168
  if not state.md.validateLink(full_url):
@@ -223,7 +226,7 @@ def _protocol_rule(state: StateInline, silent: bool) -> bool:
223
226
  label = combined[:total_len]
224
227
 
225
228
  if silent:
226
- return True
229
+ return False # see _www_inline_rule
227
230
  return _create_autolink(state, bscan_len, total_len, full_url, label)
228
231
 
229
232
 
@@ -291,5 +294,5 @@ def _email_rule(state: StateInline, silent: bool) -> bool:
291
294
  url = "mailto:" + email_text
292
295
 
293
296
  if silent:
294
- return True
297
+ return False # see _www_inline_rule
295
298
  return _create_autolink(state, local_len, total_len, url, email_text)
@@ -152,7 +152,7 @@ def render_myst_target(
152
152
  options: OptionsDict,
153
153
  env: EnvType,
154
154
  ) -> str:
155
- label = tokens[idx].content
155
+ label = escapeHtml(tokens[idx].content)
156
156
  class_name = "myst-target"
157
157
  target = f'<a href="#{label}">({label})=</a>'
158
158
  return f'<div class="{class_name}">{target}</div>'
@@ -21,20 +21,15 @@ def myst_role_plugin(md: MarkdownIt) -> None:
21
21
 
22
22
 
23
23
  def myst_role(state: StateInline, silent: bool) -> bool:
24
+ # note ``\{`` escaping is handled by the core ``escape`` rule,
25
+ # which runs before this rule
26
+
24
27
  # check name
25
28
  match = VALID_NAME_PATTERN.match(state.src[state.pos :])
26
29
  if not match:
27
30
  return False
28
31
  name = match.group(1)
29
32
 
30
- # check for starting backslash escape
31
- try:
32
- if state.src[state.pos - 1] == "\\":
33
- # escaped (this could be improved in the case of edge case '\\{')
34
- return False
35
- except IndexError:
36
- pass
37
-
38
33
  # scan opening tick length
39
34
  start = pos = state.pos + match.end()
40
35
  try:
@@ -0,0 +1,12 @@
1
+ """Section reference plugin for markdown-it-py.
2
+
3
+ Captures section-sign references such as ``§1``, ``§1.1`` and ``§2.3.4``
4
+ into dedicated ``section_ref`` tokens, so downstream renderers
5
+ (e.g. MyST-Parser) can resolve them to numbered heading cross-references.
6
+
7
+ Requires markdown-it-py >= 4.1.0.
8
+ """
9
+
10
+ from .index import section_ref_plugin
11
+
12
+ __all__ = ("section_ref_plugin",)