diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 0000000..ba1d7a7
--- /dev/null
+++ b/docs/configuration.md
@@ -0,0 +1,181 @@
+# Configuration
+
+This page lists the available configuration options and what they achieve.
+
+[](){#option-extra}
+## `extra`
+
+- **:octicons-package-24: Type [`dict`][] :material-equal: `{}`{ title="default value" }**
+
+The `extra` option lets you inject additional variables into the Jinja context used when rendering templates. You can then use this extra context in your [overridden templates](https://mkdocstrings.github.io/usage/theming/#templates).
+
+Local `extra` options will be merged into the global `extra` option:
+
+```yaml title="in mkdocs.yml (global configuration)"
+plugins:
+- mkdocstrings:
+ handlers:
+ shell:
+ options:
+ extra:
+ hello: world
+```
+
+```md title="in docs/some_page.md (local configuration)"
+::: your_package.your_module.your_func
+ handler: shell
+ options:
+ extra:
+ foo: bar
+```
+
+...will inject both `hello` and `foo` into the Jinja context when rendering `your_package.your_module.your_func`.
+
+[](){#option-heading_level}
+## `heading_level`
+
+- **:octicons-package-24: Type [`int`][] :material-equal: `2`{ title="default value" }**
+
+The initial heading level to use.
+
+When injecting documentation for an object,
+the object itself and its members are rendered.
+For each layer of objects, we increase the heading level by 1.
+
+The initial heading level will be used for the first layer.
+If you set it to 3, then headings will start with `
`.
+
+If the [heading for the root object][show_root_heading] is not shown,
+then the initial heading level is used for its members.
+
+```yaml title="in mkdocs.yml (global configuration)"
+plugins:
+- mkdocstrings:
+ handlers:
+ shell:
+ options:
+ heading_level: 2
+```
+
+```md title="or in docs/some_page.md (local configuration)"
+::: path.to.module
+ handler: shell
+ options:
+ heading_level: 3
+```
+
+/// admonition | Preview
+ type: preview
+
+//// tab | With level 3 and root heading
+
module (3)
+
Docstring of the module.
+
ClassA (4)
+
Docstring of class A.
+
ClassB (4)
+
Docstring of class B.
+
method_1 (5)
+
Docstring of the method.
+////
+
+//// tab | With level 3, without root heading
+
Docstring of the module.
+
ClassA (3)
+
Docstring of class A.
+
ClassB (3)
+
Docstring of class B.
+
method_1 (4)
+
Docstring of the method.
+////
+///
+
+[](){#option-show_root_heading}
+## `show_root_heading`
+
+- **:octicons-package-24: Type [`bool`][] :material-equal: `False`{ title="default value" }**
+
+Show the heading of the object at the root of the documentation tree
+(i.e. the object referenced by the identifier after `:::`).
+
+While this option defaults to false for backwards compatibility, we recommend setting it to true. Note that the heading of the root object can be a level 1 heading (the first on the page):
+
+```md
+# ::: path.to.object
+```
+
+```yaml title="in mkdocs.yml (global configuration)"
+plugins:
+- mkdocstrings:
+ handlers:
+ shell:
+ options:
+ show_root_heading: false
+```
+
+```md title="or in docs/some_page.md (local configuration)"
+::: path.to.Class
+ handler: shell
+ options:
+ show_root_heading: true
+```
+
+[](){#option-show_root_toc_entry}
+## `show_root_toc_entry`
+
+- **:octicons-package-24: Type [`bool`][] :material-equal: `True`{ title="default value" }**
+
+
+If the root heading is not shown, at least add a ToC entry for it.
+
+If you inject documentation for an object in the middle of a page,
+after long paragraphs, and without showing the [root heading][show_root_heading],
+then you will not be able to link to this particular object
+as it won't have a permalink and will be "lost" in the middle of text.
+In that case, it is useful to add a hidden anchor to the document,
+which will also appear in the table of contents.
+
+In other cases, you might want to disable the entry to avoid polluting the ToC.
+It is not possible to show the root heading *and* hide the ToC entry.
+
+```yaml title="in mkdocs.yml (global configuration)"
+plugins:
+- mkdocstrings:
+ handlers:
+ shell:
+ options:
+ show_root_heading: false
+ show_root_toc_entry: true
+```
+
+```md title="or in docs/some_page.md (local configuration)"
+## Some heading
+
+Lots of text.
+
+::: path.to.object
+ handler: shell
+ options:
+ show_root_heading: false
+ show_root_toc_entry: false
+
+## Other heading.
+
+More text.
+```
+
+/// admonition | Preview
+ type: preview
+
+//// tab | With ToC entry
+**Table of contents**
+[Some heading](#permalink-to-some-heading){ title="#permalink-to-some-heading" }
+[`object`](#permalink-to-object){ title="#permalink-to-object" }
+[Other heading](#permalink-to-other-heading){ title="#permalink-to-other-heading" }
+////
+
+//// tab | Without ToC entry
+**Table of contents**
+[Some heading](#permalink-to-some-heading){ title="#permalink-to-some-heading" }
+[Other heading](#permalink-to-other-heading){ title="#permalink-to-other-heading" }
+////
+///
diff --git a/docs/css/material.css b/docs/css/material.css
index 9e8c14a..235ef94 100644
--- a/docs/css/material.css
+++ b/docs/css/material.css
@@ -2,3 +2,25 @@
.md-main__inner {
margin-bottom: 1.5rem;
}
+
+/* Custom admonition: preview */
+:root {
+ --md-admonition-icon--preview: url('data:image/svg+xml;charset=utf-8,');
+}
+
+.md-typeset .admonition.preview,
+.md-typeset details.preview {
+ border-color: rgb(220, 139, 240);
+}
+
+.md-typeset .preview>.admonition-title,
+.md-typeset .preview>summary {
+ background-color: rgba(142, 43, 155, 0.1);
+}
+
+.md-typeset .preview>.admonition-title::before,
+.md-typeset .preview>summary::before {
+ background-color: rgb(220, 139, 240);
+ -webkit-mask-image: var(--md-admonition-icon--preview);
+ mask-image: var(--md-admonition-icon--preview);
+}
diff --git a/duties.py b/duties.py
index 3978f44..765ab46 100644
--- a/duties.py
+++ b/duties.py
@@ -89,6 +89,8 @@ def check_types(ctx: Context) -> None:
ctx.run(
tools.mypy(*PY_SRC_LIST, config_file="config/mypy.ini"),
title=pyprefix("Type-checking"),
+ # TODO: Update when Pydantic supports 3.14.
+ nofail=sys.version_info >= (3, 14),
)
diff --git a/mkdocs.yml b/mkdocs.yml
index fa23198..a24c12d 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -16,6 +16,7 @@ validation:
nav:
- Home:
- Overview: index.md
+ - Configuration: configuration.md
- Changelog: changelog.md
- Credits: credits.md
- License: license.md
@@ -87,6 +88,9 @@ markdown_extensions:
- admonition
- callouts
- footnotes
+- pymdownx.blocks.admonition
+- pymdownx.blocks.tab:
+ alternate_style: true
- pymdownx.emoji:
emoji_index: !!python/name:material.extensions.emoji.twemoji
emoji_generator: !!python/name:material.extensions.emoji.to_svg
@@ -107,6 +111,7 @@ markdown_extensions:
plugins:
- search
+- autorefs
- markdown-exec
- gen-files:
scripts:
diff --git a/pyproject.toml b/pyproject.toml
index f37cb1a..58e201d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -29,7 +29,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
- "mkdocstrings>=0.18",
+ "mkdocstrings>=0.28",
"shellman>=1.0.0",
]
@@ -106,4 +106,5 @@ dev = [
"mkdocstrings[python]>=0.25",
# YORE: EOL 3.10: Remove line.
"tomli>=2.0; python_version < '3.11'",
+ "pydantic>=2.10",
]
\ No newline at end of file
diff --git a/src/mkdocstrings_handlers/shell/config.py b/src/mkdocstrings_handlers/shell/config.py
new file mode 100644
index 0000000..9e9e546
--- /dev/null
+++ b/src/mkdocstrings_handlers/shell/config.py
@@ -0,0 +1,133 @@
+"""Configuration and options dataclasses."""
+
+from __future__ import annotations
+
+import sys
+from dataclasses import field
+from typing import TYPE_CHECKING, Annotated, Any
+
+# YORE: EOL 3.10: Replace block with line 2.
+if sys.version_info >= (3, 11):
+ from typing import Self
+else:
+ from typing_extensions import Self
+
+try:
+ # When Pydantic is available, use it to validate options (done automatically).
+ # Users can therefore opt into validation by installing Pydantic in development/CI.
+ # When building the docs to deploy them, Pydantic is not required anymore.
+
+ # When building our own docs, Pydantic is always installed (see `docs` group in `pyproject.toml`)
+ # to allow automatic generation of a JSON Schema. The JSON Schema is then referenced by mkdocstrings,
+ # which is itself referenced by mkdocs-material's schema system. For example in VSCode:
+ #
+ # "yaml.schemas": {
+ # "https://squidfunk.github.io/mkdocs-material/schema.json": "mkdocs.yml"
+ # }
+ from inspect import cleandoc
+
+ from pydantic import Field as BaseField
+ from pydantic.dataclasses import dataclass
+
+ _base_url = "https://mkdocstrings.github.io/shell/configuration"
+
+ def Field( # noqa: N802, D103
+ *args: Any,
+ description: str,
+ parent: str | None = None,
+ **kwargs: Any,
+ ) -> None:
+ def _add_markdown_description(schema: dict[str, Any]) -> None:
+ url = f"{_base_url}/#{parent or schema['title']}"
+ schema["markdownDescription"] = f"[DOCUMENTATION]({url})\n\n{schema['description']}"
+
+ return BaseField(
+ *args,
+ description=cleandoc(description),
+ field_title_generator=lambda name, _: name,
+ json_schema_extra=_add_markdown_description,
+ **kwargs,
+ )
+except ImportError:
+ from dataclasses import dataclass # type: ignore[no-redef]
+
+ def Field(*args: Any, **kwargs: Any) -> None: # type: ignore[misc] # noqa: D103, N802
+ pass
+
+
+if TYPE_CHECKING:
+ from collections.abc import MutableMapping
+
+
+# YORE: EOL 3.9: Remove block.
+_dataclass_options = {"frozen": True}
+if sys.version_info >= (3, 10):
+ _dataclass_options["kw_only"] = True
+
+
+# YORE: EOL 3.9: Replace `**_dataclass_options` with `frozen=True, kw_only=True` within line.
+@dataclass(**_dataclass_options) # type: ignore[call-overload]
+class ShellInputOptions:
+ """Accepted input options."""
+
+ extra: Annotated[
+ dict[str, Any],
+ Field(description="Extra options."),
+ ] = field(default_factory=dict)
+
+ heading_level: Annotated[
+ int,
+ Field(description="The initial heading level to use."),
+ ] = 2
+
+ show_root_heading: Annotated[
+ bool,
+ Field(
+ description="""Show the heading of the object at the root of the documentation tree.
+
+ The root object is the object referenced by the identifier after `:::`.
+ """,
+ ),
+ ] = False
+
+ show_root_toc_entry: Annotated[
+ bool,
+ Field(
+ description="If the root heading is not shown, at least add a ToC entry for it.",
+ ),
+ ] = True
+
+ @classmethod
+ def coerce(cls, **data: Any) -> MutableMapping[str, Any]:
+ """Coerce data."""
+ return data
+
+ @classmethod
+ def from_data(cls, **data: Any) -> Self:
+ """Create an instance from a dictionary."""
+ return cls(**cls.coerce(**data))
+
+
+# YORE: EOL 3.9: Replace `**_dataclass_options` with `frozen=True, kw_only=True` within line.
+@dataclass(**_dataclass_options) # type: ignore[call-overload]
+class ShellOptions(ShellInputOptions): # type: ignore[override,unused-ignore]
+ """Final options passed as template context."""
+
+
+# YORE: EOL 3.9: Replace `**_dataclass_options` with `frozen=True, kw_only=True` within line.
+@dataclass(**_dataclass_options) # type: ignore[call-overload]
+class ShellInputConfig:
+ """Python handler configuration."""
+
+ options: Annotated[
+ ShellInputOptions,
+ Field(description="Configuration options for collecting and rendering objects."),
+ ] = field(default_factory=ShellInputOptions)
+
+
+# YORE: EOL 3.9: Replace `**_dataclass_options` with `frozen=True, kw_only=True` within line.
+@dataclass(**_dataclass_options) # type: ignore[call-overload]
+class ShellConfig(ShellInputConfig): # type: ignore[override,unused-ignore]
+ """Shell handler configuration."""
+
+ options: dict[str, Any] = field(default_factory=dict) # type: ignore[assignment]
diff --git a/src/mkdocstrings_handlers/shell/handler.py b/src/mkdocstrings_handlers/shell/handler.py
index ea83726..1a29992 100644
--- a/src/mkdocstrings_handlers/shell/handler.py
+++ b/src/mkdocstrings_handlers/shell/handler.py
@@ -5,15 +5,19 @@
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar
+from mkdocs.exceptions import PluginError
from mkdocstrings.handlers.base import BaseHandler, CollectionError, CollectorItem
from mkdocstrings.loggers import get_logger
from shellman import DocFile
from shellman.templates.filters import FILTERS
+from mkdocstrings_handlers.shell.config import ShellConfig, ShellOptions
+
if TYPE_CHECKING:
from collections.abc import Mapping, MutableMapping
- from markdown import Markdown
+ from mkdocs.config.defaults import MkDocsConfig
+ from mkdocstrings.handlers.base import HandlerOptions
logger = get_logger(__name__)
@@ -22,100 +26,52 @@
class ShellHandler(BaseHandler):
"""The Shell handler class."""
- name: str = "shell"
+ name: ClassVar[str] = "shell"
"""The handler's name."""
- domain: str = "shell"
+ domain: ClassVar[str] = "shell"
"""The cross-documentation domain/language for this handler."""
- enable_inventory: bool = False
+ enable_inventory: ClassVar[bool] = False
"""Whether this handler is interested in enabling the creation of the `objects.inv` Sphinx inventory file."""
- fallback_theme = "material"
+ fallback_theme: ClassVar[str] = "material"
"""The theme to fallback to."""
- fallback_config: ClassVar[dict] = {"fallback": True}
- """The configuration used to collect item during autorefs fallback."""
-
- default_config: ClassVar[dict] = {
- "show_root_heading": False,
- "show_root_toc_entry": True,
- "heading_level": 2,
- }
- """The default configuration options.
-
- Option | Type | Description | Default
- ------ | ---- | ----------- | -------
- **`show_root_heading`** | `bool` | Show the heading of the object at the root of the documentation tree. | `False`
- **`show_root_toc_entry`** | `bool` | If the root heading is not shown, at least add a ToC entry for it. | `True`
- **`heading_level`** | `int` | The initial heading level to use. | `2`
- """
+ def __init__(self, config: ShellConfig, base_dir: Path, **kwargs: Any) -> None: # noqa: D107
+ super().__init__(**kwargs)
+ self.config = config
+ self.base_dir = base_dir
+ self.global_options = config.options
+
+ def get_options(self, local_options: Mapping[str, Any]) -> HandlerOptions: # noqa: D102
+ extra = {**self.global_options.get("extra", {}), **local_options.get("extra", {})}
+ options = {**self.global_options, **local_options, "extra": extra}
+ try:
+ return ShellOptions.from_data(**options)
+ except Exception as error:
+ raise PluginError(f"Invalid options: {error}") from error
- def __init__( # noqa: D107
- self,
- handler: str,
- theme: str,
- custom_templates: str | None = None,
- config_file_path: str | None = None,
- ) -> None:
- super().__init__(handler, theme, custom_templates)
- if config_file_path:
- self.base_dir = Path(config_file_path).parent
- else:
- self.base_dir = Path(".")
-
- def collect(self, identifier: str, config: MutableMapping[str, Any]) -> CollectorItem: # noqa: ARG002
- """Collect data given an identifier and selection configuration.
-
- In the implementation, you typically call a subprocess that returns JSON, and load that JSON again into
- a Python dictionary for example, though the implementation is completely free.
-
- Parameters:
- identifier: An identifier that was found in a markdown document for which to collect data. For example,
- in Python, it would be 'mkdocstrings.handlers' to collect documentation about the handlers module.
- It can be anything that you can feed to the tool of your choice.
- config: All configuration options for this handler either defined globally in `mkdocs.yml` or
- locally overridden in an identifier block by the user.
-
- Returns:
- Anything you want, as long as you can feed it to the `render` method.
- """
+ def collect(self, identifier: str, options: ShellOptions) -> CollectorItem: # noqa: ARG002
+ """Collect data from a shell script/library."""
script_path = self.base_dir / identifier
try:
return DocFile(str(script_path))
except FileNotFoundError as error:
raise CollectionError(f"Could not find script '{script_path}'") from error
- def render(self, data: CollectorItem, config: Mapping[str, Any]) -> str:
- """Render a template using provided data and configuration options.
-
- Parameters:
- data: The data to render that was collected above in `collect()`.
- config: All configuration options for this handler either defined globally in `mkdocs.yml` or
- locally overridden in an identifier block by the user.
-
- Returns:
- The rendered template as HTML.
- """
- final_config = {**self.default_config, **config}
- heading_level = final_config["heading_level"]
+ def render(self, data: CollectorItem, options: ShellOptions) -> str:
+ """Render the collected data."""
+ heading_level = options.heading_level
template = self.env.get_template("script.html.jinja")
return template.render(
- config=final_config,
+ config=options,
filename=data.filename,
script=data.sections,
heading_level=heading_level,
)
- def update_env(self, md: Markdown, config: dict) -> None:
- """Update the Jinja environment with any custom settings/filters/options for this handler.
-
- Parameters:
- md: The Markdown instance. Useful to add functions able to convert Markdown into the environment filters.
- config: Configuration options for `mkdocs` and `mkdocstrings`, read from `mkdocs.yml`. See the source code
- of [mkdocstrings.plugin.MkdocstringsPlugin.on_config][] to see what's in this dictionary.
- """
- super().update_env(md, config) # Add some mkdocstrings default filters such as highlight and convert_markdown
+ def update_env(self, config: MkDocsConfig) -> None: # noqa: ARG002, D102
self.env.trim_blocks = True
self.env.lstrip_blocks = True
self.env.keep_trailing_newline = False
@@ -123,25 +79,20 @@ def update_env(self, md: Markdown, config: dict) -> None:
def get_handler(
- theme: str,
- custom_templates: str | None = None,
- config_file_path: str | None = None,
- **config: Any, # noqa: ARG001
+ *,
+ handler_config: MutableMapping[str, Any],
+ tool_config: MkDocsConfig,
+ **kwargs: Any,
) -> ShellHandler:
"""Simply return an instance of `ShellHandler`.
Parameters:
- theme: The theme to use when rendering contents.
- custom_templates: Directory containing custom templates.
- config_file_path: The MkDocs configuration file path.
- **config: Configuration passed to the handler.
+ handler_config: The handler configuration.
+ tool_config: The tool (SSG) configuration.
+ **kwargs: Keyword arguments for the base handler constructor.
Returns:
An instance of the handler.
"""
- return ShellHandler(
- handler="shell",
- theme=theme,
- custom_templates=custom_templates,
- config_file_path=config_file_path,
- )
+ base_dir = Path(tool_config.config_file_path or "./mkdocs.yml").parent
+ return ShellHandler(config=ShellConfig(**handler_config), base_dir=base_dir, **kwargs)
diff --git a/tests/conftest.py b/tests/conftest.py
index 51a124e..a8cdc66 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -94,5 +94,5 @@ def fixture_handler(plugin: MkdocstringsPlugin, ext_markdown: Markdown) -> Shell
A handler instance.
"""
handler = plugin.handlers.get_handler("shell")
- handler._update_env(ext_markdown, plugin.handlers._config)
+ handler._update_env(ext_markdown, config=plugin.handlers._tool_config)
return handler # type: ignore[return-value]
diff --git a/tests/test_themes.py b/tests/test_themes.py
index a11f713..6a0db32 100644
--- a/tests/test_themes.py
+++ b/tests/test_themes.py
@@ -35,6 +35,6 @@ def test_render_themes_templates_python(identifier: str, plugin: MkdocstringsPlu
ext_markdown: Pytest fixture (see conftest.py).
"""
handler = plugin.handlers.get_handler("shell")
- handler._update_env(ext_markdown, plugin.handlers._config)
+ handler._update_env(ext_markdown, config=plugin.handlers._tool_config)
data = handler.collect(identifier, {})
handler.render(data, {})