mkdocs-rangefind 0.1.0__tar.gz

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,12 @@
1
+ # Python / packaging artifacts, scoped to this integration directory.
2
+ .venv/
3
+ __pycache__/
4
+ *.py[cod]
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+
9
+ # Test artifacts written during the run.
10
+ .pytest_cache/
11
+ test/fixture/site/
12
+ test/fixture/node_modules/
@@ -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,147 @@
1
+ # mkdocs-rangefind
2
+
3
+ An MkDocs plugin that wires [Rangefind](https://github.com/xjodoin/rangefind)
4
+ static search into your docs site. After MkDocs builds your site, the plugin
5
+ crawls the rendered HTML into a static, range-request search index and injects
6
+ the Rangefind search widget into every page — so you get working, serverless
7
+ search with **zero manual HTML editing**.
8
+
9
+ ## How it works (and the one prerequisite)
10
+
11
+ Rangefind is a Node.js tool; MkDocs is a Python static-site generator. There is
12
+ no way to run Rangefind's indexing logic from pure Python, so this plugin is a
13
+ small, honest bridge: its MkDocs lifecycle hooks **shell out to the Rangefind
14
+ Node CLI** after the site is built.
15
+
16
+ **Prerequisite:** Node.js (>= 22) and the `rangefind` npm package must be
17
+ reachable from your MkDocs project directory. The simplest setup:
18
+
19
+ ```bash
20
+ npm install rangefind # adds ./node_modules/rangefind + the CLI bin
21
+ pip install mkdocs-rangefind
22
+ ```
23
+
24
+ With `rangefind` installed locally, both `npx rangefind` (the index build) and
25
+ Node's module resolution (locating the widget's JS/CSS assets) work offline,
26
+ including in CI.
27
+
28
+ ## Usage
29
+
30
+ Add the plugin to `mkdocs.yml`:
31
+
32
+ ```yaml
33
+ plugins:
34
+ - search # optional; Rangefind replaces the need for it
35
+ - rangefind
36
+ ```
37
+
38
+ Then `mkdocs build` (or `mkdocs gh-deploy`). The plugin will:
39
+
40
+ 1. Run `rangefind build <site_dir> --output <site_dir>/rangefind --base-url <…>`
41
+ to crawl the built HTML and produce the index under `site/rangefind/`.
42
+ 2. Copy the search Web Component assets into `site/_rangefind/`.
43
+ 3. Inject a `<script type="module">` for the component, an optional theme
44
+ `<link>`, and (by default) a `<rangefind-search>` box on every page.
45
+
46
+ That's it — search works on the deployed static site with no server.
47
+
48
+ ## Configuration
49
+
50
+ All options are optional. Defaults shown.
51
+
52
+ ```yaml
53
+ plugins:
54
+ - rangefind:
55
+ enabled: true # master switch; false = complete no-op
56
+ base_url: "" # URL prefix baked into result links stored in
57
+ # the index (CLI --base-url). Empty = derive from
58
+ # mkdocs `site_url` path, falling back to "/".
59
+ output_dir: rangefind # index output dir, relative to the built site
60
+ assets_dir: _rangefind # where the widget JS/CSS are copied, rel. to site
61
+ theme: false # also inject the optional framework-free stylesheet
62
+ node_command: npx # command used to run the Rangefind CLI
63
+ placement: body_end # "body_end" | "manual" (see below)
64
+ selector: "" # optional literal HTML marker to place the widget after
65
+ placeholder: Search # input placeholder for the injected element
66
+ element_attributes: {} # extra attributes for <rangefind-search>
67
+ element_js_path: "" # explicit path to rangefind-search.js (advanced)
68
+ element_css_path: "" # explicit path to rangefind-search.css (advanced)
69
+ ```
70
+
71
+ ### `base_url`
72
+
73
+ This is passed to the crawler as `--base-url` and controls the URL prefix stored
74
+ in the **result links** inside the index. Left empty, the plugin derives it from
75
+ your MkDocs `site_url` path (e.g. `https://example.com/docs/` → `/docs/`),
76
+ falling back to `/`. Set it explicitly if you need a different prefix. Note this
77
+ is unrelated to where the widget loads the index from — that is always resolved
78
+ as a path *relative to each page*, so nested and subpath-deployed sites work
79
+ without configuration.
80
+
81
+ ### Placement modes
82
+
83
+ - **`body_end`** (default): the plugin injects
84
+ `<rangefind-search src="…"></rangefind-search>` right before `</body>` on every
85
+ page. Search "just works" out of the box.
86
+ - **`manual`**: the plugin injects the component's `<script>` (and theme `<link>`
87
+ if `theme: true`) but does **not** inject the `<rangefind-search>` element.
88
+ Hand-place it yourself in a
89
+ [theme override](https://www.mkdocs.org/user-guide/customizing-your-theme/#using-the-theme-custom_dir),
90
+ e.g. `<rangefind-search src="/rangefind/"></rangefind-search>`. Use this when
91
+ you want the search box in a specific spot (a header, a sidebar) rather than at
92
+ the end of the body.
93
+
94
+ ```yaml
95
+ plugins:
96
+ - rangefind:
97
+ placement: manual
98
+ ```
99
+
100
+ - **`selector`**: a middle ground. Provide a literal HTML substring that appears
101
+ in your rendered pages (for example a placeholder you put in a template or
102
+ Markdown page: `<div id="rangefind"></div>`). The `<rangefind-search>` element
103
+ is inserted immediately after the first occurrence of that exact string. If the
104
+ marker is not found on a page, the plugin falls back to `body_end`.
105
+
106
+ ### Styling / Tailwind
107
+
108
+ The `<rangefind-search>` component renders into **light DOM** and ships no
109
+ styling of its own, so your site's CSS applies directly. For manual placement
110
+ you can pass Tailwind (or any) classes through the component's `*-class`
111
+ attributes via `element_attributes`:
112
+
113
+ ```yaml
114
+ plugins:
115
+ - rangefind:
116
+ element_attributes:
117
+ input-class: "w-full border rounded px-3 py-2"
118
+ mark-class: "bg-yellow-200"
119
+ hotkey: true # boolean attributes: use `true`
120
+ router: true
121
+ ```
122
+
123
+ The full attribute and class-hook vocabulary (`input-class`, `option-class`,
124
+ `mark-class`, …, plus attributes like `page-size`, `debounce`, `suggest`,
125
+ `hotkey`, `router`) is documented in the Rangefind monorepo's
126
+ [reference guide](https://github.com/xjodoin/rangefind/blob/main/docs/reference.md#search-ui-component)
127
+ — this plugin does not duplicate it, it just forwards attributes verbatim.
128
+
129
+ ### Asset resolution
130
+
131
+ The plugin needs the component's `rangefind-search.js` / `.css`. Rather than
132
+ guess where npm installed them, it asks Node to resolve them through the
133
+ `rangefind` package's own export map (`rangefind/element` and
134
+ `rangefind/element.css`). This is robust across npm/pnpm/yarn layouts because it
135
+ uses the package's declared entry points rather than assuming a path. If you have
136
+ an unusual setup, set `element_js_path` / `element_css_path` explicitly (paths
137
+ are resolved relative to the MkDocs project directory).
138
+
139
+ ## MkDocs hooks used
140
+
141
+ - `on_post_build` — builds the index (shell-out) and copies the widget assets.
142
+ - `on_post_page` — injects the `<script>`/`<link>`/`<rangefind-search>` tags,
143
+ computing correct page-relative asset paths for nested pages.
144
+
145
+ ## License
146
+
147
+ MIT
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "mkdocs-rangefind"
7
+ version = "0.1.0"
8
+ description = "MkDocs plugin that builds a Rangefind static search index and injects the search widget after every build."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Xavier Jodoin", email = "xavier@jodoin.me" }]
13
+ keywords = ["mkdocs", "mkdocs-plugin", "search", "rangefind", "static-search"]
14
+ classifiers = [
15
+ "Framework :: MkDocs",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Topic :: Documentation",
19
+ "Topic :: Text Processing :: Indexing",
20
+ ]
21
+ dependencies = ["mkdocs>=1.4"]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/xjodoin/rangefind"
25
+ Repository = "https://github.com/xjodoin/rangefind"
26
+ Issues = "https://github.com/xjodoin/rangefind/issues"
27
+
28
+ # The MkDocs plugin entry-point group is "mkdocs.plugins" (confirmed against the
29
+ # installed mkdocs package). The name on the left ("rangefind") is what users put
30
+ # under `plugins:` in mkdocs.yml.
31
+ [project.entry-points."mkdocs.plugins"]
32
+ rangefind = "mkdocs_rangefind.plugin:RangefindPlugin"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/mkdocs_rangefind"]
@@ -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,6 @@
1
+ # About
2
+
3
+ About page distinct content xylophone marker.
4
+
5
+ The rare word above (xylophone) makes this page uniquely findable so the search
6
+ verification can prove the index actually resolves a query to this document.
@@ -0,0 +1,6 @@
1
+ # Home
2
+
3
+ MkDocs fixture home content for search testing.
4
+
5
+ This is the landing page of a minimal MkDocs site used to exercise the
6
+ mkdocs-rangefind plugin end to end.
@@ -0,0 +1,11 @@
1
+ site_name: Rangefind MkDocs Fixture
2
+ site_url: https://example.com/
3
+ docs_dir: docs
4
+ site_dir: site
5
+
6
+ plugins:
7
+ - rangefind:
8
+ # Point the CLI at the local repo checkout via the node_modules symlink
9
+ # the test creates, so the build works offline in CI.
10
+ node_command: npx
11
+ theme: true
@@ -0,0 +1,121 @@
1
+ """End-to-end test for the mkdocs-rangefind plugin.
2
+
3
+ Runs a real `mkdocs build` against a fixture site, then asserts that the
4
+ Rangefind index and widget assets were produced and the widget tags injected.
5
+ Finally it runs a companion Node script that serves the built site over a
6
+ Range-capable HTTP server and queries the real runtime, proving the index is
7
+ searchable.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ import unittest
16
+
17
+ HERE = os.path.dirname(os.path.abspath(__file__))
18
+ FIXTURE = os.path.join(HERE, "fixture")
19
+ # integrations/mkdocs-rangefind/test -> repo root is three levels up.
20
+ REPO_ROOT = os.path.abspath(os.path.join(HERE, "..", "..", ".."))
21
+ SITE_DIR = os.path.join(FIXTURE, "site")
22
+
23
+
24
+ def _link(target, link_path):
25
+ if os.path.islink(link_path) or os.path.exists(link_path):
26
+ if os.path.islink(link_path):
27
+ os.unlink(link_path)
28
+ elif os.path.isdir(link_path):
29
+ shutil.rmtree(link_path)
30
+ else:
31
+ os.remove(link_path)
32
+ os.symlink(target, link_path)
33
+
34
+
35
+ class RangefindMkDocsBuildTest(unittest.TestCase):
36
+ @classmethod
37
+ def setUpClass(cls):
38
+ # Make the local repo checkout resolvable as the `rangefind` npm package
39
+ # from the fixture dir, so `npx rangefind` and Node's require.resolve
40
+ # work offline (mirrors what `npm install rangefind` would produce).
41
+ node_modules = os.path.join(FIXTURE, "node_modules")
42
+ os.makedirs(os.path.join(node_modules, ".bin"), exist_ok=True)
43
+ _link(REPO_ROOT, os.path.join(node_modules, "rangefind"))
44
+ _link(
45
+ os.path.join("..", "rangefind", "bin", "rangefind.js"),
46
+ os.path.join(node_modules, ".bin", "rangefind"),
47
+ )
48
+
49
+ if os.path.isdir(SITE_DIR):
50
+ shutil.rmtree(SITE_DIR)
51
+
52
+ # Run the real MkDocs build from within the venv.
53
+ result = subprocess.run(
54
+ [sys.executable, "-m", "mkdocs", "build", "--strict"],
55
+ cwd=FIXTURE,
56
+ capture_output=True,
57
+ text=True,
58
+ timeout=120,
59
+ )
60
+ cls.build = result
61
+ if result.returncode != 0:
62
+ raise AssertionError(
63
+ "mkdocs build failed:\n"
64
+ f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}"
65
+ )
66
+
67
+ def test_build_succeeded(self):
68
+ self.assertEqual(self.build.returncode, 0, self.build.stderr)
69
+
70
+ def test_manifest_exists_and_indexed_all_pages(self):
71
+ manifest_path = os.path.join(SITE_DIR, "rangefind", "manifest.min.json")
72
+ self.assertTrue(os.path.isfile(manifest_path), manifest_path)
73
+ with open(manifest_path, encoding="utf-8") as handle:
74
+ manifest = json.load(handle)
75
+ self.assertGreaterEqual(
76
+ manifest.get("total", 0), 2, f"expected >=2 docs, manifest={manifest!r}"
77
+ )
78
+
79
+ def test_widget_assets_copied(self):
80
+ assets = os.path.join(SITE_DIR, "_rangefind")
81
+ self.assertTrue(os.path.isfile(os.path.join(assets, "rangefind-search.js")))
82
+ self.assertTrue(os.path.isfile(os.path.join(assets, "rangefind-search.css")))
83
+
84
+ def test_page_html_has_injected_tags(self):
85
+ index_html = os.path.join(SITE_DIR, "index.html")
86
+ with open(index_html, encoding="utf-8") as handle:
87
+ html = handle.read()
88
+ self.assertIn('<script type="module"', html)
89
+ self.assertIn("rangefind-search.js", html)
90
+ self.assertIn("<rangefind-search", html)
91
+ # theme: true in the fixture -> stylesheet link injected too.
92
+ self.assertIn("rangefind-search.css", html)
93
+
94
+ def test_nested_relative_paths(self):
95
+ # The About page lives at site/about/index.html (use_directory_urls),
96
+ # so its asset references must be one level up.
97
+ about_html = os.path.join(SITE_DIR, "about", "index.html")
98
+ with open(about_html, encoding="utf-8") as handle:
99
+ html = handle.read()
100
+ self.assertIn("../_rangefind/rangefind-search.js", html)
101
+ self.assertIn('src="../rangefind/"', html)
102
+
103
+ def test_index_is_searchable(self):
104
+ script = os.path.join(HERE, "verify_search.mjs")
105
+ result = subprocess.run(
106
+ ["node", script, SITE_DIR, "rangefind", "xylophone"],
107
+ capture_output=True,
108
+ text=True,
109
+ timeout=120,
110
+ )
111
+ self.assertEqual(
112
+ result.returncode,
113
+ 0,
114
+ f"search verification failed:\nstdout:\n{result.stdout}\n"
115
+ f"stderr:\n{result.stderr}",
116
+ )
117
+ self.assertIn("resolved to the About page", result.stdout)
118
+
119
+
120
+ if __name__ == "__main__":
121
+ unittest.main(verbosity=2)
@@ -0,0 +1,90 @@
1
+ // Proves the Rangefind index produced by the MkDocs plugin is actually
2
+ // searchable through the real range-request runtime (not merely present on
3
+ // disk). Serves the built site with a Range-capable static server (mirroring
4
+ // the serveStatic helper in the repo's test/build-runtime.test.js), then runs a
5
+ // real query and asserts the expected document comes back.
6
+ //
7
+ // Usage: node verify_search.mjs <site_dir> [outputDir] [query]
8
+
9
+ import assert from "node:assert/strict";
10
+ import { createServer } from "node:http";
11
+ import { readFile } from "node:fs/promises";
12
+ import { fileURLToPath } from "node:url";
13
+ import { dirname, resolve } from "node:path";
14
+
15
+ // Resolve the runtime from the monorepo checkout (this file lives at
16
+ // integrations/mkdocs-rangefind/test/, so the repo root is three levels up).
17
+ const here = dirname(fileURLToPath(import.meta.url));
18
+ const repoRoot = resolve(here, "..", "..", "..");
19
+ const { createSearch } = await import(resolve(repoRoot, "src", "runtime.js"));
20
+
21
+ const siteDir = process.argv[2];
22
+ const outputDir = process.argv[3] || "rangefind";
23
+ const query = process.argv[4] || "xylophone";
24
+ if (!siteDir) {
25
+ console.error("verify_search.mjs: missing <site_dir> argument");
26
+ process.exit(2);
27
+ }
28
+
29
+ async function serveStatic(root) {
30
+ const server = createServer(async (request, response) => {
31
+ try {
32
+ const url = new URL(request.url, "http://localhost");
33
+ const path = resolve(root, `.${decodeURIComponent(url.pathname)}`);
34
+ if (!path.startsWith(resolve(root))) {
35
+ response.writeHead(403).end();
36
+ return;
37
+ }
38
+ const data = await readFile(path);
39
+ const range = request.headers.range?.match(/^bytes=(\d+)-(\d+)$/);
40
+ if (range) {
41
+ const start = Number(range[1]);
42
+ const end = Math.min(Number(range[2]), data.length - 1);
43
+ response.writeHead(206, {
44
+ "Accept-Ranges": "bytes",
45
+ "Content-Length": String(end - start + 1),
46
+ "Content-Range": `bytes ${start}-${end}/${data.length}`
47
+ });
48
+ response.end(data.subarray(start, end + 1));
49
+ return;
50
+ }
51
+ response.writeHead(200, { "Content-Length": String(data.length) });
52
+ response.end(data);
53
+ } catch {
54
+ response.writeHead(404).end();
55
+ }
56
+ });
57
+ await new Promise(res => server.listen(0, "127.0.0.1", res));
58
+ const { port } = server.address();
59
+ return {
60
+ baseUrl: `http://127.0.0.1:${port}/${outputDir}/`,
61
+ close: () => new Promise(res => server.close(res))
62
+ };
63
+ }
64
+
65
+ const server = await serveStatic(siteDir);
66
+ try {
67
+ const engine = await createSearch({ baseUrl: server.baseUrl });
68
+ const response = await engine.search({ q: query, size: 5 });
69
+ const results = response.results || [];
70
+ console.log(
71
+ `query=${JSON.stringify(query)} -> ${results.length} result(s):`,
72
+ results.map(r => ({ id: r.id, title: r.title, url: r.url }))
73
+ );
74
+ assert.ok(results.length >= 1, `expected at least one result for "${query}"`);
75
+ const match = results.find(
76
+ r =>
77
+ (r.url && r.url.toLowerCase().includes("about")) ||
78
+ (r.id && r.id.toLowerCase().includes("about")) ||
79
+ (r.title && r.title.toLowerCase().includes("about"))
80
+ );
81
+ assert.ok(
82
+ match,
83
+ `expected the About page in results for "${query}", got ${JSON.stringify(
84
+ results.map(r => r.url || r.id)
85
+ )}`
86
+ );
87
+ console.log(`OK: "${query}" resolved to the About page (${match.url || match.id}).`);
88
+ } finally {
89
+ await server.close();
90
+ }