diff --git a/mdit_py_plugins/admon/index.py b/mdit_py_plugins/admon/index.py index a9efeef..fe58b82 100644 --- a/mdit_py_plugins/admon/index.py +++ b/mdit_py_plugins/admon/index.py @@ -1,13 +1,13 @@ # Process admonitions and pass to cb. -import math -from typing import Callable, Optional, Tuple +from typing import Callable, List, Optional, Tuple from markdown_it import MarkdownIt from markdown_it.rules_block import StateBlock -def get_tag(params: str) -> Tuple[str, str]: +def _get_tag(params: str) -> Tuple[str, str]: + """Separate the tag name from the admonition title.""" if not params.strip(): return "", "" @@ -22,15 +22,25 @@ def get_tag(params: str) -> Tuple[str, str]: return tag.lower(), title -def validate(params: str) -> bool: +def _validate(params: str) -> bool: + """Validate the presence of the tag name after the marker.""" tag = params.strip().split(" ", 1)[-1] or "" return bool(tag) -MIN_MARKERS = 3 -MARKER_STR = "!" -MARKER_CHAR = ord(MARKER_STR) -MARKER_LEN = len(MARKER_STR) +MARKER_LEN = 3 # Regardless of extra characters, block indent stays the same +MARKERS = ("!!!", "???", "???+") +MARKER_CHARS = {_m[0] for _m in MARKERS} +MAX_MARKER_LEN = max(len(_m) for _m in MARKERS) + + +def _extra_classes(markup: str) -> List[str]: + """Return the list of additional classes based on the markup.""" + if markup.startswith("?"): + if markup.endswith("+"): + return ["is-collapsible collapsible-open"] + return ["is-collapsible collapsible-closed"] + return [] def admonition(state: StateBlock, startLine: int, endLine: int, silent: bool) -> bool: @@ -38,22 +48,25 @@ def admonition(state: StateBlock, startLine: int, endLine: int, silent: bool) -> maximum = state.eMarks[startLine] # Check out the first character quickly, which should filter out most of non-containers - if ord(state.src[start]) != MARKER_CHAR: + if state.src[start] not in MARKER_CHARS: return False # Check out the rest of the marker string - pos = start + 1 - while pos <= maximum and MARKER_STR[(pos - start) % MARKER_LEN] == state.src[pos]: - pos += 1 - - marker_count = math.floor((pos - start) / MARKER_LEN) - if marker_count < MIN_MARKERS: + marker = "" + marker_len = MAX_MARKER_LEN + while marker_len > 0: + marker_pos = start + marker_len + markup = state.src[start:marker_pos] + if markup in MARKERS: + marker = markup + break + marker_len -= 1 + else: return False - marker_pos = pos - ((pos - start) % MARKER_LEN) + params = state.src[marker_pos:maximum] - markup = state.src[start:marker_pos] - if not validate(params): + if not _validate(params): return False # Since start is found, we can report success here in validation mode @@ -64,12 +77,14 @@ def admonition(state: StateBlock, startLine: int, endLine: int, silent: bool) -> old_line_max = state.lineMax old_indent = state.blkIndent - blk_start = pos + blk_start = marker_pos while blk_start < maximum and state.src[blk_start] == " ": blk_start += 1 state.parentType = "admonition" - state.blkIndent += blk_start - start + # Correct block indentation when extra marker characters are present + marker_alignment_correction = MARKER_LEN - len(marker) + state.blkIndent += blk_start - start + marker_alignment_correction was_empty = False @@ -99,12 +114,12 @@ def admonition(state: StateBlock, startLine: int, endLine: int, silent: bool) -> # this will prevent lazy continuations from ever going past our end marker state.lineMax = next_line - tag, title = get_tag(params) + tag, title = _get_tag(params) token = state.push("admonition_open", "div", 1) token.markup = markup token.block = True - token.attrs = {"class": f"admonition {tag}"} + token.attrs = {"class": " ".join(["admonition", tag, *_extra_classes(markup)])} token.meta = {"tag": tag} token.content = title token.info = params @@ -123,12 +138,11 @@ def admonition(state: StateBlock, startLine: int, endLine: int, silent: bool) -> token.children = [] token = state.push("admonition_title_close", "p", -1) - token.markup = title_markup state.md.block.tokenize(state, startLine + 1, next_line) token = state.push("admonition_close", "div", -1) - token.markup = state.src[start:pos] + token.markup = markup token.block = True state.parentType = old_parent @@ -149,6 +163,14 @@ def admon_plugin(md: MarkdownIt, render: Optional[Callable] = None) -> None: !!! note *content* + `And mkdocs-style collapsible blocks + `_. + + .. code-block:: md + + ???+ note + *content* + Note, this is ported from `markdown-it-admon `_. diff --git a/tests/fixtures/admon.md b/tests/fixtures/admon.md index 587ae80..91057a5 100644 --- a/tests/fixtures/admon.md +++ b/tests/fixtures/admon.md @@ -267,3 +267,28 @@ Does not render

