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.
- mkdocs_rangefind-0.1.0/.gitignore +12 -0
- mkdocs_rangefind-0.1.0/PKG-INFO +166 -0
- mkdocs_rangefind-0.1.0/README.md +147 -0
- mkdocs_rangefind-0.1.0/pyproject.toml +35 -0
- mkdocs_rangefind-0.1.0/src/mkdocs_rangefind/__init__.py +3 -0
- mkdocs_rangefind-0.1.0/src/mkdocs_rangefind/plugin.py +305 -0
- mkdocs_rangefind-0.1.0/test/fixture/docs/about.md +6 -0
- mkdocs_rangefind-0.1.0/test/fixture/docs/index.md +6 -0
- mkdocs_rangefind-0.1.0/test/fixture/mkdocs.yml +11 -0
- mkdocs_rangefind-0.1.0/test/test_build.py +121 -0
- mkdocs_rangefind-0.1.0/test/verify_search.mjs +90 -0
|
@@ -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,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("&", "&").replace('"', """)
|
|
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,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
|
+
}
|