Skip to content

Commit a9f8c9a

Browse files
committed
feat: Implement manpage generation
1 parent a018851 commit a9f8c9a

File tree

10 files changed

+322
-4
lines changed

10 files changed

+322
-4
lines changed

README.md

+49
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88

99
MkDocs plugin to generate a manpage from the documentation site.
1010

11+
## Requirements
12+
13+
Pandoc must be [installed](https://pandoc.org/installing.html) and available as `pandoc`.
14+
1115
## Installation
1216

1317
With `pip`:
@@ -20,3 +24,48 @@ With [`pipx`](https://github.com/pipxproject/pipx):
2024
python3.7 -m pip install --user pipx
2125
pipx install mkdocs-manpage
2226
```
27+
28+
## Usage
29+
30+
```yaml
31+
# mkdocs.yml
32+
plugins:
33+
- manpage:
34+
enabled: !ENV [MANPAGE, false]
35+
pages:
36+
- index.md
37+
- usage.md
38+
- reference/api.md
39+
```
40+
41+
We also recommend disabling some options from other plugins/extensions
42+
to improve the final manual page:
43+
44+
- no source code through `mkdocstrings`:
45+
46+
```yaml
47+
- mkdocstrings:
48+
handlers:
49+
python:
50+
options:
51+
show_source: !ENV [SHOW_SOURCE, true]
52+
```
53+
54+
- no permalink through `toc`:
55+
56+
```yaml
57+
markdown_extensions:
58+
- toc:
59+
permalink: !ENV [PERMALINK, true]
60+
```
61+
62+
Then set these environment variables before building
63+
the documentation and generating the manpage:
64+
65+
```bash
66+
export MANPAGE=true
67+
export PERMALINK=false
68+
export SHOW_SOURCE=false
69+
mkdocs build
70+
# manpage is in site dir: ./site/manpage.1
71+
```

docs/insiders/changelog.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22

33
## MkDocs Manpage Insiders
44

5-
### 1.0.0 <small>April 22, 2023</small> { id="1.0.0" }
5+
### 1.0.0 <small>June 06, 2023</small> { id="1.0.0" }
66

77
- Release first Insiders version

docs/insiders/goals.yml

+7-1
Original file line numberDiff line numberDiff line change
@@ -1 +1,7 @@
1-
goals: {}
1+
goals:
2+
500:
3+
name: PlasmaVac User Guide
4+
features:
5+
- name: The project itself!
6+
ref: /
7+
since: 2023/06/06

duties.py

+15
Original file line numberDiff line numberDiff line change
@@ -300,3 +300,18 @@ def test(ctx: Context, match: str = "") -> None:
300300
pytest.run("-n", "auto", "tests", config_file="config/pytest.ini", select=match),
301301
title=pyprefix("Running tests"),
302302
)
303+
304+
305+
@duty(aliases=["man"])
306+
def manpage(ctx: Context) -> None:
307+
"""Run the test suite.
308+
309+
Parameters:
310+
ctx: The context instance (passed automatically).
311+
"""
312+
os.environ["MANPAGE"] = "true"
313+
os.environ["SHOW_SOURCE"] = "false"
314+
os.environ["PERMALINK"] = "false"
315+
os.environ["DEPLOY"] = "false"
316+
ctx.run(mkdocs.build(), title="Building docs and manpage")
317+
ctx.run("man ./site/manpage.1", capture=False)

mkdocs.yml

+14-2
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ markdown_extensions:
9292
- pymdownx.tasklist:
9393
custom_checkbox: true
9494
- toc:
95-
permalink: "¤"
95+
permalink: !ENV [PERMALINK, "¤"]
9696

9797
plugins:
9898
- search
@@ -113,10 +113,22 @@ plugins:
113113
merge_init_into_class: true
114114
docstring_options:
115115
ignore_init_summary: true
116+
show_source: !ENV [SHOW_SOURCE, true]
116117
- git-committers:
117118
enabled: !ENV [DEPLOY, false]
118119
repository: pawamoy/mkdocs-manpage
119-
120+
- manpage:
121+
enabled: !ENV [MANPAGE, false]
122+
pages:
123+
- index.md
124+
- changelog.md
125+
- credits.md
126+
- license.md
127+
- contributing.md
128+
- code_of_conduct.md
129+
- insiders/index.md
130+
- insiders/installation.md
131+
- insiders/changelog.md
120132
- minify:
121133
minify_html: !ENV [DEPLOY, false]
122134

pyproject.toml

+3
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ Discussions = "https://github.com/pawamoy/mkdocs-manpage/discussions"
3939
Gitter = "https://gitter.im/mkdocs-manpage/community"
4040
Funding = "https://github.com/sponsors/pawamoy"
4141

42+
[project.entry-points."mkdocs.plugins"]
43+
manpage = "mkdocs_manpage.plugin:MkdocsManpagePlugin"
44+
4245
[tool.pdm]
4346
version = {source = "scm"}
4447
plugins = [

src/mkdocs_manpage/config.py

+70
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Configuration options for the MkDocs Manpage plugin."""
2+
3+
from __future__ import annotations
4+
5+
import contextlib
6+
from typing import Any, Dict, Generic, TypeVar
7+
8+
from mkdocs.config import config_options as mkconf
9+
from mkdocs.config.base import Config
10+
from mkdocs.config.base import Config as BaseConfig
11+
from mkdocs.config.config_options import BaseConfigOption, LegacyConfig, ValidationError
12+
13+
T = TypeVar("T")
14+
15+
16+
# TODO: remove once https://github.com/mkdocs/mkdocs/pull/3242 is merged and released
17+
class DictOfItems(Generic[T], BaseConfigOption[Dict[str, T]]):
18+
"""Validates a dict of items. Keys are always strings.
19+
20+
E.g. for `config_options.DictOfItems(config_options.Type(int))` a valid item is `{"a": 1, "b": 2}`.
21+
"""
22+
23+
required: bool | None = None # Only for subclasses to set.
24+
25+
def __init__(self, option_type: BaseConfigOption[T], default: Any = None) -> None: # noqa: D107
26+
super().__init__()
27+
self.default = default
28+
self.option_type = option_type
29+
self.option_type.warnings = self.warnings
30+
31+
def __repr__(self) -> str:
32+
return f"{type(self).__name__}: {self.option_type}"
33+
34+
def pre_validation(self, config: Config, key_name: str) -> None: # noqa: D102
35+
self._config = config
36+
self._key_name = key_name
37+
38+
def run_validation(self, value: object) -> dict[str, T]: # noqa: D102
39+
if value is None:
40+
if self.required or self.default is None:
41+
raise ValidationError("Required configuration not provided.")
42+
value = self.default
43+
if not isinstance(value, dict):
44+
raise ValidationError(f"Expected a dict of items, but a {type(value)} was given.")
45+
if not value: # Optimization for empty list
46+
return value
47+
48+
fake_config = LegacyConfig(())
49+
with contextlib.suppress(AttributeError):
50+
fake_config.config_file_path = self._config.config_file_path
51+
52+
# Emulate a config-like environment for pre_validation and post_validation.
53+
fake_config.data = value
54+
55+
for key_name in fake_config:
56+
self.option_type.pre_validation(fake_config, key_name)
57+
for key_name in fake_config:
58+
# Specifically not running `validate` to avoid the OptionallyRequired effect.
59+
fake_config[key_name] = self.option_type.run_validation(fake_config[key_name])
60+
for key_name in fake_config:
61+
self.option_type.post_validation(fake_config, key_name)
62+
63+
return value
64+
65+
66+
class PluginConfig(BaseConfig):
67+
"""Configuration options for the plugin."""
68+
69+
enabled = mkconf.Type(bool, default=True)
70+
pages = mkconf.ListOfItems(mkconf.Type(str))

src/mkdocs_manpage/logger.py

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Logging functions."""
2+
3+
from __future__ import annotations
4+
5+
import logging
6+
from typing import Any, MutableMapping
7+
8+
9+
class PluginLogger(logging.LoggerAdapter):
10+
"""A logger adapter to prefix messages with the originating package name."""
11+
12+
def __init__(self, prefix: str, logger: logging.Logger):
13+
"""Initialize the object.
14+
15+
Arguments:
16+
prefix: The string to insert in front of every message.
17+
logger: The logger instance.
18+
"""
19+
super().__init__(logger, {})
20+
self.prefix = prefix
21+
22+
def process(self, msg: str, kwargs: MutableMapping[str, Any]) -> tuple[str, Any]:
23+
"""Process the message.
24+
25+
Arguments:
26+
msg: The message:
27+
kwargs: Remaining arguments.
28+
29+
Returns:
30+
The processed message.
31+
"""
32+
return f"{self.prefix}: {msg}", kwargs
33+
34+
35+
def get_logger(name: str) -> PluginLogger:
36+
"""Return a logger for plugins.
37+
38+
Arguments:
39+
name: The name to use with `logging.getLogger`.
40+
41+
Returns:
42+
A logger configured to work well in MkDocs,
43+
prefixing each message with the plugin package name.
44+
"""
45+
logger = logging.getLogger(f"mkdocs.plugins.{name}")
46+
return PluginLogger(name.split(".", 1)[0], logger)

src/mkdocs_manpage/plugin.py

+103
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""MkDocs plugin that generates a manpage at the end of the build."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import subprocess
7+
import tempfile
8+
from shutil import which
9+
from typing import TYPE_CHECKING
10+
11+
from mkdocs.plugins import BasePlugin
12+
13+
from mkdocs_manpage.config import PluginConfig
14+
from mkdocs_manpage.logger import get_logger
15+
16+
if TYPE_CHECKING:
17+
from typing import Any
18+
19+
from mkdocs.config.defaults import MkDocsConfig
20+
from mkdocs.structure.pages import Page
21+
22+
23+
logger = get_logger(__name__)
24+
25+
26+
def _log_pandoc_output(output: str) -> None:
27+
for line in output.split("\n"):
28+
if line.startswith("[INFO]"):
29+
logger.debug(f"pandoc: {line[7:]}")
30+
elif line.startswith("[WARNING]"):
31+
logger.warning(f"pandoc: {line[10:]}")
32+
else:
33+
logger.debug(f"pandoc: {line[8:]}")
34+
35+
36+
class MkdocsManpagePlugin(BasePlugin[PluginConfig]):
37+
"""The MkDocs plugin to generate manpages.
38+
39+
This plugin defines the following event hooks:
40+
41+
- `on_page_content`
42+
- `on_post_build`
43+
44+
Check the [Developing Plugins](https://www.mkdocs.org/user-guide/plugins/#developing-plugins) page of `mkdocs`
45+
for more information about its plugin system.
46+
"""
47+
48+
def __init__(self) -> None: # noqa: D107
49+
self.pages: dict[str, str] = {}
50+
51+
def on_page_content(self, html: str, *, page: Page, **kwargs: Any) -> str | None: # noqa: ARG002
52+
"""Record pages contents.
53+
54+
Hook for the [`on_page_content` event](https://www.mkdocs.org/user-guide/plugins/#on_page_content).
55+
In this hook we simply record the HTML of the pages into a dictionary whose keys are the pages' URIs.
56+
57+
Parameters:
58+
html: The page HTML.
59+
page: The page object.
60+
"""
61+
if not self.config.enabled:
62+
return None
63+
if page.file.src_uri in self.config.pages or not self.config.pages:
64+
logger.debug(f"Adding page {page.file.src_uri} to manpage")
65+
self.pages[page.file.src_uri] = html
66+
return html
67+
68+
def on_post_build(self, config: MkDocsConfig, **kwargs: Any) -> None: # noqa: ARG002
69+
"""Combine all recorded pages contents and convert it to a manual page with Pandoc.
70+
71+
Hook for the [`on_post_build` event](https://www.mkdocs.org/user-guide/plugins/#on_post_build).
72+
In this hook we concatenate all previously recorded HTML, and convert it to a manual page with Pandoc.
73+
74+
Parameters:
75+
config: MkDocs configuration.
76+
"""
77+
if not self.config.enabled:
78+
return
79+
pandoc = which("pandoc")
80+
if pandoc is None:
81+
logger.debug("Could not find pandoc executable, trying to call 'pandoc' directly")
82+
pandoc = "pandoc"
83+
pages = []
84+
if self.config.pages:
85+
for page in self.config.pages:
86+
try:
87+
pages.append(self.pages[page])
88+
except KeyError:
89+
logger.error(f"No page with path {page}") # noqa: TRY400
90+
else:
91+
pages = list(self.pages.values())
92+
combined = "\n\n".join(pages)
93+
output_file = os.path.join(config.site_dir, "manpage.1")
94+
with tempfile.NamedTemporaryFile("w", prefix="mkdocs_manpage_", suffix=".html") as temp_file:
95+
temp_file.write(combined)
96+
pandoc_process = subprocess.run(
97+
[pandoc, "--verbose", "--standalone", "--to", "man", temp_file.name, "-o", output_file], # noqa: S603
98+
stdout=subprocess.PIPE,
99+
stderr=subprocess.STDOUT,
100+
text=True,
101+
)
102+
_log_pandoc_output(pandoc_process.stdout)
103+
logger.info(f"Generated manpage at {output_file}")

tests/test_plugin.py

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""Tests for the plugin."""
2+
3+
import os
4+
5+
import pytest
6+
from duty.callables import mkdocs
7+
8+
9+
def test_plugin() -> None:
10+
"""Run the plugin."""
11+
os.environ["MANPAGE"] = "true"
12+
with pytest.raises(expected_exception=SystemExit) as exc:
13+
mkdocs.build()()
14+
assert exc.value.code == 0

0 commit comments

Comments
 (0)