diff --git a/mdformat_mkdocs/mdit_plugins/__init__.py b/mdformat_mkdocs/mdit_plugins/__init__.py index 1e392a0..3e48ee2 100644 --- a/mdformat_mkdocs/mdit_plugins/__init__.py +++ b/mdformat_mkdocs/mdit_plugins/__init__.py @@ -16,6 +16,7 @@ ) from ._pymd_abbreviations import PYMD_ABBREVIATIONS_PREFIX, pymd_abbreviations_plugin from ._pymd_admon import pymd_admon_plugin +from ._pymd_captions import PYMD_CAPTIONS_PREFIX, pymd_captions_plugin from ._pymd_snippet import PYMD_SNIPPET_PREFIX, pymd_snippet_plugin from ._python_markdown_attr_list import ( PYTHON_MARKDOWN_ATTR_LIST_PREFIX, @@ -29,6 +30,7 @@ "MKDOCSTRINGS_CROSSREFERENCE_PREFIX", "MKDOCSTRINGS_HEADING_AUTOREFS_PREFIX", "PYMD_ABBREVIATIONS_PREFIX", + "PYMD_CAPTIONS_PREFIX", "PYMD_SNIPPET_PREFIX", "PYTHON_MARKDOWN_ATTR_LIST_PREFIX", "material_admon_plugin", @@ -37,6 +39,7 @@ "mkdocstrings_crossreference_plugin", "pymd_abbreviations_plugin", "pymd_admon_plugin", + "pymd_captions_plugin", "pymd_snippet_plugin", "python_markdown_attr_list_plugin", ) diff --git a/mdformat_mkdocs/mdit_plugins/_pymd_captions.py b/mdformat_mkdocs/mdit_plugins/_pymd_captions.py new file mode 100644 index 0000000..b5b8789 --- /dev/null +++ b/mdformat_mkdocs/mdit_plugins/_pymd_captions.py @@ -0,0 +1,122 @@ +"""Python-Markdown Extensions Captions. + +Matches: + +```md +/// caption +Default values for config variables. +/// +``` + +Docs: +https://github.com/facelessuser/pymdown-extensions/blob/main/pymdownx/blocks/caption.py + + +""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +from mdit_py_plugins.utils import is_code_block + +from mdformat_mkdocs._synced.admon_factories._whitespace_admon_factories import ( + new_token, +) + +if TYPE_CHECKING: + from markdown_it import MarkdownIt + from markdown_it.rules_block import StateBlock + +_CAPTION_START_PATTERN = re.compile( + r"\s*///\s*(?Pfigure-|table-|)caption\s*(\|\s*(?P[\d\.]+))?", +) +_CAPTION_END_PATTERN = re.compile(r"^\s*///\s*$") +_CAPTION_ATTRS_PATTERN = re.compile(r"^s*(?Pattrs:\s*\{[^}]*\})\s*$") +PYMD_CAPTIONS_PREFIX = "mkdocs_caption" + + +def _src_in_line(state: StateBlock, line: int) -> tuple[str, int, int]: + """Get the source in a given line number.""" + start_pos = state.bMarks[line] + state.tShift[line] + end_pos = state.eMarks[line] + return state.src[start_pos:end_pos], start_pos, end_pos + + +def _parse( + state: StateBlock, + first_line_max_pos: int, + start_line: int, + end_line: int, +) -> tuple[int, str, str | None]: + """Parse a caption block: optionally read attrs and extract content.""" + end_match = None + max_line = start_line + 1 + end_pos = -1 + attrs_text, _, attrs_max_pos = _src_in_line(state, max_line) + caption_attrs_match = _CAPTION_ATTRS_PATTERN.match(attrs_text) + content_start_pos = ( + first_line_max_pos + 1 if caption_attrs_match is None else attrs_max_pos + 1 + ) + attrs = ( + caption_attrs_match.group("attrs") if caption_attrs_match is not None else None + ) + if not isinstance(attrs, str): + attrs = None + + while end_match is None and max_line <= end_line: + line_text, end_pos, _ = _src_in_line(state, max_line) + if _CAPTION_END_PATTERN.match(line_text) is None: + max_line += 1 + else: + end_match = max_line + + return max_line, state.src[content_start_pos:end_pos], attrs + + +def _material_captions( + state: StateBlock, + start_line: int, + end_line: int, + silent: bool, +) -> bool: + """Detect caption blocks and wrap them in a token.""" + if is_code_block(state, start_line): + return False + + first_line_text, _, first_line_max_pos = _src_in_line(state, start_line) + start_match = _CAPTION_START_PATTERN.match(first_line_text) + if start_match is None: + return False + + if silent: + return True + + max_line, content, attrs = _parse(state, first_line_max_pos, start_line, end_line) + + with ( + new_token(state, PYMD_CAPTIONS_PREFIX, "figcaption") as token, + new_token(state, "", "p"), + ): + token.info = start_match.group("type") + "caption" + token.meta = {"number": start_match.group("number")} + if attrs is not None: + token.meta["attrs"] = attrs + tkn_inline = state.push("inline", "", 0) + tkn_inline.content = content.strip() + tkn_inline.map = [start_line, max_line] + tkn_inline.children = [] + + state.line = max_line + 1 + + return True + + +def pymd_captions_plugin(md: MarkdownIt) -> None: + md.block.ruler.before( + "fence", + PYMD_CAPTIONS_PREFIX, + _material_captions, + {"alt": ["paragraph"]}, + ) diff --git a/mdformat_mkdocs/plugin.py b/mdformat_mkdocs/plugin.py index 0e07bc7..f34d7df 100644 --- a/mdformat_mkdocs/plugin.py +++ b/mdformat_mkdocs/plugin.py @@ -16,6 +16,7 @@ MKDOCSTRINGS_CROSSREFERENCE_PREFIX, MKDOCSTRINGS_HEADING_AUTOREFS_PREFIX, PYMD_ABBREVIATIONS_PREFIX, + PYMD_CAPTIONS_PREFIX, PYMD_SNIPPET_PREFIX, PYTHON_MARKDOWN_ATTR_LIST_PREFIX, material_admon_plugin, @@ -24,6 +25,7 @@ mkdocstrings_crossreference_plugin, pymd_abbreviations_plugin, pymd_admon_plugin, + pymd_captions_plugin, pymd_snippet_plugin, python_markdown_attr_list_plugin, ) @@ -78,6 +80,7 @@ def add_cli_argument_group(group: argparse._ArgumentGroup) -> None: def update_mdit(mdit: MarkdownIt) -> None: """Update the parser.""" mdit.use(material_admon_plugin) + mdit.use(pymd_captions_plugin) mdit.use(material_content_tabs_plugin) mdit.use(mkdocstrings_autorefs_plugin) mdit.use(pymd_abbreviations_plugin) @@ -179,6 +182,19 @@ def add_extra_admon_newline(node: RenderTreeNode, context: RenderContext) -> str return f"{title}\n\n{''.join(content)}" +def render_pymd_caption(node: RenderTreeNode, context: RenderContext) -> str: + """Render caption with normalized format.""" + caption_type = node.info or "caption" + attrs = node.meta.get("attrs") + number = node.meta.get("number") + rendered_content = "".join( + child.render(context) for child in node.children[0].children + ) + caption_number = f" | {number}" if number else "" + caption_attrs = f"\n {attrs}" if attrs else "" + return f"/// {caption_type}{caption_number}{caption_attrs}\n{rendered_content}\n///" + + # A mapping from syntax tree node type to a function that renders it. # This can be used to overwrite renderer functions of existing syntax # or add support for new syntax. @@ -189,6 +205,7 @@ def add_extra_admon_newline(node: RenderTreeNode, context: RenderContext) -> str "admonition_mkdocs_title": render_admon_title, "content_tab_mkdocs": add_extra_admon_newline, "content_tab_mkdocs_title": render_admon_title, + PYMD_CAPTIONS_PREFIX: render_pymd_caption, MKDOCSTRINGS_AUTOREFS_PREFIX: _render_meta_content, MKDOCSTRINGS_CROSSREFERENCE_PREFIX: _render_cross_reference, MKDOCSTRINGS_HEADING_AUTOREFS_PREFIX: _render_heading_autoref, diff --git a/tests/format/fixtures/text.md b/tests/format/fixtures/text.md index 9e2aa67..e755814 100644 --- a/tests/format/fixtures/text.md +++ b/tests/format/fixtures/text.md @@ -1735,3 +1735,22 @@ Don't wrap long URLs (fixes: https://github.com/KyleKing/mdformat-mkdocs/issues/ - a [with space](https://github.com/python/mypy/blob/a3ce6d5307e99a1b6c181eaa7c5cf134c53b7d/test-data/check-protocols) - b . + +Format captions correctly +. +|a|b| +|-|-| +|c|d| + +/// table-caption | 1.5.2 +A table with letters. +/// +. +|a|b| +|-|-| +|c|d| + +/// table-caption | 1.5.2 +A table with letters. +/// +. diff --git a/tests/format/test_wrap.py b/tests/format/test_wrap.py index 6172c85..61ffe84 100644 --- a/tests/format/test_wrap.py +++ b/tests/format/test_wrap.py @@ -188,6 +188,39 @@ {: .class1 .class2 .class3 .class4 .class5 .class6 .class7 .class8 .class9 .class10 .class11 .class12 .class13 .class14 .class15 .class16 .class17 .class18 .class19 .class20 } """ +CASE_CAPTION_WRAP = """ +This line is longer than 40 characters and should be wrapped. + +``` +def gcd(a, b): + if a == 0: return b + elif b == 0: return a + if a > b: return gcd(a % b, b) + else: return gcd(a, b % a) +``` + +/// caption +Greatest common divisor algorithm. +/// +""" + +CASE_CAPTION_WRAP_TRUE_40 = """ +This line is longer than 40 characters +and should be wrapped. + +``` +def gcd(a, b): + if a == 0: return b + elif b == 0: return a + if a > b: return gcd(a % b, b) + else: return gcd(a, b % a) +``` + +/// caption +Greatest common divisor algorithm. +/// +""" + @pytest.mark.parametrize( ("text", "expected", "align_lists", "wrap"), @@ -200,6 +233,7 @@ (WITH_CODE, WITH_CODE_TRUE_80, True, 80), (WITH_ATTR_LIST, WITH_ATTR_LIST_TRUE_80, True, 80), (CASE_ATTR_LIST_WRAP, CASE_ATTR_LIST_WRAP_TRUE_80, True, 80), + (CASE_CAPTION_WRAP, CASE_CAPTION_WRAP_TRUE_40, True, 40), ], ids=[ "CASE_1_FALSE_40", @@ -210,6 +244,7 @@ "WITH_CODE_TRUE_80", "WITH_ATTR_LIST_TRUE_80", "CASE_ATTR_LIST_WRAP_TRUE_80", + "CASE_CAPTION_WRAP_TRUE_40", ], ) def test_wrap(text: str, expected: str, align_lists: bool, wrap: int): diff --git a/tests/render/fixtures/pymd_captions.md b/tests/render/fixtures/pymd_captions.md new file mode 100644 index 0000000..25c9cd4 --- /dev/null +++ b/tests/render/fixtures/pymd_captions.md @@ -0,0 +1,23 @@ +pymdown captions (https://github.com/KyleKing/mdformat-mkdocs/issues/51) +. +# Captions + +Captioned content. + +/// caption +First caption. +/// + +/// caption +Second caption. +/// +. +

Captions

+

Captioned content.

+
+

First caption.

+
+
+

Second caption.

+
+. diff --git a/tests/render/test_render.py b/tests/render/test_render.py index 5c8ebb4..221896b 100644 --- a/tests/render/test_render.py +++ b/tests/render/test_render.py @@ -10,6 +10,7 @@ mkdocstrings_autorefs_plugin, mkdocstrings_crossreference_plugin, pymd_abbreviations_plugin, + pymd_captions_plugin, pymd_snippet_plugin, python_markdown_attr_list_plugin, ) @@ -32,6 +33,7 @@ def with_plugin(filename, plugins): ), *with_plugin("mkdocstrings_autorefs.md", [mkdocstrings_autorefs_plugin]), *with_plugin("pymd_abbreviations.md", [pymd_abbreviations_plugin]), + *with_plugin("pymd_captions.md", [pymd_captions_plugin]), *with_plugin( "mkdocstrings_crossreference.md", [mkdocstrings_crossreference_plugin],