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.
File without changes
@@ -0,0 +1,3 @@
1
+ from passageway_connector_mkdocs.connector import main
2
+
3
+ main()
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any