Skip to content

Commit 33f64b3

Browse files
feat: Support file descriptions
Issue-6: #6 PR-8: #8 Co-authored-by: Timothée Mazzucotelli <dev@pawamoy.fr>
1 parent 9ff4294 commit 33f64b3

File tree

5 files changed

+138
-25
lines changed

5 files changed

+138
-25
lines changed

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ plugins:
3131
markdown_description: Long description of my project.
3232
sections:
3333
Usage documentation:
34-
- file1.md
35-
- file2.md
34+
- file1.md: Description of file1
35+
- file2.md # Descriptions are optional.
3636
```
3737
3838
The resulting `/llms.txt` file will be available at the root of your documentation. With the previous example, it will be accessible at https://myproject.com/llms.txt and will contain the following:
@@ -46,7 +46,7 @@ Long description of my project.
4646
4747
## Usage documentation
4848
49-
- [File1 title](https://myproject.com/file1.md)
49+
- [File1 title](https://myproject.com/file1.md): Description of file1
5050
- [File2 title](https://myproject.com/file2.md)
5151
```
5252

@@ -59,7 +59,7 @@ plugins:
5959
- llmstxt:
6060
sections:
6161
Usage documentation:
62-
- index.md
62+
- index.md: Main documentation page
6363
- usage/*.md
6464
```
6565

src/mkdocs_llmstxt/_internal/config.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,12 @@ class _PluginConfig(BaseConfig):
1313
preprocess = mkconf.Optional(mkconf.File(exists=True))
1414
markdown_description = mkconf.Optional(mkconf.Type(str))
1515
full_output = mkconf.Optional(mkconf.Type(str))
16-
sections = mkconf.DictOfItems(mkconf.ListOfItems(mkconf.Type(str)))
16+
sections = mkconf.DictOfItems(
17+
# Each list item can either be:
18+
#
19+
# - a string representing the source file path (possibly with glob patterns)
20+
# - a mapping where the single key is the file path and the value is its description.
21+
#
22+
# We therefore accept both `str` and `dict` values.
23+
mkconf.ListOfItems(mkconf.Type((str, dict))),
24+
)

src/mkdocs_llmstxt/_internal/plugin.py

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ class _MDPageInfo(NamedTuple):
3636
path_md: Path
3737
md_url: str
3838
content: str
39+
description: str
3940

4041

4142
class MkdocsLLMsTxtPlugin(BasePlugin[_PluginConfig]):
@@ -56,13 +57,21 @@ class MkdocsLLMsTxtPlugin(BasePlugin[_PluginConfig]):
5657
md_pages: dict[str, list[_MDPageInfo]]
5758
"""Dictionary mapping section names to a list of page infos."""
5859

59-
def _expand_inputs(self, inputs: list[str], page_uris: list[str]) -> list[str]:
60-
expanded: list[str] = []
61-
for input_file in inputs:
60+
_sections: dict[str, dict[str, str]]
61+
62+
def _expand_inputs(self, inputs: list[str | dict[str, str]], page_uris: list[str]) -> dict[str, str]:
63+
expanded: dict[str, str] = {}
64+
for input_item in inputs:
65+
if isinstance(input_item, dict):
66+
input_file, description = next(iter(input_item.items()))
67+
else:
68+
input_file = input_item
69+
description = ""
6270
if "*" in input_file:
63-
expanded.extend(fnmatch.filter(page_uris, input_file))
71+
for match in fnmatch.filter(page_uris, input_file):
72+
expanded[match] = description
6473
else:
65-
expanded.append(input_file)
74+
expanded[input_file] = description
6675
return expanded
6776

6877
def on_config(self, config: MkDocsConfig) -> MkDocsConfig | None:
@@ -81,6 +90,7 @@ def on_config(self, config: MkDocsConfig) -> MkDocsConfig | None:
8190
if config.site_url is None:
8291
raise ValueError("'site_url' must be set in the MkDocs configuration to be used with the 'llmstxt' plugin")
8392
self.mkdocs_config = config
93+
8494
# A `defaultdict` could be used, but we need to retain the same order between `config.sections` and `md_pages`
8595
# (which wouldn't be guaranteed when filling `md_pages` in `on_page_content()`).
8696
self.md_pages = {section: [] for section in self.config.sections}
@@ -100,10 +110,10 @@ def on_files(self, files: Files, *, config: MkDocsConfig) -> Files | None: # no
100110
Modified collection or none.
101111
"""
102112
page_uris = list(files.src_uris)
103-
104-
for section_name, file_list in list(self.config.sections.items()):
105-
self.config.sections[section_name] = self._expand_inputs(file_list, page_uris=page_uris)
106-
113+
self._sections = {
114+
section_name: self._expand_inputs(file_list, page_uris=page_uris) # type: ignore[arg-type]
115+
for section_name, file_list in self.config.sections.items()
116+
}
107117
return files
108118

109119
def on_page_content(self, html: str, *, page: Page, **kwargs: Any) -> str | None: # noqa: ARG002
@@ -115,8 +125,9 @@ def on_page_content(self, html: str, *, page: Page, **kwargs: Any) -> str | None
115125
html: The rendered HTML.
116126
page: The page object.
117127
"""
118-
for section_name, file_list in self.config.sections.items():
119-
if page.file.src_uri in file_list:
128+
src_uri = page.file.src_uri
129+
for section_name, files in self._sections.items():
130+
if src_uri in files:
120131
path_md = Path(page.file.abs_dest_path).with_suffix(".md")
121132
page_md = _generate_page_markdown(
122133
html,
@@ -138,10 +149,11 @@ def on_page_content(self, html: str, *, page: Page, **kwargs: Any) -> str | None
138149

139150
self.md_pages[section_name].append(
140151
_MDPageInfo(
141-
title=page.title if page.title is not None else page.file.src_uri,
152+
title=page.title if page.title is not None else src_uri,
142153
path_md=path_md,
143154
md_url=md_url,
144155
content=page_md,
156+
description=files[src_uri],
145157
),
146158
)
147159

@@ -169,10 +181,10 @@ def on_post_build(self, *, config: MkDocsConfig, **kwargs: Any) -> None: # noqa
169181

170182
for section_name, file_list in self.md_pages.items():
171183
markdown += f"## {section_name}\n\n"
172-
for page_title, path_md, md_url, content in file_list:
184+
for page_title, path_md, md_url, content, desc in file_list:
173185
path_md.write_text(content, encoding="utf8")
174186
_logger.debug(f"Generated MD file to {path_md}")
175-
markdown += f"- [{page_title}]({md_url})\n"
187+
markdown += f"- [{page_title}]({md_url}){(': ' + desc) if desc else ''}\n"
176188
markdown += "\n"
177189

178190
output_file.write_text(markdown, encoding="utf8")

tests/conftest.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,48 @@
11
"""Configuration for the pytest test suite."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
from typing import TYPE_CHECKING
7+
8+
import pytest
9+
from mkdocs.config.defaults import MkDocsConfig
10+
11+
if TYPE_CHECKING:
12+
from mkdocs_llmstxt._internal.plugin import MkdocsLLMsTxtPlugin
13+
14+
15+
@pytest.fixture(name="mkdocs_conf")
16+
def fixture_mkdocs_conf(request: pytest.FixtureRequest, tmp_path: Path) -> MkDocsConfig:
17+
"""Yield a MkDocs configuration object."""
18+
while hasattr(request, "_parent_request") and hasattr(request._parent_request, "_parent_request"):
19+
request = request._parent_request
20+
params = getattr(request, "param", {})
21+
config = params.get("config", {})
22+
pages = params.get("pages", {})
23+
conf = MkDocsConfig()
24+
conf.load_dict(
25+
{
26+
"site_name": "Test Project",
27+
"site_url": "https://example.org/",
28+
"site_dir": str(tmp_path / "site"),
29+
"docs_dir": str(tmp_path / "docs"),
30+
**config,
31+
},
32+
)
33+
Path(conf.docs_dir).mkdir(exist_ok=True)
34+
for page, content in pages.items():
35+
page_file = Path(conf.docs_dir, page)
36+
page_file.parent.mkdir(exist_ok=True)
37+
page_file.write_text(content)
38+
assert conf.validate() == ([], [])
39+
if "toc" not in conf.markdown_extensions:
40+
# Guaranteed to be added by MkDocs.
41+
conf.markdown_extensions.insert(0, "toc")
42+
return conf
43+
44+
45+
@pytest.fixture(name="plugin")
46+
def fixture_plugin(mkdocs_conf: MkDocsConfig) -> MkdocsLLMsTxtPlugin:
47+
"""Return a plugin instance."""
48+
return mkdocs_conf.plugins["llmstxt"] # type: ignore[return-value]

tests/test_plugin.py

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,57 @@
11
"""Tests for the plugin."""
22

3+
from pathlib import Path
4+
35
import pytest
4-
from duty.tools import mkdocs
6+
from mkdocs.commands.build import build
7+
from mkdocs.config.defaults import MkDocsConfig
8+
9+
10+
@pytest.mark.parametrize(
11+
"mkdocs_conf",
12+
[
13+
{
14+
"config": {
15+
"plugins": [
16+
{
17+
"llmstxt": {
18+
"full_output": "llms-full.txt",
19+
"sections": {
20+
"Index": ["index.md"],
21+
"Usage": [{"page1.md": "Some usage docs."}],
22+
},
23+
},
24+
},
25+
],
26+
},
27+
"pages": {
28+
"index.md": "# Hello world",
29+
"page1.md": "# Usage\n\nSome paragraph.",
30+
},
31+
},
32+
],
33+
indirect=["mkdocs_conf"],
34+
)
35+
def test_plugin(mkdocs_conf: MkDocsConfig) -> None:
36+
"""Test that page descriptions are correctly handled and included in output."""
37+
build(config=mkdocs_conf)
38+
39+
llmstxt = Path(mkdocs_conf.site_dir, "llms.txt")
40+
assert llmstxt.exists()
41+
llmstxt_content = llmstxt.read_text()
42+
assert "Some usage docs." in llmstxt_content
43+
assert "Some paragraph." not in llmstxt_content
44+
45+
llmsfulltxt = Path(mkdocs_conf.site_dir, "llms-full.txt")
46+
assert llmsfulltxt.exists()
47+
llmsfulltxt_content = llmsfulltxt.read_text()
48+
assert "Some usage docs." not in llmsfulltxt_content
49+
assert "Some paragraph." in llmsfulltxt_content
550

51+
indexmd = Path(mkdocs_conf.site_dir, "index.md")
52+
assert indexmd.exists()
53+
assert "Hello world" in indexmd.read_text()
654

7-
def test_plugin() -> None:
8-
"""Run the plugin."""
9-
with pytest.raises(expected_exception=SystemExit) as exc:
10-
mkdocs.build()()
11-
assert exc.value.code == 0
55+
page1md = Path(mkdocs_conf.site_dir, "page1/index.md")
56+
assert page1md.exists()
57+
assert "Some paragraph." in page1md.read_text()

0 commit comments

Comments
 (0)