From 7e2d2737e0816f1da138d4eff7264ac490fb6077 Mon Sep 17 00:00:00 2001 From: lwk <3098293798@qq.com> Date: Fri, 13 Mar 2026 16:10:11 +0800 Subject: [PATCH] feat: add interactive containers, code clustering and frontend assets This PR adds comprehensive interactive container support to Mistune: 1. New assets module: - assets.py: Vanilla JavaScript and CSS for interactive features - Includes tabs, collapsible containers, and code cluster functionality 2. New directive plugin: - containers.py: Unified container parser for tabs, tips, warnings, etc. - Supports ::: tabs, ::: tip, ::: warning, ::: note, ::: danger, etc. - Integrated with BlockParser lifecycle hooks 3. New plugin: - code_cluster.py: Automatically clusters adjacent code blocks with different languages into tabbed interfaces 4. Core enhancements: - BlockParser: Added lifecycle hooks for container parsing - BlockState: Added container tracking (container_stack, container_depth) 5. Renderer enhancements: - HTMLRenderer: Added interactive container renderers - Added data-tab-name, data-container-type, data-cluster-id attributes - Added enable_interactive flag for opt-in features 6. CLI enhancements: - Added --enable-interactive flag All interactive features are opt-in via enable_interactive parameter to maintain backward compatibility and avoid performance overhead. --- src/mistune/__init__.py | 26 +- src/mistune/__main__.py | 18 + src/mistune/assets.py | 497 +++++++++++++++++++++++++++ src/mistune/block_parser.py | 51 ++- src/mistune/core.py | 28 ++ src/mistune/directives/__init__.py | 18 + src/mistune/directives/containers.py | 350 +++++++++++++++++++ src/mistune/markdown.py | 36 +- src/mistune/plugins/code_cluster.py | 262 ++++++++++++++ src/mistune/renderers/html.py | 131 ++++++- 10 files changed, 1409 insertions(+), 8 deletions(-) create mode 100644 src/mistune/assets.py create mode 100644 src/mistune/directives/containers.py create mode 100644 src/mistune/plugins/code_cluster.py diff --git a/src/mistune/__init__.py b/src/mistune/__init__.py index 1f466bc..4040e2f 100644 --- a/src/mistune/__init__.py +++ b/src/mistune/__init__.py @@ -25,6 +25,8 @@ def create_markdown( hard_wrap: bool = False, renderer: Optional[RendererRef] = "html", plugins: Optional[Iterable[PluginRef]] = None, + enable_interactive: bool = False, + inject_assets: bool = False, ) -> Markdown: """Create a Markdown instance based on the given condition. @@ -32,6 +34,8 @@ 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_interactive: Enable interactive features (tabs, enhanced containers). + :param inject_assets: Inject frontend JS/CSS assets for interactive features. This method is used when you want to re-use a Markdown instance:: @@ -46,13 +50,31 @@ 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, enable_interactive=enable_interactive) 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) + + # Add container directive plugins if interactive mode is enabled + if enable_interactive: + from .directives import ContainerDirective, TabsDirective, TipDirective + from .plugins.code_cluster import code_cluster_plugin + if real_plugins is None: + real_plugins = [] + else: + real_plugins = list(real_plugins) + real_plugins.append(ContainerDirective([TabsDirective(), TipDirective()])) + real_plugins.append(code_cluster_plugin) + + return Markdown( + renderer=renderer, + inline=inline, + plugins=real_plugins, + enable_interactive=enable_interactive, + inject_assets=inject_assets, + ) 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..db4e1b7 100644 --- a/src/mistune/__main__.py +++ b/src/mistune/__main__.py @@ -25,11 +25,19 @@ def _md(args: argparse.Namespace) -> "Markdown": renderer = MarkdownRenderer() else: renderer = args.renderer + + # Add container directive plugins if interactive mode is enabled + if args.interactive: + from .directives import ContainerDirective, TabsDirective, TipDirective + plugins.append(ContainerDirective([TabsDirective(), TipDirective()])) + return create_markdown( escape=args.escape, hard_wrap=args.hardwrap, renderer=renderer, plugins=plugins, + enable_interactive=args.interactive, + inject_assets=args.inject_assets, ) @@ -101,6 +109,16 @@ def cli() -> None: default="html", help="specify the output renderer", ) + parser.add_argument( + "--interactive", + action="store_true", + help="enable interactive features (tabs, enhanced containers)", + ) + parser.add_argument( + "--inject-assets", + action="store_true", + help="inject frontend JS/CSS assets for interactive features", + ) parser.add_argument("--version", action="version", version="mistune " + version) args = parser.parse_args() diff --git a/src/mistune/assets.py b/src/mistune/assets.py new file mode 100644 index 0000000..09a39ad --- /dev/null +++ b/src/mistune/assets.py @@ -0,0 +1,497 @@ +""" +Frontend assets for interactive Markdown features. +Provides Vanilla JavaScript and CSS for tabs, containers, and other interactive elements. +""" + +# Vanilla JavaScript for interactive features +INTERACTIVE_JS = """ +(function() { + 'use strict'; + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initInteractiveFeatures); + } else { + initInteractiveFeatures(); + } + + function initInteractiveFeatures() { + initTabs(); + initCollapsibleContainers(); + initCodeClusters(); + } + + // Tabs functionality + function initTabs() { + const tabContainers = document.querySelectorAll('.md-tabs'); + + tabContainers.forEach(container => { + const tabsId = container.getAttribute('data-tabs-id'); + const tabButtons = container.querySelectorAll('.md-tab-btn'); + const tabPanels = container.querySelectorAll('.md-tab-panel'); + + // Set first tab as active by default + if (tabButtons.length > 0 && !container.querySelector('.md-tab-btn-active')) { + tabButtons[0].classList.add('md-tab-btn-active'); + } + if (tabPanels.length > 0 && !container.querySelector('.md-tab-panel-active')) { + tabPanels[0].classList.add('md-tab-panel-active'); + } + + // Add click handlers to tab buttons + tabButtons.forEach((btn, index) => { + btn.addEventListener('click', function(e) { + e.preventDefault(); + + const targetId = this.getAttribute('data-tab-target'); + + // Deactivate all tabs in this container + tabButtons.forEach(b => b.classList.remove('md-tab-btn-active')); + tabPanels.forEach(p => p.classList.remove('md-tab-panel-active')); + + // Activate clicked tab + this.classList.add('md-tab-btn-active'); + + // Activate corresponding panel + const targetPanel = container.querySelector('[data-tab-id="' + targetId + '"]'); + if (targetPanel) { + targetPanel.classList.add('md-tab-panel-active'); + } + + // Dispatch custom event + container.dispatchEvent(new CustomEvent('tabChange', { + detail: { tabId: targetId, tabIndex: index, tabsId: tabsId } + })); + }); + }); + }); + } + + // Collapsible containers functionality + function initCollapsibleContainers() { + const containers = document.querySelectorAll('[data-container-type]'); + + containers.forEach(container => { + const title = container.querySelector('.md-tip-title'); + if (title) { + title.style.cursor = 'pointer'; + title.addEventListener('click', function() { + container.classList.toggle('md-container-collapsed'); + + // Dispatch custom event + container.dispatchEvent(new CustomEvent('containerToggle', { + detail: { + containerId: container.id, + containerType: container.getAttribute('data-container-type'), + isCollapsed: container.classList.contains('md-container-collapsed') + } + })); + }); + } + }); + } + + // Code cluster tabs functionality + function initCodeClusters() { + const codeClusters = document.querySelectorAll('.md-code-cluster'); + + codeClusters.forEach(cluster => { + const clusterId = cluster.getAttribute('data-cluster-id'); + const tabButtons = cluster.querySelectorAll('.md-code-tab-btn'); + const tabPanels = cluster.querySelectorAll('.md-code-tab-panel'); + + // Set first tab as active by default + if (tabButtons.length > 0 && !cluster.querySelector('.md-code-tab-btn-active')) { + tabButtons[0].classList.add('md-code-tab-btn-active'); + } + if (tabPanels.length > 0 && !cluster.querySelector('.md-code-tab-panel-active')) { + tabPanels[0].classList.add('md-code-tab-panel-active'); + } + + // Add click handlers to tab buttons + tabButtons.forEach((btn, index) => { + btn.addEventListener('click', function(e) { + e.preventDefault(); + + const targetId = this.getAttribute('data-code-tab-target'); + + // Deactivate all tabs in this cluster + tabButtons.forEach(b => b.classList.remove('md-code-tab-btn-active')); + tabPanels.forEach(p => p.classList.remove('md-code-tab-panel-active')); + + // Activate clicked tab + this.classList.add('md-code-tab-btn-active'); + + // Activate corresponding panel + const targetPanel = cluster.querySelector('[data-code-tab-id="' + targetId + '"]'); + if (targetPanel) { + targetPanel.classList.add('md-code-tab-panel-active'); + } + + // Dispatch custom event + cluster.dispatchEvent(new CustomEvent('codeTabChange', { + detail: { tabId: targetId, tabIndex: index, clusterId: clusterId } + })); + }); + }); + }); + } + + // Expose API for programmatic control + window.MistuneInteractive = { + // Switch to a specific tab + switchTab: function(tabsId, tabId) { + const container = document.querySelector('[data-tabs-id="' + tabsId + '"]'); + if (!container) return false; + + const btn = container.querySelector('.md-tab-btn[data-tab-target="' + tabId + '"]'); + if (btn) { + btn.click(); + return true; + } + return false; + }, + + // Get active tab info + getActiveTab: function(tabsId) { + const container = document.querySelector('[data-tabs-id="' + tabsId + '"]'); + if (!container) return null; + + const activeBtn = container.querySelector('.md-tab-btn-active'); + const activePanel = container.querySelector('.md-tab-panel-active'); + + return { + tabId: activeBtn ? activeBtn.getAttribute('data-tab-target') : null, + tabName: activeBtn ? activeBtn.getAttribute('data-tab-name') : null, + panelId: activePanel ? activePanel.getAttribute('data-tab-id') : null + }; + }, + + // Toggle container + toggleContainer: function(containerId) { + const container = document.getElementById(containerId); + if (container && container.hasAttribute('data-container-type')) { + container.classList.toggle('md-container-collapsed'); + return true; + } + return false; + }, + + // Re-initialize (useful for dynamically added content) + reinit: function() { + initInteractiveFeatures(); + }, + + // Switch to a specific code tab + switchCodeTab: function(clusterId, tabId) { + const cluster = document.querySelector('[data-cluster-id="' + clusterId + '"]'); + if (!cluster) return false; + + const btn = cluster.querySelector('.md-code-tab-btn[data-code-tab-target="' + tabId + '"]'); + if (btn) { + btn.click(); + return true; + } + return false; + }, + + // Get active code tab info + getActiveCodeTab: function(clusterId) { + const cluster = document.querySelector('[data-cluster-id="' + clusterId + '"]'); + if (!cluster) return null; + + const activeBtn = cluster.querySelector('.md-code-tab-btn-active'); + const activePanel = cluster.querySelector('.md-code-tab-panel-active'); + + return { + tabId: activeBtn ? activeBtn.getAttribute('data-code-tab-target') : null, + tabName: activeBtn ? activeBtn.getAttribute('data-code-tab-name') : null, + panelId: activePanel ? activePanel.getAttribute('data-code-tab-id') : null + }; + } + }; +})(); +""" + +# CSS for interactive features +INTERACTIVE_CSS = """ +/* Tabs Container Styles */ +.md-tabs-container { + border: 1px solid #e1e4e8; + border-radius: 6px; + margin: 1em 0; + overflow: hidden; +} + +.md-tabs-header { + background: #f6f8fa; + padding: 0.75em 1em; + font-weight: 600; + border-bottom: 1px solid #e1e4e8; +} + +.md-tabs-nav { + display: flex; + background: #f6f8fa; + border-bottom: 1px solid #e1e4e8; + flex-wrap: wrap; +} + +.md-tab-btn { + padding: 0.75em 1.25em; + border: none; + background: transparent; + cursor: pointer; + font-size: 0.9em; + color: #586069; + border-bottom: 2px solid transparent; + transition: all 0.2s ease; +} + +.md-tab-btn:hover { + color: #24292e; + background: rgba(0, 0, 0, 0.05); +} + +.md-tab-btn-active { + color: #0366d6; + border-bottom-color: #0366d6; + font-weight: 500; +} + +.md-tabs-content { + padding: 1em; +} + +.md-tab-panel { + display: none; +} + +.md-tab-panel-active { + display: block; + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +/* Tip/Warning/Info Container Styles */ +.md-tip, +.md-info, +.md-warning, +.md-danger, +.md-note { + border-left: 4px solid; + padding: 1em; + margin: 1em 0; + border-radius: 0 6px 6px 0; + background: #f6f8fa; +} + +.md-tip { + border-left-color: #28a745; + background: #f0fff4; +} + +.md-info { + border-left-color: #0366d6; + background: #f1f8ff; +} + +.md-warning { + border-left-color: #f9a825; + background: #fffbf0; +} + +.md-danger { + border-left-color: #d73a49; + background: #fff5f5; +} + +.md-note { + border-left-color: #6f42c1; + background: #f5f0ff; +} + +.md-tip-title { + font-weight: 600; + margin-bottom: 0.5em; + display: flex; + align-items: center; +} + +.md-tip-title::before { + content: ""; + display: inline-block; + width: 1em; + height: 1em; + margin-right: 0.5em; + background-size: contain; + background-repeat: no-repeat; +} + +.md-tip-title-tip::before { + content: "💡"; +} + +.md-tip-title-info::before { + content: "â„šī¸"; +} + +.md-tip-title-warning::before { + content: "âš ī¸"; +} + +.md-tip-title-danger::before { + content: "đŸšĢ"; +} + +.md-tip-title-note::before { + content: "📝"; +} + +.md-tip-content { + color: #24292e; +} + +.md-tip-content p:first-child { + margin-top: 0; +} + +.md-tip-content p:last-child { + margin-bottom: 0; +} + +/* Collapsible state */ +.md-container-collapsed .md-tip-content { + display: none; +} + +.md-container-collapsed .md-tip-title { + margin-bottom: 0; +} + +/* Code block enhancements */ +pre[data-block-type="code"] { + position: relative; +} + +pre[data-language]::before { + content: attr(data-language); + position: absolute; + top: 0; + right: 0; + padding: 0.25em 0.75em; + font-size: 0.75em; + color: #586069; + background: rgba(0, 0, 0, 0.05); + border-radius: 0 0 0 4px; + text-transform: uppercase; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .md-tabs-nav { + flex-direction: column; + } + + .md-tab-btn { + border-bottom: none; + border-left: 2px solid transparent; + text-align: left; + } + + .md-tab-btn-active { + border-left-color: #0366d6; + border-bottom: none; + } +} + +/* Print styles */ +@media print { + .md-tab-panel { + display: block !important; + } + + .md-tabs-nav { + display: none; + } + + .md-tip-content { + display: block !important; + } +} + +/* Code Cluster Styles */ +.md-code-cluster { + border: 1px solid #e1e4e8; + border-radius: 6px; + margin: 1em 0; + overflow: hidden; +} + +.md-code-cluster-nav { + display: flex; + background: #f6f8fa; + border-bottom: 1px solid #e1e4e8; + flex-wrap: wrap; +} + +.md-code-tab-btn { + padding: 0.5em 1em; + border: none; + background: transparent; + cursor: pointer; + font-size: 0.85em; + color: #586069; + border-bottom: 2px solid transparent; + transition: all 0.2s ease; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; +} + +.md-code-tab-btn:hover { + color: #24292e; + background: rgba(0, 0, 0, 0.05); +} + +.md-code-tab-btn-active { + color: #0366d6; + border-bottom-color: #0366d6; + font-weight: 500; +} + +.md-code-cluster-content { + background: #f6f8fa; +} + +.md-code-tab-panel { + display: none; +} + +.md-code-tab-panel-active { + display: block; + animation: fadeIn 0.3s ease; +} + +.md-code-tab-panel > pre { + margin: 0; + border-radius: 0; +} + +.md-code-tab-panel > pre > code { + border-radius: 0; +} +""" + +def get_assets() -> dict: + """Get frontend assets for interactive features. + + Returns: + dict with 'js' and 'css' keys containing the asset strings. + """ + return { + "js": INTERACTIVE_JS, + "css": INTERACTIVE_CSS, + } diff --git a/src/mistune/block_parser.py b/src/mistune/block_parser.py index 055d42e..408d451 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, Dict, Any import string from .util import ( unikey, @@ -110,6 +110,36 @@ def __init__( # register default parse methods self._methods = {name: getattr(self, "parse_" + name) for name in self.SPECIFICATION} + # Lifecycle hooks for state management + self._before_parse_hooks: List[Callable[["BlockParser", BlockState], None]] = [] + self._after_parse_hooks: List[Callable[["BlockParser", BlockState], None]] = [] + self._on_token_hooks: Dict[str, List[Callable[["BlockParser", Dict[str, Any], BlockState], None]]] = {} + + def register_hook(self, hook_type: str, hook: Callable, token_type: Optional[str] = None) -> None: + """Register a lifecycle hook for the block parser. + + :param hook_type: 'before_parse', 'after_parse', or 'on_token' + :param hook: The hook function to register + :param token_type: Required for 'on_token' hooks, specifies which token type to hook + """ + if hook_type == "before_parse": + self._before_parse_hooks.append(hook) + elif hook_type == "after_parse": + self._after_parse_hooks.append(hook) + elif hook_type == "on_token": + if token_type is None: + raise ValueError("token_type is required for 'on_token' hooks") + if token_type not in self._on_token_hooks: + self._on_token_hooks[token_type] = [] + self._on_token_hooks[token_type].append(hook) + + def _trigger_token_hooks(self, token: Dict[str, Any], state: BlockState) -> None: + """Trigger hooks for a specific token type.""" + token_type = token.get("type") + if token_type and token_type in self._on_token_hooks: + for hook in self._on_token_hooks[token_type]: + hook(self, token, 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 +472,10 @@ 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: + # Trigger before_parse hooks + for hook in self._before_parse_hooks: + hook(self, state) + sc = self.compile_sc(rules) while state.cursor < state.cursor_max: @@ -469,6 +503,21 @@ def parse(self, state: BlockState, rules: Optional[List[str]] = None) -> None: state.add_paragraph(text) state.cursor = state.cursor_max + # Trigger after_parse hooks + for hook in self._after_parse_hooks: + hook(self, state) + + # Trigger token hooks for all tokens + for token in state.tokens: + self._process_token_hooks(token, state) + + def _process_token_hooks(self, token: Dict[str, Any], state: BlockState) -> None: + """Recursively process token hooks including nested children.""" + self._trigger_token_hooks(token, state) + if "children" in token: + for child in token["children"]: + self._process_token_hooks(child, state) + 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..f497217 100644 --- a/src/mistune/core.py +++ b/src/mistune/core.py @@ -55,6 +55,11 @@ def __init__(self, parent: Optional[Any] = None) -> None: else: self.env = {"ref_links": {}} + # Track container context for nested directive parsing + self._container_stack: List[Dict[str, Any]] = [] + if parent: + self._container_stack = parent._container_stack.copy() + def child_state(self, src: str) -> "BlockState": child = self.__class__(self) child.process(src) @@ -107,6 +112,29 @@ def depth(self) -> int: parent = parent.parent return d + def push_container(self, container_type: str, attrs: Optional[Dict[str, Any]] = None) -> None: + """Push a container context onto the stack for tracking nested structures.""" + container = {"type": container_type} + if attrs: + container.update(attrs) + self._container_stack.append(container) + + def pop_container(self) -> Optional[Dict[str, Any]]: + """Pop the current container context from the stack.""" + if self._container_stack: + return self._container_stack.pop() + return None + + def get_current_container(self) -> Optional[Dict[str, Any]]: + """Get the current container context without removing it.""" + if self._container_stack: + return self._container_stack[-1] + return None + + def get_container_stack(self) -> List[Dict[str, Any]]: + """Get the full container stack for context analysis.""" + return self._container_stack.copy() + 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..416a3d0 100644 --- a/src/mistune/directives/__init__.py +++ b/src/mistune/directives/__init__.py @@ -4,6 +4,16 @@ from ._fenced import FencedDirective from ._rst import RSTDirective from .admonition import Admonition +from .containers import ( + ContainerDirective, + TabsDirective, + TipDirective, + render_tabs_container, + render_tab_panel, + render_tip_container, + render_tip_title, + render_tip_content, +) from .image import Figure, Image from .include import Include from .toc import TableOfContents @@ -27,9 +37,17 @@ def __init__(self, plugins: List[DirectivePlugin]) -> None: "DirectivePlugin", "RSTDirective", "FencedDirective", + "ContainerDirective", + "TabsDirective", + "TipDirective", "Admonition", "TableOfContents", "Include", "Image", "Figure", + "render_tabs_container", + "render_tab_panel", + "render_tip_container", + "render_tip_title", + "render_tip_content", ] diff --git a/src/mistune/directives/containers.py b/src/mistune/directives/containers.py new file mode 100644 index 0000000..2212b9d --- /dev/null +++ b/src/mistune/directives/containers.py @@ -0,0 +1,350 @@ +""" +Container directives for rich text enhancement. +Supports ::: tabs, ::: tip, ::: warning and other custom container blocks. +""" +import re +from typing import TYPE_CHECKING, Any, Dict, List, Match, Optional, Tuple, Union + +from ._base import BaseDirective, DirectivePlugin + +if TYPE_CHECKING: + from ..block_parser import BlockParser + from ..core import BlockState + from ..markdown import Markdown + + +class ContainerParser: + """Parser for container-style directives using ::: syntax.""" + name = "container_directive" + + # Pattern for containers with title (tip, warning, etc.) + # Title is optional and should not start with === (which is for tabs) + CONTAINER_PATTERN_WITH_TITLE = re.compile( + r'^\s*:::\s*(?P[a-zA-Z0-9_-]+)' + r'(?:\s+\{(?P[a-zA-Z0-9_-]+)\})?' + r'(?:\s+(?P(?!===)[^\n]*?))??\s*\n' + r'(?P<content>(?:(?!^\s*:::).*(?:\n|$))*)' + r'^\s*:::\s*(?:\n|$)', + re.MULTILINE + ) + + # Pattern for tabs container (no title, starts with ===) + TABS_CONTAINER_PATTERN = re.compile( + r'^\s*:::\s*(?P<type>tabs)' + r'(?:\s+\{(?P<id>[a-zA-Z0-9_-]+)\})?\s*\n' + r'(?P<content>(?:(?!^\s*:::).*(?:\n|$))*)' + r'^\s*:::\s*(?:\n|$)', + re.MULTILINE + ) + + TAB_PATTERN = re.compile( + r'^\s*===\s*"(?P<label>[^"]*)"\s*(?:\{(?P<attrs>[^}]*)\})?\s*\n' + r'(?P<content>(?:(?!^\s*===)(?!^\s*:::).*(?:\n|$))*)', + re.MULTILINE + ) + + @classmethod + def parse_type(cls, m: Match[str]) -> str: + return m.group("type") + + @classmethod + def parse_id(cls, m: Match[str]) -> Optional[str]: + return m.group("id") + + @classmethod + def parse_title(cls, m: Match[str]) -> str: + title = m.group("title") or "" + return title.strip() + + @classmethod + def parse_content(cls, m: Match[str]) -> str: + return m.group("content") or "" + + +class TabsDirective(DirectivePlugin): + """Tabs container directive for grouping code blocks or content. + + Example: + ::: tabs {my-tabs} + === "Python" + ```python + print("hello") + ``` + + === "JavaScript" + ```javascript + console.log("hello"); + ``` + ::: + """ + + def parse(self, block: "BlockParser", m: Match[str], state: "BlockState") -> Dict[str, Any]: + container_id = ContainerParser.parse_id(m) or self._generate_id(state) + # Tabs container doesn't have a title + title = None + content = ContainerParser.parse_content(m) + + tab_panels: List[Dict[str, Any]] = [] + tabs_data: List[Dict[str, str]] = [] + code_blocks: List[Dict[str, Any]] = [] + + for tab_match in ContainerParser.TAB_PATTERN.finditer(content): + tab_label = tab_match.group("label") + tab_attrs = self._parse_attrs(tab_match.group("attrs") or "") + tab_content = tab_match.group("content") + + tab_id = f"{container_id}-{len(tab_panels)}" + + child = state.child_state(tab_content) + block.parse(child) + + # Create a tab panel token + tab_panel = { + "type": "tab_panel", + "id": tab_id, + "label": tab_label, + "attrs": {"id": tab_id, "label": tab_label, **tab_attrs}, + "children": child.tokens, + } + tab_panels.append(tab_panel) + tabs_data.append({"id": tab_id, "label": tab_label}) + + for token in child.tokens: + if token.get("type") == "block_code": + code_blocks.append({ + "tab_id": tab_id, + "tab_label": tab_label, + "code_info": token.get("attrs", {}).get("info", ""), + "token": token, + }) + + attrs = { + "id": container_id, + "class": "md-tabs", + "data-tabs-id": container_id, + "tabs": tabs_data, + } + + if title: + attrs["title"] = title + + state.push_container("tabs", { + "id": container_id, + "tabs": [t["label"] for t in tabs_data], + "code_blocks": code_blocks, + }) + + return { + "type": "tabs_container", + "children": tab_panels, + "attrs": attrs, + } + + def _generate_id(self, state: "BlockState") -> str: + """Generate unique ID for tabs container.""" + parent = state.get_current_container() + base_id = "tabs" + if parent and "id" in parent: + base_id = f"{parent['id']}-tabs" + count = state.env.get("_tabs_counter", 0) + 1 + state.env["_tabs_counter"] = count + return f"{base_id}-{count}" + + def _parse_attrs(self, attrs_str: str) -> Dict[str, str]: + """Parse attribute string like: key="value" key2='value2'""" + attrs = {} + pattern = re.compile(r'(\w+)=["\']([^"\']+)["\']') + for match in pattern.finditer(attrs_str): + attrs[match.group(1)] = match.group(2) + return attrs + + def __call__(self, directive: "BaseDirective", md: "Markdown") -> None: + directive.register("tabs", self.parse) + + # Register renderers if HTML renderer is used + if md.renderer and md.renderer.NAME == "html": + md.renderer.register("tabs_container", render_tabs_container) + md.renderer.register("tab_panel", render_tab_panel) + + +class TipDirective(DirectivePlugin): + """Tip/Info/Warning container directive. + + Example: + ::: tip + This is a helpful tip! + ::: + + ::: warning + This is a warning! + ::: + """ + + SUPPORTED_TYPES = { + "tip": {"class": "md-tip", "default_title": "Tip"}, + "info": {"class": "md-info", "default_title": "Info"}, + "warning": {"class": "md-warning", "default_title": "Warning"}, + "danger": {"class": "md-danger", "default_title": "Danger"}, + "note": {"class": "md-note", "default_title": "Note"}, + } + + def parse(self, block: "BlockParser", m: Match[str], state: "BlockState") -> Dict[str, Any]: + container_type = ContainerParser.parse_type(m) + title = ContainerParser.parse_title(m) + content = ContainerParser.parse_content(m) + + config = self.SUPPORTED_TYPES.get(container_type, { + "class": f"md-{container_type}", + "default_title": container_type.capitalize(), + }) + + if not title: + title = config["default_title"] + + child = state.child_state(content) + block.parse(child) + + container_id = self._generate_id(container_type, state) + + attrs = { + "id": container_id, + "class": config["class"], + "data-container-type": container_type, + } + + state.push_container(container_type, {"id": container_id}) + + return { + "type": "tip_container", + "children": [ + { + "type": "tip_title", + "text": title, + "attrs": {"container_type": container_type}, + }, + { + "type": "tip_content", + "children": child.tokens, + }, + ], + "attrs": attrs, + } + + def _generate_id(self, container_type: str, state: "BlockState") -> str: + """Generate unique ID for container.""" + count = state.env.get(f"_{container_type}_counter", 0) + 1 + state.env[f"_{container_type}_counter"] = count + return f"{container_type}-{count}" + + def __call__(self, directive: "BaseDirective", md: "Markdown") -> None: + for name in self.SUPPORTED_TYPES: + directive.register(name, self.parse) + + # Register renderers if HTML renderer is used + if md.renderer and md.renderer.NAME == "html": + md.renderer.register("tip_container", render_tip_container) + md.renderer.register("tip_title", render_tip_title) + md.renderer.register("tip_content", render_tip_content) + + +class ContainerDirective(BaseDirective): + """Container-style directive using ::: syntax. + + This supports custom containers like: + + ::: tabs + === "Tab 1" + Content for tab 1 + === "Tab 2" + Content for tab 2 + ::: + + ::: tip + This is a tip + ::: + + ::: warning + This is a warning + ::: + """ + + parser = ContainerParser + directive_pattern = r'^\s*:::\s*[a-zA-Z0-9_-]+' + + def __init__(self, plugins: List[DirectivePlugin]): + super().__init__(plugins) + self._pattern = re.compile(self.directive_pattern, re.MULTILINE) + + def parse_directive(self, block: "BlockParser", m: Match[str], state: "BlockState") -> Optional[int]: + # Try tabs pattern first (no title) + match = ContainerParser.TABS_CONTAINER_PATTERN.match(state.src, m.start()) + if match: + self.parse_method(block, match, state) + return match.end() + + # Try general container pattern + match = ContainerParser.CONTAINER_PATTERN_WITH_TITLE.match(state.src, m.start()) + if match: + self.parse_method(block, match, state) + return match.end() + + return None + + def __call__(self, md: "Markdown") -> None: + super().__call__(md) + self.register_block_parser(md, before="fenced_code") + + +# HTML Renderers + +def render_tabs_container(self: Any, text: str, **attrs: Any) -> str: + """Render tabs container with interactive classes and data attributes.""" + container_id = attrs.get("id", "tabs") + html = f'<div class="md-tabs-container" id="{container_id}" data-tabs-id="{container_id}">\n' + + title = attrs.get("title") + if title: + html += f'<div class="md-tabs-header">{title}</div>\n' + + html += '<div class="md-tabs-nav">\n' + html += text + html += '</div>\n' + html += '</div>\n' + return html + + +def render_tab_panel(self: Any, text: str, **attrs: Any) -> str: + """Render individual tab panel.""" + tab_id = attrs.get("id", "") + tab_label = attrs.get("label", "") + is_active = attrs.get("active", False) + + active_class = " md-tab-active" if is_active else "" + + html = f'<div class="md-tab-panel{active_class}" data-tab-id="{tab_id}" data-tab-name="{tab_label}">\n' + html += text + html += '</div>\n' + return html + + +def render_tip_container(self: Any, text: str, **attrs: Any) -> str: + """Render tip/warning/info container with interactive classes.""" + container_id = attrs.get("id", "") + container_class = attrs.get("class", "md-tip") + container_type = attrs.get("data-container-type", "tip") + + html = f'<div class="{container_class}" id="{container_id}" data-container-type="{container_type}">\n' + html += text + html += '</div>\n' + return html + + +def render_tip_title(self: Any, text: str, **attrs: Any) -> str: + """Render tip container title.""" + container_type = attrs.get("container_type", "tip") + return f'<div class="md-tip-title md-tip-title-{container_type}">{text}</div>\n' + + +def render_tip_content(self: Any, text: str) -> str: + """Render tip container content.""" + return f'<div class="md-tip-content">{text}</div>\n' diff --git a/src/mistune/markdown.py b/src/mistune/markdown.py index 532c35a..cb561a3 100644 --- a/src/mistune/markdown.py +++ b/src/mistune/markdown.py @@ -19,6 +19,8 @@ class Markdown: :param block: block level syntax parser :param inline: inline level syntax parser :param plugins: mistune plugins to use + :param enable_interactive: Enable interactive features (tabs, enhanced containers) + :param inject_assets: Inject frontend JS/CSS assets for interactive features """ def __init__( @@ -27,6 +29,8 @@ def __init__( block: Optional[BlockParser] = None, inline: Optional[InlineParser] = None, plugins: Optional[Iterable[Plugin]] = None, + enable_interactive: bool = False, + inject_assets: bool = False, ): if block is None: block = BlockParser() @@ -37,6 +41,8 @@ def __init__( self.renderer = renderer self.block: BlockParser = block self.inline: InlineParser = inline + self.enable_interactive = enable_interactive + self.inject_assets = inject_assets self.before_parse_hooks: List[Callable[["Markdown", BlockState], None]] = [] self.before_render_hooks: List[Callable[["Markdown", BlockState], Any]] = [] self.after_render_hooks: List[ @@ -53,9 +59,37 @@ def use(self, plugin: Plugin) -> None: def render_state(self, state: BlockState) -> Union[str, List[Dict[str, Any]]]: data = self._iter_render(state.tokens, state) if self.renderer: - return self.renderer(data, state) + result = self.renderer(data, state) + # Inject frontend assets if enabled + if self.inject_assets and isinstance(result, str): + result = self._inject_frontend_assets(result) + return result return list(data) + def _inject_frontend_assets(self, html: str) -> str: + """Inject frontend JS and CSS assets for interactive features.""" + from .assets import get_assets + assets = get_assets() + + # Inject CSS in head or at the beginning + if assets["css"]: + css_block = f"<style>\n{assets['css']}\n</style>\n" + # Try to insert before </head> or at the beginning + if "</head>" in html: + html = html.replace("</head>", css_block + "</head>") + else: + html = css_block + html + + # Inject JS before </body> or at the end + if assets["js"]: + js_block = f"<script>\n{assets['js']}\n</script>\n" + if "</body>" in html: + html = html.replace("</body>", js_block + "</body>") + else: + html = html + js_block + + return html + def _iter_render(self, tokens: Iterable[Dict[str, Any]], state: BlockState) -> Iterable[Dict[str, Any]]: for tok in tokens: if "children" in tok: diff --git a/src/mistune/plugins/code_cluster.py b/src/mistune/plugins/code_cluster.py new file mode 100644 index 0000000..fdd2ff8 --- /dev/null +++ b/src/mistune/plugins/code_cluster.py @@ -0,0 +1,262 @@ +""" +Code block clustering plugin for Mistune. + +This plugin automatically clusters adjacent code blocks with different languages +into a tabbed interface structure. This is useful for documentation that shows +the same example in multiple programming languages. + +Example: + ```python + print("Hello") + ``` + + ```javascript + console.log("Hello"); + ``` + +Will be rendered as a tabbed interface with "Python" and "JavaScript" tabs. +""" +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple +import re + +if TYPE_CHECKING: + from ..block_parser import BlockParser + from ..core import BlockState + from ..markdown import Markdown + + +class CodeClusterPlugin: + """Plugin to cluster adjacent code blocks into tabbed interfaces.""" + + def __init__(self, max_gap: int = 1): + """ + Initialize the code cluster plugin. + + :param max_gap: Maximum number of non-code tokens between code blocks + to still consider them as a cluster. Default is 1 (allows + a single blank line between code blocks). + """ + self.max_gap = max_gap + self._cluster_counter = 0 + + def __call__(self, md: "Markdown") -> None: + """Register the plugin with the Markdown instance.""" + if not md.enable_interactive: + return + + # Register after_parse hook to cluster code blocks + md.block.register_hook("after_parse", self._cluster_code_blocks) + + # Register token renderer if HTML renderer is used + if md.renderer and md.renderer.NAME == "html": + md.renderer.register("code_cluster", self._render_code_cluster) + md.renderer.register("code_cluster_tab", self._render_code_cluster_tab) + + def _cluster_code_blocks(self, block: "BlockParser", state: "BlockState") -> None: + """ + Post-process tokens to cluster adjacent code blocks. + + This method is called after parsing is complete. It traverses the token + tree and groups adjacent code blocks into clusters. + """ + state.tokens = self._process_tokens(state.tokens, state) + + def _process_tokens( + self, tokens: List[Dict[str, Any]], state: "BlockState" + ) -> List[Dict[str, Any]]: + """Process a list of tokens and cluster adjacent code blocks.""" + result: List[Dict[str, Any]] = [] + i = 0 + + while i < len(tokens): + token = tokens[i] + + # Check if this is a code block that could start a cluster + if token.get("type") == "block_code": + cluster, next_idx = self._collect_cluster(tokens, i, state) + if len(cluster) > 1: + # Create a cluster token + cluster_token = self._create_cluster_token(cluster, state) + result.append(cluster_token) + i = next_idx + continue + elif cluster: + # Single code block, add as-is + result.append(cluster[0]) + i = next_idx + continue + + # Process children if present + if "children" in token: + token["children"] = self._process_tokens(token["children"], state) + + result.append(token) + i += 1 + + return result + + def _collect_cluster( + self, + tokens: List[Dict[str, Any]], + start_idx: int, + state: "BlockState", + ) -> Tuple[List[Dict[str, Any]], int]: + """ + Collect a cluster of adjacent code blocks starting at start_idx. + + Returns: + Tuple of (cluster_tokens, next_index) + """ + cluster: List[Dict[str, Any]] = [] + i = start_idx + gap_count = 0 + + while i < len(tokens): + token = tokens[i] + + if token.get("type") == "block_code": + # Check if code block has a language identifier + info = token.get("attrs", {}).get("info", "") + lang = info.split()[0] if info else "" + + # Only cluster code blocks with different languages + if not cluster or self._get_code_lang(cluster[-1]) != lang: + cluster.append(token) + gap_count = 0 + i += 1 + else: + # Same language as previous, stop clustering + break + elif token.get("type") in ("blank_line",): + # Allow blank lines within max_gap + gap_count += 1 + if gap_count > self.max_gap: + break + i += 1 + else: + # Non-code, non-blank token - stop clustering + break + + return cluster, i + + def _get_code_lang(self, token: Dict[str, Any]) -> str: + """Extract language from a code block token.""" + info = token.get("attrs", {}).get("info", "") + return info.split()[0] if info else "" + + def _create_cluster_token( + self, cluster: List[Dict[str, Any]], state: "BlockState" + ) -> Dict[str, Any]: + """Create a cluster token from a list of code block tokens.""" + self._cluster_counter += 1 + cluster_id = f"code-cluster-{self._cluster_counter}" + + # Create tab tokens for each code block + tabs: List[Dict[str, Any]] = [] + for i, code_token in enumerate(cluster): + lang = self._get_code_lang(code_token) + label = self._format_lang_label(lang) + + tab_token = { + "type": "code_cluster_tab", + "code_token": code_token, + "attrs": { + "id": f"{cluster_id}-{i}", + "label": label, + "lang": lang, + "active": i == 0, + }, + } + tabs.append(tab_token) + + return { + "type": "code_cluster", + "children": tabs, + "attrs": { + "id": cluster_id, + "class": "md-code-cluster", + "data-cluster-id": cluster_id, + "tabs": [ + {"id": f"{cluster_id}-{i}", "label": self._format_lang_label(self._get_code_lang(t))} + for i, t in enumerate(cluster) + ], + }, + } + + def _format_lang_label(self, lang: str) -> str: + """Format language identifier as a human-readable label.""" + if not lang: + return "Code" + + # Common language mappings + lang_labels = { + "js": "JavaScript", + "ts": "TypeScript", + "py": "Python", + "rb": "Ruby", + "go": "Go", + "rs": "Rust", + "cpp": "C++", + "cs": "C#", + "sh": "Shell", + "bash": "Bash", + "zsh": "Zsh", + "ps1": "PowerShell", + "yml": "YAML", + "yaml": "YAML", + "json": "JSON", + "xml": "XML", + "html": "HTML", + "css": "CSS", + "scss": "SCSS", + "sass": "Sass", + "less": "Less", + "sql": "SQL", + "graphql": "GraphQL", + } + + return lang_labels.get(lang.lower(), lang.capitalize()) + + def _render_code_cluster(self, text: str, **attrs: Any) -> str: + """Render a code cluster as a tabbed interface.""" + from ..renderers.html import HTMLRenderer + + cluster_id = attrs.get("id", "code-cluster") + container_class = attrs.get("class", "md-code-cluster") + tabs_data = attrs.get("tabs", []) + + html = f'<div class="{container_class}" id="{cluster_id}" data-cluster-id="{cluster_id}">\n' + html += '<div class="md-code-cluster-nav">\n' + + # Render tab buttons + for i, tab_info in enumerate(tabs_data): + active_class = " md-code-tab-btn-active" if i == 0 else "" + tab_id = tab_info.get("id", f"{cluster_id}-{i}") + tab_label = tab_info.get("label", f"Tab {i+1}") + html += f'<button class="md-code-tab-btn{active_class}" data-code-tab-target="{tab_id}" data-code-tab-name="{tab_label}">{tab_label}</button>\n' + + html += '</div>\n' + html += '<div class="md-code-cluster-content">\n' + html += text + html += '</div>\n' + html += '</div>\n' + return html + + def _render_code_cluster_tab(self, text: str, **attrs: Any) -> str: + """Render a single code cluster tab.""" + tab_id = attrs.get("id", "") + tab_label = attrs.get("label", "") + is_active = attrs.get("active", False) + + active_class = " md-code-tab-panel-active" if is_active else "" + + html = f'<div class="md-code-tab-panel{active_class}" data-code-tab-id="{tab_id}" data-code-tab-name="{tab_label}">\n' + html += text + html += '</div>\n' + return html + + +def code_cluster_plugin(md: "Markdown") -> None: + """Convenience function to create and register the code cluster plugin.""" + plugin = CodeClusterPlugin() + plugin(md) diff --git a/src/mistune/renderers/html.py b/src/mistune/renderers/html.py index 0f8d41d..30c6ca7 100644 --- a/src/mistune/renderers/html.py +++ b/src/mistune/renderers/html.py @@ -1,4 +1,4 @@ -from typing import Any, ClassVar, Dict, Optional, Tuple, Literal +from typing import Any, ClassVar, Dict, Optional, Tuple, Literal, List from ..core import BaseRenderer, BlockState from ..util import escape as escape_text from ..util import safe_entity, striptags @@ -22,10 +22,17 @@ 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, + enable_interactive: bool = False, + ) -> None: super(HTMLRenderer, self).__init__() self._allow_harmful_protocols = allow_harmful_protocols self._escape = escape + self._enable_interactive = enable_interactive + self._interactive_tokens: List[Dict[str, Any]] = [] def render_token(self, token: Dict[str, Any], state: BlockState) -> str: # backward compitable with v2 @@ -36,6 +43,9 @@ def render_token(self, token: Dict[str, Any], state: BlockState) -> str: text = token["raw"] elif "children" in token: text = self.render_tokens(token["children"], state) + elif "code_token" in token: + # Special handling for code_cluster_tab which wraps a code token + text = self.render_token(token["code_token"], state) else: if attrs: return func(**attrs) @@ -122,12 +132,19 @@ def block_text(self, text: str) -> str: def block_code(self, code: str, info: Optional[str] = None) -> str: html = "<pre><code" + data_attrs = "" if info is not None: info = safe_entity(info.strip()) if info: lang = info.split(None, 1)[0] html += ' class="language-' + lang + '"' - return html + ">" + escape_text(code) + "</code></pre>\n" + if self._enable_interactive: + data_attrs += f' data-language="{lang}"' + + if self._enable_interactive: + data_attrs += ' data-block-type="code"' + + return html + data_attrs + ">" + escape_text(code) + "</code></pre>\n" def block_quote(self, text: str) -> str: return "<blockquote>\n" + text + "</blockquote>\n" @@ -146,8 +163,114 @@ def list(self, text: str, ordered: bool, **attrs: Any) -> str: start = attrs.get("start") if start is not None: html += ' start="' + str(start) + '"' + if self._enable_interactive: + html += ' data-list-type="ordered"' return html + ">\n" + text + "</ol>\n" - return "<ul>\n" + text + "</ul>\n" + html = "<ul" + if self._enable_interactive: + html += ' data-list-type="unordered"' + return html + ">\n" + text + "</ul>\n" def list_item(self, text: str) -> str: return "<li>" + text + "</li>\n" + + # Interactive container renderers + + def tabs_container(self, text: str, **attrs: Any) -> str: + """Render tabs container with interactive classes and data attributes.""" + container_id = attrs.get("id", "tabs") + container_class = attrs.get("class", "md-tabs") + title = attrs.get("title") + + html = f'<div class="{container_class}" id="{container_id}" data-tabs-id="{container_id}">\n' + + if title: + html += f'<div class="md-tabs-header">{title}</div>\n' + + html += '<div class="md-tabs-nav">\n' + + # Render tab buttons + tabs_data = attrs.get("tabs", []) + for i, tab_info in enumerate(tabs_data): + active_class = " md-tab-btn-active" if i == 0 else "" + tab_id = tab_info.get("id", f"{container_id}-{i}") + tab_label = tab_info.get("label", f"Tab {i+1}") + html += f'<button class="md-tab-btn{active_class}" data-tab-target="{tab_id}" data-tab-name="{tab_label}">{tab_label}</button>\n' + + html += '</div>\n' + html += '<div class="md-tabs-content">\n' + html += text + html += '</div>\n' + html += '</div>\n' + return html + + def tab_panel(self, text: str, **attrs: Any) -> str: + """Render individual tab panel.""" + tab_id = attrs.get("id", "") + tab_label = attrs.get("label", "") + is_active = attrs.get("active", False) + + active_class = " md-tab-panel-active" if is_active else "" + + html = f'<div class="md-tab-panel{active_class}" data-tab-id="{tab_id}" data-tab-name="{tab_label}">\n' + html += text + html += '</div>\n' + return html + + def tip_container(self, text: str, **attrs: Any) -> str: + """Render tip/warning/info container with interactive classes.""" + container_id = attrs.get("id", "") + container_class = attrs.get("class", "md-tip") + container_type = attrs.get("data-container-type", "tip") + + html = f'<div class="{container_class}" id="{container_id}" data-container-type="{container_type}">\n' + html += text + html += '</div>\n' + return html + + def tip_title(self, text: str, **attrs: Any) -> str: + """Render tip container title.""" + container_type = attrs.get("container_type", "tip") + return f'<div class="md-tip-title md-tip-title-{container_type}">{text}</div>\n' + + def tip_content(self, text: str) -> str: + """Render tip container content.""" + return f'<div class="md-tip-content">{text}</div>\n' + + # Code cluster renderers + + def code_cluster(self, text: str, **attrs: Any) -> str: + """Render a code cluster as a tabbed interface.""" + cluster_id = attrs.get("id", "code-cluster") + container_class = attrs.get("class", "md-code-cluster") + tabs_data = attrs.get("tabs", []) + + html = f'<div class="{container_class}" id="{cluster_id}" data-cluster-id="{cluster_id}">\n' + html += '<div class="md-code-cluster-nav">\n' + + # Render tab buttons + for i, tab_info in enumerate(tabs_data): + active_class = " md-code-tab-btn-active" if i == 0 else "" + tab_id = tab_info.get("id", f"{cluster_id}-{i}") + tab_label = tab_info.get("label", f"Tab {i+1}") + html += f'<button class="md-code-tab-btn{active_class}" data-code-tab-target="{tab_id}" data-code-tab-name="{tab_label}">{tab_label}</button>\n' + + html += '</div>\n' + html += '<div class="md-code-cluster-content">\n' + html += text + html += '</div>\n' + html += '</div>\n' + return html + + def code_cluster_tab(self, text: str, **attrs: Any) -> str: + """Render a single code cluster tab panel.""" + tab_id = attrs.get("id", "") + tab_label = attrs.get("label", "") + is_active = attrs.get("active", False) + + active_class = " md-code-tab-panel-active" if is_active else "" + + html = f'<div class="md-code-tab-panel{active_class}" data-code-tab-id="{tab_id}" data-code-tab-name="{tab_label}">\n' + html += text + html += '</div>\n' + return html