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 CHANGED
@@ -1,429 +1,464 @@
1
- #
2
- # extension.py: The core logic for the sphinx-filter-tabs Sphinx extension.
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
- # Used for type hinting to avoid circular imports and improve code clarity.
21
- from typing import TYPE_CHECKING, Any, Dict, List
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
- # --- Constants ---
33
- # A dedicated UUID namespace ensures that the same tab name will always produce
34
- # the same unique identifier. This is a crucial security measure to prevent CSS injection.
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
- # The ContainerNode is essential for applying CSS Custom Properties via the 'style' attribute.
59
- # The default docutils container doesn't reliably render the style attribute, so this
60
- # custom node and its visitor function ensure the theming mechanism works correctly.
61
- class ContainerNode(nodes.General, nodes.Element):
62
- pass
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
- # --- Renderer Class ---
74
- class FilterTabsRenderer:
42
+ @dataclass
43
+ class TabData:
75
44
  """
76
- Handles the primary logic of converting the parsed directive content
77
- into a final node structure for both HTML and fallback formats (like LaTeX).
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
- def __init__(self, directive: Directive, tab_names: list[str], default_tab: str, temp_blocks: list[nodes.Node]):
80
- """Initializes the renderer with all necessary context from the directive."""
81
- self.directive: Directive = directive
82
- self.env: BuildEnvironment = directive.state.document.settings.env
83
- self.tab_names: list[str] = tab_names
84
- self.default_tab: str = default_tab
85
- self.temp_blocks: list[nodes.Node] = temp_blocks
86
-
87
- def render_html(self) -> list[nodes.Node]:
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
- # 1. Create all radio buttons and labels first and add them to the tab_bar.
153
- for i, tab_name in enumerate(self.tab_names):
154
- radio_id = f"{group_id}-{self._css_escape(tab_name)}"
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
- if tab_name in content_map:
185
- panel.extend(copy.deepcopy(content_map[tab_name]))
186
- content_area += panel
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
- container.children = [fieldset]
189
- return [container]
133
+ # --- Constants ---
134
+ COLLAPSIBLE_SECTION = "collapsible-section"
135
+ COLLAPSIBLE_CONTENT = "collapsible-content"
136
+ CUSTOM_ARROW = "custom-arrow"
190
137
 
191
- def render_fallback(self) -> list[nodes.Node]:
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
- # Ensure `tab` is only used inside `filter-tabs`.
151
+
152
+ # Validate context
230
153
  if not hasattr(env, 'sft_context') or not env.sft_context:
231
- raise self.error("`tab` can only be used inside a `filter-tabs` directive.")
232
- # Store the tab name and parsed content in a temporary node.
233
- container = nodes.container(classes=[SFT_TEMP_PANEL])
234
- container['filter-name'] = self.arguments[0].strip()
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, which orchestrates the entire component."""
201
+ """Handles the main `.. filter-tabs::` directive."""
241
202
  has_content = True
242
- required_arguments = 1
243
- final_argument_whitespace = True
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
- # Remove comments to prevent nesting of filter-tabs directives.
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 the content of the directive to find all `.. tab::` blocks.
216
+ # Parse content
262
217
  temp_container = nodes.container()
263
218
  self.state.nested_parse(self.content, self.content_offset, temp_container)
264
- env.sft_context.pop() # Unset the context flag.
265
-
266
- # Find all the temporary panel nodes created by the TabDirective.
267
- temp_blocks = temp_container.findall(lambda n: isinstance(n, nodes.Element) and SFT_TEMP_PANEL in n.get('classes', []))
268
- if not temp_blocks:
269
- # Raise a clear error if the directive is empty, instead of failing silently.
270
- self.error("No `.. tab::` directives found inside `filter-tabs`. Content will not be rendered.")
271
- return []
272
-
273
- # Parse the tab names from the directive's arguments.
274
- tabs_raw = [t.strip() for t in self.arguments[0].split(',')]
275
- tab_names_only = [re.sub(r'\s*\(\s*default\s*\)$', '', t, re.IGNORECASE).strip() for t in tabs_raw]
276
-
277
- if len(set(tab_names_only)) != len(tab_names_only):
278
- raise self.error(f"Duplicate tab names found: {tab_names_only}")
279
-
280
- # Identify the default tab.
281
- default_tab, tab_names = "", []
282
- for tab in tabs_raw:
283
- match = re.match(r"^(.*?)\s*\(\s*default\s*\)$", tab, re.IGNORECASE)
284
- tab_name = match.group(1).strip() if match else tab
285
- if match and not default_tab:
286
- default_tab = tab_name
287
- tab_names.append(tab_name)
288
-
289
- # If no default is specified, the first tab becomes the default.
290
- if not default_tab and tab_names:
291
- default_tab = tab_names[0]
292
-
293
- # Instantiate the renderer and call the appropriate render method based on the builder.
294
- renderer = FilterTabsRenderer(self, tab_names, default_tab, temp_blocks)
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
- Finds any admonition with the `:class: collapsible` option and transforms it
304
- into an HTML `<details>`/`<summary>` element for a native collapsible effect.
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) # Remove the old title.
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
- # Replace the original admonition node with the new details node.
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
- """Helper to get a clean dictionary of HTML attributes from a docutils node."""
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
- self.body.append(self.starttag(node, 'fieldset', CLASS=SFT_FIELDSET))
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=SFT_LEGEND))
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
- self.body.append(self.starttag(node, 'input', **_get_html_attrs(node)))
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 # No closing tag for <input>.
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
- attrs['for'] = node['for_id'] # Connect the label to its radio button.
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
- self.body.append(self.starttag(node, 'div', CLASS=SFT_PANEL, **_get_html_attrs(node)))
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
- self.body.append(self.starttag(node, 'details', **_get_html_attrs(node)))
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
- source_css = Path(__file__).parent / "static" / "filter_tabs.css"
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
- shutil.copy(source_css, dest_dir)
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
- The main entry point for the Sphinx extension.
412
- This function registers all components with Sphinx.
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
- app.add_config_value('filter_tabs_collapsible_enabled', True, 'html', [bool])
421
- app.add_config_value('filter_tabs_collapsible_accent_color', '#17a2b8', 'html', [str])
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
- # Register all custom nodes and their HTML visitor/depart functions.
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 the RST directives.
470
+
471
+ # Register directives
437
472
  app.add_directive('filter-tabs', FilterTabsDirective)
438
473
  app.add_directive('tab', TabDirective)
439
-
440
- # Connect to Sphinx events (hooks) to run custom functions at specific build stages.
441
- app.connect('doctree-resolved', setup_collapsible_admonitions)
474
+
475
+ # Connect event handlers
442
476
  app.connect('builder-inited', copy_static_files)
443
-
444
- # Return metadata about the extension.
477
+ app.connect('doctree-resolved', setup_collapsible_admonitions)
478
+
445
479
  return {
446
480
  'version': __version__,
447
481
  'parallel_read_safe': True,