Skip to content

Commit

Permalink
Allow using full command paths in headers (#36)
Browse files Browse the repository at this point in the history
* Allow using full command paths in headers

* Reduce diff

* Format
  • Loading branch information
Florimond Manca committed Feb 19, 2021
1 parent 7b50e5d commit edf0b7f
Show file tree
Hide file tree
Showing 6 changed files with 175 additions and 12 deletions.
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<h1>`. If set to `1`, headers will start at `<h2>`, 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 `<h1>` 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
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ theme: readthedocs
docs_dir: example

markdown_extensions:
- attr_list
- mkdocs-click
61 changes: 53 additions & 8 deletions mkdocs_click/_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,47 @@
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)

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]:
Expand All @@ -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 ""


Expand Down
21 changes: 17 additions & 4 deletions mkdocs_click/_extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
Expand All @@ -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):
Expand All @@ -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)


Expand Down
63 changes: 63 additions & 0 deletions tests/app/expected-enhanced.md
Original file line number Diff line number Diff line change
@@ -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.
```
23 changes: 23 additions & 0 deletions tests/test_extension.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)

0 comments on commit edf0b7f

Please sign in to comment.