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: 3 additions & 1 deletion src/mistune/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ def create_markdown(
hard_wrap: bool = False,
renderer: Optional[RendererRef] = "html",
plugins: Optional[Iterable[PluginRef]] = None,
enable_enhanced: bool = False,
) -> Markdown:
"""Create a Markdown instance based on the given condition.

:param escape: Boolean. If using html renderer, escape html.
:param hard_wrap: Boolean. Break every new line into ``<br>``.
:param renderer: renderer instance, default is HTMLRenderer.
:param plugins: List of plugins.
:param enable_enhanced: Boolean. Enable enhanced features like tabs and tip containers.

This method is used when you want to re-use a Markdown instance::

Expand All @@ -52,7 +54,7 @@ def create_markdown(
real_plugins: Optional[Iterable[Plugin]] = None
if plugins is not None:
real_plugins = [import_plugin(n) for n in plugins]
return Markdown(renderer=renderer, inline=inline, plugins=real_plugins)
return Markdown(renderer=renderer, inline=inline, plugins=real_plugins, enable_enhanced=enable_enhanced)


html: Markdown = create_markdown(escape=False, plugins=["strikethrough", "footnotes", "table", "speedup"])
Expand Down
10 changes: 10 additions & 0 deletions src/mistune/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ def _md(args: argparse.Namespace) -> "Markdown":
# default plugins
plugins = ["strikethrough", "footnotes", "table", "speedup"]

if args.enhanced:
from .directives import EnhancedDirective, TabsDirective, TipDirective
plugins.append(EnhancedDirective([TabsDirective(), TipDirective()]))

if args.renderer == "rst":
renderer: "BaseRenderer" = RSTRenderer()
elif args.renderer == "markdown":
Expand All @@ -30,6 +34,7 @@ def _md(args: argparse.Namespace) -> "Markdown":
hard_wrap=args.hardwrap,
renderer=renderer,
plugins=plugins,
enable_enhanced=args.enhanced,
)


Expand Down Expand Up @@ -101,6 +106,11 @@ def cli() -> None:
default="html",
help="specify the output renderer",
)
parser.add_argument(
"--enhanced",
action="store_true",
help="enable enhanced features like tabs and tip containers",
)
parser.add_argument("--version", action="version", version="mistune " + version)
args = parser.parse_args()

Expand Down
51 changes: 51 additions & 0 deletions src/mistune/block_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,38 @@ def parse_list(self, m: Match[str], state: BlockState) -> int:
def parse_block_html(self, m: Match[str], state: BlockState) -> Optional[int]:
return self.parse_raw_html(m, state)

def _process_container_relationships(self, tokens: list, container_stack: list) -> None:
"""Process tokens to establish parent-child relationships between containers and code blocks."""
# Track current container hierarchy
current_containers = []

for token in tokens:
if token is None:
continue
if 'type' in token:
if token['type'] in ['tabs', 'tip']:
# Add this container to the hierarchy
current_containers.append(token)
# Set container level
token['level'] = len(current_containers)

# Process children recursively
if 'children' in token:
self._process_container_relationships(token['children'], container_stack)

# Remove from hierarchy when done
current_containers.pop()
elif token['type'] == 'block_code':
# For code blocks, associate with current container
if current_containers:
token['container'] = current_containers[-1]['type']
# If in a tabs container, add language information
if current_containers[-1]['type'] == 'tabs':
info = token.get('attrs', {}).get('info', '')
if info:
lang = info.split(None, 1)[0]
token['language'] = lang

def parse_raw_html(self, m: Match[str], state: BlockState) -> Optional[int]:
marker = m.group(0).strip()

Expand Down Expand Up @@ -444,6 +476,11 @@ def parse_raw_html(self, m: Match[str], state: BlockState) -> Optional[int]:
def parse(self, state: BlockState, rules: Optional[List[str]] = None) -> None:
sc = self.compile_sc(rules)

# Track container hierarchy for tabs and code blocks
container_stack = []
if state.container_type:
container_stack.append(state.container_type)

while state.cursor < state.cursor_max:
m = sc.search(state.src, state.cursor)
if not m:
Expand All @@ -455,6 +492,17 @@ def parse(self, state: BlockState, rules: Optional[List[str]] = None) -> None:
state.add_paragraph(text)
state.cursor = end_pos

# Check if we're entering a new container
lastgroup = m.lastgroup
if lastgroup == 'enhanced_directive':
# Extract directive type from match
directive_text = m.group(0)
import re
directive_match = re.match(r'^:{3,}[ \t]*(\w+)', directive_text)
if directive_match:
directive_type = directive_match.group(1)
container_stack.append(directive_type)

end_pos2 = self.parse_method(m, state)
if end_pos2:
state.cursor = end_pos2
Expand All @@ -469,6 +517,9 @@ def parse(self, state: BlockState, rules: Optional[List[str]] = None) -> None:
state.add_paragraph(text)
state.cursor = state.cursor_max

# Post-process tokens to establish parent-child relationships
self._process_container_relationships(state.tokens, container_stack)


def _parse_html_to_end(state: BlockState, end_marker: str, start_pos: int) -> int:
marker_pos = state.src.find(end_marker, start_pos)
Expand Down
8 changes: 5 additions & 3 deletions src/mistune/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ class BlockState:
list_tight: bool
parent: Any
env: MutableMapping[str, Any]
container_type: Optional[str]

def __init__(self, parent: Optional[Any] = None) -> None:
def __init__(self, parent: Optional[Any] = None, container_type: Optional[str] = None) -> None:
self.src = ""
self.tokens = []

Expand All @@ -48,15 +49,16 @@ def __init__(self, parent: Optional[Any] = None) -> None:
# for list and block quote chain
self.list_tight = True
self.parent = parent
self.container_type = container_type

# for saving def references
if parent:
self.env = parent.env
else:
self.env = {"ref_links": {}}

def child_state(self, src: str) -> "BlockState":
child = self.__class__(self)
def child_state(self, src: str, container_type: Optional[str] = None) -> "BlockState":
child = self.__class__(self, container_type)
child.process(src)
return child

Expand Down
4 changes: 4 additions & 0 deletions src/mistune/directives/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from .image import Figure, Image
from .include import Include
from .toc import TableOfContents
from .enhanced import EnhancedDirective, TabsDirective, TipDirective


class RstDirective(RSTDirective): # pragma: no cover
Expand All @@ -32,4 +33,7 @@ def __init__(self, plugins: List[DirectivePlugin]) -> None:
"Include",
"Image",
"Figure",
"EnhancedDirective",
"TabsDirective",
"TipDirective",
]
166 changes: 166 additions & 0 deletions src/mistune/directives/enhanced.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import re
from typing import List, Match, Optional, Dict, Any

from ._base import BaseDirective, DirectiveParser, DirectivePlugin

if False:
from ..block_parser import BlockParser
from ..core import BlockState
from ..markdown import Markdown


__all__ = ["EnhancedDirective", "TabsDirective", "TipDirective"]


def parse_options(text: str) -> Dict[str, str]:
"""Parse options from directive content."""
options = {}
if not text:
return options
for line in text.splitlines():
line = line.strip()
if not line or not line.startswith(':'):
continue
parts = line.split(':', 2)
if len(parts) < 3:
continue
key = parts[1].strip()
value = parts[2].strip()
options[key] = value
return options


class EnhancedParser(DirectiveParser):
name = "enhanced_directive"

@staticmethod
def parse_type(m: Match[str]) -> str:
return m.group("type")

@staticmethod
def parse_title(m: Match[str]) -> str:
return m.group("title").strip() if m.group("title") else ""

@staticmethod
def parse_content(m: Match[str]) -> str:
return m.group("text").strip() if m.group("text") else ""

@staticmethod
def parse_options(m: Match[str]) -> Dict[str, str]:
return parse_options(m.group("options"))


class TabsDirective(DirectivePlugin):
"""Directive for tabs container."""

name = "tabs"

def parse(self, block: "BlockParser", m: Match[str], state: "BlockState") -> None:
title = m.group("title").strip() if m.group("title") else ""
options = parse_options(m.group("options"))
content = m.group("text").strip() if m.group("text") else ""

# Create a child state to parse the content
child = state.child_state(content, container_type="tabs")
block.parse(child)

token = {
"type": "tabs",
"title": title,
"options": options,
"children": child.tokens,
}
state.append_token(token)

def __call__(self, directive: BaseDirective, md: "Markdown") -> None:
directive.register(self.name, self.parse)


class TipDirective(DirectivePlugin):
"""Directive for tip container."""

name = "tip"

def parse(self, block: "BlockParser", m: Match[str], state: "BlockState") -> None:
title = m.group("title").strip() if m.group("title") else ""
options = parse_options(m.group("options"))
content = m.group("text").strip() if m.group("text") else ""

# Create a child state to parse the content
child = state.child_state(content, container_type="tip")
block.parse(child)

token = {
"type": "tip",
"title": title,
"options": options,
"children": child.tokens,
}
state.append_token(token)

def __call__(self, directive: BaseDirective, md: "Markdown") -> None:
directive.register(self.name, self.parse)


class EnhancedDirective(BaseDirective):
"""Enhanced directive parser for block-level containers like ::: tabs and ::: tip."""

parser = EnhancedParser

def __init__(self, plugins: List[DirectivePlugin], markers: str = ":") -> None:
super(EnhancedDirective, self).__init__(plugins)
self.markers = markers
_marker_pattern = "|".join(re.escape(c) for c in markers)
self.directive_pattern = (
r"^(?P<enhanced_directive_mark>(?:" + _marker_pattern + r"){3,})"
r"[ \t]*(?P<type>[a-zA-Z0-9_-]+)"
r"[ \t]*(?P<title>[^\n]*)"
r"(?:\n|$)"
r"(?P<options>(?:\:[a-zA-Z0-9_-]+\: *[^\n]*\n+)*)"
r"\n*(?P<text>(?:[^\n]*\n+)*)"
)
self._directive_re = re.compile(self.directive_pattern, re.M)

def _process_directive(self, block: "BlockParser", marker: str, start: int, state: "BlockState") -> Optional[int]:
mlen = len(marker)
cursor_start = start + len(marker)

_end_pattern = (
r"^ {0,3}" + marker[0] + "{" + str(mlen) + r",}"
r"[ \t]*(?:\n|$)"
)
_end_re = re.compile(_end_pattern, re.M)

_end_m = _end_re.search(state.src, cursor_start)
if _end_m:
text = state.src[cursor_start : _end_m.start()]
end_pos = _end_m.end()
else:
text = state.src[cursor_start:]
end_pos = state.cursor_max

# Parse the directive content
directive_re = re.compile(
r"^[ \t]*(?P<type>[a-zA-Z0-9_-]+)[ \t]*(?P<title>[^\n]*)(?:\n|$)(?P<options>(?:\:[a-zA-Z0-9_-]+\: *[^\n]*\n+)*)\n*(?P<text>(?:[^\n]*\n+)*)",
re.DOTALL
)
m = directive_re.match(text)
if not m:
return None

self.parse_method(block, m, state)
return end_pos

def parse_directive(self, block: "BlockParser", m: Match[str], state: "BlockState") -> Optional[int]:
marker = m.group("enhanced_directive_mark")
return self._process_directive(block, marker, m.start(), state)

def __call__(self, md: "Markdown") -> None:
super(EnhancedDirective, self).__call__(md)
# Register the directive pattern
md.block.register(
"enhanced_directive",
r"^(?P<enhanced_directive_mark>:{3,})[ \t]*(?P<type>[a-zA-Z0-9_-]+)",
self.parse_directive,
before="fenced_code"
)
Loading