Repository: manuzhang/mkdocs-htmlproofer-plugin

Issue:
Anchors in attr_list contains other attribute value are reported as invalid urls
**Describe the bug**

For example, the following header is reported as invalid url.

```md
## Header {: #header-anchor .class-name }
```
anchor_slug: header-anchor-class-name

https://github.com/manuzhang/mkdocs-htmlproofer-plugin/blob/main/htmlproofer/plugin.py#L154
```python
@staticmethod
def contains_anchor(markdown: str, anchor: str) -> bool:
    """Check if a set of Markdown source text contains a heading that corresponds to a
    given anchor."""
    for line in markdown.splitlines():
        # Markdown allows whitespace before headers and an arbitrary number of #'s.
        heading_match = HEADING_PATTERN.match(line)
        if heading_match is not None:
            heading = heading_match.groups()[0]

            # Headings are allowed to have images after them, of the form:
            # # Heading [![Image](image-link)] or ![Image][image-reference]
            # But these images are not included in the generated anchor, so remove them.
            heading = re.sub(IMAGE_PATTERN, '', heading)
            anchor_slug = slugify(heading, '-')
            if anchor == anchor_slug:
                return True

        link_match = HTML_LINK_PATTERN.match(line)
        if link_match is not None and link_match.group(1) == anchor:
            return True

    return False
```

mkdocs.yml
```yaml
markdown_extensions:
  - attr_list

plugins:
  - htmlproofer
```

Would it be possible to support attr_list extensions format?


Discussion:
manuzhang: thanks @hendrikp I'm also exploring checking anchors in output html files. If that works, I will submit a PR and let's see which approach is better.
hendrikp: > thanks @hendrikp I'm also exploring checking anchors in output html files. If that works, I will submit a PR and let's see which approach is better.

Yes maybe, i thought about it a bit and im not sure all the html will be available at that point.

