From f0fad63dd701414cfadb592e06617877d52f0b03 Mon Sep 17 00:00:00 2001 From: lwk <3098293798@qq.com> Date: Fri, 13 Mar 2026 10:57:57 +0800 Subject: [PATCH] feat: add interactive containers (tabs & tip) with frontend assets [seed] - Add BlockState.container_type to track nesting context - Add lifecycle hooks in block_parser for container tracking - Add EnhancedDirective, TabsDirective, TipDirective in directives/ - Add tabs() and tip() renderer methods in html.py with data-* attrs - Inject Vanilla JS/CSS scaffold via after_render hook in markdown.py - Add --enhanced CLI flag in __main__.py for feature toggle - Fully backward compatible when flag is disabled --- src/mistune/__init__.py | 4 +- src/mistune/__main__.py | 10 ++ src/mistune/block_parser.py | 51 +++++++++ src/mistune/core.py | 8 +- src/mistune/directives/__init__.py | 4 + src/mistune/directives/enhanced.py | 166 +++++++++++++++++++++++++++++ src/mistune/markdown.py | 136 +++++++++++++++++++++++ src/mistune/renderers/html.py | 31 ++++++ 8 files changed, 406 insertions(+), 4 deletions(-) create mode 100644 src/mistune/directives/enhanced.py diff --git a/src/mistune/__init__.py b/src/mistune/__init__.py index 1f466bc..931fefe 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, + enable_enhanced: bool = False, ) -> Markdown: """Create a Markdown instance based on the given condition. @@ -32,6 +33,7 @@ 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 enable_enhanced: Boolean. Enable enhanced features like tabs and tip containers. This method is used when you want to re-use a Markdown instance:: @@ -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"]) diff --git a/src/mistune/__main__.py b/src/mistune/__main__.py index 42ed842..1aae7d2 100644 --- a/src/mistune/__main__.py +++ b/src/mistune/__main__.py @@ -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": @@ -30,6 +34,7 @@ def _md(args: argparse.Namespace) -> "Markdown": hard_wrap=args.hardwrap, renderer=renderer, plugins=plugins, + enable_enhanced=args.enhanced, ) @@ -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() diff --git a/src/mistune/block_parser.py b/src/mistune/block_parser.py index 055d42e..0c39d54 100644 --- a/src/mistune/block_parser.py +++ b/src/mistune/block_parser.py @@ -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() @@ -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: @@ -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 @@ -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) diff --git a/src/mistune/core.py b/src/mistune/core.py index 26ea4b6..02957dd 100644 --- a/src/mistune/core.py +++ b/src/mistune/core.py @@ -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 = [] @@ -48,6 +49,7 @@ 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: @@ -55,8 +57,8 @@ def __init__(self, parent: Optional[Any] = None) -> None: 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 diff --git a/src/mistune/directives/__init__.py b/src/mistune/directives/__init__.py index e398670..5fec3ce 100644 --- a/src/mistune/directives/__init__.py +++ b/src/mistune/directives/__init__.py @@ -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 @@ -32,4 +33,7 @@ def __init__(self, plugins: List[DirectivePlugin]) -> None: "Include", "Image", "Figure", + "EnhancedDirective", + "TabsDirective", + "TipDirective", ] diff --git a/src/mistune/directives/enhanced.py b/src/mistune/directives/enhanced.py new file mode 100644 index 0000000..a19d4b0 --- /dev/null +++ b/src/mistune/directives/enhanced.py @@ -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(?:" + _marker_pattern + r"){3,})" + r"[ \t]*(?P[a-zA-Z0-9_-]+)" + r"[ \t]*(?P[^\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" + ) diff --git a/src/mistune/markdown.py b/src/mistune/markdown.py index 532c35a..fcd027e 100644 --- a/src/mistune/markdown.py +++ b/src/mistune/markdown.py @@ -27,6 +27,7 @@ def __init__( block: Optional[BlockParser] = None, inline: Optional[InlineParser] = None, plugins: Optional[Iterable[Plugin]] = None, + enable_enhanced: bool = False, ): if block is None: block = BlockParser() @@ -37,6 +38,7 @@ def __init__( self.renderer = renderer self.block: BlockParser = block self.inline: InlineParser = inline + self.enable_enhanced = enable_enhanced self.before_parse_hooks: List[Callable[["Markdown", BlockState], None]] = [] self.before_render_hooks: List[Callable[["Markdown", BlockState], Any]] = [] self.after_render_hooks: List[ @@ -47,6 +49,138 @@ def __init__( for plugin in plugins: plugin(self) + # Add hook to inject frontend code when enhanced features are enabled + if self.enable_enhanced: + def inject_frontend_code(md, result, state): + if isinstance(result, str): + # Add CSS + css = ''' +<style> +.tabs-container { + border: 1px solid #ddd; + border-radius: 4px; + margin: 1em 0; +} +.tabs-title { + background-color: #f5f5f5; + padding: 0.5em 1em; + border-bottom: 1px solid #ddd; + font-weight: bold; +} +.tabs-nav { + display: flex; + border-bottom: 1px solid #ddd; + background-color: #f9f9f9; +} +.tabs-nav button { + padding: 0.5em 1em; + border: none; + background: none; + cursor: pointer; + border-bottom: 3px solid transparent; +} +.tabs-nav button.active { + border-bottom-color: #0288d1; + color: #0288d1; + font-weight: bold; +} +.tabs-content { + padding: 1em; +} +.tabs-content .tab-panel { + display: none; +} +.tabs-content .tab-panel.active { + display: block; +} +.tip-container { + border: 1px solid #e1f5fe; + border-radius: 4px; + background-color: #e1f5fe; + margin: 1em 0; + padding: 1em; +} +.tip-title { + font-weight: bold; + margin-bottom: 0.5em; + color: #0288d1; +} +</style> + ''' + # Add JavaScript + js = ''' +<script> +// Tab switching functionality +document.addEventListener('DOMContentLoaded', function() { + const tabContainers = document.querySelectorAll('.tabs-container'); + tabContainers.forEach(container => { + const contentDiv = container.querySelector('.tabs-content'); + const codeBlocks = contentDiv.querySelectorAll('pre'); + const navDiv = container.querySelector('.tabs-nav'); + + // Generate tab navigation based on code blocks + const panels = []; + codeBlocks.forEach((block, index) => { + // Extract language from class name + let language = 'code'; + const classList = block.querySelector('code').className; + if (classList) { + const match = classList.match(/language-(\w+)/); + if (match) { + language = match[1]; + } + } + + // Create tab button + const button = document.createElement('button'); + button.textContent = language; + button.dataset.tabIndex = index; + navDiv.appendChild(button); + + // Create tab panel + const panel = document.createElement('div'); + panel.className = 'tab-panel'; + panel.appendChild(block.cloneNode(true)); + panels.push(panel); + + // Hide original code block + block.style.display = 'none'; + }); + + // Add panels to content + panels.forEach(panel => { + contentDiv.appendChild(panel); + }); + + // Set first tab as active + if (panels.length > 0) { + navDiv.querySelector('button').classList.add('active'); + panels[0].classList.add('active'); + } + + // Add click event listeners to tab buttons + navDiv.querySelectorAll('button').forEach(button => { + button.addEventListener('click', function() { + const tabIndex = parseInt(this.dataset.tabIndex); + + // Remove active class from all buttons and panels + navDiv.querySelectorAll('button').forEach(btn => btn.classList.remove('active')); + panels.forEach(panel => panel.classList.remove('active')); + + // Add active class to clicked button and corresponding panel + this.classList.add('active'); + panels[tabIndex].classList.add('active'); + }); + }); + }); +}); +</script> + ''' + # Insert CSS at the beginning and JS at the end + result = css + result + js + return result + self.after_render_hooks.append(inject_frontend_code) + def use(self, plugin: Plugin) -> None: plugin(self) @@ -58,6 +192,8 @@ def render_state(self, state: BlockState) -> Union[str, List[Dict[str, Any]]]: def _iter_render(self, tokens: Iterable[Dict[str, Any]], state: BlockState) -> Iterable[Dict[str, Any]]: for tok in tokens: + if tok is None: + continue if "children" in tok: children = self._iter_render(tok["children"], state) tok["children"] = list(children) diff --git a/src/mistune/renderers/html.py b/src/mistune/renderers/html.py index 0f8d41d..e9ed14f 100644 --- a/src/mistune/renderers/html.py +++ b/src/mistune/renderers/html.py @@ -151,3 +151,34 @@ def list(self, text: str, ordered: bool, **attrs: Any) -> str: def list_item(self, text: str) -> str: return "<li>" + text + "</li>\n" + + def tabs(self, text: str, title: str = "", options: dict = None) -> str: + """Render tabs container.""" + if options is None: + options = {} + tab_id = options.get('id', f"tabs-{hash(title)}") + html = f'<div class="tabs-container" id="{tab_id}" data-tab-name="{title}">\n' + if title: + html += f'<div class="tabs-title">{title}</div>\n' + html += '<div class="tabs-nav">\n' + # Tab navigation will be generated by JavaScript based on code blocks + html += '</div>\n' + html += '<div class="tabs-content">\n' + html += text + html += '</div>\n' + html += '</div>\n' + return html + + def tip(self, text: str, title: str = "", options: dict = None) -> str: + """Render tip container.""" + if options is None: + options = {} + tip_id = options.get('id', f"tip-{hash(title)}") + html = f'<div class="tip-container" id="{tip_id}" data-tip-name="{title}">\n' + if title: + html += f'<div class="tip-title">{title}</div>\n' + html += '<div class="tip-content">\n' + html += text + html += '</div>\n' + html += '</div>\n' + return html