sphinx-filter-tabs 0.9.2__py3-none-any.whl → 1.2.2__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 +308 -298
- filter_tabs/renderer.py +261 -0
- filter_tabs/static/filter_tabs.css +96 -79
- filter_tabs/static/filter_tabs.js +73 -43
- {sphinx_filter_tabs-0.9.2.dist-info → sphinx_filter_tabs-1.2.2.dist-info}/METADATA +11 -6
- sphinx_filter_tabs-1.2.2.dist-info/RECORD +11 -0
- sphinx_filter_tabs-0.9.2.dist-info/RECORD +0 -10
- {sphinx_filter_tabs-0.9.2.dist-info → sphinx_filter_tabs-1.2.2.dist-info}/WHEEL +0 -0
- {sphinx_filter_tabs-0.9.2.dist-info → sphinx_filter_tabs-1.2.2.dist-info}/entry_points.txt +0 -0
- {sphinx_filter_tabs-0.9.2.dist-info → sphinx_filter_tabs-1.2.2.dist-info}/licenses/LICENSE +0 -0
- {sphinx_filter_tabs-0.9.2.dist-info → sphinx_filter_tabs-1.2.2.dist-info}/top_level.txt +0 -0
filter_tabs/extension.py
CHANGED
|
@@ -1,380 +1,393 @@
|
|
|
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
7
|
from __future__ import annotations
|
|
7
|
-
|
|
8
|
-
import re
|
|
9
|
-
import uuid
|
|
10
8
|
import copy
|
|
11
9
|
import shutil
|
|
12
10
|
from pathlib import Path
|
|
13
11
|
from docutils import nodes
|
|
14
|
-
from docutils.parsers.rst import Directive
|
|
12
|
+
from docutils.parsers.rst import Directive, directives
|
|
15
13
|
from sphinx.application import Sphinx
|
|
16
14
|
from sphinx.util import logging
|
|
17
15
|
from sphinx.writers.html import HTML5Translator
|
|
18
16
|
|
|
19
|
-
from typing import TYPE_CHECKING, Any, Dict, List
|
|
17
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
|
18
|
+
from dataclasses import dataclass, field
|
|
20
19
|
from . import __version__
|
|
21
20
|
|
|
22
21
|
if TYPE_CHECKING:
|
|
23
|
-
from sphinx.config import Config
|
|
24
22
|
from sphinx.environment import BuildEnvironment
|
|
25
23
|
|
|
26
|
-
#
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
SFT_CONTAINER = "sft-container"
|
|
30
|
-
SFT_FIELDSET = "sft-fieldset"
|
|
31
|
-
SFT_LEGEND = "sft-legend"
|
|
32
|
-
SFT_TAB_BAR = "sft-tab-bar"
|
|
33
|
-
SFT_CONTENT = "sft-content"
|
|
34
|
-
SFT_PANEL = "sft-panel"
|
|
35
|
-
SFT_TEMP_PANEL = "sft-temp-panel"
|
|
36
|
-
COLLAPSIBLE_SECTION = "collapsible-section"
|
|
37
|
-
COLLAPSIBLE_CONTENT = "collapsible-content"
|
|
38
|
-
CUSTOM_ARROW = "custom-arrow"
|
|
24
|
+
# =============================================================================
|
|
25
|
+
# Custom Docutils Nodes
|
|
26
|
+
# =============================================================================
|
|
39
27
|
|
|
40
|
-
|
|
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
|
|
41
36
|
|
|
42
|
-
# --- Custom Nodes ---
|
|
43
|
-
class ContainerNode(nodes.General, nodes.Element):
|
|
44
|
-
pass
|
|
45
37
|
|
|
46
|
-
|
|
47
|
-
|
|
38
|
+
# =============================================================================
|
|
39
|
+
# Data Models and Configuration
|
|
40
|
+
# =============================================================================
|
|
48
41
|
|
|
49
|
-
|
|
50
|
-
|
|
42
|
+
@dataclass
|
|
43
|
+
class TabData:
|
|
44
|
+
"""
|
|
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
|
|
52
|
+
"""
|
|
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")
|
|
62
|
+
|
|
63
|
+
# Ensure content is a list
|
|
64
|
+
if self.content is None:
|
|
65
|
+
self.content = []
|
|
51
66
|
|
|
52
|
-
class RadioInputNode(nodes.General, nodes.Element):
|
|
53
|
-
pass
|
|
54
67
|
|
|
55
|
-
|
|
56
|
-
|
|
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};"
|
|
57
96
|
|
|
58
|
-
class PanelNode(nodes.General, nodes.Element):
|
|
59
|
-
pass
|
|
60
97
|
|
|
61
|
-
|
|
62
|
-
|
|
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}"
|
|
63
127
|
|
|
64
|
-
class SummaryNode(nodes.General, nodes.Element):
|
|
65
|
-
pass
|
|
66
128
|
|
|
129
|
+
# =============================================================================
|
|
130
|
+
# Directive Classes
|
|
131
|
+
# =============================================================================
|
|
67
132
|
|
|
68
|
-
# ---
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
into a final node structure for both HTML and fallback formats (like LaTeX).
|
|
73
|
-
"""
|
|
74
|
-
def __init__(self, directive: Directive, tab_names: list[str], default_tab: str, temp_blocks: list[nodes.Node]):
|
|
75
|
-
"""Initializes the renderer with all necessary context from the directive."""
|
|
76
|
-
self.directive: Directive = directive
|
|
77
|
-
self.env: BuildEnvironment = directive.state.document.settings.env
|
|
78
|
-
self.tab_names: list[str] = tab_names
|
|
79
|
-
self.default_tab: str = default_tab
|
|
80
|
-
self.temp_blocks: list[nodes.Node] = temp_blocks
|
|
81
|
-
|
|
82
|
-
def render_html(self) -> list[nodes.Node]:
|
|
83
|
-
"""Constructs the complete, W3C valid, and ARIA-compliant docutils node tree."""
|
|
84
|
-
# Ensure a unique ID for each filter-tabs instance on a page.
|
|
85
|
-
if not hasattr(self.env, 'filter_tabs_counter'):
|
|
86
|
-
self.env.filter_tabs_counter = 0
|
|
87
|
-
self.env.filter_tabs_counter += 1
|
|
88
|
-
group_id = f"filter-group-{self.env.filter_tabs_counter}"
|
|
89
|
-
|
|
90
|
-
config = self.env.app.config
|
|
91
|
-
|
|
92
|
-
# Create a dictionary of CSS Custom Properties from conf.py settings.
|
|
93
|
-
style_vars = {
|
|
94
|
-
"--sft-border-radius": str(config.filter_tabs_border_radius),
|
|
95
|
-
"--sft-tab-background": str(config.filter_tabs_tab_background_color),
|
|
96
|
-
"--sft-tab-font-size": str(config.filter_tabs_tab_font_size),
|
|
97
|
-
"--sft-tab-highlight-color": str(config.filter_tabs_tab_highlight_color),
|
|
98
|
-
"--sft-collapsible-accent-color": str(config.filter_tabs_collapsible_accent_color),
|
|
99
|
-
}
|
|
100
|
-
style_string = "; ".join([f"{key}: {value}" for key, value in style_vars.items()])
|
|
101
|
-
|
|
102
|
-
# If debug mode is on, log the generated ID and styles.
|
|
103
|
-
if config.filter_tabs_debug_mode:
|
|
104
|
-
logger.info(f"[sphinx-filter-tabs] ID: {group_id}, Styles: '{style_string}'")
|
|
105
|
-
|
|
106
|
-
# Create the main container node with the inline style for theming.
|
|
107
|
-
container = ContainerNode(classes=[SFT_CONTAINER], style=style_string)
|
|
108
|
-
|
|
109
|
-
# Build the semantic structure using fieldset and a hidden legend.
|
|
110
|
-
fieldset = FieldsetNode()
|
|
111
|
-
legend = LegendNode()
|
|
112
|
-
legend += nodes.Text(f"Filter by: {', '.join(self.tab_names)}")
|
|
113
|
-
fieldset += legend
|
|
114
|
-
|
|
115
|
-
# --- CSS Generation ---
|
|
116
|
-
css_rules = []
|
|
117
|
-
for i, tab_name in enumerate(self.tab_names):
|
|
118
|
-
# FIX 1: Use position-based IDs instead of hash-based
|
|
119
|
-
radio_id = f"{group_id}-tab-{i}"
|
|
120
|
-
panel_id = f"{radio_id}-panel"
|
|
121
|
-
css_rules.append(
|
|
122
|
-
f".{SFT_TAB_BAR}:has(#{radio_id}:checked) ~ .sft-content > #{panel_id} {{ display: block; }}"
|
|
123
|
-
)
|
|
124
|
-
|
|
125
|
-
# Write the dynamic CSS to a temporary file and add it to the build.
|
|
126
|
-
css_content = ''.join(css_rules)
|
|
127
|
-
static_dir = Path(self.env.app.outdir) / '_static'
|
|
128
|
-
static_dir.mkdir(parents=True, exist_ok=True)
|
|
129
|
-
css_filename = f"dynamic-filter-tabs-{group_id}.css"
|
|
130
|
-
(static_dir / css_filename).write_text(css_content, encoding='utf-8')
|
|
131
|
-
self.env.app.add_css_file(css_filename)
|
|
132
|
-
|
|
133
|
-
# The tab bar container - NO ARIA attributes here
|
|
134
|
-
tab_bar = ContainerNode(classes=[SFT_TAB_BAR])
|
|
135
|
-
fieldset += tab_bar
|
|
136
|
-
|
|
137
|
-
# The content area holds all the panels
|
|
138
|
-
content_area = ContainerNode(classes=[SFT_CONTENT])
|
|
139
|
-
fieldset += content_area
|
|
140
|
-
|
|
141
|
-
# Map tab names to their content blocks for easy lookup.
|
|
142
|
-
content_map = {block['filter-name']: block.children for block in self.temp_blocks}
|
|
143
|
-
|
|
144
|
-
# 1. Create all radio buttons and labels - NO ARIA on labels
|
|
145
|
-
for i, tab_name in enumerate(self.tab_names):
|
|
146
|
-
# FIX 1: Use position-based IDs
|
|
147
|
-
radio_id = f"{group_id}-tab-{i}"
|
|
148
|
-
panel_id = f"{radio_id}-panel"
|
|
149
|
-
|
|
150
|
-
# The radio button is for state management.
|
|
151
|
-
radio = RadioInputNode(type='radio', name=group_id, ids=[radio_id])
|
|
152
|
-
|
|
153
|
-
is_default = (self.default_tab == tab_name) or (i == 0 and not self.default_tab)
|
|
154
|
-
if is_default:
|
|
155
|
-
radio['checked'] = 'checked'
|
|
156
|
-
tab_bar += radio
|
|
157
|
-
|
|
158
|
-
# FIX 2: Remove ALL ARIA attributes from labels - just use for_id
|
|
159
|
-
# FIX 3: Don't add IDs to labels - they don't need them
|
|
160
|
-
label = LabelNode(for_id=radio_id)
|
|
161
|
-
label += nodes.Text(tab_name)
|
|
162
|
-
tab_bar += label
|
|
163
|
-
|
|
164
|
-
# 2. Create all tab panels - panels can have ARIA attributes
|
|
165
|
-
all_tab_names = ["General"] + self.tab_names
|
|
166
|
-
for i, tab_name in enumerate(all_tab_names):
|
|
167
|
-
if tab_name == "General":
|
|
168
|
-
# General panel doesn't correspond to a specific tab control
|
|
169
|
-
panel = PanelNode(
|
|
170
|
-
classes=[SFT_PANEL],
|
|
171
|
-
**{'data-filter': tab_name}
|
|
172
|
-
)
|
|
173
|
-
else:
|
|
174
|
-
# FIX 1: Use position-based IDs for panels too
|
|
175
|
-
tab_index = self.tab_names.index(tab_name)
|
|
176
|
-
radio_id = f"{group_id}-tab-{tab_index}"
|
|
177
|
-
panel_id = f"{radio_id}-panel"
|
|
178
|
-
|
|
179
|
-
# Panels can have ARIA attributes for screen readers
|
|
180
|
-
panel_attrs = {
|
|
181
|
-
'classes': [SFT_PANEL],
|
|
182
|
-
'ids': [panel_id],
|
|
183
|
-
'role': 'tabpanel',
|
|
184
|
-
'aria-labelledby': radio_id,
|
|
185
|
-
'tabindex': '0'
|
|
186
|
-
}
|
|
187
|
-
panel = PanelNode(**panel_attrs)
|
|
188
|
-
|
|
189
|
-
if tab_name in content_map:
|
|
190
|
-
panel.extend(copy.deepcopy(content_map[tab_name]))
|
|
191
|
-
content_area += panel
|
|
192
|
-
|
|
193
|
-
container.children = [fieldset]
|
|
194
|
-
return [container]
|
|
133
|
+
# --- Constants ---
|
|
134
|
+
COLLAPSIBLE_SECTION = "collapsible-section"
|
|
135
|
+
COLLAPSIBLE_CONTENT = "collapsible-content"
|
|
136
|
+
CUSTOM_ARROW = "custom-arrow"
|
|
195
137
|
|
|
196
|
-
|
|
197
|
-
"""Renders content as a series of simple admonitions for non-HTML builders (e.g., LaTeX/PDF)."""
|
|
198
|
-
output_nodes: list[nodes.Node] = []
|
|
199
|
-
content_map = {block['filter-name']: block.children for block in self.temp_blocks}
|
|
200
|
-
# "General" content is rendered first, without a title.
|
|
201
|
-
if "General" in content_map:
|
|
202
|
-
output_nodes.extend(copy.deepcopy(content_map["General"]))
|
|
203
|
-
# Each specific tab's content is placed inside a titled admonition block.
|
|
204
|
-
for tab_name in self.tab_names:
|
|
205
|
-
if tab_name in content_map:
|
|
206
|
-
admonition = nodes.admonition()
|
|
207
|
-
admonition += nodes.title(text=tab_name)
|
|
208
|
-
admonition.extend(copy.deepcopy(content_map[tab_name]))
|
|
209
|
-
output_nodes.append(admonition)
|
|
210
|
-
return output_nodes
|
|
211
|
-
|
|
212
|
-
@staticmethod
|
|
213
|
-
def _css_escape(name: str) -> str:
|
|
214
|
-
"""
|
|
215
|
-
Generates a deterministic, CSS-safe identifier from any given tab name string.
|
|
216
|
-
"""
|
|
217
|
-
return str(uuid.uuid5(_CSS_NAMESPACE, name.strip().lower()))
|
|
138
|
+
logger = logging.getLogger(__name__)
|
|
218
139
|
|
|
219
140
|
|
|
220
141
|
class TabDirective(Directive):
|
|
221
|
-
"""Handles the `.. tab::` directive, capturing its content."""
|
|
142
|
+
"""Handles the `.. tab::` directive, capturing its content and options."""
|
|
222
143
|
has_content = True
|
|
223
144
|
required_arguments = 1
|
|
224
145
|
final_argument_whitespace = True
|
|
146
|
+
option_spec = {'aria-label': directives.unchanged}
|
|
225
147
|
|
|
226
148
|
def run(self) -> list[nodes.Node]:
|
|
227
|
-
"""
|
|
228
|
-
Parses the content of a tab and stores it in a temporary container.
|
|
229
|
-
"""
|
|
149
|
+
"""Process the tab directive and return container node."""
|
|
230
150
|
env = self.state.document.settings.env
|
|
231
|
-
|
|
151
|
+
|
|
152
|
+
# Validate context
|
|
232
153
|
if not hasattr(env, 'sft_context') or not env.sft_context:
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
237
169
|
self.state.nested_parse(self.content, self.content_offset, container)
|
|
170
|
+
|
|
238
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
|
|
239
198
|
|
|
240
199
|
|
|
241
200
|
class FilterTabsDirective(Directive):
|
|
242
201
|
"""Handles the main `.. filter-tabs::` directive."""
|
|
243
202
|
has_content = True
|
|
244
|
-
required_arguments =
|
|
245
|
-
|
|
203
|
+
required_arguments = 0
|
|
204
|
+
optional_arguments = 0
|
|
205
|
+
option_spec = {'legend': directives.unchanged}
|
|
246
206
|
|
|
247
207
|
def run(self) -> list[nodes.Node]:
|
|
248
|
-
"""
|
|
249
|
-
Parses the list of tabs, manages the parsing context for its content,
|
|
250
|
-
and delegates the final rendering to the FilterTabsRenderer.
|
|
251
|
-
"""
|
|
208
|
+
"""Process the filter-tabs directive."""
|
|
252
209
|
env = self.state.document.settings.env
|
|
253
210
|
|
|
254
|
-
# Set
|
|
211
|
+
# Set up context
|
|
255
212
|
if not hasattr(env, 'sft_context'):
|
|
256
213
|
env.sft_context = []
|
|
257
214
|
env.sft_context.append(True)
|
|
258
215
|
|
|
259
|
-
# Parse
|
|
216
|
+
# Parse content
|
|
260
217
|
temp_container = nodes.container()
|
|
261
218
|
self.state.nested_parse(self.content, self.content_offset, temp_container)
|
|
219
|
+
|
|
262
220
|
env.sft_context.pop()
|
|
221
|
+
|
|
222
|
+
# Get the custom legend option
|
|
223
|
+
custom_legend = self.options.get('legend')
|
|
263
224
|
|
|
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
|
-
|
|
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
|
|
292
268
|
if env.app.builder.name == 'html':
|
|
293
269
|
return renderer.render_html()
|
|
294
270
|
else:
|
|
295
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
|
+
|
|
296
304
|
|
|
305
|
+
# =============================================================================
|
|
306
|
+
# Collapsible Admonitions Support
|
|
307
|
+
# =============================================================================
|
|
297
308
|
|
|
298
309
|
def setup_collapsible_admonitions(app: Sphinx, doctree: nodes.document, docname: str):
|
|
299
|
-
"""
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
"""
|
|
303
|
-
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':
|
|
304
313
|
return
|
|
305
|
-
|
|
314
|
+
|
|
306
315
|
for node in list(doctree.findall(nodes.admonition)):
|
|
307
316
|
if 'collapsible' not in node.get('classes', []):
|
|
308
317
|
continue
|
|
309
|
-
|
|
318
|
+
|
|
310
319
|
is_expanded = 'expanded' in node.get('classes', [])
|
|
311
320
|
title_node = next(iter(node.findall(nodes.title)), None)
|
|
312
321
|
summary_text = title_node.astext() if title_node else "Details"
|
|
322
|
+
|
|
313
323
|
if title_node:
|
|
314
324
|
title_node.parent.remove(title_node)
|
|
315
325
|
|
|
316
|
-
# Create the new <details> node.
|
|
317
326
|
details_node = DetailsNode(classes=[COLLAPSIBLE_SECTION])
|
|
318
327
|
if is_expanded:
|
|
319
328
|
details_node['open'] = 'open'
|
|
320
|
-
|
|
321
|
-
# Create the new <summary> node with a custom arrow.
|
|
329
|
+
|
|
322
330
|
summary_node = SummaryNode()
|
|
323
331
|
arrow_span = nodes.inline(classes=[CUSTOM_ARROW])
|
|
324
332
|
arrow_span += nodes.Text("▶")
|
|
325
333
|
summary_node += arrow_span
|
|
326
334
|
summary_node += nodes.Text(summary_text)
|
|
327
335
|
details_node += summary_node
|
|
328
|
-
|
|
329
|
-
# Move the original content of the admonition into a new container.
|
|
336
|
+
|
|
330
337
|
content_node = nodes.container(classes=[COLLAPSIBLE_CONTENT])
|
|
331
338
|
content_node.extend(copy.deepcopy(node.children))
|
|
332
339
|
details_node += content_node
|
|
333
|
-
|
|
340
|
+
|
|
334
341
|
node.replace_self(details_node)
|
|
335
342
|
|
|
343
|
+
|
|
344
|
+
# =============================================================================
|
|
345
|
+
# HTML Visitor Functions
|
|
346
|
+
# =============================================================================
|
|
347
|
+
|
|
336
348
|
def _get_html_attrs(node: nodes.Element) -> Dict[str, Any]:
|
|
337
|
-
"""
|
|
349
|
+
"""Extract HTML attributes from a docutils node, excluding internal attributes."""
|
|
338
350
|
attrs = node.attributes.copy()
|
|
339
|
-
# Remove docutils-internal attributes to avoid rendering them in the HTML.
|
|
340
351
|
for key in ('ids', 'backrefs', 'dupnames', 'names', 'classes', 'id', 'for_id'):
|
|
341
352
|
attrs.pop(key, None)
|
|
342
353
|
return attrs
|
|
343
354
|
|
|
344
|
-
|
|
355
|
+
|
|
345
356
|
def visit_container_node(self: HTML5Translator, node: ContainerNode) -> None:
|
|
346
357
|
self.body.append(self.starttag(node, 'div', **_get_html_attrs(node)))
|
|
358
|
+
|
|
347
359
|
def depart_container_node(self: HTML5Translator, node: ContainerNode) -> None:
|
|
348
360
|
self.body.append('</div>')
|
|
349
361
|
|
|
350
362
|
def visit_fieldset_node(self: HTML5Translator, node: FieldsetNode) -> None:
|
|
351
|
-
|
|
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
|
+
|
|
352
368
|
def depart_fieldset_node(self: HTML5Translator, node: FieldsetNode) -> None:
|
|
353
369
|
self.body.append('</fieldset>')
|
|
354
370
|
|
|
355
371
|
def visit_legend_node(self: HTML5Translator, node: LegendNode) -> None:
|
|
356
|
-
self.body.append(self.starttag(node, 'legend', CLASS=
|
|
372
|
+
self.body.append(self.starttag(node, 'legend', CLASS="sft-legend"))
|
|
373
|
+
|
|
357
374
|
def depart_legend_node(self: HTML5Translator, node: LegendNode) -> None:
|
|
358
375
|
self.body.append('</legend>')
|
|
359
376
|
|
|
360
377
|
def visit_radio_input_node(self: HTML5Translator, node: RadioInputNode) -> None:
|
|
361
378
|
attrs = _get_html_attrs(node)
|
|
362
|
-
|
|
363
|
-
# Include other important attributes
|
|
364
|
-
for key in ['type', 'name', 'checked']:
|
|
379
|
+
for key in ['type', 'name', 'checked', 'aria-label']:
|
|
365
380
|
if key in node.attributes:
|
|
366
381
|
attrs[key] = node[key]
|
|
367
382
|
self.body.append(self.starttag(node, 'input', **attrs))
|
|
368
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
|
-
# Ensure the 'for' attribute is set correctly
|
|
375
389
|
if 'for_id' in node.attributes:
|
|
376
390
|
attrs['for'] = node['for_id']
|
|
377
|
-
# FIX: Don't add any ARIA attributes or IDs to labels
|
|
378
391
|
self.body.append(self.starttag(node, 'label', **attrs))
|
|
379
392
|
|
|
380
393
|
def depart_label_node(self: HTML5Translator, node: LabelNode) -> None:
|
|
@@ -382,73 +395,70 @@ def depart_label_node(self: HTML5Translator, node: LabelNode) -> None:
|
|
|
382
395
|
|
|
383
396
|
def visit_panel_node(self: HTML5Translator, node: PanelNode) -> None:
|
|
384
397
|
attrs = _get_html_attrs(node)
|
|
385
|
-
# Panels can have ARIA attributes
|
|
386
398
|
for key in ['role', 'aria-labelledby', 'tabindex']:
|
|
387
399
|
if key in node.attributes:
|
|
388
400
|
attrs[key] = node[key]
|
|
389
|
-
|
|
390
|
-
if 'data-filter' in node.attributes:
|
|
391
|
-
attrs['data-filter'] = node['data-filter']
|
|
392
|
-
self.body.append(self.starttag(node, 'div', CLASS=SFT_PANEL, **attrs))
|
|
401
|
+
self.body.append(self.starttag(node, 'div', CLASS="sft-panel", **attrs))
|
|
393
402
|
|
|
394
403
|
def depart_panel_node(self: HTML5Translator, node: PanelNode) -> None:
|
|
395
404
|
self.body.append('</div>')
|
|
396
405
|
|
|
397
406
|
def visit_details_node(self: HTML5Translator, node: DetailsNode) -> None:
|
|
398
|
-
attrs =
|
|
407
|
+
attrs = _get_html_attrs(node)
|
|
399
408
|
if 'open' in node.attributes:
|
|
400
|
-
attrs['open'] =
|
|
409
|
+
attrs['open'] = 'open'
|
|
401
410
|
self.body.append(self.starttag(node, 'details', **attrs))
|
|
402
411
|
|
|
403
412
|
def depart_details_node(self: HTML5Translator, node: DetailsNode) -> None:
|
|
404
413
|
self.body.append('</details>')
|
|
405
414
|
|
|
406
415
|
def visit_summary_node(self: HTML5Translator, node: SummaryNode) -> None:
|
|
407
|
-
self.body.append(self.starttag(node, 'summary'))
|
|
416
|
+
self.body.append(self.starttag(node, 'summary', **_get_html_attrs(node)))
|
|
408
417
|
|
|
409
418
|
def depart_summary_node(self: HTML5Translator, node: SummaryNode) -> None:
|
|
410
419
|
self.body.append('</summary>')
|
|
411
420
|
|
|
412
421
|
|
|
422
|
+
# =============================================================================
|
|
423
|
+
# Static File Handling
|
|
424
|
+
# =============================================================================
|
|
425
|
+
|
|
413
426
|
def copy_static_files(app: Sphinx):
|
|
414
|
-
"""
|
|
415
|
-
Copies the extension's static CSS and JS files to the build output directory.
|
|
416
|
-
"""
|
|
427
|
+
"""Copy CSS and JS files to the build directory."""
|
|
417
428
|
if app.builder.name != 'html':
|
|
418
429
|
return
|
|
419
|
-
|
|
430
|
+
|
|
420
431
|
static_source_dir = Path(__file__).parent / "static"
|
|
421
432
|
dest_dir = Path(app.outdir) / "_static"
|
|
422
433
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
423
434
|
|
|
424
|
-
#
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
+
|
|
428
445
|
|
|
446
|
+
# =============================================================================
|
|
447
|
+
# Extension Setup
|
|
448
|
+
# =============================================================================
|
|
429
449
|
|
|
430
450
|
def setup(app: Sphinx) -> Dict[str, Any]:
|
|
431
|
-
"""
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
#
|
|
435
|
-
app.add_config_value('filter_tabs_tab_highlight_color', '#007bff', 'html', [str])
|
|
436
|
-
app.add_config_value('filter_tabs_tab_background_color', '#f0f0f0', 'html', [str])
|
|
437
|
-
app.add_config_value('filter_tabs_tab_font_size', '1em', 'html', [str])
|
|
438
|
-
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])
|
|
439
455
|
app.add_config_value('filter_tabs_debug_mode', False, 'html', [bool])
|
|
440
|
-
app.add_config_value('filter_tabs_collapsible_enabled', True, 'html', [bool])
|
|
441
|
-
app.add_config_value('filter_tabs_collapsible_accent_color', '#17a2b8', 'html', [str])
|
|
442
456
|
|
|
443
|
-
#
|
|
444
|
-
app.add_config_value('filter_tabs_keyboard_navigation', True, 'html', [bool])
|
|
445
|
-
app.add_config_value('filter_tabs_announce_changes', True, 'html', [bool])
|
|
446
|
-
|
|
447
|
-
# Add the main stylesheet and JavaScript to the HTML output.
|
|
457
|
+
# Add static files
|
|
448
458
|
app.add_css_file('filter_tabs.css')
|
|
449
459
|
app.add_js_file('filter_tabs.js')
|
|
450
|
-
|
|
451
|
-
# Register
|
|
460
|
+
|
|
461
|
+
# Register custom nodes (keep existing node registration code)
|
|
452
462
|
app.add_node(ContainerNode, html=(visit_container_node, depart_container_node))
|
|
453
463
|
app.add_node(FieldsetNode, html=(visit_fieldset_node, depart_fieldset_node))
|
|
454
464
|
app.add_node(LegendNode, html=(visit_legend_node, depart_legend_node))
|
|
@@ -457,15 +467,15 @@ def setup(app: Sphinx) -> Dict[str, Any]:
|
|
|
457
467
|
app.add_node(PanelNode, html=(visit_panel_node, depart_panel_node))
|
|
458
468
|
app.add_node(DetailsNode, html=(visit_details_node, depart_details_node))
|
|
459
469
|
app.add_node(SummaryNode, html=(visit_summary_node, depart_summary_node))
|
|
460
|
-
|
|
461
|
-
# Register
|
|
470
|
+
|
|
471
|
+
# Register directives
|
|
462
472
|
app.add_directive('filter-tabs', FilterTabsDirective)
|
|
463
473
|
app.add_directive('tab', TabDirective)
|
|
464
|
-
|
|
465
|
-
# Connect
|
|
466
|
-
app.connect('doctree-resolved', setup_collapsible_admonitions)
|
|
474
|
+
|
|
475
|
+
# Connect event handlers
|
|
467
476
|
app.connect('builder-inited', copy_static_files)
|
|
468
|
-
|
|
477
|
+
app.connect('doctree-resolved', setup_collapsible_admonitions)
|
|
478
|
+
|
|
469
479
|
return {
|
|
470
480
|
'version': __version__,
|
|
471
481
|
'parallel_read_safe': True,
|