sphinx-filter-tabs 0.7.0__py3-none-any.whl → 0.9.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 +131 -97
- filter_tabs/static/filter_tabs.css +101 -29
- filter_tabs/static/filter_tabs.js +130 -0
- {sphinx_filter_tabs-0.7.0.dist-info → sphinx_filter_tabs-0.9.2.dist-info}/METADATA +20 -2
- sphinx_filter_tabs-0.9.2.dist-info/RECORD +10 -0
- sphinx_filter_tabs-0.7.0.dist-info/RECORD +0 -9
- {sphinx_filter_tabs-0.7.0.dist-info → sphinx_filter_tabs-0.9.2.dist-info}/WHEEL +0 -0
- {sphinx_filter_tabs-0.7.0.dist-info → sphinx_filter_tabs-0.9.2.dist-info}/entry_points.txt +0 -0
- {sphinx_filter_tabs-0.7.0.dist-info → sphinx_filter_tabs-0.9.2.dist-info}/licenses/LICENSE +0 -0
- {sphinx_filter_tabs-0.7.0.dist-info → sphinx_filter_tabs-0.9.2.dist-info}/top_level.txt +0 -0
filter_tabs/extension.py
CHANGED
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
#
|
|
4
4
|
|
|
5
5
|
# --- Imports ---
|
|
6
|
-
# Ensures that all type hints are treated as forward references, which is standard practice.
|
|
7
6
|
from __future__ import annotations
|
|
8
7
|
|
|
9
8
|
import re
|
|
@@ -17,24 +16,16 @@ from sphinx.application import Sphinx
|
|
|
17
16
|
from sphinx.util import logging
|
|
18
17
|
from sphinx.writers.html import HTML5Translator
|
|
19
18
|
|
|
20
|
-
# Used for type hinting to avoid circular imports and improve code clarity.
|
|
21
19
|
from typing import TYPE_CHECKING, Any, Dict, List
|
|
22
|
-
|
|
23
|
-
# Imports the package version, dynamically read from the installed package's metadata.
|
|
24
20
|
from . import __version__
|
|
25
21
|
|
|
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
22
|
if TYPE_CHECKING:
|
|
29
23
|
from sphinx.config import Config
|
|
30
24
|
from sphinx.environment import BuildEnvironment
|
|
31
25
|
|
|
32
26
|
# --- 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
27
|
_CSS_NAMESPACE = uuid.UUID('d1b1b3e8-5e7c-48d6-a235-9a4c14c9b139')
|
|
36
28
|
|
|
37
|
-
# Centralizing CSS class names makes them easy to manage and prevents typos.
|
|
38
29
|
SFT_CONTAINER = "sft-container"
|
|
39
30
|
SFT_FIELDSET = "sft-fieldset"
|
|
40
31
|
SFT_LEGEND = "sft-legend"
|
|
@@ -46,28 +37,32 @@ COLLAPSIBLE_SECTION = "collapsible-section"
|
|
|
46
37
|
COLLAPSIBLE_CONTENT = "collapsible-content"
|
|
47
38
|
CUSTOM_ARROW = "custom-arrow"
|
|
48
39
|
|
|
49
|
-
# --- Logger ---
|
|
50
|
-
# A dedicated logger for this extension, following Sphinx's best practices.
|
|
51
|
-
# This allows for clean, configurable logging output.
|
|
52
40
|
logger = logging.getLogger(__name__)
|
|
53
41
|
|
|
54
42
|
# --- 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.
|
|
57
|
-
|
|
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
43
|
class ContainerNode(nodes.General, nodes.Element):
|
|
62
44
|
pass
|
|
63
45
|
|
|
64
|
-
class FieldsetNode(nodes.General, nodes.Element):
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
class
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
class
|
|
46
|
+
class FieldsetNode(nodes.General, nodes.Element):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
class LegendNode(nodes.General, nodes.Element):
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
class RadioInputNode(nodes.General, nodes.Element):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
class LabelNode(nodes.General, nodes.Element):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
class PanelNode(nodes.General, nodes.Element):
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
class DetailsNode(nodes.General, nodes.Element):
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
class SummaryNode(nodes.General, nodes.Element):
|
|
65
|
+
pass
|
|
71
66
|
|
|
72
67
|
|
|
73
68
|
# --- Renderer Class ---
|
|
@@ -85,7 +80,7 @@ class FilterTabsRenderer:
|
|
|
85
80
|
self.temp_blocks: list[nodes.Node] = temp_blocks
|
|
86
81
|
|
|
87
82
|
def render_html(self) -> list[nodes.Node]:
|
|
88
|
-
"""Constructs the complete
|
|
83
|
+
"""Constructs the complete, W3C valid, and ARIA-compliant docutils node tree."""
|
|
89
84
|
# Ensure a unique ID for each filter-tabs instance on a page.
|
|
90
85
|
if not hasattr(self.env, 'filter_tabs_counter'):
|
|
91
86
|
self.env.filter_tabs_counter = 0
|
|
@@ -104,79 +99,98 @@ class FilterTabsRenderer:
|
|
|
104
99
|
}
|
|
105
100
|
style_string = "; ".join([f"{key}: {value}" for key, value in style_vars.items()])
|
|
106
101
|
|
|
107
|
-
# If debug mode is on, log the generated ID and styles
|
|
102
|
+
# If debug mode is on, log the generated ID and styles.
|
|
108
103
|
if config.filter_tabs_debug_mode:
|
|
109
104
|
logger.info(f"[sphinx-filter-tabs] ID: {group_id}, Styles: '{style_string}'")
|
|
110
105
|
|
|
111
106
|
# Create the main container node with the inline style for theming.
|
|
112
107
|
container = ContainerNode(classes=[SFT_CONTAINER], style=style_string)
|
|
113
108
|
|
|
114
|
-
# Build the semantic structure using fieldset and a hidden legend
|
|
109
|
+
# Build the semantic structure using fieldset and a hidden legend.
|
|
115
110
|
fieldset = FieldsetNode()
|
|
116
111
|
legend = LegendNode()
|
|
117
112
|
legend += nodes.Text(f"Filter by: {', '.join(self.tab_names)}")
|
|
118
113
|
fieldset += legend
|
|
119
|
-
|
|
120
|
-
# ---
|
|
121
|
-
# Generate the dynamic CSS that handles the core filtering logic.
|
|
114
|
+
|
|
115
|
+
# --- CSS Generation ---
|
|
122
116
|
css_rules = []
|
|
123
|
-
for tab_name in self.tab_names:
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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"
|
|
127
121
|
css_rules.append(
|
|
128
|
-
f".{SFT_TAB_BAR}:has(#{radio_id}:checked) ~ "
|
|
129
|
-
f".{SFT_CONTENT} > .{SFT_PANEL}[data-filter='{tab_name}'] {{ display: block; }}"
|
|
122
|
+
f".{SFT_TAB_BAR}:has(#{radio_id}:checked) ~ .sft-content > #{panel_id} {{ display: block; }}"
|
|
130
123
|
)
|
|
131
|
-
|
|
132
|
-
#
|
|
124
|
+
|
|
125
|
+
# Write the dynamic CSS to a temporary file and add it to the build.
|
|
133
126
|
css_content = ''.join(css_rules)
|
|
134
127
|
static_dir = Path(self.env.app.outdir) / '_static'
|
|
135
128
|
static_dir.mkdir(parents=True, exist_ok=True)
|
|
136
129
|
css_filename = f"dynamic-filter-tabs-{group_id}.css"
|
|
137
130
|
(static_dir / css_filename).write_text(css_content, encoding='utf-8')
|
|
138
|
-
|
|
139
|
-
# 2. Add the temporary CSS file using the backward-compatible app.add_css_file() method.
|
|
140
|
-
# This correctly places a <link> tag in the HTML <head>.
|
|
141
131
|
self.env.app.add_css_file(css_filename)
|
|
142
132
|
|
|
143
|
-
#
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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"
|
|
148
149
|
|
|
149
|
-
#
|
|
150
|
+
# The radio button is for state management.
|
|
150
151
|
radio = RadioInputNode(type='radio', name=group_id, ids=[radio_id])
|
|
151
152
|
|
|
152
|
-
|
|
153
|
+
is_default = (self.default_tab == tab_name) or (i == 0 and not self.default_tab)
|
|
154
|
+
if is_default:
|
|
153
155
|
radio['checked'] = 'checked'
|
|
154
156
|
tab_bar += radio
|
|
155
157
|
|
|
156
|
-
#
|
|
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
|
|
157
160
|
label = LabelNode(for_id=radio_id)
|
|
158
161
|
label += nodes.Text(tab_name)
|
|
159
162
|
tab_bar += label
|
|
160
|
-
fieldset += tab_bar
|
|
161
163
|
|
|
162
|
-
# Create
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
+
|
|
170
189
|
if tab_name in content_map:
|
|
171
|
-
# Use deepcopy to prevent docutils node mutation bugs. Since the same content
|
|
172
|
-
# might be referenced or processed multiple times, a deep copy ensures that
|
|
173
|
-
# each panel gets a completely independent set of nodes.
|
|
174
190
|
panel.extend(copy.deepcopy(content_map[tab_name]))
|
|
175
191
|
content_area += panel
|
|
176
|
-
fieldset += content_area
|
|
177
|
-
container.children = [fieldset]
|
|
178
192
|
|
|
179
|
-
|
|
193
|
+
container.children = [fieldset]
|
|
180
194
|
return [container]
|
|
181
195
|
|
|
182
196
|
def render_fallback(self) -> list[nodes.Node]:
|
|
@@ -199,8 +213,6 @@ class FilterTabsRenderer:
|
|
|
199
213
|
def _css_escape(name: str) -> str:
|
|
200
214
|
"""
|
|
201
215
|
Generates a deterministic, CSS-safe identifier from any given tab name string.
|
|
202
|
-
This uses uuid.uuid5 to create a hashed value, which robustly prevents
|
|
203
|
-
CSS injection vulnerabilities that could arise from special characters in tab names.
|
|
204
216
|
"""
|
|
205
217
|
return str(uuid.uuid5(_CSS_NAMESPACE, name.strip().lower()))
|
|
206
218
|
|
|
@@ -214,7 +226,6 @@ class TabDirective(Directive):
|
|
|
214
226
|
def run(self) -> list[nodes.Node]:
|
|
215
227
|
"""
|
|
216
228
|
Parses the content of a tab and stores it in a temporary container.
|
|
217
|
-
This method validates that the directive is used within a `filter-tabs` block.
|
|
218
229
|
"""
|
|
219
230
|
env = self.state.document.settings.env
|
|
220
231
|
# Ensure `tab` is only used inside `filter-tabs`.
|
|
@@ -228,7 +239,7 @@ class TabDirective(Directive):
|
|
|
228
239
|
|
|
229
240
|
|
|
230
241
|
class FilterTabsDirective(Directive):
|
|
231
|
-
"""Handles the main `.. filter-tabs::` directive
|
|
242
|
+
"""Handles the main `.. filter-tabs::` directive."""
|
|
232
243
|
has_content = True
|
|
233
244
|
required_arguments = 1
|
|
234
245
|
final_argument_whitespace = True
|
|
@@ -239,10 +250,7 @@ class FilterTabsDirective(Directive):
|
|
|
239
250
|
and delegates the final rendering to the FilterTabsRenderer.
|
|
240
251
|
"""
|
|
241
252
|
env = self.state.document.settings.env
|
|
242
|
-
|
|
243
|
-
if hasattr(env, 'sft_context') and env.sft_context:
|
|
244
|
-
raise self.error("Nesting `filter-tabs` is not supported.")
|
|
245
|
-
|
|
253
|
+
|
|
246
254
|
# Set a context flag to indicate that we are inside a filter-tabs block.
|
|
247
255
|
if not hasattr(env, 'sft_context'):
|
|
248
256
|
env.sft_context = []
|
|
@@ -251,12 +259,11 @@ class FilterTabsDirective(Directive):
|
|
|
251
259
|
# Parse the content of the directive to find all `.. tab::` blocks.
|
|
252
260
|
temp_container = nodes.container()
|
|
253
261
|
self.state.nested_parse(self.content, self.content_offset, temp_container)
|
|
254
|
-
env.sft_context.pop()
|
|
262
|
+
env.sft_context.pop()
|
|
255
263
|
|
|
256
264
|
# Find all the temporary panel nodes created by the TabDirective.
|
|
257
265
|
temp_blocks = temp_container.findall(lambda n: isinstance(n, nodes.Element) and SFT_TEMP_PANEL in n.get('classes', []))
|
|
258
266
|
if not temp_blocks:
|
|
259
|
-
# Raise a clear error if the directive is empty, instead of failing silently.
|
|
260
267
|
self.error("No `.. tab::` directives found inside `filter-tabs`. Content will not be rendered.")
|
|
261
268
|
return []
|
|
262
269
|
|
|
@@ -292,12 +299,10 @@ def setup_collapsible_admonitions(app: Sphinx, doctree: nodes.document, docname:
|
|
|
292
299
|
"""
|
|
293
300
|
Finds any admonition with the `:class: collapsible` option and transforms it
|
|
294
301
|
into an HTML `<details>`/`<summary>` element for a native collapsible effect.
|
|
295
|
-
This hook runs after the document tree is resolved.
|
|
296
302
|
"""
|
|
297
303
|
if not app.config.filter_tabs_collapsible_enabled or app.builder.name != 'html':
|
|
298
304
|
return
|
|
299
305
|
|
|
300
|
-
# Iterate over a copy of the list of nodes to allow for safe modification.
|
|
301
306
|
for node in list(doctree.findall(nodes.admonition)):
|
|
302
307
|
if 'collapsible' not in node.get('classes', []):
|
|
303
308
|
continue
|
|
@@ -306,7 +311,7 @@ def setup_collapsible_admonitions(app: Sphinx, doctree: nodes.document, docname:
|
|
|
306
311
|
title_node = next(iter(node.findall(nodes.title)), None)
|
|
307
312
|
summary_text = title_node.astext() if title_node else "Details"
|
|
308
313
|
if title_node:
|
|
309
|
-
title_node.parent.remove(title_node)
|
|
314
|
+
title_node.parent.remove(title_node)
|
|
310
315
|
|
|
311
316
|
# Create the new <details> node.
|
|
312
317
|
details_node = DetailsNode(classes=[COLLAPSIBLE_SECTION])
|
|
@@ -316,7 +321,7 @@ def setup_collapsible_admonitions(app: Sphinx, doctree: nodes.document, docname:
|
|
|
316
321
|
# Create the new <summary> node with a custom arrow.
|
|
317
322
|
summary_node = SummaryNode()
|
|
318
323
|
arrow_span = nodes.inline(classes=[CUSTOM_ARROW])
|
|
319
|
-
arrow_span += nodes.Text("
|
|
324
|
+
arrow_span += nodes.Text("▶")
|
|
320
325
|
summary_node += arrow_span
|
|
321
326
|
summary_node += nodes.Text(summary_text)
|
|
322
327
|
details_node += summary_node
|
|
@@ -337,8 +342,6 @@ def _get_html_attrs(node: nodes.Element) -> Dict[str, Any]:
|
|
|
337
342
|
return attrs
|
|
338
343
|
|
|
339
344
|
# --- HTML Visitor Functions ---
|
|
340
|
-
# These functions translate the custom docutils nodes into HTML tags.
|
|
341
|
-
|
|
342
345
|
def visit_container_node(self: HTML5Translator, node: ContainerNode) -> None:
|
|
343
346
|
self.body.append(self.starttag(node, 'div', **_get_html_attrs(node)))
|
|
344
347
|
def depart_container_node(self: HTML5Translator, node: ContainerNode) -> None:
|
|
@@ -355,53 +358,80 @@ def depart_legend_node(self: HTML5Translator, node: LegendNode) -> None:
|
|
|
355
358
|
self.body.append('</legend>')
|
|
356
359
|
|
|
357
360
|
def visit_radio_input_node(self: HTML5Translator, node: RadioInputNode) -> None:
|
|
358
|
-
|
|
361
|
+
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']:
|
|
365
|
+
if key in node.attributes:
|
|
366
|
+
attrs[key] = node[key]
|
|
367
|
+
self.body.append(self.starttag(node, 'input', **attrs))
|
|
368
|
+
|
|
359
369
|
def depart_radio_input_node(self: HTML5Translator, node: RadioInputNode) -> None:
|
|
360
|
-
pass
|
|
370
|
+
pass
|
|
361
371
|
|
|
362
372
|
def visit_label_node(self: HTML5Translator, node: LabelNode) -> None:
|
|
363
373
|
attrs = _get_html_attrs(node)
|
|
364
|
-
|
|
374
|
+
# Ensure the 'for' attribute is set correctly
|
|
375
|
+
if 'for_id' in node.attributes:
|
|
376
|
+
attrs['for'] = node['for_id']
|
|
377
|
+
# FIX: Don't add any ARIA attributes or IDs to labels
|
|
365
378
|
self.body.append(self.starttag(node, 'label', **attrs))
|
|
379
|
+
|
|
366
380
|
def depart_label_node(self: HTML5Translator, node: LabelNode) -> None:
|
|
367
381
|
self.body.append('</label>')
|
|
368
382
|
|
|
369
383
|
def visit_panel_node(self: HTML5Translator, node: PanelNode) -> None:
|
|
370
|
-
|
|
384
|
+
attrs = _get_html_attrs(node)
|
|
385
|
+
# Panels can have ARIA attributes
|
|
386
|
+
for key in ['role', 'aria-labelledby', 'tabindex']:
|
|
387
|
+
if key in node.attributes:
|
|
388
|
+
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))
|
|
393
|
+
|
|
371
394
|
def depart_panel_node(self: HTML5Translator, node: PanelNode) -> None:
|
|
372
395
|
self.body.append('</div>')
|
|
373
396
|
|
|
374
397
|
def visit_details_node(self: HTML5Translator, node: DetailsNode) -> None:
|
|
375
|
-
|
|
398
|
+
attrs = {}
|
|
399
|
+
if 'open' in node.attributes:
|
|
400
|
+
attrs['open'] = node['open']
|
|
401
|
+
self.body.append(self.starttag(node, 'details', **attrs))
|
|
402
|
+
|
|
376
403
|
def depart_details_node(self: HTML5Translator, node: DetailsNode) -> None:
|
|
377
404
|
self.body.append('</details>')
|
|
378
405
|
|
|
379
406
|
def visit_summary_node(self: HTML5Translator, node: SummaryNode) -> None:
|
|
380
|
-
self.body.append(self.starttag(node, 'summary'
|
|
407
|
+
self.body.append(self.starttag(node, 'summary'))
|
|
408
|
+
|
|
381
409
|
def depart_summary_node(self: HTML5Translator, node: SummaryNode) -> None:
|
|
382
410
|
self.body.append('</summary>')
|
|
383
411
|
|
|
384
412
|
|
|
385
413
|
def copy_static_files(app: Sphinx):
|
|
386
414
|
"""
|
|
387
|
-
Copies the extension's static CSS
|
|
388
|
-
This hook runs when the builder is initialized, ensuring the CSS file is
|
|
389
|
-
always available for HTML builds without complex packaging maneuvers.
|
|
415
|
+
Copies the extension's static CSS and JS files to the build output directory.
|
|
390
416
|
"""
|
|
391
417
|
if app.builder.name != 'html':
|
|
392
418
|
return
|
|
393
|
-
|
|
419
|
+
|
|
420
|
+
static_source_dir = Path(__file__).parent / "static"
|
|
394
421
|
dest_dir = Path(app.outdir) / "_static"
|
|
395
422
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
396
|
-
|
|
423
|
+
|
|
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)
|
|
397
428
|
|
|
398
429
|
|
|
399
430
|
def setup(app: Sphinx) -> Dict[str, Any]:
|
|
400
431
|
"""
|
|
401
432
|
The main entry point for the Sphinx extension.
|
|
402
|
-
This function registers all components with Sphinx.
|
|
403
433
|
"""
|
|
404
|
-
# Register custom configuration values
|
|
434
|
+
# Register custom configuration values
|
|
405
435
|
app.add_config_value('filter_tabs_tab_highlight_color', '#007bff', 'html', [str])
|
|
406
436
|
app.add_config_value('filter_tabs_tab_background_color', '#f0f0f0', 'html', [str])
|
|
407
437
|
app.add_config_value('filter_tabs_tab_font_size', '1em', 'html', [str])
|
|
@@ -409,9 +439,14 @@ def setup(app: Sphinx) -> Dict[str, Any]:
|
|
|
409
439
|
app.add_config_value('filter_tabs_debug_mode', False, 'html', [bool])
|
|
410
440
|
app.add_config_value('filter_tabs_collapsible_enabled', True, 'html', [bool])
|
|
411
441
|
app.add_config_value('filter_tabs_collapsible_accent_color', '#17a2b8', 'html', [str])
|
|
442
|
+
|
|
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])
|
|
412
446
|
|
|
413
|
-
# Add the main stylesheet to the HTML output.
|
|
447
|
+
# Add the main stylesheet and JavaScript to the HTML output.
|
|
414
448
|
app.add_css_file('filter_tabs.css')
|
|
449
|
+
app.add_js_file('filter_tabs.js')
|
|
415
450
|
|
|
416
451
|
# Register all custom nodes and their HTML visitor/depart functions.
|
|
417
452
|
app.add_node(ContainerNode, html=(visit_container_node, depart_container_node))
|
|
@@ -427,11 +462,10 @@ def setup(app: Sphinx) -> Dict[str, Any]:
|
|
|
427
462
|
app.add_directive('filter-tabs', FilterTabsDirective)
|
|
428
463
|
app.add_directive('tab', TabDirective)
|
|
429
464
|
|
|
430
|
-
# Connect to Sphinx events
|
|
465
|
+
# Connect to Sphinx events
|
|
431
466
|
app.connect('doctree-resolved', setup_collapsible_admonitions)
|
|
432
467
|
app.connect('builder-inited', copy_static_files)
|
|
433
468
|
|
|
434
|
-
# Return metadata about the extension.
|
|
435
469
|
return {
|
|
436
470
|
'version': __version__,
|
|
437
471
|
'parallel_read_safe': True,
|
|
@@ -1,37 +1,57 @@
|
|
|
1
|
-
/* Sphinx Filter Tabs --
|
|
2
|
-
/* This file provides
|
|
3
|
-
The core filtering logic (showing the active panel) is handled by a small,
|
|
4
|
-
dynamically generated <style> block created by extension.py. */
|
|
1
|
+
/* Sphinx Filter Tabs -- Complete Stylesheet */
|
|
2
|
+
/* This file provides both structural and accessibility styles */
|
|
5
3
|
|
|
6
4
|
/* Main container for the tab component */
|
|
7
5
|
.sft-container {
|
|
8
6
|
border: 1px solid #e0e0e0;
|
|
9
|
-
/* THEME: The border-radius is controlled by a CSS variable from conf.py. */
|
|
10
7
|
border-radius: var(--sft-border-radius, 8px);
|
|
11
8
|
overflow: hidden;
|
|
12
9
|
margin: 1.5em 0;
|
|
13
10
|
}
|
|
14
11
|
|
|
15
12
|
/* The <fieldset> provides semantic grouping for accessibility. */
|
|
16
|
-
.sft-fieldset {
|
|
13
|
+
.sft-fieldset {
|
|
14
|
+
border: none;
|
|
15
|
+
padding: 0;
|
|
16
|
+
margin: 0;
|
|
17
|
+
}
|
|
17
18
|
|
|
18
19
|
/* The <legend> is visually hidden but essential for screen reader users. */
|
|
19
|
-
.sft-legend {
|
|
20
|
+
.sft-legend {
|
|
21
|
+
position: absolute;
|
|
22
|
+
width: 1px;
|
|
23
|
+
height: 1px;
|
|
24
|
+
padding: 0;
|
|
25
|
+
margin: -1px;
|
|
26
|
+
overflow: hidden;
|
|
27
|
+
clip: rect(0, 0, 0, 0);
|
|
28
|
+
white-space: nowrap;
|
|
29
|
+
border: 0;
|
|
30
|
+
}
|
|
20
31
|
|
|
21
|
-
/*
|
|
32
|
+
/* Hide radio buttons completely but keep them accessible */
|
|
22
33
|
.sft-tab-bar > input[type="radio"] {
|
|
23
|
-
|
|
34
|
+
position: absolute;
|
|
35
|
+
width: 1px;
|
|
36
|
+
height: 1px;
|
|
37
|
+
padding: 0;
|
|
38
|
+
margin: -1px;
|
|
39
|
+
overflow: hidden;
|
|
40
|
+
clip: rect(0, 0, 0, 0);
|
|
41
|
+
white-space: nowrap;
|
|
42
|
+
border: 0;
|
|
43
|
+
opacity: 0;
|
|
24
44
|
}
|
|
25
45
|
|
|
26
46
|
/* The tab bar containing the clickable labels. */
|
|
27
47
|
.sft-tab-bar {
|
|
28
48
|
display: flex;
|
|
29
49
|
flex-wrap: wrap;
|
|
30
|
-
/* THEME: The background color is controlled by a CSS variable. */
|
|
31
50
|
background-color: var(--sft-tab-background, #f0f0f0);
|
|
32
51
|
border-bottom: 1px solid #e0e0e0;
|
|
33
52
|
padding: 0 10px;
|
|
34
53
|
}
|
|
54
|
+
|
|
35
55
|
.sft-tab-bar > label {
|
|
36
56
|
padding: 12px 18px;
|
|
37
57
|
cursor: pointer;
|
|
@@ -39,40 +59,61 @@
|
|
|
39
59
|
border-bottom: 3px solid transparent;
|
|
40
60
|
color: #555;
|
|
41
61
|
font-weight: 500;
|
|
42
|
-
/* THEME: The font size is controlled by a CSS variable. */
|
|
43
62
|
font-size: var(--sft-tab-font-size, 1em);
|
|
44
63
|
line-height: 1.5;
|
|
45
64
|
}
|
|
46
65
|
|
|
47
|
-
/*
|
|
48
|
-
The adjacent sibling selector (+) is robust and efficient. */
|
|
66
|
+
/* Style for checked state - using adjacent sibling selector */
|
|
49
67
|
.sft-tab-bar > input[type="radio"]:checked + label {
|
|
50
68
|
border-bottom-color: var(--sft-tab-highlight-color, #007bff);
|
|
51
69
|
color: #000;
|
|
70
|
+
font-weight: 600;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* Hover state for labels */
|
|
74
|
+
.sft-tab-bar > label:hover {
|
|
75
|
+
color: #333;
|
|
76
|
+
border-bottom-color: rgba(0, 123, 255, 0.3);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* Focus state for keyboard navigation */
|
|
80
|
+
.sft-tab-bar > input[type="radio"]:focus + label {
|
|
81
|
+
box-shadow: 0 0 0 3px rgba(66, 153, 225, 0.6);
|
|
82
|
+
border-radius: 3px;
|
|
83
|
+
outline: none;
|
|
84
|
+
z-index: 1;
|
|
85
|
+
position: relative;
|
|
52
86
|
}
|
|
53
87
|
|
|
54
88
|
/* The container for all content panels. */
|
|
55
|
-
.sft-content {
|
|
89
|
+
.sft-content {
|
|
90
|
+
padding: 20px;
|
|
91
|
+
}
|
|
56
92
|
|
|
57
|
-
/* By default, hide all tab-specific panels. */
|
|
58
|
-
.sft-
|
|
93
|
+
/* By default, hide all tab-specific panels. The dynamic CSS will show the active one. */
|
|
94
|
+
.sft-content > .sft-panel {
|
|
59
95
|
display: none;
|
|
60
96
|
}
|
|
61
|
-
|
|
62
|
-
|
|
97
|
+
|
|
98
|
+
/* Always show the "General" panel, as it has no radio button to control it. */
|
|
99
|
+
.sft-content > .sft-panel[data-filter="General"] {
|
|
63
100
|
display: block;
|
|
64
101
|
}
|
|
65
|
-
/* The dynamically generated <style> block handles showing the one active panel. */
|
|
66
102
|
|
|
103
|
+
/* Ensure panels have proper focus styles for screen readers */
|
|
104
|
+
.sft-content > .sft-panel[role="tabpanel"]:focus {
|
|
105
|
+
outline: 2px solid var(--sft-tab-highlight-color, #007bff);
|
|
106
|
+
outline-offset: 2px;
|
|
107
|
+
}
|
|
67
108
|
|
|
68
|
-
/* Styles for collapsible sections
|
|
109
|
+
/* Styles for collapsible sections */
|
|
69
110
|
.collapsible-section {
|
|
70
111
|
border: 1px solid #e0e0e0;
|
|
71
|
-
/* THEME: The accent color is controlled by a CSS variable. */
|
|
72
112
|
border-left: 4px solid var(--sft-collapsible-accent-color, #17a2b8);
|
|
73
113
|
border-radius: 4px;
|
|
74
114
|
margin-top: 1em;
|
|
75
115
|
}
|
|
116
|
+
|
|
76
117
|
.collapsible-section summary {
|
|
77
118
|
display: block;
|
|
78
119
|
cursor: pointer;
|
|
@@ -80,16 +121,47 @@
|
|
|
80
121
|
font-weight: bold;
|
|
81
122
|
background-color: #f9f9f9;
|
|
82
123
|
outline: none;
|
|
124
|
+
list-style: none;
|
|
83
125
|
}
|
|
84
|
-
|
|
126
|
+
|
|
85
127
|
.collapsible-section summary::-webkit-details-marker {
|
|
86
128
|
display: none;
|
|
87
129
|
}
|
|
88
|
-
|
|
89
|
-
.collapsible-section summary {
|
|
90
|
-
|
|
130
|
+
|
|
131
|
+
.collapsible-section[open] > summary {
|
|
132
|
+
border-bottom: 1px solid #e0e0e0;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.custom-arrow {
|
|
136
|
+
display: inline-block;
|
|
137
|
+
width: 1em;
|
|
138
|
+
margin-right: 8px;
|
|
139
|
+
transition: transform 0.2s;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.collapsible-section[open] > summary .custom-arrow {
|
|
143
|
+
transform: rotate(90deg);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.collapsible-content {
|
|
147
|
+
padding: 15px;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* Support for high contrast mode */
|
|
151
|
+
@media (prefers-contrast: high) {
|
|
152
|
+
.sft-tab-bar > input[type="radio"]:checked + label {
|
|
153
|
+
border-bottom-width: 4px;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.sft-tab-bar > input[type="radio"]:focus + label {
|
|
157
|
+
box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.8);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/* Support for reduced motion preferences */
|
|
162
|
+
@media (prefers-reduced-motion: reduce) {
|
|
163
|
+
.sft-tab-bar > label,
|
|
164
|
+
.custom-arrow {
|
|
165
|
+
transition: none;
|
|
166
|
+
}
|
|
91
167
|
}
|
|
92
|
-
.collapsible-section[open] > summary { border-bottom: 1px solid #e0e0e0; }
|
|
93
|
-
.custom-arrow { display: inline-block; width: 1em; margin-right: 8px; transition: transform 0.2s; }
|
|
94
|
-
.collapsible-section[open] > summary .custom-arrow { transform: rotate(90deg); }
|
|
95
|
-
.collapsible-content { padding: 15px; }
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Progressive enhancement for keyboard navigation
|
|
2
|
+
// This file ensures proper keyboard navigation while maintaining CSS-only fallback
|
|
3
|
+
|
|
4
|
+
(function() {
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
// Only enhance if the extension is present
|
|
8
|
+
if (!document.querySelector('.sft-container')) return;
|
|
9
|
+
|
|
10
|
+
function initTabKeyboardNavigation() {
|
|
11
|
+
const containers = document.querySelectorAll('.sft-container');
|
|
12
|
+
|
|
13
|
+
containers.forEach(container => {
|
|
14
|
+
const tabBar = container.querySelector('.sft-tab-bar');
|
|
15
|
+
if (!tabBar) return;
|
|
16
|
+
|
|
17
|
+
const radios = tabBar.querySelectorAll('input[type="radio"]');
|
|
18
|
+
const labels = tabBar.querySelectorAll('label');
|
|
19
|
+
|
|
20
|
+
if (radios.length === 0 || labels.length === 0) return;
|
|
21
|
+
|
|
22
|
+
// Make labels focusable for keyboard navigation
|
|
23
|
+
labels.forEach(label => {
|
|
24
|
+
if (!label.hasAttribute('tabindex')) {
|
|
25
|
+
label.setAttribute('tabindex', '0');
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Handle keyboard navigation on labels
|
|
30
|
+
labels.forEach((label, index) => {
|
|
31
|
+
label.addEventListener('keydown', (event) => {
|
|
32
|
+
let targetIndex = index;
|
|
33
|
+
let handled = false;
|
|
34
|
+
|
|
35
|
+
switch (event.key) {
|
|
36
|
+
case 'ArrowRight':
|
|
37
|
+
event.preventDefault();
|
|
38
|
+
targetIndex = (index + 1) % labels.length;
|
|
39
|
+
handled = true;
|
|
40
|
+
break;
|
|
41
|
+
|
|
42
|
+
case 'ArrowLeft':
|
|
43
|
+
event.preventDefault();
|
|
44
|
+
targetIndex = (index - 1 + labels.length) % labels.length;
|
|
45
|
+
handled = true;
|
|
46
|
+
break;
|
|
47
|
+
|
|
48
|
+
case 'Home':
|
|
49
|
+
event.preventDefault();
|
|
50
|
+
targetIndex = 0;
|
|
51
|
+
handled = true;
|
|
52
|
+
break;
|
|
53
|
+
|
|
54
|
+
case 'End':
|
|
55
|
+
event.preventDefault();
|
|
56
|
+
targetIndex = labels.length - 1;
|
|
57
|
+
handled = true;
|
|
58
|
+
break;
|
|
59
|
+
|
|
60
|
+
case 'Enter':
|
|
61
|
+
case ' ':
|
|
62
|
+
// Activate the associated radio button
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
if (radios[index]) {
|
|
65
|
+
radios[index].checked = true;
|
|
66
|
+
radios[index].dispatchEvent(new Event('change', { bubbles: true }));
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
|
|
70
|
+
default:
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (handled) {
|
|
75
|
+
// Move focus to target label and activate its radio
|
|
76
|
+
labels[targetIndex].focus();
|
|
77
|
+
if (radios[targetIndex]) {
|
|
78
|
+
radios[targetIndex].checked = true;
|
|
79
|
+
radios[targetIndex].dispatchEvent(new Event('change', { bubbles: true }));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Optional: Announce tab changes for screen readers
|
|
86
|
+
if (window.config && window.config.filter_tabs_announce_changes !== false) {
|
|
87
|
+
radios.forEach((radio, index) => {
|
|
88
|
+
radio.addEventListener('change', () => {
|
|
89
|
+
if (radio.checked && labels[index]) {
|
|
90
|
+
announceTabChange(labels[index].textContent.trim());
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function announceTabChange(tabName) {
|
|
99
|
+
// Create or update live region for screen reader announcements
|
|
100
|
+
let liveRegion = document.getElementById('tab-live-region');
|
|
101
|
+
if (!liveRegion) {
|
|
102
|
+
liveRegion = document.createElement('div');
|
|
103
|
+
liveRegion.id = 'tab-live-region';
|
|
104
|
+
liveRegion.setAttribute('role', 'status');
|
|
105
|
+
liveRegion.setAttribute('aria-live', 'polite');
|
|
106
|
+
liveRegion.setAttribute('aria-atomic', 'true');
|
|
107
|
+
liveRegion.style.position = 'absolute';
|
|
108
|
+
liveRegion.style.left = '-10000px';
|
|
109
|
+
liveRegion.style.width = '1px';
|
|
110
|
+
liveRegion.style.height = '1px';
|
|
111
|
+
liveRegion.style.overflow = 'hidden';
|
|
112
|
+
document.body.appendChild(liveRegion);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Update the announcement
|
|
116
|
+
liveRegion.textContent = `${tabName} tab selected`;
|
|
117
|
+
|
|
118
|
+
// Clear the announcement after a short delay
|
|
119
|
+
setTimeout(() => {
|
|
120
|
+
liveRegion.textContent = '';
|
|
121
|
+
}, 1000);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Initialize when DOM is ready
|
|
125
|
+
if (document.readyState === 'loading') {
|
|
126
|
+
document.addEventListener('DOMContentLoaded', initTabKeyboardNavigation);
|
|
127
|
+
} else {
|
|
128
|
+
initTabKeyboardNavigation();
|
|
129
|
+
}
|
|
130
|
+
})();
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sphinx-filter-tabs
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.2
|
|
4
4
|
Summary: A Sphinx extension for accessible, JS-free filterable content tabs.
|
|
5
5
|
Author-email: Aputsiak Niels Janussen <aputsiak+sphinx@gmail.com>
|
|
6
6
|
License: GNU General Public License v3.0
|
|
7
7
|
Project-URL: Homepage, https://github.com/aputtu/sphinx-filter-tabs
|
|
8
8
|
Project-URL: Repository, https://github.com/aputtu/sphinx-filter-tabs.git
|
|
9
9
|
Project-URL: Issues, https://github.com/aputtu/sphinx-filter-tabs/issues
|
|
10
|
-
Keywords: sphinx,extension,tabs,filter,documentation,no-js
|
|
10
|
+
Keywords: sphinx,extension,tabs,filter,documentation,no-js,aria,accessibility
|
|
11
11
|
Classifier: Development Status :: 4 - Beta
|
|
12
12
|
Classifier: Framework :: Sphinx :: Extension
|
|
13
13
|
Classifier: Intended Audience :: Developers
|
|
@@ -45,3 +45,21 @@ This extension provides `filter-tabs` and `tab` directives to create user-friend
|
|
|
45
45
|
You can install this extension using `pip`:
|
|
46
46
|
```bash
|
|
47
47
|
pip install sphinx-filter-tabs
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Development
|
|
51
|
+
|
|
52
|
+
1. You can install a local version of the Sphinx with extension using:
|
|
53
|
+
```bash
|
|
54
|
+
./scripts/setup_dev.sh # Initially cleans previous folders in _docs/build and venv.
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Command to enter venv is provided.
|
|
58
|
+
|
|
59
|
+
2. Once inside virtual environment, you can use following commands:
|
|
60
|
+
```bash
|
|
61
|
+
pytest # Runs test suite on configured version of Sphinx.
|
|
62
|
+
tox # Check across multiple Sphinx versions. Manual install of tox required.
|
|
63
|
+
./scripts/export-project.sh # Outputs directory structure and code to txt
|
|
64
|
+
./dev.sh [options] # Allows for faster generation for html, pdf, clean up
|
|
65
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
filter_tabs/__init__.py,sha256=VPpIhj4HaLeMX7ai7dZFkUm81ii2ePPGjCd9hsMjsN4,397
|
|
2
|
+
filter_tabs/extension.py,sha256=VWZj6fcgkH2WSBX8r7JPyarpqCSLAq1dtqzZdBDGejo,19457
|
|
3
|
+
filter_tabs/static/filter_tabs.css,sha256=M-6VbOsWU7xiklpHeRtD-iVDK-Bmk_NZle6e7AgiYLE,3994
|
|
4
|
+
filter_tabs/static/filter_tabs.js,sha256=r_zWkPYDgdKEQP4vkXRbe3YtcChEvzJ5WPVKzpS8-Q0,5309
|
|
5
|
+
sphinx_filter_tabs-0.9.2.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
6
|
+
sphinx_filter_tabs-0.9.2.dist-info/METADATA,sha256=JIzur1-LMOiUgZz3Oazsp-FyJLnvl6UsQEq1dQ4grF0,2872
|
|
7
|
+
sphinx_filter_tabs-0.9.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
8
|
+
sphinx_filter_tabs-0.9.2.dist-info/entry_points.txt,sha256=za_bQcueY8AHyq7XnnjkW9X3C-LsZjeERVQ_ds7jV1A,62
|
|
9
|
+
sphinx_filter_tabs-0.9.2.dist-info/top_level.txt,sha256=K0Iy-6EsYYdvlyXdsJT0SQg-BLDBgT5-Y8ZKy5SNAfc,12
|
|
10
|
+
sphinx_filter_tabs-0.9.2.dist-info/RECORD,,
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
filter_tabs/__init__.py,sha256=VPpIhj4HaLeMX7ai7dZFkUm81ii2ePPGjCd9hsMjsN4,397
|
|
2
|
-
filter_tabs/extension.py,sha256=tU2ZBYWRmYHyvQh8NgPWWqFidunsL16KHhoyqMfboCw,20739
|
|
3
|
-
filter_tabs/static/filter_tabs.css,sha256=btJoI-Q87e0XUQARwJUhM6RCfg5m2dvxKtfABZ2zRsc,3429
|
|
4
|
-
sphinx_filter_tabs-0.7.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
|
|
5
|
-
sphinx_filter_tabs-0.7.0.dist-info/METADATA,sha256=WJjRU9rRRzgFFKlheXL4QTa_c1vF0FWhd3bqMyiG_zA,2264
|
|
6
|
-
sphinx_filter_tabs-0.7.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
7
|
-
sphinx_filter_tabs-0.7.0.dist-info/entry_points.txt,sha256=za_bQcueY8AHyq7XnnjkW9X3C-LsZjeERVQ_ds7jV1A,62
|
|
8
|
-
sphinx_filter_tabs-0.7.0.dist-info/top_level.txt,sha256=K0Iy-6EsYYdvlyXdsJT0SQg-BLDBgT5-Y8ZKy5SNAfc,12
|
|
9
|
-
sphinx_filter_tabs-0.7.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|