From 53f33a7426d65f5edadf588703a38cd33c5ad37e Mon Sep 17 00:00:00 2001 From: lwk <3098293798@qq.com> Date: Fri, 13 Mar 2026 16:06:31 +0800 Subject: [PATCH] feat: add tabs, tip containers and container plugin with lifecycle hooks This PR adds interactive container support to Mistune: 1. New directive plugins: - tabs.py: Support for ::: tabs and ::: tab syntax - tip.py: Support for ::: tip, ::: warning, ::: danger, etc. 2. New plugin: - container.py: Generic container parser 3. Core enhancements: - BlockParser: Added lifecycle hooks (before/after parse, container enter/exit) - BlockState: Added container_stack and container_depth tracking 4. Renderer enhancements: - HTMLRenderer: Added interactive CSS/JS injection support - Added data-tab-name and data-container-type attributes 5. CLI enhancements: - Added --tabs, --tip, --inject-assets flags All features are opt-in to maintain backward compatibility. --- src/mistune/__init__.py | 24 +++- src/mistune/__main__.py | 24 ++++ src/mistune/block_parser.py | 97 ++++++++++++- src/mistune/core.py | 24 ++++ src/mistune/directives/__init__.py | 4 + src/mistune/directives/tabs.py | 147 ++++++++++++++++++++ src/mistune/directives/tip.py | 110 +++++++++++++++ src/mistune/markdown.py | 6 + src/mistune/plugins/__init__.py | 1 + src/mistune/plugins/container.py | 213 +++++++++++++++++++++++++++++ src/mistune/renderers/html.py | 186 ++++++++++++++++++++++++- 11 files changed, 827 insertions(+), 9 deletions(-) create mode 100644 src/mistune/directives/tabs.py create mode 100644 src/mistune/directives/tip.py create mode 100644 src/mistune/plugins/container.py 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'

\n' + html += text + html += "
\n" + return html + + +def render_tab_item(text: str, name: str = "", **attrs: Any) -> str: + tab_id = attrs.get("id", str(uuid.uuid4())[:8]) + active = attrs.get("active", False) + extra = attrs.get("extra", "") + + classes = ["tab-content"] + if active: + classes.append("tab-active") + + html = f'
Dict[str, Any]: + name = self.parse_type(m) + options = dict(self.parse_options(m)) + title = self.parse_title(m) + content = self.parse_content(m) + + attrs = {"name": name, "type": "tip"} + + if "class" in options: + attrs["class"] = options["class"] + + if not title: + title = name.capitalize() + + state.push_container(f"tip_{name}") + block._run_container_enter_hooks("tip", attrs, state) + + children = list(self.parse_tokens(block, content, state)) + + block._run_container_exit_hooks("tip", attrs, state) + state.pop_container() + + result_children = [ + { + "type": "tip_title", + "text": title, + }, + { + "type": "tip_content", + "children": children, + }, + ] + + return { + "type": "tip", + "children": result_children, + "attrs": attrs, + } + + def __call__(self, directive: "BaseDirective", md: "Markdown") -> None: + from ._base import BaseDirective + for name in self.SUPPORTED_TYPES: + directive.register(name, self.parse) + + assert md.renderer is not None + if md.renderer.NAME == "html": + md.renderer.register("tip", render_tip) + md.renderer.register("tip_title", render_tip_title) + md.renderer.register("tip_content", render_tip_content) + + +def render_tip(text: str, name: str = "", **attrs: Any) -> str: + classes = [f"tip-{name}", "tip-container"] + extra_class = attrs.get("class") + if extra_class: + classes.append(extra_class) + + html = f'
\n' + html += text + html += "
\n" + return html + + +def render_tip_title(text: str) -> str: + return f'

{text}

