From c8c0fd65d22cd6424a2403a77cf2945dcd0c13fc Mon Sep 17 00:00:00 2001 From: Mike McKiernan Date: Tue, 25 Oct 2022 08:18:38 -0400 Subject: [PATCH] feat: Sticky sync tabs and URL param Add a Sphinx configuration option that is named `sphinx_design_sync_tabs_storage_key` so that users can set a project-specific local storage key rather than risk clobbering the value for `sphinx-design-last-tab` across different projects. Add support for an optional URL search param of like `?tab=blah` or `?code=blah` so that I can copy and paste a URL and the receiver sees the tab content that I see. By default, no param and you get to add "Oh, and click blah" in your message. If the URL has search param `?tab=blah`, set the local storage value to `blah`. If `blah` matches a synchronization key for a tab, show the tab. When someone clicks a tab, set the local storage value to the synchronization key value and also update the URL search params with `?tab=` if the project configures a URL param. --- docs/tabs.md | 27 ++++++++++++++++++ sphinx_design/compiled/sd_tabs.js | 34 +++++++++++++++++++++- sphinx_design/extension.py | 20 ++++++++++++- tests/test_opts.py | 47 +++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 tests/test_opts.py diff --git a/docs/tabs.md b/docs/tabs.md index 8f7da1b..fa93526 100644 --- a/docs/tabs.md +++ b/docs/tabs.md @@ -228,3 +228,30 @@ class-label class-content : Additional CSS classes for the content element. + +## Synchronised tab options + +### URL parameter + +By default, the URL does not indicate the tab that you selected. +To include the tab as a URL parameter, add a setting like the following example in your `conf.py` file: + +```python +sphinx_design_sync_tabs_url_param = "tab" +``` + +With a setting like the preceding example, after you browse to a URL like `https://www.example.com/project-A/index.html` and select a tab that you configured with the `sync` option of `python`, the URL changes to `https://www.example.com/project-A/index.html?tab=python`. + +### Local storage key + +Synchronised tabs and tabbed code examples store the value of the `sync` option in browser local storage so that a return visit to a page opens the most-recently viewed tab. +The storage applies to a single domain, such as `https://www.example.com`. +As a result, two projects on the same domain can interfere with each other. + +To use project-specific storage, add a setting like the following example in your `conf.py` file: + +```python +sphinx_design_sync_tabs_storage_key = "proj-a" +``` + +The default value for the storage key is `sphinx-design-last-tab`. diff --git a/sphinx_design/compiled/sd_tabs.js b/sphinx_design/compiled/sd_tabs.js index 36b38cf..2405791 100644 --- a/sphinx_design/compiled/sd_tabs.js +++ b/sphinx_design/compiled/sd_tabs.js @@ -1,7 +1,19 @@ var sd_labels_by_text = {}; +const storageKey = '&{storage_key}'; +// The default value for paramKey is the string 'None'. That value indicates +// not to set a URL search parameter. +const paramKey = '&{param_key}'; + function ready() { + const tabParam = new URLSearchParams(window.location.search).get(paramKey); + if (tabParam) { + window.localStorage.setItem(storageKey, tabParam) + } + const li = document.getElementsByClassName("sd-tab-label"); + const previousChoice = window.localStorage.getItem(storageKey) + for (const label of li) { syncId = label.getAttribute("data-sync-id"); if (syncId) { @@ -10,6 +22,10 @@ function ready() { sd_labels_by_text[syncId] = []; } sd_labels_by_text[syncId].push(label); + if (previousChoice === syncId) { + label.previousElementSibling.checked = true; + updateSearchParams(syncId) + } } } } @@ -21,7 +37,23 @@ function onLabelClick() { if (label === this) continue; label.previousElementSibling.checked = true; } - window.localStorage.setItem("sphinx-design-last-tab", syncId); + window.localStorage.setItem(storageKey, syncId); + updateSearchParams(syncId) +} + +/** + * @param {string} syncId + */ +function updateSearchParams(syncId) { + if (paramKey == 'None') { + return; + } + const params = new URLSearchParams(window.location.search); + if (params.get(paramKey) === syncId) { + return; + } + params.set(paramKey, syncId); + window.history.replaceState({}, '', `${window.location.pathname}?${params}`) } document.addEventListener("DOMContentLoaded", ready, false); diff --git a/sphinx_design/extension.py b/sphinx_design/extension.py index b0d41bf..b098e52 100644 --- a/sphinx_design/extension.py +++ b/sphinx_design/extension.py @@ -1,6 +1,7 @@ import hashlib import importlib.resources as resources from pathlib import Path +from string import Template from docutils import nodes from docutils.parsers.rst import directives @@ -21,6 +22,15 @@ from .tabs import setup_tabs +class AmpersandTemplate(Template): + """We need a Template with a delimiter other than dollar sign + to template the JavaScript file. This is shamelessly stolen from + test_string.py in the Python repository. + """ + + delimiter = "&" + + def setup_extension(app: Sphinx) -> None: """Set up the sphinx extension.""" app.connect("builder-inited", update_css_js) @@ -42,6 +52,10 @@ def setup_extension(app: Sphinx) -> None: "div", Div, override=True ) # override sphinx-panels implementation app.add_transform(AddFirstTitleCss) + app.add_config_value( + "sphinx_design_sync_tabs_storage_key", "sphinx-design-last-tab", rebuild=True + ) + app.add_config_value("sphinx_design_sync_tabs_url_param", None, rebuild=True) setup_badges_and_buttons(app) setup_cards(app) setup_grids(app) @@ -64,7 +78,11 @@ def update_css_js(app: Sphinx): js_path = static_path / "design-tabs.js" app.add_js_file(js_path.name) if not js_path.exists(): - content = resources.read_text(static_module, "sd_tabs.js") + stor_key = app.env.config.sphinx_design_sync_tabs_storage_key + url_key = app.env.config.sphinx_design_sync_tabs_url_param + content = AmpersandTemplate( + resources.read_text(static_module, "sd_tabs.js") + ).substitute(storage_key=stor_key, param_key=url_key) js_path.write_text(content) # Read the css content and hash it content = resources.read_text(static_module, "style.min.css") diff --git a/tests/test_opts.py b/tests/test_opts.py new file mode 100644 index 0000000..5d57794 --- /dev/null +++ b/tests/test_opts.py @@ -0,0 +1,47 @@ +"""Test extension configuration options.""" +from pathlib import Path +from typing import Callable + +from .conftest import SphinxBuilder + + +def test_tabs_opts_default_values(sphinx_builder: Callable[..., SphinxBuilder]): + """Test the sphinx_design_sync_tabs_xxx options default values.""" + builder = sphinx_builder() + assert "sphinx_design_sync_tabs_storage_key" in builder.app.env.config + assert "sphinx_design_sync_tabs_url_param" in builder.app.env.config + assert ( + builder.app.env.config.sphinx_design_sync_tabs_storage_key + == "sphinx-design-last-tab" + ) + assert builder.app.env.config.sphinx_design_sync_tabs_url_param is None + content = "Heading\n-------\n\ncontent" + builder.src_path.joinpath("index.rst").write_text(content, encoding="utf8") + builder.build() + design_tabs_js = Path(builder.out_path) / "_static" / "design-tabs.js" + with design_tabs_js.open() as f: + lines = f.read() + assert "sphinx-design-last-tab" in lines + + +def test_tabs_storage_key_set(sphinx_builder: Callable[..., SphinxBuilder]): + """Test the sphinx_design_sync_tabs_storage_key option.""" + builder = sphinx_builder( + conf_kwargs={ + "extensions": ["myst_parser", "sphinx_design"], + "myst_enable_extensions": ["colon_fence"], + "sphinx_design_sync_tabs_storage_key": "spam-42", + "sphinx_design_sync_tabs_url_param": "bag_of_ham", + } + ) + assert "sphinx_design_sync_tabs_storage_key" in builder.app.env.config + assert builder.app.env.config.sphinx_design_sync_tabs_storage_key == "spam-42" + content = "Heading\n-------\n\ncontent" + builder.src_path.joinpath("index.rst").write_text(content, encoding="utf8") + builder.build() + design_tabs_js = Path(builder.out_path) / "_static" / "design-tabs.js" + with design_tabs_js.open() as f: + lines = f.read() + assert "spam-42" in lines + assert "bag_of_ham" in lines + assert "sphinx-design-last-tab" not in lines