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 CHANGED
@@ -1,380 +1,393 @@
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
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
- # --- Constants ---
27
- _CSS_NAMESPACE = uuid.UUID('d1b1b3e8-5e7c-48d6-a235-9a4c14c9b139')
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
- logger = logging.getLogger(__name__)
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
- class FieldsetNode(nodes.General, nodes.Element):
47
- pass
38
+ # =============================================================================
39
+ # Data Models and Configuration
40
+ # =============================================================================
48
41
 
49
- class LegendNode(nodes.General, nodes.Element):
50
- pass
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
- class LabelNode(nodes.General, nodes.Element):
56
- pass
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
- class DetailsNode(nodes.General, nodes.Element):
62
- pass
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
- # --- Renderer Class ---
69
- class FilterTabsRenderer:
70
- """
71
- Handles the primary logic of converting the parsed directive content
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
- def render_fallback(self) -> list[nodes.Node]:
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
- # Ensure `tab` is only used inside `filter-tabs`.
151
+
152
+ # Validate context
232
153
  if not hasattr(env, 'sft_context') or not env.sft_context:
233
- raise self.error("`tab` can only be used inside a `filter-tabs` directive.")
234
- # Store the tab name and parsed content in a temporary node.
235
- container = nodes.container(classes=[SFT_TEMP_PANEL])
236
- 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
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 = 1
245
- final_argument_whitespace = True
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 a context flag to indicate that we are inside a filter-tabs block.
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 the content of the directive to find all `.. tab::` blocks.
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
- # Find all the temporary panel nodes created by the TabDirective.
265
- temp_blocks = temp_container.findall(lambda n: isinstance(n, nodes.Element) and SFT_TEMP_PANEL in n.get('classes', []))
266
- if not temp_blocks:
267
- self.error("No `.. tab::` directives found inside `filter-tabs`. Content will not be rendered.")
268
- return []
269
-
270
- # Parse the tab names from the directive's arguments.
271
- tabs_raw = [t.strip() for t in self.arguments[0].split(',')]
272
- tab_names_only = [re.sub(r'\s*\(\s*default\s*\)$', '', t, re.IGNORECASE).strip() for t in tabs_raw]
273
-
274
- if len(set(tab_names_only)) != len(tab_names_only):
275
- raise self.error(f"Duplicate tab names found: {tab_names_only}")
276
-
277
- # Identify the default tab.
278
- default_tab, tab_names = "", []
279
- for tab in tabs_raw:
280
- match = re.match(r"^(.*?)\s*\(\s*default\s*\)$", tab, re.IGNORECASE)
281
- tab_name = match.group(1).strip() if match else tab
282
- if match and not default_tab:
283
- default_tab = tab_name
284
- tab_names.append(tab_name)
285
-
286
- # If no default is specified, the first tab becomes the default.
287
- if not default_tab and tab_names:
288
- default_tab = tab_names[0]
289
-
290
- # Instantiate the renderer and call the appropriate render method based on the builder.
291
- renderer = FilterTabsRenderer(self, tab_names, default_tab, temp_blocks)
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
- Finds any admonition with the `:class: collapsible` option and transforms it
301
- into an HTML `<details>`/`<summary>` element for a native collapsible effect.
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
- # Replace the original admonition node with the new details node.
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
- """Helper to get a clean dictionary of HTML attributes from a docutils node."""
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
- # --- HTML Visitor Functions ---
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
- 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
+
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=SFT_LEGEND))
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
- # Don't manually add 'id' - starttag handles 'ids' automatically
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
- # Handle data attributes
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'] = node['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
- # Copy both CSS and JS files
425
- for file_pattern in ["*.css", "*.js"]:
426
- for file_path in static_source_dir.glob(file_pattern):
427
- shutil.copy(file_path, dest_dir)
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
- The main entry point for the Sphinx extension.
433
- """
434
- # Register custom configuration values
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
- # NEW: Add accessibility configuration options
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 all custom nodes and their HTML visitor/depart functions.
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 the RST directives.
470
+
471
+ # Register directives
462
472
  app.add_directive('filter-tabs', FilterTabsDirective)
463
473
  app.add_directive('tab', TabDirective)
464
-
465
- # Connect to Sphinx events
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,