mkdocs-rangefind 0.1.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.
@@ -0,0 +1,3 @@
1
+ """MkDocs plugin for Rangefind static search."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,305 @@
1
+ """Rangefind MkDocs plugin.
2
+
3
+ MkDocs is a Python static-site generator; Rangefind is a Node.js tool. There is
4
+ no way to run Rangefind's indexing logic from pure Python, so this plugin does
5
+ the honest thing: after MkDocs finishes building the site it *shells out* to the
6
+ Rangefind Node CLI (``npx rangefind build <site_dir>``) to crawl the freshly
7
+ rendered HTML into a static, range-request search index, then copies the
8
+ Rangefind search Web Component assets into the site and injects the widget tags
9
+ into every rendered page.
10
+
11
+ Prerequisite: Node.js and the ``rangefind`` npm package must be reachable from
12
+ the MkDocs project directory (typically ``npm install rangefind`` so that
13
+ ``npx rangefind`` and Node's module resolution both find it).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import shutil
20
+ import subprocess
21
+ from urllib.parse import urlsplit
22
+
23
+ from mkdocs.config import config_options
24
+ from mkdocs.exceptions import PluginError
25
+ from mkdocs.plugins import BasePlugin
26
+ from mkdocs.utils import get_relative_url
27
+
28
+ try: # MkDocs >= 1.2 ships a namespaced logger helper.
29
+ from mkdocs.plugins import get_plugin_logger
30
+
31
+ log = get_plugin_logger(__name__)
32
+ except ImportError: # pragma: no cover - very old MkDocs
33
+ import logging
34
+
35
+ log = logging.getLogger(f"mkdocs.plugins.{__name__}")
36
+
37
+
38
+ class RangefindPlugin(BasePlugin):
39
+ """Build a Rangefind index after the site is built and inject the widget."""
40
+
41
+ # MkDocs uses the classic tuple-based config scheme (name, option) pairs.
42
+ config_scheme = (
43
+ # Master switch. When false the plugin is a complete no-op.
44
+ ("enabled", config_options.Type(bool, default=True)),
45
+ # URL prefix baked into the *result links* stored in the index
46
+ # (passed to the CLI as --base-url). Empty string => derive from the
47
+ # MkDocs `site_url` path (falling back to "/"). Set explicitly when the
48
+ # index's stored URLs need a specific prefix.
49
+ ("base_url", config_options.Type(str, default="")),
50
+ # Index output directory, relative to the built site_dir.
51
+ ("output_dir", config_options.Type(str, default="rangefind")),
52
+ # Where the Web Component JS/CSS assets are copied, relative to site_dir.
53
+ ("assets_dir", config_options.Type(str, default="_rangefind")),
54
+ # Include the optional framework-free theme stylesheet. Off by default:
55
+ # the component is headless and inherits the host page's CSS.
56
+ ("theme", config_options.Type(bool, default=False)),
57
+ # Command used to run the Rangefind CLI. "npx" resolves the local
58
+ # `rangefind` install; can be pointed at a specific binary in CI.
59
+ ("node_command", config_options.Type(str, default="npx")),
60
+ # Where to place the <rangefind-search> element:
61
+ # "body_end" (default) - injected right before </body> on every page.
62
+ # "manual" - not injected; you hand-place it in a theme
63
+ # override. Script/CSS are still injected.
64
+ ("placement", config_options.Choice(("body_end", "manual"), default="body_end")),
65
+ # Optional literal HTML marker. When set (and placement != manual), the
66
+ # <rangefind-search> element is inserted immediately after the first
67
+ # occurrence of this exact substring instead of before </body> (e.g.
68
+ # '<div id="rangefind">'). Falls back to body_end if not found.
69
+ ("selector", config_options.Type(str, default="")),
70
+ # Input placeholder text for the injected element.
71
+ ("placeholder", config_options.Type(str, default="Search")),
72
+ # Arbitrary extra attributes for the injected <rangefind-search> element
73
+ # (e.g. {hotkey: true, router: true, input-class: "w-full"}).
74
+ ("element_attributes", config_options.Type(dict, default={})),
75
+ # Explicit paths to the component assets. When unset the plugin asks
76
+ # Node to resolve them from the installed `rangefind` package.
77
+ ("element_js_path", config_options.Type(str, default="")),
78
+ ("element_css_path", config_options.Type(str, default="")),
79
+ )
80
+
81
+ # ---- lifecycle hooks -------------------------------------------------
82
+
83
+ def on_post_build(self, config, **kwargs):
84
+ """Real MkDocs post-build hook: crawl the built site and copy assets."""
85
+ if not self.config["enabled"]:
86
+ return
87
+
88
+ site_dir = config["site_dir"]
89
+ project_dir = _project_dir(config)
90
+ output_abs = os.path.join(site_dir, self.config["output_dir"])
91
+ base_url = self._resolve_base_url(config)
92
+
93
+ # 1. Build the index by shelling out to the Node CLI. Real flags,
94
+ # confirmed against bin/rangefind.js:
95
+ # rangefind build <dir> --output <dir> --base-url <url>
96
+ command = [
97
+ self.config["node_command"],
98
+ "rangefind",
99
+ "build",
100
+ site_dir,
101
+ "--output",
102
+ output_abs,
103
+ "--base-url",
104
+ base_url,
105
+ ]
106
+ log.info("Building Rangefind index: %s", " ".join(command))
107
+ try:
108
+ result = subprocess.run(
109
+ command,
110
+ cwd=project_dir,
111
+ capture_output=True,
112
+ text=True,
113
+ check=False,
114
+ )
115
+ except FileNotFoundError as exc:
116
+ raise PluginError(
117
+ f"mkdocs-rangefind: could not run '{self.config['node_command']}'. "
118
+ "Node.js and the `rangefind` npm package must be installed and on "
119
+ f"PATH. Original error: {exc}"
120
+ )
121
+ if result.returncode != 0:
122
+ raise PluginError(
123
+ "mkdocs-rangefind: `rangefind build` failed "
124
+ f"(exit code {result.returncode}).\n"
125
+ f"command: {' '.join(command)}\n"
126
+ f"stdout:\n{result.stdout}\n"
127
+ f"stderr:\n{result.stderr}"
128
+ )
129
+ if result.stdout.strip():
130
+ log.info(result.stdout.strip())
131
+
132
+ # 2. Copy the Web Component assets into the site.
133
+ js_src, css_src = self._resolve_assets(project_dir)
134
+ assets_abs = os.path.join(site_dir, self.config["assets_dir"])
135
+ os.makedirs(assets_abs, exist_ok=True)
136
+ shutil.copyfile(js_src, os.path.join(assets_abs, "rangefind-search.js"))
137
+ if self.config["theme"]:
138
+ shutil.copyfile(css_src, os.path.join(assets_abs, "rangefind-search.css"))
139
+ log.info("Copied Rangefind widget assets to %s", assets_abs)
140
+
141
+ def on_post_page(self, output, page, config, **kwargs):
142
+ """Real MkDocs post-page hook: inject the widget into the page HTML."""
143
+ if not self.config["enabled"]:
144
+ return output
145
+
146
+ assets_dir = self.config["assets_dir"].strip("/")
147
+ output_dir = self.config["output_dir"].strip("/")
148
+ page_url = page.url
149
+
150
+ js_href = get_relative_url(f"{assets_dir}/rangefind-search.js", page_url)
151
+ index_src = get_relative_url(f"{output_dir}/", page_url)
152
+
153
+ head_injection = f'<script type="module" src="{_attr(js_href)}"></script>'
154
+ if self.config["theme"]:
155
+ css_href = get_relative_url(f"{assets_dir}/rangefind-search.css", page_url)
156
+ head_injection = (
157
+ f'<link rel="stylesheet" href="{_attr(css_href)}">\n' + head_injection
158
+ )
159
+ output = _inject_head(output, head_injection)
160
+
161
+ if self.config["placement"] != "manual":
162
+ element = self._render_element(index_src)
163
+ output = self._inject_element(output, element)
164
+
165
+ return output
166
+
167
+ # ---- helpers ---------------------------------------------------------
168
+
169
+ def _resolve_base_url(self, config):
170
+ configured = self.config["base_url"].strip()
171
+ if configured:
172
+ return configured
173
+ site_url = config.get("site_url")
174
+ if site_url:
175
+ path = urlsplit(site_url).path or "/"
176
+ return path if path.endswith("/") else path + "/"
177
+ return "/"
178
+
179
+ def _render_element(self, index_src):
180
+ attrs = {"src": index_src, "placeholder": self.config["placeholder"]}
181
+ for key, value in (self.config["element_attributes"] or {}).items():
182
+ if value is True:
183
+ attrs[str(key)] = "" # bare boolean attribute
184
+ elif value is False or value is None:
185
+ continue
186
+ else:
187
+ attrs[str(key)] = str(value)
188
+ rendered = []
189
+ for key, value in attrs.items():
190
+ rendered.append(key if value == "" else f'{key}="{_attr(value)}"')
191
+ return f"<rangefind-search {' '.join(rendered)}></rangefind-search>"
192
+
193
+ def _inject_element(self, output, element):
194
+ selector = self.config["selector"]
195
+ if selector:
196
+ idx = output.find(selector)
197
+ if idx != -1:
198
+ cut = idx + len(selector)
199
+ return output[:cut] + "\n" + element + output[cut:]
200
+ log.warning(
201
+ "mkdocs-rangefind: selector %r not found on a page; "
202
+ "falling back to placement before </body>.",
203
+ selector,
204
+ )
205
+ return _inject_body_end(output, element)
206
+
207
+ def _resolve_assets(self, project_dir):
208
+ """Locate the component's JS/CSS.
209
+
210
+ Preference order:
211
+ 1. Explicit `element_js_path` / `element_css_path` config values.
212
+ 2. Ask Node to resolve them via the `rangefind` package export map
213
+ (`rangefind/element` and `rangefind/element.css`). The export map
214
+ is the robust seam here: `require.resolve('rangefind/package.json')`
215
+ is blocked by the package's `exports`, but the declared subpath
216
+ exports point straight at dist/rangefind-search.{js,css}.
217
+ """
218
+ js_cfg = self.config["element_js_path"].strip()
219
+ css_cfg = self.config["element_css_path"].strip()
220
+ if js_cfg and css_cfg:
221
+ return _require_file(js_cfg, project_dir), _require_file(css_cfg, project_dir)
222
+
223
+ node_bin = _node_binary(self.config["node_command"])
224
+ js = js_cfg or self._node_resolve(node_bin, "rangefind/element", project_dir)
225
+ css = css_cfg or self._node_resolve(node_bin, "rangefind/element.css", project_dir)
226
+ return _require_file(js, project_dir), _require_file(css, project_dir)
227
+
228
+ def _node_resolve(self, node_bin, specifier, project_dir):
229
+ code = f"process.stdout.write(require.resolve({specifier!r}))"
230
+ try:
231
+ result = subprocess.run(
232
+ [node_bin, "-e", code],
233
+ cwd=project_dir,
234
+ capture_output=True,
235
+ text=True,
236
+ check=False,
237
+ )
238
+ except FileNotFoundError as exc:
239
+ raise PluginError(
240
+ f"mkdocs-rangefind: could not run Node ('{node_bin}') to resolve "
241
+ f"the '{specifier}' asset. Install Node.js, or set "
242
+ "`element_js_path`/`element_css_path` explicitly in mkdocs.yml. "
243
+ f"Original error: {exc}"
244
+ )
245
+ path = result.stdout.strip()
246
+ if result.returncode != 0 or not path:
247
+ raise PluginError(
248
+ f"mkdocs-rangefind: could not resolve '{specifier}' from the "
249
+ f"`rangefind` npm package (run from {project_dir}). Make sure "
250
+ "`rangefind` is installed there (e.g. `npm install rangefind`), "
251
+ "or set `element_js_path`/`element_css_path` in mkdocs.yml.\n"
252
+ f"stderr:\n{result.stderr}"
253
+ )
254
+ return path
255
+
256
+
257
+ # ---- module-level pure helpers ------------------------------------------
258
+
259
+
260
+ def _project_dir(config):
261
+ config_file = config.get("config_file_path")
262
+ if config_file:
263
+ return os.path.dirname(os.path.abspath(config_file))
264
+ return os.getcwd()
265
+
266
+
267
+ def _node_binary(node_command):
268
+ """Derive a plain Node binary from the configured command.
269
+
270
+ The index is built with `node_command` (default "npx"), but resolving the
271
+ asset paths needs Node itself. If the command is npx, use the sibling
272
+ `node`; otherwise assume the command can already run `-e`.
273
+ """
274
+ base = os.path.basename(node_command)
275
+ if base in ("npx", "npx.cmd", "npx.exe"):
276
+ directory = os.path.dirname(node_command)
277
+ return os.path.join(directory, "node") if directory else "node"
278
+ return node_command
279
+
280
+
281
+ def _require_file(path, project_dir):
282
+ resolved = path if os.path.isabs(path) else os.path.join(project_dir, path)
283
+ if not os.path.isfile(resolved):
284
+ raise PluginError(f"mkdocs-rangefind: asset file not found: {resolved}")
285
+ return resolved
286
+
287
+
288
+ def _attr(value):
289
+ return str(value).replace("&", "&amp;").replace('"', "&quot;")
290
+
291
+
292
+ def _inject_head(output, snippet):
293
+ lower = output.lower()
294
+ idx = lower.rfind("</head>")
295
+ if idx != -1:
296
+ return output[:idx] + snippet + "\n" + output[idx:]
297
+ return _inject_body_end(output, snippet)
298
+
299
+
300
+ def _inject_body_end(output, snippet):
301
+ lower = output.lower()
302
+ idx = lower.rfind("</body>")
303
+ if idx != -1:
304
+ return output[:idx] + snippet + "\n" + output[idx:]
305
+ return output + "\n" + snippet
@@ -0,0 +1,166 @@
1
+ Metadata-Version: 2.4
2
+ Name: mkdocs-rangefind
3
+ Version: 0.1.0
4
+ Summary: MkDocs plugin that builds a Rangefind static search index and injects the search widget after every build.
5
+ Project-URL: Homepage, https://github.com/xjodoin/rangefind
6
+ Project-URL: Repository, https://github.com/xjodoin/rangefind
7
+ Project-URL: Issues, https://github.com/xjodoin/rangefind/issues
8
+ Author-email: Xavier Jodoin <xavier@jodoin.me>
9
+ License: MIT
10
+ Keywords: mkdocs,mkdocs-plugin,rangefind,search,static-search
11
+ Classifier: Framework :: MkDocs
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Documentation
15
+ Classifier: Topic :: Text Processing :: Indexing
16
+ Requires-Python: >=3.8
17
+ Requires-Dist: mkdocs>=1.4
18
+ Description-Content-Type: text/markdown
19
+
20
+ # mkdocs-rangefind
21
+
22
+ An MkDocs plugin that wires [Rangefind](https://github.com/xjodoin/rangefind)
23
+ static search into your docs site. After MkDocs builds your site, the plugin
24
+ crawls the rendered HTML into a static, range-request search index and injects
25
+ the Rangefind search widget into every page — so you get working, serverless
26
+ search with **zero manual HTML editing**.
27
+
28
+ ## How it works (and the one prerequisite)
29
+
30
+ Rangefind is a Node.js tool; MkDocs is a Python static-site generator. There is
31
+ no way to run Rangefind's indexing logic from pure Python, so this plugin is a
32
+ small, honest bridge: its MkDocs lifecycle hooks **shell out to the Rangefind
33
+ Node CLI** after the site is built.
34
+
35
+ **Prerequisite:** Node.js (>= 22) and the `rangefind` npm package must be
36
+ reachable from your MkDocs project directory. The simplest setup:
37
+
38
+ ```bash
39
+ npm install rangefind # adds ./node_modules/rangefind + the CLI bin
40
+ pip install mkdocs-rangefind
41
+ ```
42
+
43
+ With `rangefind` installed locally, both `npx rangefind` (the index build) and
44
+ Node's module resolution (locating the widget's JS/CSS assets) work offline,
45
+ including in CI.
46
+
47
+ ## Usage
48
+
49
+ Add the plugin to `mkdocs.yml`:
50
+
51
+ ```yaml
52
+ plugins:
53
+ - search # optional; Rangefind replaces the need for it
54
+ - rangefind
55
+ ```
56
+
57
+ Then `mkdocs build` (or `mkdocs gh-deploy`). The plugin will:
58
+
59
+ 1. Run `rangefind build <site_dir> --output <site_dir>/rangefind --base-url <…>`
60
+ to crawl the built HTML and produce the index under `site/rangefind/`.
61
+ 2. Copy the search Web Component assets into `site/_rangefind/`.
62
+ 3. Inject a `<script type="module">` for the component, an optional theme
63
+ `<link>`, and (by default) a `<rangefind-search>` box on every page.
64
+
65
+ That's it — search works on the deployed static site with no server.
66
+
67
+ ## Configuration
68
+
69
+ All options are optional. Defaults shown.
70
+
71
+ ```yaml
72
+ plugins:
73
+ - rangefind:
74
+ enabled: true # master switch; false = complete no-op
75
+ base_url: "" # URL prefix baked into result links stored in
76
+ # the index (CLI --base-url). Empty = derive from
77
+ # mkdocs `site_url` path, falling back to "/".
78
+ output_dir: rangefind # index output dir, relative to the built site
79
+ assets_dir: _rangefind # where the widget JS/CSS are copied, rel. to site
80
+ theme: false # also inject the optional framework-free stylesheet
81
+ node_command: npx # command used to run the Rangefind CLI
82
+ placement: body_end # "body_end" | "manual" (see below)
83
+ selector: "" # optional literal HTML marker to place the widget after
84
+ placeholder: Search # input placeholder for the injected element
85
+ element_attributes: {} # extra attributes for <rangefind-search>
86
+ element_js_path: "" # explicit path to rangefind-search.js (advanced)
87
+ element_css_path: "" # explicit path to rangefind-search.css (advanced)
88
+ ```
89
+
90
+ ### `base_url`
91
+
92
+ This is passed to the crawler as `--base-url` and controls the URL prefix stored
93
+ in the **result links** inside the index. Left empty, the plugin derives it from
94
+ your MkDocs `site_url` path (e.g. `https://example.com/docs/` → `/docs/`),
95
+ falling back to `/`. Set it explicitly if you need a different prefix. Note this
96
+ is unrelated to where the widget loads the index from — that is always resolved
97
+ as a path *relative to each page*, so nested and subpath-deployed sites work
98
+ without configuration.
99
+
100
+ ### Placement modes
101
+
102
+ - **`body_end`** (default): the plugin injects
103
+ `<rangefind-search src="…"></rangefind-search>` right before `</body>` on every
104
+ page. Search "just works" out of the box.
105
+ - **`manual`**: the plugin injects the component's `<script>` (and theme `<link>`
106
+ if `theme: true`) but does **not** inject the `<rangefind-search>` element.
107
+ Hand-place it yourself in a
108
+ [theme override](https://www.mkdocs.org/user-guide/customizing-your-theme/#using-the-theme-custom_dir),
109
+ e.g. `<rangefind-search src="/rangefind/"></rangefind-search>`. Use this when
110
+ you want the search box in a specific spot (a header, a sidebar) rather than at
111
+ the end of the body.
112
+
113
+ ```yaml
114
+ plugins:
115
+ - rangefind:
116
+ placement: manual
117
+ ```
118
+
119
+ - **`selector`**: a middle ground. Provide a literal HTML substring that appears
120
+ in your rendered pages (for example a placeholder you put in a template or
121
+ Markdown page: `<div id="rangefind"></div>`). The `<rangefind-search>` element
122
+ is inserted immediately after the first occurrence of that exact string. If the
123
+ marker is not found on a page, the plugin falls back to `body_end`.
124
+
125
+ ### Styling / Tailwind
126
+
127
+ The `<rangefind-search>` component renders into **light DOM** and ships no
128
+ styling of its own, so your site's CSS applies directly. For manual placement
129
+ you can pass Tailwind (or any) classes through the component's `*-class`
130
+ attributes via `element_attributes`:
131
+
132
+ ```yaml
133
+ plugins:
134
+ - rangefind:
135
+ element_attributes:
136
+ input-class: "w-full border rounded px-3 py-2"
137
+ mark-class: "bg-yellow-200"
138
+ hotkey: true # boolean attributes: use `true`
139
+ router: true
140
+ ```
141
+
142
+ The full attribute and class-hook vocabulary (`input-class`, `option-class`,
143
+ `mark-class`, …, plus attributes like `page-size`, `debounce`, `suggest`,
144
+ `hotkey`, `router`) is documented in the Rangefind monorepo's
145
+ [reference guide](https://github.com/xjodoin/rangefind/blob/main/docs/reference.md#search-ui-component)
146
+ — this plugin does not duplicate it, it just forwards attributes verbatim.
147
+
148
+ ### Asset resolution
149
+
150
+ The plugin needs the component's `rangefind-search.js` / `.css`. Rather than
151
+ guess where npm installed them, it asks Node to resolve them through the
152
+ `rangefind` package's own export map (`rangefind/element` and
153
+ `rangefind/element.css`). This is robust across npm/pnpm/yarn layouts because it
154
+ uses the package's declared entry points rather than assuming a path. If you have
155
+ an unusual setup, set `element_js_path` / `element_css_path` explicitly (paths
156
+ are resolved relative to the MkDocs project directory).
157
+
158
+ ## MkDocs hooks used
159
+
160
+ - `on_post_build` — builds the index (shell-out) and copies the widget assets.
161
+ - `on_post_page` — injects the `<script>`/`<link>`/`<rangefind-search>` tags,
162
+ computing correct page-relative asset paths for nested pages.
163
+
164
+ ## License
165
+
166
+ MIT
@@ -0,0 +1,6 @@
1
+ mkdocs_rangefind/__init__.py,sha256=jX_c4CBr0w9_h654x1179dWThCpnLc3zPaARWt82_tQ,72
2
+ mkdocs_rangefind/plugin.py,sha256=CLxfrn5gxw7VnhOGszYV0aXpSiamDgP2B2xwVZxZRAw,13152
3
+ mkdocs_rangefind-0.1.0.dist-info/METADATA,sha256=zIoAQleFNUZQ6pEbXLfkooKRmf_HOVj9Q8Tx_CgCyfQ,7131
4
+ mkdocs_rangefind-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
5
+ mkdocs_rangefind-0.1.0.dist-info/entry_points.txt,sha256=eu9PBcfgDU6Y0yLN_cIsXTDX6Vq2EEC63dIVnD-BqTI,69
6
+ mkdocs_rangefind-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [mkdocs.plugins]
2
+ rangefind = mkdocs_rangefind.plugin:RangefindPlugin