Skip to content

Commit b45a81e

Browse files
committed
feat: Support multiple outputs, glob pattern for inputs, custom title and header
Breaking changes: configuration format in mkdocs.yml and public API changed
1 parent 0d7aecd commit b45a81e

File tree

7 files changed

+139
-72
lines changed

7 files changed

+139
-72
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
/.pdm-build/
1616
/htmlcov/
1717
/site/
18+
/share/
1819

1920
# cache
2021
.cache/

README.md

+11-7
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,16 @@ pipx install mkdocs-manpage[preprocess]
3434
plugins:
3535
- manpage:
3636
pages:
37-
- index.md
38-
- usage.md
39-
- reference/api.md
37+
- title: My Project # defaults to site name
38+
output: share/man/man1/my-project.1
39+
inputs:
40+
- index.md
41+
- usage.md
42+
- title: my-project API
43+
header: Python Library APIs # defaults to common header for section 3 (see `man man`)
44+
output: share/man/man3/my_project.3
45+
inputs:
46+
- reference/my_project/*.md
4047
```
4148
4249
To enable/disable the plugin with an environment variable:
@@ -54,9 +61,6 @@ Then set the environment variable and run MkDocs:
5461
MANPAGE=true mkdocs build
5562
```
5663

57-
The manpage will be written into the root of the site directory
58-
and named `manpage.1`.
59-
6064
### Pre-processing HTML
6165

6266
This plugin works by concatenating the HTML from all selected pages
@@ -94,7 +98,7 @@ def to_remove(tag: Tag) -> bool:
9498
return False
9599

96100

97-
def preprocess(soup: BeautifulSoup) -> None:
101+
def preprocess(soup: BeautifulSoup, output: str) -> None:
98102
for element in soup.find_all(to_remove):
99103
element.decompose()
100104
```

mkdocs.yml

+14-9
Original file line numberDiff line numberDiff line change
@@ -141,15 +141,20 @@ plugins:
141141
- manpage:
142142
preprocess: scripts/preprocess.py
143143
pages:
144-
- index.md
145-
- changelog.md
146-
- credits.md
147-
- license.md
148-
- contributing.md
149-
- code_of_conduct.md
150-
- insiders/index.md
151-
- insiders/installation.md
152-
- insiders/changelog.md
144+
- title: MkDocs Manpage
145+
header: MkDocs plugins
146+
output: share/man/man1/mkdocs-manpage.1
147+
inputs:
148+
- index.md
149+
- changelog.md
150+
- contributing.md
151+
- credits.md
152+
- license.md
153+
- title: mkdocs-manpage API
154+
header: Python Library APIs
155+
output: share/man/man3/mkdocs_manpage.3
156+
inputs:
157+
- reference/mkdocs_manpage/*.md
153158
- minify:
154159
minify_html: !ENV [DEPLOY, false]
155160
- group:

scripts/preprocess.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,12 @@ def to_remove(tag: Tag) -> bool:
2727
return False
2828

2929

30-
def preprocess(soup: Soup) -> None:
30+
def preprocess(soup: Soup, output: str) -> None: # noqa: ARG001
3131
"""Pre-process the soup by removing elements.
3232
3333
Parameters:
3434
soup: The soup to modify.
35+
output: The manpage output path.
3536
"""
3637
for element in soup.find_all(to_remove):
3738
element.decompose()

src/mkdocs_manpage/config.py

+10-1
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,18 @@
66
from mkdocs.config.base import Config as BaseConfig
77

88

9+
class PageConfig(BaseConfig):
10+
"""Sub-config for each manual page."""
11+
12+
title = mkconf.Type(str)
13+
header = mkconf.Type(str)
14+
output = mkconf.File(exists=False)
15+
inputs = mkconf.ListOfItems(mkconf.Type(str))
16+
17+
918
class PluginConfig(BaseConfig):
1019
"""Configuration options for the plugin."""
1120

1221
enabled = mkconf.Type(bool, default=True)
13-
pages = mkconf.ListOfItems(mkconf.Type(str))
1422
preprocess = mkconf.File(exists=True)
23+
pages = mkconf.ListOfItems(mkconf.SubConfig(PageConfig))

src/mkdocs_manpage/plugin.py

+98-52
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@
22

33
from __future__ import annotations
44

5+
import fnmatch
56
import subprocess
67
import tempfile
8+
from collections import defaultdict
79
from datetime import date
810
from importlib import metadata
911
from pathlib import Path
1012
from shutil import which
1113
from typing import TYPE_CHECKING
1214

15+
from mkdocs.config.defaults import MkDocsConfig
16+
from mkdocs.exceptions import PluginError
1317
from mkdocs.plugins import BasePlugin
1418

1519
from mkdocs_manpage.config import PluginConfig
@@ -20,6 +24,7 @@
2024
from typing import Any
2125

2226
from mkdocs.config.defaults import MkDocsConfig
27+
from mkdocs.structure.files import Files
2328
from mkdocs.structure.pages import Page
2429

2530

@@ -32,6 +37,19 @@ def _log_pandoc_output(output: str) -> None:
3237
logger.debug(f"pandoc: {line.strip()}")
3338

3439

40+
section_headers = {
41+
"1": "User Commands",
42+
"2": "System Calls Manual",
43+
"3": "Library Functions Manual",
44+
"4": "Kernel Interfaces Manual",
45+
"5": "File Formats Manual",
46+
"6": "Games Manual",
47+
"7": "Miscellaneous Information Manual",
48+
"8": "System Administration",
49+
"9": "Kernel Routines",
50+
}
51+
52+
3553
class MkdocsManpagePlugin(BasePlugin[PluginConfig]):
3654
"""The MkDocs plugin to generate manpages.
3755
@@ -47,7 +65,16 @@ class MkdocsManpagePlugin(BasePlugin[PluginConfig]):
4765
mkdocs_config: MkDocsConfig
4866

4967
def __init__(self) -> None: # noqa: D107
50-
self.pages: dict[str, str] = {}
68+
self.html_pages: dict[str, dict[str, str]] = defaultdict(dict)
69+
70+
def _expand_inputs(self, inputs: list[str], page_uris: list[str]) -> list[str]:
71+
expanded: list[str] = []
72+
for input_file in inputs:
73+
if "*" in input_file:
74+
expanded.extend(fnmatch.filter(page_uris, input_file))
75+
else:
76+
expanded.append(input_file)
77+
return expanded
5178

5279
def on_config(self, config: MkDocsConfig) -> MkDocsConfig | None:
5380
"""Save the global MkDocs configuration.
@@ -65,6 +92,23 @@ def on_config(self, config: MkDocsConfig) -> MkDocsConfig | None:
6592
self.mkdocs_config = config
6693
return config
6794

95+
def on_files(self, files: Files, *, config: MkDocsConfig) -> Files | None: # noqa: ARG002
96+
"""Expand inputs for manual pages.
97+
98+
Hook for the [`on_files` event](https://www.mkdocs.org/user-guide/plugins/#on_files).
99+
In this hook we expand inputs for each manual pages (glob patterns using `*`).
100+
101+
Parameters:
102+
files: The collection of MkDocs files.
103+
config: The MkDocs configuration.
104+
105+
Returns:
106+
Modified collection or none.
107+
"""
108+
for manpage in self.config.pages:
109+
manpage["inputs"] = self._expand_inputs(manpage["inputs"], page_uris=list(files.src_uris.keys()))
110+
return files
111+
68112
def on_page_content(self, html: str, *, page: Page, **kwargs: Any) -> str | None: # noqa: ARG002
69113
"""Record pages contents.
70114
@@ -77,9 +121,10 @@ def on_page_content(self, html: str, *, page: Page, **kwargs: Any) -> str | None
77121
"""
78122
if not self.config.enabled:
79123
return None
80-
if page.file.src_uri in self.config.pages or not self.config.pages:
81-
logger.debug(f"Adding page {page.file.src_uri} to manpage")
82-
self.pages[page.file.src_uri] = html
124+
for manpage in self.config.pages:
125+
if page.file.src_uri in manpage["inputs"]:
126+
logger.debug(f"Adding page {page.file.src_uri} to manpage {manpage['output']}")
127+
self.html_pages[manpage["output"]][page.file.src_uri] = html
83128
return html
84129

85130
def on_post_build(self, config: MkDocsConfig, **kwargs: Any) -> None: # noqa: ARG002
@@ -97,51 +142,52 @@ def on_post_build(self, config: MkDocsConfig, **kwargs: Any) -> None: # noqa: A
97142
if pandoc is None:
98143
logger.debug("Could not find pandoc executable, trying to call 'pandoc' directly")
99144
pandoc = "pandoc"
100-
pages = []
101-
if self.config.pages:
102-
for page in self.config.pages:
103-
try:
104-
pages.append(self.pages[page])
105-
except KeyError:
106-
logger.error(f"No page with path {page}") # noqa: TRY400
107-
else:
108-
pages = list(self.pages.values())
109-
html = "\n\n".join(pages)
110-
111-
if self.config.preprocess:
112-
html = preprocess(html, self.config.preprocess)
113-
114-
output_file = Path(config.site_dir, "manpage.1")
115-
with tempfile.NamedTemporaryFile("w", prefix="mkdocs_manpage_", suffix=".1.html") as temp_file:
116-
temp_file.write(html)
117-
pandoc_variables = [
118-
f"title:{self.mkdocs_config.site_name}",
119-
"section:1",
120-
f"date:{date.today().strftime('%Y-%m-%d')}", # noqa: DTZ011
121-
f"footer:mkdocs-manpage v{metadata.version('mkdocs-manpage')}",
122-
"header:User Commands",
123-
]
124-
pandoc_options = [
125-
"--verbose",
126-
"--standalone",
127-
"--wrap=none",
128-
]
129-
pandoc_command = [
130-
pandoc,
131-
*pandoc_options,
132-
*[f"-V{var}" for var in pandoc_variables],
133-
"--to",
134-
"man",
135-
temp_file.name,
136-
"-o",
137-
str(output_file),
138-
]
139-
pandoc_process = subprocess.run(
140-
pandoc_command, # noqa: S603
141-
stdout=subprocess.PIPE,
142-
stderr=subprocess.STDOUT,
143-
text=True,
144-
check=False,
145-
)
146-
_log_pandoc_output(pandoc_process.stdout)
147-
logger.info(f"Generated manpage at {output_file}")
145+
146+
for page in self.config.pages:
147+
try:
148+
html = "\n\n".join(self.html_pages[page["output"]][input_page] for input_page in page["inputs"])
149+
except KeyError as error:
150+
raise PluginError(str(error)) from error
151+
152+
if self.config.get("preprocess"):
153+
html = preprocess(html, self.config["preprocess"], page["output"])
154+
155+
output_file = Path(config.config_file_path).parent.joinpath(page["output"])
156+
output_file.parent.mkdir(parents=True, exist_ok=True)
157+
section = output_file.suffix[1:]
158+
section_header = page.get("header", section_headers.get(section, section_headers["1"]))
159+
title = page.get("title", self.mkdocs_config.site_name)
160+
161+
with tempfile.NamedTemporaryFile("w", prefix="mkdocs_manpage_", suffix=".1.html") as temp_file:
162+
temp_file.write(html)
163+
pandoc_variables = [
164+
f"title:{title}",
165+
f"section:{section}",
166+
f"date:{date.today().strftime('%Y-%m-%d')}", # noqa: DTZ011
167+
f"footer:mkdocs-manpage v{metadata.version('mkdocs-manpage')}",
168+
f"header:{section_header}",
169+
]
170+
pandoc_options = [
171+
"--verbose",
172+
"--standalone",
173+
"--wrap=none",
174+
]
175+
pandoc_command = [
176+
pandoc,
177+
*pandoc_options,
178+
*[f"-V{var}" for var in pandoc_variables],
179+
"--to",
180+
"man",
181+
temp_file.name,
182+
"-o",
183+
str(output_file),
184+
]
185+
pandoc_process = subprocess.run(
186+
pandoc_command, # noqa: S603
187+
stdout=subprocess.PIPE,
188+
stderr=subprocess.STDOUT,
189+
text=True,
190+
check=False,
191+
)
192+
_log_pandoc_output(pandoc_process.stdout)
193+
logger.info(f"Generated manpage {output_file}")

src/mkdocs_manpage/preprocess.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,15 @@ def _load_module(module_path: str) -> ModuleType:
2424
raise RuntimeError("Spec or loader is null")
2525

2626

27-
def preprocess(html: str, module_path: str) -> str:
27+
def preprocess(html: str, module_path: str, output: str) -> str:
2828
"""Pre-process HTML with user-defined functions.
2929
3030
Parameters:
3131
html: The HTML to process before conversion to a manpage.
3232
module_path: The path of a Python module containing a `preprocess` function.
3333
The function must accept one and only one argument called `soup`.
3434
The `soup` argument is an instance of [`bs4.BeautifulSoup`][].
35+
output: The output path of the relevant manual page.
3536
3637
Returns:
3738
The processed HTML.
@@ -49,7 +50,7 @@ def preprocess(html: str, module_path: str) -> str:
4950
raise PluginError(f"Could not load module: {error}") from error
5051
soup = BeautifulSoup(html, "lxml")
5152
try:
52-
module.preprocess(soup)
53+
module.preprocess(soup, output)
5354
except Exception as error: # noqa: BLE001
5455
raise PluginError(f"Could not pre-process HTML: {error}") from error
5556
return str(soup)

0 commit comments

Comments
 (0)