Repository: manuzhang/mkdocs-htmlproofer-plugin

Issue:
Validation of root path local referenced image links fails
When referencing image links to a root path the validation fails (while mkdocs itself suceeds for the image).

Given this example: (Root referencing required for common language shared between multilingual folders)
```markdown
![test](/common/image1.png)
```

This will succeed validation for the image itself in mkdocs build, but when used together with the glightbox plugin which generates an link for the image to zoom in the validation of the link will fail with htmlproofer (404 error).

```html
<a href="/common/image1.png"><img ......></a>
```

HTML proofer should validate local root links against the site folder.

Discussion:

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..cf6090d 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()
 
@@ -160,8 +162,12 @@ class HtmlProoferPlugin(BasePlugin):
     def find_source_file(url: str, src_path: str, files: Dict[str, File]) -> Optional[File]:
         """From a built URL, find the original file from the project that built it."""
 
-        # Handle relative links by concatenating the source dir with the destination path
-        search_path = os.path.normpath(str(pathlib.Path(src_path).parent / pathlib.Path(url)))
+        if len(url) > 1 and url[0] == '/':
+            # Convert root/site paths
+            search_path = os.path.normpath(url[1:])
+        else:
+            # Handle relative links by concatenating the source dir with the destination path
+            search_path = os.path.normpath(str(pathlib.Path(src_path).parent / pathlib.Path(url)))
 
         try:
             return files[search_path]
@@ -179,10 +185,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 +212,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/integration/docs/index.md b/tests/integration/docs/index.md
index 70b1f6f..4d302a6 100644
--- a/tests/integration/docs/index.md
+++ b/tests/integration/docs/index.md
@@ -74,4 +74,10 @@ This work is based on the [mkdocs-markdownextradata-plugin](https://github.com/r
 ## GitHub Action
 
 ![GitHub Action](https://github.com/manuzhang/mkdocs-htmlproofer-plugin/actions/workflows/ci.yml/badge.svg){ #github-action }
-[GitHub Action](#github-action)
\ No newline at end of file
+[GitHub Action](#github-action)
+
+## Image Link absolute/relative
+
+<a href="assets/hello-world.drawio.svg">![test](assets/hello-world.drawio.svg)</a>
+
+<a href="/assets/hello-world.drawio.svg">![test](/assets/hello-world.drawio.svg)</a>
diff --git a/tests/integration/docs/nested/page1.md b/tests/integration/docs/nested/page1.md
index ba26388..c96b242 100644
--- a/tests/integration/docs/nested/page1.md
+++ b/tests/integration/docs/nested/page1.md
@@ -23,3 +23,9 @@ or to a nested page
 But allows valid anchors such as
 [Main Page](../index.md#mkdocs-htmlproofer-plugin) and
 [Table of Contents](../index.md#table-of-contents).
+
+## Image Link absolute/relative
+
+<a href="../assets/hello-world.drawio.svg">![test](../assets/hello-world.drawio.svg)</a>
+
+<a href="/assets/hello-world.drawio.svg">![test](/assets/hello-world.drawio.svg)</a>
diff --git a/tests/integration/mkdocs.yml b/tests/integration/mkdocs.yml
index bb7a878..f79ce65 100644
--- a/tests/integration/mkdocs.yml
+++ b/tests/integration/mkdocs.yml
@@ -6,7 +6,7 @@ markdown_extensions:
   - attr_list
 plugins:
     - htmlproofer:
-        raise_error: True
+        raise_error_after_finish: True
         raise_error_excludes:
           504: ['*']
           404: ['https://www.mkdocs.org/user-guide/plugin',
diff --git a/tests/unit/test_plugin.py b/tests/unit/test_plugin.py
index 39aacc3..7dd53ba 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
@@ -189,6 +200,7 @@ def test_get_url_status__non_markdown_page(plugin):
     ])}
 
     assert plugin.get_url_status('drawing.svg', 'index.md', set(), files, False) == 0
+    assert plugin.get_url_status('/drawing.svg', 'index.md', set(), files, False) == 0
     assert plugin.get_url_status('not-existing.svg', 'index.md', set(), files, False) == 404
 
 
@@ -214,3 +226,5 @@ def test_get_url_status__local_page_nested(plugin):
 
     assert plugin.get_url_status('foo/bar/nested.html#nested-one', 'index.md', set(), files, False) == 0
     assert plugin.get_url_status('foo/baz/nested.html#nested-two', 'index.md', set(), files, False) == 0
+
+    assert plugin.get_url_status('/index.html', 'foo/baz/sibling.md', set(), files, False) == 0