Repository: facelessuser/pymdown-extensions

Issue:
Slugs that combine headers with anchors
### Description

### Problem

When using Mkdocs Material, it generates slugs for links being clicked based on the text of the link. In particular, I'm using a feature called [Content Tab Anchor Links](https://squidfunk.github.io/mkdocs-material/reference/content-tabs/#anchor-links), where tabs are rendered on a page, and clicking a tab will update the URL based on the tab's text.

<img width="830" alt="image" src="https://github.com/facelessuser/pymdown-extensions/assets/2481802/5e3b4b9c-af3d-486a-83e6-4a8aec460545">


When clicking on the tabs from content tabs that have the same names, I get links like these:

- example.com/#about
- example.com/#value
- example.com/#about_1
- example.com/#value_1

What I'd really like is to have the headers factor into the slug:

- example.com/#log_file-about
- example.com/#log_file-value
- example.com/#log_level-about
- example.com/#log_level-value

I opened a [discussion over at Mkdocs Material](https://github.com/squidfunk/mkdocs-material/discussions/5675), and it was mentioned to see if this was possible or something that could be pursued over here.

Is there any way to achieve this today? I was trying to figure out if I could write my own python function, but I couldn't easily determine that nor could I determine if there was a way to get the header(s) preceding a content tab.


### Benefits

Allows having even more readable slugs by using contextual positioning to fully represent where you are in a page. I've seen this done with HTML output from tools like vimwiki where they keep track of the depth that elements are in within a page, and combine all of the nested headers into part of the anchor.

### Solution Idea

I'm unaware of how Python markdown parsing works in terms of a solid structure. If it forms any kind of tree or association between headers and other content, then it would be trivial to reverse up the stack to see what each header's text was and combine that.

vimiwki handles it by scanning a document from top to bottom and maintaining a stateful track of the current headers as a stack, pushing onto the stack when we see a smaller header, and popping off the stack when we encounter a larger header. When a link is encountered, the current header tracking is used as a prefix to the anchor itself.

```markdown
# One

## Two

some anchor text

## Three

### Four

some anchor text
```

Yields links `example.com/#one-two-some-anchor-text` and `example.com/#one-three-four-some-anchor-text`.

Discussion:
facelessuser: @gir-bot lgtm

Patch:
diff --git a/docs/src/markdown/about/changelog.md b/docs/src/markdown/about/changelog.md
index 68887d99..d86cea9e 100644
--- a/docs/src/markdown/about/changelog.md
+++ b/docs/src/markdown/about/changelog.md
@@ -1,5 +1,11 @@
 # Changelog
 
+## 10.1
+
+-   **NEW**: Add new `combine_header_slug` option in legacy Tabbed extension and new Block Tab extension that will
+    prefix a content tab's slug with the parent header's slug. This allows for content tab slugs that are scoped to the
+    header they are under.
+
 ## 10.0.1
 
 - **FIX**: Regression related to snippets nested deeply under specified base path.
diff --git a/docs/src/markdown/extensions/blocks/plugins/tab.md b/docs/src/markdown/extensions/blocks/plugins/tab.md
index 92c4514c..eac250f0 100644
--- a/docs/src/markdown/extensions/blocks/plugins/tab.md
+++ b/docs/src/markdown/extensions/blocks/plugins/tab.md
@@ -116,6 +116,25 @@ If `slugify` is given a slug function (you can use any that [ship with Pymdownx
 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).
 
+If you'd like the slugs to be prefixed with the slug of the parent header, you can enable the `combine_header_slug`
+option. If you had the following example, normally the header slug would be `header` and the content tab would have the
+slug `tab`.
+
+```
+# header
+
+/// tab | tab
+content
+///
+```
+
+With `combine_header_slug` enabled, the header slug would still be `header`, but now the content tab slug would be
+`header-tab`.
+
+/// new | New 10.1
+`combine_header_slug` is new in 10.1
+///
+
 ## Additional Topics
 
 As Tab shares the same output and functionality as the [Tabbed extension](../../tabbed.md), you can check out the
@@ -127,11 +146,12 @@ documentation there to learn the following:
 
 ## Global Options
 
-Options           | Type     | Descriptions
------------------ | -------- | ------------
-`alternate_style` | bool     | Use the experimental, alternative style.
-`slugify`         | function | A function to generate slugs from tab titles.
-`separator`       | string   | Default word separator when generating slugs.
+Options               | Type     | Descriptions
+--------------------- | -------- | ------------
+`alternate_style`     | bool     | Use the experimental, alternative style.
+`slugify`             | function | A function to generate slugs from tab titles.
+`separator`           | string   | Default word separator when generating slugs.
+`combine_header_slug` | bool     | Combine the parent header slug with the tab content slug.
 
 ## Per Block Options
 
diff --git a/docs/src/markdown/extensions/tabbed.md b/docs/src/markdown/extensions/tabbed.md
index 3e87513a..f36f2b46 100644
--- a/docs/src/markdown/extensions/tabbed.md
+++ b/docs/src/markdown/extensions/tabbed.md
@@ -143,6 +143,24 @@ If `slugify` is given a slug function (you can use any that [ship with Pymdownx
 Tabbed extension will generate IDs from the tab titles just like headers. `separator` allows for the specifying of the
 word separator (`-` is the default).
 
+If you'd like the slugs to be prefixed with the slug of the parent header, you can enable the `combine_header_slug`
+option. If you had the following example, normally the header slug would be `header` and the content tab would have the
+slug `tab`.
+
+```
+# header
+
+=== "tab"
+    content
+```
+
+With `combine_header_slug` enabled, the header slug would still be `header`, but now the content tab slug would be
+`header-tab`.
+
+/// new | New 10.1
+`combine_header_slug` is new in 10.1
+///
+
 ## Styling with CSS
 
 In order to use tabbed blocks, some additional CSS is needed. You can check out the configuration below which will
@@ -598,8 +616,9 @@ are overflowed tabs, and scrolls tabs into view smoothly.
 
 ## Options
 
-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.
+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.
+`combine_header_slug` | bool     | Combine the parent header slug with the tab content slug.
diff --git a/docs/src/mkdocs.yml b/docs/src/mkdocs.yml
index b372a5f2..c38ed2b0 100644
--- a/docs/src/mkdocs.yml
+++ b/docs/src/mkdocs.yml
@@ -192,6 +192,8 @@ markdown_extensions:
   - pymdownx.blocks.definition:
   - pymdownx.blocks.tab:
       alternate_style: True
+      combine_header_slug: True
+      slugify: !!python/object/apply:pymdownx.slugs.slugify {kwds: {case: lower}}
   - tools.collapse_code:
       expand_text: ''
       collapse_text: ''
diff --git a/mkdocs.yml b/mkdocs.yml
index 71422dbf..dc75d79d 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -192,6 +192,8 @@ markdown_extensions:
   - pymdownx.blocks.definition:
   - pymdownx.blocks.tab:
       alternate_style: True
+      combine_header_slug: True
+      slugify: !!python/object/apply:pymdownx.slugs.slugify {kwds: {case: lower}}
   - tools.collapse_code:
       expand_text: ''
       collapse_text: ''
diff --git a/pymdownx/__meta__.py b/pymdownx/__meta__.py
index 3f8370ee..e9ba8a22 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(10, 0, 1, "final")
+__version_info__ = Version(10, 1, 0, "final")
 __version__ = __version_info__._get_canonical()
diff --git a/pymdownx/blocks/tab.py b/pymdownx/blocks/tab.py
index 363d55aa..4a1e5280 100644
--- a/pymdownx/blocks/tab.py
+++ b/pymdownx/blocks/tab.py
@@ -5,6 +5,8 @@ from markdown.treeprocessors import Treeprocessor
 from .block import Block, type_boolean
 from ..blocks import BlocksExtension
 
+HEADERS = {'h1', 'h2', 'h3', 'h4', 'h5', 'h6'}
+
 
 class TabbedTreeprocessor(Treeprocessor):
     """Tab tree processor."""
@@ -16,15 +18,48 @@ class TabbedTreeprocessor(Treeprocessor):
 
         self.alternate = config['alternate_style']
         self.slugify = config['slugify']
+        self.combine_header_slug = config['combine_header_slug']
         self.sep = config["separator"]
 
+    def get_parent_header_slug(self, root, header_map, parent_map, el):
+        """Attempt retrieval of parent header slug."""
+
+        parent = el
+        last_parent = parent
+        while parent is not root:
+            last_parent = parent
+            parent = parent_map[parent]
+            if parent in header_map:
+                headers = header_map[parent]
+                header = None
+                for i in list(parent):
+                    if i is el and header is None:
+                        break
+                    if i is last_parent:
+                        return header.attrib.get("id", '')
+                    if i in headers:
+                        header = i
+        return ''
+
     def run(self, doc):
         """Update tab IDs."""
 
         # Get a list of id attributes
         used_ids = set()
+        parent_map = {}
+        header_map = {}
+
+        if self.combine_header_slug:
+            parent_map = dict((c, p) for p in doc.iter() for c in p)
+
         for el in doc.iter():
             if "id" in el.attrib:
+                if self.combine_header_slug and el.tag in HEADERS:
+                    parent = parent_map[el]
+                    if parent in header_map:
+                        header_map[parent].append(el)
+                    else:
+                        header_map[parent] = [el]
                 used_ids.add(el.attrib["id"])
 
         for el in doc.iter():
@@ -50,7 +85,14 @@ class TabbedTreeprocessor(Treeprocessor):
                     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)
+                        if self.combine_header_slug:
+                            parent_slug = self.get_parent_header_slug(doc, header_map, parent_map, el)
+                        else:
+                            parent_slug = ''
+                        slug = self.slugify(innertext, self.sep)
+                        if parent_slug:
+                            slug = parent_slug + self.sep + slug
+                        slug = toc.unique(slug, used_ids)
                         inpt.attrib["id"] = slug
                         label.attrib["for"] = slug
 
@@ -245,6 +287,7 @@ class TabExtension(BlocksExtension):
         self.config = {
             'alternate_style': [False, "Use alternate style - Default: False"],
             'slugify': [0, "Slugify function used to create tab specific IDs - Default: None"],
+            'combine_header_slug': [False, "Combine the tab slug with the slug of the parent header - Default: False"],
             'separator': ['-', "Slug separator - Default: '-'"]
         }
 
diff --git a/pymdownx/tabbed.py b/pymdownx/tabbed.py
index 81bfd9b2..4bc1b261 100644
--- a/pymdownx/tabbed.py
+++ b/pymdownx/tabbed.py
@@ -28,6 +28,8 @@ from markdown.extensions import toc
 import xml.etree.ElementTree as etree
 import re
 
+HEADERS = {'h1', 'h2', 'h3', 'h4', 'h5', 'h6'}
+
 
 class TabbedProcessor(BlockProcessor):
     """Tabbed block processor."""
@@ -307,14 +309,47 @@ class TabbedTreeprocessor(Treeprocessor):
         self.slugify = config["slugify"]
         self.alternate = config["alternate_style"]
         self.sep = config["separator"]
+        self.combine_header_slug = config["combine_header_slug"]
+
+    def get_parent_header_slug(self, root, header_map, parent_map, el):
+        """Attempt retrieval of parent header slug."""
+
+        parent = el
+        last_parent = parent
+        while parent is not root:
+            last_parent = parent
+            parent = parent_map[parent]
+            if parent in header_map:
+                headers = header_map[parent]
+                header = None
+                for i in list(parent):
+                    if i is el and header is None:
+                        break
+                    if i is last_parent:
+                        return header.attrib.get("id", '')
+                    if i in headers:
+                        header = i
+        return ''
 
     def run(self, doc):
         """Update tab IDs."""
 
         # Get a list of id attributes
         used_ids = set()
+        parent_map = {}
+        header_map = {}
+
+        if self.combine_header_slug:
+            parent_map = dict((c, p) for p in doc.iter() for c in p)
+
         for el in doc.iter():
             if "id" in el.attrib:
+                if self.combine_header_slug and el.tag in HEADERS:
+                    parent = parent_map[el]
+                    if parent in header_map:
+                        header_map[parent].append(el)
+                    else:
+                        header_map[parent] = [el]
                 used_ids.add(el.attrib["id"])
 
         for el in doc.iter():
@@ -340,7 +375,14 @@ class TabbedTreeprocessor(Treeprocessor):
                     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)
+                        if self.combine_header_slug:
+                            parent_slug = self.get_parent_header_slug(doc, header_map, parent_map, el)
+                        else:
+                            parent_slug = ''
+                        slug = self.slugify(innertext, self.sep)
+                        if parent_slug:
+                            slug = parent_slug + self.sep + slug
+                        slug = toc.unique(slug, used_ids)
                         inpt.attrib["id"] = slug
                         label.attrib["for"] = slug
 
@@ -354,6 +396,7 @@ class TabbedExtension(Extension):
         self.config = {
             'alternate_style': [False, "Use alternate style - Default: False"],
             'slugify': [0, "Slugify function used to create tab specific IDs - Default: None"],
+            'combine_header_slug': [False, "Combine the tab slug with the slug of the parent header - Default: False"],
             'separator': ['-', "Slug separator - Default: '-'"]
         }
 


Test patch:
diff --git a/tests/test_extensions/test_blocks/test_tab.py b/tests/test_extensions/test_blocks/test_tab.py
index f9b1e6ee..5f10ae19 100644
--- a/tests/test_extensions/test_blocks/test_tab.py
+++ b/tests/test_extensions/test_blocks/test_tab.py
@@ -91,6 +91,114 @@ class TestTabSlugsSep(util.MdCase):
         )
 
 
+class TestTabSlugsCombineHeader(util.MdCase):
+    """Combine header slug with content tab."""
+
+    extension = ['pymdownx.blocks.tab', 'toc', 'pymdownx.blocks.details']
+    extension_configs = {
+        'pymdownx.blocks.tab': {
+            'slugify': slugify(case='lower'),
+            'combine_header_slug': True,
+            'alternate_style': True
+        }
+    }
+
+    def test_combine_header_slug(self):
+        """Test that slugs are a combination of the header slug and the tab title."""
+
+        md = R"""
+        ### Here is some text
+
+        /// tab | First Tab
+        content
+        ///
+
+
+        ### Another header
+
+        /// details | title
+        //// tab | Second Tab
+        content
+        ////
+        ///
+        """
+
+        self.check_markdown(
+            md,
+            '''
+            <h3 id="here-is-some-text">Here is some text</h3>
+            <div class="tabbed-set tabbed-alternate" data-tabs="1:1"><input checked="checked" id="here-is-some-text-first-tab" name="__tabbed_1" type="radio" /><div class="tabbed-labels"><label for="here-is-some-text-first-tab">First Tab</label></div>
+            <div class="tabbed-content">
+            <div class="tabbed-block">
+            <p>content</p>
+            </div>
+            </div>
+            </div>
+            <h3 id="another-header">Another header</h3>
+            <details>
+            <summary>title</summary>
+            <div class="tabbed-set tabbed-alternate" data-tabs="2:1"><input checked="checked" id="another-header-second-tab" name="__tabbed_2" type="radio" /><div class="tabbed-labels"><label for="another-header-second-tab">Second Tab</label></div>
+            <div class="tabbed-content">
+            <div class="tabbed-block">
+            <p>content</p>
+            </div>
+            </div>
+            </div>
+            </details>
+            ''',  # noqa: E501
+            True
+        )
+
+    def test_no_header(self):
+        """Test when there is no header."""
+
+        md = R"""
+        /// tab | A Tab
+        content
+        ///
+        """
+
+        self.check_markdown(
+            md,
+            '''
+            <div class="tabbed-set tabbed-alternate" data-tabs="1:1"><input checked="checked" id="a-tab" name="__tabbed_1" type="radio" /><div class="tabbed-labels"><label for="a-tab">A Tab</label></div>
+            <div class="tabbed-content">
+            <div class="tabbed-block">
+            <p>content</p>
+            </div>
+            </div>
+            </div>
+            ''',  # noqa: E501
+            True
+        )
+
+    def test_header_after(self):
+        """Test when header comes after."""
+
+        md = R"""
+        /// tab | A Tab
+        content
+        ///
+
+        # Header
+        """
+
+        self.check_markdown(
+            md,
+            '''
+            <div class="tabbed-set tabbed-alternate" data-tabs="1:1"><input checked="checked" id="a-tab" name="__tabbed_1" type="radio" /><div class="tabbed-labels"><label for="a-tab">A Tab</label></div>
+            <div class="tabbed-content">
+            <div class="tabbed-block">
+            <p>content</p>
+            </div>
+            </div>
+            </div>
+            <h1 id="header">Header</h1>
+            ''',  # noqa: E501
+            True
+        )
+
+
 class TestBlocksTab(util.MdCase):
     """Test Blocks tab cases."""
 
diff --git a/tests/test_extensions/test_tabbed.py b/tests/test_extensions/test_tabbed.py
index 9b716cb3..ff30adad 100644
--- a/tests/test_extensions/test_tabbed.py
+++ b/tests/test_extensions/test_tabbed.py
@@ -577,3 +577,105 @@ class TestLegacyTabSlugsSep(util.MdCase):
             ''',  # noqa: E501
             True
         )
+
+
+class TestTabSlugsCombineHeader(util.MdCase):
+    """Combine header slug with content tab."""
+
+    extension = ['pymdownx.tabbed', 'toc', 'pymdownx.details']
+    extension_configs = {
+        'pymdownx.tabbed': {
+            'slugify': slugify(case='lower'),
+            'combine_header_slug': True,
+            'alternate_style': True
+        }
+    }
+
+    def test_combine_header_slug(self):
+        """Test that slugs are a combination of the header slug and the tab title."""
+
+        md = R"""
+        ### Here is some text
+
+        === "First Tab"
+            content
+
+        ### Another header
+
+        ??? "title"
+            === "Second Tab"
+                content
+        """
+
+        self.check_markdown(
+            md,
+            '''
+            <h3 id="here-is-some-text">Here is some text</h3>
+            <div class="tabbed-set tabbed-alternate" data-tabs="1:1"><input checked="checked" id="here-is-some-text-first-tab" name="__tabbed_1" type="radio" /><div class="tabbed-labels"><label for="here-is-some-text-first-tab">First Tab</label></div>
+            <div class="tabbed-content">
+            <div class="tabbed-block">
+            <p>content</p>
+            </div>
+            </div>
+            </div>
+            <h3 id="another-header">Another header</h3>
+            <details>
+            <summary>title</summary>
+            <div class="tabbed-set tabbed-alternate" data-tabs="2:1"><input checked="checked" id="another-header-second-tab" name="__tabbed_2" type="radio" /><div class="tabbed-labels"><label for="another-header-second-tab">Second Tab</label></div>
+            <div class="tabbed-content">
+            <div class="tabbed-block">
+            <p>content</p>
+            </div>
+            </div>
+            </div>
+            </details>
+            ''',  # noqa: E501
+            True
+        )
+
+    def test_no_header(self):
+        """Test when there is no header."""
+
+        md = R"""
+        === "A Tab"
+            content
+        """
+
+        self.check_markdown(
+            md,
+            '''
+            <div class="tabbed-set tabbed-alternate" data-tabs="1:1"><input checked="checked" id="a-tab" name="__tabbed_1" type="radio" /><div class="tabbed-labels"><label for="a-tab">A Tab</label></div>
+            <div class="tabbed-content">
+            <div class="tabbed-block">
+            <p>content</p>
+            </div>
+            </div>
+            </div>
+            ''',  # noqa: E501
+            True
+        )
+
+    def test_header_after(self):
+        """Test when header comes after."""
+
+        md = R"""
+        === "A Tab"
+            content
+
+        # Header
+        """
+
+        self.check_markdown(
+            md,
+            '''
+            <div class="tabbed-set tabbed-alternate" data-tabs="1:1"><input checked="checked" id="a-tab" name="__tabbed_1" type="radio" /><div class="tabbed-labels"><label for="a-tab">A Tab</label></div>
+            <div class="tabbed-content">
+            <div class="tabbed-block">
+            <p>content</p>
+            </div>
+            </div>
+            </div>
+            <h1 id="header">Header</h1>
+            ''',  # noqa: E501
+            True
+        )