Repository: mkdocs/mkdocs

Issue:
Hooks scripts can't really use imports
It would be nice if the files that are used as hooks would have the ability to import files relative to them or maybe relative to the root. But currently the only way to achieve this is with this workaround first:

```python
import sys, os
sys.path.insert(0, os.path.dirname(__file__))

import something_from_the_directory
```

Discussion:

Patch:
diff --git a/.csslintrc b/.csslintrc
deleted file mode 100644
index 8877d0ea..00000000
--- a/.csslintrc
+++ /dev/null
@@ -1,42 +0,0 @@
---exclude-list = mkdocs/themes/mkdocs/css/bootstrap.min.css,
-                 mkdocs/themes/mkdocs/css/font-awesome.min.css,
-                 mkdocs/themes/mkdocs/css/highlight.css,
-                 mkdocs/themes/readthedocs/css/theme.css
---errors = known-properties,
-           box-sizing,
-           outline-none,
-           bulletproof-font-face,
-           compatible-vendor-prefixes,
-           errors,
-           duplicate-background-images,
-           duplicate-properties,
-           empty-rules,
-           selector-max-approaching,
-           gradients,
-           floats,
-           font-faces,
-           font-sizes,
-           shorthand,
-           import,
-           import-ie-limit,
-           text-indent,
-           rules-count,
-           regex-selectors,
-           selector-max,
-           selector-newline,
-           star-property-hack,
-           underscore-property-hack,
-           universal-selector,
-           unqualified-attributes,
-           vendor-prefix,
-           zero-units,
-           overqualified-elements,
-           unique-headings,
-           qualified-headings,
-           ids,
-           display-property-grouping,
-           fallback-colors,
-           box-model,
-           important,
-           adjoining-classes
---ignore = order-alphabetical
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 38526769..bf93e0d2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -79,9 +79,6 @@ jobs:
       - name: Check JS style
         if: always()
         run: hatch run lint:js
-      - name: Check CSS style
-        if: always()
-        run: hatch run lint:css
       - name: Check spelling
         if: always()
         run: hatch run lint:spelling
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index d64bf5c9..510ef6f6 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -97,7 +97,7 @@ hatch run types:check
 
 ### Other style checks
 
-There are several other checks, such as spelling and CSS style. To run all of them, use this command:
+There are several other checks, such as spelling and JS style. To run all of them, use this command:
 
 ```bash
 hatch run lint:check
diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md
index 607bcf50..6a7bb581 100644
--- a/docs/user-guide/configuration.md
+++ b/docs/user-guide/configuration.md
@@ -804,7 +804,7 @@ hooks:
   - my_hooks.py
 ```
 
