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 +141 -0
- package/index.js +80 -0
- package/package.json +47 -0
- package/src/render_markup.js +115 -0
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, "&")
|
|
43
|
+
.replace(/</g, "<")
|
|
44
|
+
.replace(/>/g, ">")
|
|
45
|
+
.replace(/"/g, """);
|
|
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
|
+
}
|