Is there a way to guarantee that the html is validated only after all other plugins (e.g. https://github.com/flywire/caption have modified the html) and all pages have been generated. (Because when you'd check before that it would fail, as dependent pages might be missing still or unmodified). Also the custom caption would require i think also checking `table` and `div` tag ids (ill check and get back to you). Basically you'd have to have a option in the html proofer plugin which html tags should have their ids checked if not checking all by default.

With markdown at least one doesn't have that issue, as order as well as which html tags is not important.

Patch:
diff --git a/README.md b/README.md
index 2c91a82..57582ed 100644
--- a/README.md
+++ b/README.md
@@ -113,6 +113,19 @@ plugins:
       validate_rendered_template: True
 ```
 
+## Compatibility with `attr_list` extension
+
+If you need to manually specify anchors make use of the `attr_list` [extension](https://python-markdown.github.io/extensions/attr_list) in the markdown. 
+This can be useful for multilingual documentation to keep anchors as language neutral permalinks in all languages.
+
+* A sample for a heading `# Grüße {#greetings}` (the slugified generated anchor `Gre` is overwritten with `greetings`).
+* This also works for images `this is a nice image [](foo-bar.png){#nice-image}` 
+* And generall for paragraphs:
+```markdown
+Listing: This is noteworthy.
+{#paragraphanchor}
+```
+
 ## Improving
 
 More information about plugins in the [MkDocs documentation](http://www.mkdocs.org/user-guide/plugins/)
diff --git a/htmlproofer/plugin.py b/htmlproofer/plugin.py
index 87ae85c..38c3f55 100644
--- a/htmlproofer/plugin.py
+++ b/htmlproofer/plugin.py
@@ -29,6 +29,8 @@ LOCAL_PATTERNS = [
     re.compile(rf'https?://{local}')
     for local in ('localhost', '127.0.0.1', 'app_server')
 ]
+ATTRLIST_ANCHOR_PATTERN = re.compile(r'\{.*?\#([^\s\}]*).*?\}')
+ATTRLIST_PATTERN = re.compile(r'\{.*?\}')
 
 urllib3.disable_warnings()
 
@@ -179,10 +181,25 @@ class HtmlProoferPlugin(BasePlugin):
             if heading_match is not None:
                 heading = heading_match.groups()[0]
 
+                # Headings are allowed to have attr_list after them, of the form:
+                # # Heading { #testanchor .testclass }
+                # # Heading {: #testanchor .testclass }
+                # # Heading {.testclass #testanchor}
+                # # Heading {.testclass}
+                # these can override the headings anchor id, or alternativly just provide additional class etc.
+                attr_list_anchor_match = ATTRLIST_ANCHOR_PATTERN.match(heading)
+                if attr_list_anchor_match is not None:
+                    attr_list_anchor = heading_match.groups()[1]
+                    if anchor == attr_list_anchor:
+                        return True
+
+                heading = re.sub(ATTRLIST_PATTERN, '', heading) # remove any attribute list from heading, before slugify
+
                 # Headings are allowed to have images after them, of the form:
                 # # Heading [![Image](image-link)] or ![Image][image-reference]
                 # But these images are not included in the generated anchor, so remove them.
                 heading = re.sub(IMAGE_PATTERN, '', heading)
+
                 anchor_slug = slugify(heading, '-')
                 if anchor == anchor_slug:
                     return True
@@ -191,6 +208,12 @@ class HtmlProoferPlugin(BasePlugin):
             if link_match is not None and link_match.group(1) == anchor:
                 return True
 
+            # Any attribute list at end of paragraphs or after images can also generate an anchor (in addition to the heading ones)
+            # so gather those and check as well (multiple could be a line so gather all)
+            for attr_list_anchor in re.findall(ATTRLIST_ANCHOR_PATTERN, line):
+                if anchor == attr_list_anchor:
+                    return True
+
         return False
 
     @staticmethod


Test patch:
diff --git a/tests/unit/test_plugin.py b/tests/unit/test_plugin.py
index 39aacc3..23e3ac0 100644
--- a/tests/unit/test_plugin.py
+++ b/tests/unit/test_plugin.py
@@ -133,12 +133,23 @@ def test_get_url_status(validate_external: bool):
         ('## git add [$changed-files]', 'git-add-changed-files', True),
         ('''## Delete ![][delete_icon]
 [delete_icon]: ./delete.svg''', 'delete', True),
+        # attr_list extension tests
+        (r'## Heading {.customclass}', 'heading', True),
+        (r'## Heading {#customanchor}', 'customanchor', True),
+        (r'## Heading {: #customanchor}', 'customanchor', True),
+        (r'## Heading {.customclass #customanchor}', 'customanchor', True),
+        (r'## refer to this ![image](image-link){#imageanchorheading}', 'imageanchorheading', True),
+        (r'## refer to this ![image](image-link){.customclass}', 'refer-to-this-imageimage-link', True), # test faulty image in heading syntax
+        (r'## refer to this [![image](image-link){#imageanchorheading}]', 'imageanchorheading', True),
+        (r'see image ![image](image-link){#imageanchor1} see image 2 ![image](image-link){#imageanchor2}', 'imageanchor1', True),
+        (r'see image ![image](image-link){#imageanchor1} see image 2 ![image](image-link){#imageanchor2}', 'imageanchor2', True),
+        (r'paragraph text\n{#paragraphanchor}', 'paragraphanchor', True),
+        (r'paragraph text\n{#paragraphanchor test', 'paragraphanchor', False),
     ]
 )
 def test_contains_anchor(plugin, markdown, anchor, expected):
     assert plugin.contains_anchor(markdown, anchor) == expected
 
-
 def test_get_url_status__same_page_anchor(plugin, empty_files):
     assert plugin.get_url_status('#ref', 'src/path.md', {'ref'}, empty_files, False) == 0
     assert plugin.get_url_status('##ref', 'src/path.md', {'ref'}, empty_files, False) == 404