mdformat-mkdocs 5.1.5b0__tar.gz → 5.2.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 (28) hide show
  1. {mdformat_mkdocs-5.1.5b0 → mdformat_mkdocs-5.2.0}/PKG-INFO +21 -1
  2. {mdformat_mkdocs-5.1.5b0 → mdformat_mkdocs-5.2.0}/README.md +19 -0
  3. {mdformat_mkdocs-5.1.5b0 → mdformat_mkdocs-5.2.0}/mdformat_mkdocs/__init__.py +1 -1
  4. {mdformat_mkdocs-5.1.5b0 → mdformat_mkdocs-5.2.0}/mdformat_mkdocs/_helpers.py +1 -1
  5. {mdformat_mkdocs-5.1.5b0 → mdformat_mkdocs-5.2.0}/mdformat_mkdocs/_normalize_list.py +77 -17
  6. {mdformat_mkdocs-5.1.5b0 → mdformat_mkdocs-5.2.0}/mdformat_mkdocs/_synced/admon_factories/_whitespace_admon_factories.py +4 -2
  7. {mdformat_mkdocs-5.1.5b0 → mdformat_mkdocs-5.2.0}/mdformat_mkdocs/mdit_plugins/__init__.py +11 -0
  8. {mdformat_mkdocs-5.1.5b0 → mdformat_mkdocs-5.2.0}/mdformat_mkdocs/mdit_plugins/_material_deflist.py +0 -1
  9. {mdformat_mkdocs-5.1.5b0 → mdformat_mkdocs-5.2.0}/mdformat_mkdocs/mdit_plugins/_mkdocstrings_autorefs.py +1 -0
  10. {mdformat_mkdocs-5.1.5b0 → mdformat_mkdocs-5.2.0}/mdformat_mkdocs/mdit_plugins/_mkdocstrings_crossreference.py +1 -0
  11. mdformat_mkdocs-5.2.0/mdformat_mkdocs/mdit_plugins/_mkdocstrings_injection.py +78 -0
  12. {mdformat_mkdocs-5.1.5b0 → mdformat_mkdocs-5.2.0}/mdformat_mkdocs/mdit_plugins/_python_markdown_attr_list.py +3 -4
  13. mdformat_mkdocs-5.2.0/mdformat_mkdocs/mdit_plugins/_spaced_url_link.py +78 -0
  14. {mdformat_mkdocs-5.1.5b0 → mdformat_mkdocs-5.2.0}/mdformat_mkdocs/plugin.py +70 -3
  15. {mdformat_mkdocs-5.1.5b0 → mdformat_mkdocs-5.2.0}/pyproject.toml +10 -2
  16. {mdformat_mkdocs-5.1.5b0 → mdformat_mkdocs-5.2.0}/LICENSE +0 -0
  17. {mdformat_mkdocs-5.1.5b0 → mdformat_mkdocs-5.2.0}/mdformat_mkdocs/_postprocess_inline.py +0 -0
  18. {mdformat_mkdocs-5.1.5b0 → mdformat_mkdocs-5.2.0}/mdformat_mkdocs/_synced/__init__.py +0 -0
  19. {mdformat_mkdocs-5.1.5b0 → mdformat_mkdocs-5.2.0}/mdformat_mkdocs/_synced/admon_factories/README.md +0 -0
  20. {mdformat_mkdocs-5.1.5b0 → mdformat_mkdocs-5.2.0}/mdformat_mkdocs/_synced/admon_factories/__init__.py +0 -0
  21. {mdformat_mkdocs-5.1.5b0 → mdformat_mkdocs-5.2.0}/mdformat_mkdocs/mdit_plugins/_material_admon.py +0 -0
  22. {mdformat_mkdocs-5.1.5b0 → mdformat_mkdocs-5.2.0}/mdformat_mkdocs/mdit_plugins/_material_content_tabs.py +0 -0
  23. {mdformat_mkdocs-5.1.5b0 → mdformat_mkdocs-5.2.0}/mdformat_mkdocs/mdit_plugins/_pymd_abbreviations.py +0 -0
  24. {mdformat_mkdocs-5.1.5b0 → mdformat_mkdocs-5.2.0}/mdformat_mkdocs/mdit_plugins/_pymd_admon.py +0 -0
  25. {mdformat_mkdocs-5.1.5b0 → mdformat_mkdocs-5.2.0}/mdformat_mkdocs/mdit_plugins/_pymd_arithmatex.py +0 -0
  26. {mdformat_mkdocs-5.1.5b0 → mdformat_mkdocs-5.2.0}/mdformat_mkdocs/mdit_plugins/_pymd_captions.py +0 -0
  27. {mdformat_mkdocs-5.1.5b0 → mdformat_mkdocs-5.2.0}/mdformat_mkdocs/mdit_plugins/_pymd_snippet.py +0 -0
  28. {mdformat_mkdocs-5.1.5b0 → mdformat_mkdocs-5.2.0}/mdformat_mkdocs/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mdformat_mkdocs
3
- Version: 5.1.5b0
3
+ Version: 5.2.0
4
4
  Summary: An mdformat plugin for mkdocs and Material for MkDocs
5
5
  Keywords: markdown,markdown-it,mdformat,mdformat_plugin_template
6
6
  Author: kyleking
@@ -32,6 +32,7 @@ Requires-Dist: mdformat-hooks>=0.1.0 ; extra == 'recommended-mdsf'
32
32
  Requires-Dist: mdformat-simple-breaks>=0.0.1 ; extra == 'recommended-mdsf'