\n' + + +def render_tip_content(text: str) -> str: + return f'
{text}
\n' + + +__all__ = ["Tip"] diff --git a/src/mistune/markdown.py b/src/mistune/markdown.py index 532c35a..db88fdc 100644 --- a/src/mistune/markdown.py +++ b/src/mistune/markdown.py @@ -19,6 +19,7 @@ class Markdown: :param block: block level syntax parser :param inline: inline level syntax parser :param plugins: mistune plugins to use + :param inject_tabs_assets: whether to inject CSS/JS for tabs support """ def __init__( @@ -27,16 +28,21 @@ def __init__( block: Optional[BlockParser] = None, inline: Optional[InlineParser] = None, plugins: Optional[Iterable[Plugin]] = None, + inject_tabs_assets: bool = False, ): if block is None: block = BlockParser() if inline is None: inline = InlineParser() + + if renderer is not None and hasattr(renderer, '_inject_tabs_assets'): + renderer._inject_tabs_assets = inject_tabs_assets self.renderer = renderer self.block: BlockParser = block self.inline: InlineParser = inline + self.inject_tabs_assets = inject_tabs_assets self.before_parse_hooks: List[Callable[["Markdown", BlockState], None]] = [] self.before_render_hooks: List[Callable[["Markdown", BlockState], Any]] = [] self.after_render_hooks: List[ diff --git a/src/mistune/plugins/__init__.py b/src/mistune/plugins/__init__.py index 6cabd92..73b14a1 100644 --- a/src/mistune/plugins/__init__.py +++ b/src/mistune/plugins/__init__.py @@ -20,6 +20,7 @@ "ruby": "mistune.plugins.ruby.ruby", "task_lists": "mistune.plugins.task_lists.task_lists", "spoiler": "mistune.plugins.spoiler.spoiler", + "container": "mistune.plugins.container.container", } diff --git a/src/mistune/plugins/container.py b/src/mistune/plugins/container.py new file mode 100644 index 0000000..bcc6f5c --- /dev/null +++ b/src/mistune/plugins/container.py @@ -0,0 +1,213 @@ +import re +import uuid +from typing import TYPE_CHECKING, Any, Dict, List, Match, Optional + +if TYPE_CHECKING: + from ..block_parser import BlockParser + from ..core import BlockState + from ..markdown import Markdown + + +CONTAINER_PATTERN = r'^(?P:{3,})\s*(?P[a-zA-Z][a-zA-Z0-9_-]*)\s*(?:\"(?P[^\"]*)\")?\s*$' + + +def _get_container_rules(block: "BlockParser") -> List[str]: + return list(block.rules) + + +def parse_container( + block: "BlockParser", + m: Match[str], + state: "BlockState" +) -> Optional[int]: + """Parse a generic container block like ::: tabs or ::: tip. + + This function is called by the BlockParser through the registered parse method. + It uses the container hooks from BlockParser for lifecycle management. + """ + marker = m.group("container_mark") + container_type = m.group("container_type") + container_title = m.group("container_title") or "" + + marker_len = len(marker) + cursor_start = m.end() + 1 + + end_pattern = r'^ {0,3}:{3,}[ \t]*(?:\n|$)' + if marker_len > 3: + end_pattern = f'^ {{0,3}}:{{{marker_len},}}[ \\t]*(?:\\n|$)' + end_re = re.compile(end_pattern, re.M) + + end_m = end_re.search(state.src, cursor_start) + if end_m: + content = state.src[cursor_start:end_m.start()] + end_pos = end_m.end() + else: + content = state.src[cursor_start:] + end_pos = state.cursor_max + + attrs = {"type": container_type, "title": container_title.strip() if container_title else ""} + + state.push_container(container_type) + block._run_container_enter_hooks(container_type, attrs, state) + + child = state.child_state(content) + rules = _get_container_rules(block) + block.parse(child, rules) + + block._run_container_exit_hooks(container_type, attrs, state) + state.pop_container() + + token = { + "type": "container", + "children": child.tokens, + "attrs": attrs, + "marker": marker, + } + + state.append_token(token) + return end_pos + + +def _container_enter_hook( + block: "BlockParser", + container_type: str, + attrs: Dict[str, Any], + state: "BlockState" +) -> None: + """Hook called when entering a container. + + Maintains parent-child relationships for tabs and code block clustering. + """ + if "container_nesting" not in state.env: + state.env["container_nesting"] = [] + + state.env["container_nesting"].append({ + "type": container_type, + "attrs": attrs, + "depth": state.container_depth, + }) + + if container_type == "tabs": + if "tabs_groups" not in state.env: + state.env["tabs_groups"] = {} + group_id = attrs.get("group", str(uuid.uuid4())[:8]) + state.env["current_tabs_group"] = group_id + state.env["tabs_groups"][group_id] = { + "tabs": [], + "parent": state.env.get("container_nesting", [])[-2] if len(state.env.get("container_nesting", [])) > 1 else None + } + + +def _container_exit_hook( + block: "BlockParser", + container_type: str, + attrs: Dict[str, Any], + state: "BlockState" +) -> None: + """Hook called when exiting a container. + + Cleans up nesting state and finalizes parent-child relationships. + """ + if container_type == "tab": + if "current_tabs_group" in state.env: + group_id = state.env["current_tabs_group"] + if group_id in state.env.get("tabs_groups", {}): + state.env["tabs_groups"][group_id]["tabs"].append({ + "name": attrs.get("title", ""), + "attrs": attrs, + }) + + if container_type == "tabs": + state.env.pop("current_tabs_group", None) + + if "container_nesting" in state.env and state.env["container_nesting"]: + state.env["container_nesting"].pop() + + +def container(md: "Markdown") -> None: + """Plugin to enable generic container blocks. + + This plugin integrates with BlockParser's lifecycle hooks for proper + container nesting and parent-child relationship management. + + Example: + ::: tip + This is a tip container + ::: + + :::: tabs + ::: tab "First" + Content + ::: + :::: + """ + md.block.register( + "container", + CONTAINER_PATTERN, + parse_container, + before="fenced_code", + ) + + md.block.add_container_enter_hook("*", _container_enter_hook) + md.block.add_container_exit_hook("*", _container_exit_hook) + + if md.renderer and md.renderer.NAME == "html": + md.renderer.register("container", render_container) + + +def render_container( + text: str, + type: str = "", + title: str = "", + **attrs: Any +) -> str: + """Render a generic container to HTML.""" + if type == "tab": + tab_id = attrs.get("id", str(uuid.uuid4())[:8]) + active = attrs.get("active", False) + + classes = ["tab-content"] + if active: + classes.append("tab-active") + + html = f'
\n' + html += text + html += "
\n" + return html + + if type == "tabs": + classes = ["tabs-container"] + html = f'
\n' + html += text + html += "
\n" + return html + + if type in ("tip", "note", "warning", "danger", "info", "success", "caution"): + classes = [f"tip-{type}", "tip-container"] + extra_class = attrs.get("class") + if extra_class: + classes.append(extra_class) + + html = f'
\n' + if title: + html += f'

{title}

\n' + html += f'
{text}
\n' + html += "
\n" + return html + + classes = [f"container-{type}", "custom-container"] + + html = f'
{title}
\n' + + html += text + html += "
\n" + return html + + +__all__ = ["container", "parse_container", "render_container", "CONTAINER_PATTERN"] diff --git a/src/mistune/renderers/html.py b/src/mistune/renderers/html.py index 0f8d41d..2e7f7c7 100644 --- a/src/mistune/renderers/html.py +++ b/src/mistune/renderers/html.py @@ -1,13 +1,70 @@ -from typing import Any, ClassVar, Dict, Optional, Tuple, Literal +import uuid +from typing import Any, ClassVar, Dict, Iterable, Optional, Tuple, Literal from ..core import BaseRenderer, BlockState from ..util import escape as escape_text from ..util import safe_entity, striptags +TABS_CSS = """ + +""" + +TABS_JS = """ + +""".replace("__CSS__", TABS_CSS.strip().replace("\n", "").replace("`", "\\`")) + + class HTMLRenderer(BaseRenderer): """A renderer for converting Markdown to HTML.""" _escape: bool + _inject_tabs_assets: bool + _has_tabs: bool NAME: ClassVar[Literal["html"]] = "html" HARMFUL_PROTOCOLS: ClassVar[Tuple[str, ...]] = ( "javascript:", @@ -22,10 +79,23 @@ class HTMLRenderer(BaseRenderer): "data:image/webp;", ) - def __init__(self, escape: bool = True, allow_harmful_protocols: Optional[bool] = None) -> None: + def __init__( + self, + escape: bool = True, + allow_harmful_protocols: Optional[bool] = None, + inject_tabs_assets: bool = False + ) -> None: super(HTMLRenderer, self).__init__() self._allow_harmful_protocols = allow_harmful_protocols self._escape = escape + self._inject_tabs_assets = inject_tabs_assets + self._has_tabs = False + + def render_tokens(self, tokens: Iterable[Dict[str, Any]], state: BlockState) -> str: + result = super().render_tokens(tokens, state) + if self._inject_tabs_assets and self._has_tabs: + result = TABS_JS + result + return result def render_token(self, token: Dict[str, Any], state: BlockState) -> str: # backward compitable with v2 @@ -151,3 +221,115 @@ def list(self, text: str, ordered: bool, **attrs: Any) -> str: def list_item(self, text: str) -> str: return "
  • " + text + "
  • \n" + + def tabs(self, text: str, **attrs: Any) -> str: + self._has_tabs = True + 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'
    \n' + html += text + html += "
    \n" + return html + + def tab_item(self, text: str, name: str = "", **attrs: Any) -> str: + tab_id = attrs.get("id", str(uuid.uuid4())[:8]) + active = attrs.get("active", False) + extra = attrs.get("extra", "") + + classes = ["tab-content"] + if active: + classes.append("tab-active") + + html = f'
    str: + classes = [f"tip-{name}", "tip-container"] + extra_class = attrs.get("class") + if extra_class: + classes.append(extra_class) + + html = f'
    \n' + html += text + html += "
    \n" + return html + + def tip_title(self, text: str) -> str: + return f'

    {text}

    \n' + + def tip_content(self, text: str) -> str: + return f'
    {text}
    \n' + + def container(self, text: str, type: str = "", title: str = "", **attrs: Any) -> str: + if type == "tab": + self._has_tabs = True + tab_id = attrs.get("id", str(uuid.uuid4())[:8]) + active = attrs.get("active", False) + + classes = ["tab-content"] + if active: + classes.append("tab-active") + + html = f'
    \n' + html += text + html += "
    \n" + return html + + if type == "tabs": + self._has_tabs = True + 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'
    \n' + html += text + html += "
    \n" + return html + + if type in ("tip", "note", "warning", "danger", "info", "success", "caution"): + classes = [f"tip-{type}", "tip-container"] + extra_class = attrs.get("class") + if extra_class: + classes.append(extra_class) + + html = f'
    \n' + if title: + html += f'

    {title}

    \n' + html += f'
    {text}
    \n' + html += "
    \n" + return html + + classes = [f"container-{type}", "custom-container"] + + html = f'
    {title}
    \n' + + html += text + html += "
    \n" + return html