Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
42 changes: 36 additions & 6 deletions mdit_py_plugins/front_matter/index.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,66 @@
"""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 <https://github.com/ParkSB/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

---
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
Expand Down Expand Up @@ -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]
Expand Down
78 changes: 78 additions & 0 deletions tests/test_front_matter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<h1>Head</h1>\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<h1>Head</h1>\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)