eleventy-plugin-rangefind 0.3.0

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.
package/README.md ADDED
@@ -0,0 +1,141 @@
1
+ # eleventy-plugin-rangefind
2
+
3
+ Add fast, framework-free search to any [Eleventy](https://www.11ty.dev/) site
4
+ with [Rangefind](https://github.com/xjodoin/rangefind) — a static search engine
5
+ that ships a **packed static index** and fetches only the byte ranges it needs
6
+ via **HTTP Range requests**. No search server, no third-party service, no
7
+ runtime index download.
8
+
9
+ The plugin:
10
+
11
+ 1. Crawls your built site's HTML on `eleventy.after` and writes a Rangefind
12
+ index into your output directory.
13
+ 2. Copies the drop-in `<rangefind-search>` Web Component (JS + optional CSS
14
+ theme) alongside it.
15
+ 3. Registers a universal `{% rangefindSearch %}` shortcode that mounts the
16
+ component.
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ npm install eleventy-plugin-rangefind rangefind
22
+ ```
23
+
24
+ `rangefind` is a peer of this plugin — install it in your project too.
25
+
26
+ ## Register
27
+
28
+ Eleventy configs are ESM-friendly. In `eleventy.config.js`:
29
+
30
+ ```js
31
+ import rangefindPlugin from "eleventy-plugin-rangefind";
32
+
33
+ export default function (eleventyConfig) {
34
+ eleventyConfig.addPlugin(rangefindPlugin, {
35
+ baseUrl: "/" // URL prefix baked into result URLs
36
+ });
37
+ }
38
+ ```
39
+
40
+ CommonJS projects (`.eleventy.js`) can use dynamic import:
41
+
42
+ ```js
43
+ module.exports = async function (eleventyConfig) {
44
+ const { default: rangefindPlugin } = await import("eleventy-plugin-rangefind");
45
+ eleventyConfig.addPlugin(rangefindPlugin);
46
+ };
47
+ ```
48
+
49
+ ## Use in templates
50
+
51
+ Drop the shortcode anywhere — it works in Nunjucks, Liquid, Markdown, and
52
+ 11ty.js templates:
53
+
54
+ ```njk
55
+ {% rangefindSearch %}
56
+ ```
57
+
58
+ With options (Nunjucks / 11ty.js object-argument form):
59
+
60
+ ```njk
61
+ {% rangefindSearch {
62
+ placeholder: "Search the docs",
63
+ theme: true,
64
+ pageSize: 5,
65
+ inputClass: "w-full border rounded px-3 py-2",
66
+ markClass: "bg-yellow-200"
67
+ } %}
68
+ ```
69
+
70
+ The shortcode renders:
71
+
72
+ ```html
73
+ <link rel="stylesheet" href="/_rangefind/rangefind-search.css"> <!-- only when theme: true -->
74
+ <script type="module" src="/_rangefind/rangefind-search.js"></script>
75
+ <rangefind-search src="/rangefind/"
76
+ placeholder="Search the docs" page-size="5"
77
+ input-class="w-full border rounded px-3 py-2"
78
+ mark-class="bg-yellow-200"></rangefind-search>
79
+ ```
80
+
81
+ ## Plugin options
82
+
83
+ | Option | Default | Meaning |
84
+ | --- | --- | --- |
85
+ | `enabled` | `true` | Set `false` to skip indexing and shortcode registration entirely. |
86
+ | `outputDir` | `"rangefind"` | Index directory, relative to Eleventy's output dir. |
87
+ | `assetsDir` | `"_rangefind"` | Directory the client JS/CSS are copied into, relative to output. |
88
+ | `baseUrl` | `"/"` | URL prefix or origin baked into indexed result URLs. A path like `/blog/` prefixes every URL; an absolute origin like `https://example.com/` produces absolute URLs. |
89
+ | `src` | `"/rangefind/"` | Default `src` (index base URL) for the shortcode's element. |
90
+ | `assetsBase` | `"/_rangefind"` | Default URL base the shortcode points `<script>`/`<link>` at. |
91
+ | `theme` | `false` | Default for whether the shortcode emits the optional CSS theme link. |
92
+ | `shortcodeName` | `"rangefindSearch"` | Name to register the shortcode under. |
93
+
94
+ > `outputDir`/`assetsDir` are filesystem paths inside your output folder;
95
+ > `src`/`assetsBase` are the **URLs** the browser uses. They are separate so you
96
+ > can deploy under a subpath or CDN without changing where files are written.
97
+
98
+ ## Shortcode arguments
99
+
100
+ All are optional. Control keys shape the markup; every other key becomes an
101
+ attribute on `<rangefind-search>`.
102
+
103
+ | Argument | Effect |
104
+ | --- | --- |
105
+ | `src` | Override the index base URL for this element (defaults to plugin `src`). |
106
+ | `assetsBase` | Override the asset URL base for this call (defaults to plugin `assetsBase`). |
107
+ | `theme` | `true` to emit the optional stylesheet link for this call. |
108
+ | *any element attribute* | Passed through to the element, normalized to kebab-case. `pageSize` → `page-size`, `inputClass` → `input-class`. |
109
+
110
+ Recognized element attributes (from the underlying Web Component): `placeholder`,
111
+ `label`, `page-size`, `debounce`, `min-length`, `highlight`, `suggest`,
112
+ `router`, `open-on-focus`, `hotkey`, `empty-text`, `loading-text`, `error-text`,
113
+ plus the per-part class hooks `root-class`, `input-class`, `panel-class`,
114
+ `list-class`, `option-class`, `option-title-class`, `option-snippet-class`,
115
+ `option-url-class`, `empty-class`, `status-class`, `suggest-class`,
116
+ `suggest-item-class`, `mark-class`. Unrecognized names are dropped with a
117
+ warning rather than rendered.
118
+
119
+ Boolean arguments follow the component's semantics: `router: true` renders a
120
+ bare `router` attribute; `highlight: false` renders `highlight="false"` to turn
121
+ a default-on feature off.
122
+
123
+ ## Zero framework required, any CSS
124
+
125
+ `<rangefind-search>` renders into its **light DOM** and ships **no styling of
126
+ its own** — the host page's CSS applies directly. Style it with your own CSS
127
+ targeting the `rf-search__*` class hooks, with Tailwind (or any utility CSS) via
128
+ the `*-class` attributes shown above, or opt into the bundled theme with
129
+ `theme: true`. See the [Rangefind component docs](https://github.com/xjodoin/rangefind/blob/main/docs/reference.md#search-ui-component)
130
+ for the full attribute, class-hook, and event reference.
131
+
132
+ ## How it works
133
+
134
+ - **Indexing** runs on Eleventy's official `eleventy.after` event, so the crawl
135
+ sees the final built HTML. Extraction rules (title, headings, body,
136
+ `data-rangefind-*` attributes) are documented in the
137
+ [Rangefind crawler reference](https://github.com/xjodoin/rangefind/blob/main/docs/reference.md#crawling-a-static-site).
138
+ - **Assets** are resolved from the installed `rangefind` package via
139
+ `import.meta.resolve("rangefind/element")` and
140
+ `import.meta.resolve("rangefind/element.css")`, so they always match the
141
+ installed Rangefind version.
package/index.js ADDED
@@ -0,0 +1,80 @@
1
+ // eleventy-plugin-rangefind — index a built Eleventy site with Rangefind and
2
+ // drop in the <rangefind-search> Web Component.
3
+ //
4
+ // Rangefind is a static search engine: the build step crawls the already-built
5
+ // HTML into a packed static index, and the client fetches only the byte ranges
6
+ // it needs via HTTP Range requests — no search server. This plugin wires that
7
+ // into Eleventy's real build lifecycle:
8
+ //
9
+ // 1. On `eleventy.after` (the official post-build hook), crawl the output
10
+ // directory into `<output>/<outputDir>` and copy the two client assets
11
+ // into `<output>/<assetsDir>`.
12
+ // 2. A universal `rangefindSearch` shortcode renders the markup that mounts
13
+ // the component.
14
+
15
+ import { copyFile, mkdir } from "node:fs/promises";
16
+ import { join } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+ import { buildFromCrawl } from "rangefind/crawler";
19
+ import { renderSearchMarkup } from "./src/render_markup.js";
20
+
21
+ // Resolve the real on-disk path of a Rangefind subpath export. Both
22
+ // `rangefind/element` and `rangefind/element.css` are declared in Rangefind's
23
+ // package.json `exports`; import.meta.resolve returns a file:// URL we convert
24
+ // to a path. Resolution is relative to THIS module, so it follows the plugin's
25
+ // own dependency tree regardless of the consumer's cwd.
26
+ function resolveAsset(specifier) {
27
+ return fileURLToPath(import.meta.resolve(specifier));
28
+ }
29
+
30
+ export default function rangefindPlugin(eleventyConfig, options = {}) {
31
+ const {
32
+ enabled = true,
33
+ // Rangefind index directory, relative to Eleventy's output dir.
34
+ outputDir = "rangefind",
35
+ // Client asset directory (JS + optional CSS), relative to the output dir.
36
+ assetsDir = "_rangefind",
37
+ // URL prefix/origin baked into indexed result URLs.
38
+ baseUrl = "/",
39
+ // Shortcode defaults (overridable per call site).
40
+ src = "/rangefind/",
41
+ assetsBase = "/_rangefind",
42
+ theme = false,
43
+ shortcodeName = "rangefindSearch"
44
+ } = options;
45
+
46
+ if (enabled === false) return;
47
+
48
+ // Universal shortcode: addShortcode registers across Nunjucks, Liquid,
49
+ // Markdown (via its template engine), Handlebars, and 11ty.js — the widest
50
+ // coverage for a simple options-object shortcode.
51
+ eleventyConfig.addShortcode(shortcodeName, (args = {}) =>
52
+ renderSearchMarkup(args, { src, assetsBase, theme })
53
+ );
54
+
55
+ // eleventy.after fires once the whole site is written. The event object is
56
+ // `{ directories, dir, results, runMode, outputMode }`. `directories.output`
57
+ // is the normalized project output dir (Eleventy 3); `dir.output` is the
58
+ // legacy, un-normalized fallback (Eleventy 2). We prefer the former.
59
+ eleventyConfig.on("eleventy.after", async ({ directories, dir } = {}) => {
60
+ const outputRoot = directories?.output || dir?.output;
61
+ if (!outputRoot) {
62
+ throw new Error("eleventy-plugin-rangefind: could not determine Eleventy output directory from the eleventy.after event.");
63
+ }
64
+
65
+ // 1. Crawl the built HTML into the static index. The crawler prunes the
66
+ // nested index dir from its own scan automatically.
67
+ await buildFromCrawl({
68
+ root: outputRoot,
69
+ scanDir: outputRoot,
70
+ output: join(outputRoot, outputDir),
71
+ baseUrl
72
+ });
73
+
74
+ // 2. Copy the client assets next to the site.
75
+ const assetDirAbs = join(outputRoot, assetsDir);
76
+ await mkdir(assetDirAbs, { recursive: true });
77
+ await copyFile(resolveAsset("rangefind/element"), join(assetDirAbs, "rangefind-search.js"));
78
+ await copyFile(resolveAsset("rangefind/element.css"), join(assetDirAbs, "rangefind-search.css"));
79
+ });
80
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "eleventy-plugin-rangefind",
3
+ "version": "0.3.0",
4
+ "description": "Eleventy plugin that indexes your built site with Rangefind (static index + HTTP Range requests, no search server) and drops in the search Web Component.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Xavier Jodoin <xavier@jodoin.me>",
8
+ "keywords": [
9
+ "eleventy",
10
+ "eleventy-plugin",
11
+ "search",
12
+ "static-search",
13
+ "rangefind",
14
+ "range-requests"
15
+ ],
16
+ "main": "index.js",
17
+ "exports": {
18
+ ".": "./index.js"
19
+ },
20
+ "files": [
21
+ "index.js",
22
+ "src/",
23
+ "README.md"
24
+ ],
25
+ "scripts": {
26
+ "check": "for f in index.js src/*.js test/*.js test/fixture/*.js test/fixture/src/_includes/*.njk; do case \"$f\" in *.js) node --check \"$f\";; esac; done",
27
+ "test": "node --test test/*.test.js"
28
+ },
29
+ "dependencies": {
30
+ "rangefind": "^0.3.0"
31
+ },
32
+ "peerDependencies": {
33
+ "@11ty/eleventy": ">=2.0.0"
34
+ },
35
+ "devDependencies": {
36
+ "@11ty/eleventy": "^3.0.0"
37
+ },
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/xjodoin/rangefind.git",
41
+ "directory": "packages/eleventy-plugin-rangefind"
42
+ },
43
+ "homepage": "https://github.com/xjodoin/rangefind/tree/main/packages/eleventy-plugin-rangefind#readme",
44
+ "bugs": {
45
+ "url": "https://github.com/xjodoin/rangefind/issues"
46
+ }
47
+ }
@@ -0,0 +1,115 @@
1
+ // Pure, Eleventy-free markup builder for the `rangefindSearch` shortcode. Kept
2
+ // separate from the plugin registration so the branching logic (attribute
3
+ // normalization, boolean handling, escaping, asset-base normalization) is
4
+ // unit-testable under plain `node --test` without booting Eleventy.
5
+
6
+ // The real attribute vocabulary understood by <rangefind-search>, mirrored from
7
+ // src/element_util.js in the Rangefind monorepo (CONFIG_ATTRS +
8
+ // PART_CLASS_ATTRS). These are the only names the element observes; anything
9
+ // else is ignored by the component. We keep the list here so callers get a
10
+ // warning when they pass an unrecognized name rather than silently rendering a
11
+ // dead attribute — we do not invent new names.
12
+ export const CONFIG_ATTRS = [
13
+ "src", "placeholder", "page-size", "debounce", "min-length", "highlight",
14
+ "suggest", "router", "open-on-focus", "hotkey", "label", "empty-text",
15
+ "loading-text", "error-text"
16
+ ];
17
+
18
+ export const PART_CLASS_ATTRS = [
19
+ "root-class", "input-class", "panel-class", "list-class", "option-class",
20
+ "option-title-class", "option-snippet-class", "option-url-class",
21
+ "empty-class", "status-class", "suggest-class", "suggest-item-class",
22
+ "mark-class"
23
+ ];
24
+
25
+ export const KNOWN_ATTRS = new Set([...CONFIG_ATTRS, ...PART_CLASS_ATTRS]);
26
+
27
+ // Keys that control markup structure rather than element attributes.
28
+ const CONTROL_KEYS = new Set(["src", "assetsBase", "theme"]);
29
+
30
+ // camelCase / snake_case -> kebab-case, so `pageSize`, `page_size`, and
31
+ // `page-size` all resolve to the `page-size` attribute the element observes.
32
+ export function toKebab(name) {
33
+ return String(name)
34
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
35
+ .replace(/_/g, "-")
36
+ .toLowerCase();
37
+ }
38
+
39
+ // Escape a value for use inside a double-quoted HTML attribute.
40
+ export function escapeAttr(value) {
41
+ return String(value)
42
+ .replace(/&/g, "&amp;")
43
+ .replace(/</g, "&lt;")
44
+ .replace(/>/g, "&gt;")
45
+ .replace(/"/g, "&quot;");
46
+ }
47
+
48
+ // Escape a value for use in an unquoted URL-ish attribute (src/href). Same as
49
+ // attribute escaping — the element re-resolves `src` against document.baseURI.
50
+ export function escapeUrl(value) {
51
+ return escapeAttr(value);
52
+ }
53
+
54
+ // Trailing-slash-trim an assets base URL so we can join a filename with a
55
+ // single "/". A bare "" or "/" both normalize to "" -> "/rangefind-search.js".
56
+ function normalizeBase(base) {
57
+ return String(base ?? "").replace(/\/+$/, "");
58
+ }
59
+
60
+ // Render one attribute token from a normalized (kebab) name + value.
61
+ // true -> bare attribute (e.g. `router`)
62
+ // false / null -> `name="false"` (turns a default-on boolean off, matching
63
+ // the element's parseBoolAttr semantics; the element reads
64
+ // `highlight="false"` etc.)
65
+ // string / number -> `name="escaped-value"`
66
+ function renderAttr(name, value) {
67
+ if (value === true) return name;
68
+ if (value === false || value === null) return `${name}="false"`;
69
+ return `${name}="${escapeAttr(value)}"`;
70
+ }
71
+
72
+ // Build the full markup fragment for a search box.
73
+ //
74
+ // `args` — the shortcode's argument object (per call site).
75
+ // `defaults` — plugin-level defaults for `src`, `assetsBase`, `theme`.
76
+ //
77
+ // Returns a string:
78
+ // [<link rel="stylesheet"> if theme]
79
+ // <script type="module" src="<assetsBase>/rangefind-search.js"></script>
80
+ // <rangefind-search src="..." ...attrs></rangefind-search>
81
+ export function renderSearchMarkup(args = {}, defaults = {}) {
82
+ const opts = args && typeof args === "object" ? args : {};
83
+
84
+ const src = opts.src ?? defaults.src ?? "/rangefind/";
85
+ const assetsBase = normalizeBase(opts.assetsBase ?? defaults.assetsBase ?? "/_rangefind");
86
+ const theme = (opts.theme ?? defaults.theme ?? false) === true
87
+ || (opts.theme ?? defaults.theme) === "true";
88
+
89
+ // Collect element attributes from every non-control key, normalized to kebab.
90
+ const attrs = [];
91
+ for (const [rawKey, value] of Object.entries(opts)) {
92
+ if (CONTROL_KEYS.has(rawKey)) continue;
93
+ if (value === undefined) continue;
94
+ const name = toKebab(rawKey);
95
+ if (name === "src") continue; // src is handled explicitly above
96
+ if (!KNOWN_ATTRS.has(name)) {
97
+ // Unknown to the element — skip it rather than invent a new attribute.
98
+ if (typeof console !== "undefined" && typeof console.warn === "function") {
99
+ console.warn(`eleventy-plugin-rangefind: ignoring unknown rangefindSearch attribute "${rawKey}".`);
100
+ }
101
+ continue;
102
+ }
103
+ attrs.push(renderAttr(name, value));
104
+ }
105
+
106
+ const jsHref = `${assetsBase}/rangefind-search.js`;
107
+ const cssHref = `${assetsBase}/rangefind-search.css`;
108
+
109
+ const lines = [];
110
+ if (theme) lines.push(`<link rel="stylesheet" href="${escapeUrl(cssHref)}">`);
111
+ lines.push(`<script type="module" src="${escapeUrl(jsHref)}"></script>`);
112
+ const attrString = attrs.length ? ` ${attrs.join(" ")}` : "";
113
+ lines.push(`<rangefind-search src="${escapeUrl(src)}"${attrString}></rangefind-search>`);
114
+ return lines.join("\n");
115
+ }