Repository: facelessuser/pymdown-extensions

Issue:
Allow slugify function for Tabbed to generate readable anchors
### Description

Currently, `ids` generated by Tabbed are of the form `__tabbed_{tabset}_{tab}`, e.g. `__tabbed_1_2`. Material for MkDocs allows to [add anchor links to tabs](https://squidfunk.github.io/mkdocs-material/reference/content-tabs/#anchor-links), which means you can now explicitly link to tabs. However, this generates rather ugly and non-permalink URLs: https://squidfunk.github.io/mkdocs-material/getting-started/#__tabbed_1_2

### Benefits

[Users asked](https://github.com/squidfunk/mkdocs-material/discussions/4360) whether the ids can be customized, and immediately the Attribute Lists extension popped up in my head. However, we probably don't need support for Attribute Lists, as the usage of a user-definable slug function would probably be enough. The slug function could be passed the contents of the tab label, and generate a readable anchor.

### Solution Idea

For example, with this Markdown:

```
=== "This is a label"

    Content
```

The following id could be generated, which can be used as an anchor:

``` html
...
<input id="this-is-a-label" name="__tabbed_1" type="radio">
...
```

The [Table of Contents](https://python-markdown.github.io/extensions/toc/#usage) extension also implements this, which is related, as it also generates anchor links. Configuration could possibly look like this:

``` yaml
markdown_extensions:
  pymdownx.tabbed:
    slugify: !!python/name:pymdownx.slugs.uslugify
```

And as a shortcut defaulting to the standard Markdown slugify function:

``` yaml
markdown_extensions:
  pymdownx.tabbed:
    slugify: true
```

Discussion:
facelessuser: Tests still need to be written.
facelessuser: Documentation is also required
facelessuser: @gir-bot lgtm

Patch:
diff --git a/docs/src/markdown/about/changelog.md b/docs/src/markdown/about/changelog.md
index 57f53fc7..0bd3ec0c 100644
--- a/docs/src/markdown/about/changelog.md
+++ b/docs/src/markdown/about/changelog.md
@@ -1,5 +1,9 @@
 # Changelog
 
+## 9.6
+
+- **NEW**: Add options for generating tab IDs from tab titles.
+
 ## 9.5
 
 - **NEW**: InlineHilite: Custom inline code block formatters can now be forced to raise an exception by raising a
diff --git a/docs/src/markdown/extensions/tabbed.md b/docs/src/markdown/extensions/tabbed.md
index a4a1031b..dbf2d33a 100644
--- a/docs/src/markdown/extensions/tabbed.md
+++ b/docs/src/markdown/extensions/tabbed.md
@@ -96,6 +96,21 @@ of a new tab set with `!`.
             ```
         ```
 
+## Tab IDs
+
+By default, tabs generate IDs for each tab using the following template `__tabbed_<tab_set_number>_<tab_number>`. If it
+is desired to implement jumping to a specific tab with more intuitive IDs, it may be preferable to generate IDs from
+slugs. To do so, two [options](#options) are provided: `slugify` and `separator`.
+
+!!! tip
+    Jumping to tabs via IDs may require additional JavaScript to select the targeted tabs.
+
+If `slugify` is given a slug function (you can use any that [ship with Pymdownx Extensions](../extras/slugs.md)), the
+Tabbed extension will generate IDs from the tab titles just like headers. `separator` allows for the specifying of the
+word separator (`-` is the default).
+
+!!! new "New 9.0"
+
 ## Styling with CSS
 
 In order to use tabbed blocks, some additional CSS is needed. You can check out the configuration below which will
@@ -533,6 +548,8 @@ to some practical number.
 
 ## Options
 
-Option            | Type | Default       | Description
------------------ | ---- | ------------- | -----------
-`alternate_style` | bool | `#!py3 False` | Use the experimental, alternative style.
+Option            | Type     | Default       | Description
+----------------- | -------- | ------------- | -----------
+`alternate_style` | bool     | `#!py3 False` | Use the experimental, alternative style.
+`slugify`         | function | `#!py3 None`  | A function to generate slugs from tab titles.
+`separator`       | string   | `#!py3 '-'`   | Default word separator when generating slugs.
diff --git a/docs/src/mkdocs.yml b/docs/src/mkdocs.yml
index 3fbba32d..0719c952 100644
--- a/docs/src/mkdocs.yml
+++ b/docs/src/mkdocs.yml
@@ -173,6 +173,8 @@ extra_javascript:
 
 plugins:
   - search
-  - git-revision-date-localized
+  - git-revision-date-localized:
+      fallback_to_build_date: true
+  - mkdocs_pymdownx_material_extras
   - minify:
       minify_html: true
diff --git a/mkdocs.yml b/mkdocs.yml
index ba762a9b..71e9b14d 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -173,6 +173,8 @@ extra_javascript:
 
 plugins:
   - search
-  - git-revision-date-localized
+  - git-revision-date-localized:
+      fallback_to_build_date: true
+  - mkdocs_pymdownx_material_extras
   - minify:
       minify_html: true
diff --git a/pymdownx/__meta__.py b/pymdownx/__meta__.py
index 1008f158..5f4a36fd 100644
--- a/pymdownx/__meta__.py
+++ b/pymdownx/__meta__.py
@@ -185,5 +185,5 @@ def parse_version(ver, pre=False):
     return Version(major, minor, micro, release, pre, post, dev)
 
 
-__version_info__ = Version(9, 5, 0, "final")
+__version_info__ = Version(9, 6, 0, "final")
 __version__ = __version_info__._get_canonical()
diff --git a/pymdownx/tabbed.py b/pymdownx/tabbed.py
index b1ba5d13..dfff9314 100644
--- a/pymdownx/tabbed.py
+++ b/pymdownx/tabbed.py
@@ -23,6 +23,8 @@ DEALINGS IN THE SOFTWARE.
 """
 from markdown import Extension
 from markdown.blockprocessors import BlockProcessor
+from markdown.treeprocessors import Treeprocessor
+from markdown.extensions import toc
 import xml.etree.ElementTree as etree
 import re
 
@@ -35,14 +37,15 @@ class TabbedProcessor(BlockProcessor):
     )
     COMPRESS_SPACES = re.compile(r' {2,}')
 
-    def __init__(self, *args, alternate_style=False):
+    def __init__(self, parser, config):
         """Initialize."""
 
-        super().__init__(*args)
+        super().__init__(parser)
         self.tab_group_count = 0
         self.current_sibling = None
         self.content_indention = 0
-        self.alternate_style = alternate_style
+        self.alternate_style = config['alternate_style']
+        self.slugify = callable(config['slugify'])
 
     def detab_by_length(self, text, length):
         """Remove a tab from the front of each line of the given text."""
@@ -166,6 +169,9 @@ class TabbedProcessor(BlockProcessor):
         if m:
             special = m.group(1) if m.group(1) else ''
             title = m.group(2) if m.group(2) else m.group(3)
+            index = 0
+            labels = None
+            content = None
 
             if (
                 sibling and sibling.tag.lower() == 'div' and
@@ -176,8 +182,6 @@ class TabbedProcessor(BlockProcessor):
                 sfences = sibling
                 if self.alternate_style:
                     index = [index for index, last_input in enumerate(sfences.findall('input'), 1)][-1]
-                    labels = None
-                    content = None
                     for d in sfences.findall('div'):
                         if d.attrib['class'] == 'tabbed-labels':
                             labels = d
@@ -194,7 +198,6 @@ class TabbedProcessor(BlockProcessor):
                     {'class': tabbed_set, 'data-tabs': '%d:0' % self.tab_group_count}
                 )
                 if self.alternate_style:
-                    index = 0
                     labels = etree.SubElement(
                         sfences,
                         'div',
@@ -212,13 +215,17 @@ class TabbedProcessor(BlockProcessor):
 
             attributes = {
                 "name": "__tabbed_%d" % tab_set,
-                "type": "radio",
-                "id": "__tabbed_%d_%d" % (tab_set, tab_count)
+                "type": "radio"
             }
 
+            if not self.slugify:
+                attributes['id'] = "__tabbed_%d_%d" % (tab_set, tab_count)
+
             if first:
                 attributes['checked'] = 'checked'
 
+            attributes2 = {"for": "__tabbed_%d_%d" % (tab_set, tab_count)} if not self.slugify else {}
+
             if self.alternate_style:
                 input_el = etree.Element(
                     'input',
@@ -228,9 +235,7 @@ class TabbedProcessor(BlockProcessor):
                 lab = etree.SubElement(
                     labels,
                     "label",
-                    {
-                        "for": "__tabbed_%d_%d" % (tab_set, tab_count)
-                    }
+                    attributes2
                 )
                 lab.text = title
 
@@ -248,9 +253,7 @@ class TabbedProcessor(BlockProcessor):
                 lab = etree.SubElement(
                     sfences,
                     "label",
-                    {
-                        "for": "__tabbed_%d_%d" % (tab_set, tab_count)
-                    }
+                    attributes2
                 )
                 lab.text = title
 
@@ -288,6 +291,55 @@ class TabbedProcessor(BlockProcessor):
             blocks.insert(0, non_tabs)
 
 
+class TabbedTreeprocessor(Treeprocessor):
+    """Tab tree processor."""
+
+    def __init__(self, md, config):
+        """Initialize."""
+
+        super().__init__(md)
+
+        self.slugify = config["slugify"]
+        self.alternate = config["alternate_style"]
+        self.sep = config["separator"]
+
+    def run(self, doc):
+        """Update tab IDs."""
+
+        # Get a list of id attributes
+        used_ids = set()
+        for el in doc.iter():
+            if "id" in el.attrib:
+                used_ids.add(el.attrib["id"])
+
+        for el in doc.iter():
+            if isinstance(el.tag, str) and el.tag.lower() == 'div':
+                classes = el.attrib.get('class', '').split()
+                if 'tabbed-set' in classes and (not self.alternate or 'tabbed-alternate' in classes):
+                    inputs = []
+                    labels = []
+                    if self.alternate:
+                        for i in list(el):
+                            if i.tag == 'input':
+                                inputs.append(i)
+                            if i.tag == 'div' and i.attrib.get('class', '') == 'tabbed-labels':
+                                labels = [j for j in list(i) if j.tag == 'label']
+                    else:
+                        for i in list(el):
+                            if i.tag == 'input':
+                                inputs.append(i)
+                            if i.tag == 'label':
+                                labels.append(i)
+
+                    # Generate slugged IDs
+                    for inpt, label in zip(inputs, labels):
+                        text = toc.get_name(label)
+                        innertext = toc.unescape(toc.stashedHTML2text(text, self.md))
+                        slug = toc.unique(self.slugify(innertext, self.sep), used_ids)
+                        inpt.attrib["id"] = slug
+                        label.attrib["for"] = slug
+
+
 class TabbedExtension(Extension):
     """Add Tabbed extension."""
 
@@ -295,7 +347,9 @@ class TabbedExtension(Extension):
         """Initialize."""
 
         self.config = {
-            'alternate_style': [False, "Use alternate style - Default: False"]
+            'alternate_style': [False, "Use alternate style - Default: False"],
+            'slugify': [0, "Slugify function used to create tab specific IDs - Default: None"],
+            'separator': ['-', "Slug separator - Default: '-'"]
         }
 
         super(TabbedExtension, self).__init__(*args, **kwargs)
@@ -305,8 +359,11 @@ class TabbedExtension(Extension):
         md.registerExtension(self)
 
         config = self.getConfigs()
-        self.tab_processor = TabbedProcessor(md.parser, alternate_style=config['alternate_style'])
+        self.tab_processor = TabbedProcessor(md.parser, config)
         md.parser.blockprocessors.register(self.tab_processor, "tabbed", 105)
+        if config['slugify']:
+            slugs = TabbedTreeprocessor(md, config)
+            md.treeprocessors.register(slugs, 'tab_slugs', 4)
 
     def reset(self):
         """Reset."""
diff --git a/requirements/docs.txt b/requirements/docs.txt
index 4376e44c..8795bdec 100644
--- a/requirements/docs.txt
+++ b/requirements/docs.txt
@@ -1,4 +1,4 @@
-mkdocs-material >= 5.0.2
+mkdocs_pymdownx_material_extras>=2.1
 git+https://github.com/facelessuser/pymdown-lexers.git
 mkdocs-git-revision-date-localized-plugin
 mkdocs-minify-plugin


Test patch:
diff --git a/tests/test_extensions/test_tabbed.py b/tests/test_extensions/test_tabbed.py
index 0ae1b332..3f925320 100644
--- a/tests/test_extensions/test_tabbed.py
+++ b/tests/test_extensions/test_tabbed.py
@@ -1,5 +1,6 @@
 """Test cases for SuperFences."""
 from .. import util
+from pymdownx.slugs import slugify
 
 
 class TestLegacyTab(util.MdCase):
@@ -448,3 +449,73 @@ class TestLegacyTab(util.MdCase):
             ''',  # noqa: E501
             True
         )
+
+
+class TestLegacyTabSlugs(util.MdCase):
+    """Test legacy tab slug cases."""
+
+    extension = ['pymdownx.tabbed', 'toc']
+    extension_configs = {'pymdownx.tabbed': {'slugify': slugify(case='lower')}}
+
+    MD = """
+    ### Here is some text
+
+    === "Here is some text"
+        content
+
+    === "Here is some text"
+        content
+    """
+
+    def test_tab_slugs(self):
+        """Test tab slugs."""
+
+        self.check_markdown(
+            self.MD,
+            '''
+            <h3 id="here-is-some-text">Here is some text</h3>
+            <div class="tabbed-set" data-tabs="1:2"><input checked="checked" id="here-is-some-text_1" name="__tabbed_1" type="radio" /><label for="here-is-some-text_1">Here is some text</label><div class="tabbed-content">
+            <p>content</p>
+            </div>
+            <input id="here-is-some-text_2" name="__tabbed_1" type="radio" /><label for="here-is-some-text_2">Here is some text</label><div class="tabbed-content">
+            <p>content</p>
+            </div>
+            </div>
+            ''',  # noqa: E501
+            True
+        )
+
+
+class TestLegacyTabSlugsSep(util.MdCase):
+    """Test legacy tab slug separator cases."""
+
+    extension = ['pymdownx.tabbed', 'toc']
+    extension_configs = {'pymdownx.tabbed': {'slugify': slugify(case='lower'), 'separator': '_'}}
+
+    MD = """
+    ### Here is some text
+
+    === "Here is some text"
+        content
+
+    === "Here is some text"
+        content
+    """
+
+    def test_slug_with_separator(self):
+        """Test tab slugs with separator."""
+
+        self.check_markdown(
+            self.MD,
+            '''
+            <h3 id="here-is-some-text">Here is some text</h3>
+            <div class="tabbed-set" data-tabs="1:2"><input checked="checked" id="here_is_some_text" name="__tabbed_1" type="radio" /><label for="here_is_some_text">Here is some text</label><div class="tabbed-content">
+            <p>content</p>
+            </div>
+            <input id="here_is_some_text_1" name="__tabbed_1" type="radio" /><label for="here_is_some_text_1">Here is some text</label><div class="tabbed-content">
+            <p>content</p>
+            </div>
+            </div>
+            ''',  # noqa: E501
+            True
+        )
diff --git a/tests/test_extensions/test_tabbed_alternate.py b/tests/test_extensions/test_tabbed_alternate.py
index 3ab2e1ad..28e2f861 100644
--- a/tests/test_extensions/test_tabbed_alternate.py
+++ b/tests/test_extensions/test_tabbed_alternate.py
@@ -1,5 +1,6 @@
 """Test cases for SuperFences."""
 from .. import util
+from pymdownx.slugs import slugify
 
 
 class TestLegacyTab(util.MdCase):
@@ -496,3 +497,81 @@ class TestLegacyTab(util.MdCase):
             ''',  # noqa: E501
             True
         )
+
+
+class TestLegacyTabSlugs(util.MdCase):
+    """Test legacy tab slug cases."""
+
+    extension = ['pymdownx.tabbed', 'toc']
+    extension_configs = {'pymdownx.tabbed': {'slugify': slugify(case='lower'), 'alternate_style': True}}
+
+    MD = """
+    ### Here is some text
+
+    === "Here is some text"
+        content
+
+    === "Here is some text"
+        content
+    """
+
+    def test_tab_slugs(self):
+        """Test tab slugs."""
+
+        self.check_markdown(
+            self.MD,
+            '''
+            <h3 id="here-is-some-text">Here is some text</h3>
+            <div class="tabbed-set tabbed-alternate" data-tabs="1:2"><input checked="checked" id="here-is-some-text_1" name="__tabbed_1" type="radio" /><input id="here-is-some-text_2" name="__tabbed_1" type="radio" /><div class="tabbed-labels"><label for="here-is-some-text_1">Here is some text</label><label for="here-is-some-text_2">Here is some text</label></div>
+            <div class="tabbed-content">
+            <div class="tabbed-block">
+            <p>content</p>
+            </div>
+            <div class="tabbed-block">
+            <p>content</p>
+            </div>
+            </div>
+            </div>
+            ''',  # noqa: E501
+            True
+        )
+
+
+class TestLegacyTabSlugsSep(util.MdCase):
+    """Test legacy tab slug separator cases."""
+
+    extension = ['pymdownx.tabbed', 'toc']
+    extension_configs = {
+        'pymdownx.tabbed': {'slugify': slugify(case='lower'), 'separator': '_', 'alternate_style': True}
+    }
+
+    MD = """
+    ### Here is some text
+
+    === "Here is some text"
+        content
+
+    === "Here is some text"
+        content
+    """
+
+    def test_slug_with_separator(self):
+        """Test tab slugs with separator."""
+
+        self.check_markdown(
+            self.MD,
+            '''
+            <h3 id="here-is-some-text">Here is some text</h3>
+            <div class="tabbed-set tabbed-alternate" data-tabs="1:2"><input checked="checked" id="here_is_some_text" name="__tabbed_1" type="radio" /><input id="here_is_some_text_1" name="__tabbed_1" type="radio" /><div class="tabbed-labels"><label for="here_is_some_text">Here is some text</label><label for="here_is_some_text_1">Here is some text</label></div>
+            <div class="tabbed-content">
+            <div class="tabbed-block">
+            <p>content</p>
+            </div>
+            <div class="tabbed-block">
+            <p>content</p>
+            </div>
+            </div>
+            </div>
+            ''',  # noqa: E501
+            True
+        )