-Then the file *my_hooks.py* can contain any [plugin event handlers](../dev-guide/plugins.md#events) (without `self`), e.g.:
+Then the file `my_hooks.py` can contain any [plugin event handlers](../dev-guide/plugins.md#events) (without `self`), e.g.:
 
 ```python
 def on_page_markdown(markdown, **kwargs):
@@ -843,6 +843,12 @@ You might have seen this feature in the [mkdocs-simple-hooks plugin](https://git
 +  - my_hooks.py
 ```
 
+> NEW: **New in MkDocs 1.6.**
+>
+> If a hook file has a file `foo.py` adjacent to it, it can use the normal Python syntax `import foo` to access its functions.
+>
+> In older versions of MkDocs, a workaround was necessary to make this work - adding the path to `sys.path`.
+
 ### plugins
 
 A list of plugins (with optional configuration settings) to use when building
diff --git a/docs/user-guide/customizing-your-theme.md b/docs/user-guide/customizing-your-theme.md
index 8d9155e6..0b3357f0 100644
--- a/docs/user-guide/customizing-your-theme.md
+++ b/docs/user-guide/customizing-your-theme.md
@@ -66,7 +66,7 @@ Developer Guide][custom theme] for more information.
 For example, the [mkdocs] theme ([browse source]), contains the following
 directory structure (in part):
 
-```nohighlight
+```text
 - css\
 - fonts\
 - img\
@@ -108,7 +108,7 @@ directory.
 
 Your directory structure should now look like this:
 
-```nohighlight
+```text
 - docs/
   - index.html
 - custom_theme/
@@ -181,7 +181,7 @@ be added to the library from the HTML.
 
 Starting the with directory structure above (truncated):
 
-```nohighlight
+```text
 - docs/
 - custom_theme/
   - js/
diff --git a/mkdocs/config/config_options.py b/mkdocs/config/config_options.py
index 3025045f..b5c19d81 100644
--- a/mkdocs/config/config_options.py
+++ b/mkdocs/config/config_options.py
@@ -1208,7 +1208,14 @@ class Hooks(BaseConfigOption[List[types.ModuleType]]):
         sys.modules[name] = module
         if spec.loader is None:
             raise ValidationError(f"Cannot import path '{path}' as a Python module")
-        spec.loader.exec_module(module)
+
+        old_sys_path = sys.path.copy()
+        sys.path.insert(0, os.path.dirname(path))
+        try:
+            spec.loader.exec_module(module)
+        finally:
+            sys.path[:] = old_sys_path
+
         return module
 
     def post_validation(self, config: Config, key_name: str):
diff --git a/mkdocs/structure/pages.py b/mkdocs/structure/pages.py
index 8b0a642d..5b4a849b 100644
--- a/mkdocs/structure/pages.py
+++ b/mkdocs/structure/pages.py
@@ -1,6 +1,5 @@
 from __future__ import annotations
 
-import copy
 import enum
 import logging
 import posixpath
@@ -20,6 +19,7 @@ from mkdocs import utils
 from mkdocs.structure import StructureItem
 from mkdocs.structure.toc import get_toc
 from mkdocs.utils import _removesuffix, get_build_date, get_markdown_title, meta, weak_property
+from mkdocs.utils.rendering import get_heading_text
 
 if TYPE_CHECKING:
     from xml.etree import ElementTree as etree
@@ -555,23 +555,13 @@ class _ExtractTitleTreeprocessor(markdown.treeprocessors.Treeprocessor):
     def run(self, root: etree.Element) -> etree.Element:
         for el in root:
             if el.tag == 'h1':
-                # Drop anchorlink from the element, if present.
-                if len(el) > 0 and el[-1].tag == 'a' and not (el[-1].tail or '').strip():
-                    el = copy.copy(el)
-                    del el[-1]
-                # Extract the text only, recursively.
-                title = ''.join(el.itertext())
-                # Unescape per Markdown implementation details.
-                title = markdown.extensions.toc.stashedHTML2text(
-                    title, self.md, strip_entities=False
-                )
-                self.title = title.strip()
+                self.title = get_heading_text(el, self.md)
             break
         return root
 
     def _register(self, md: markdown.Markdown) -> None:
         self.md = md
-        md.treeprocessors.register(self, "mkdocs_extract_title", priority=-1)  # After the end.
+        md.treeprocessors.register(self, "mkdocs_extract_title", priority=1)  # Close to the end.
 
 
 class _AbsoluteLinksValidationValue(enum.IntEnum):
diff --git a/mkdocs/themes/mkdocs/css/base.css b/mkdocs/themes/mkdocs/css/base.css
index a396ae53..9748be2b 100644
--- a/mkdocs/themes/mkdocs/css/base.css
+++ b/mkdocs/themes/mkdocs/css/base.css
@@ -1,9 +1,7 @@
 html {
-    /* csslint ignore:start */
     /* The nav header is 3.5rem high, plus 20px for the margin-top of the
        main container. */
     scroll-padding-top: calc(3.5rem + 20px);
-    /* csslint ignore:end */
 }
 
 /* Replacement for `body { background-attachment: fixed; }`, which has
@@ -26,11 +24,9 @@ body > .container {
     min-height: 400px;
 }
 
-.navbar.fixed-top { /* csslint allow: adjoining-classes */
-    /* csslint ignore:start */
+.navbar.fixed-top {
     position: -webkit-sticky;
     position: sticky;
-    /* csslint ignore:end */
 }
 
 .source-links {
@@ -130,17 +126,15 @@ footer {
  * sections of docs content.
  */
 
-.bs-sidebar.affix { /* csslint allow: adjoining-classes */
-    /* csslint ignore:start */
+.bs-sidebar.affix {
     position: -webkit-sticky;
     position: sticky;
-    /* csslint ignore:end */
     /* The nav header is 3.5rem high, plus 20px for the margin-top of the
        main container. */
     top: calc(3.5rem + 20px);
 }
 
-.bs-sidebar.card { /* csslint allow: adjoining-classes */
+.bs-sidebar.card {
     padding: 0;
     max-height: 90%;
     overflow-y: auto;
@@ -155,7 +149,7 @@ footer {
     transform: scale(1, -1);
 }
 
-.bs-sidebar .navbar-toggler.collapsed span { /* csslint allow: adjoining-classes */
+.bs-sidebar .navbar-toggler.collapsed span {
     -moz-transform: scale(1, 1);
     -webkit-transform: scale(1, 1);
     -o-transform: scale(1, 1);
@@ -226,19 +220,19 @@ blockquote {
     text-align: left;
 }
 
-.admonition.note, details.note, .admonition.info, details.info { /* csslint allow: adjoining-classes */
+.admonition.note, details.note, .admonition.info, details.info {
     color: #2e6b89;
     background-color: #e2f0f7;
     border-color: #bce8f1;
 }
 
-.admonition.warning, details.warning { /* csslint allow: adjoining-classes */
+.admonition.warning, details.warning {
     color: #7a6032;
     background-color: #fffae5;
     border-color: #fbeed5;
 }
 
-.admonition.danger, details.danger { /* csslint allow: adjoining-classes */
+.admonition.danger, details.danger {
     color: #7f3130;
     background-color: #fde3e3;
     border-color: #eed3d7;
@@ -254,13 +248,13 @@ blockquote {
 }
 
 @media (max-width: 991.98px) {
-    .navbar-collapse.show { /* csslint allow: adjoining-classes */
+    .navbar-collapse.show {
         overflow-y: auto;
         max-height: calc(100vh - 3.5rem);
     }
 }
 
-.dropdown-item.open { /* csslint allow: adjoining-classes */
+.dropdown-item.open {
     color: #fff;
     background-color: #2FA4E7;
 }
@@ -300,20 +294,18 @@ blockquote {
     }
 
     .dropdown-submenu > .dropdown-menu {
-        /* csslint ignore:start */
         position: fixed !important;
-        /* csslint ignore:end */
         margin-top: -9px;
         margin-left: -2px;
         border-width: 1px;
         padding: 0.5rem 0;
     }
 
-    .dropdown-submenu.pull-left { /* csslint allow: adjoining-classes */
+    .dropdown-submenu.pull-left {
         float: none;
     }
 
-    .dropdown-submenu.pull-left > .dropdown-menu { /* csslint allow: adjoining-classes */
+    .dropdown-submenu.pull-left > .dropdown-menu {
         left: -100%;
         margin-left: 10px;
     }
diff --git a/mkdocs/themes/readthedocs/css/theme_extra.css b/mkdocs/themes/readthedocs/css/theme_extra.css
index 494e7a8d..ab0631a1 100644
--- a/mkdocs/themes/readthedocs/css/theme_extra.css
+++ b/mkdocs/themes/readthedocs/css/theme_extra.css
@@ -103,7 +103,7 @@ pre .cs, pre .c {
 form .search-query {
     width: 100%;
     border-radius: 50px;
-    padding: 6px 12px;  /* csslint allow: box-model */
+    padding: 6px 12px;
     border-color: #D1D4D5;
 }
 
@@ -135,7 +135,7 @@ form .search-query {
 }
 
 td, th {
-    border: 1px solid #e1e4e5 !important; /* csslint allow: important */
+    border: 1px solid #e1e4e5 !important;
     border-collapse: collapse;
 }
 
diff --git a/mkdocs/utils/rendering.py b/mkdocs/utils/rendering.py
new file mode 100644
index 00000000..545e1efb
--- /dev/null
+++ b/mkdocs/utils/rendering.py
@@ -0,0 +1,104 @@
+from __future__ import annotations
+
+import copy
+from typing import TYPE_CHECKING, Callable
+
+import markdown
+import markdown.treeprocessors
+
+if TYPE_CHECKING:
+    from xml.etree import ElementTree as etree
+
+# TODO: This will become unnecessary after min-versions have Markdown >=3.4
+_unescape: Callable[[str], str]
+try:
+    _unescape = markdown.treeprocessors.UnescapeTreeprocessor().unescape
+except AttributeError:
+    _unescape = lambda s: s
+
+# TODO: Most of this file will become unnecessary after https://github.com/Python-Markdown/markdown/pull/1441
+
+
+def get_heading_text(el: etree.Element, md: markdown.Markdown) -> str:
+    el = copy.deepcopy(el)
+    _remove_anchorlink(el)
+    _remove_fnrefs(el)
+    _extract_alt_texts(el)
+    return _strip_tags(_render_inner_html(el, md))
+
+
+def _strip_tags(text: str) -> str:
+    """Strip HTML tags and return plain text. Note: HTML entities are unaffected."""
+    # A comment could contain a tag, so strip comments first
+    while (start := text.find('<!--')) != -1 and (end := text.find('-->', start)) != -1:
+        text = text[:start] + text[end + 3 :]
+
+    while (start := text.find('<')) != -1 and (end := text.find('>', start)) != -1:
+        text = text[:start] + text[end + 1 :]
+
+    # Collapse whitespace
+    text = ' '.join(text.split())
+    return text
+
+
+def _render_inner_html(el: etree.Element, md: markdown.Markdown) -> str:
+    # The `UnescapeTreeprocessor` runs after `toc` extension so run here.
+    text = md.serializer(el)
+    text = _unescape(text)
+
+    # Strip parent tag
+    start = text.index('>') + 1
+    end = text.rindex('<')
+    text = text[start:end].strip()
+
+    for pp in md.postprocessors:
+        text = pp.run(text)
+    return text
+
+
+def _remove_anchorlink(el: etree.Element) -> None:
+    """Drop anchorlink from the element, if present."""
+    if len(el) > 0 and el[-1].tag == 'a' and el[-1].get('class') == 'headerlink':
+        del el[-1]
+
+
+def _remove_fnrefs(root: etree.Element) -> None:
+    """Remove footnote references from the element, if any are present."""
+    for parent in root.findall('.//sup[@id]/..'):
+        _replace_elements_with_text(parent, _predicate_for_fnrefs)
+
+
+def _predicate_for_fnrefs(el: etree.Element) -> str | None:
+    if el.tag == 'sup' and el.get('id', '').startswith('fnref'):
+        return ''
+    return None
+
+
+def _extract_alt_texts(root: etree.Element) -> None:
+    """For images that have an `alt` attribute, replace them with this content."""
+    for parent in root.findall('.//img[@alt]/..'):
+        _replace_elements_with_text(parent, _predicate_for_alt_texts)
+
+
+def _predicate_for_alt_texts(el: etree.Element) -> str | None:
+    if el.tag == 'img' and (alt := el.get('alt')):
+        return alt
+    return None
+
+
+def _replace_elements_with_text(
+    parent: etree.Element, predicate: Callable[[etree.Element], str | None]
+) -> None:
+    """For each child element, if matched, replace it with the text returned from the predicate."""
+    carry_text = ""
+    for child in reversed(parent):  # Reversed for the ability to mutate during iteration.
+        # Remove matching elements but carry any `tail` text to preceding elements.
+        new_text = predicate(child)
+        if new_text is not None:
+            carry_text = new_text + (child.tail or "") + carry_text
+            parent.remove(child)
+        elif carry_text:
+            child.tail = (child.tail or "") + carry_text
+            carry_text = ""
+    if carry_text:
+        parent.text = (parent.text or "") + carry_text
diff --git a/pyproject.toml b/pyproject.toml
index 71bc189e..03067692 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -36,7 +36,7 @@ dependencies = [
     "click >=7.0",
     "Jinja2 >=2.11.1",
     "markupsafe >=2.0.1",
-    "Markdown >=3.4.1",
+    "Markdown >=3.3.6",
     "PyYAML >=5.1",
     "watchdog >=2.0",
     "ghp-import >=1.0",
@@ -57,7 +57,7 @@ min-versions = [
     "click ==7.0",
     "Jinja2 ==2.11.1",
     "markupsafe ==2.0.1",
-    "Markdown ==3.4.1",
+    "Markdown ==3.3.6",
     "PyYAML ==5.1",
     "watchdog ==2.0",
     "ghp-import ==1.0",
@@ -200,7 +200,6 @@ dependencies = [
 spelling = 'codespell mkdocs docs *.* -S LC_MESSAGES -S "*.min.js" -S "lunr*.js" -S fontawesome-webfont.svg -S tinyseg.js'
 markdown = "npm exec --yes -- markdownlint-cli README.md CONTRIBUTING.md docs/ --ignore docs/CNAME"
 js = "npm exec --yes -- jshint mkdocs/"
-css = "npm exec --yes -- csslint --quiet mkdocs/"
 check = ["markdown", "js", "css", "spelling"]
 
 [tool.hatch.env.collectors.mkdocs.docs]


Test patch:
diff --git a/mkdocs/tests/structure/page_tests.py b/mkdocs/tests/structure/page_tests.py
index 77d8542b..14bb85da 100644
--- a/mkdocs/tests/structure/page_tests.py
+++ b/mkdocs/tests/structure/page_tests.py
@@ -342,6 +342,12 @@ class PageTests(unittest.TestCase):
             expected='Welcome to MkDocs Setext',
         )
 
+    def test_page_title_from_markdown_with_email(self):
+        self._test_extract_title(
+            '''# <foo@example.org>''',
+            expected='&#102;&#111;&#111;&#64;&#101;&#120;&#97;&#109;&#112;&#108;&#101;&#46;&#111;&#114;&#103;',
+        )
+
     def test_page_title_from_markdown_stripped_anchorlinks(self):
         self._test_extract_title(
             self._SETEXT_CONTENT,
@@ -349,6 +355,24 @@ class PageTests(unittest.TestCase):
             expected='Welcome to MkDocs Setext',
         )
 
+    def test_page_title_from_markdown_strip_footnoteref(self):
+        foootnotes = '''\n\n[^1]: foo\n[^2]: bar'''
+        self._test_extract_title(
+            '''# Header[^1] foo[^2] bar''' + foootnotes,
+            extensions={'footnotes': {}},
+            expected='Header foo bar',
+        )
+        self._test_extract_title(
+            '''# *Header[^1]* *foo*[^2]''' + foootnotes,
+            extensions={'footnotes': {}},
+            expected='Header foo',
+        )
+        self._test_extract_title(
+            '''# *Header[^1][^2]s''' + foootnotes,
+            extensions={'footnotes': {}},
+            expected='*Headers',
+        )
+
     def test_page_title_from_markdown_strip_formatting(self):
         self._test_extract_title(
             '''# \\*Hello --- *beautiful* `wor<dl>`''',
@@ -356,17 +380,19 @@ class PageTests(unittest.TestCase):
             expected='*Hello &mdash; beautiful wor&lt;dl&gt;',
         )
 
+    def test_page_title_from_markdown_html_entity(self):
+        self._test_extract_title('''# Foo &lt; &amp; bar''', expected='Foo &lt; &amp; bar')
+        self._test_extract_title('''# Foo > & bar''', expected='Foo &gt; &amp; bar')
+
     def test_page_title_from_markdown_strip_raw_html(self):
-        self._test_extract_title(
-            '''# Hello <b>world</b>''',
-            expected='Hello world',
-        )
+        self._test_extract_title('''# Hello <b>world</b>''', expected='Hello world')
+
+    def test_page_title_from_markdown_strip_comments(self):
+        self._test_extract_title('''# foo <!-- comment with <em> --> bar''', expected='foo bar')
 
     def test_page_title_from_markdown_strip_image(self):
-        self._test_extract_title(
-            '''# Hi ![😄](hah.png)''',
-            expected='Hi',  # TODO: Should the alt text of the image be extracted?
-        )
+        self._test_extract_title('''# Hi ![😄](hah.png)''', expected='Hi 😄')
+        self._test_extract_title('''# Hi *-![😄](hah.png)-*''', expected='Hi -😄-')
 
     _ATTRLIST_CONTENT = dedent(
         '''