diff --git a/.copier-answers.yml b/.copier-answers.yml index ed19c28..248fe08 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -2,7 +2,7 @@ # Answer file maintained by Copier for: https://github.com/KyleKing/mdformat-plugin-template # DO NOT MODIFY THIS FILE. Edit by re-running copier and changing responses to the questions # Check into version control. -_commit: 1.0.2 +_commit: 1.1.4 _src_path: gh:KyleKing/mdformat-plugin-template author_email: dev.act.kyle@gmail.com author_name: Kyle King @@ -13,4 +13,5 @@ plugin_name: mkdocs repository_namespace: kyleking repository_provider: https://github.com repository_url: https://github.com/kyleking/mdformat-mkdocs +sync_admon_factories: true diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3cc20de..20946d7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,10 +1,12 @@ --- name: CI + "on": push: branches: [main] tags: [v*] pull_request: null + jobs: pre-commit: runs-on: ubuntu-latest @@ -15,6 +17,7 @@ jobs: with: python-version: 3.12 - uses: pre-commit/action@v3.0.1 + tests: runs-on: ${{ matrix.os }} strategy: @@ -30,22 +33,20 @@ jobs: - name: Installation (deps and package) # We install with flit --pth-file, so that coverage will be recorded for the module # Flit could be installed with pipx and use '--python=$(which python)', but - # there are issues with the Windows Runner + # there were issues with the Windows Runner run: | - pip install flit~=3.9 + pip install flit~=3.10.1 flit install --deps=production --extras=test --pth-file - name: Run pytest run: | - pytest --cov=$(ls | grep "mdformat_" | head) --cov-report=xml --cov-report=term-missing - # Not currently configured - # - name: Upload to Codecov - # if: matrix.os == 'ubuntu-latest' && matrix.python-version == 3.12 - # uses: codecov/codecov-action@v1 + pytest --cov + # # Not currently configured + # - name: Report coverage + # if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12' + # uses: codecov/codecov-action@v4 # with: - # name: pytests-py3.12 - # flags: pytests - # file: ./coverage.xml - # fail_ci_if_error: true + # token: ${{ secrets.CODECOV_TOKEN }} + pre-commit-hook: runs-on: ubuntu-latest steps: @@ -61,6 +62,7 @@ jobs: - name: run pre-commit with plugin run: | pre-commit run --config .pre-commit-test.yaml --all-files --verbose --show-diff-on-failure + publish: name: Publish to PyPi needs: [pre-commit, tests, pre-commit-hook] @@ -74,7 +76,7 @@ jobs: uses: actions/checkout@v4 - name: install flit run: | - pipx install flit~=3.9 + pipx install flit~=3.10.1 - name: Build and publish run: | flit publish diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0ad066b..040475b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,8 +18,6 @@ repos: - id: end-of-file-fixer exclude: \.copier-answers\.yml|__snapshots__/.*\.ambr - id: fix-byte-order-marker - - id: fix-encoding-pragma - args: [--remove] - id: forbid-new-submodules - id: mixed-line-ending args: [--fix=auto] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8d60a4f..16fa9a4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ To install these development dependencies: ```bash pipx install tox -# or: uv tool install tox +# or: uv tool install tox --with tox-uv ``` To run the tests: diff --git a/LICENSE b/LICENSE index 2a08e1c..5847b03 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 Executable Books +Copyright (c) 2024 Kyle King Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/mdformat_mkdocs/__init__.py b/mdformat_mkdocs/__init__.py index 7d1b002..f15d5d9 100644 --- a/mdformat_mkdocs/__init__.py +++ b/mdformat_mkdocs/__init__.py @@ -6,10 +6,4 @@ # https://github.com/executablebooks/mdformat/blob/5d9b573ce33bae219087984dd148894c774f41d4/src/mdformat/plugins.py from .plugin import POSTPROCESSORS, RENDERERS, add_cli_options, update_mdit -__all__ = ( - "POSTPROCESSORS", - "RENDERERS", - "__version__", - "add_cli_options", - "update_mdit", -) +__all__ = ("POSTPROCESSORS", "RENDERERS", "add_cli_options", "update_mdit") diff --git a/mdformat_mkdocs/_postprocess_inline.py b/mdformat_mkdocs/_postprocess_inline.py index 4178e98..dcec336 100644 --- a/mdformat_mkdocs/_postprocess_inline.py +++ b/mdformat_mkdocs/_postprocess_inline.py @@ -41,12 +41,12 @@ def postprocess_list_wrap( ): return text - _counter = -1 + counter_ = -1 parent = node.parent while parent and parent.type == "paragraph": parent = parent.parent - _counter += 1 - indent_count = max(_counter, 0) + counter_ += 1 + indent_count = max(counter_, 0) soft_break = "\x00" text = text.lstrip(soft_break).lstrip() diff --git a/mdformat_mkdocs/_synced/__init__.py b/mdformat_mkdocs/_synced/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mdformat_mkdocs/_synced/admon_factories/README.md b/mdformat_mkdocs/_synced/admon_factories/README.md new file mode 100644 index 0000000..7d2b650 --- /dev/null +++ b/mdformat_mkdocs/_synced/admon_factories/README.md @@ -0,0 +1,25 @@ +# Admonition/Callout Factories + +This code is useful to format and render admonitions similar to Python Markdown's format + +If you are looking to add `mdformat` to your project to format a specific syntax, you will want to use one of the below plugins: + +- [`mdformat-admon`](https://github.com/KyleKing/mdformat-admon) + - [`python-markdown` admonitions](https://python-markdown.github.io/extensions/admonition) +- [`mdformat-mkdocs`](https://github.com/KyleKing/mdformat-mkdocs) + - [MKDocs Admonitions](https://squidfunk.github.io/mkdocs-material/reference/admonitions) +- [`mdformat-gfm-alerts`](https://github.com/KyleKing/mdformat-gfm-alerts) + - Primarily supports [Github "Alerts"](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts), but indirectly also supports + - [Microsoft "Alerts"](https://learn.microsoft.com/en-us/contribute/content/markdown-reference#alerts-note-tip-important-caution-warning) + - [Mozilla Callouts](https://developer.mozilla.org/en-US/docs/MDN/Writing_guidelines/Howto/Markdown_in_MDN#notes_warnings_and_callouts) +- [`mdformat-obsidian`](https://github.com/KyleKing/mdformat-obsidian) + - [Obsidian Callouts](https://help.obsidian.md/How+to/Use+callouts) + +However, directive-style admonition formats are not known to be supported by an existing mdformat plugin nor by the utility code in this directory as it exists today: + +- [node.js markdown-it-container](https://github.com/markdown-it/markdown-it-container) +- [MyST](https://myst-parser.readthedocs.io/en/latest/syntax/roles-and-directives.html) +- [Sphinx Directives](https://www.sphinx-doc.org/en/master/usage/restructuredtext/directives.html) +- [reStructuredText](https://docutils.sourceforge.io/docs/ref/rst/directives.html#specific-admonitions) +- [pymdown-extensions](https://facelessuser.github.io/pymdown-extensions/extensions/blocks/plugins/admonition) +- [PyMDown](https://facelessuser.github.io/pymdown-extensions/extensions/blocks/plugins/admonition) diff --git a/mdformat_mkdocs/_synced/admon_factories/__init__.py b/mdformat_mkdocs/_synced/admon_factories/__init__.py new file mode 100644 index 0000000..1251064 --- /dev/null +++ b/mdformat_mkdocs/_synced/admon_factories/__init__.py @@ -0,0 +1,15 @@ +from ._whitespace_admon_factories import ( + AdmonitionData, + admon_plugin_factory, + new_token, + parse_possible_whitespace_admon_factory, + parse_tag_and_title, +) + +__all__ = ( + "AdmonitionData", + "admon_plugin_factory", + "new_token", + "parse_possible_whitespace_admon_factory", + "parse_tag_and_title", +) diff --git a/mdformat_mkdocs/_synced/admon_factories/_whitespace_admon_factories.py b/mdformat_mkdocs/_synced/admon_factories/_whitespace_admon_factories.py new file mode 100644 index 0000000..8603a5e --- /dev/null +++ b/mdformat_mkdocs/_synced/admon_factories/_whitespace_admon_factories.py @@ -0,0 +1,227 @@ +"""Note, this is ported from `markdown-it-admon` .""" + +from __future__ import annotations + +import re +from collections.abc import Generator, Sequence +from contextlib import contextmanager, suppress +from typing import Callable, NamedTuple + +from markdown_it import MarkdownIt +from markdown_it.renderer import RendererProtocol +from markdown_it.ruler import RuleOptionsType +from markdown_it.rules_block import StateBlock +from markdown_it.rules_inline import StateInline +from markdown_it.token import Token +from markdown_it.utils import EnvType, OptionsDict +from mdit_py_plugins.utils import is_code_block + + +def _get_multiple_tags(meta_text: str) -> tuple[list[str], str]: + """Check for multiple tags when the title is double quoted. + + Raises: + ValueError: if no tags matched + + """ + re_tags = re.compile(r'^\s*(?P[^"]+)\s+"(?P.*)"\S*$') + if match := re_tags.match(meta_text): + tags = match["tokens"].strip().split(" ") + return [tag.lower() for tag in tags], match["title"] + raise ValueError("No match found for parameters") + + +def parse_tag_and_title(admon_meta_text: str) -> tuple[list[str], str]: + """Separate the tag name from the admonition title.""" + if not (meta_text := admon_meta_text.strip()): + return [""], "" + + with suppress(ValueError): + return _get_multiple_tags(meta_text) + + tag, *title_ = meta_text.split(" ") + joined = " ".join(title_) + + title = "" + if not joined: + title = tag.title() + elif joined != '""': # Specifically check for no title + title = joined + return [tag.lower()], title + + +def validate_admon_meta(meta_text: str) -> bool: + """Validate the presence of the tag name after the marker.""" + tag = meta_text.strip().split(" ", 1)[-1] or "" + return bool(tag) + + +class AdmonState(NamedTuple): + """Frozen state using the same variable case.""" + + parentType: str + lineMax: int + blkIndent: int + + +class AdmonitionData(NamedTuple): + """AdmonitionData data for rendering.""" + + old_state: AdmonState + marker: str + markup: str + meta_text: str + next_line: int + + +def search_admon_end(state: StateBlock, start_line: int, end_line: int) -> int: + was_empty = False + + # Search for the end of the block + next_line = start_line + is_fenced = False + while True: + next_line += 1 + if next_line >= end_line: + # unclosed block should be autoclosed by end of document. + # also block seems to be autoclosed by end of parent + break + pos = state.bMarks[next_line] + state.tShift[next_line] + maximum = state.eMarks[next_line] + is_empty = state.sCount[next_line] < state.blkIndent + + # two consecutive empty lines autoclose the block, unless the block is fenced + if not is_fenced and is_empty and was_empty: + break + was_empty = is_empty + + # Check if line starts with ``` + if state.src[pos : pos + 3] == "```": + is_fenced = not is_fenced + + if pos < maximum and state.sCount[next_line] < state.blkIndent: + # non-empty line with negative indent should stop the block: + # - !!! + # test + break + + return next_line + + +def parse_possible_whitespace_admon_factory( + markers: set[str], +) -> Callable[[StateBlock, int, int, bool], AdmonitionData | bool]: + expected_marker_len = 3 # Regardless of extra chars, block indent stays the same + marker_first_chars = {_m[0] for _m in markers} + max_marker_len = max(len(_m) for _m in markers) + + def parse_possible_whitespace_admon( + state: StateBlock, + start_line: int, + end_line: int, + silent: bool, + ) -> AdmonitionData | bool: + if is_code_block(state, start_line): + return False + + start = state.bMarks[start_line] + state.tShift[start_line] + maximum = state.eMarks[start_line] + + # Exit quickly on a non-match for first char + if state.src[start] not in marker_first_chars: + return False + + # Check out the rest of the marker string + marker = "" + marker_len = max_marker_len + marker_pos = 0 + markup = "" + while marker_len > 0: + marker_pos = start + marker_len + if (markup := state.src[start:marker_pos]) in markers: + marker = markup + break + marker_len -= 1 + else: + return False + + admon_meta_text = state.src[marker_pos:maximum] + if not validate_admon_meta(admon_meta_text): + return False + # Since start is found, we can report success here in validation mode + if silent: + return True + + old_state = AdmonState( + parentType=state.parentType, + lineMax=state.lineMax, + blkIndent=state.blkIndent, + ) + state.parentType = "admonition" + + blk_start = marker_pos + while blk_start < maximum and state.src[blk_start] == " ": + blk_start += 1 + + # Correct block indentation when extra marker characters are present + marker_alignment_correction = expected_marker_len - len(marker) + state.blkIndent += blk_start - start + marker_alignment_correction + + next_line = search_admon_end(state, start_line, end_line) + + # this will prevent lazy continuations from ever going past our end marker + state.lineMax = next_line + return AdmonitionData( + old_state=old_state, + marker=marker, + markup=markup, + meta_text=admon_meta_text, + next_line=next_line, + ) + + return parse_possible_whitespace_admon + + +@contextmanager +def new_token( + state: StateBlock | StateInline, + name: str, + kind: str, +) -> Generator[Token, None, None]: + """Create scoped token.""" + yield state.push(f"{name}_open", kind, 1) + state.push(f"{name}_close", kind, -1) + + +def default_render( + self: RendererProtocol, + tokens: Sequence[Token], + idx: int, + _options: OptionsDict, + env: EnvType, +) -> str: + """Render token if no more specific renderer is specified.""" + return self.renderToken(tokens, idx, _options, env) # type: ignore[attr-defined] + + +RenderType = Callable[..., str] + + +def admon_plugin_factory( + prefix: str, + logic: Callable[[StateBlock, int, int, bool], bool], +) -> Callable[[MarkdownIt, RenderType | None], None]: + def admon_plugin(md: MarkdownIt, render: RenderType | None = None) -> None: + render = render or default_render + + md.add_render_rule(f"{prefix}_open", render) + md.add_render_rule(f"{prefix}_close", render) + md.add_render_rule(f"{prefix}_title_open", render) + md.add_render_rule(f"{prefix}_title_close", render) + + options: RuleOptionsType = { + "alt": ["paragraph", "reference", "blockquote", "list"], + } + md.block.ruler.before("fence", prefix, logic, options) + + return admon_plugin diff --git a/mdformat_mkdocs/mdit_plugins/__init__.py b/mdformat_mkdocs/mdit_plugins/__init__.py index 8dda6cb..565d1d9 100644 --- a/mdformat_mkdocs/mdit_plugins/__init__.py +++ b/mdformat_mkdocs/mdit_plugins/__init__.py @@ -15,6 +15,7 @@ mkdocstrings_crossreference_plugin, ) from ._pymd_abbreviations import PYMD_ABBREVIATIONS_PREFIX, pymd_abbreviations_plugin +from ._python_markdown_admon import python_markdown_admon_plugin __all__ = ( "MATERIAL_ADMON_MARKERS", @@ -28,4 +29,5 @@ "mkdocstrings_autorefs_plugin", "mkdocstrings_crossreference_plugin", "pymd_abbreviations_plugin", + "python_markdown_admon_plugin", ) diff --git a/mdformat_mkdocs/mdit_plugins/_material_admon.py b/mdformat_mkdocs/mdit_plugins/_material_admon.py index d73c77d..0f92fde 100644 --- a/mdformat_mkdocs/mdit_plugins/_material_admon.py +++ b/mdformat_mkdocs/mdit_plugins/_material_admon.py @@ -19,14 +19,16 @@ from typing import Any from markdown_it.rules_block import StateBlock -from mdformat_admon.factories import ( + +from mdformat_mkdocs._synced.admon_factories import ( AdmonitionData, admon_plugin_factory, new_token, parse_possible_whitespace_admon_factory, parse_tag_and_title, ) -from mdformat_admon.mdit_plugins import format_python_markdown_admon_markup + +from ._python_markdown_admon import format_python_markdown_admon_markup MATERIAL_ADMON_PREFIX = "admonition_mkdocs" """Prefix used to differentiate the parsed output.""" diff --git a/mdformat_mkdocs/mdit_plugins/_material_content_tabs.py b/mdformat_mkdocs/mdit_plugins/_material_content_tabs.py index 7e51a30..1fa8137 100644 --- a/mdformat_mkdocs/mdit_plugins/_material_content_tabs.py +++ b/mdformat_mkdocs/mdit_plugins/_material_content_tabs.py @@ -31,7 +31,8 @@ """ from markdown_it.rules_block import StateBlock -from mdformat_admon.factories import ( + +from mdformat_mkdocs._synced.admon_factories import ( AdmonitionData, admon_plugin_factory, new_token, diff --git a/mdformat_mkdocs/mdit_plugins/_mkdocstrings_autorefs.py b/mdformat_mkdocs/mdit_plugins/_mkdocstrings_autorefs.py index 6efe54a..3411598 100644 --- a/mdformat_mkdocs/mdit_plugins/_mkdocstrings_autorefs.py +++ b/mdformat_mkdocs/mdit_plugins/_mkdocstrings_autorefs.py @@ -18,7 +18,8 @@ from markdown_it import MarkdownIt from markdown_it.rules_block import StateBlock from markdown_it.rules_inline import StateInline -from mdformat_admon.factories import new_token + +from mdformat_mkdocs._synced.admon_factories import new_token _AUTOREFS_PATTERN = re.compile(r"\[\]\(<?>?\){#(?P<anchor>[^ }]+)}") _HEADING_PATTERN = re.compile(r"(?P<markdown>^#{1,6}) (?P<content>.+)") diff --git a/mdformat_mkdocs/mdit_plugins/_mkdocstrings_crossreference.py b/mdformat_mkdocs/mdit_plugins/_mkdocstrings_crossreference.py index ae7ebf8..c8dd3dd 100644 --- a/mdformat_mkdocs/mdit_plugins/_mkdocstrings_crossreference.py +++ b/mdformat_mkdocs/mdit_plugins/_mkdocstrings_crossreference.py @@ -15,7 +15,8 @@ from markdown_it import MarkdownIt from markdown_it.rules_inline import StateInline -from mdformat_admon.factories import new_token + +from mdformat_mkdocs._synced.admon_factories import new_token _CROSSREFERENCE_PATTERN = re.compile(r"\[(?P<link>[^[|\]\n]+)\]\[(?P<href>[^\]\n]*)\]") MKDOCSTRINGS_CROSSREFERENCE_PREFIX = "mkdocstrings_crossreference" diff --git a/mdformat_mkdocs/mdit_plugins/_pymd_abbreviations.py b/mdformat_mkdocs/mdit_plugins/_pymd_abbreviations.py index 8f9f602..8458f27 100644 --- a/mdformat_mkdocs/mdit_plugins/_pymd_abbreviations.py +++ b/mdformat_mkdocs/mdit_plugins/_pymd_abbreviations.py @@ -18,9 +18,10 @@ from markdown_it import MarkdownIt from markdown_it.rules_block import StateBlock -from mdformat_admon.factories import new_token from mdit_py_plugins.utils import is_code_block +from mdformat_mkdocs._synced.admon_factories import new_token + _ABBREVIATION_PATTERN = re.compile( r"\\?\*\\?\[(?P<label>[^\]\\]+)\\?\]: (?P<description>.+)", ) diff --git a/mdformat_mkdocs/mdit_plugins/_python_markdown_admon.py b/mdformat_mkdocs/mdit_plugins/_python_markdown_admon.py new file mode 100644 index 0000000..50c536a --- /dev/null +++ b/mdformat_mkdocs/mdit_plugins/_python_markdown_admon.py @@ -0,0 +1,85 @@ +"""Python-Markdown Admonition Plugin. + +Copied from: https://github.com/KyleKing/mdformat-admon/blob/a5c965f867cda2256b3259f36ec36dda3b4bf831/mdformat_admon/mdit_plugins/_python_markdown_admon.py + +""" + +from markdown_it.rules_block import StateBlock + +from mdformat_mkdocs._synced.admon_factories import ( + AdmonitionData, + admon_plugin_factory, + new_token, + parse_possible_whitespace_admon_factory, + parse_tag_and_title, +) + +PREFIX = "admonition" +"""Prefix used to differentiate the parsed output.""" + + +def format_python_markdown_admon_markup( + state: StateBlock, + start_line: int, + admonition: AdmonitionData, +) -> None: + """Format markup.""" + tags, title = parse_tag_and_title(admonition.meta_text) + tag = tags[0] + + with new_token(state, PREFIX, "div") as token: + token.markup = admonition.markup + token.block = True + token.attrs = {"class": " ".join(["admonition", *tags])} + token.meta = {"tag": tag} + token.info = admonition.meta_text + token.map = [start_line, admonition.next_line] + + if title: + title_markup = f"{admonition.markup} {tag}" + with new_token(state, f"{PREFIX}_title", "p") as tkn_title: + tkn_title.markup = title_markup + tkn_title.attrs = {"class": "admonition-title"} + tkn_title.map = [start_line, start_line + 1] + + tkn_inline = state.push("inline", "", 0) + tkn_inline.content = title + tkn_inline.map = [start_line, start_line + 1] + tkn_inline.children = [] + + state.md.block.tokenize(state, start_line + 1, admonition.next_line) + + state.parentType = admonition.old_state.parentType + state.lineMax = admonition.old_state.lineMax + state.blkIndent = admonition.old_state.blkIndent + state.line = admonition.next_line + + +def admonition_logic( + state: StateBlock, + start_line: int, + end_line: int, + silent: bool, +) -> bool: + """Parse Python Markdown-style Admonitions. + + `python-markdown style admonitions + <https://python-markdown.github.io/extensions/admonition>`. + + .. code-block:: md + + !!! note + *content* + + """ + parse_possible_whitespace_admon = parse_possible_whitespace_admon_factory( + markers={"!!!"}, + ) + result = parse_possible_whitespace_admon(state, start_line, end_line, silent) + if isinstance(result, AdmonitionData): + format_python_markdown_admon_markup(state, start_line, admonition=result) + return True + return result + + +python_markdown_admon_plugin = admon_plugin_factory(PREFIX, admonition_logic) diff --git a/mdformat_mkdocs/plugin.py b/mdformat_mkdocs/plugin.py index f292673..c3d5db6 100644 --- a/mdformat_mkdocs/plugin.py +++ b/mdformat_mkdocs/plugin.py @@ -2,6 +2,7 @@ from __future__ import annotations +import textwrap from argparse import ArgumentParser from collections.abc import Mapping from functools import partial @@ -10,7 +11,6 @@ from markdown_it import MarkdownIt from mdformat.renderer import DEFAULT_RENDERERS, RenderContext, RenderTreeNode from mdformat.renderer.typing import Postprocess, Render -from mdformat_admon import RENDERERS as ADMON_RENDERS from ._normalize_list import normalize_list as unbounded_normalize_list from ._postprocess_inline import postprocess_list_wrap @@ -24,6 +24,7 @@ mkdocstrings_autorefs_plugin, mkdocstrings_crossreference_plugin, pymd_abbreviations_plugin, + python_markdown_admon_plugin, ) ContextOptions = Mapping[str, Any] @@ -68,6 +69,7 @@ def update_mdit(mdit: MarkdownIt) -> None: mdit.use(material_content_tabs_plugin) mdit.use(mkdocstrings_autorefs_plugin) mdit.use(pymd_abbreviations_plugin) + mdit.use(python_markdown_admon_plugin) if cli_is_ignore_missing_references(mdit.options): mdit.use(mkdocstrings_crossreference_plugin) @@ -122,14 +124,57 @@ def _render_cross_reference(node: RenderTreeNode, context: RenderContext) -> str return _render_with_default_renderer(node, context, "link") +# Start: copied from mdformat-admon + + +def render_admon(node: RenderTreeNode, context: RenderContext) -> str: + """Render a `RenderTreeNode` of type `admonition`.""" + prefix = node.markup.split(" ")[0] + title = node.info.strip() + title_line = f"{prefix} {title}" + + elements = [render for child in node.children if (render := child.render(context))] + separator = "\n\n" + + # Then indent to either 3 or 4 based on the length of the prefix + # For reStructuredText, '..' should be indented 3-spaces + # While '!!!', , '...', '???', '???+', etc. are indented 4-spaces + indent = " " * (min(len(prefix), 3) + 1) + content = textwrap.indent(separator.join(elements), indent) + + return title_line + "\n" + content if content else title_line + + +def render_admon_title( + node: RenderTreeNode, # noqa: ARG001 + context: RenderContext, # noqa: ARG001 +) -> str: + """Skip rendering the title when called from the `node.children`.""" + return "" + + +# End: copied from mdformat-admon + + +def add_extra_admon_newline(node: RenderTreeNode, context: RenderContext) -> str: + """Return admonition with additional newline after the title for mkdocs.""" + result = render_admon(node, context) + if "\n" not in result: + return result + title, *content = result.split("\n", maxsplit=1) + return f"{title}\n\n{''.join(content)}" + + # 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. RENDERERS: Mapping[str, Render] = { - "admonition_mkdocs": ADMON_RENDERS["admonition"], - "admonition_mkdocs_title": ADMON_RENDERS["admonition_title"], - "content_tab_mkdocs": ADMON_RENDERS["admonition"], - "content_tab_mkdocs_title": ADMON_RENDERS["admonition_title"], + "admonition": add_extra_admon_newline, + "admonition_title": render_admon_title, + "admonition_mkdocs": add_extra_admon_newline, + "admonition_mkdocs_title": render_admon_title, + "content_tab_mkdocs": add_extra_admon_newline, + "content_tab_mkdocs_title": render_admon_title, MKDOCSTRINGS_AUTOREFS_PREFIX: _render_meta_content, MKDOCSTRINGS_HEADING_AUTOREFS_PREFIX: _render_heading_autoref, MKDOCSTRINGS_CROSSREFERENCE_PREFIX: _render_cross_reference, diff --git a/pyproject.toml b/pyproject.toml index 0834fb3..32f167c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,6 @@ classifiers = [ ] dependencies = [ "mdformat >= 0.7.19", - "mdformat-admon >= 2.0.6", "mdformat-gfm >= 0.3.6", "mdit-py-plugins >= 0.4.1", "more-itertools >= 10.5.0", @@ -29,7 +28,6 @@ requires-python = ">=3.9.0" mkdocs = "mdformat_mkdocs" [project.optional-dependencies] -dev = ["pre-commit"] recommended = [ # Keep in-sync with README "mdformat-beautysh >= 0.1.1", @@ -45,9 +43,9 @@ recommended = [ "setuptools", ] test = [ - "pytest >= 8.3.3", - "pytest-beartype >= 0.1.0", - "pytest-cov >= 5.0.0", + "pytest >= 8.3.4", + "pytest-beartype >= 0.1.1", + "pytest-cov >= 6.0.0", "syrupy >= 4.7.2", ] @@ -86,7 +84,7 @@ ignore_patterns = [] now = true patterns = ["*.ambr", "*.md", "*.py"] runner = "tox" -runner_args = ["-e", "py312-beartype"] +runner_args = ["-e", "py312-test", "--", "--exitfirst", "--failed-first", "--new-first", "-vv", "--beartype-packages=mdformat_mkdocs", "--snapshot-update"] [tool.ruff] # Docs: https://github.com/charliermarsh/ruff @@ -98,13 +96,12 @@ target-version = 'py39' ignore = [ 'ANN002', # Missing type annotation for `*args` 'ANN003', # Missing type annotation for `**kwargs` - 'ANN101', # Missing type annotation for `self` in method (automatically inferred) - 'ANN102', # Missing type annotation for `cls` in classmethod (automatically inferred) 'BLE001', # Do not catch blind exception: `Exception` 'CPY001', # Missing copyright notice at top of file 'D203', # "1 blank line required before class docstring" (Conflicts with D211) 'D213', # "Multi-line docstring summary should start at the second line" (Conflicts with D212) - 'DOC201', # PLANNED: https://github.com/astral-sh/ruff/issues/12434#issuecomment-2304325741 + 'DOC201', # `return` is not documented in docstring + 'DOC402', # `yield` is not documented in docstring 'EM101', # Exception must not use a string literal, assign to variable first 'FBT001', # Boolean-typed positional argument in function definition 'FIX001', # Line contains FIXME @@ -114,8 +111,8 @@ ignore = [ 'N815', # Variable `lineMax` in class scope should not be mixedCase 'PLR0913', # Too many arguments in function definition (6 > 5) 'S101', # Use of `assert` detected - 'TCH002', # Move third-party import `mdformat.renderer.typing.Postprocess` into a type-checking block (for beartype) - 'TCH003', # Move standard library import `argparse` into a type-checking block (for beartype) + 'TC002', # Move third-party import `mdformat.renderer.typing.Postprocess` into a type-checking block (for beartype) + 'TC003', # Move standard library import `argparse` into a type-checking block (for beartype) 'TD001', # Invalid TODO tag: `FIXME` 'TD002', # Missing author in TODO; try: `# TODO(<author_name>): ...` 'TD003', # Missing issue link on the line following this TODO @@ -153,3 +150,52 @@ convention = "google" all = true in_place = true trailing_comma_inline_array = true + +[tool.tomlsort.overrides."tool.pytest-watcher.*"] +inline_arrays = false + +[tool.tomlsort.overrides."tool.tox.env.*"] +inline_arrays = false + +[tool.tox] +# Docs: https://tox.wiki/en/4.23.2/config.html#core +basepython = ["python3.12", "python3.9"] +env_list = ["py312-pre-commit", "py312-ruff", "py312-test", "py312-type", "py39-hook", "py39-test"] +isolated_build = true +requires = ["tox>=4.20.0"] +skip_missing_interpreters = false + +[tool.tox.env."py312-pre-commit"] +commands = [["pre-commit", "run", "--all-files", {default = [], extend = true, replace = "posargs"}]] +deps = "pre-commit>=4.0.1" +skip_install = true + +[tool.tox.env."py312-ruff"] +commands = [ + ["ruff", "check", ".", "--fix", {default = [], extend = true, replace = "posargs"}], + ["ruff", "format", "."], +] +deps = "ruff>=0.8.3" +description = "Optionally, specify: '--unsafe-fixes'" +skip_install = true + +[tool.tox.env."py312-test"] +commands = [["pytest", "--cov=mdformat_mkdocs", {default = [], extend = true, replace = "posargs"}]] +description = "Optionally, specify: '--exitfirst --failed-first --new-first -vv --beartype-packages=mdformat_mkdocs --snapshot-update" +extras = ["test"] + +[tool.tox.env."py312-type"] +commands = [["mypy", "./mdformat_mkdocs", {default = [], extend = true, replace = "posargs"}]] +deps = ["mypy>=1.13.0"] + +[tool.tox.env."py39-hook"] +commands = [["pre-commit", "run", "--config=.pre-commit-test.yaml", "--all-files", {default = ["--show-diff-on-failure", "--verbose"], extend = true, replace = "posargs"}]] +deps = "pre-commit>=4.0.1" + +[tool.tox.env."py39-test"] +commands = [["pytest", "--cov=mdformat_mkdocs"]] +extras = ["test"] + +[tool.tox.env_run_base] +# Validates that commands are set +commands = [["error-commands-are-not-set"]] diff --git a/tests/format/fixtures/material_content_tabs.md b/tests/format/fixtures/material_content_tabs.md index 2be12c6..3b66436 100644 --- a/tests/format/fixtures/material_content_tabs.md +++ b/tests/format/fixtures/material_content_tabs.md @@ -20,6 +20,7 @@ Do not modify multi-line code from: https://github.com/KyleKing/mdformat-mkdocs/ ``` . === "duty" + ```python title="duties.py" @duty(silent=True) def coverage(ctx): diff --git a/tests/format/fixtures/text.md b/tests/format/fixtures/text.md index 5d8f945..a262014 100644 --- a/tests/format/fixtures/text.md +++ b/tests/format/fixtures/text.md @@ -352,12 +352,14 @@ Nested Python Classes. Resolves #13: https://github.com/KyleKing/mdformat-mkdocs . -Simple admonition +Simple admonition. Verify that the extra mkdocs newline is inserted . !!! note + *content* . !!! note + *content* . @@ -365,12 +367,14 @@ Simple admonition Could contain block elements too . !!! note + ### heading ----------- . !!! note + ### heading ______________________________________________________________________ @@ -386,6 +390,7 @@ Shows custom title . !!! note Custom title + Some text . @@ -398,6 +403,7 @@ Shows no title . !!! note "" + Some text . @@ -410,6 +416,7 @@ Removes extra quotes from the title . !!! danger "Don't try this at home" + ... . @@ -422,6 +429,7 @@ Parse additional classes to support Python markdown (https://github.com/executab . !!! a b c d inline-classes "Note: note about "foo"" + ... . @@ -436,6 +444,7 @@ Closes block after 2 empty lines A code block . !!! note + Some text ``` @@ -453,7 +462,9 @@ Nested blocks code block . !!! note + !!! note + Some text ``` @@ -480,6 +491,7 @@ Marker may be indented up to 3 chars content . !!! note + content . @@ -520,8 +532,8 @@ Type could be adjacent to marker . !!! note - xxx + xxx . @@ -532,6 +544,7 @@ Type could be adjacent to marker and content may be shifted up to 3 chars . !!! note + xxx . @@ -543,6 +556,7 @@ Or several spaces apart xxx . !!! note + xxx . @@ -553,11 +567,12 @@ Admonitions self-close at the end of the document xxx . !!! note + xxx . -These are not admonitions +or in a list somehow? . - !!! note - a @@ -567,9 +582,11 @@ These are not admonitions - d . - !!! note + - a - b - !!! warning + - c - d . @@ -584,6 +601,7 @@ Or in blockquotes > . > !!! note +> > xxx > > > yyy @@ -597,17 +615,28 @@ Renders unknown admonition type content . !!! unknown title + content . -Does not render +Does not render as admonition . !!! content + +!!! + + content . !!! content + +!!! + +``` +content +``` . @@ -617,6 +646,7 @@ MkDocs Closed Collapsible Sections content . ??? note + content . @@ -627,6 +657,7 @@ MkDocs Open Collapsible Sections content . ???+ note + content . @@ -640,6 +671,7 @@ Formats non-root lists 1. d . !!! note + 1. a 1. b 1. c @@ -674,12 +706,15 @@ Ultralytics commands use the following syntax: Ultralytics commands use the following syntax: !!! Example + === "CLI" + ```bash yolo TASK MODE ARGS ``` === "Python" + ```python from ultralytics import YOLO @@ -721,6 +756,7 @@ Ultralytics commands use the following syntax: ### 优化和压缩数据集的示例代码 !!! Example "优化和压缩数据集" + === "Python" ```python @@ -772,7 +808,9 @@ Ultralytics commands use the following syntax: ### 优化和压缩数据集的示例代码 !!! Example "优化和压缩数据集" + === "Python" + ```python from pathlib import Path from ultralytics.data.utils import compress_one_image @@ -820,7 +858,9 @@ If you use the Caltech-101 dataset in your research or development work, please If you use the Caltech-101 dataset in your research or development work, please cite the following paper: !!! Quote "" + === "BibTeX" + ```bibtex @article{fei2007learning, title={Learning generative visual models from few training examples: An incremental Bayesian approach tested on 101 object categories}, @@ -898,6 +938,7 @@ Example from Ultralytics Documentation (https://github.com/ultralytics/ultralyti ``` ???+ tip "Ultralytics Install" + See the Ultralytics [Quickstart](../quickstart.md/#install-ultralytics) Installation section for a quick walkthrough on installing the required libraries. ______________________________________________________________________ @@ -915,6 +956,7 @@ Example from Ultralytics Documentation (https://github.com/ultralytics/ultralyti ``` ??? question "No Prediction Arguments?" + Without specifying a source, the example images from the library will be used: ``` @@ -1054,7 +1096,9 @@ Example from Ultralytics Documentation (https://github.com/ultralytics/ultralyti ### Object Isolation Options !!! example "" + === "Black Background Pixels" + ```py # Create 3-channel mask mask3ch = cv.cvtColor(b_mask, cv.COLOR_GRAY2BGR) @@ -1065,6 +1109,7 @@ Example from Ultralytics Documentation (https://github.com/ultralytics/ultralyti ``` ??? question "How does this work?" + - First, the binary mask is first converted from a single-channel image to a three-channel image. This conversion is necessary for the subsequent step where the mask and the original image are combined. Both images must have the same number of channels to be compatible with the blending operation. - The original image and the three-channel binary mask are merged using the OpenCV function `bitwise_and()`. This operation retains <u>only</u> pixel values that are greater than zero `(> 0)` from both images. Since the mask pixels are greater than zero `(> 0)` <u>only</u> within the contour region, the pixels remaining from the original image are those that overlap with the contour. @@ -1072,6 +1117,7 @@ Example from Ultralytics Documentation (https://github.com/ultralytics/ultralyti ### Isolate with Black Pixels: Sub-options ??? info "Full-size Image" + There are no additional steps required if keeping full size image. <figure markdown> @@ -1080,6 +1126,7 @@ Example from Ultralytics Documentation (https://github.com/ultralytics/ultralyti </figure> ??? info "Cropped object Image" + Additional steps required to crop image to only include object region. ![Example Crop Isolated Object Image Black Background](https://github.com/ultralytics/ultralytics/assets/62214284/103dbf90-c169-4f77-b791-76cdf09c6f22){ align="right" } @@ -1095,6 +1142,7 @@ Example from Ultralytics Documentation (https://github.com/ultralytics/ultralyti 1. For more information on bounding box results, see [Boxes Section from Predict Mode](../modes/predict.md/#boxes) ??? question "What does this code do?" + - The `c.boxes.xyxy.cpu().numpy()` call retrieves the bounding boxes as a NumPy array in the `xyxy` format, where `xmin`, `ymin`, `xmax`, and `ymax` represent the coordinates of the bounding box rectangle. See [Boxes Section from Predict Mode](../modes/predict.md/#boxes) for more details. - The `squeeze()` operation removes any unnecessary dimensions from the NumPy array, ensuring it has the expected shape. @@ -1104,6 +1152,7 @@ Example from Ultralytics Documentation (https://github.com/ultralytics/ultralyti - Finally, the bounding box region is cropped from the image using index slicing. The bounds are defined by the `[ymin:ymax, xmin:xmax]` coordinates of the detection bounding box. === "Transparent Background Pixels" + ```py # Isolate object with transparent background (when saved as PNG) isolated = np.dstack([img, b_mask]) @@ -1111,11 +1160,13 @@ Example from Ultralytics Documentation (https://github.com/ultralytics/ultralyti ``` ??? question "How does this work?" + - Using the NumPy `dstack()` function (array stacking along depth-axis) in conjunction with the binary mask generated, will create an image with four channels. This allows for all pixels outside of the object contour to be transparent when saving as a `PNG` file. ### Isolate with Transparent Pixels: Sub-options ??? info "Full-size Image" + There are no additional steps required if keeping full size image. <figure markdown> @@ -1124,6 +1175,7 @@ Example from Ultralytics Documentation (https://github.com/ultralytics/ultralyti </figure> ??? info "Cropped object Image" + Additional steps required to crop image to only include object region. ![Example Crop Isolated Object Image No Background](https://github.com/ultralytics/ultralytics/assets/62214284/5910244f-d1e1-44af-af7f-6dea4c688da8){ align="right" } @@ -1139,6 +1191,7 @@ Example from Ultralytics Documentation (https://github.com/ultralytics/ultralyti 1. For more information on bounding box results, see [Boxes Section from Predict Mode](../modes/predict.md/#boxes) ??? question "What does this code do?" + - When using `c.boxes.xyxy.cpu().numpy()`, the bounding boxes are returned as a NumPy array, using the `xyxy` box coordinates format, which correspond to the points `xmin, ymin, xmax, ymax` for the bounding box (rectangle), see [Boxes Section from Predict Mode](../modes/predict.md/#boxes) for more information. - Adding `squeeze()` ensures that any extraneous dimensions are removed from the NumPy array. @@ -1148,6 +1201,7 @@ Example from Ultralytics Documentation (https://github.com/ultralytics/ultralyti - Finally the image region for the bounding box is cropped using index slicing, where the bounds are set using the `[ymin:ymax, xmin:xmax]` coordinates of the detection bounding box. ??? question "What if I want the cropped object **including** the background?" + This is a built in feature for the Ultralytics library. See the `save_crop` argument for [Predict Mode Inference Arguments](../modes/predict.md/#inference-arguments) for details. ______________________________________________________________________ @@ -1169,11 +1223,13 @@ Example of non-code content from Material-MkDocs documentation without admonitio 3. Nulla tempor lobortis orci . === "Unordered list" + - Sed sagittis eleifend rutrum - Donec vitae suscipit est - Nulla tempor lobortis orci === "Ordered list" + 1. Sed sagittis eleifend rutrum 1. Donec vitae suscipit est 1. Nulla tempor lobortis orci @@ -1198,7 +1254,9 @@ Example from Material-MkDocs documentation within an admonition ``` . !!! example + === "Unordered List" + ```markdown * Sed sagittis eleifend rutrum * Donec vitae suscipit est @@ -1206,6 +1264,7 @@ Example from Material-MkDocs documentation within an admonition ``` === "Ordered List" + ```markdown 1. Sed sagittis eleifend rutrum 2. Donec vitae suscipit est @@ -1235,20 +1294,24 @@ Example with '===!' (break) from <https://facelessuser.github.io/pymdown-extensi ``` . === "Tab 1" + Markdown **content**. Multiple paragraphs. === "Tab 2" + More Markdown **content**. - list item a - list item b ===! "Tab A" + Different tab set. === "Tab B" + ``` More content. ``` @@ -1272,17 +1335,20 @@ Example with '===+' (active) from <https://facelessuser.github.io/pymdown-extens Another Tab . === "Not Me" + Markdown **content**. Multiple paragraphs. ===+ "Select Me" + More Markdown **content**. - list item a - list item b === "Not Me Either" + Another Tab . @@ -1292,33 +1358,25 @@ More complex example to validate formatting when nested 1. List Outer ???+ Note - === "First" - Markdown **content**. Multiple paragraphs. ??? "Second" - Markdown **content**. Multiple paragraphs. ===+ "Third" - - List Item - - Another Item === "Fourth" - - List Item - - Another Item ===! "Lastly a new item" - Markdown **content** for last item. Very last indented paragraph. @@ -1328,27 +1386,32 @@ More complex example to validate formatting when nested 1. List Outer ???+ Note + === "First" + Markdown **content**. Multiple paragraphs. ??? "Second" + Markdown **content**. Multiple paragraphs. ===+ "Third" + - List Item - Another Item === "Fourth" - - List Item + - List Item - Another Item ===! "Lastly a new item" + Markdown **content** for last item. Very last indented paragraph. @@ -1376,6 +1439,7 @@ Deterministic indents for HTML ### Object Isolation Options ??? info "Full-size Image" + There are no additional steps required if keeping full size image. <figure markdown> diff --git a/tests/format/test_format.py b/tests/format/test_format.py index eea441a..eeef244 100644 --- a/tests/format/test_format.py +++ b/tests/format/test_format.py @@ -35,7 +35,7 @@ def flatten(nested_list: list[list[T]]) -> list[T]: fixtures, ids=[f[1] for f in fixtures], ) -def test_material_content_tabs_fixtures(line, title, text, expected): - output = mdformat.text(text, extensions={"mkdocs", "admon"}) +def test_format_fixtures(line, title, text, expected): + output = mdformat.text(text, extensions={"mkdocs"}) print_text(output, expected) assert output.rstrip() == expected.rstrip() diff --git a/tests/format/test_tabbed_code_block.py b/tests/format/test_tabbed_code_block.py index d2a2339..dabb01d 100644 --- a/tests/format/test_tabbed_code_block.py +++ b/tests/format/test_tabbed_code_block.py @@ -24,6 +24,6 @@ class RecurringEventSerializer(serializers.ModelSerializer): # (1)! ids=["TABBED_CODE_BLOCK"], ) def test_tabbed_code_block(text: str, expected: str): - output = mdformat.text(text, extensions={"mkdocs", "admon"}) + output = mdformat.text(text, extensions={"mkdocs"}) print_text(output, expected) assert output.strip() == expected.strip() diff --git a/tests/helpers.py b/tests/helpers.py index 3897751..ea29807 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -16,7 +16,7 @@ def separate_indent(line: str) -> tuple[str, str]: """ re_indent = re.compile(r"(?P<indent>\s*)(?P<content>[^\s]?.*)") match = re_indent.match(line) - assert match # for pyright + assert match is not None # for pyright return (match["indent"], match["content"]) diff --git a/tests/render/fixtures/matieral_admonitions.md b/tests/render/fixtures/matieral_admonitions.md index aa7b3ca..6965daa 100644 --- a/tests/render/fixtures/matieral_admonitions.md +++ b/tests/render/fixtures/matieral_admonitions.md @@ -1,6 +1,7 @@ Simple admonition . !!! note + *content* . <div class="admonition note"> @@ -13,6 +14,7 @@ Simple admonition Simple admonition without title . !!! note "" + *content* . <div class="admonition note"> @@ -21,19 +23,22 @@ Simple admonition without title . -Does not render +Does not render as admonition . !!! + content . -<p>!!! -content</p> +<p>!!!</p> +<pre><code>content +</code></pre> . MKdocs Closed Collapsible Sections . ??? note + content . <details class="note"> @@ -46,6 +51,7 @@ MKdocs Closed Collapsible Sections MKdocs Open Collapsible Sections . ???+ note + content . <details class="note" open="open"> diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 9618610..0000000 --- a/tox.ini +++ /dev/null @@ -1,40 +0,0 @@ -[tox] -envlist = - py{312}-beartype - py{39}-cov - py{312}-pre-commit - py{39}-hook - py{312}-mypy - py{312}-ruff -isolated_build = True -skip_missing_interpreters = False - -[testenv:py{312}-beartype] -extras = test -commands = pytest {posargs} --ff --nf -vv --exitfirst --beartype-packages='mdformat_mkdocs' - -[testenv:py{39}-snapshot-update] -extras = test -commands = pytest {posargs} --snapshot-update - -[testenv:py{39}-cov] -extras = test -commands = pytest --cov=mdformat_mkdocs {posargs} - -[testenv:py{312}-pre-commit] -extras = dev -commands = pre-commit run {posargs:--all-files} - -[testenv:py{39}-hook] -extras = dev -commands = pre-commit run --config .pre-commit-test.yaml {posargs:--all-files --verbose --show-diff-on-failure} - -[testenv:py{312}-mypy] -deps = mypy>=1.13.0 -commands = mypy ./mdformat_mkdocs - -[testenv:py{312}-ruff] -deps = ruff>=0.7.1 -commands = - ruff check . --fix --unsafe-fixes - ruff format .