@farm.js/search 0.1.0-beta.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/LICENSE +22 -0
- package/README.md +28 -0
- package/dist/build.d.ts +22 -0
- package/dist/build.d.ts.map +1 -0
- package/dist/build.js +174 -0
- package/dist/client-runtime.d.ts +53 -0
- package/dist/client-runtime.d.ts.map +1 -0
- package/dist/client-runtime.js +148 -0
- package/dist/client.d.ts +7 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +14 -0
- package/dist/config.d.ts +39 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +83 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +84 -0
- package/dist/types.d.ts +75 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Farm.js Team
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# `@farm.js/search`
|
|
2
|
+
|
|
3
|
+
Build-time static page search for Farm.js applications, powered by Pagefind.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { defineConfig } from "@farm.js/core";
|
|
7
|
+
import { search } from "@farm.js/search";
|
|
8
|
+
|
|
9
|
+
export default defineConfig({
|
|
10
|
+
plugins: [search({ include: ["/docs/**", "/blog/**"] })],
|
|
11
|
+
});
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Create a renderer-neutral browser client and query the generated index:
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { createSearch } from "@farm.js/search/client";
|
|
18
|
+
|
|
19
|
+
const siteSearch = createSearch();
|
|
20
|
+
const response = await siteSearch.search("server functions");
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The plugin indexes only HTML emitted by static generation. It automatically respects Farm's
|
|
24
|
+
`basePath`, writes a deployment-ready chunked index, and keeps dynamic or authenticated content out
|
|
25
|
+
unless it was deliberately emitted as public static HTML.
|
|
26
|
+
|
|
27
|
+
See the [Farm Search documentation](https://farmjs.dev/docs/plugins/search) for route selection,
|
|
28
|
+
filters, result types, and production behavior.
|
package/dist/build.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ResolvedSearchOptions } from "./config.js";
|
|
2
|
+
export interface SearchBuildInput {
|
|
3
|
+
outputDir: string;
|
|
4
|
+
publicDir?: string;
|
|
5
|
+
preset: string;
|
|
6
|
+
basePath: string;
|
|
7
|
+
options: ResolvedSearchOptions;
|
|
8
|
+
}
|
|
9
|
+
export interface SearchBuildResult {
|
|
10
|
+
bundlePath: string;
|
|
11
|
+
outputPath: string;
|
|
12
|
+
indexedRoutes: string[];
|
|
13
|
+
skippedRoutes: string[];
|
|
14
|
+
}
|
|
15
|
+
export declare function writeSearchIndex(input: SearchBuildInput): Promise<SearchBuildResult>;
|
|
16
|
+
export declare function resolveSearchPublicDir(outputDir: string, preset: string): string;
|
|
17
|
+
export declare function normalizeBasePath(value: string | undefined): string;
|
|
18
|
+
export declare function withBasePath(route: string, basePath: string): string;
|
|
19
|
+
export declare function stripBasePath(route: string, basePath: string): string;
|
|
20
|
+
export declare function routeFromHtmlFile(file: string): string;
|
|
21
|
+
export declare function matchesRoutePattern(route: string, pattern: string): boolean;
|
|
22
|
+
//# sourceMappingURL=build.d.ts.map
|
|
@@ -0,0 +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,CA+E1F;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"}
|
package/dist/build.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { mkdir, readFile, readdir, rm } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import * as pagefind from "pagefind";
|
|
4
|
+
export async function writeSearchIndex(input) {
|
|
5
|
+
const publicDir = path.resolve(input.publicDir ?? resolveSearchPublicDir(input.outputDir, input.preset));
|
|
6
|
+
const basePath = normalizeBasePath(input.basePath);
|
|
7
|
+
const bundlePath = withBasePath(`/${input.options.output}`, basePath);
|
|
8
|
+
const outputPath = resolveOutputPath(publicDir, bundlePath);
|
|
9
|
+
const htmlFiles = await collectHtmlFiles(publicDir, outputPath);
|
|
10
|
+
const routes = htmlFiles
|
|
11
|
+
.map((file) => ({
|
|
12
|
+
file,
|
|
13
|
+
logicalRoute: stripBasePath(routeFromHtmlFile(path.relative(publicDir, file)), basePath),
|
|
14
|
+
}))
|
|
15
|
+
.sort((left, right) => left.logicalRoute.localeCompare(right.logicalRoute));
|
|
16
|
+
const selected = routes.filter(({ logicalRoute }) => shouldIndexRoute(logicalRoute, input.options));
|
|
17
|
+
const skippedRoutes = routes
|
|
18
|
+
.filter(({ logicalRoute }) => !shouldIndexRoute(logicalRoute, input.options))
|
|
19
|
+
.map(({ logicalRoute }) => withBasePath(logicalRoute, basePath));
|
|
20
|
+
if (selected.length === 0) {
|
|
21
|
+
throw new Error("[farm:search] No static HTML pages matched the configured routes. " +
|
|
22
|
+
"Mark searchable pages as static or update search include/exclude patterns.");
|
|
23
|
+
}
|
|
24
|
+
const created = await pagefind.createIndex({
|
|
25
|
+
...(input.options.rootSelector ? { rootSelector: input.options.rootSelector } : {}),
|
|
26
|
+
...(input.options.excludeSelectors.length
|
|
27
|
+
? { excludeSelectors: input.options.excludeSelectors }
|
|
28
|
+
: {}),
|
|
29
|
+
...(input.options.language ? { forceLanguage: input.options.language } : {}),
|
|
30
|
+
...(input.options.includeCharacters
|
|
31
|
+
? { includeCharacters: input.options.includeCharacters }
|
|
32
|
+
: {}),
|
|
33
|
+
keepIndexUrl: input.options.keepIndexUrl,
|
|
34
|
+
verbose: input.options.verbose,
|
|
35
|
+
writePlayground: false,
|
|
36
|
+
});
|
|
37
|
+
if (!created.index || created.errors.length) {
|
|
38
|
+
await pagefind.close();
|
|
39
|
+
throw pagefindError("could not create an index", created.errors);
|
|
40
|
+
}
|
|
41
|
+
const index = created.index;
|
|
42
|
+
const indexedRoutes = [];
|
|
43
|
+
try {
|
|
44
|
+
for (const { file, logicalRoute } of selected) {
|
|
45
|
+
const route = withBasePath(logicalRoute, basePath);
|
|
46
|
+
const result = await index.addHTMLFile({
|
|
47
|
+
url: route,
|
|
48
|
+
content: await readFile(file, "utf8"),
|
|
49
|
+
});
|
|
50
|
+
if (result.errors.length) {
|
|
51
|
+
throw pagefindError(`could not index ${route}`, result.errors);
|
|
52
|
+
}
|
|
53
|
+
indexedRoutes.push(route);
|
|
54
|
+
}
|
|
55
|
+
await rm(outputPath, { recursive: true, force: true });
|
|
56
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
57
|
+
const written = await index.writeFiles({ outputPath });
|
|
58
|
+
if (written.errors.length)
|
|
59
|
+
throw pagefindError("could not write the index", written.errors);
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
try {
|
|
63
|
+
await index.deleteIndex();
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
await pagefind.close();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
bundlePath: `${bundlePath}/`,
|
|
71
|
+
outputPath,
|
|
72
|
+
indexedRoutes,
|
|
73
|
+
skippedRoutes,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
export function resolveSearchPublicDir(outputDir, preset) {
|
|
77
|
+
return path.join(outputDir, preset === "vercel" || preset === "vercel-edge" ? "static" : "public");
|
|
78
|
+
}
|
|
79
|
+
export function normalizeBasePath(value) {
|
|
80
|
+
if (!value || value === "/")
|
|
81
|
+
return "/";
|
|
82
|
+
return `/${value.replace(/^\/+|\/+$/g, "")}`;
|
|
83
|
+
}
|
|
84
|
+
export function withBasePath(route, basePath) {
|
|
85
|
+
const normalizedBase = normalizeBasePath(basePath);
|
|
86
|
+
const normalizedRoute = route === "/" ? "/" : `/${route.replace(/^\/+|\/+$/g, "")}`;
|
|
87
|
+
if (normalizedBase === "/")
|
|
88
|
+
return normalizedRoute;
|
|
89
|
+
if (normalizedRoute === normalizedBase || normalizedRoute.startsWith(`${normalizedBase}/`)) {
|
|
90
|
+
return normalizedRoute;
|
|
91
|
+
}
|
|
92
|
+
return normalizedRoute === "/" ? normalizedBase : `${normalizedBase}${normalizedRoute}`;
|
|
93
|
+
}
|
|
94
|
+
export function stripBasePath(route, basePath) {
|
|
95
|
+
const normalizedBase = normalizeBasePath(basePath);
|
|
96
|
+
if (normalizedBase === "/")
|
|
97
|
+
return route;
|
|
98
|
+
if (route === normalizedBase)
|
|
99
|
+
return "/";
|
|
100
|
+
return route.startsWith(`${normalizedBase}/`) ? route.slice(normalizedBase.length) || "/" : route;
|
|
101
|
+
}
|
|
102
|
+
export function routeFromHtmlFile(file) {
|
|
103
|
+
const normalized = file.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
104
|
+
const withoutIndex = normalized.replace(/(?:^|\/)index\.html$/i, "");
|
|
105
|
+
const route = withoutIndex ? withoutIndex.replace(/\.html$/i, "").replace(/^\/+|\/+$/g, "") : "";
|
|
106
|
+
return route ? `/${route}` : "/";
|
|
107
|
+
}
|
|
108
|
+
export function matchesRoutePattern(route, pattern) {
|
|
109
|
+
if (pattern.endsWith("/**")) {
|
|
110
|
+
const parent = pattern.slice(0, -3) || "/";
|
|
111
|
+
return route === parent || route.startsWith(parent === "/" ? "/" : `${parent}/`);
|
|
112
|
+
}
|
|
113
|
+
let source = "";
|
|
114
|
+
for (let index = 0; index < pattern.length; index++) {
|
|
115
|
+
const character = pattern[index];
|
|
116
|
+
if (character === "*" && pattern[index + 1] === "*") {
|
|
117
|
+
source += ".*";
|
|
118
|
+
index++;
|
|
119
|
+
}
|
|
120
|
+
else if (character === "*") {
|
|
121
|
+
source += "[^/]*";
|
|
122
|
+
}
|
|
123
|
+
else if (character === "?") {
|
|
124
|
+
source += "[^/]";
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
source += character.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return new RegExp(`^${source}/?$`).test(route);
|
|
131
|
+
}
|
|
132
|
+
function shouldIndexRoute(route, options) {
|
|
133
|
+
return (options.include.some((pattern) => matchesRoutePattern(route, pattern)) &&
|
|
134
|
+
!options.exclude.some((pattern) => matchesRoutePattern(route, pattern)));
|
|
135
|
+
}
|
|
136
|
+
async function collectHtmlFiles(root, ignoredDirectory) {
|
|
137
|
+
const files = [];
|
|
138
|
+
const pending = [root];
|
|
139
|
+
while (pending.length) {
|
|
140
|
+
const directory = pending.pop();
|
|
141
|
+
let entries;
|
|
142
|
+
try {
|
|
143
|
+
entries = await readdir(directory, { withFileTypes: true });
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
if (error.code === "ENOENT")
|
|
147
|
+
continue;
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
for (const entry of entries) {
|
|
151
|
+
const absolute = path.join(directory, entry.name);
|
|
152
|
+
if (entry.isDirectory()) {
|
|
153
|
+
if (absolute !== ignoredDirectory)
|
|
154
|
+
pending.push(absolute);
|
|
155
|
+
}
|
|
156
|
+
else if (entry.isFile() && entry.name.toLowerCase().endsWith(".html")) {
|
|
157
|
+
files.push(absolute);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return files.sort();
|
|
162
|
+
}
|
|
163
|
+
function resolveOutputPath(publicDir, bundlePath) {
|
|
164
|
+
const outputPath = path.resolve(publicDir, `.${bundlePath}`);
|
|
165
|
+
const relative = path.relative(publicDir, outputPath);
|
|
166
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
167
|
+
throw new Error("[farm:search] Search output must stay inside the public output directory");
|
|
168
|
+
}
|
|
169
|
+
return outputPath;
|
|
170
|
+
}
|
|
171
|
+
function pagefindError(action, errors) {
|
|
172
|
+
const details = errors.length ? `:\n${errors.map((error) => ` - ${error}`).join("\n")}` : "";
|
|
173
|
+
return new Error(`[farm:search] Pagefind ${action}${details}`);
|
|
174
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { FarmSearchClient, FarmSearchPublicConfig, SearchClientOptions, SearchFilterCounts } from "./types.js";
|
|
2
|
+
interface PagefindSearchFragment {
|
|
3
|
+
url: string;
|
|
4
|
+
content: string;
|
|
5
|
+
excerpt: string;
|
|
6
|
+
plain_excerpt: string;
|
|
7
|
+
word_count: number;
|
|
8
|
+
filters: Record<string, string[]>;
|
|
9
|
+
meta: Record<string, string>;
|
|
10
|
+
sub_results: Array<{
|
|
11
|
+
title: string;
|
|
12
|
+
url: string;
|
|
13
|
+
excerpt: string;
|
|
14
|
+
plain_excerpt: string;
|
|
15
|
+
}>;
|
|
16
|
+
}
|
|
17
|
+
interface PagefindResultHandle {
|
|
18
|
+
id: string;
|
|
19
|
+
score: number;
|
|
20
|
+
words: number[];
|
|
21
|
+
matchedMetaFields?: string[];
|
|
22
|
+
data(): Promise<PagefindSearchFragment>;
|
|
23
|
+
}
|
|
24
|
+
interface PagefindSearchResponse {
|
|
25
|
+
results: PagefindResultHandle[];
|
|
26
|
+
unfilteredResultCount: number;
|
|
27
|
+
filters: SearchFilterCounts;
|
|
28
|
+
totalFilters: SearchFilterCounts;
|
|
29
|
+
timings: {
|
|
30
|
+
preload: number;
|
|
31
|
+
search: number;
|
|
32
|
+
total: number;
|
|
33
|
+
} | Array<{
|
|
34
|
+
preload: number;
|
|
35
|
+
search: number;
|
|
36
|
+
total: number;
|
|
37
|
+
}>;
|
|
38
|
+
}
|
|
39
|
+
export interface PagefindBrowserApi {
|
|
40
|
+
options(options: Record<string, unknown>): Promise<void>;
|
|
41
|
+
init(): Promise<void>;
|
|
42
|
+
search(term: string | null, options?: Record<string, unknown>): Promise<PagefindSearchResponse>;
|
|
43
|
+
preload(term: string, options?: Record<string, unknown>): Promise<void>;
|
|
44
|
+
filters(): Promise<SearchFilterCounts>;
|
|
45
|
+
destroy(): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
export interface PagefindBrowserModule extends PagefindBrowserApi {
|
|
48
|
+
createInstance?(options?: Record<string, unknown>): PagefindBrowserApi | Promise<PagefindBrowserApi>;
|
|
49
|
+
}
|
|
50
|
+
export type PagefindModuleLoader = (url: string) => Promise<PagefindBrowserModule>;
|
|
51
|
+
export declare function createSearchClient(resolveConfig: () => FarmSearchPublicConfig, options: SearchClientOptions, loadModule: PagefindModuleLoader): FarmSearchClient;
|
|
52
|
+
export {};
|
|
53
|
+
//# sourceMappingURL=client-runtime.d.ts.map
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
export function createSearchClient(resolveConfig, options, loadModule) {
|
|
2
|
+
let loaded;
|
|
3
|
+
const getApi = async () => {
|
|
4
|
+
const config = resolveConfig();
|
|
5
|
+
if (!config.available && !options.bundlePath) {
|
|
6
|
+
throw new Error("[farm:search] The search index is generated by production builds. " +
|
|
7
|
+
"Run farm build and serve the production output before searching.");
|
|
8
|
+
}
|
|
9
|
+
const bundlePath = normalizeBundlePath(options.bundlePath ?? config.bundlePath);
|
|
10
|
+
if (!loaded) {
|
|
11
|
+
const loading = loadModule(`${bundlePath}pagefind.js`)
|
|
12
|
+
.then(async (module) => {
|
|
13
|
+
const highlightParam = options.highlightParam === false
|
|
14
|
+
? undefined
|
|
15
|
+
: (options.highlightParam ?? config.highlightParam);
|
|
16
|
+
const pagefindOptions = {
|
|
17
|
+
basePath: bundlePath,
|
|
18
|
+
excerptLength: normalizePositiveInteger(options.excerptLength ?? config.excerptLength, "excerptLength"),
|
|
19
|
+
...(highlightParam ? { highlightParam } : {}),
|
|
20
|
+
};
|
|
21
|
+
const api = module.createInstance ? await module.createInstance(pagefindOptions) : module;
|
|
22
|
+
if (!module.createInstance)
|
|
23
|
+
await api.options(pagefindOptions);
|
|
24
|
+
await api.init();
|
|
25
|
+
return { api, bundlePath };
|
|
26
|
+
})
|
|
27
|
+
.catch((error) => {
|
|
28
|
+
loaded = undefined;
|
|
29
|
+
throw error;
|
|
30
|
+
});
|
|
31
|
+
loaded = loading;
|
|
32
|
+
}
|
|
33
|
+
const current = await loaded;
|
|
34
|
+
if (current.bundlePath !== bundlePath) {
|
|
35
|
+
await current.api.destroy();
|
|
36
|
+
loaded = undefined;
|
|
37
|
+
return getApi();
|
|
38
|
+
}
|
|
39
|
+
return current.api;
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
async search(term, queryOptions = {}) {
|
|
43
|
+
if (term !== null && typeof term !== "string") {
|
|
44
|
+
throw new TypeError("Search term must be a string or null");
|
|
45
|
+
}
|
|
46
|
+
const limit = normalizePositiveInteger(queryOptions.limit ?? 20, "limit");
|
|
47
|
+
const offset = normalizeOffset(queryOptions.offset ?? 0);
|
|
48
|
+
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
|
+
};
|
|
61
|
+
},
|
|
62
|
+
async preload(term, queryOptions = {}) {
|
|
63
|
+
if (typeof term !== "string")
|
|
64
|
+
throw new TypeError("Search preload term must be a string");
|
|
65
|
+
const pagefindOptions = engineOptions(queryOptions);
|
|
66
|
+
const api = await getApi();
|
|
67
|
+
await api.preload(term, pagefindOptions);
|
|
68
|
+
},
|
|
69
|
+
async filters() {
|
|
70
|
+
const api = await getApi();
|
|
71
|
+
return api.filters();
|
|
72
|
+
},
|
|
73
|
+
async destroy() {
|
|
74
|
+
if (!loaded)
|
|
75
|
+
return;
|
|
76
|
+
const current = await loaded;
|
|
77
|
+
loaded = undefined;
|
|
78
|
+
await current.api.destroy();
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function engineOptions(options) {
|
|
83
|
+
if (options.sort) {
|
|
84
|
+
const entries = Object.entries(options.sort);
|
|
85
|
+
if (entries.length !== 1) {
|
|
86
|
+
throw new TypeError("Search sort must contain exactly one field");
|
|
87
|
+
}
|
|
88
|
+
const [field, direction] = entries[0];
|
|
89
|
+
if (!field.trim() || (direction !== "asc" && direction !== "desc")) {
|
|
90
|
+
throw new TypeError("Search sort must use a non-empty field and asc or desc direction");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
...(options.filters ? { filters: options.filters } : {}),
|
|
95
|
+
...(options.sort ? { sort: options.sort } : {}),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
async function loadResult(handle) {
|
|
99
|
+
const data = await handle.data();
|
|
100
|
+
return {
|
|
101
|
+
id: handle.id,
|
|
102
|
+
score: handle.score,
|
|
103
|
+
url: data.url,
|
|
104
|
+
title: data.meta.title || data.sub_results[0]?.title || data.url,
|
|
105
|
+
excerpt: data.excerpt,
|
|
106
|
+
plainExcerpt: data.plain_excerpt,
|
|
107
|
+
content: data.content,
|
|
108
|
+
wordCount: data.word_count,
|
|
109
|
+
words: handle.words,
|
|
110
|
+
matchedMetaFields: handle.matchedMetaFields ?? [],
|
|
111
|
+
meta: data.meta,
|
|
112
|
+
filters: data.filters,
|
|
113
|
+
subResults: data.sub_results.map((result) => ({
|
|
114
|
+
title: result.title,
|
|
115
|
+
url: result.url,
|
|
116
|
+
excerpt: result.excerpt,
|
|
117
|
+
plainExcerpt: result.plain_excerpt,
|
|
118
|
+
})),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function combineTimings(timings) {
|
|
122
|
+
if (!Array.isArray(timings))
|
|
123
|
+
return timings;
|
|
124
|
+
return timings.reduce((total, value) => ({
|
|
125
|
+
preload: total.preload + value.preload,
|
|
126
|
+
search: total.search + value.search,
|
|
127
|
+
total: total.total + value.total,
|
|
128
|
+
}), { preload: 0, search: 0, total: 0 });
|
|
129
|
+
}
|
|
130
|
+
function normalizeBundlePath(value) {
|
|
131
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
132
|
+
throw new TypeError("Search bundlePath must be a non-empty URL path");
|
|
133
|
+
}
|
|
134
|
+
const normalized = value.trim();
|
|
135
|
+
return normalized.endsWith("/") ? normalized : `${normalized}/`;
|
|
136
|
+
}
|
|
137
|
+
function normalizePositiveInteger(value, label) {
|
|
138
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
139
|
+
throw new TypeError(`Search ${label} must be a positive integer`);
|
|
140
|
+
}
|
|
141
|
+
return value;
|
|
142
|
+
}
|
|
143
|
+
function normalizeOffset(value) {
|
|
144
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
145
|
+
throw new TypeError("Search offset must be a non-negative integer");
|
|
146
|
+
}
|
|
147
|
+
return value;
|
|
148
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { FarmSearchClient, FarmSearchPublicConfig, SearchClientOptions } from "./types.js";
|
|
2
|
+
export type { FarmSearchClient, SearchClientOptions, SearchFilterCounts, SearchFilterExpression, SearchFilterPrimitive, SearchFilters, SearchQueryOptions, SearchResponse, SearchResult, SearchSort, SearchSubResult, SearchTimings, } from "./types.js";
|
|
3
|
+
/** @internal Configured by the Farm client plugin before hydration. */
|
|
4
|
+
export declare function configureFarmSearch(config: FarmSearchPublicConfig): void;
|
|
5
|
+
/** Create a lazy, renderer-neutral client for the generated static search index. */
|
|
6
|
+
export declare function createSearch(options?: SearchClientOptions): FarmSearchClient;
|
|
7
|
+
//# sourceMappingURL=client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,sBAAsB,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAEhG,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;AAQpB,uEAAuE;AACvE,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,sBAAsB,GAAG,IAAI,CAExE;AAED,oFAAoF;AACpF,wBAAgB,YAAY,CAAC,OAAO,GAAE,mBAAwB,GAAG,gBAAgB,CAMhF"}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createSearchClient } from "./client-runtime.js";
|
|
2
|
+
let configuredSearch = {
|
|
3
|
+
available: false,
|
|
4
|
+
bundlePath: "/_farm/search/",
|
|
5
|
+
excerptLength: 30,
|
|
6
|
+
};
|
|
7
|
+
/** @internal Configured by the Farm client plugin before hydration. */
|
|
8
|
+
export function configureFarmSearch(config) {
|
|
9
|
+
configuredSearch = { ...config };
|
|
10
|
+
}
|
|
11
|
+
/** Create a lazy, renderer-neutral client for the generated static search index. */
|
|
12
|
+
export function createSearch(options = {}) {
|
|
13
|
+
return createSearchClient(() => configuredSearch, options, (url) => import(/* @vite-ignore */ url));
|
|
14
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface SearchOptions {
|
|
2
|
+
/** URL patterns to index. Patterns are matched without Farm's basePath. */
|
|
3
|
+
include?: string[];
|
|
4
|
+
/** URL patterns to omit. Patterns are matched without Farm's basePath. */
|
|
5
|
+
exclude?: string[];
|
|
6
|
+
/** Static bundle directory, relative to Farm's basePath. */
|
|
7
|
+
output?: string;
|
|
8
|
+
/** Restrict indexed document content to one CSS selector. */
|
|
9
|
+
rootSelector?: string;
|
|
10
|
+
/** Additional CSS selectors Pagefind should ignore. */
|
|
11
|
+
excludeSelectors?: string[];
|
|
12
|
+
/** Force every page into one ISO 639-1 language index. */
|
|
13
|
+
language?: string;
|
|
14
|
+
/** Characters Pagefind should preserve inside indexed words. */
|
|
15
|
+
includeCharacters?: string;
|
|
16
|
+
/** Keep index.html in result URLs. */
|
|
17
|
+
keepIndexUrl?: boolean;
|
|
18
|
+
/** Number of words in generated result excerpts. */
|
|
19
|
+
excerptLength?: number;
|
|
20
|
+
/** Query parameter used by Pagefind's optional result highlighting. */
|
|
21
|
+
highlightParam?: string | false;
|
|
22
|
+
/** Print Pagefind's detailed build diagnostics. */
|
|
23
|
+
verbose?: boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface ResolvedSearchOptions {
|
|
26
|
+
include: string[];
|
|
27
|
+
exclude: string[];
|
|
28
|
+
output: string;
|
|
29
|
+
rootSelector?: string;
|
|
30
|
+
excludeSelectors: string[];
|
|
31
|
+
language?: string;
|
|
32
|
+
includeCharacters?: string;
|
|
33
|
+
keepIndexUrl: boolean;
|
|
34
|
+
excerptLength: number;
|
|
35
|
+
highlightParam?: string;
|
|
36
|
+
verbose: boolean;
|
|
37
|
+
}
|
|
38
|
+
export declare function resolveSearchOptions(options?: SearchOptions): ResolvedSearchOptions;
|
|
39
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,2EAA2E;IAC3E,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,0EAA0E;IAC1E,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,4DAA4D;IAC5D,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6DAA6D;IAC7D,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,uDAAuD;IACvD,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC5B,0DAA0D;IAC1D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,gEAAgE;IAChE,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,sCAAsC;IACtC,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,oDAAoD;IACpD,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,uEAAuE;IACvE,cAAc,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;IAChC,mDAAmD;IACnD,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,YAAY,EAAE,OAAO,CAAC;IACtB,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,OAAO,EAAE,OAAO,CAAC;CAClB;AAID,wBAAgB,oBAAoB,CAAC,OAAO,GAAE,aAAkB,GAAG,qBAAqB,CAuBvF"}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const DEFAULT_OUTPUT = "_farm/search";
|
|
2
|
+
export function resolveSearchOptions(options = {}) {
|
|
3
|
+
if ("enabled" in options) {
|
|
4
|
+
throw new TypeError("Search does not accept enabled; remove search() from plugins to disable it");
|
|
5
|
+
}
|
|
6
|
+
return {
|
|
7
|
+
include: normalizePatterns(options.include ?? ["/**"], "include"),
|
|
8
|
+
exclude: normalizePatterns(options.exclude ?? [], "exclude"),
|
|
9
|
+
output: normalizeOutput(options.output ?? DEFAULT_OUTPUT),
|
|
10
|
+
rootSelector: normalizeOptionalString(options.rootSelector, "rootSelector"),
|
|
11
|
+
excludeSelectors: normalizeStrings(options.excludeSelectors ?? [], "excludeSelectors"),
|
|
12
|
+
language: normalizeLanguage(options.language),
|
|
13
|
+
includeCharacters: normalizeOptionalString(options.includeCharacters, "includeCharacters"),
|
|
14
|
+
keepIndexUrl: normalizeBoolean(options.keepIndexUrl, "keepIndexUrl") ?? false,
|
|
15
|
+
excerptLength: normalizeExcerptLength(options.excerptLength ?? 30),
|
|
16
|
+
highlightParam: options.highlightParam === false
|
|
17
|
+
? undefined
|
|
18
|
+
: normalizeOptionalString(options.highlightParam, "highlightParam"),
|
|
19
|
+
verbose: normalizeBoolean(options.verbose, "verbose") ?? false,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function normalizePatterns(values, label) {
|
|
23
|
+
const patterns = normalizeStrings(values, label).map((value) => {
|
|
24
|
+
const normalized = value === "/" ? "/" : `/${value.replace(/^\/+|\/+$/g, "")}`;
|
|
25
|
+
if (normalized.includes("\\")) {
|
|
26
|
+
throw new TypeError(`Search ${label} patterns must use URL separators`);
|
|
27
|
+
}
|
|
28
|
+
return normalized;
|
|
29
|
+
});
|
|
30
|
+
return [...new Set(patterns)];
|
|
31
|
+
}
|
|
32
|
+
function normalizeStrings(values, label) {
|
|
33
|
+
if (!Array.isArray(values))
|
|
34
|
+
throw new TypeError(`Search ${label} must be an array of strings`);
|
|
35
|
+
return values.map((value) => {
|
|
36
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
37
|
+
throw new TypeError(`Search ${label} must contain non-empty strings`);
|
|
38
|
+
}
|
|
39
|
+
return value.trim();
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
function normalizeOptionalString(value, label) {
|
|
43
|
+
if (value === undefined)
|
|
44
|
+
return undefined;
|
|
45
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
46
|
+
throw new TypeError(`Search ${label} must be a non-empty string`);
|
|
47
|
+
}
|
|
48
|
+
return value.trim();
|
|
49
|
+
}
|
|
50
|
+
function normalizeOutput(value) {
|
|
51
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
52
|
+
throw new TypeError("Search output must be a non-empty relative URL path");
|
|
53
|
+
}
|
|
54
|
+
const normalized = value.trim().replace(/^\/+|\/+$/g, "");
|
|
55
|
+
const segments = normalized.split("/");
|
|
56
|
+
if (!normalized ||
|
|
57
|
+
value.includes("\\") ||
|
|
58
|
+
value.includes("?") ||
|
|
59
|
+
value.includes("#") ||
|
|
60
|
+
segments.some((segment) => segment === "." || segment === ".." || !segment)) {
|
|
61
|
+
throw new TypeError("Search output must stay inside the public output directory");
|
|
62
|
+
}
|
|
63
|
+
return normalized;
|
|
64
|
+
}
|
|
65
|
+
function normalizeLanguage(value) {
|
|
66
|
+
const normalized = normalizeOptionalString(value, "language")?.toLowerCase();
|
|
67
|
+
if (normalized && !/^[a-z]{2}$/.test(normalized)) {
|
|
68
|
+
throw new TypeError("Search language must be a two-letter ISO 639-1 code");
|
|
69
|
+
}
|
|
70
|
+
return normalized;
|
|
71
|
+
}
|
|
72
|
+
function normalizeExcerptLength(value) {
|
|
73
|
+
if (!Number.isInteger(value) || value < 1 || value > 1_000) {
|
|
74
|
+
throw new TypeError("Search excerptLength must be an integer from 1 to 1000");
|
|
75
|
+
}
|
|
76
|
+
return value;
|
|
77
|
+
}
|
|
78
|
+
function normalizeBoolean(value, label) {
|
|
79
|
+
if (value !== undefined && typeof value !== "boolean") {
|
|
80
|
+
throw new TypeError(`Search ${label} must be a boolean`);
|
|
81
|
+
}
|
|
82
|
+
return value;
|
|
83
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type SearchOptions } from "./config.js";
|
|
2
|
+
export type { ResolvedSearchOptions, SearchOptions } from "./config.js";
|
|
3
|
+
export type { FarmSearchClient, SearchClientOptions, SearchFilterCounts, SearchFilterExpression, SearchFilterPrimitive, SearchFilters, SearchQueryOptions, SearchResponse, SearchResult, SearchSort, SearchSubResult, SearchTimings, } from "./types.js";
|
|
4
|
+
/** 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
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +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"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { definePlugin } from "@farm.js/core/plugin";
|
|
2
|
+
import { normalizeBasePath, withBasePath, writeSearchIndex } from "./build.js";
|
|
3
|
+
import { resolveSearchOptions } from "./config.js";
|
|
4
|
+
/** Build a static Pagefind index from Farm's emitted HTML pages. */
|
|
5
|
+
export function search(options = {}) {
|
|
6
|
+
const resolved = resolveSearchOptions(options);
|
|
7
|
+
let configuredBasePath = "/";
|
|
8
|
+
let generated = false;
|
|
9
|
+
const publicConfig = {
|
|
10
|
+
available: false,
|
|
11
|
+
bundlePath: `/${resolved.output}/`,
|
|
12
|
+
excerptLength: resolved.excerptLength,
|
|
13
|
+
...(resolved.highlightParam ? { highlightParam: resolved.highlightParam } : {}),
|
|
14
|
+
};
|
|
15
|
+
return definePlugin({
|
|
16
|
+
name: "farm:search",
|
|
17
|
+
enforce: "post",
|
|
18
|
+
configure(config) {
|
|
19
|
+
if ((config.plugins ?? []).filter((candidate) => candidate.name === "farm:search").length > 1) {
|
|
20
|
+
throw new Error("[farm:search] Configure search in one search() plugin instance");
|
|
21
|
+
}
|
|
22
|
+
configuredBasePath = normalizeBasePath(config.basePath);
|
|
23
|
+
publicConfig.bundlePath = `${withBasePath(`/${resolved.output}`, configuredBasePath)}/`;
|
|
24
|
+
},
|
|
25
|
+
build: {
|
|
26
|
+
before() {
|
|
27
|
+
generated = false;
|
|
28
|
+
},
|
|
29
|
+
configure(nitroConfig) {
|
|
30
|
+
const hooks = nitroConfig.hooks ?? {};
|
|
31
|
+
const previousPrerenderDone = hooks["prerender:done"];
|
|
32
|
+
const generate = async () => {
|
|
33
|
+
if (generated)
|
|
34
|
+
return;
|
|
35
|
+
const result = await writeSearchIndex({
|
|
36
|
+
outputDir: nitroConfig.output?.dir ?? ".farm/.output",
|
|
37
|
+
publicDir: nitroConfig.output?.publicDir,
|
|
38
|
+
preset: nitroConfig.preset ?? "node-server",
|
|
39
|
+
basePath: configuredBasePath,
|
|
40
|
+
options: resolved,
|
|
41
|
+
});
|
|
42
|
+
generated = true;
|
|
43
|
+
console.info(`[farm:search] Indexed ${result.indexedRoutes.length} static page${result.indexedRoutes.length === 1 ? "" : "s"} at ${result.bundlePath}`);
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
...nitroConfig,
|
|
47
|
+
hooks: {
|
|
48
|
+
...hooks,
|
|
49
|
+
"prerender:done": async (...args) => {
|
|
50
|
+
await callConfiguredHooks(previousPrerenderDone, args);
|
|
51
|
+
await generate();
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
async after(result) {
|
|
57
|
+
if (!result.success || generated)
|
|
58
|
+
return;
|
|
59
|
+
const built = await writeSearchIndex({
|
|
60
|
+
outputDir: result.outputDir ?? `${result.root}/.farm/.output`,
|
|
61
|
+
preset: result.preset,
|
|
62
|
+
basePath: configuredBasePath,
|
|
63
|
+
options: resolved,
|
|
64
|
+
});
|
|
65
|
+
generated = true;
|
|
66
|
+
console.info(`[farm:search] Indexed ${built.indexedRoutes.length} static page${built.indexedRoutes.length === 1 ? "" : "s"} at ${built.bundlePath}`);
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
client: {
|
|
70
|
+
public: publicConfig,
|
|
71
|
+
async setup({ public: config, isProd }) {
|
|
72
|
+
const runtime = await import("@farm.js/search/client");
|
|
73
|
+
runtime.configureFarmSearch({ ...config, available: isProd });
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
async function callConfiguredHooks(value, args) {
|
|
79
|
+
const hooks = Array.isArray(value) ? value : [value];
|
|
80
|
+
for (const hook of hooks) {
|
|
81
|
+
if (typeof hook === "function")
|
|
82
|
+
await hook(...args);
|
|
83
|
+
}
|
|
84
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export type SearchFilterPrimitive = string | number | boolean;
|
|
2
|
+
export type SearchFilterExpression = SearchFilterPrimitive | SearchFilterPrimitive[] | {
|
|
3
|
+
[key: string]: SearchFilterExpression | SearchFilterExpression[];
|
|
4
|
+
};
|
|
5
|
+
export type SearchFilters = Record<string, SearchFilterExpression | SearchFilterExpression[]>;
|
|
6
|
+
export type SearchSort = Record<string, "asc" | "desc">;
|
|
7
|
+
export type SearchFilterCounts = Record<string, Record<string, number>>;
|
|
8
|
+
export interface SearchQueryOptions {
|
|
9
|
+
/** Pagefind filter expression. */
|
|
10
|
+
filters?: SearchFilters;
|
|
11
|
+
/** One Pagefind sort key and direction. Sorting replaces relevance order. */
|
|
12
|
+
sort?: SearchSort;
|
|
13
|
+
/** Number of fully loaded results. Defaults to 20. */
|
|
14
|
+
limit?: number;
|
|
15
|
+
/** Number of result handles to skip before loading. Defaults to 0. */
|
|
16
|
+
offset?: number;
|
|
17
|
+
}
|
|
18
|
+
export interface SearchClientOptions {
|
|
19
|
+
/** Override the generated search bundle URL. */
|
|
20
|
+
bundlePath?: string;
|
|
21
|
+
/** Override the configured result excerpt length. */
|
|
22
|
+
excerptLength?: number;
|
|
23
|
+
/** Add the query to result links through this parameter. */
|
|
24
|
+
highlightParam?: string | false;
|
|
25
|
+
}
|
|
26
|
+
export interface SearchSubResult {
|
|
27
|
+
title: string;
|
|
28
|
+
url: string;
|
|
29
|
+
excerpt: string;
|
|
30
|
+
plainExcerpt: string;
|
|
31
|
+
}
|
|
32
|
+
export interface SearchResult {
|
|
33
|
+
id: string;
|
|
34
|
+
score: number;
|
|
35
|
+
url: string;
|
|
36
|
+
title: string;
|
|
37
|
+
excerpt: string;
|
|
38
|
+
/** Excerpt without Pagefind's mark elements. Safe to render as ordinary text. */
|
|
39
|
+
plainExcerpt: string;
|
|
40
|
+
content: string;
|
|
41
|
+
wordCount: number;
|
|
42
|
+
words: number[];
|
|
43
|
+
matchedMetaFields: string[];
|
|
44
|
+
meta: Record<string, string>;
|
|
45
|
+
filters: Record<string, string[]>;
|
|
46
|
+
subResults: SearchSubResult[];
|
|
47
|
+
}
|
|
48
|
+
export interface SearchTimings {
|
|
49
|
+
preload: number;
|
|
50
|
+
search: number;
|
|
51
|
+
total: number;
|
|
52
|
+
}
|
|
53
|
+
export interface SearchResponse {
|
|
54
|
+
results: SearchResult[];
|
|
55
|
+
/** Number of matching results after filters, before offset and limit. */
|
|
56
|
+
total: number;
|
|
57
|
+
/** Number of results that matched the term before filters. */
|
|
58
|
+
unfilteredTotal: number;
|
|
59
|
+
filters: SearchFilterCounts;
|
|
60
|
+
totalFilters: SearchFilterCounts;
|
|
61
|
+
timings: SearchTimings;
|
|
62
|
+
}
|
|
63
|
+
export interface FarmSearchClient {
|
|
64
|
+
search(term: string | null, options?: SearchQueryOptions): Promise<SearchResponse>;
|
|
65
|
+
preload(term: string, options?: Pick<SearchQueryOptions, "filters" | "sort">): Promise<void>;
|
|
66
|
+
filters(): Promise<SearchFilterCounts>;
|
|
67
|
+
destroy(): Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
export interface FarmSearchPublicConfig {
|
|
70
|
+
available: boolean;
|
|
71
|
+
bundlePath: string;
|
|
72
|
+
excerptLength: number;
|
|
73
|
+
highlightParam?: string;
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@farm.js/search",
|
|
3
|
+
"version": "0.1.0-beta.0",
|
|
4
|
+
"description": "Build-time static page search for Farm.js applications",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"farm.js",
|
|
7
|
+
"pagefind",
|
|
8
|
+
"search",
|
|
9
|
+
"static-site",
|
|
10
|
+
"typescript"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "https://github.com/farming-labs/farm.js",
|
|
16
|
+
"directory": "packages/farm-search"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"main": "./dist/index.js",
|
|
23
|
+
"module": "./dist/index.js",
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"import": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./client": {
|
|
31
|
+
"types": "./dist/client.d.ts",
|
|
32
|
+
"import": "./dist/client.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"pagefind": "^1.5.2",
|
|
40
|
+
"@farm.js/core": "0.1.0-beta.92"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^20.10.5",
|
|
44
|
+
"typescript": "^5.3.3",
|
|
45
|
+
"vitest": "^3.2.7"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=22.13.0"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc",
|
|
52
|
+
"dev": "tsc --watch",
|
|
53
|
+
"type-check": "tsc --noEmit",
|
|
54
|
+
"test": "vitest run"
|
|
55
|
+
}
|
|
56
|
+
}
|