@farm.js/search 0.1.0-beta.1 → 0.1.0-beta.3

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 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 {
@@ -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,CAgF1F;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,CAQpE;AAED,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAKrE;AAED,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAKtD;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAoB3E"}
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
@@ -11,13 +11,13 @@ export async function writeSearchIndex(input) {
11
11
  const routes = htmlFiles
12
12
  .map((file) => ({
13
13
  file,
14
- logicalRoute: stripBasePath(routeFromHtmlFile(path.relative(publicDir, file)), basePath),
14
+ logicalRoute: stripBasePath(routeFromHtmlFile(path.relative(publicDir, file)), input.htmlBasePath ?? "/"),
15
15
  }))
16
16
  .sort((left, right) => left.logicalRoute.localeCompare(right.logicalRoute));
17
17
  const selected = routes.filter(({ logicalRoute }) => shouldIndexRoute(logicalRoute, input.options));
18
18
  const skippedRoutes = routes
19
19
  .filter(({ logicalRoute }) => !shouldIndexRoute(logicalRoute, input.options))
20
- .map(({ logicalRoute }) => withBasePath(logicalRoute, basePath));
20
+ .map(({ logicalRoute }) => toResultUrl(logicalRoute, basePath));
21
21
  if (selected.length === 0) {
22
22
  throw new Error("[farm:search] No static HTML pages matched the configured routes. " +
23
23
  "Mark searchable pages as static or update search include/exclude patterns.");
@@ -43,7 +43,7 @@ export async function writeSearchIndex(input) {
43
43
  const indexedRoutes = [];
44
44
  try {
45
45
  for (const { file, logicalRoute } of selected) {
46
- const route = withBasePath(logicalRoute, basePath);
46
+ const route = toResultUrl(logicalRoute, basePath);
47
47
  const result = await index.addHTMLFile({
48
48
  url: route,
49
49
  content: await readFile(file, "utf8"),
@@ -53,6 +53,7 @@ export async function writeSearchIndex(input) {
53
53
  }
54
54
  indexedRoutes.push(route);
55
55
  }
56
+ await assertOutputPathIsOwned(outputPath);
56
57
  await rm(outputPath, { recursive: true, force: true });
57
58
  await mkdir(path.dirname(outputPath), { recursive: true });
58
59
  const written = await index.writeFiles({ outputPath });
@@ -87,9 +88,6 @@ export function withBasePath(route, basePath) {
87
88
  const normalizedRoute = route === "/" ? "/" : `/${route.replace(/^\/+|\/+$/g, "")}`;
88
89
  if (normalizedBase === "/")
89
90
  return normalizedRoute;
90
- if (normalizedRoute === normalizedBase || normalizedRoute.startsWith(`${normalizedBase}/`)) {
91
- return normalizedRoute;
92
- }
93
91
  return normalizedRoute === "/" ? normalizedBase : `${normalizedBase}${normalizedRoute}`;
94
92
  }
95
93
  export function stripBasePath(route, basePath) {
@@ -106,20 +104,31 @@ export function routeFromHtmlFile(file) {
106
104
  const route = withoutIndex ? withoutIndex.replace(/\.html$/i, "").replace(/^\/+|\/+$/g, "") : "";
107
105
  return route ? `/${route}` : "/";
108
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
+ }
109
116
  export function matchesRoutePattern(route, pattern) {
110
- if (pattern.endsWith("/**")) {
111
- const parent = pattern.slice(0, -3) || "/";
112
- return route === parent || route.startsWith(parent === "/" ? "/" : `${parent}/`);
113
- }
117
+ const descendants = pattern.endsWith("/**");
118
+ const parentPattern = descendants ? pattern.slice(0, -3) : pattern;
114
119
  let source = "";
115
- for (let index = 0; index < pattern.length; index++) {
116
- const character = pattern[index];
117
- if (character === "*" && pattern[index + 1] === "*") {
118
- source += ".*";
119
- index++;
120
- }
121
- else if (character === "*") {
122
- source += "[^/]*";
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;
123
132
  }
124
133
  else if (character === "?") {
125
134
  source += "[^/]";
@@ -128,6 +137,9 @@ export function matchesRoutePattern(route, pattern) {
128
137
  source += character.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
129
138
  }
130
139
  }
140
+ // Compile the parent too: a terminal /** must not turn earlier wildcards into literals.
141
+ if (descendants)
142
+ source += parentPattern ? "(?:/.*)?" : "/.*";
131
143
  return new RegExp(`^${source}/?$`).test(route);
132
144
  }
133
145
  function shouldIndexRoute(route, options) {
@@ -172,6 +184,51 @@ function resolveOutputPath(publicDir, bundlePath) {
172
184
  }
173
185
  return outputPath;
174
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
+ }
175
232
  async function assertOutputPathInside(publicDir, outputPath) {
176
233
  const realPublicDir = await realpath(publicDir);
177
234
  let existingAncestor = outputPath;
@@ -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,CA0FlB"}
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"}
@@ -1,14 +1,39 @@
1
1
  export function createSearchClient(resolveConfig, options, loadModule) {
2
- let loaded;
3
- const getApi = async () => {
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 (!loaded) {
11
- const loading = loadModule(`${bundlePath}pagefind.js`)
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
- loaded = undefined;
53
+ if (loading === attempt)
54
+ loading = undefined;
29
55
  throw error;
30
56
  });
31
- loaded = loading;
57
+ loading = attempt;
32
58
  }
33
- const current = await loaded;
59
+ const attempt = loading;
60
+ const current = await attempt;
34
61
  if (current.bundlePath !== bundlePath) {
35
- await current.api.destroy();
36
- loaded = undefined;
37
- return getApi();
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
- const api = await getApi();
50
- const response = await api.search(term, pagefindOptions);
51
- const handles = response.results.slice(offset, offset + limit);
52
- const results = await Promise.all(handles.map(loadResult));
53
- return {
54
- results,
55
- total: response.results.length,
56
- unfilteredTotal: response.unfilteredResultCount,
57
- filters: response.filters,
58
- totalFilters: response.totalFilters,
59
- timings: combineTimings(response.timings),
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
- const api = await getApi();
67
- await api.preload(term, pagefindOptions);
122
+ await withApi((api) => api.preload(term, pagefindOptions));
68
123
  },
69
124
  async filters() {
70
- const api = await getApi();
71
- return api.filters();
125
+ return withApi((api) => api.filters());
72
126
  },
73
127
  async destroy() {
74
- if (!loaded)
128
+ if (!loading)
75
129
  return;
76
- const current = await loaded;
77
- loaded = undefined;
78
- await current.api.destroy();
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/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
@@ -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;AAGvE,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,6CAuFjD"}
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.1",
3
+ "version": "0.1.0-beta.3",
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.0-beta.93"
40
+ "@farm.js/core": "0.1.0-beta.108"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "^20.10.5",