33
33
  Requires-Dist: mdformat-wikilink>=0.2.0 ; extra == 'recommended-mdsf'
34
34
  Requires-Dist: beartype>=0.21.0 ; extra == 'test'
35
+ Requires-Dist: hypothesis>=6.100.0 ; extra == 'test'
35
36
  Requires-Dist: pytest>=9.0.1 ; extra == 'test'
36
37
  Requires-Dist: pytest-beartype>=0.2.0 ; extra == 'test'
37
38
  Requires-Dist: pytest-cov>=7.0.0 ; extra == 'test'
@@ -62,6 +63,8 @@ Supports:
62
63
  - [MkDocs-Material Content Tabs\*](https://squidfunk.github.io/mkdocs-material/reference/content-tabs)
63
64
  - \*Note: the markup (HTML) rendered by this plugin is sufficient for formatting but not for viewing in a browser. Please open an issue if you have a need to generate valid HTML.
64
65
  - [MkDocs-Material Definition Lists](https://squidfunk.github.io/mkdocs-material/reference/lists/#using-definition-lists)
66
+ - [mkdocstrings Injection Blocks](https://mkdocstrings.github.io/usage/)
67
+ - Preserves `:::` identifier blocks and their indented YAML options verbatim, including when nested inside lists with `--align-semantic-breaks-in-lists`
65
68
  - [mkdocstrings Anchors (autorefs)](https://mkdocstrings.github.io/autorefs/#markdown-anchors)
66
69
  - [mkdocstrings Cross-References](https://mkdocstrings.github.io/usage/#cross-references)
67
70
  - [Python Markdown "Abbreviations"\*](https://squidfunk.github.io/mkdocs-material/reference/tooltips/#adding-abbreviations)
@@ -77,6 +80,23 @@ Supports:
77
80
  - [Python Markdown "Snippets"\*](https://facelessuser.github.io/pymdown-extensions/extensions/snippets)
78
81
  - \*Note: the markup (HTML) renders the plain text without implementing the snippet logic. I'm open to contributions if anyone needs full support for snippets
79
82
 
83
+ ### Features with Implicit Support
84
+
85
+ The following MkDocs/Material/PyMdown syntax passes through mdformat-mkdocs unchanged — it is not modified or corrupted, but it is also not actively normalized:
86
+
87
+ - [PyMdown Keys](https://facelessuser.github.io/pymdown-extensions/extensions/keys/) — `++ctrl+alt+del++` (not a markdown construct, preserved as-is)
88
+ - [PyMdown Critic Markup](https://facelessuser.github.io/pymdown-extensions/extensions/critic/) — `{--deleted--}`, `{++added++}`, `{~~old~>new~~}`, `{==highlight==}`, `{>>comment<<}`
89
+ - [PyMdown Highlight](https://facelessuser.github.io/pymdown-extensions/extensions/mark/) — `==marked text==`
90
+ - [PyMdown Caret / Tilde](https://facelessuser.github.io/pymdown-extensions/extensions/caret/) — `H^2^O`, `CH~3~OH`
91
+ - [PyMdown Emoji](https://facelessuser.github.io/pymdown-extensions/extensions/emoji/) — `:smile:`, `:material-icon:`
92
+ - [PyMdown InlineHilite](https://facelessuser.github.io/pymdown-extensions/extensions/inlinehilite/) — language hints inside backtick spans (`:::python code`) are never modified
93
+ - [PyMdown SmartSymbols](https://facelessuser.github.io/pymdown-extensions/extensions/smartsymbols/) — `(c)`, `(tm)`, `--`, `-->` are plain ASCII in source and not touched
94
+ - [PyMdown MagicLink](https://facelessuser.github.io/pymdown-extensions/extensions/magiclink/) — `@username`, `#123` are plain text and pass through
95
+ - [Material Grids](https://squidfunk.github.io/mkdocs-material/reference/grids/) — `<div class="grid cards" markdown>` is an HTML block; content is preserved but markdown inside is not reformatted
96
+ - [Mermaid / Superfences](https://squidfunk.github.io/mkdocs-material/reference/diagrams/) — diagram code inside fenced blocks is never modified
97
+
98
+ **Note on [PyMdown ProgressBar](https://facelessuser.github.io/pymdown-extensions/extensions/progressbar/)**: The syntax `[=50% "50%"]` resembles an undefined link reference and will be escaped to `\[=50% "50%"\]` by default. Use `--ignore-missing-references` to preserve it, or avoid this extension if you use mdformat without that flag.
99
+
80
100
  See the example test files, [./tests/pre-commit-test.md](https://raw.githubusercontent.com/KyleKing/mdformat-mkdocs/main/tests/pre-commit-test.md) and [./tests/format/fixtures.md](https://raw.githubusercontent.com/KyleKing/mdformat-mkdocs/main/tests/format/fixtures.md)
81
101
 
82
102
  ## `mdformat` Usage
@@ -15,6 +15,8 @@ Supports:
15
15
  - [MkDocs-Material Content Tabs\*](https://squidfunk.github.io/mkdocs-material/reference/content-tabs)
16
16
  - \*Note: the markup (HTML) rendered by this plugin is sufficient for formatting but not for viewing in a browser. Please open an issue if you have a need to generate valid HTML.
17
17
  - [MkDocs-Material Definition Lists](https://squidfunk.github.io/mkdocs-material/reference/lists/#using-definition-lists)
18
+ - [mkdocstrings Injection Blocks](https://mkdocstrings.github.io/usage/)
19
+ - Preserves `:::` identifier blocks and their indented YAML options verbatim, including when nested inside lists with `--align-semantic-breaks-in-lists`
18
20
  - [mkdocstrings Anchors (autorefs)](https://mkdocstrings.github.io/autorefs/#markdown-anchors)
19
21
  - [mkdocstrings Cross-References](https://mkdocstrings.github.io/usage/#cross-references)
20
22
  - [Python Markdown "Abbreviations"\*](https://squidfunk.github.io/mkdocs-material/reference/tooltips/#adding-abbreviations)
@@ -30,6 +32,23 @@ Supports:
30
32
  - [Python Markdown "Snippets"\*](https://facelessuser.github.io/pymdown-extensions/extensions/snippets)
31
33
  - \*Note: the markup (HTML) renders the plain text without implementing the snippet logic. I'm open to contributions if anyone needs full support for snippets
32
34
 
35
+ ### Features with Implicit Support
36
+
37
+ The following MkDocs/Material/PyMdown syntax passes through mdformat-mkdocs unchanged — it is not modified or corrupted, but it is also not actively normalized:
38
+
39
+ - [PyMdown Keys](https://facelessuser.github.io/pymdown-extensions/extensions/keys/) — `++ctrl+alt+del++` (not a markdown construct, preserved as-is)
40
+ - [PyMdown Critic Markup](https://facelessuser.github.io/pymdown-extensions/extensions/critic/) — `{--deleted--}`, `{++added++}`, `{~~old~>new~~}`, `{==highlight==}`, `{>>comment<<}`
41
+ - [PyMdown Highlight](https://facelessuser.github.io/pymdown-extensions/extensions/mark/) — `==marked text==`
42
+ - [PyMdown Caret / Tilde](https://facelessuser.github.io/pymdown-extensions/extensions/caret/) — `H^2^O`, `CH~3~OH`
43
+ - [PyMdown Emoji](https://facelessuser.github.io/pymdown-extensions/extensions/emoji/) — `:smile:`, `:material-icon:`
44
+ - [PyMdown InlineHilite](https://facelessuser.github.io/pymdown-extensions/extensions/inlinehilite/) — language hints inside backtick spans (`:::python code`) are never modified
45
+ - [PyMdown SmartSymbols](https://facelessuser.github.io/pymdown-extensions/extensions/smartsymbols/) — `(c)`, `(tm)`, `--`, `-->` are plain ASCII in source and not touched
46
+ - [PyMdown MagicLink](https://facelessuser.github.io/pymdown-extensions/extensions/magiclink/) — `@username`, `#123` are plain text and pass through
47
+ - [Material Grids](https://squidfunk.github.io/mkdocs-material/reference/grids/) — `<div class="grid cards" markdown>` is an HTML block; content is preserved but markdown inside is not reformatted
48
+ - [Mermaid / Superfences](https://squidfunk.github.io/mkdocs-material/reference/diagrams/) — diagram code inside fenced blocks is never modified
49
+
50
+ **Note on [PyMdown ProgressBar](https://facelessuser.github.io/pymdown-extensions/extensions/progressbar/)**: The syntax `[=50% "50%"]` resembles an undefined link reference and will be escaped to `\[=50% "50%"\]` by default. Use `--ignore-missing-references` to preserve it, or avoid this extension if you use mdformat without that flag.
51
+
33
52
  See the example test files, [./tests/pre-commit-test.md](https://raw.githubusercontent.com/KyleKing/mdformat-mkdocs/main/tests/pre-commit-test.md) and [./tests/format/fixtures.md](https://raw.githubusercontent.com/KyleKing/mdformat-mkdocs/main/tests/format/fixtures.md)
34
53
 
35
54
  ## `mdformat` Usage
@@ -1,7 +1,7 @@
1
1
  # ruff: noqa: RUF067
2
2
  """An mdformat plugin for `mkdocs`."""
3
3
 
4
- __version__ = "5.1.5b0"
4
+ __version__ = "5.2.0"
5
5
 
6
6
  __plugin_name__ = "mkdocs"
7
7
 
@@ -15,7 +15,7 @@ EOL = "\n"
15
15
  MKDOCS_INDENT_COUNT = 4
16
16
  """Use 4-spaces for mkdocs."""
17
17
 
18
- FILLER_CHAR = "𝕏" # noqa: RUF001
18
+ FILLER_CHAR = ""
19
19
  """A spacer that is inserted and then removed to ensure proper word wrap."""
20
20
 
21
21
 
@@ -19,7 +19,11 @@ from ._helpers import (
19
19
  rstrip_result,
20
20
  separate_indent,
21
21
  )
22
- from .mdit_plugins import MATERIAL_ADMON_MARKERS, MATERIAL_CONTENT_TAB_MARKERS
22
+ from .mdit_plugins import (
23
+ INJECTION_PATTERN,
24
+ MATERIAL_ADMON_MARKERS,
25
+ MATERIAL_CONTENT_TAB_MARKERS,
26
+ )
23
27
 
24
28
  if TYPE_CHECKING:
25
29
  from collections.abc import Mapping
@@ -71,6 +75,7 @@ class Syntax(Enum):
71
75
  START_MARKED = "START_MARKED"
72
76
  EDGE_CODE = "EDGE_CODE"
73
77
  HTML = "HTML"
78
+ INJECTION = "INJECTION"
74
79
 
75
80
  @classmethod
76
81
  def from_content(cls, content: str) -> Syntax | None:
@@ -85,18 +90,25 @@ class Syntax(Enum):
85
90
  if match["item"].startswith("```"):
86
91
  return cls.CODE_NUMBERED if is_numbered else cls.CODE_BULLETED
87
92
  return cls.LIST_NUMBERED if is_numbered else cls.LIST_BULLETED
93
+
94
+ result = None
88
95
  if any(content.startswith(f"{marker} ") for marker in MARKERS):
89
- return cls.START_MARKED
90
- if content.startswith("```"):
91
- return cls.EDGE_CODE
92
- if content.startswith("<"):
93
- return cls.HTML
94
- return None
96
+ result = cls.START_MARKED
97
+ elif content.startswith("```"):
98
+ result = cls.EDGE_CODE
99
+ elif content.startswith("<"):
100
+ result = cls.HTML
101
+ elif INJECTION_PATTERN.match(content):
102
+ result = cls.INJECTION
103
+ return result
95
104
 
96
105
 
97
106
  SYNTAX_CODE_LIST = {Syntax.CODE_BULLETED, Syntax.CODE_NUMBERED}
98
107
  """The start of a code block, which is also the start of a list."""
99
108
 
109
+ SYNTAX_LIST = {Syntax.LIST_BULLETED, Syntax.LIST_NUMBERED, *SYNTAX_CODE_LIST}
110
+ """Any line that begins a list item."""
111
+
100
112
 
101
113
  class ParsedLine(NamedTuple):
102
114
  """Parsed Line of text."""
@@ -212,7 +224,7 @@ class BlockIndent(NamedTuple):
212
224
  start_line: int
213
225
  raw_indent: str
214
226
  indent_depth: int
215
- kind: Literal["code", "HTML"]
227
+ kind: Literal["code", "HTML", "injection"]
216
228
 
217
229
 
218
230
  def _parse_code_block(last: BlockIndent | None, line: LineResult) -> BlockIndent | None:
@@ -251,9 +263,44 @@ def _parse_html_line(last: BlockIndent | None, line: LineResult) -> BlockIndent
251
263
  elif last and not line.parsed.content:
252
264
  # Stop tracking an HTML block on a line break
253
265
  result = None
266
+ elif (
267
+ last
268
+ and line.parsed.syntax in SYNTAX_LIST
269
+ and len(line.parsed.indent) < len(last.raw_indent)
270
+ ):
271
+ # A list item that dedents below the block is a sibling, not HTML
272
+ # content. This guards against an inline autolink (or comment) on a
273
+ # continuation line being mistaken for a block that swallows siblings.
274
+ result = None
254
275
  return result
255
276
 
256
277
 
278
+ def _parse_injection_block(
279
+ last: BlockIndent | None,
280
+ line: LineResult,
281
+ ) -> BlockIndent | None:
282
+ """Identify mkdocstrings injection sections."""
283
+ if last is not None:
284
+ if line.parsed.content and len(line.parsed.indent) <= len(last.raw_indent):
285
+ if line.parsed.syntax == Syntax.INJECTION:
286
+ return BlockIndent(
287
+ start_line=line.parsed.line_num,
288
+ raw_indent=line.parsed.indent,
289
+ indent_depth=len(line.parents),
290
+ kind="injection",
291
+ )
292
+ return None
293
+ return last
294
+ if line.parsed.syntax == Syntax.INJECTION:
295
+ return BlockIndent(
296
+ start_line=line.parsed.line_num,
297
+ raw_indent=line.parsed.indent,
298
+ indent_depth=len(line.parents),
299
+ kind="injection",
300
+ )
301
+ return None
302
+
303
+
257
304
  # ======================================================================================
258
305
  # Semantic Indent Handling
259
306
 
@@ -274,12 +321,11 @@ def _parse_semantic_indent(
274
321
  tin: tuple[LineResult, BlockIndent | None],
275
322
  ) -> SemanticIndent:
276
323
  """Conditionally evaluate when semantic indents are necessary."""
277
- # PLANNED: This works, but is very confusing
278
- line, code_indent = tin
324
+ line, block_indent = tin
279
325
 
280
326
  if (
281
327
  not line.parsed.content
282
- or code_indent is not None
328
+ or block_indent is not None
283
329
  or line.parsed.syntax in SYNTAX_CODE_LIST
284
330
  ):
285
331
  result = SemanticIndent.EMPTY
@@ -420,22 +466,36 @@ def parse_text(
420
466
  indent if (indent and code_indents[indent.start_line] is None) else None
421
467
  for indent in map_lookback(_parse_html_line, lines, None)
422
468
  ]
423
- # When both, code_indents take precedence
469
+ injection_indents = [
470
+ # Any indents initiated from within a `code_block_indents` should be ignored
471
+ indent if (indent and code_indents[indent.start_line] is None) else None
472
+ for indent in map_lookback(_parse_injection_block, lines, None)
473
+ ]
474
+ # When multiple match, code_indents take precedence, then html_indents
424
475
  block_indents = [
425
- c_ or h_ for c_, h_ in zip(code_indents, html_indents, strict=True)
476
+ c_ or h_ or i_
477
+ for c_, h_, i_ in zip(
478
+ code_indents, html_indents, injection_indents, strict=True
479
+ )
426
480
  ]
427
481
  new_indents = [*starmap(_format_new_indent, zip(lines, block_indents, strict=True))]
428
482
 
429
483
  new_contents = [
430
- _format_new_content(line, inc_numbers, ci is not None)
431
- for line, ci in zip(lines, code_indents, strict=True)
484
+ _format_new_content(
485
+ line,
486
+ inc_numbers,
487
+ block_indent is not None and block_indent.kind in {"code", "injection"},
488
+ )
489
+ for line, block_indent in zip(lines, block_indents, strict=True)
432
490
  ]
433
491
 
434
492
  if use_sem_break:
435
493
  semantic_indents = map_lookback(
436
494
  _parse_semantic_indent,
437
- [*zip(lines, code_indents, strict=True)],
438
- _parse_semantic_indent(SemanticIndent.INITIAL, (lines[0], code_indents[0])),
495
+ [*zip(lines, block_indents, strict=True)],
496
+ _parse_semantic_indent(
497
+ SemanticIndent.INITIAL, (lines[0], block_indents[0])
498
+ ),
439
499
  )
440
500
  new_indents = [
441
501
  _trim_semantic_indent(indent, s_i, in_defbody)
@@ -189,8 +189,10 @@ def new_token(
189
189
  kind: str,
190
190
  ) -> Generator[Token, None, None]:
191
191
  """Create scoped token."""
192
- yield state.push(f"{name}_open", kind, 1)
193
- state.push(f"{name}_close", kind, -1)
192
+ try:
193
+ yield state.push(f"{name}_open", kind, 1)
194
+ finally:
195
+ state.push(f"{name}_close", kind, -1)
194
196
 
195
197
 
196
198
  def default_render(
@@ -21,6 +21,11 @@ from ._mkdocstrings_crossreference import (
21
21
  MKDOCSTRINGS_CROSSREFERENCE_PREFIX,
22
22
  mkdocstrings_crossreference_plugin,
23
23
  )
24
+ from ._mkdocstrings_injection import (
25
+ INJECTION_PATTERN,
26
+ MKDOCSTRINGS_INJECTION_PREFIX,
27
+ mkdocstrings_injection_plugin,
28
+ )
24
29
  from ._pymd_abbreviations import PYMD_ABBREVIATIONS_PREFIX, pymd_abbreviations_plugin
25
30
  from ._pymd_admon import pymd_admon_plugin
26
31
  from ._pymd_arithmatex import (
@@ -37,21 +42,25 @@ from ._python_markdown_attr_list import (
37
42
  PYTHON_MARKDOWN_ATTR_LIST_PREFIX,
38
43
  python_markdown_attr_list_plugin,
39
44
  )
45
+ from ._spaced_url_link import SPACED_URL_LINK_PREFIX, spaced_url_link_plugin
40
46
 
41
47
  __all__ = (
42
48
  "AMSMATH_BLOCK",
43
49
  "DOLLARMATH_BLOCK",
44
50
  "DOLLARMATH_BLOCK_LABEL",
45
51
  "DOLLARMATH_INLINE",
52
+ "INJECTION_PATTERN",
46
53
  "MATERIAL_ADMON_MARKERS",
47
54
  "MATERIAL_CONTENT_TAB_MARKERS",
48
55
  "MKDOCSTRINGS_AUTOREFS_PREFIX",
49
56
  "MKDOCSTRINGS_CROSSREFERENCE_PREFIX",
50
57
  "MKDOCSTRINGS_HEADING_AUTOREFS_PREFIX",
58
+ "MKDOCSTRINGS_INJECTION_PREFIX",
51
59
  "PYMD_ABBREVIATIONS_PREFIX",
52
60
  "PYMD_CAPTIONS_PREFIX",
53
61
  "PYMD_SNIPPET_PREFIX",
54
62
  "PYTHON_MARKDOWN_ATTR_LIST_PREFIX",
63
+ "SPACED_URL_LINK_PREFIX",
55
64
  "TEXMATH_BLOCK_EQNO",
56
65
  "escape_deflist",
57
66
  "material_admon_plugin",
@@ -59,6 +68,7 @@ __all__ = (
59
68
  "material_deflist_plugin",
60
69
  "mkdocstrings_autorefs_plugin",
61
70
  "mkdocstrings_crossreference_plugin",
71
+ "mkdocstrings_injection_plugin",
62
72
  "pymd_abbreviations_plugin",
63
73
  "pymd_admon_plugin",
64
74
  "pymd_arithmatex_plugin",
@@ -68,4 +78,5 @@ __all__ = (
68
78
  "render_material_definition_body",
69
79
  "render_material_definition_list",
70
80
  "render_material_definition_term",
81
+ "spaced_url_link_plugin",
71
82
  )
@@ -35,7 +35,6 @@ from markdown_it import MarkdownIt
35
35
  from mdit_py_plugins.deflist import deflist_plugin
36
36
 
37
37
  if TYPE_CHECKING:
38
- from markdown_it import MarkdownIt
39
38
  from mdformat.renderer import RenderContext, RenderTreeNode
40
39
  from mdformat.renderer.typing import Render
41
40
 
@@ -35,6 +35,7 @@ def _mkdocstrings_autorefs_plugin(state: StateInline, silent: bool) -> bool:
35
35
  return False
36
36
 
37
37
  if silent:
38
+ state.pos += match.end() # skipToken only auto-advances when ok=False
38
39
  return True
39
40
 
40
41
  anchor = match["anchor"]
@@ -28,6 +28,7 @@ def _mkdocstrings_crossreference(state: StateInline, silent: bool) -> bool:
28
28
  return False
29
29
 
30
30
  if silent:
31
+ state.pos += match.end() # skipToken only auto-advances when ok=False
31
32
  return True
32
33
 
33
34
  original_pos = state.pos
@@ -0,0 +1,78 @@
1
+ """mkdocstrings injection blocks.
2
+
3
+ Matches:
4
+
5
+ ```md
6
+ ::: package.module.Class
7
+ options:
8
+ heading_level: 2
9
+ ```
10
+
11
+ Docs: https://mkdocstrings.github.io/usage/
12
+
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from typing import TYPE_CHECKING
19
+
20
+ from mdit_py_plugins.utils import is_code_block
21
+
22
+ if TYPE_CHECKING:
23
+ from markdown_it import MarkdownIt
24
+ from markdown_it.rules_block import StateBlock
25
+
26
+ INJECTION_PATTERN = re.compile(r"^:::\s+\S")
27
+ MKDOCSTRINGS_INJECTION_PREFIX = "mkdocstrings_injection"
28
+
29
+
30
+ def _get_line(state: StateBlock, line: int, base_indent: int) -> str:
31
+ return state.src[state.bMarks[line] + base_indent : state.eMarks[line]]
32
+
33
+
34
+ def _mkdocstrings_injection(
35
+ state: StateBlock,
36
+ start_line: int,
37
+ end_line: int,
38
+ silent: bool,
39
+ ) -> bool:
40
+ if is_code_block(state, start_line):
41
+ return False
42
+
43
+ base_indent = state.tShift[start_line]
44
+ header = _get_line(state, start_line, base_indent)
45
+ if not INJECTION_PATTERN.match(header):
46
+ return False
47
+
48
+ if silent:
49
+ return True
50
+
51
+ lines = [header]
52
+ next_line = start_line + 1
53
+ while next_line < end_line:
54
+ if not state.isEmpty(next_line) and state.tShift[next_line] <= base_indent:
55
+ break
56
+ lines.append(_get_line(state, next_line, base_indent))
57
+ next_line += 1
58
+
59
+ while len(lines) > 1 and not lines[-1].strip():
60
+ lines.pop()
61
+ next_line -= 1
62
+
63
+ token = state.push(MKDOCSTRINGS_INJECTION_PREFIX, "", 0)
64
+ token.content = "\n".join(lines)
65
+ token.block = True
66
+ token.map = [start_line, next_line]
67
+
68
+ state.line = next_line
69
+ return True
70
+
71
+
72
+ def mkdocstrings_injection_plugin(md: MarkdownIt) -> None:
73
+ md.block.ruler.before(
74
+ "paragraph",
75
+ MKDOCSTRINGS_INJECTION_PREFIX,
76
+ _mkdocstrings_injection,
77
+ {"alt": ["paragraph"]},
78
+ )
@@ -17,7 +17,6 @@ from markdown_it import MarkdownIt
17
17
  from mdformat_mkdocs._synced.admon_factories import new_token
18
18
 
19
19
  if TYPE_CHECKING:
20
- from markdown_it import MarkdownIt
21
20
  from markdown_it.rules_inline import StateInline
22
21
 
23
22
  _ATTR_LIST_PATTERN = re.compile(r"{:? (?P<attrs>[^}]+) }")
@@ -38,9 +37,8 @@ def _python_markdown_attr_list(state: StateInline, silent: bool) -> bool:
38
37
  if state.linkLevel > 0:
39
38
  return False
40
39
 
41
- # Look backwards for unclosed '['
42
- search_start = max(0, state.pos - 100) # Limit backwards search
43
- text_before = state.src[search_start : state.pos]
40
+ # Look backwards for unclosed '[' — no length cap; any fixed cap fails for long URLs.
41
+ text_before = state.src[: state.pos]
44
42
  open_brackets = text_before.count("[") - text_before.count("]")
45
43
  if open_brackets > 0:
46
44
  # We might be inside a link, check if there's '](' after our match
@@ -54,6 +52,7 @@ def _python_markdown_attr_list(state: StateInline, silent: bool) -> bool:
54
52
  return False
55
53
 
56
54
  if silent:
55
+ state.pos += match.end() # skipToken only auto-advances when ok=False
57
56
  return True
58
57
 
59
58
  original_pos = state.pos
@@ -0,0 +1,78 @@
1
+ """Inline rule: parse ``[text](url with spaces)`` as a link.
2
+
3
+ CommonMark requires angle brackets for link destinations containing spaces.
4
+ Python-Markdown/MkDocs users write these without angle brackets. Without
5
+ this rule, markdown-it treats the whole construct as plain text, and
6
+ mdformat's HTML stability check fires.
7
+
8
+ This rule fires BEFORE markdown-it's built-in link rule. When it detects a
9
+ URL with at least one literal space (and no angle brackets), it emits proper
10
+ link tokens with the space percent-encoded in the href, matching the HTML
11
+ that mdformat outputs for the corrected form ``[text](<url with spaces>)``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import TYPE_CHECKING
17
+
18
+ from markdown_it import MarkdownIt
19
+
20
+ if TYPE_CHECKING:
21
+ from markdown_it.rules_inline import StateInline
22
+
23
+ SPACED_URL_LINK_PREFIX = "spaced_url_link"
24
+
25
+
26
+ def _spaced_url_link(state: StateInline, silent: bool) -> bool:
27
+ src = state.src
28
+ pos = state.pos
29
+
30
+ if src[pos] != "[":
31
+ return False
32
+
33
+ bracket_end = src.find("]", pos + 1)
34
+ if bracket_end == -1 or bracket_end + 1 >= len(src) or src[bracket_end + 1] != "(":
35
+ return False
36
+
37
+ paren_start = bracket_end + 2
38
+ paren_end = src.find(")", paren_start)
39
+ if paren_end == -1:
40
+ return False
41
+
42
+ url = src[paren_start:paren_end]
43
+ if " " not in url or url.startswith("<"):
44
+ return False
45
+
46
+ first_space = url.index(" ")
47
+ char_after_space = url[first_space + 1] if first_space + 1 < len(url) else ""
48
+ if char_after_space in {'"', "'", "("}:
49
+ return False
50
+
51
+ if not silent:
52
+ old_pos_max = state.posMax
53
+
54
+ token = state.push("link_open", "a", 1)
55
+ token.attrs = {"href": url.replace(" ", "%20")}
56
+ token.markup = ""
57
+ token.info = ""
58
+
59
+ state.pos = pos + 1
60
+ state.posMax = bracket_end
61
+ state.linkLevel += 1
62
+ state.md.inline.tokenize(state)
63
+ state.linkLevel -= 1
64
+ state.posMax = old_pos_max
65
+
66
+ state.push("link_close", "a", -1)
67
+
68
+ state.pos = paren_end + 1
69
+ return True
70
+
71
+
72
+ def spaced_url_link_plugin(md: MarkdownIt) -> None:
73
+ """Register the spaced-URL inline rule before the built-in link rule."""
74
+ md.inline.ruler.before(
75
+ "link",
76
+ SPACED_URL_LINK_PREFIX,
77
+ _spaced_url_link,
78
+ )
@@ -20,6 +20,7 @@ from .mdit_plugins import (
20
20
  MKDOCSTRINGS_AUTOREFS_PREFIX,
21
21
  MKDOCSTRINGS_CROSSREFERENCE_PREFIX,
22
22
  MKDOCSTRINGS_HEADING_AUTOREFS_PREFIX,
23
+ MKDOCSTRINGS_INJECTION_PREFIX,
23
24
  PYMD_ABBREVIATIONS_PREFIX,
24
25
  PYMD_CAPTIONS_PREFIX,
25
26
  PYMD_SNIPPET_PREFIX,
@@ -31,6 +32,7 @@ from .mdit_plugins import (
31
32
  material_deflist_plugin,
32
33
  mkdocstrings_autorefs_plugin,
33
34
  mkdocstrings_crossreference_plugin,
35
+ mkdocstrings_injection_plugin,
34
36
  pymd_abbreviations_plugin,
35
37
  pymd_admon_plugin,
36
38
  pymd_arithmatex_plugin,
@@ -40,6 +42,7 @@ from .mdit_plugins import (
40
42
  render_material_definition_body,
41
43
  render_material_definition_list,
42
44
  render_material_definition_term,
45
+ spaced_url_link_plugin,
43
46
  )
44
47
 
45
48
  if TYPE_CHECKING:
@@ -109,10 +112,12 @@ def update_mdit(mdit: MarkdownIt) -> None:
109
112
  mdit.use(material_content_tabs_plugin)
110
113
  mdit.use(material_deflist_plugin)
111
114
  mdit.use(mkdocstrings_autorefs_plugin)
115
+ mdit.use(mkdocstrings_injection_plugin)
112
116
  mdit.use(pymd_abbreviations_plugin)
113
117
  mdit.use(pymd_admon_plugin)
114
118
  mdit.use(pymd_snippet_plugin)
115
119
  mdit.use(python_markdown_attr_list_plugin)
120
+ mdit.use(spaced_url_link_plugin)
116
121
 
117
122
  if cli_is_ignore_missing_references(mdit.options):
118
123
  mdit.use(mkdocstrings_crossreference_plugin)
@@ -190,6 +195,58 @@ def _render_inline_content(node: RenderTreeNode, context: RenderContext) -> str:
190
195
  return inline.content
191
196
 
192
197
 
198
+ _ESCAPED_LINK_SPACED_URL = re.compile(r"\\\[([^\]]*)\\\]\(([^)]*[ ][^)]*)\)")
199
+ _PERCENT_ENCODED_URL_LINK = re.compile(r"\[([^\]]*)\]\(([^)]*%20[^)]*)\)")
200
+ _ANGLE_BRACKET_SPACED_URL_SOURCE = re.compile(r"\]\(<([^>]* [^>]*)>\)")
201
+ _UNBRACKETED_SPACED_URL_SOURCE = re.compile(r"\]\(([^<>()]*\s[^<>()]*)\)")
202
+
203
+
204
+ def _fix_links_with_spaced_urls(
205
+ text: str,
206
+ node: RenderTreeNode,
207
+ context: RenderContext, # noqa: ARG001
208
+ ) -> str:
209
+ r"""Rewrite links with space-containing URLs to angle-bracket syntax.
210
+
211
+ CommonMark requires link destinations with spaces to use angle brackets.
212
+ Handles three cases:
213
+
214
+ 1. markdown-it fails to parse [text](url space) as a link and mdformat
215
+ escapes the brackets — detects ``\\[text\\](url space)`` and rewrites to
216
+ ``[text](<url space>)``. (Fallback for edge cases not caught by the
217
+ spaced_url_link mdit rule.)
218
+ 2. markdown-it parses [text](<url space>) correctly but percent-encodes
219
+ the space in the href — detects [text](url%20space) and restores to
220
+ [text](<url space>) using the original source in node.content.
221
+ 3. The spaced_url_link mdit rule parses [text](url space) as a link with
222
+ percent-encoded href — same rewrite as case 2, but source has no
223
+ angle brackets so a separate pattern extracts the original URL.
224
+
225
+ Addresses: https://github.com/KyleKing/mdformat-mkdocs/issues/80
226
+
227
+ """
228
+ text = _ESCAPED_LINK_SPACED_URL.sub(
229
+ lambda m: f"[{m.group(1)}](<{m.group(2)}>)",
230
+ text,
231
+ )
232
+
233
+ spaced_urls_from_source = {
234
+ m.group(1) for m in _ANGLE_BRACKET_SPACED_URL_SOURCE.finditer(node.content)
235
+ } | {m.group(1) for m in _UNBRACKETED_SPACED_URL_SOURCE.finditer(node.content)}
236
+ if spaced_urls_from_source:
237
+
238
+ def _restore_spaced_url(m: re.Match[str]) -> str:
239
+ link_text, encoded_url = m.group(1), m.group(2)
240
+ decoded_url = encoded_url.replace("%20", " ")
241
+ if decoded_url in spaced_urls_from_source:
242
+ return f"[{link_text}](<{decoded_url}>)"
243
+ return m.group(0)
244
+
245
+ text = _PERCENT_ENCODED_URL_LINK.sub(_restore_spaced_url, text)
246
+
247
+ return text
248
+
249
+
193
250
  def _render_text(node: RenderTreeNode, context: RenderContext) -> str:
194
251
  r"""Re-escape dollar signs that mdformat core stripped.
195
252
 
@@ -327,6 +384,7 @@ RENDERERS: Mapping[str, Render] = {
327
384
  PYMD_CAPTIONS_PREFIX: render_pymd_caption,
328
385
  MKDOCSTRINGS_AUTOREFS_PREFIX: _render_meta_content,
329
386
  MKDOCSTRINGS_CROSSREFERENCE_PREFIX: _render_cross_reference,
387
+ MKDOCSTRINGS_INJECTION_PREFIX: _render_node_content,
330
388
  MKDOCSTRINGS_HEADING_AUTOREFS_PREFIX: _render_heading_autoref,
331
389
  PYMD_ABBREVIATIONS_PREFIX: _render_inline_content,
332
390
  PYMD_SNIPPET_PREFIX: _render_inline_content,
@@ -336,13 +394,22 @@ RENDERERS: Mapping[str, Render] = {
336
394
 
337
395
  if TYPE_CHECKING:
338
396
  normalize_list: Postprocess
339
- postprocess_list_wrap: Postprocess
397
+ postprocess_inline: Postprocess
340
398
  else:
341
399
  normalize_list = partial(
342
400
  unbounded_normalize_list,
343
401
  check_if_align_semantic_breaks_in_lists=cli_is_align_semantic_breaks_in_lists,
344
402
  )
345
- postprocess_list_wrap = _postprocess_list_wrap
403
+
404
+ def postprocess_inline(
405
+ text: str,
406
+ node: RenderTreeNode,
407
+ context: RenderContext,
408
+ ) -> str:
409
+ """Run all inline postprocessors in sequence."""
410
+ text = _postprocess_list_wrap(text, node, context)
411
+ return _fix_links_with_spaced_urls(text, node, context)
412
+
346
413
 
347
414
  # A mapping from `RenderTreeNode.type` to a `Postprocess` that does
348
415
  # postprocessing for the output of the `Render` function. Unlike
@@ -351,7 +418,7 @@ else:
351
418
  # will run in series.
352
419
  POSTPROCESSORS: Mapping[str, Postprocess] = {
353
420
  "bullet_list": normalize_list,
354
- "inline": postprocess_list_wrap,
421
+ "inline": postprocess_inline,
355
422
  "ordered_list": normalize_list,
356
423
  "paragraph": escape_deflist,
357
424
  }
@@ -25,7 +25,7 @@ license-files = ["LICENSE"]
25
25
  name = "mdformat_mkdocs"
26
26
  readme = "README.md"
27
27
  requires-python = ">=3.10.0"
28
- version = "5.1.5b0"
28
+ version = "5.2.0"
29
29
 
30
30
  [project.entry-points."mdformat.parser_extension"]
31
31
  mkdocs = "mdformat_mkdocs"
@@ -56,6 +56,7 @@ recommended-mdsf = [
56
56
  ]
57
57
  test = [
58
58
  "beartype >= 0.21.0",
59
+ "hypothesis >= 6.100.0",
59
60
  "pytest >= 9.0.1",
60
61
  "pytest-beartype >= 0.2.0",
61
62
  "pytest-cov >= 7.0.0",
@@ -69,7 +70,7 @@ homepage = "https://github.com/kyleking/mdformat-mkdocs"
69
70
 
70
71
  [tool.commitizen]
71
72
  tag_format = "v${version}"
72
- version = "5.1.5b0"
73
+ version = "5.2.0"
73
74
  version_files = ["mdformat_mkdocs/__init__.py", "pyproject.toml:^version"]
74
75
 
75
76
  [tool.mypy]
@@ -183,6 +184,13 @@ isolated_build = true
183
184
  requires = ["tox>=4.32.0"]
184
185
  skip_missing_interpreters = false
185
186
 
187
+ [tool.tox.env.canary]
188
+ basepython = ["py314"]
189
+ changedir = "{tox_root}"
190
+ commands = [["python", "scripts/canary.py", {default = [], extend = true, replace = "posargs"}]]
191
+ description = "Run mdformat --check against real downstream repos. Optionally specify: '-- ruff uv' to test a subset."
192
+ skip_install = false
193
+
186
194
  [tool.tox.env.cz]
187
195
  basepython = ["py314"]
188
196
  commands = [["cz", "bump", {default = [], extend = true, replace = "posargs"}]]