diff --git a/src/mistune/__init__.py b/src/mistune/__init__.py
index 1f466bc..4e6f773 100644
--- a/src/mistune/__init__.py
+++ b/src/mistune/__init__.py
@@ -25,6 +25,7 @@ def create_markdown(
hard_wrap: bool = False,
renderer: Optional[RendererRef] = "html",
plugins: Optional[Iterable[PluginRef]] = None,
+ inject_tabs_assets: bool = False,
) -> Markdown:
"""Create a Markdown instance based on the given condition.
@@ -32,12 +33,14 @@ def create_markdown(
:param hard_wrap: Boolean. Break every new line into ``
``.
:param renderer: renderer instance, default is HTMLRenderer.
:param plugins: List of plugins.
+ :param inject_tabs_assets: Boolean. Inject CSS/JS for tabs support.
This method is used when you want to re-use a Markdown instance::
markdown = create_markdown(
escape=False,
hard_wrap=True,
+ inject_tabs_assets=True,
)
# re-use markdown function
markdown('.... your text ...')
@@ -46,19 +49,24 @@ def create_markdown(
# explicit and more similar to 2.x's API
renderer = None
elif renderer == "html":
- renderer = HTMLRenderer(escape=escape)
+ renderer = HTMLRenderer(escape=escape, inject_tabs_assets=inject_tabs_assets)
inline = InlineParser(hard_wrap=hard_wrap)
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,
+ inject_tabs_assets=inject_tabs_assets
+ )
html: Markdown = create_markdown(escape=False, plugins=["strikethrough", "footnotes", "table", "speedup"])
-__cached_parsers: Dict[Tuple[bool, Optional[RendererRef], Optional[Iterable[Any]]], Markdown] = {}
+__cached_parsers: Dict[Tuple[bool, bool, Optional[RendererRef], Optional[Iterable[Any]]], Markdown] = {}
def markdown(
@@ -66,15 +74,21 @@ def markdown(
escape: bool = True,
renderer: Optional[RendererRef] = "html",
plugins: Optional[Iterable[Any]] = None,
+ inject_tabs_assets: bool = False,
) -> Union[str, List[Dict[str, Any]]]:
if renderer == "ast":
# explicit and more similar to 2.x's API
renderer = None
- key = (escape, renderer, plugins)
+ key = (escape, inject_tabs_assets, renderer, plugins)
if key in __cached_parsers:
return __cached_parsers[key](text)
- md = create_markdown(escape=escape, renderer=renderer, plugins=plugins)
+ md = create_markdown(
+ escape=escape,
+ renderer=renderer,
+ plugins=plugins,
+ inject_tabs_assets=inject_tabs_assets
+ )
# improve the speed for markdown parser creation
__cached_parsers[key] = md
return md(text)
diff --git a/src/mistune/__main__.py b/src/mistune/__main__.py
index 42ed842..489a042 100644
--- a/src/mistune/__main__.py
+++ b/src/mistune/__main__.py
@@ -18,6 +18,10 @@ def _md(args: argparse.Namespace) -> "Markdown":
else:
# default plugins
plugins = ["strikethrough", "footnotes", "table", "speedup"]
+
+ if args.tabs or args.tip:
+ plugins = list(plugins) if plugins else []
+ plugins.append("container")
if args.renderer == "rst":
renderer: "BaseRenderer" = RSTRenderer()
@@ -30,6 +34,7 @@ def _md(args: argparse.Namespace) -> "Markdown":
hard_wrap=args.hardwrap,
renderer=renderer,
plugins=plugins,
+ inject_tabs_assets=args.inject_assets,
)
@@ -53,6 +58,10 @@ def _output(text: str, args: argparse.Namespace) -> None:
$ cat README.md | python -m mistune
... + +Container syntax examples: + $ python -m mistune --tabs --inject-assets -m "::: tip\\nThis is a tip\\n:::" + $ python -m mistune --tip -f document.md """ @@ -101,6 +110,21 @@ def cli() -> None: default="html", help="specify the output renderer", ) + parser.add_argument( + "--tabs", + action="store_true", + help="enable tabs container support (::: tabs syntax)", + ) + parser.add_argument( + "--tip", + action="store_true", + help="enable tip container support (::: tip, ::: warning, etc.)", + ) + parser.add_argument( + "--inject-assets", + action="store_true", + help="inject CSS and JavaScript for interactive tabs/tips", + ) parser.add_argument("--version", action="version", version="mistune " + version) args = parser.parse_args() diff --git a/src/mistune/block_parser.py b/src/mistune/block_parser.py index 055d42e..ccbc5cf 100644 --- a/src/mistune/block_parser.py +++ b/src/mistune/block_parser.py @@ -1,5 +1,5 @@ import re -from typing import Optional, List, Tuple, Match, Pattern +from typing import Optional, List, Tuple, Match, Pattern, Callable, Any, Dict import string from .util import ( unikey, @@ -34,6 +34,13 @@ _STRICT_BLOCK_QUOTE = re.compile(r"( {0,3}>[^\n]*(?:\n|$))+") +BeforeParseHook = Callable[["BlockParser", BlockState], None] +AfterParseHook = Callable[["BlockParser", BlockState], None] +BeforeTokenHook = Callable[["BlockParser", Match[str], BlockState, str], Optional[int]] +AfterTokenHook = Callable[["BlockParser", Dict[str, Any], BlockState, str], None] +ContainerHook = Callable[["BlockParser", str, Dict[str, Any], BlockState], None] + + class BlockParser(Parser[BlockState]): state_cls = BlockState @@ -107,9 +114,91 @@ def __init__( self.block_quote_rules = block_quote_rules self.list_rules = list_rules self.max_nested_level = max_nested_level - # register default parse methods + + self._before_parse_hooks: List[BeforeParseHook] = [] + self._after_parse_hooks: List[AfterParseHook] = [] + self._before_token_hooks: Dict[str, List[BeforeTokenHook]] = {} + self._after_token_hooks: Dict[str, List[AfterTokenHook]] = {} + self._container_enter_hooks: Dict[str, List[ContainerHook]] = {} + self._container_exit_hooks: Dict[str, List[ContainerHook]] = {} + self._methods = {name: getattr(self, "parse_" + name) for name in self.SPECIFICATION} + def add_before_parse_hook(self, hook: BeforeParseHook) -> None: + """Add a hook that runs before parsing starts.""" + self._before_parse_hooks.append(hook) + + def add_after_parse_hook(self, hook: AfterParseHook) -> None: + """Add a hook that runs after parsing completes.""" + self._after_parse_hooks.append(hook) + + def add_before_token_hook(self, token_type: str, hook: BeforeTokenHook) -> None: + """Add a hook that runs before a specific token type is parsed.""" + if token_type not in self._before_token_hooks: + self._before_token_hooks[token_type] = [] + self._before_token_hooks[token_type].append(hook) + + def add_after_token_hook(self, token_type: str, hook: AfterTokenHook) -> None: + """Add a hook that runs after a specific token type is parsed.""" + if token_type not in self._after_token_hooks: + self._after_token_hooks[token_type] = [] + self._after_token_hooks[token_type].append(hook) + + def add_container_enter_hook(self, container_type: str, hook: ContainerHook) -> None: + """Add a hook that runs when entering a container.""" + if container_type not in self._container_enter_hooks: + self._container_enter_hooks[container_type] = [] + self._container_enter_hooks[container_type].append(hook) + + def add_container_exit_hook(self, container_type: str, hook: ContainerHook) -> None: + """Add a hook that runs when exiting a container.""" + if container_type not in self._container_exit_hooks: + self._container_exit_hooks[container_type] = [] + self._container_exit_hooks[container_type].append(hook) + + def _run_before_parse_hooks(self, state: BlockState) -> None: + """Run all before-parse hooks.""" + for hook in self._before_parse_hooks: + hook(self, state) + + def _run_after_parse_hooks(self, state: BlockState) -> None: + """Run all after-parse hooks.""" + for hook in self._after_parse_hooks: + hook(self, state) + + def _run_before_token_hooks(self, token_type: str, m: Match[str], state: BlockState) -> Optional[int]: + """Run all before-token hooks for a specific token type.""" + hooks = self._before_token_hooks.get(token_type, []) + for hook in hooks: + result = hook(self, m, state, token_type) + if result is not None: + return result + return None + + def _run_after_token_hooks(self, token_type: str, token: Dict[str, Any], state: BlockState) -> None: + """Run all after-token hooks for a specific token type.""" + hooks = self._after_token_hooks.get(token_type, []) + for hook in hooks: + hook(self, token, state, token_type) + + def _run_container_enter_hooks(self, container_type: str, attrs: Dict[str, Any], state: BlockState) -> None: + """Run all container-enter hooks.""" + hooks = self._container_enter_hooks.get(container_type, []) + for hook in hooks: + hook(self, container_type, attrs, state) + hooks = self._container_enter_hooks.get("*", []) + for hook in hooks: + hook(self, container_type, attrs, state) + + def _run_container_exit_hooks(self, container_type: str, attrs: Dict[str, Any], state: BlockState) -> None: + """Run all container-exit hooks.""" + hooks = self._container_exit_hooks.get(container_type, []) + for hook in hooks: + hook(self, container_type, attrs, state) + hooks = self._container_exit_hooks.get("*", []) + for hook in hooks: + hook(self, container_type, attrs, state) + def parse_blank_line(self, m: Match[str], state: BlockState) -> int: """Parse token for blank lines.""" state.append_token({"type": "blank_line"}) @@ -442,6 +531,8 @@ def parse_raw_html(self, m: Match[str], state: BlockState) -> Optional[int]: return None def parse(self, state: BlockState, rules: Optional[List[str]] = None) -> None: + self._run_before_parse_hooks(state) + sc = self.compile_sc(rules) while state.cursor < state.cursor_max: @@ -468,6 +559,8 @@ def parse(self, state: BlockState, rules: Optional[List[str]] = None) -> None: text = state.src[state.cursor :] state.add_paragraph(text) state.cursor = state.cursor_max + + self._run_after_parse_hooks(state) def _parse_html_to_end(state: BlockState, end_marker: str, start_pos: int) -> int: diff --git a/src/mistune/core.py b/src/mistune/core.py index 26ea4b6..7647e34 100644 --- a/src/mistune/core.py +++ b/src/mistune/core.py @@ -36,6 +36,8 @@ class BlockState: list_tight: bool parent: Any env: MutableMapping[str, Any] + container_stack: List[str] + container_depth: int def __init__(self, parent: Optional[Any] = None) -> None: self.src = "" @@ -52,8 +54,12 @@ def __init__(self, parent: Optional[Any] = None) -> None: # for saving def references if parent: self.env = parent.env + self.container_stack = parent.container_stack + self.container_depth = parent.container_depth else: self.env = {"ref_links": {}} + self.container_stack = [] + self.container_depth = 0 def child_state(self, src: str) -> "BlockState": child = self.__class__(self) @@ -107,6 +113,24 @@ def depth(self) -> int: parent = parent.parent return d + def push_container(self, container_type: str) -> None: + self.container_stack.append(container_type) + self.container_depth = len(self.container_stack) + + def pop_container(self) -> Optional[str]: + if self.container_stack: + self.container_depth = len(self.container_stack) - 1 + return self.container_stack.pop() + return None + + def current_container(self) -> Optional[str]: + if self.container_stack: + return self.container_stack[-1] + return None + + def is_in_container(self, container_type: str) -> bool: + return container_type in self.container_stack + class InlineState: """The state to save inline parser's tokens.""" diff --git a/src/mistune/directives/__init__.py b/src/mistune/directives/__init__.py index e398670..8684ed9 100644 --- a/src/mistune/directives/__init__.py +++ b/src/mistune/directives/__init__.py @@ -7,6 +7,8 @@ from .image import Figure, Image from .include import Include from .toc import TableOfContents +from .tabs import Tabs +from .tip import Tip class RstDirective(RSTDirective): # pragma: no cover @@ -32,4 +34,6 @@ def __init__(self, plugins: List[DirectivePlugin]) -> None: "Include", "Image", "Figure", + "Tabs", + "Tip", ] diff --git a/src/mistune/directives/tabs.py b/src/mistune/directives/tabs.py new file mode 100644 index 0000000..5c94a65 --- /dev/null +++ b/src/mistune/directives/tabs.py @@ -0,0 +1,147 @@ +import re +import uuid +from typing import TYPE_CHECKING, Any, Dict, List, Match, Optional + +from ._base import DirectivePlugin + +if TYPE_CHECKING: + from ..block_parser import BlockParser + from ..core import BlockState + from ..markdown import Markdown + + +class Tabs(DirectivePlugin): + """Tabs directive plugin for creating tabbed content containers. + + This plugin integrates with BlockParser's lifecycle hooks for proper + container nesting and parent-child relationship management. + + Syntax: + ::: tabs + ::: tab "Tab Name 1" + Content for tab 1 + ::: + ::: tab "Tab Name 2" + Content for tab 2 + ::: + ::: + """ + + TAB_PATTERN = re.compile(r'^tab\s+"([^"]+)"(?:\s+(.*))?$') + + def parse_tab(self, block: "BlockParser", m: Match[str], state: "BlockState") -> Dict[str, Any]: + options = dict(self.parse_options(m)) + title = self.parse_title(m) + content = self.parse_content(m) + + tab_name = "" + tab_attrs = {} + + if title: + tab_match = self.TAB_PATTERN.match(title.strip()) + if tab_match: + tab_name = tab_match.group(1) + extra = tab_match.group(2) + if extra: + tab_attrs["extra"] = extra + + if "active" in options: + tab_attrs["active"] = options["active"].lower() == "true" + + if "id" in options: + tab_attrs["id"] = options["id"] + + attrs = {"type": "tab", "title": tab_name, **tab_attrs} + block._run_container_enter_hooks("tab", attrs, state) + + children = list(self.parse_tokens(block, content, state)) + + block._run_container_exit_hooks("tab", attrs, state) + + return { + "type": "tab_item", + "children": children, + "attrs": { + "name": tab_name, + **tab_attrs + } + } + + def parse_tabs(self, block: "BlockParser", m: Match[str], state: "BlockState") -> Dict[str, Any]: + options = dict(self.parse_options(m)) + content = self.parse_content(m) + + tabs_attrs = {} + if "group" in options: + tabs_attrs["group"] = options["group"] + if "sync" in options: + tabs_attrs["sync"] = options["sync"].lower() == "true" + + attrs = {"type": "tabs", **tabs_attrs} + state.push_container("tabs") + block._run_container_enter_hooks("tabs", attrs, state) + + children = list(self.parse_tokens(block, content, state)) + + block._run_container_exit_hooks("tabs", attrs, state) + state.pop_container() + + tab_items = [] + for child in children: + if child.get("type") == "tab_item": + tab_items.append(child) + + return { + "type": "tabs", + "children": tab_items, + "attrs": tabs_attrs + } + + def __call__(self, directive: "BaseDirective", md: "Markdown") -> None: + from ._base import BaseDirective + directive.register("tabs", self.parse_tabs) + directive.register("tab", self.parse_tab) + + assert md.renderer is not None + if md.renderer.NAME == "html": + md.renderer.register("tabs", render_tabs) + md.renderer.register("tab_item", render_tab_item) + + +def render_tabs(text: str, **attrs: Any) -> str: + group = attrs.get("group", "") + sync = attrs.get("sync", False) + + container_id = "" + if group: + container_id = f' id="tabs-{group}"' + + classes = ["tabs-container"] + if sync: + classes.append("tabs-sync") + + html = f'
{text}
\n' + + +def render_tip_content(text: str) -> str: + return f'{title}
\n' + html += f'{text}
\n' + + def tip_content(self, text: str) -> str: + return f'{title}
\n' + html += f'