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)