!!! content

. + + + +MKdocs Closed Collapsible Sections +. +??? note + content +. +
+

Note

+

content

+
+. + + +MKdocs Open Collapsible Sections +. +???+ note + content +. +
+

Note

+

content

+
+. diff --git a/tests/test_admon.py b/tests/test_admon.py index efd0ee8..8e246c4 100644 --- a/tests/test_admon.py +++ b/tests/test_admon.py @@ -1,4 +1,5 @@ from pathlib import Path +from textwrap import dedent from markdown_it import MarkdownIt from markdown_it.utils import read_fixture_file @@ -19,3 +20,15 @@ def test_all(line, title, input, expected): text = md.render(input) print(text) assert text.rstrip() == expected.rstrip() + + +@pytest.mark.parametrize("text_idx", (0, 1, 2)) +def test_plugin_parse(data_regression, text_idx): + texts = [ + "!!! note\n content 1", + "??? note\n content 2", + "???+ note\n content 3", + ] + md = MarkdownIt().use(admon_plugin) + tokens = md.parse(dedent(texts[text_idx])) + data_regression.check([t.as_dict() for t in tokens]) diff --git a/tests/test_admon/test_plugin_parse_0_.yml b/tests/test_admon/test_plugin_parse_0_.yml new file mode 100644 index 0000000..3272842 --- /dev/null +++ b/tests/test_admon/test_plugin_parse_0_.yml @@ -0,0 +1,145 @@ +- attrs: + - - class + - admonition note + block: true + children: null + content: Note + hidden: false + info: ' note' + level: 0 + map: + - 0 + - 2 + markup: '!!!' + meta: + tag: note + nesting: 1 + tag: div + type: admonition_open +- attrs: + - - class + - admonition-title + block: true + children: null + content: '' + hidden: false + info: '' + level: 1 + map: + - 0 + - 1 + markup: '!!! note' + meta: {} + nesting: 1 + tag: p + type: admonition_title_open +- attrs: null + block: true + children: + - attrs: null + block: false + children: null + content: Note + hidden: false + info: '' + level: 0 + map: null + markup: '' + meta: {} + nesting: 0 + tag: '' + type: text + content: Note + hidden: false + info: '' + level: 2 + map: + - 0 + - 1 + markup: '' + meta: {} + nesting: 0 + tag: '' + type: inline +- attrs: null + block: true + children: null + content: '' + hidden: false + info: '' + level: 1 + map: null + markup: '' + meta: {} + nesting: -1 + tag: p + type: admonition_title_close +- attrs: null + block: true + children: null + content: '' + hidden: false + info: '' + level: 1 + map: + - 1 + - 2 + markup: '' + meta: {} + nesting: 1 + tag: p + type: paragraph_open +- attrs: null + block: true + children: + - attrs: null + block: false + children: null + content: content 1 + hidden: false + info: '' + level: 0 + map: null + markup: '' + meta: {} + nesting: 0 + tag: '' + type: text + content: content 1 + hidden: false + info: '' + level: 2 + map: + - 1 + - 2 + markup: '' + meta: {} + nesting: 0 + tag: '' + type: inline +- attrs: null + block: true + children: null + content: '' + hidden: false + info: '' + level: 1 + map: null + markup: '' + meta: {} + nesting: -1 + tag: p + type: paragraph_close +- attrs: null + block: true + children: null + content: '' + hidden: false + info: '' + level: 0 + map: null + markup: '!!!' + meta: {} + nesting: -1 + tag: div + type: admonition_close diff --git a/tests/test_admon/test_plugin_parse_1_.yml b/tests/test_admon/test_plugin_parse_1_.yml new file mode 100644 index 0000000..3a8c206 --- /dev/null +++ b/tests/test_admon/test_plugin_parse_1_.yml @@ -0,0 +1,145 @@ +- attrs: + - - class + - admonition note is-collapsible collapsible-closed + block: true + children: null + content: Note + hidden: false + info: ' note' + level: 0 + map: + - 0 + - 2 + markup: ??? + meta: + tag: note + nesting: 1 + tag: div + type: admonition_open +- attrs: + - - class + - admonition-title + block: true + children: null + content: '' + hidden: false + info: '' + level: 1 + map: + - 0 + - 1 + markup: ??? note + meta: {} + nesting: 1 + tag: p + type: admonition_title_open +- attrs: null + block: true + children: + - attrs: null + block: false + children: null + content: Note + hidden: false + info: '' + level: 0 + map: null + markup: '' + meta: {} + nesting: 0 + tag: '' + type: text + content: Note + hidden: false + info: '' + level: 2 + map: + - 0 + - 1 + markup: '' + meta: {} + nesting: 0 + tag: '' + type: inline +- attrs: null + block: true + children: null + content: '' + hidden: false + info: '' + level: 1 + map: null + markup: '' + meta: {} + nesting: -1 + tag: p + type: admonition_title_close +- attrs: null + block: true + children: null + content: '' + hidden: false + info: '' + level: 1 + map: + - 1 + - 2 + markup: '' + meta: {} + nesting: 1 + tag: p + type: paragraph_open +- attrs: null + block: true + children: + - attrs: null + block: false + children: null + content: content 2 + hidden: false + info: '' + level: 0 + map: null + markup: '' + meta: {} + nesting: 0 + tag: '' + type: text + content: content 2 + hidden: false + info: '' + level: 2 + map: + - 1 + - 2 + markup: '' + meta: {} + nesting: 0 + tag: '' + type: inline +- attrs: null + block: true + children: null + content: '' + hidden: false + info: '' + level: 1 + map: null + markup: '' + meta: {} + nesting: -1 + tag: p + type: paragraph_close +- attrs: null + block: true + children: null + content: '' + hidden: false + info: '' + level: 0 + map: null + markup: ??? + meta: {} + nesting: -1 + tag: div + type: admonition_close diff --git a/tests/test_admon/test_plugin_parse_2_.yml b/tests/test_admon/test_plugin_parse_2_.yml new file mode 100644 index 0000000..bfada8e --- /dev/null +++ b/tests/test_admon/test_plugin_parse_2_.yml @@ -0,0 +1,145 @@ +- attrs: + - - class + - admonition note is-collapsible collapsible-open + block: true + children: null + content: Note + hidden: false + info: ' note' + level: 0 + map: + - 0 + - 2 + markup: ???+ + meta: + tag: note + nesting: 1 + tag: div + type: admonition_open +- attrs: + - - class + - admonition-title + block: true + children: null + content: '' + hidden: false + info: '' + level: 1 + map: + - 0 + - 1 + markup: ???+ note + meta: {} + nesting: 1 + tag: p + type: admonition_title_open +- attrs: null + block: true + children: + - attrs: null + block: false + children: null + content: Note + hidden: false + info: '' + level: 0 + map: null + markup: '' + meta: {} + nesting: 0 + tag: '' + type: text + content: Note + hidden: false + info: '' + level: 2 + map: + - 0 + - 1 + markup: '' + meta: {} + nesting: 0 + tag: '' + type: inline +- attrs: null + block: true + children: null + content: '' + hidden: false + info: '' + level: 1 + map: null + markup: '' + meta: {} + nesting: -1 + tag: p + type: admonition_title_close +- attrs: null + block: true + children: null + content: '' + hidden: false + info: '' + level: 1 + map: + - 1 + - 2 + markup: '' + meta: {} + nesting: 1 + tag: p + type: paragraph_open +- attrs: null + block: true + children: + - attrs: null + block: false + children: null + content: content 3 + hidden: false + info: '' + level: 0 + map: null + markup: '' + meta: {} + nesting: 0 + tag: '' + type: text + content: content 3 + hidden: false + info: '' + level: 2 + map: + - 1 + - 2 + markup: '' + meta: {} + nesting: 0 + tag: '' + type: inline +- attrs: null + block: true + children: null + content: '' + hidden: false + info: '' + level: 1 + map: null + markup: '' + meta: {} + nesting: -1 + tag: p + type: paragraph_close +- attrs: null + block: true + children: null + content: '' + hidden: false + info: '' + level: 0 + map: null + markup: ???+ + meta: {} + nesting: -1 + tag: div + type: admonition_close