diff --git a/README.md b/README.md
index 194dbe5..16fefdc 100644
--- a/README.md
+++ b/README.md
@@ -94,6 +94,24 @@ If you are inserting documentation within other Markdown content, you can set th
By default it is set to `0`, i.e. headers start at `
`. If set to `1`, headers will start at ``, and so on. Note that if you insert your own first level heading and leave depth at its default value of 0, the page will have multiple `` tags, which is not compatible with themes that generate page-internal menus such as the ReadTheDocs and mkdocs-material themes.
+### Full command path headers
+
+By default, `mkdocs-click` outputs headers that contain the command name. For nested commands such as `$ cli build all`, this also means the heading would be `## all`. This might be surprising, and may be harder to navigate at a glance for highly nested CLI apps.
+
+If you'd like to show the full command path instead, turn on the [Attribute Lists extension](https://python-markdown.github.io/extensions/attr_list/):
+
+```yaml
+# mkdocs.yaml
+
+markdown_extensions:
+ - attr_list
+ - mkdocs-click
+```
+
+`mkdocs-click` will then output the full command path in headers (e.g. `## cli build all`) and permalinks (e.g. `#cli-build-all`).
+
+Note that the table of content (TOC) will still use the command name: the TOC is naturally hierarchal, so full command paths would be redundant. (This exception is why the `attr_list` extension is required.)
+
## Reference
### Block syntax
diff --git a/mkdocs.yml b/mkdocs.yml
index 921563f..3265c43 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -6,4 +6,5 @@ theme: readthedocs
docs_dir: example
markdown_extensions:
+ - attr_list
- mkdocs-click
diff --git a/mkdocs_click/_docs.py b/mkdocs_click/_docs.py
index 1941638..1c64a9d 100644
--- a/mkdocs_click/_docs.py
+++ b/mkdocs_click/_docs.py
@@ -4,25 +4,37 @@
from typing import Iterator, List, cast
import click
+from markdown.extensions.toc import slugify
from ._exceptions import MkDocsClickException
def make_command_docs(
- prog_name: str, command: click.BaseCommand, depth: int = 0, style: str = "plain"
+ prog_name: str,
+ command: click.BaseCommand,
+ depth: int = 0,
+ style: str = "plain",
+ has_attr_list: bool = False,
) -> Iterator[str]:
"""Create the Markdown lines for a command and its sub-commands."""
- for line in _recursively_make_command_docs(prog_name, command, depth=depth, style=style):
+ for line in _recursively_make_command_docs(
+ prog_name, command, depth=depth, style=style, has_attr_list=has_attr_list
+ ):
yield line.replace("\b", "")
def _recursively_make_command_docs(
- prog_name: str, command: click.BaseCommand, parent: click.Context = None, depth: int = 0, style: str = "plain"
+ prog_name: str,
+ command: click.BaseCommand,
+ parent: click.Context = None,
+ depth: int = 0,
+ style: str = "plain",
+ has_attr_list: bool = False,
) -> Iterator[str]:
"""Create the raw Markdown lines for a command and its sub-commands."""
ctx = click.Context(cast(click.Command, command), info_name=prog_name, parent=parent)
- yield from _make_title(prog_name, depth)
+ yield from _make_title(ctx, depth, has_attr_list=has_attr_list)
yield from _make_description(ctx)
yield from _make_usage(ctx)
yield from _make_options(ctx, style)
@@ -30,7 +42,9 @@ def _recursively_make_command_docs(
subcommands = _get_sub_commands(ctx.command, ctx)
for command in sorted(subcommands, key=lambda cmd: cmd.name):
- yield from _recursively_make_command_docs(command.name, command, parent=ctx, depth=depth + 1, style=style)
+ yield from _recursively_make_command_docs(
+ command.name, command, parent=ctx, depth=depth + 1, style=style, has_attr_list=has_attr_list
+ )
def _get_sub_commands(command: click.Command, ctx: click.Context) -> List[click.Command]:
@@ -52,9 +66,40 @@ def _get_sub_commands(command: click.Command, ctx: click.Context) -> List[click.
return subcommands
-def _make_title(prog_name: str, depth: int) -> Iterator[str]:
- """Create the first markdown lines describing a command."""
- yield f"{'#' * (depth + 1)} {prog_name}"
+def _make_title(ctx: click.Context, depth: int, *, has_attr_list: bool) -> Iterator[str]:
+ """Create the Markdown heading for a command."""
+ if has_attr_list:
+ yield from _make_title_full_command_path(ctx, depth)
+ else:
+ yield from _make_title_basic(ctx, depth)
+
+
+def _make_title_basic(ctx: click.Context, depth: int) -> Iterator[str]:
+ """Create a basic Markdown heading for a command."""
+ yield f"{'#' * (depth + 1)} {ctx.info_name}"
+ yield ""
+
+
+def _make_title_full_command_path(ctx: click.Context, depth: int) -> Iterator[str]:
+ """Create the markdown heading for a command, showing the full command path.
+
+ This style accomodates nested commands by showing:
+ * The full command path for headers and permalinks (eg `# git commit` and `http://localhost:8000/#git-commit`)
+ * The command leaf name only for TOC entries (eg `* commit`).
+
+ We do this because a TOC naturally conveys the hierarchy, whereas headings and permalinks should be namespaced to
+ convey the hierarchy.
+
+ See: https://github.com/DataDog/mkdocs-click/issues/35
+ """
+ text = ctx.command_path # 'git commit'
+ permalink = slugify(ctx.command_path, "-") # 'git-commit'
+ toc_label = ctx.info_name # 'commit'
+
+ # Requires `attr_list` extension, see: https://python-markdown.github.io/extensions/toc/#custom-labels
+ attributes = f"#{permalink} data-toc-label='{toc_label}'"
+
+ yield f"{'#' * (depth + 1)} {text} {{ {attributes} }}"
yield ""
diff --git a/mkdocs_click/_extension.py b/mkdocs_click/_extension.py
index daf1c76..489cdbf 100644
--- a/mkdocs_click/_extension.py
+++ b/mkdocs_click/_extension.py
@@ -4,6 +4,7 @@
from typing import Any, Iterator, List
from markdown.extensions import Extension
+from markdown.extensions.attr_list import AttrListExtension
from markdown.preprocessors import Preprocessor
from ._docs import make_command_docs
@@ -12,7 +13,7 @@
from ._processing import replace_blocks
-def replace_command_docs(**options: Any) -> Iterator[str]:
+def replace_command_docs(has_attr_list: bool = False, **options: Any) -> Iterator[str]:
for option in ("module", "command"):
if option not in options:
raise MkDocsClickException(f"Option {option!r} is required")
@@ -27,12 +28,24 @@ def replace_command_docs(**options: Any) -> Iterator[str]:
prog_name = prog_name or command_obj.name or command
- return make_command_docs(prog_name=prog_name, command=command_obj, depth=depth, style=style)
+ return make_command_docs(
+ prog_name=prog_name, command=command_obj, depth=depth, style=style, has_attr_list=has_attr_list
+ )
class ClickProcessor(Preprocessor):
+ def __init__(self, md: Any) -> None:
+ super().__init__(md)
+ self._has_attr_list = any(isinstance(ext, AttrListExtension) for ext in md.registeredExtensions)
+
def run(self, lines: List[str]) -> List[str]:
- return list(replace_blocks(lines, title="mkdocs-click", replace=replace_command_docs))
+ return list(
+ replace_blocks(
+ lines,
+ title="mkdocs-click",
+ replace=lambda **options: replace_command_docs(has_attr_list=self._has_attr_list, **options),
+ )
+ )
class MKClickExtension(Extension):
@@ -48,7 +61,7 @@ class MKClickExtension(Extension):
def extendMarkdown(self, md: Any) -> None:
md.registerExtension(self)
- processor = ClickProcessor(md.parser)
+ processor = ClickProcessor(md)
md.preprocessors.register(processor, "mk_click", 141)
diff --git a/tests/app/expected-enhanced.md b/tests/app/expected-enhanced.md
new file mode 100644
index 0000000..4b2c490
--- /dev/null
+++ b/tests/app/expected-enhanced.md
@@ -0,0 +1,63 @@
+# cli { #cli data-toc-label="cli" }
+
+Main entrypoint for this dummy program
+
+**Usage:**
+
+```
+cli [OPTIONS] COMMAND [ARGS]...
+```
+
+**Options:**
+
+```
+ --help Show this message and exit.
+```
+
+## cli bar { #cli-bar data-toc-label="bar" }
+
+The bar command
+
+**Usage:**
+
+```
+cli bar [OPTIONS] COMMAND [ARGS]...
+```
+
+**Options:**
+
+```
+ --help Show this message and exit.
+```
+
+### cli bar hello { #cli-bar-hello data-toc-label="hello" }
+
+Simple program that greets NAME for a total of COUNT times.
+
+**Usage:**
+
+```
+cli bar hello [OPTIONS]
+```
+
+**Options:**
+
+```
+ --count INTEGER Number of greetings.
+ --name TEXT The person to greet.
+ --help Show this message and exit.
+```
+
+## cli foo { #cli-foo data-toc-label="foo" }
+
+**Usage:**
+
+```
+cli foo [OPTIONS]
+```
+
+**Options:**
+
+```
+ --help Show this message and exit.
+```
diff --git a/tests/test_extension.py b/tests/test_extension.py
index d112932..332db46 100644
--- a/tests/test_extension.py
+++ b/tests/test_extension.py
@@ -10,6 +10,7 @@
import mkdocs_click
EXPECTED = (Path(__file__).parent / "app" / "expected.md").read_text()
+EXPECTED_ENHANCED = (Path(__file__).parent / "app" / "expected-enhanced.md").read_text()
@pytest.mark.parametrize(
@@ -101,3 +102,25 @@ def test_required_options(option):
with pytest.raises(mkdocs_click.MkDocsClickException):
md.convert(source)
+
+
+def test_enhanced_titles():
+ """
+ If `attr_list` extension is registered, section titles are enhanced with full command paths.
+
+ See: https://github.com/DataDog/mkdocs-click/issues/35
+ """
+ md = Markdown(extensions=["attr_list"])
+ # Register our extension as a second step, so that we see `attr_list`.
+ # This is what MkDocs does, so there's no hidden usage constraint here.
+ md.registerExtensions([mkdocs_click.makeExtension()], {})
+
+ source = dedent(
+ """
+ ::: mkdocs-click
+ :module: tests.app.cli
+ :command: cli
+ """
+ )
+
+ assert md.convert(source) == md.convert(EXPECTED_ENHANCED)