From 07d3a4f7317ed95a5ffad1e5da72c88fc9100b15 Mon Sep 17 00:00:00 2001 From: Gonghan-Princess <268298391+Gonghan-Princess@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:26:45 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20NEW:=20Configure=20front=20matter?= =?UTF-8?q?=20delimiter=20characters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 ++ mdit_py_plugins/front_matter/index.py | 42 ++++++++++++--- tests/test_front_matter.py | 78 +++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1b78b3..a649e9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Change Log +## Unreleased + +- ✨ NEW: Allow configuring the front matter delimiter character with `marker` (#134). + ## 0.7.0 - 2026-07-19 - ✨ NEW: Add section reference plugin (`section_ref`) (#144) diff --git a/mdit_py_plugins/front_matter/index.py b/mdit_py_plugins/front_matter/index.py index 1551d76..a785d5f 100644 --- a/mdit_py_plugins/front_matter/index.py +++ b/mdit_py_plugins/front_matter/index.py @@ -1,16 +1,18 @@ """Process front matter.""" +from functools import partial + from markdown_it import MarkdownIt from markdown_it.rules_block import StateBlock from mdit_py_plugins.utils import is_code_block -def front_matter_plugin(md: MarkdownIt) -> None: +def front_matter_plugin(md: MarkdownIt, *, marker: str = "-") -> None: """Plugin ported from `markdown-it-front-matter `__. - It parses initial metadata, stored between opening/closing dashes: + It parses initial metadata, stored between opening/closing markers: .. code-block:: md @@ -18,19 +20,47 @@ def front_matter_plugin(md: MarkdownIt) -> None: valid-front-matter: true --- + For example, to extract TOML-style front matter: + + .. code-block:: python + + md = MarkdownIt().use(front_matter_plugin, marker="+") + tokens = md.parse('+++\\ntitle = "Hello"\\n+++\\n# Heading') + assert tokens[0].content == 'title = "Hello"' + + :param marker: Single non-whitespace character used for the delimiters, + excluding NUL (which Markdown normalizes before parsing). + At least three repetitions are required; the closing delimiter must + be at least as long as the opening delimiter. Defaults to ``-`` for + YAML-style front matter. Use ``+`` for TOML-style ``+++`` delimiters + or ``;`` for JSON-style ``;;;`` delimiters. The content is returned + as raw text, without decoding YAML, TOML, or JSON. The YAML ``...`` + terminator is recognized only with the default marker. + :raises ValueError: If the marker is not a single non-whitespace character, + or is NUL. + """ + if len(marker) != 1 or marker.isspace() or marker == "\x00": + raise ValueError( + "marker must be a single non-whitespace character other than NUL" + ) + md.block.ruler.before( "table", "front_matter", - _front_matter_rule, + partial(_front_matter_rule, marker_chr=marker), {"alt": ["paragraph", "reference", "blockquote", "list"]}, ) def _front_matter_rule( - state: StateBlock, startLine: int, endLine: int, silent: bool + state: StateBlock, + startLine: int, + endLine: int, + silent: bool, + *, + marker_chr: str = "-", ) -> bool: - marker_chr = "-" min_markers = 3 auto_closed = False @@ -69,7 +99,7 @@ def _front_matter_rule( # unclosed block should be autoclosed by end of document. return False - if state.src[start:maximum] == "...": + if marker_chr == "-" and state.src[start:maximum] == "...": break start = state.bMarks[nextLine] + state.tShift[nextLine] diff --git a/tests/test_front_matter.py b/tests/test_front_matter.py index 627cb89..79c0843 100644 --- a/tests/test_front_matter.py +++ b/tests/test_front_matter.py @@ -47,3 +47,81 @@ def test_short_source(): # The code should not raise an IndexError. assert md.parse("-") + + +@pytest.mark.parametrize( + "marker,content", [("+", 'title = "Hello"'), (";", '{"title": "Hello"}')] +) +def test_custom_marker(marker, content): + md = MarkdownIt("commonmark").use(front_matter_plugin, marker=marker) + source = f"{marker * 3}\n{content}\n{marker * 3}\n# Head" + tokens = md.parse(source) + + assert tokens[0].type == "front_matter" + assert tokens[0].content == content + assert tokens[0].markup == marker * 3 + assert tokens[0].map == [0, 3] + assert tokens[0].hidden + assert tokens[0].block + assert md.render(source) == "\n

Head

\n" + + +@pytest.mark.parametrize( + "opening,closing", [("+++", "+++"), ("++++", "+++++"), ("+++ ", "+++\t ")] +) +def test_custom_marker_empty_metadata(opening, closing): + md = MarkdownIt().use(front_matter_plugin, marker="+") + tokens = md.parse(f"{opening}\n{closing}") + assert len(tokens) == 1 + assert tokens[0].type == "front_matter" + assert tokens[0].content == "" + assert tokens[0].map == [0, 2] + + +@pytest.mark.parametrize( + "source", + [ + "", + "+", + "++", + "++\na: 1\n++", + "+++\na: 1", + "+++\na: 1\n---", + "++++\na: 1\n+++", + "+++\na: 1\n+++ text", + " +++\na: 1\n+++", + "\n+++\na: 1\n+++", + "# Head\n+++\na: 1\n+++", + ], +) +def test_custom_marker_not_front_matter(source): + md = MarkdownIt().use(front_matter_plugin, marker="+") + assert all(token.type != "front_matter" for token in md.parse(source)) + + +def test_custom_marker_keeps_yaml_terminator_as_content(): + md = MarkdownIt().use(front_matter_plugin, marker="+") + content = 'text = """\n...\n"""' + source = f"+++\n{content}\n+++\n# Head" + tokens = md.parse(source) + assert tokens[0].content == content + assert tokens[0].map == [0, 5] + assert md.render(source) == "\n

Head

\n" + + +def test_marker_configuration_is_per_parser(): + default = MarkdownIt().use(front_matter_plugin) + custom = MarkdownIt().use(front_matter_plugin, marker="+") + yaml_source = "---\nx: 1\n---" + toml_source = "+++\nx = 1\n+++" + + assert default.parse(yaml_source)[0].type == "front_matter" + assert custom.parse(toml_source)[0].type == "front_matter" + assert all(token.type != "front_matter" for token in default.parse(toml_source)) + assert all(token.type != "front_matter" for token in custom.parse(yaml_source)) + + +@pytest.mark.parametrize("marker", ["", "++", " ", "\t", "\n", "\x00"]) +def test_invalid_marker(marker): + with pytest.raises(ValueError, match="single non-whitespace character"): + MarkdownIt().use(front_matter_plugin, marker=marker)