Repository: mkdocs/mkdocs

Issue:
Support for relative base_path in markdown_extensions
Many extensions need to know the base path of the currently rendered Markdown file, as they are supposed to include assets relative to the markdown file. Two of them are:

- [markdown-include](https://github.com/cmacmackin/markdown-include)
- [pymdownx.snippets](https://facelessuser.github.io/pymdown-extensions/extensions/snippets/)

Both make use of the config variable `base_path` for including other files, so in order to configure it, you would specify:

```yaml
markdown_extensions:
  - pymdownx.snippets:
      base_path: docs
  - markdown_include.include:
      base_path: docs
```

# Currently

Please take a look at this [example repository](https://github.com/dersimn/mkdocs-snippets-issue).

```
|-- README.md
|-- docs
|   |-- big_topic
|   |   |-- index.md
|   |   `-- more_important_code.c
|   |-- index.md
|   `-- smaller_topic
|       |-- hello_world.c
|       `-- index.md
`-- mkdocs.yml
```

Currently you would have to include the `*.c` files with their full path, so in both files you would have to write:

```
    --8<-- "smaller_topic/hello_world.c"
```

```
    --8<-- "big_topic/more_important_code.c"
```

# Why is this bad?

This is fine for Markdown files that are created and maintained for just one website, but we are currently including generic sub topics into our site that are shared among many repositories via `git submodule`. For e.g. some introduction topic on how to set up Docker. It's obvious that include paths must be relative to the Markdown file in this case, like:

```
    --8<-- "hello_world.c"
```

```
    --8<-- "more_important_code.c"
```

# Desirable solution

If we want to be able to specify relative paths in every Markdown file, the `base_path` variable need to change with every Markdown file to be rendered, so it can't simply be included in `mkdocs.yml`.  
This makes my feature request different from #998 #777 #761, because I'm not able to alter the `.md` files as they are shared over multiple projects.

It would be nice if we could specify something like

```yaml
markdown_extensions:
  - pymdownx.snippets:
      base_path: $relative
  - markdown_include.include:
      base_path: $relative
  - one of many other extensions that use exacly 'base_path' as variable:
      base_path: $relative
```

that would then make MkDocs add the currently valid path as `base_path` variable for each rendered Markdown file.

This is an issue for the one who _calls_ the plugin, and not the plugin itself, as an additional (dynamic) `base_path` must be exchanged [at this point in code](https://github.com/mkdocs/mkdocs/blob/ff0b7260564e65b6547fd41753ec971e4237823b/mkdocs/structure/pages.py#L173):

```python
md = markdown.Markdown(
    extensions=extensions,
    extension_configs=config['mdx_configs'] or {}
)
```

Currently (with the upper config from my example), `extension_configs` would contain something in the structure of [this](https://python-markdown.github.io/reference/#extension_configs):

```python
extension_configs = {
    'pymdownx.snippets': {
        'base_path': '$relative'
    },
    'markdown_include.include': {
        'base_path': '$relative'
    }
}
```

We need to replace `$relative` here with the proper path depending on the current navigation.

[1]: https://facelessuser.github.io/pymdown-extensions/faq/#github-ish-configurations

Discussion:

Patch:
diff --git a/docs/dev-guide/plugins.md b/docs/dev-guide/plugins.md
index 9fdc410f..705faf57 100644
--- a/docs/dev-guide/plugins.md
+++ b/docs/dev-guide/plugins.md
@@ -110,7 +110,7 @@ class MyPlugin(mkdocs.plugins.BasePlugin):
 > from mkdocs.config import base, config_options as c
 >
 > class _ValidationOptions(base.Config):
->     enable = c.Type(bool, default=True)
+>     enabled = c.Type(bool, default=True)
 >     verbose = c.Type(bool, default=False)
 >     skip_checks = c.ListOfItems(c.Choice(('foo', 'bar', 'baz')), default=[])
 >
@@ -130,7 +130,7 @@ class MyPlugin(mkdocs.plugins.BasePlugin):
 > my_plugin:
 >   definition_file: configs/test.ini  # relative to mkdocs.yml
 >   validation:
->     enable: !ENV [CI, false]
+>     enabled: !ENV [CI, false]
 >     verbose: true
 >     skip_checks:
 >       - foo
diff --git a/docs/hooks.py b/docs/hooks.py
index 1ab58b88..34b556f1 100644
--- a/docs/hooks.py
+++ b/docs/hooks.py
@@ -16,7 +16,7 @@ def _get_language_of_translation_file(path: Path) -> str:
 
 def on_page_markdown(markdown: str, page: Page, config: MkDocsConfig, **kwargs):
     if page.file.src_uri == 'user-guide/choosing-your-theme.md':
-        here = Path(config.config_file_path or '').parent
+        here = Path(config.config_file_path).parent
 
         def replacement(m: re.Match) -> str:
             lines = []
diff --git a/docs/user-guide/configuration.md b/docs/user-guide/configuration.md
index 73661dea..bfb025ea 100644
--- a/docs/user-guide/configuration.md
+++ b/docs/user-guide/configuration.md
@@ -618,7 +618,7 @@ For example, to enable permalinks in the (included) `toc` extension, use:
 ```yaml
 markdown_extensions:
     - toc:
-        permalink: True
+        permalink: true
 ```
 
 Note that a colon (`:`) must follow the extension name (`toc`) and then on a new
@@ -629,7 +629,7 @@ defined on a separate line:
 ```yaml
 markdown_extensions:
     - toc:
-        permalink: True
+        permalink: true
         separator: "_"
 ```
 
@@ -641,10 +641,14 @@ for that extension:
 markdown_extensions:
     - smarty
     - toc:
-        permalink: True
+        permalink: true
     - sane_lists
 ```
 
+> NOTE: **Dynamic config values.**
+>
+> To dynamically configure the extensions, you can get the config values from [environment variables](#environment-variables) or [obtain paths](#paths-relative-to-the-current-file-or-site) of the currently rendered Markdown file or the overall MkDocs site.
+
 In the above examples, each extension is a list item (starts with a `-`). As an
 alternative, key/value pairs can be used instead. However, in that case an empty
 value must be provided for extensions for which no options are defined.
@@ -654,14 +658,14 @@ Therefore, the last example above could also be defined as follows:
 markdown_extensions:
     smarty: {}
     toc:
-        permalink: True
+        permalink: true
     sane_lists: {}
 ```
 
 This alternative syntax is required if you intend to override some options via
 [inheritance].
 
-> NOTE: **See Also:**
+> NOTE: **More extensions.**
 >
 > The Python-Markdown documentation provides a [list of extensions][exts]
 > which are available out-of-the-box. For a list of configuration options
@@ -896,7 +900,9 @@ plugins:
 
 **default**: `full`
 
-## Environment Variables
+## Special YAML tags
+
+### Environment variables
 
 In most cases, the value of a configuration option is set directly in the
 configuration file. However, as an option, the value of a configuration option
@@ -933,6 +939,41 @@ cannot be defined within a single environment variable.
 For more details, see the [pyyaml_env_tag](https://github.com/waylan/pyyaml-env-tag)
 project.
 
+### Paths relative to the current file or site
+
+NEW: **New in version 1.5.**
+
+Some Markdown extensions can benefit from knowing the path of the Markdown file that's currently being processed, or just the root path of the current site. For that, the special tag `!relative` can be used in most contexts within the config file, though the only known usecases are within [`markdown_extensions`](#markdown_extensions).
+
+Examples of the possible values are:
+
+```yaml
+- !relative  # Relative to the directory of the current Markdown file
+- !relative $docs_dir  # Path of the docs_dir
+- !relative $config_dir  # Path of the directory that contains the main mkdocs.yml
+- !relative $config_dir/some/child/dir  # Some subdirectory of the root config directory
+```
+
+(Here, `$docs_dir` and `$config_dir` are currently the *only* special prefixes that are recognized.)
+
+Example:
+
+```yaml
+markdown_extensions:
+  - pymdownx.snippets:
+      base_path: !relative  # Relative to the current Markdown file
+```
+
+This allows the [pymdownx.snippets] extension to include files relative to the current Markdown file, which without this tag it would have no way of knowing.
+
+> NOTE: Even for the default case, any extension's base path is technically the *current working directory* although the assumption is that it's the *directory of mkdocs.yml*. So even if you don't want the paths to be relative, to improve the default behavior, always prefer to use this idiom:
+>
+> ```yaml
+> markdown_extensions:
+>   - pymdownx.snippets:
+>       base_path: !relative $config_dir  # Relative to the root directory with mkdocs.yml
+> ```
+
 ## Configuration Inheritance
 
 Generally, a single file would hold the entire configuration for a site.
@@ -1077,3 +1118,4 @@ echo '{INHERIT: mkdocs.yml, site_name: "Renamed site"}' | mkdocs build -f -
 [markdown_extensions]: #markdown_extensions
 [nav]: #nav
 [inheritance]: #configuration-inheritance
+[pymdownx.snippets]: https://facelessuser.github.io/pymdown-extensions/extensions/snippets/
diff --git a/mkdocs/commands/build.py b/mkdocs/commands/build.py
index c118737a..4c2a3dab 100644
--- a/mkdocs/commands/build.py
+++ b/mkdocs/commands/build.py
@@ -154,6 +154,7 @@ def _build_extra_template(template_name: str, files: Files, config: MkDocsConfig
 def _populate_page(page: Page, config: MkDocsConfig, files: Files, dirty: bool = False) -> None:
     """Read page content from docs_dir and render Markdown."""
 
+    config._current_page = page
     try:
         # When --dirty is used, only read the page if the file has been modified since the
         # previous build of the output.
@@ -185,6 +186,8 @@ def _populate_page(page: Page, config: MkDocsConfig, files: Files, dirty: bool =
             message += f" {e}"
         log.error(message)
         raise
+    finally:
+        config._current_page = None
 
 
 def _build_page(
@@ -198,6 +201,7 @@ def _build_page(
 ) -> None:
     """Pass a Page to theme template and write output to site_dir."""
 
+    config._current_page = page
     try:
         # When --dirty is used, only build the page if the file has been modified since the
         # previous build of the output.
@@ -238,8 +242,6 @@ def _build_page(
         else:
             log.info(f"Page skipped: '{page.file.src_uri}'. Generated empty output.")
 
-        # Deactivate page
-        page.active = False
     except Exception as e:
         message = f"Error building page '{page.file.src_uri}':"
         # Prevent duplicated the error message because it will be printed immediately afterwards.
@@ -247,6 +249,10 @@ def _build_page(
             message += f" {e}"
         log.error(message)
         raise
+    finally:
+        # Deactivate page
+        page.active = False
+        config._current_page = None
 
 
 def build(
diff --git a/mkdocs/commands/gh_deploy.py b/mkdocs/commands/gh_deploy.py
index de5fb99b..f6e5fa05 100644
--- a/mkdocs/commands/gh_deploy.py
+++ b/mkdocs/commands/gh_deploy.py
@@ -116,7 +116,7 @@ def gh_deploy(
 
     if message is None:
         message = default_message
-    sha = _get_current_sha(os.path.dirname(config.config_file_path or ''))
+    sha = _get_current_sha(os.path.dirname(config.config_file_path))
     message = message.format(version=mkdocs.__version__, sha=sha)
 
     log.info(
diff --git a/mkdocs/commands/serve.py b/mkdocs/commands/serve.py
index 48095f9b..1797e815 100644
--- a/mkdocs/commands/serve.py
+++ b/mkdocs/commands/serve.py
@@ -100,7 +100,8 @@ def serve(
         if livereload:
             # Watch the documentation files, the config file and the theme files.
             server.watch(config.docs_dir)
-            server.watch(config.config_file_path)
+            if config.config_file_path:
+                server.watch(config.config_file_path)
 
             if watch_theme:
                 for d in config.theme.dirs:
diff --git a/mkdocs/config/base.py b/mkdocs/config/base.py
index dcbc6e23..cee32e31 100644
--- a/mkdocs/config/base.py
+++ b/mkdocs/config/base.py
@@ -21,8 +21,6 @@ from typing import (
     overload,
 )
 
-from yaml import YAMLError
-
 from mkdocs import exceptions, utils
 from mkdocs.utils import weak_property
 
@@ -133,7 +131,7 @@ class Config(UserDict):
     """
 
     _schema: PlainConfigSchema
-    config_file_path: str | None
+    config_file_path: str
 
     def __init_subclass__(cls):
         schema = dict(getattr(cls, '_schema', ()))
@@ -170,7 +168,7 @@ class Config(UserDict):
                 config_file_path = config_file_path.decode(encoding=sys.getfilesystemencoding())
             except UnicodeDecodeError:
                 raise ValidationError("config_file_path is not a Unicode string.")
-        self.config_file_path = config_file_path
+        self.config_file_path = config_file_path or ''
 
     def set_defaults(self) -> None:
         """
@@ -257,13 +255,12 @@ class Config(UserDict):
 
     def load_file(self, config_file: IO) -> None:
         """Load config options from the open file descriptor of a YAML file."""
-        try:
-            return self.load_dict(utils.yaml_load(config_file))
-        except YAMLError as e:
-            # MkDocs knows and understands ConfigurationErrors
-            raise exceptions.ConfigurationError(
-                f"MkDocs encountered an error parsing the configuration file: {e}"
-            )
+        warnings.warn(
+            "Config.load_file is not used since MkDocs 1.5 and will be removed soon. "
+            "Use MkDocsConfig.load_file instead",
+            DeprecationWarning,
+        )
+        return self.load_dict(utils.yaml_load(config_file))
 
     @weak_property
     def user_configs(self) -> Sequence[Mapping[str, Any]]:
@@ -344,7 +341,9 @@ def _open_config_file(config_file: str | IO | None) -> Iterator[IO]:
             result_config_file.close()
 
 
-def load_config(config_file: str | IO | None = None, **kwargs) -> MkDocsConfig:
+def load_config(
+    config_file: str | IO | None = None, *, config_file_path: str | None = None, **kwargs
+) -> MkDocsConfig:
     """
     Load the configuration for a given file object or name
 
@@ -366,7 +365,10 @@ def load_config(config_file: str | IO | None = None, **kwargs) -> MkDocsConfig:
         # Initialize the config with the default schema.
         from mkdocs.config.defaults import MkDocsConfig
 
-        cfg = MkDocsConfig(config_file_path=getattr(fd, 'name', ''))
+        if config_file_path is None:
+            if fd is not sys.stdin.buffer:
+                config_file_path = getattr(fd, 'name', None)
+        cfg = MkDocsConfig(config_file_path=config_file_path)
         # load the config file
         cfg.load_file(fd)
 
diff --git a/mkdocs/config/config_options.py b/mkdocs/config/config_options.py
index 23a113c0..05994b03 100644
--- a/mkdocs/config/config_options.py
+++ b/mkdocs/config/config_options.py
@@ -708,7 +708,7 @@ class Dir(FilesystemObject):
 
 class DocsDir(Dir):
     def post_validation(self, config: Config, key_name: str):
-        if config.config_file_path is None:
+        if not config.config_file_path:
             return
 
         # Validate that the dir is not the parent dir of the config file.
@@ -826,7 +826,7 @@ class Theme(BaseConfigOption[theme.Theme]):
 
         # Ensure custom_dir is an absolute path
         if 'custom_dir' in theme_config and not os.path.isabs(theme_config['custom_dir']):
-            config_dir = os.path.dirname(self.config_file_path or '')
+            config_dir = os.path.dirname(self.config_file_path)
             theme_config['custom_dir'] = os.path.join(config_dir, theme_config['custom_dir'])
 
         if 'custom_dir' in theme_config and not os.path.isdir(theme_config['custom_dir']):
diff --git a/mkdocs/config/defaults.py b/mkdocs/config/defaults.py
index 891ee710..1a76f1fa 100644
--- a/mkdocs/config/defaults.py
+++ b/mkdocs/config/defaults.py
@@ -1,9 +1,11 @@
 from __future__ import annotations
 
-from typing import Dict
+from typing import IO, Dict
 
+import mkdocs.structure.pages
 from mkdocs.config import base
 from mkdocs.config import config_options as c
+from mkdocs.utils.yaml import get_yaml_loader, yaml_load
 
 
 def get_schema() -> base.PlainConfigSchema:
@@ -16,7 +18,7 @@ def get_schema() -> base.PlainConfigSchema:
 class MkDocsConfig(base.Config):
     """The configuration of MkDocs itself (the root object of mkdocs.yml)."""
 
-    config_file_path: str | None = c.Optional(c.Type(str))  # type: ignore[assignment]
+    config_file_path: str = c.Type(str)  # type: ignore[assignment]
     """The path to the mkdocs.yml config file. Can't be populated from the config."""
 
     site_name = c.Type(str)
@@ -137,7 +139,16 @@ class MkDocsConfig(base.Config):
     watch = c.ListOfPaths(default=[])
     """A list of extra paths to watch while running `mkdocs serve`."""
 
+    _current_page: mkdocs.structure.pages.Page | None = None
+    """The currently rendered page. Please do not access this and instead
+    rely on the `page` argument to event handlers."""
+
     def load_dict(self, patch: dict) -> None:
         super().load_dict(patch)
         if 'config_file_path' in patch:
             raise base.ValidationError("Can't set config_file_path in config")
+
+    def load_file(self, config_file: IO) -> None:
+        """Load config options from the open file descriptor of a YAML file."""
+        loader = get_yaml_loader(config=self)
+        self.load_dict(yaml_load(config_file, loader))
diff --git a/mkdocs/utils/__init__.py b/mkdocs/utils/__init__.py
index 23ec3a0b..4787d8ca 100644
--- a/mkdocs/utils/__init__.py
+++ b/mkdocs/utils/__init__.py
@@ -17,7 +17,7 @@ import warnings
 from collections import defaultdict
 from datetime import datetime, timezone
 from pathlib import PurePath
-from typing import IO, TYPE_CHECKING, Any, Collection, Iterable, MutableSequence, TypeVar
+from typing import TYPE_CHECKING, Collection, Iterable, MutableSequence, TypeVar
 from urllib.parse import urlsplit
 
 if sys.version_info >= (3, 10):
@@ -25,11 +25,8 @@ if sys.version_info >= (3, 10):
 else:
     from importlib_metadata import EntryPoint, entry_points
 
-import yaml
-from mergedeep import merge
-from yaml_env_tag import construct_env_tag
-
 from mkdocs import exceptions
+from mkdocs.utils.yaml import get_yaml_loader, yaml_load  # noqa - legacy re-export
 
 if TYPE_CHECKING:
     from mkdocs.structure.pages import Page
@@ -47,42 +44,6 @@ markdown_extensions = (
 )
 
 
-def get_yaml_loader(loader=yaml.Loader):
-    """Wrap PyYaml's loader so we can extend it to suit our needs."""
-
-    class Loader(loader):
-        """
-        Define a custom loader derived from the global loader to leave the
-        global loader unaltered.
-        """
-
-    # Attach Environment Variable constructor.
-    # See https://github.com/waylan/pyyaml-env-tag
-    Loader.add_constructor('!ENV', construct_env_tag)
-
-    return Loader
-
-
-def yaml_load(source: IO | str, loader: type[yaml.BaseLoader] | None = None) -> dict[str, Any]:
-    """Return dict of source YAML file using loader, recursively deep merging inherited parent."""
-    Loader = loader or get_yaml_loader()
-    result = yaml.load(source, Loader=Loader)
-    if result is None:
-        return {}
-    if 'INHERIT' in result and not isinstance(source, str):
-        relpath = result.pop('INHERIT')
-        abspath = os.path.normpath(os.path.join(os.path.dirname(source.name), relpath))
-        if not os.path.exists(abspath):
-            raise exceptions.ConfigurationError(
-                f"Inherited config file '{relpath}' does not exist at '{abspath}'."
-            )
-        log.debug(f"Loading inherited configuration file: {abspath}")
-        with open(abspath, 'rb') as fd:
-            parent = yaml_load(fd, Loader)
-        result = merge(parent, result)
-    return result
-
-
 def modified_time(file_path):
     warnings.warn(
         "modified_time is never used in MkDocs and will be removed soon.", DeprecationWarning
diff --git a/mkdocs/utils/yaml.py b/mkdocs/utils/yaml.py
new file mode 100644
index 00000000..40e1d301
--- /dev/null
+++ b/mkdocs/utils/yaml.py
@@ -0,0 +1,146 @@
+from __future__ import annotations
+
+import functools
+import logging
+import os
+import os.path
+from typing import IO, TYPE_CHECKING, Any
+
+import mergedeep
+import yaml
+import yaml.constructor
+import yaml_env_tag
+
+from mkdocs import exceptions
+
+if TYPE_CHECKING:
+    from mkdocs.config.defaults import MkDocsConfig
+
+log = logging.getLogger(__name__)
+
+
+def _construct_dir_placeholder(
+    config: MkDocsConfig, loader: yaml.BaseLoader, node: yaml.ScalarNode
+) -> _DirPlaceholder:
+    loader.construct_scalar(node)
+
+    value: str = (node and node.value) or ''
+    prefix, _, suffix = value.partition('/')
+    if prefix.startswith('$'):
+        if prefix == '$config_dir':
+            return ConfigDirPlaceholder(config, suffix)
+        elif prefix == '$docs_dir':
+            return DocsDirPlaceholder(config, suffix)
+        else:
+            raise exceptions.ConfigurationError(
+                f"Unknown prefix {prefix!r} in {node.tag} {node.value!r}"
+            )
+    else:
+        return RelativeDirPlaceholder(config, value)
+
+
+class _DirPlaceholder(os.PathLike):
+    def __init__(self, config: MkDocsConfig, suffix: str = ''):
+        self.config = config
+        self.suffix = suffix
+
+    def value(self) -> str:
+        raise NotImplementedError
+
+    def __fspath__(self) -> str:
+        """Can be used as a path."""
+        return os.path.join(self.value(), self.suffix)
+
+    def __str__(self) -> str:
+        """Can be converted to a string to obtain the current class."""
+        return self.__fspath__()
+
+
+class ConfigDirPlaceholder(_DirPlaceholder):
+    """A placeholder object that gets resolved to the directory of the config file when used as a path.
+
+    The suffix can be an additional sub-path that is always appended to this path.
+
+    This is the implementation of the `!relative $config_dir/suffix` tag, but can also be passed programmatically.
+    """
+
+    def value(self) -> str:
+        return os.path.dirname(self.config.config_file_path)
+
+
+class DocsDirPlaceholder(_DirPlaceholder):
+    """A placeholder object that gets resolved to the docs dir when used as a path.
+
+    The suffix can be an additional sub-path that is always appended to this path.
+
+    This is the implementation of the `!relative $docs_dir/suffix` tag, but can also be passed programmatically.
+    """
+
+    def value(self) -> str:
+        return self.config.docs_dir
+
+
+class RelativeDirPlaceholder(_DirPlaceholder):
+    """A placeholder object that gets resolved to the directory of the Markdown file currently being rendered.
+
+    This is the implementation of the `!relative` tag, but can also be passed programmatically.
+    """
+
+    def __init__(self, config: MkDocsConfig, suffix: str = ''):
+        if suffix:
+            raise exceptions.ConfigurationError(
+                f"'!relative' tag does not expect any value; received {suffix!r}"
+            )
+        super().__init__(config, suffix)
+
+    def value(self) -> str:
+        if self.config._current_page is None:
+            raise exceptions.ConfigurationError(
+                "The current file is not set for the '!relative' tag. "
+                "It cannot be used in this context; the intended usage is within `markdown_extensions`."
+            )
+        return os.path.dirname(self.config._current_page.file.abs_src_path)
+
+
+def get_yaml_loader(loader=yaml.Loader, config: MkDocsConfig | None = None):
+    """Wrap PyYaml's loader so we can extend it to suit our needs."""
+
+    class Loader(loader):
+        """
+        Define a custom loader derived from the global loader to leave the
+        global loader unaltered.
+        """
+
+    # Attach Environment Variable constructor.
+    # See https://github.com/waylan/pyyaml-env-tag
+    Loader.add_constructor('!ENV', yaml_env_tag.construct_env_tag)
+
+    if config is not None:
+        Loader.add_constructor('!relative', functools.partial(_construct_dir_placeholder, config))
+
+    return Loader
+
+
+def yaml_load(source: IO | str, loader: type[yaml.BaseLoader] | None = None) -> dict[str, Any]:
+    """Return dict of source YAML file using loader, recursively deep merging inherited parent."""
+    loader = loader or get_yaml_loader()
+    try:
+        result = yaml.load(source, Loader=loader)
+    except yaml.YAMLError as e:
+        raise exceptions.ConfigurationError(
+            f"MkDocs encountered an error parsing the configuration file: {e}"
+        )
+    if result is None:
+        return {}
+    if 'INHERIT' in result and not isinstance(source, str):
+        relpath = result.pop('INHERIT')
+        abspath = os.path.normpath(os.path.join(os.path.dirname(source.name), relpath))
+        if not os.path.exists(abspath):
+            raise exceptions.ConfigurationError(
+                f"Inherited config file '{relpath}' does not exist at '{abspath}'."
+            )
+        log.debug(f"Loading inherited configuration file: {abspath}")
+        with open(abspath, 'rb') as fd:
+            parent = yaml_load(fd, loader)
+        result = mergedeep.merge(parent, result)
+    return result


Test patch:
diff --git a/mkdocs/tests/build_tests.py b/mkdocs/tests/build_tests.py
index bc161fce..24b2fa6c 100644
--- a/mkdocs/tests/build_tests.py
+++ b/mkdocs/tests/build_tests.py
@@ -2,13 +2,19 @@
 from __future__ import annotations
 
 import contextlib
+import io
+import os.path
+import re
 import textwrap
 import unittest
 from pathlib import Path
 from typing import TYPE_CHECKING
 from unittest import mock
 
+import markdown.preprocessors
+
 from mkdocs.commands import build
+from mkdocs.config import base
 from mkdocs.exceptions import PluginError
 from mkdocs.livereload import LiveReloadServer
 from mkdocs.structure.files import File, Files
@@ -743,6 +749,61 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
                     else:
                         self.assertPathExists(summary_path)
 
+    @tempdir(
+        files={
+            'README.md': 'CONFIG_README\n',
+            'docs/foo.md': 'ROOT_FOO\n',
+            'docs/test/bar.md': 'TEST_BAR\n',
+            'docs/main/foo.md': 'MAIN_FOO\n',
+            'docs/main/main.md': (
+                '--8<-- "README.md"\n\n'
+                '--8<-- "foo.md"\n\n'
+                '--8<-- "test/bar.md"\n\n'
+                '--8<-- "../foo.md"\n\n'
+            ),
+        }
+    )
+    def test_markdown_extension_with_relative(self, config_dir):
+        for base_path, expected in {
+            '!relative': '''
+                <p>(Failed to read 'README.md')</p>
+                <p>MAIN_FOO</p>
+                <p>(Failed to read 'test/bar.md')</p>
+                <p>ROOT_FOO</p>''',
+            '!relative $docs_dir': '''
+                <p>(Failed to read 'README.md')</p>
+                <p>ROOT_FOO</p>
+                <p>TEST_BAR</p>
+                <p>(Failed to read '../foo.md')</p>''',
+            '!relative $config_dir/docs': '''
+                <p>(Failed to read 'README.md')</p>
+                <p>ROOT_FOO</p>
+                <p>TEST_BAR</p>
+                <p>(Failed to read '../foo.md')</p>''',
+            '!relative $config_dir': '''
+                <p>CONFIG_README</p>
+                <p>(Failed to read 'foo.md')</p>
+                <p>(Failed to read 'test/bar.md')</p>
+                <p>(Failed to read '../foo.md')</p>''',
+        }.items():
+            with self.subTest(base_path=base_path):
+                cfg = f'''
+                    site_name: test
+                    use_directory_urls: false
+                    markdown_extensions:
+                        - mkdocs.tests.build_tests:
+                            base_path: {base_path}
+                '''
+                config = base.load_config(
+                    io.StringIO(cfg), config_file_path=os.path.join(config_dir, 'mkdocs.yml')
+                )
+
+                with self._assert_build_logs(''):
+                    build.build(config)
+                main_path = Path(config_dir, 'site', 'main', 'main.html')
+                self.assertTrue(main_path.is_file())
+                self.assertIn(textwrap.dedent(expected), main_path.read_text())
+
     # Test build.site_directory_contains_stale_files
 
     @tempdir(files=['index.html'])
@@ -752,3 +813,29 @@ class BuildTests(PathAssertionMixin, unittest.TestCase):
     @tempdir()
     def test_not_site_dir_contains_stale_files(self, site_dir):
         self.assertFalse(build.site_directory_contains_stale_files(site_dir))
+
+
+class _TestPreprocessor(markdown.preprocessors.Preprocessor):
+    def __init__(self, base_path: str) -> None:
+        self.base_path = base_path
+
+    def run(self, lines: list[str]) -> list[str]:
+        for i, line in enumerate(lines):
+            m = re.search(r'^--8<-- "(.+)"$', line)
+            if m:
+                try:
+                    lines[i] = Path(self.base_path, m[1]).read_text()
+                except OSError:
+                    lines[i] = f"(Failed to read {m[1]!r})\n"
+        return lines
+
+
+class _TestExtension(markdown.extensions.Extension):
+    def __init__(self, base_path: str) -> None:
+        self.base_path = base_path
+
+    def extendMarkdown(self, md: markdown.Markdown) -> None:
+        md.preprocessors.register(_TestPreprocessor(self.base_path), "mkdocs_test", priority=32)
+
+
+makeExtension = _TestExtension