sphinx-filter-tabs 0.8.0__py3-none-any.whl → 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- filter_tabs/extension.py +334 -300
- filter_tabs/renderer.py +261 -0
- filter_tabs/static/filter_tabs.css +139 -62
- filter_tabs/static/filter_tabs.js +160 -0
- {sphinx_filter_tabs-0.8.0.dist-info → sphinx_filter_tabs-1.2.0.dist-info}/METADATA +14 -5
- sphinx_filter_tabs-1.2.0.dist-info/RECORD +11 -0
- sphinx_filter_tabs-0.8.0.dist-info/RECORD +0 -9
- {sphinx_filter_tabs-0.8.0.dist-info → sphinx_filter_tabs-1.2.0.dist-info}/WHEEL +0 -0
- {sphinx_filter_tabs-0.8.0.dist-info → sphinx_filter_tabs-1.2.0.dist-info}/entry_points.txt +0 -0
- {sphinx_filter_tabs-0.8.0.dist-info → sphinx_filter_tabs-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {sphinx_filter_tabs-0.8.0.dist-info → sphinx_filter_tabs-1.2.0.dist-info}/top_level.txt +0 -0
filter_tabs/extension.py
CHANGED
|
@@ -1,429 +1,464 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
# filter_tabs/extension.py
|
|
2
|
+
"""
|
|
3
|
+
Core extension module for sphinx-filter-tabs.
|
|
4
|
+
Consolidated version containing directives, data models, nodes, and Sphinx integration.
|
|
5
|
+
"""
|
|
4
6
|
|
|
5
|
-
# --- Imports ---
|
|
6
|
-
# Ensures that all type hints are treated as forward references, which is standard practice.
|
|
7
7
|
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
import re
|
|
10
|
-
import uuid
|
|
11
8
|
import copy
|
|
12
9
|
import shutil
|
|
13
10
|
from pathlib import Path
|
|
14
11
|
from docutils import nodes
|
|
15
|
-
from docutils.parsers.rst import Directive
|
|
12
|
+
from docutils.parsers.rst import Directive, directives
|
|
16
13
|
from sphinx.application import Sphinx
|
|
17
14
|
from sphinx.util import logging
|
|
18
15
|
from sphinx.writers.html import HTML5Translator
|
|
19
16
|
|
|
20
|
-
|
|
21
|
-
from
|
|
22
|
-
|
|
23
|
-
# Imports the package version, dynamically read from the installed package's metadata.
|
|
17
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
|
18
|
+
from dataclasses import dataclass, field
|
|
24
19
|
from . import __version__
|
|
25
20
|
|
|
26
|
-
# A block that only runs when a type checker is running, not at runtime.
|
|
27
|
-
# This avoids potential runtime errors from importing types that may not be available.
|
|
28
21
|
if TYPE_CHECKING:
|
|
29
|
-
from sphinx.config import Config
|
|
30
22
|
from sphinx.environment import BuildEnvironment
|
|
31
23
|
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
_CSS_NAMESPACE = uuid.UUID('d1b1b3e8-5e7c-48d6-a235-9a4c14c9b139')
|
|
36
|
-
|
|
37
|
-
# Centralizing CSS class names makes them easy to manage and prevents typos.
|
|
38
|
-
SFT_CONTAINER = "sft-container"
|
|
39
|
-
SFT_FIELDSET = "sft-fieldset"
|
|
40
|
-
SFT_LEGEND = "sft-legend"
|
|
41
|
-
SFT_TAB_BAR = "sft-tab-bar"
|
|
42
|
-
SFT_CONTENT = "sft-content"
|
|
43
|
-
SFT_PANEL = "sft-panel"
|
|
44
|
-
SFT_TEMP_PANEL = "sft-temp-panel"
|
|
45
|
-
COLLAPSIBLE_SECTION = "collapsible-section"
|
|
46
|
-
COLLAPSIBLE_CONTENT = "collapsible-content"
|
|
47
|
-
CUSTOM_ARROW = "custom-arrow"
|
|
48
|
-
|
|
49
|
-
# --- Logger ---
|
|
50
|
-
# A dedicated logger for this extension, following Sphinx's best practices.
|
|
51
|
-
# This allows for clean, configurable logging output.
|
|
52
|
-
logger = logging.getLogger(__name__)
|
|
53
|
-
|
|
54
|
-
# --- Custom Nodes ---
|
|
55
|
-
# Each custom node corresponds to a specific part of the component's HTML structure.
|
|
56
|
-
# This allows for fine-grained control over the final HTML output via visitor functions.
|
|
24
|
+
# =============================================================================
|
|
25
|
+
# Custom Docutils Nodes
|
|
26
|
+
# =============================================================================
|
|
57
27
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
class
|
|
62
|
-
|
|
28
|
+
class ContainerNode(nodes.General, nodes.Element): pass
|
|
29
|
+
class FieldsetNode(nodes.General, nodes.Element): pass
|
|
30
|
+
class LegendNode(nodes.General, nodes.Element): pass
|
|
31
|
+
class RadioInputNode(nodes.General, nodes.Element): pass
|
|
32
|
+
class LabelNode(nodes.General, nodes.Element): pass
|
|
33
|
+
class PanelNode(nodes.General, nodes.Element): pass
|
|
34
|
+
class DetailsNode(nodes.General, nodes.Element): pass
|
|
35
|
+
class SummaryNode(nodes.General, nodes.Element): pass
|
|
63
36
|
|
|
64
|
-
class FieldsetNode(nodes.General, nodes.Element): pass # For semantic grouping.
|
|
65
|
-
class LegendNode(nodes.General, nodes.Element): pass # For accessibility.
|
|
66
|
-
class RadioInputNode(nodes.General, nodes.Element): pass # The functional core for tab switching.
|
|
67
|
-
class LabelNode(nodes.General, nodes.Element): pass # The visible, clickable tab titles.
|
|
68
|
-
class PanelNode(nodes.General, nodes.Element): pass # The containers for tab content.
|
|
69
|
-
class DetailsNode(nodes.General, nodes.Element): pass # For collapsible sections.
|
|
70
|
-
class SummaryNode(nodes.General, nodes.Element): pass # The clickable title of a collapsible section.
|
|
71
37
|
|
|
38
|
+
# =============================================================================
|
|
39
|
+
# Data Models and Configuration
|
|
40
|
+
# =============================================================================
|
|
72
41
|
|
|
73
|
-
|
|
74
|
-
class
|
|
42
|
+
@dataclass
|
|
43
|
+
class TabData:
|
|
75
44
|
"""
|
|
76
|
-
|
|
77
|
-
|
|
45
|
+
Represents a single tab's data within a filter-tabs directive.
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
name: Display name of the tab
|
|
49
|
+
is_default: Whether this tab should be selected by default
|
|
50
|
+
aria_label: Optional ARIA label for accessibility
|
|
51
|
+
content: List of docutils nodes containing the tab's content
|
|
78
52
|
"""
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
"""Constructs the complete, W3C valid, and ARIA-compliant docutils node tree."""
|
|
89
|
-
# Ensure a unique ID for each filter-tabs instance on a page.
|
|
90
|
-
if not hasattr(self.env, 'filter_tabs_counter'):
|
|
91
|
-
self.env.filter_tabs_counter = 0
|
|
92
|
-
self.env.filter_tabs_counter += 1
|
|
93
|
-
group_id = f"filter-group-{self.env.filter_tabs_counter}"
|
|
94
|
-
|
|
95
|
-
config = self.env.app.config
|
|
96
|
-
|
|
97
|
-
# Create a dictionary of CSS Custom Properties from conf.py settings.
|
|
98
|
-
style_vars = {
|
|
99
|
-
"--sft-border-radius": str(config.filter_tabs_border_radius),
|
|
100
|
-
"--sft-tab-background": str(config.filter_tabs_tab_background_color),
|
|
101
|
-
"--sft-tab-font-size": str(config.filter_tabs_tab_font_size),
|
|
102
|
-
"--sft-tab-highlight-color": str(config.filter_tabs_tab_highlight_color),
|
|
103
|
-
"--sft-collapsible-accent-color": str(config.filter_tabs_collapsible_accent_color),
|
|
104
|
-
}
|
|
105
|
-
style_string = "; ".join([f"{key}: {value}" for key, value in style_vars.items()])
|
|
106
|
-
|
|
107
|
-
# If debug mode is on, log the generated ID and styles.
|
|
108
|
-
if config.filter_tabs_debug_mode:
|
|
109
|
-
logger.info(f"[sphinx-filter-tabs] ID: {group_id}, Styles: '{style_string}'")
|
|
110
|
-
|
|
111
|
-
# Create the main container node with the inline style for theming.
|
|
112
|
-
container = ContainerNode(classes=[SFT_CONTAINER], style=style_string)
|
|
113
|
-
|
|
114
|
-
# Build the semantic structure using fieldset and a hidden legend.
|
|
115
|
-
fieldset = FieldsetNode()
|
|
116
|
-
legend = LegendNode()
|
|
117
|
-
legend += nodes.Text(f"Filter by: {', '.join(self.tab_names)}")
|
|
118
|
-
fieldset += legend
|
|
119
|
-
|
|
120
|
-
# --- CSS Generation ---
|
|
121
|
-
# This generates the dynamic CSS that handles the core filtering logic.
|
|
122
|
-
css_rules = []
|
|
123
|
-
for tab_name in self.tab_names:
|
|
124
|
-
radio_id = f"{group_id}-{self._css_escape(tab_name)}"
|
|
125
|
-
panel_id = f"{radio_id}-panel"
|
|
126
|
-
# This rule finds the tab bar that contains the checked radio button,
|
|
127
|
-
# then finds its sibling content area and shows the correct panel inside.
|
|
128
|
-
css_rules.append(
|
|
129
|
-
f".{SFT_TAB_BAR}:has(#{radio_id}:checked) ~ .sft-content > #{panel_id} {{ display: block; }}"
|
|
130
|
-
)
|
|
131
|
-
|
|
132
|
-
# Write the dynamic CSS to a temporary file and add it to the build.
|
|
133
|
-
css_content = ''.join(css_rules)
|
|
134
|
-
static_dir = Path(self.env.app.outdir) / '_static'
|
|
135
|
-
static_dir.mkdir(parents=True, exist_ok=True)
|
|
136
|
-
css_filename = f"dynamic-filter-tabs-{group_id}.css"
|
|
137
|
-
(static_dir / css_filename).write_text(css_content, encoding='utf-8')
|
|
138
|
-
self.env.app.add_css_file(css_filename)
|
|
139
|
-
|
|
140
|
-
# --- ARIA-Compliant HTML Structure ---
|
|
141
|
-
# The tab bar container now gets the role="tablist".
|
|
142
|
-
tab_bar = nodes.container(classes=[SFT_TAB_BAR], role='tablist')
|
|
143
|
-
fieldset += tab_bar
|
|
144
|
-
|
|
145
|
-
# The content area holds all the panels.
|
|
146
|
-
content_area = nodes.container(classes=[SFT_CONTENT])
|
|
147
|
-
fieldset += content_area
|
|
148
|
-
|
|
149
|
-
# Map tab names to their content blocks for easy lookup.
|
|
150
|
-
content_map = {block['filter-name']: block.children for block in self.temp_blocks}
|
|
53
|
+
name: str
|
|
54
|
+
is_default: bool = False
|
|
55
|
+
aria_label: Optional[str] = None
|
|
56
|
+
content: List[nodes.Node] = field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
def __post_init__(self):
|
|
59
|
+
"""Validate tab data after initialization."""
|
|
60
|
+
if not self.name:
|
|
61
|
+
raise ValueError("Tab name cannot be empty")
|
|
151
62
|
|
|
152
|
-
#
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
panel_id = f"{radio_id}-panel"
|
|
63
|
+
# Ensure content is a list
|
|
64
|
+
if self.content is None:
|
|
65
|
+
self.content = []
|
|
156
66
|
|
|
157
|
-
# The radio button is for state management.
|
|
158
|
-
radio = RadioInputNode(type='radio', name=group_id, ids=[radio_id])
|
|
159
|
-
|
|
160
|
-
is_default = (self.default_tab == tab_name) or (i == 0 and not self.default_tab)
|
|
161
|
-
if is_default:
|
|
162
|
-
radio['checked'] = 'checked'
|
|
163
|
-
tab_bar += radio
|
|
164
|
-
|
|
165
|
-
# The label is the visible tab. It gets role="tab" and aria-controls.
|
|
166
|
-
label = LabelNode(for_id=radio_id, role='tab', **{'aria-controls': panel_id})
|
|
167
|
-
if is_default:
|
|
168
|
-
label['aria-selected'] = 'true'
|
|
169
|
-
label += nodes.Text(tab_name)
|
|
170
|
-
tab_bar += label
|
|
171
|
-
|
|
172
|
-
# 2. Create all tab panels and add them to the content_area.
|
|
173
|
-
all_tab_names = ["General"] + self.tab_names
|
|
174
|
-
for tab_name in all_tab_names:
|
|
175
|
-
# The "General" panel does not correspond to a specific tab control.
|
|
176
|
-
if tab_name == "General":
|
|
177
|
-
panel = PanelNode(classes=[SFT_PANEL], **{'data-filter': tab_name})
|
|
178
|
-
else:
|
|
179
|
-
radio_id = f"{group_id}-{self._css_escape(tab_name)}"
|
|
180
|
-
panel_id = f"{radio_id}-panel"
|
|
181
|
-
# The panel gets role="tabpanel" and is linked back to the label.
|
|
182
|
-
panel = PanelNode(classes=[SFT_PANEL], ids=[panel_id], role='tabpanel', **{'aria-labelledby': radio_id})
|
|
183
67
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
68
|
+
@dataclass
|
|
69
|
+
class FilterTabsConfig:
|
|
70
|
+
"""
|
|
71
|
+
Simplified configuration settings for filter-tabs rendering.
|
|
72
|
+
Reduced from 9 options to 2 essential ones.
|
|
73
|
+
"""
|
|
74
|
+
# Essential theming - only the highlight color is commonly customized
|
|
75
|
+
highlight_color: str = '#007bff'
|
|
76
|
+
|
|
77
|
+
# Development option
|
|
78
|
+
debug_mode: bool = False
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_sphinx_config(cls, app_config) -> 'FilterTabsConfig':
|
|
82
|
+
"""Create a FilterTabsConfig from Sphinx app.config."""
|
|
83
|
+
return cls(
|
|
84
|
+
highlight_color=getattr(
|
|
85
|
+
app_config, 'filter_tabs_highlight_color', cls.highlight_color
|
|
86
|
+
),
|
|
87
|
+
debug_mode=getattr(
|
|
88
|
+
app_config, 'filter_tabs_debug_mode', cls.debug_mode
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def to_css_properties(self) -> str:
|
|
93
|
+
"""Convert config to CSS custom properties string."""
|
|
94
|
+
# Generate CSS variables - the highlight color drives all other colors
|
|
95
|
+
return f"--sft-highlight-color: {self.highlight_color};"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass
|
|
99
|
+
class IDGenerator:
|
|
100
|
+
"""
|
|
101
|
+
Centralized ID generation for consistent element identification.
|
|
102
|
+
|
|
103
|
+
This ensures all IDs follow a consistent pattern and are unique
|
|
104
|
+
within their filter-tabs group.
|
|
105
|
+
"""
|
|
106
|
+
group_id: str
|
|
107
|
+
|
|
108
|
+
def radio_id(self, index: int) -> str:
|
|
109
|
+
"""Generate ID for a radio button."""
|
|
110
|
+
return f"{self.group_id}-radio-{index}"
|
|
111
|
+
|
|
112
|
+
def panel_id(self, index: int) -> str:
|
|
113
|
+
"""Generate ID for a content panel."""
|
|
114
|
+
return f"{self.group_id}-panel-{index}"
|
|
115
|
+
|
|
116
|
+
def desc_id(self, index: int) -> str:
|
|
117
|
+
"""Generate ID for a screen reader description."""
|
|
118
|
+
return f"{self.group_id}-desc-{index}"
|
|
119
|
+
|
|
120
|
+
def legend_id(self) -> str:
|
|
121
|
+
"""Generate ID for the fieldset legend."""
|
|
122
|
+
return f"{self.group_id}-legend"
|
|
123
|
+
|
|
124
|
+
def label_id(self, index: int) -> str:
|
|
125
|
+
"""Generate ID for a label (if needed)."""
|
|
126
|
+
return f"{self.group_id}-label-{index}"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# =============================================================================
|
|
130
|
+
# Directive Classes
|
|
131
|
+
# =============================================================================
|
|
187
132
|
|
|
188
|
-
|
|
189
|
-
|
|
133
|
+
# --- Constants ---
|
|
134
|
+
COLLAPSIBLE_SECTION = "collapsible-section"
|
|
135
|
+
COLLAPSIBLE_CONTENT = "collapsible-content"
|
|
136
|
+
CUSTOM_ARROW = "custom-arrow"
|
|
190
137
|
|
|
191
|
-
|
|
192
|
-
"""Renders content as a series of simple admonitions for non-HTML builders (e.g., LaTeX/PDF)."""
|
|
193
|
-
output_nodes: list[nodes.Node] = []
|
|
194
|
-
content_map = {block['filter-name']: block.children for block in self.temp_blocks}
|
|
195
|
-
# "General" content is rendered first, without a title.
|
|
196
|
-
if "General" in content_map:
|
|
197
|
-
output_nodes.extend(copy.deepcopy(content_map["General"]))
|
|
198
|
-
# Each specific tab's content is placed inside a titled admonition block.
|
|
199
|
-
for tab_name in self.tab_names:
|
|
200
|
-
if tab_name in content_map:
|
|
201
|
-
admonition = nodes.admonition()
|
|
202
|
-
admonition += nodes.title(text=tab_name)
|
|
203
|
-
admonition.extend(copy.deepcopy(content_map[tab_name]))
|
|
204
|
-
output_nodes.append(admonition)
|
|
205
|
-
return output_nodes
|
|
206
|
-
|
|
207
|
-
@staticmethod
|
|
208
|
-
def _css_escape(name: str) -> str:
|
|
209
|
-
"""
|
|
210
|
-
Generates a deterministic, CSS-safe identifier from any given tab name string.
|
|
211
|
-
This uses uuid.uuid5 to create a hashed value, which robustly prevents
|
|
212
|
-
CSS injection vulnerabilities that could arise from special characters in tab names.
|
|
213
|
-
"""
|
|
214
|
-
return str(uuid.uuid5(_CSS_NAMESPACE, name.strip().lower()))
|
|
138
|
+
logger = logging.getLogger(__name__)
|
|
215
139
|
|
|
216
140
|
|
|
217
141
|
class TabDirective(Directive):
|
|
218
|
-
"""Handles the `.. tab::` directive, capturing its content."""
|
|
142
|
+
"""Handles the `.. tab::` directive, capturing its content and options."""
|
|
219
143
|
has_content = True
|
|
220
144
|
required_arguments = 1
|
|
221
145
|
final_argument_whitespace = True
|
|
146
|
+
option_spec = {'aria-label': directives.unchanged}
|
|
222
147
|
|
|
223
148
|
def run(self) -> list[nodes.Node]:
|
|
224
|
-
"""
|
|
225
|
-
Parses the content of a tab and stores it in a temporary container.
|
|
226
|
-
This method validates that the directive is used within a `filter-tabs` block.
|
|
227
|
-
"""
|
|
149
|
+
"""Process the tab directive and return container node."""
|
|
228
150
|
env = self.state.document.settings.env
|
|
229
|
-
|
|
151
|
+
|
|
152
|
+
# Validate context
|
|
230
153
|
if not hasattr(env, 'sft_context') or not env.sft_context:
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
154
|
+
raise self.error("`tab` can only be used inside a `filter-tabs` directive.")
|
|
155
|
+
|
|
156
|
+
# Parse tab argument - moved from parsers.py
|
|
157
|
+
try:
|
|
158
|
+
tab_name, is_default = self._parse_tab_argument(self.arguments[0])
|
|
159
|
+
except ValueError as e:
|
|
160
|
+
raise self.error(f"Invalid tab argument: {e}")
|
|
161
|
+
|
|
162
|
+
# Create container with parsed data
|
|
163
|
+
container = nodes.container(classes=["sft-temp-panel"])
|
|
164
|
+
container['filter_name'] = tab_name
|
|
165
|
+
container['is_default'] = is_default
|
|
166
|
+
container['aria_label'] = self.options.get('aria-label', None)
|
|
167
|
+
|
|
168
|
+
# Parse the content
|
|
235
169
|
self.state.nested_parse(self.content, self.content_offset, container)
|
|
170
|
+
|
|
236
171
|
return [container]
|
|
172
|
+
|
|
173
|
+
def _parse_tab_argument(self, argument: str) -> tuple[str, bool]:
|
|
174
|
+
"""Parse a tab argument string to extract name and default status."""
|
|
175
|
+
import re
|
|
176
|
+
|
|
177
|
+
logger.debug(f"Parsing tab argument: '{argument}'")
|
|
178
|
+
if not argument:
|
|
179
|
+
raise ValueError("Tab argument cannot be empty")
|
|
180
|
+
|
|
181
|
+
lines = argument.strip().split('\n')
|
|
182
|
+
first_line = lines[0].strip()
|
|
183
|
+
|
|
184
|
+
if len(lines) > 1:
|
|
185
|
+
logger.debug(
|
|
186
|
+
f"Tab argument contains multiple lines, using only first: '{first_line}'"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
DEFAULT_PATTERN = re.compile(r"^(.*?)\s*\(\s*default\s*\)$", re.IGNORECASE)
|
|
190
|
+
match = DEFAULT_PATTERN.match(first_line)
|
|
191
|
+
if match:
|
|
192
|
+
tab_name = match.group(1).strip()
|
|
193
|
+
if not tab_name:
|
|
194
|
+
raise ValueError("Tab name cannot be empty")
|
|
195
|
+
return tab_name, True
|
|
196
|
+
|
|
197
|
+
return first_line, False
|
|
237
198
|
|
|
238
199
|
|
|
239
200
|
class FilterTabsDirective(Directive):
|
|
240
|
-
"""Handles the main `.. filter-tabs::` directive
|
|
201
|
+
"""Handles the main `.. filter-tabs::` directive."""
|
|
241
202
|
has_content = True
|
|
242
|
-
required_arguments =
|
|
243
|
-
|
|
203
|
+
required_arguments = 0
|
|
204
|
+
optional_arguments = 0
|
|
205
|
+
option_spec = {'legend': directives.unchanged}
|
|
244
206
|
|
|
245
207
|
def run(self) -> list[nodes.Node]:
|
|
246
|
-
"""
|
|
247
|
-
Parses the list of tabs, manages the parsing context for its content,
|
|
248
|
-
and delegates the final rendering to the FilterTabsRenderer.
|
|
249
|
-
"""
|
|
208
|
+
"""Process the filter-tabs directive."""
|
|
250
209
|
env = self.state.document.settings.env
|
|
251
210
|
|
|
252
|
-
#
|
|
253
|
-
#if hasattr(env, 'sft_context') and env.sft_context:
|
|
254
|
-
# raise self.error("Nesting `filter-tabs` is not supported.")
|
|
255
|
-
|
|
256
|
-
# Set a context flag to indicate that we are inside a filter-tabs block.
|
|
211
|
+
# Set up context
|
|
257
212
|
if not hasattr(env, 'sft_context'):
|
|
258
213
|
env.sft_context = []
|
|
259
214
|
env.sft_context.append(True)
|
|
260
215
|
|
|
261
|
-
# Parse
|
|
216
|
+
# Parse content
|
|
262
217
|
temp_container = nodes.container()
|
|
263
218
|
self.state.nested_parse(self.content, self.content_offset, temp_container)
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
219
|
+
|
|
220
|
+
env.sft_context.pop()
|
|
221
|
+
|
|
222
|
+
# Get the custom legend option
|
|
223
|
+
custom_legend = self.options.get('legend')
|
|
224
|
+
|
|
225
|
+
# Separate general content from tabs using TabData
|
|
226
|
+
general_content = []
|
|
227
|
+
tab_data_list: List[TabData] = []
|
|
228
|
+
|
|
229
|
+
for node in temp_container.children:
|
|
230
|
+
if isinstance(node, nodes.Element) and "sft-temp-panel" in node.get('classes', []):
|
|
231
|
+
# Create TabData object instead of dictionary
|
|
232
|
+
tab_data = TabData(
|
|
233
|
+
name=node.get('filter_name', 'Unknown'),
|
|
234
|
+
is_default=node.get('is_default', False),
|
|
235
|
+
aria_label=node.get('aria_label', None),
|
|
236
|
+
content=list(node.children)
|
|
237
|
+
)
|
|
238
|
+
tab_data_list.append(tab_data)
|
|
239
|
+
else:
|
|
240
|
+
general_content.append(node)
|
|
241
|
+
|
|
242
|
+
# Validate tabs - moved from parsers.py
|
|
243
|
+
if not tab_data_list:
|
|
244
|
+
error_message = (
|
|
245
|
+
"No `.. tab::` directives found inside `.. filter-tabs::`. "
|
|
246
|
+
"You must include at least one tab."
|
|
247
|
+
)
|
|
248
|
+
if general_content:
|
|
249
|
+
error_message += (
|
|
250
|
+
" Some content was found, but it was not part of a `.. tab::` block."
|
|
251
|
+
)
|
|
252
|
+
raise self.error(error_message)
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
self._validate_tabs(tab_data_list, skip_empty_check=True)
|
|
256
|
+
except ValueError as e:
|
|
257
|
+
raise self.error(str(e))
|
|
258
|
+
|
|
259
|
+
# Set first tab as default if none specified
|
|
260
|
+
if not any(tab.is_default for tab in tab_data_list):
|
|
261
|
+
tab_data_list[0].is_default = True
|
|
262
|
+
|
|
263
|
+
# Import renderer here to avoid circular imports
|
|
264
|
+
from .renderer import FilterTabsRenderer
|
|
265
|
+
renderer = FilterTabsRenderer(self, tab_data_list, general_content, custom_legend=custom_legend)
|
|
266
|
+
|
|
267
|
+
# Render based on builder
|
|
295
268
|
if env.app.builder.name == 'html':
|
|
296
269
|
return renderer.render_html()
|
|
297
270
|
else:
|
|
298
271
|
return renderer.render_fallback()
|
|
272
|
+
|
|
273
|
+
def _validate_tabs(self, tab_data_list: List[TabData], skip_empty_check: bool = False) -> None:
|
|
274
|
+
"""Validate tab data list - moved from parsers.py"""
|
|
275
|
+
if not tab_data_list and not skip_empty_check:
|
|
276
|
+
raise ValueError(
|
|
277
|
+
"No tab directives found inside filter-tabs. "
|
|
278
|
+
"Add at least one .. tab:: directive."
|
|
279
|
+
)
|
|
280
|
+
names = []
|
|
281
|
+
for tab in tab_data_list:
|
|
282
|
+
name = tab.name
|
|
283
|
+
if name in names:
|
|
284
|
+
raise ValueError(
|
|
285
|
+
f"Duplicate tab name '{name}'. Each tab must have a unique name."
|
|
286
|
+
)
|
|
287
|
+
names.append(name)
|
|
288
|
+
if not tab.content:
|
|
289
|
+
logger.warning(f"Tab '{name}' has no content.")
|
|
290
|
+
|
|
291
|
+
default_count = 0
|
|
292
|
+
default_names = []
|
|
293
|
+
for tab in tab_data_list:
|
|
294
|
+
if tab.is_default:
|
|
295
|
+
default_count += 1
|
|
296
|
+
default_names.append(tab.name)
|
|
297
|
+
|
|
298
|
+
if default_count > 1:
|
|
299
|
+
logger.warning(
|
|
300
|
+
f"Multiple tabs marked as default: {', '.join(default_names)}. "
|
|
301
|
+
f"Using first default: '{default_names[0]}'"
|
|
302
|
+
)
|
|
303
|
+
|
|
299
304
|
|
|
305
|
+
# =============================================================================
|
|
306
|
+
# Collapsible Admonitions Support
|
|
307
|
+
# =============================================================================
|
|
300
308
|
|
|
301
309
|
def setup_collapsible_admonitions(app: Sphinx, doctree: nodes.document, docname: str):
|
|
302
|
-
"""
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
This hook runs after the document tree is resolved.
|
|
306
|
-
"""
|
|
307
|
-
if not app.config.filter_tabs_collapsible_enabled or app.builder.name != 'html':
|
|
310
|
+
"""Convert admonitions with 'collapsible' class to details/summary elements."""
|
|
311
|
+
# Simplified: always enabled for HTML builds, no config needed
|
|
312
|
+
if app.builder.name != 'html':
|
|
308
313
|
return
|
|
309
|
-
|
|
310
|
-
# Iterate over a copy of the list of nodes to allow for safe modification.
|
|
314
|
+
|
|
311
315
|
for node in list(doctree.findall(nodes.admonition)):
|
|
312
316
|
if 'collapsible' not in node.get('classes', []):
|
|
313
317
|
continue
|
|
314
|
-
|
|
318
|
+
|
|
315
319
|
is_expanded = 'expanded' in node.get('classes', [])
|
|
316
320
|
title_node = next(iter(node.findall(nodes.title)), None)
|
|
317
321
|
summary_text = title_node.astext() if title_node else "Details"
|
|
322
|
+
|
|
318
323
|
if title_node:
|
|
319
|
-
title_node.parent.remove(title_node)
|
|
324
|
+
title_node.parent.remove(title_node)
|
|
320
325
|
|
|
321
|
-
# Create the new <details> node.
|
|
322
326
|
details_node = DetailsNode(classes=[COLLAPSIBLE_SECTION])
|
|
323
327
|
if is_expanded:
|
|
324
328
|
details_node['open'] = 'open'
|
|
325
|
-
|
|
326
|
-
# Create the new <summary> node with a custom arrow.
|
|
329
|
+
|
|
327
330
|
summary_node = SummaryNode()
|
|
328
331
|
arrow_span = nodes.inline(classes=[CUSTOM_ARROW])
|
|
329
|
-
arrow_span += nodes.Text("
|
|
332
|
+
arrow_span += nodes.Text("▶")
|
|
330
333
|
summary_node += arrow_span
|
|
331
334
|
summary_node += nodes.Text(summary_text)
|
|
332
335
|
details_node += summary_node
|
|
333
|
-
|
|
334
|
-
# Move the original content of the admonition into a new container.
|
|
336
|
+
|
|
335
337
|
content_node = nodes.container(classes=[COLLAPSIBLE_CONTENT])
|
|
336
338
|
content_node.extend(copy.deepcopy(node.children))
|
|
337
339
|
details_node += content_node
|
|
338
|
-
|
|
340
|
+
|
|
339
341
|
node.replace_self(details_node)
|
|
340
342
|
|
|
343
|
+
|
|
344
|
+
# =============================================================================
|
|
345
|
+
# HTML Visitor Functions
|
|
346
|
+
# =============================================================================
|
|
347
|
+
|
|
341
348
|
def _get_html_attrs(node: nodes.Element) -> Dict[str, Any]:
|
|
342
|
-
"""
|
|
349
|
+
"""Extract HTML attributes from a docutils node, excluding internal attributes."""
|
|
343
350
|
attrs = node.attributes.copy()
|
|
344
|
-
# Remove docutils-internal attributes to avoid rendering them in the HTML.
|
|
345
351
|
for key in ('ids', 'backrefs', 'dupnames', 'names', 'classes', 'id', 'for_id'):
|
|
346
352
|
attrs.pop(key, None)
|
|
347
353
|
return attrs
|
|
348
354
|
|
|
349
|
-
# --- HTML Visitor Functions ---
|
|
350
|
-
# These functions translate the custom docutils nodes into HTML tags.
|
|
351
355
|
|
|
352
356
|
def visit_container_node(self: HTML5Translator, node: ContainerNode) -> None:
|
|
353
357
|
self.body.append(self.starttag(node, 'div', **_get_html_attrs(node)))
|
|
358
|
+
|
|
354
359
|
def depart_container_node(self: HTML5Translator, node: ContainerNode) -> None:
|
|
355
360
|
self.body.append('</div>')
|
|
356
361
|
|
|
357
362
|
def visit_fieldset_node(self: HTML5Translator, node: FieldsetNode) -> None:
|
|
358
|
-
|
|
363
|
+
attrs = _get_html_attrs(node)
|
|
364
|
+
if 'role' in node.attributes:
|
|
365
|
+
attrs['role'] = node['role']
|
|
366
|
+
self.body.append(self.starttag(node, 'fieldset', CLASS="sft-fieldset", **attrs))
|
|
367
|
+
|
|
359
368
|
def depart_fieldset_node(self: HTML5Translator, node: FieldsetNode) -> None:
|
|
360
369
|
self.body.append('</fieldset>')
|
|
361
370
|
|
|
362
371
|
def visit_legend_node(self: HTML5Translator, node: LegendNode) -> None:
|
|
363
|
-
self.body.append(self.starttag(node, 'legend', CLASS=
|
|
372
|
+
self.body.append(self.starttag(node, 'legend', CLASS="sft-legend"))
|
|
373
|
+
|
|
364
374
|
def depart_legend_node(self: HTML5Translator, node: LegendNode) -> None:
|
|
365
375
|
self.body.append('</legend>')
|
|
366
376
|
|
|
367
377
|
def visit_radio_input_node(self: HTML5Translator, node: RadioInputNode) -> None:
|
|
368
|
-
|
|
378
|
+
attrs = _get_html_attrs(node)
|
|
379
|
+
for key in ['type', 'name', 'checked', 'aria-label']:
|
|
380
|
+
if key in node.attributes:
|
|
381
|
+
attrs[key] = node[key]
|
|
382
|
+
self.body.append(self.starttag(node, 'input', **attrs))
|
|
383
|
+
|
|
369
384
|
def depart_radio_input_node(self: HTML5Translator, node: RadioInputNode) -> None:
|
|
370
|
-
pass
|
|
385
|
+
pass # Self-closing tag
|
|
371
386
|
|
|
372
387
|
def visit_label_node(self: HTML5Translator, node: LabelNode) -> None:
|
|
373
388
|
attrs = _get_html_attrs(node)
|
|
374
|
-
|
|
389
|
+
if 'for_id' in node.attributes:
|
|
390
|
+
attrs['for'] = node['for_id']
|
|
375
391
|
self.body.append(self.starttag(node, 'label', **attrs))
|
|
392
|
+
|
|
376
393
|
def depart_label_node(self: HTML5Translator, node: LabelNode) -> None:
|
|
377
394
|
self.body.append('</label>')
|
|
378
395
|
|
|
379
396
|
def visit_panel_node(self: HTML5Translator, node: PanelNode) -> None:
|
|
380
|
-
|
|
397
|
+
attrs = _get_html_attrs(node)
|
|
398
|
+
for key in ['role', 'aria-labelledby', 'tabindex']:
|
|
399
|
+
if key in node.attributes:
|
|
400
|
+
attrs[key] = node[key]
|
|
401
|
+
self.body.append(self.starttag(node, 'div', CLASS="sft-panel", **attrs))
|
|
402
|
+
|
|
381
403
|
def depart_panel_node(self: HTML5Translator, node: PanelNode) -> None:
|
|
382
404
|
self.body.append('</div>')
|
|
383
405
|
|
|
384
406
|
def visit_details_node(self: HTML5Translator, node: DetailsNode) -> None:
|
|
385
|
-
|
|
407
|
+
attrs = _get_html_attrs(node)
|
|
408
|
+
if 'open' in node.attributes:
|
|
409
|
+
attrs['open'] = 'open'
|
|
410
|
+
self.body.append(self.starttag(node, 'details', **attrs))
|
|
411
|
+
|
|
386
412
|
def depart_details_node(self: HTML5Translator, node: DetailsNode) -> None:
|
|
387
413
|
self.body.append('</details>')
|
|
388
414
|
|
|
389
415
|
def visit_summary_node(self: HTML5Translator, node: SummaryNode) -> None:
|
|
390
416
|
self.body.append(self.starttag(node, 'summary', **_get_html_attrs(node)))
|
|
417
|
+
|
|
391
418
|
def depart_summary_node(self: HTML5Translator, node: SummaryNode) -> None:
|
|
392
419
|
self.body.append('</summary>')
|
|
393
420
|
|
|
394
421
|
|
|
422
|
+
# =============================================================================
|
|
423
|
+
# Static File Handling
|
|
424
|
+
# =============================================================================
|
|
425
|
+
|
|
395
426
|
def copy_static_files(app: Sphinx):
|
|
396
|
-
"""
|
|
397
|
-
Copies the extension's static CSS file to the build output directory.
|
|
398
|
-
This hook runs when the builder is initialized, ensuring the CSS file is
|
|
399
|
-
always available for HTML builds without complex packaging maneuvers.
|
|
400
|
-
"""
|
|
427
|
+
"""Copy CSS and JS files to the build directory."""
|
|
401
428
|
if app.builder.name != 'html':
|
|
402
429
|
return
|
|
403
|
-
|
|
430
|
+
|
|
431
|
+
static_source_dir = Path(__file__).parent / "static"
|
|
404
432
|
dest_dir = Path(app.outdir) / "_static"
|
|
405
433
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
406
|
-
|
|
407
|
-
|
|
434
|
+
|
|
435
|
+
# Only copy the consolidated CSS file
|
|
436
|
+
css_file = static_source_dir / "filter_tabs.css"
|
|
437
|
+
if css_file.exists():
|
|
438
|
+
shutil.copy(css_file, dest_dir)
|
|
439
|
+
|
|
440
|
+
# Copy JS file if it exists
|
|
441
|
+
js_file = static_source_dir / "filter_tabs.js"
|
|
442
|
+
if js_file.exists():
|
|
443
|
+
shutil.copy(js_file, dest_dir)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# =============================================================================
|
|
447
|
+
# Extension Setup
|
|
448
|
+
# =============================================================================
|
|
408
449
|
|
|
409
450
|
def setup(app: Sphinx) -> Dict[str, Any]:
|
|
410
|
-
"""
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
# Register custom configuration values, allowing users to theme from conf.py.
|
|
415
|
-
app.add_config_value('filter_tabs_tab_highlight_color', '#007bff', 'html', [str])
|
|
416
|
-
app.add_config_value('filter_tabs_tab_background_color', '#f0f0f0', 'html', [str])
|
|
417
|
-
app.add_config_value('filter_tabs_tab_font_size', '1em', 'html', [str])
|
|
418
|
-
app.add_config_value('filter_tabs_border_radius', '8px', 'html', [str])
|
|
451
|
+
"""Setup the Sphinx extension with minimal configuration."""
|
|
452
|
+
|
|
453
|
+
# ONLY essential configuration options (down from 9 to 2)
|
|
454
|
+
app.add_config_value('filter_tabs_highlight_color', '#007bff', 'html', [str])
|
|
419
455
|
app.add_config_value('filter_tabs_debug_mode', False, 'html', [bool])
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
# Add the main stylesheet to the HTML output.
|
|
456
|
+
|
|
457
|
+
# Add static files
|
|
424
458
|
app.add_css_file('filter_tabs.css')
|
|
425
|
-
|
|
426
|
-
|
|
459
|
+
app.add_js_file('filter_tabs.js')
|
|
460
|
+
|
|
461
|
+
# Register custom nodes (keep existing node registration code)
|
|
427
462
|
app.add_node(ContainerNode, html=(visit_container_node, depart_container_node))
|
|
428
463
|
app.add_node(FieldsetNode, html=(visit_fieldset_node, depart_fieldset_node))
|
|
429
464
|
app.add_node(LegendNode, html=(visit_legend_node, depart_legend_node))
|
|
@@ -432,16 +467,15 @@ def setup(app: Sphinx) -> Dict[str, Any]:
|
|
|
432
467
|
app.add_node(PanelNode, html=(visit_panel_node, depart_panel_node))
|
|
433
468
|
app.add_node(DetailsNode, html=(visit_details_node, depart_details_node))
|
|
434
469
|
app.add_node(SummaryNode, html=(visit_summary_node, depart_summary_node))
|
|
435
|
-
|
|
436
|
-
# Register
|
|
470
|
+
|
|
471
|
+
# Register directives
|
|
437
472
|
app.add_directive('filter-tabs', FilterTabsDirective)
|
|
438
473
|
app.add_directive('tab', TabDirective)
|
|
439
|
-
|
|
440
|
-
# Connect
|
|
441
|
-
app.connect('doctree-resolved', setup_collapsible_admonitions)
|
|
474
|
+
|
|
475
|
+
# Connect event handlers
|
|
442
476
|
app.connect('builder-inited', copy_static_files)
|
|
443
|
-
|
|
444
|
-
|
|
477
|
+
app.connect('doctree-resolved', setup_collapsible_admonitions)
|
|
478
|
+
|
|
445
479
|
return {
|
|
446
480
|
'version': __version__,
|
|
447
481
|
'parallel_read_safe': True,
|