passageway-connector-mkdocs 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- passageway_connector_mkdocs/__init__.py +0 -0
- passageway_connector_mkdocs/__main__.py +3 -0
- passageway_connector_mkdocs/connector.py +146 -0
- passageway_connector_mkdocs-0.4.0.dist-info/METADATA +80 -0
- passageway_connector_mkdocs-0.4.0.dist-info/RECORD +6 -0
- passageway_connector_mkdocs-0.4.0.dist-info/WHEEL +4 -0
|
File without changes
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""passageway connector for websites built with MkDocs: one item per section of the site's search index."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from urllib.parse import urldefrag, urlparse
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
from passageway_sdk import Connector, SettingsError, SourceError
|
|
12
|
+
from passageway_sdk.html import clean, to_markdown
|
|
13
|
+
from passageway_sdk.settings import names, weight_of, weights, whole
|
|
14
|
+
from passageway_sdk.web import TooLarge, Web
|
|
15
|
+
|
|
16
|
+
INDEX = "search/search_index.json"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Section:
|
|
21
|
+
id: str # the address: the site, the page, and the heading's anchor if it has one
|
|
22
|
+
page_title: str
|
|
23
|
+
heading: str | None # None for the text before the page's first heading
|
|
24
|
+
text: str
|
|
25
|
+
where: str # the page's location below the site, for WEIGHTS and the breadcrumb
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _base(url: str) -> str:
|
|
29
|
+
"""The site an address names: the folder of a search index, else the address itself, ending in `/`."""
|
|
30
|
+
url = urldefrag(url)[0]
|
|
31
|
+
if url.endswith(INDEX):
|
|
32
|
+
return url.removesuffix(INDEX)
|
|
33
|
+
return url if url.endswith("/") else url + "/"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _intro(page_text: str, first: str | None) -> str:
|
|
37
|
+
"""The page entry of MkDocs' own search plugin holds the whole page; keep what comes before its first section."""
|
|
38
|
+
if first:
|
|
39
|
+
at = page_text.find(first)
|
|
40
|
+
if at > 0:
|
|
41
|
+
return page_text[:at]
|
|
42
|
+
return page_text
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def sections(base: str, docs: list[dict]) -> list[Section]:
|
|
46
|
+
"""The sections of a search index, in its order; entries without text are left out, a repeated address counts once."""
|
|
47
|
+
pages: dict[str, str] = {} # page location -> page title
|
|
48
|
+
firsts: dict[str, tuple[str, str]] = {} # page location -> (title, text) of its first section with text
|
|
49
|
+
texts = {d["location"]: to_markdown(d["text"]) for d in docs}
|
|
50
|
+
for d in docs:
|
|
51
|
+
page, _, anchor = d["location"].partition("#")
|
|
52
|
+
if not anchor:
|
|
53
|
+
pages.setdefault(page, clean(d["title"]))
|
|
54
|
+
elif texts[d["location"]]:
|
|
55
|
+
firsts.setdefault(page, (clean(d["title"]), texts[d["location"]]))
|
|
56
|
+
out, seen = [], set()
|
|
57
|
+
for d in docs:
|
|
58
|
+
location = d["location"]
|
|
59
|
+
if location in seen:
|
|
60
|
+
continue
|
|
61
|
+
seen.add(location)
|
|
62
|
+
page, _, anchor = location.partition("#")
|
|
63
|
+
text = texts[location]
|
|
64
|
+
if not anchor:
|
|
65
|
+
# the first section of this page, with its heading, starts where the intro ends
|
|
66
|
+
first_title, first = firsts.get(page, (None, None))
|
|
67
|
+
text = _intro(text, first)
|
|
68
|
+
if first_title and text.rstrip().endswith(first_title):
|
|
69
|
+
text = text.rstrip()[: -len(first_title)]
|
|
70
|
+
text = text.strip()
|
|
71
|
+
if text:
|
|
72
|
+
title = pages.get(page) or clean(d["title"]) or urlparse(base).netloc
|
|
73
|
+
out.append(Section(base + location, title, clean(d["title"]) if anchor else None, text, page))
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class MkDocs(Connector):
|
|
78
|
+
name = "mkdocs connector"
|
|
79
|
+
|
|
80
|
+
def __init__(self, env: Mapping[str, str], *, transport: httpx.BaseTransport | None = None,
|
|
81
|
+
sleep: Callable[[float], None] = time.sleep):
|
|
82
|
+
urls = names(env, "URLS")
|
|
83
|
+
if not urls:
|
|
84
|
+
raise SettingsError("URLS must name at least one site")
|
|
85
|
+
for url in urls:
|
|
86
|
+
if urlparse(url).scheme not in ("http", "https") or not urlparse(url).netloc:
|
|
87
|
+
raise SettingsError(f"URLS must be http(s) addresses, got {url!r}")
|
|
88
|
+
self.sites = list(dict.fromkeys(_base(u) for u in urls))
|
|
89
|
+
self.max_bytes = whole(env, "MAX_BYTES", 50_000_000)
|
|
90
|
+
self.weights = weights(env, "WEIGHTS")
|
|
91
|
+
self._web = Web("passageway-connector-mkdocs", max_bytes=self.max_bytes, transport=transport, sleep=sleep)
|
|
92
|
+
|
|
93
|
+
# -- the protocol ------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
def versions(self) -> dict[str, str]:
|
|
96
|
+
return {s.id: self._version(s) for site in self.sites for s in self._sections(site)}
|
|
97
|
+
|
|
98
|
+
def fetch(self, ids: list[str]) -> Iterator[dict]:
|
|
99
|
+
wanted = set(ids)
|
|
100
|
+
for site in self.sites:
|
|
101
|
+
if any(i.startswith(site) for i in wanted):
|
|
102
|
+
for section in self._sections(site):
|
|
103
|
+
if section.id in wanted:
|
|
104
|
+
yield self._item(section)
|
|
105
|
+
|
|
106
|
+
# -- the sites ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def _sections(self, site: str) -> list[Section]:
|
|
109
|
+
url = site + INDEX
|
|
110
|
+
try:
|
|
111
|
+
text = self._web.get(url, required=True).text
|
|
112
|
+
except TooLarge:
|
|
113
|
+
raise SourceError(f"{url}: larger than MAX_BYTES ({self.max_bytes})") from None
|
|
114
|
+
try:
|
|
115
|
+
docs = json.loads(text)["docs"]
|
|
116
|
+
docs = [{"location": str(d["location"]), "title": str(d.get("title") or ""), "text": str(d.get("text") or "")}
|
|
117
|
+
for d in docs]
|
|
118
|
+
except (ValueError, KeyError, TypeError):
|
|
119
|
+
raise SourceError(f"{url}: not an MkDocs search index") from None
|
|
120
|
+
return sections(site, docs)
|
|
121
|
+
|
|
122
|
+
def _weight(self, section: Section) -> float:
|
|
123
|
+
return weight_of(self.weights, section.where)
|
|
124
|
+
|
|
125
|
+
def _version(self, section: Section) -> str:
|
|
126
|
+
weight = self._weight(section) # a changed weight is a changed item, so the index follows it
|
|
127
|
+
body = f"{section.page_title}\0{section.heading}\0{section.text}" + (f"\0{weight}" if weight != 1.0 else "")
|
|
128
|
+
return hashlib.sha256(body.encode()).hexdigest()[:16]
|
|
129
|
+
|
|
130
|
+
def _item(self, section: Section) -> dict:
|
|
131
|
+
item = {
|
|
132
|
+
"id": section.id,
|
|
133
|
+
"title": section.page_title,
|
|
134
|
+
"url": section.id,
|
|
135
|
+
"version": self._version(section),
|
|
136
|
+
"sections": [{"heading": section.heading, "text": section.text}],
|
|
137
|
+
}
|
|
138
|
+
if folders := [p for p in section.where.split("/") if p]:
|
|
139
|
+
item["path"] = folders
|
|
140
|
+
if (weight := self._weight(section)) != 1.0:
|
|
141
|
+
item["weight"] = weight
|
|
142
|
+
return item
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def main() -> None:
|
|
146
|
+
MkDocs.main()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: passageway-connector-mkdocs
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: passageway connector for websites built with MkDocs, read from their search index
|
|
5
|
+
License-Expression: PolyForm-Noncommercial-1.0.0
|
|
6
|
+
Requires-Python: >=3.12
|
|
7
|
+
Requires-Dist: passageway-sdk[html,web]~=0.4.0
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# passageway-connector-mkdocs
|
|
11
|
+
|
|
12
|
+
A [passageway](https://github.com/getpassageway/passageway#readme) connector that indexes a website built with [MkDocs](https://www.mkdocs.org/) or [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/): someone else's documentation, when you have the site but not its repository. It reads the site's search index, the file its search box uses, so it needs no crawling, and every hit opens the page at its heading.
|
|
13
|
+
|
|
14
|
+
## What it does
|
|
15
|
+
|
|
16
|
+
- **Each section of the search index is one item.** A built MkDocs site publishes `search/search_index.json`, with an entry for every page and every heading in it. Each entry with text becomes an item whose address is the page at that heading (`https://docs.example.org/guide/setup/#linux`), so a hit lands on the passage, not at the top of a long page. The text before a page's first heading is an item of its own at the page's address.
|
|
17
|
+
- **The page title is the item's title, the heading is its section,** and the folders of the address are its breadcrumb (`path`), so a `path:` filter works.
|
|
18
|
+
- **The id is the address, the version a hash of the text,** so an edit re-indexes only the sections it touched.
|
|
19
|
+
- **A site address means its search index.** `https://docs.example.org` is read as `https://docs.example.org/search/search_index.json`; a site under a path (`https://user.github.io/project`) works the same, and the index's own address can be given too.
|
|
20
|
+
- **Both search plugins are understood.** Material for MkDocs writes HTML into the index, which becomes text with code blocks and lists kept. MkDocs' own search plugin writes plain text and repeats the whole page in the page's entry; only the part before the first heading is kept there, so a passage is not indexed twice.
|
|
21
|
+
- **Any failure fails the whole listing.** A site without a search index, an index that is not one, a server that is down: the sync stops with a message that names the address, so a half-read site never deletes what was indexed before.
|
|
22
|
+
|
|
23
|
+
When the site publishes an `llms-full.txt` (the `mkdocs-llmstxt` plugin writes one), the [llms.txt connector](https://getpassageway.com/connectors/llmstxt/) reads the markdown itself; when you have the repository, the [Markdown connector](https://getpassageway.com/connectors/markdown/) does. This connector is for the site as it is published.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
Add the connector to the environment that runs passageway (see [Install](https://github.com/getpassageway/passageway/blob/main/docs/install.md)):
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
uv tool install --python 3.14 "passageway[mkdocs]"
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## An example
|
|
34
|
+
|
|
35
|
+
A site `https://docs.example.org` whose `search/search_index.json` is:
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{"docs": [
|
|
39
|
+
{"location": "guide/setup/", "title": "Setup", "text": "<p>How to set it up.</p>"},
|
|
40
|
+
{"location": "guide/setup/#linux", "title": "Linux", "text": "<p>Use the <code>apt</code> package.</p>"}
|
|
41
|
+
]}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
gives, for the second entry, the item:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"id": "https://docs.example.org/guide/setup/#linux",
|
|
49
|
+
"title": "Setup",
|
|
50
|
+
"url": "https://docs.example.org/guide/setup/#linux",
|
|
51
|
+
"path": ["guide", "setup"],
|
|
52
|
+
"sections": [
|
|
53
|
+
{
|
|
54
|
+
"heading": "Linux",
|
|
55
|
+
"text": "Use the apt package."
|
|
56
|
+
}
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
and is configured as:
|
|
62
|
+
|
|
63
|
+
```toml
|
|
64
|
+
[[connector]]
|
|
65
|
+
name = "vendor-docs"
|
|
66
|
+
command = ["/path/to/python", "-m", "passageway_connector_mkdocs"]
|
|
67
|
+
env = { URLS = "https://docs.example.org|https://user.github.io/project", WEIGHTS = "changelog=0.5" }
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Settings
|
|
71
|
+
|
|
72
|
+
All settings are strings in the connector's `env`.
|
|
73
|
+
|
|
74
|
+
| Setting | Meaning | Default |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `URLS` | Required. Site addresses, or the addresses of their `search_index.json`, separated by `\|`. | |
|
|
77
|
+
| `MAX_BYTES` | The largest search index that is read, in bytes. A larger one fails the sync. | `50000000` |
|
|
78
|
+
| `WEIGHTS` | Lowers or raises the weight of the sections whose path starts with a prefix: `changelog=0.5,blog/=0.3` (comma-separated `prefix=weight`, the weight above 0, the first match wins). Release notes repeat the vocabulary of how-to pages and push them down the results; a weight below 1 ranks them lower. The path is the page's address without the site's own. | all sections 1.0 |
|
|
79
|
+
|
|
80
|
+
Requests carry the user agent `passageway-connector-mkdocs`, follow redirects and verify HTTPS against the operating system's trust store.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
passageway_connector_mkdocs/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
passageway_connector_mkdocs/__main__.py,sha256=0vK3RfxGFBCp9nAQUG9GYa1iL8jm87jQ_JRnfwNaVLg,63
|
|
3
|
+
passageway_connector_mkdocs/connector.py,sha256=CLdtcRHBpr5xazO6qN1mcZXp_qk_bKr8H62lWi0YGI0,6096
|
|
4
|
+
passageway_connector_mkdocs-0.4.0.dist-info/METADATA,sha256=wOQ8xoXravPXc1q3aut_n7JhcNwcFKi4Rob-1-KCAZ0,4605
|
|
5
|
+
passageway_connector_mkdocs-0.4.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
6
|
+
passageway_connector_mkdocs-0.4.0.dist-info/RECORD,,
|