@farm.js/search 0.1.0-beta.0 → 0.1.0-beta.10
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/dist/build.d.ts +2 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +111 -20
- package/dist/client-runtime.d.ts.map +1 -1
- package/dist/client-runtime.js +89 -32
- package/dist/config.js +20 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/package.json +2 -2
package/dist/build.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ export interface SearchBuildInput {
|
|
|
4
4
|
publicDir?: string;
|
|
5
5
|
preset: string;
|
|
6
6
|
basePath: string;
|
|
7
|
+
/** Prefix already present in emitted HTML paths (localized Farm SSG output). */
|
|
8
|
+
htmlBasePath?: string;
|
|
7
9
|
options: ResolvedSearchOptions;
|
|
8
10
|
}
|
|
9
11
|
export interface SearchBuildResult {
|
package/dist/build.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../src/build.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AAEzD,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,qBAAqB,CAAC;CAChC;AAED,MAAM,WAAW,iBAAiB;IAChC,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,aAAa,EAAE,MAAM,EAAE,CAAC;CACzB;AAED,wBAAsB,gBAAgB,CAAC,KAAK,EAAE,gBAAgB,GAAG,OAAO,CAAC,iBAAiB,CAAC,
|
|
1
|
+
{"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../src/build.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,aAAa,CAAC;AAEzD,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,gFAAgF;IAChF,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE,qBAAqB,CAAC;CAChC;AAED,MAAM,WAAW,iBAAiB;IAChC,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,aAAa,EAAE,MAAM,EAAE,CAAC;CACzB;AAED,wBAAsB,gBAAgB,CAAC,KAAK,EAAE,gBAAgB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAoF1F;AAED,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAKhF;AAED,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAGnE;AAED,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAKpE;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAKrE;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAKtD;AAYD,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAwB3E"}
|
package/dist/build.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readFile, readdir, rm } from "node:fs/promises";
|
|
1
|
+
import { lstat, mkdir, readFile, readdir, realpath, rm } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import * as pagefind from "pagefind";
|
|
4
4
|
export async function writeSearchIndex(input) {
|
|
@@ -6,17 +6,18 @@ export async function writeSearchIndex(input) {
|
|
|
6
6
|
const basePath = normalizeBasePath(input.basePath);
|
|
7
7
|
const bundlePath = withBasePath(`/${input.options.output}`, basePath);
|
|
8
8
|
const outputPath = resolveOutputPath(publicDir, bundlePath);
|
|
9
|
+
await assertOutputPathInside(publicDir, outputPath);
|
|
9
10
|
const htmlFiles = await collectHtmlFiles(publicDir, outputPath);
|
|
10
11
|
const routes = htmlFiles
|
|
11
12
|
.map((file) => ({
|
|
12
13
|
file,
|
|
13
|
-
logicalRoute: stripBasePath(routeFromHtmlFile(path.relative(publicDir, file)),
|
|
14
|
+
logicalRoute: stripBasePath(routeFromHtmlFile(path.relative(publicDir, file)), input.htmlBasePath ?? "/"),
|
|
14
15
|
}))
|
|
15
16
|
.sort((left, right) => left.logicalRoute.localeCompare(right.logicalRoute));
|
|
16
17
|
const selected = routes.filter(({ logicalRoute }) => shouldIndexRoute(logicalRoute, input.options));
|
|
17
18
|
const skippedRoutes = routes
|
|
18
19
|
.filter(({ logicalRoute }) => !shouldIndexRoute(logicalRoute, input.options))
|
|
19
|
-
.map(({ logicalRoute }) =>
|
|
20
|
+
.map(({ logicalRoute }) => toResultUrl(logicalRoute, basePath));
|
|
20
21
|
if (selected.length === 0) {
|
|
21
22
|
throw new Error("[farm:search] No static HTML pages matched the configured routes. " +
|
|
22
23
|
"Mark searchable pages as static or update search include/exclude patterns.");
|
|
@@ -42,7 +43,7 @@ export async function writeSearchIndex(input) {
|
|
|
42
43
|
const indexedRoutes = [];
|
|
43
44
|
try {
|
|
44
45
|
for (const { file, logicalRoute } of selected) {
|
|
45
|
-
const route =
|
|
46
|
+
const route = toResultUrl(logicalRoute, basePath);
|
|
46
47
|
const result = await index.addHTMLFile({
|
|
47
48
|
url: route,
|
|
48
49
|
content: await readFile(file, "utf8"),
|
|
@@ -52,6 +53,7 @@ export async function writeSearchIndex(input) {
|
|
|
52
53
|
}
|
|
53
54
|
indexedRoutes.push(route);
|
|
54
55
|
}
|
|
56
|
+
await assertOutputPathIsOwned(outputPath);
|
|
55
57
|
await rm(outputPath, { recursive: true, force: true });
|
|
56
58
|
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
57
59
|
const written = await index.writeFiles({ outputPath });
|
|
@@ -86,9 +88,6 @@ export function withBasePath(route, basePath) {
|
|
|
86
88
|
const normalizedRoute = route === "/" ? "/" : `/${route.replace(/^\/+|\/+$/g, "")}`;
|
|
87
89
|
if (normalizedBase === "/")
|
|
88
90
|
return normalizedRoute;
|
|
89
|
-
if (normalizedRoute === normalizedBase || normalizedRoute.startsWith(`${normalizedBase}/`)) {
|
|
90
|
-
return normalizedRoute;
|
|
91
|
-
}
|
|
92
91
|
return normalizedRoute === "/" ? normalizedBase : `${normalizedBase}${normalizedRoute}`;
|
|
93
92
|
}
|
|
94
93
|
export function stripBasePath(route, basePath) {
|
|
@@ -105,20 +104,31 @@ export function routeFromHtmlFile(file) {
|
|
|
105
104
|
const route = withoutIndex ? withoutIndex.replace(/\.html$/i, "").replace(/^\/+|\/+$/g, "") : "";
|
|
106
105
|
return route ? `/${route}` : "/";
|
|
107
106
|
}
|
|
107
|
+
function toResultUrl(route, basePath) {
|
|
108
|
+
// Routes came from decoded filesystem names. Encode each segment once, after
|
|
109
|
+
// filtering and removing any explicit HTML prefix; basePath is already a URL path.
|
|
110
|
+
const pathname = route
|
|
111
|
+
.split("/")
|
|
112
|
+
.map((segment) => encodeURIComponent(segment))
|
|
113
|
+
.join("/");
|
|
114
|
+
return withBasePath(pathname, basePath);
|
|
115
|
+
}
|
|
108
116
|
export function matchesRoutePattern(route, pattern) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
return route === parent || route.startsWith(parent === "/" ? "/" : `${parent}/`);
|
|
112
|
-
}
|
|
117
|
+
const descendants = pattern.endsWith("/**");
|
|
118
|
+
const parentPattern = descendants ? pattern.slice(0, -3) : pattern;
|
|
113
119
|
let source = "";
|
|
114
|
-
for (let index = 0; index <
|
|
115
|
-
const character =
|
|
116
|
-
if (character === "*"
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
120
|
+
for (let index = 0; index < parentPattern.length; index++) {
|
|
121
|
+
const character = parentPattern[index];
|
|
122
|
+
if (character === "*") {
|
|
123
|
+
// Collapse the whole run. Emitting one `.*` per adjacent pair turned
|
|
124
|
+
// `****` into `.*.*`, and each extra pair multiplied the ways a failing
|
|
125
|
+
// route could be split between them, so a pattern with a handful of
|
|
126
|
+
// consecutive stars hung the build instead of matching.
|
|
127
|
+
let run = 1;
|
|
128
|
+
while (parentPattern[index + run] === "*")
|
|
129
|
+
run++;
|
|
130
|
+
source += run > 1 ? ".*" : "[^/]*";
|
|
131
|
+
index += run - 1;
|
|
122
132
|
}
|
|
123
133
|
else if (character === "?") {
|
|
124
134
|
source += "[^/]";
|
|
@@ -127,6 +137,9 @@ export function matchesRoutePattern(route, pattern) {
|
|
|
127
137
|
source += character.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
128
138
|
}
|
|
129
139
|
}
|
|
140
|
+
// Compile the parent too: a terminal /** must not turn earlier wildcards into literals.
|
|
141
|
+
if (descendants)
|
|
142
|
+
source += parentPattern ? "(?:/.*)?" : "/.*";
|
|
130
143
|
return new RegExp(`^${source}/?$`).test(route);
|
|
131
144
|
}
|
|
132
145
|
function shouldIndexRoute(route, options) {
|
|
@@ -163,11 +176,89 @@ async function collectHtmlFiles(root, ignoredDirectory) {
|
|
|
163
176
|
function resolveOutputPath(publicDir, bundlePath) {
|
|
164
177
|
const outputPath = path.resolve(publicDir, `.${bundlePath}`);
|
|
165
178
|
const relative = path.relative(publicDir, outputPath);
|
|
166
|
-
if (!relative ||
|
|
179
|
+
if (!relative ||
|
|
180
|
+
relative === ".." ||
|
|
181
|
+
relative.startsWith(`..${path.sep}`) ||
|
|
182
|
+
path.isAbsolute(relative)) {
|
|
167
183
|
throw new Error("[farm:search] Search output must stay inside the public output directory");
|
|
168
184
|
}
|
|
169
185
|
return outputPath;
|
|
170
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* Refuse to delete prerendered pages that happen to live where the index goes.
|
|
189
|
+
*
|
|
190
|
+
* The index directory is wiped before it is rewritten, and it comes from the
|
|
191
|
+
* `output` option, so `output: "search"` on a site that also prerenders a
|
|
192
|
+
* `/search` page pointed the wipe at the app's own pages. `collectHtmlFiles`
|
|
193
|
+
* skips this directory, so those pages were left out of the index and then
|
|
194
|
+
* deleted from the deploy output, with a success log and a zero exit.
|
|
195
|
+
*
|
|
196
|
+
* Only HTML is treated as the app's: a previous bundle leaves non-HTML files
|
|
197
|
+
* behind, and clearing those is this function's normal job.
|
|
198
|
+
*/
|
|
199
|
+
async function assertOutputPathIsOwned(outputPath) {
|
|
200
|
+
const pages = await findHtmlFile(outputPath);
|
|
201
|
+
if (!pages)
|
|
202
|
+
return;
|
|
203
|
+
throw new Error(`The search index directory ${outputPath} contains a prerendered page (${path.relative(outputPath, pages)}), ` +
|
|
204
|
+
`so it will not be replaced. Give \`search({ output })\` a directory of its own, ` +
|
|
205
|
+
`or move the conflicting route, then build again.`);
|
|
206
|
+
}
|
|
207
|
+
/** The first HTML file at any depth, or undefined when there is none. */
|
|
208
|
+
async function findHtmlFile(directory) {
|
|
209
|
+
let entries;
|
|
210
|
+
try {
|
|
211
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
// Nothing there yet is the ordinary first build.
|
|
215
|
+
if (error.code === "ENOENT")
|
|
216
|
+
return undefined;
|
|
217
|
+
throw error;
|
|
218
|
+
}
|
|
219
|
+
for (const entry of entries) {
|
|
220
|
+
const entryPath = path.join(directory, entry.name);
|
|
221
|
+
if (entry.isDirectory()) {
|
|
222
|
+
const nested = await findHtmlFile(entryPath);
|
|
223
|
+
if (nested)
|
|
224
|
+
return nested;
|
|
225
|
+
}
|
|
226
|
+
else if (entry.isFile() && entry.name.toLowerCase().endsWith(".html")) {
|
|
227
|
+
return entryPath;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
async function assertOutputPathInside(publicDir, outputPath) {
|
|
233
|
+
const realPublicDir = await realpath(publicDir);
|
|
234
|
+
let existingAncestor = outputPath;
|
|
235
|
+
while (true) {
|
|
236
|
+
try {
|
|
237
|
+
existingAncestor = await realpath(existingAncestor);
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
if (error.code !== "ENOENT")
|
|
242
|
+
throw error;
|
|
243
|
+
try {
|
|
244
|
+
await lstat(existingAncestor);
|
|
245
|
+
throw new Error("[farm:search] Search output must stay inside the public output directory, including through symlinks");
|
|
246
|
+
}
|
|
247
|
+
catch (statError) {
|
|
248
|
+
if (statError.code !== "ENOENT")
|
|
249
|
+
throw statError;
|
|
250
|
+
}
|
|
251
|
+
const parent = path.dirname(existingAncestor);
|
|
252
|
+
if (parent === existingAncestor)
|
|
253
|
+
throw error;
|
|
254
|
+
existingAncestor = parent;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const relative = path.relative(realPublicDir, existingAncestor);
|
|
258
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
259
|
+
throw new Error("[farm:search] Search output must stay inside the public output directory, including through symlinks");
|
|
260
|
+
}
|
|
261
|
+
}
|
|
171
262
|
function pagefindError(action, errors) {
|
|
172
263
|
const details = errors.length ? `:\n${errors.map((error) => ` - ${error}`).join("\n")}` : "";
|
|
173
264
|
return new Error(`[farm:search] Pagefind ${action}${details}`);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client-runtime.d.ts","sourceRoot":"","sources":["../src/client-runtime.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,gBAAgB,EAChB,sBAAsB,EACtB,mBAAmB,EACnB,kBAAkB,EAInB,MAAM,YAAY,CAAC;AAEpB,UAAU,sBAAsB;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IAClC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B,WAAW,EAAE,KAAK,CAAC;QACjB,KAAK,EAAE,MAAM,CAAC;QACd,GAAG,EAAE,MAAM,CAAC;QACZ,OAAO,EAAE,MAAM,CAAC;QAChB,aAAa,EAAE,MAAM,CAAC;KACvB,CAAC,CAAC;CACJ;AAED,UAAU,oBAAoB;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,iBAAiB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC7B,IAAI,IAAI,OAAO,CAAC,sBAAsB,CAAC,CAAC;CACzC;AAED,UAAU,sBAAsB;IAC9B,OAAO,EAAE,oBAAoB,EAAE,CAAC;IAChC,qBAAqB,EAAE,MAAM,CAAC;IAC9B,OAAO,EAAE,kBAAkB,CAAC;IAC5B,YAAY,EAAE,kBAAkB,CAAC;IACjC,OAAO,EACH;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,GAClD,KAAK,CAAC;QACJ,OAAO,EAAE,MAAM,CAAC;QAChB,MAAM,EAAE,MAAM,CAAC;QACf,KAAK,EAAE,MAAM,CAAC;KACf,CAAC,CAAC;CACR;AAED,MAAM,WAAW,kBAAkB;IACjC,OAAO,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACzD,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACtB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAChG,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxE,OAAO,IAAI,OAAO,CAAC,kBAAkB,CAAC,CAAC;IACvC,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B;AAED,MAAM,WAAW,qBAAsB,SAAQ,kBAAkB;IAC/D,cAAc,CAAC,CACb,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAAC;CACrD;AAED,MAAM,MAAM,oBAAoB,GAAG,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAC;AAEnF,wBAAgB,kBAAkB,CAChC,aAAa,EAAE,MAAM,sBAAsB,EAC3C,OAAO,EAAE,mBAAmB,EAC5B,UAAU,EAAE,oBAAoB,GAC/B,gBAAgB,
|
|
1
|
+
{"version":3,"file":"client-runtime.d.ts","sourceRoot":"","sources":["../src/client-runtime.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,gBAAgB,EAChB,sBAAsB,EACtB,mBAAmB,EACnB,kBAAkB,EAInB,MAAM,YAAY,CAAC;AAEpB,UAAU,sBAAsB;IAC9B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC;IAClC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B,WAAW,EAAE,KAAK,CAAC;QACjB,KAAK,EAAE,MAAM,CAAC;QACd,GAAG,EAAE,MAAM,CAAC;QACZ,OAAO,EAAE,MAAM,CAAC;QAChB,aAAa,EAAE,MAAM,CAAC;KACvB,CAAC,CAAC;CACJ;AAED,UAAU,oBAAoB;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,iBAAiB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC7B,IAAI,IAAI,OAAO,CAAC,sBAAsB,CAAC,CAAC;CACzC;AAED,UAAU,sBAAsB;IAC9B,OAAO,EAAE,oBAAoB,EAAE,CAAC;IAChC,qBAAqB,EAAE,MAAM,CAAC;IAC9B,OAAO,EAAE,kBAAkB,CAAC;IAC5B,YAAY,EAAE,kBAAkB,CAAC;IACjC,OAAO,EACH;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,GAClD,KAAK,CAAC;QACJ,OAAO,EAAE,MAAM,CAAC;QAChB,MAAM,EAAE,MAAM,CAAC;QACf,KAAK,EAAE,MAAM,CAAC;KACf,CAAC,CAAC;CACR;AAED,MAAM,WAAW,kBAAkB;IACjC,OAAO,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACzD,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACtB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,sBAAsB,CAAC,CAAC;IAChG,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxE,OAAO,IAAI,OAAO,CAAC,kBAAkB,CAAC,CAAC;IACvC,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B;AAED,MAAM,WAAW,qBAAsB,SAAQ,kBAAkB;IAC/D,cAAc,CAAC,CACb,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,CAAC,CAAC;CACrD;AAED,MAAM,MAAM,oBAAoB,GAAG,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAC;AAEnF,wBAAgB,kBAAkB,CAChC,aAAa,EAAE,MAAM,sBAAsB,EAC3C,OAAO,EAAE,mBAAmB,EAC5B,UAAU,EAAE,oBAAoB,GAC/B,gBAAgB,CA2JlB"}
|
package/dist/client-runtime.js
CHANGED
|
@@ -1,14 +1,39 @@
|
|
|
1
1
|
export function createSearchClient(resolveConfig, options, loadModule) {
|
|
2
|
-
let
|
|
3
|
-
|
|
2
|
+
let loading;
|
|
3
|
+
/**
|
|
4
|
+
* Stop handing this instance out and destroy it once every in-flight call
|
|
5
|
+
* has released it. Destroying eagerly is the bug this replaces: a
|
|
6
|
+
* bundle-path change or destroy() could tear the engine down under a
|
|
7
|
+
* search(), preload(), or filters() call that had already resolved it.
|
|
8
|
+
*/
|
|
9
|
+
const retire = (instance) => {
|
|
10
|
+
if (!instance.retired) {
|
|
11
|
+
instance.retired = true;
|
|
12
|
+
instance.drained = (async () => {
|
|
13
|
+
if (instance.uses > 0) {
|
|
14
|
+
await new Promise((resolve) => {
|
|
15
|
+
instance.resolveDrained = resolve;
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
await instance.api.destroy();
|
|
19
|
+
})();
|
|
20
|
+
}
|
|
21
|
+
return instance.drained;
|
|
22
|
+
};
|
|
23
|
+
const release = (instance) => {
|
|
24
|
+
instance.uses -= 1;
|
|
25
|
+
if (instance.retired && instance.uses === 0)
|
|
26
|
+
instance.resolveDrained?.();
|
|
27
|
+
};
|
|
28
|
+
const getInstance = async () => {
|
|
4
29
|
const config = resolveConfig();
|
|
5
30
|
if (!config.available && !options.bundlePath) {
|
|
6
31
|
throw new Error("[farm:search] The search index is generated by production builds. " +
|
|
7
32
|
"Run farm build and serve the production output before searching.");
|
|
8
33
|
}
|
|
9
34
|
const bundlePath = normalizeBundlePath(options.bundlePath ?? config.bundlePath);
|
|
10
|
-
if (!
|
|
11
|
-
const
|
|
35
|
+
if (!loading) {
|
|
36
|
+
const attempt = loadModule(`${bundlePath}pagefind.js`)
|
|
12
37
|
.then(async (module) => {
|
|
13
38
|
const highlightParam = options.highlightParam === false
|
|
14
39
|
? undefined
|
|
@@ -22,21 +47,49 @@ export function createSearchClient(resolveConfig, options, loadModule) {
|
|
|
22
47
|
if (!module.createInstance)
|
|
23
48
|
await api.options(pagefindOptions);
|
|
24
49
|
await api.init();
|
|
25
|
-
return { api, bundlePath };
|
|
50
|
+
return { api, bundlePath, uses: 0, retired: false };
|
|
26
51
|
})
|
|
27
52
|
.catch((error) => {
|
|
28
|
-
|
|
53
|
+
if (loading === attempt)
|
|
54
|
+
loading = undefined;
|
|
29
55
|
throw error;
|
|
30
56
|
});
|
|
31
|
-
|
|
57
|
+
loading = attempt;
|
|
32
58
|
}
|
|
33
|
-
const
|
|
59
|
+
const attempt = loading;
|
|
60
|
+
const current = await attempt;
|
|
34
61
|
if (current.bundlePath !== bundlePath) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
62
|
+
// Only clear our own load: a concurrent caller that noticed the same
|
|
63
|
+
// stale bundle may already have started the replacement.
|
|
64
|
+
if (loading === attempt)
|
|
65
|
+
loading = undefined;
|
|
66
|
+
// Drain the old engine in the background; the swap must not wait for
|
|
67
|
+
// calls that are still finishing against it. Its destroy() failure has
|
|
68
|
+
// no caller to surface to here — an explicit destroy() still reports it.
|
|
69
|
+
void retire(current).catch(() => { });
|
|
70
|
+
return getInstance();
|
|
71
|
+
}
|
|
72
|
+
return current;
|
|
73
|
+
};
|
|
74
|
+
/** Resolve an instance and hold it for the duration of one call. */
|
|
75
|
+
const withApi = async (run) => {
|
|
76
|
+
for (;;) {
|
|
77
|
+
const instance = await getInstance();
|
|
78
|
+
instance.uses += 1;
|
|
79
|
+
if (instance.retired) {
|
|
80
|
+
// Retired between resolving and acquiring; hand it back and take the
|
|
81
|
+
// replacement instead of running against an engine that may already
|
|
82
|
+
// be destroyed.
|
|
83
|
+
release(instance);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
return await run(instance.api);
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
release(instance);
|
|
91
|
+
}
|
|
38
92
|
}
|
|
39
|
-
return current.api;
|
|
40
93
|
};
|
|
41
94
|
return {
|
|
42
95
|
async search(term, queryOptions = {}) {
|
|
@@ -46,36 +99,40 @@ export function createSearchClient(resolveConfig, options, loadModule) {
|
|
|
46
99
|
const limit = normalizePositiveInteger(queryOptions.limit ?? 20, "limit");
|
|
47
100
|
const offset = normalizeOffset(queryOptions.offset ?? 0);
|
|
48
101
|
const pagefindOptions = engineOptions(queryOptions);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
results
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
102
|
+
// Result fragments load lazily, so the instance stays held until they
|
|
103
|
+
// have loaded too, not just through the initial query.
|
|
104
|
+
return withApi(async (api) => {
|
|
105
|
+
const response = await api.search(term, pagefindOptions);
|
|
106
|
+
const handles = response.results.slice(offset, offset + limit);
|
|
107
|
+
const results = await Promise.all(handles.map(loadResult));
|
|
108
|
+
return {
|
|
109
|
+
results,
|
|
110
|
+
total: response.results.length,
|
|
111
|
+
unfilteredTotal: response.unfilteredResultCount,
|
|
112
|
+
filters: response.filters,
|
|
113
|
+
totalFilters: response.totalFilters,
|
|
114
|
+
timings: combineTimings(response.timings),
|
|
115
|
+
};
|
|
116
|
+
});
|
|
61
117
|
},
|
|
62
118
|
async preload(term, queryOptions = {}) {
|
|
63
119
|
if (typeof term !== "string")
|
|
64
120
|
throw new TypeError("Search preload term must be a string");
|
|
65
121
|
const pagefindOptions = engineOptions(queryOptions);
|
|
66
|
-
|
|
67
|
-
await api.preload(term, pagefindOptions);
|
|
122
|
+
await withApi((api) => api.preload(term, pagefindOptions));
|
|
68
123
|
},
|
|
69
124
|
async filters() {
|
|
70
|
-
|
|
71
|
-
return api.filters();
|
|
125
|
+
return withApi((api) => api.filters());
|
|
72
126
|
},
|
|
73
127
|
async destroy() {
|
|
74
|
-
if (!
|
|
128
|
+
if (!loading)
|
|
75
129
|
return;
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
130
|
+
const attempt = loading;
|
|
131
|
+
const current = await attempt.catch(() => undefined);
|
|
132
|
+
if (loading === attempt)
|
|
133
|
+
loading = undefined;
|
|
134
|
+
if (current)
|
|
135
|
+
await retire(current);
|
|
79
136
|
},
|
|
80
137
|
};
|
|
81
138
|
}
|
package/dist/config.js
CHANGED
|
@@ -57,11 +57,30 @@ function normalizeOutput(value) {
|
|
|
57
57
|
value.includes("\\") ||
|
|
58
58
|
value.includes("?") ||
|
|
59
59
|
value.includes("#") ||
|
|
60
|
-
segments.some((segment) =>
|
|
60
|
+
segments.some((segment) => !isStableOutputSegment(segment))) {
|
|
61
61
|
throw new TypeError("Search output must stay inside the public output directory");
|
|
62
62
|
}
|
|
63
63
|
return normalized;
|
|
64
64
|
}
|
|
65
|
+
function isStableOutputSegment(segment) {
|
|
66
|
+
if (!segment)
|
|
67
|
+
return false;
|
|
68
|
+
let decoded = segment;
|
|
69
|
+
try {
|
|
70
|
+
decoded = decodeURIComponent(segment);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Malformed escapes remain literal in browser pathnames.
|
|
74
|
+
}
|
|
75
|
+
return (decoded !== "." &&
|
|
76
|
+
decoded !== ".." &&
|
|
77
|
+
!decoded.includes("/") &&
|
|
78
|
+
!decoded.includes("\\") &&
|
|
79
|
+
!Array.from(decoded).some((character) => {
|
|
80
|
+
const code = character.charCodeAt(0);
|
|
81
|
+
return code <= 31 || (code >= 127 && code <= 159);
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
65
84
|
function normalizeLanguage(value) {
|
|
66
85
|
const normalized = normalizeOptionalString(value, "language")?.toLowerCase();
|
|
67
86
|
if (normalized && !/^[a-z]{2}$/.test(normalized)) {
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type SearchOptions } from "./config.js";
|
|
2
|
+
import type { FarmSearchPublicConfig } from "./types.js";
|
|
2
3
|
export type { ResolvedSearchOptions, SearchOptions } from "./config.js";
|
|
3
4
|
export type { FarmSearchClient, SearchClientOptions, SearchFilterCounts, SearchFilterExpression, SearchFilterPrimitive, SearchFilters, SearchQueryOptions, SearchResponse, SearchResult, SearchSort, SearchSubResult, SearchTimings, } from "./types.js";
|
|
4
5
|
/** Build a static Pagefind index from Farm's emitted HTML pages. */
|
|
5
|
-
export declare function search(options?: SearchOptions): import("@farm.js/core/plugin").FarmPlugin
|
|
6
|
+
export declare function search(options?: SearchOptions): import("@farm.js/core/plugin").FarmPlugin<undefined, Record<string, never>, void, FarmSearchPublicConfig, unknown, false, import("@farm.js/core/plugin").PluginRoutes>;
|
|
6
7
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAwB,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAwB,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AACvE,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,YAAY,CAAC;AAEzD,YAAY,EAAE,qBAAqB,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACxE,YAAY,EACV,gBAAgB,EAChB,mBAAmB,EACnB,kBAAkB,EAClB,sBAAsB,EACtB,qBAAqB,EACrB,aAAa,EACb,kBAAkB,EAClB,cAAc,EACd,YAAY,EACZ,UAAU,EACV,eAAe,EACf,aAAa,GACd,MAAM,YAAY,CAAC;AAEpB,oEAAoE;AACpE,wBAAgB,MAAM,CAAC,OAAO,GAAE,aAAkB,0KAkGjD"}
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { resolveSearchOptions } from "./config.js";
|
|
|
5
5
|
export function search(options = {}) {
|
|
6
6
|
const resolved = resolveSearchOptions(options);
|
|
7
7
|
let configuredBasePath = "/";
|
|
8
|
+
let htmlBasePath = "/";
|
|
8
9
|
let generated = false;
|
|
9
10
|
const publicConfig = {
|
|
10
11
|
available: false,
|
|
@@ -20,6 +21,14 @@ export function search(options = {}) {
|
|
|
20
21
|
throw new Error("[farm:search] Configure search in one search() plugin instance");
|
|
21
22
|
}
|
|
22
23
|
configuredBasePath = normalizeBasePath(config.basePath);
|
|
24
|
+
// RouteManager emits ordinary SSG paths relative to the app, while its
|
|
25
|
+
// localized SSG paths already include basePath. Do not infer this from
|
|
26
|
+
// folder names: an application route can have the same name as basePath.
|
|
27
|
+
const i18n = config.i18n;
|
|
28
|
+
htmlBasePath =
|
|
29
|
+
i18n && typeof i18n === "object" && "enabled" in i18n && i18n.enabled
|
|
30
|
+
? configuredBasePath
|
|
31
|
+
: "/";
|
|
23
32
|
publicConfig.bundlePath = `${withBasePath(`/${resolved.output}`, configuredBasePath)}/`;
|
|
24
33
|
},
|
|
25
34
|
build: {
|
|
@@ -37,6 +46,7 @@ export function search(options = {}) {
|
|
|
37
46
|
publicDir: nitroConfig.output?.publicDir,
|
|
38
47
|
preset: nitroConfig.preset ?? "node-server",
|
|
39
48
|
basePath: configuredBasePath,
|
|
49
|
+
htmlBasePath,
|
|
40
50
|
options: resolved,
|
|
41
51
|
});
|
|
42
52
|
generated = true;
|
|
@@ -60,6 +70,7 @@ export function search(options = {}) {
|
|
|
60
70
|
outputDir: result.outputDir ?? `${result.root}/.farm/.output`,
|
|
61
71
|
preset: result.preset,
|
|
62
72
|
basePath: configuredBasePath,
|
|
73
|
+
htmlBasePath,
|
|
63
74
|
options: resolved,
|
|
64
75
|
});
|
|
65
76
|
generated = true;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@farm.js/search",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.10",
|
|
4
4
|
"description": "Build-time static page search for Farm.js applications",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"farm.js",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"pagefind": "^1.5.2",
|
|
40
|
-
"@farm.js/core": "0.1.
|
|
40
|
+
"@farm.js/core": "0.1.6"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "^20.10.5",
|