@nikala-ui/folio-algolia 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -13
- package/dist/indexing/client.d.ts +4 -0
- package/dist/indexing/client.js +76 -0
- package/dist/indexing/env.d.ts +4 -0
- package/dist/indexing/env.js +17 -0
- package/dist/indexing/errors.d.ts +4 -0
- package/dist/indexing/errors.js +8 -0
- package/dist/indexing/index.d.ts +4 -0
- package/dist/indexing/index.js +3 -0
- package/dist/indexing/sync.d.ts +2 -0
- package/dist/indexing/sync.js +78 -0
- package/dist/indexing/types.d.ts +29 -0
- package/dist/indexing/types.js +1 -0
- package/package.json +7 -3
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Folio.
|
|
|
10
10
|
|
|
11
11
|
## Requirements
|
|
12
12
|
|
|
13
|
-
- Folio `0.
|
|
13
|
+
- Folio `0.14.0` or newer;
|
|
14
14
|
- an Algolia index containing the same page URLs used by Folio;
|
|
15
15
|
- an Algolia Search-Only API key.
|
|
16
16
|
|
|
@@ -66,8 +66,10 @@ are public by design; use only a Search-Only key.
|
|
|
66
66
|
|
|
67
67
|
## Index records
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
The indexing entrypoint is server-only. It accepts the page catalog produced by
|
|
70
|
+
Folio and uploads records with an Algolia Admin API key. Use the Folio page URL
|
|
71
|
+
as Algolia's stable object ID. The helper preserves the page metadata needed
|
|
72
|
+
by Folio:
|
|
71
73
|
|
|
72
74
|
```ts
|
|
73
75
|
import { toAlgoliaRecord } from "@nikala-ui/folio-algolia";
|
|
@@ -79,8 +81,68 @@ The generated record contains `objectID`, `url`, `slug`, `title`,
|
|
|
79
81
|
`description`, `metadata` (frontmatter), and `headings` (table-of-contents
|
|
80
82
|
text).
|
|
81
83
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
+
## Synchronize the index
|
|
85
|
+
|
|
86
|
+
Create a script in the consuming Folio project, for example
|
|
87
|
+
`scripts/index-search.ts`:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import path from "node:path";
|
|
91
|
+
import { DEFAULT_DOCS_CONFIG, loadConfig } from "@nikala-ui/folio/config";
|
|
92
|
+
import { scanContent } from "@nikala-ui/folio/content";
|
|
93
|
+
import {
|
|
94
|
+
createAlgoliaIndexer,
|
|
95
|
+
getAlgoliaIndexerOptions,
|
|
96
|
+
} from "@nikala-ui/folio-algolia/indexing";
|
|
97
|
+
|
|
98
|
+
const projectRoot = process.cwd();
|
|
99
|
+
const config = await loadConfig(projectRoot);
|
|
100
|
+
const contentDir = path.resolve(
|
|
101
|
+
projectRoot,
|
|
102
|
+
config.contentDir ?? DEFAULT_DOCS_CONFIG.contentDir,
|
|
103
|
+
);
|
|
104
|
+
const pages = await scanContent(contentDir);
|
|
105
|
+
const indexer = createAlgoliaIndexer(getAlgoliaIndexerOptions());
|
|
106
|
+
const dryRun = process.argv.includes("--dry-run");
|
|
107
|
+
const summary = await indexer.sync(pages, { dryRun });
|
|
108
|
+
|
|
109
|
+
console.log(summary);
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The indexing script runs outside the browser and reads these server-only
|
|
113
|
+
variables:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
ALGOLIA_APP_ID=your_application_id
|
|
117
|
+
ALGOLIA_ADMIN_API_KEY=your_admin_key
|
|
118
|
+
ALGOLIA_INDEX=your_index_name
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Run a write-free validation first, then perform the upload:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
bun run scripts/index-search.ts --dry-run
|
|
125
|
+
bun run scripts/index-search.ts
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The current sync operation uses Algolia's `updateObject` batch action, so
|
|
129
|
+
re-running it safely updates existing records and creates missing records.
|
|
130
|
+
|
|
131
|
+
Use full synchronization when records removed from the documentation should
|
|
132
|
+
also be removed from Algolia:
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
const summary = await indexer.sync(pages, {
|
|
136
|
+
mode: "full",
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Full synchronization browses existing Algolia `objectID` values, compares
|
|
141
|
+
them with the current Folio catalog, and deletes stale records. Transient
|
|
142
|
+
`408`, `429`, and `5xx` responses are retried automatically. Configure
|
|
143
|
+
`maxRetries` and `retryDelayMs` when the deployment environment needs a
|
|
144
|
+
different policy. Use `continueOnError: true` only when the caller wants a
|
|
145
|
+
summary containing failed batch counts instead of stopping at the first error.
|
|
84
146
|
|
|
85
147
|
## Manual adapter construction
|
|
86
148
|
|
|
@@ -97,16 +159,26 @@ const adapter = createAlgoliaAdapter({
|
|
|
97
159
|
});
|
|
98
160
|
```
|
|
99
161
|
|
|
100
|
-
|
|
162
|
+
Most Folio sites should use the exported `algoliaAdapter` instance instead.
|
|
163
|
+
|
|
164
|
+
## Migration from 0.1.x
|
|
165
|
+
|
|
166
|
+
Version `0.2.0` adds the server-only indexing entrypoint at
|
|
167
|
+
`@nikala-ui/folio-algolia/indexing` and requires Folio `0.14.0` or newer for
|
|
168
|
+
the configured content-directory workflow. Existing browser-side adapter
|
|
169
|
+
configuration remains compatible:
|
|
101
170
|
|
|
102
171
|
```ts
|
|
103
|
-
import {
|
|
172
|
+
import { algoliaAdapter } from "@nikala-ui/folio-algolia";
|
|
104
173
|
|
|
105
|
-
|
|
174
|
+
export default {
|
|
175
|
+
search: {
|
|
176
|
+
enabled: true,
|
|
177
|
+
provider: algoliaAdapter,
|
|
178
|
+
},
|
|
179
|
+
};
|
|
106
180
|
```
|
|
107
181
|
|
|
108
|
-
Most Folio sites should use the exported `algoliaAdapter` instance instead.
|
|
109
|
-
|
|
110
182
|
## Matching behavior
|
|
111
183
|
|
|
112
184
|
Folio passes the query and its local page catalog to the adapter. Algolia
|
|
@@ -139,9 +211,9 @@ bun run build
|
|
|
139
211
|
```
|
|
140
212
|
|
|
141
213
|
The package contains the Folio adapter contract, Algolia query client,
|
|
142
|
-
environment resolution, and record mapping.
|
|
143
|
-
|
|
144
|
-
indexing service.
|
|
214
|
+
server-side batch indexing, environment resolution, and record mapping.
|
|
215
|
+
Crawling and deployment orchestration remain in the consuming project or a
|
|
216
|
+
separate indexing service.
|
|
145
217
|
|
|
146
218
|
## License
|
|
147
219
|
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { AlgoliaIndexerOptions } from "./types.js";
|
|
2
|
+
export declare function uploadBatch(options: AlgoliaIndexerOptions, records: Record<string, unknown>[]): Promise<void>;
|
|
3
|
+
export declare function deleteBatch(options: AlgoliaIndexerOptions, objectIDs: string[]): Promise<void>;
|
|
4
|
+
export declare function browseObjectIDs(options: AlgoliaIndexerOptions): Promise<string[]>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { AlgoliaIndexingError } from "./errors.js";
|
|
2
|
+
const DEFAULT_INDEXING_ENDPOINT = "https://{appId}.algolia.net";
|
|
3
|
+
const DEFAULT_MAX_RETRIES = 2;
|
|
4
|
+
const DEFAULT_RETRY_DELAY_MS = 250;
|
|
5
|
+
function createBatchUrl(endpoint, indexName) {
|
|
6
|
+
return `${endpoint.replace(/\/$/, "")}/1/indexes/${encodeURIComponent(indexName)}/batch`;
|
|
7
|
+
}
|
|
8
|
+
function resolveEndpoint(options) {
|
|
9
|
+
return options.endpoint
|
|
10
|
+
?? DEFAULT_INDEXING_ENDPOINT.replace("{appId}", options.appId);
|
|
11
|
+
}
|
|
12
|
+
function isRetryableStatus(status) {
|
|
13
|
+
return status === 408 || status === 429 || status >= 500;
|
|
14
|
+
}
|
|
15
|
+
function wait(milliseconds) {
|
|
16
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
17
|
+
}
|
|
18
|
+
function createHeaders(options) {
|
|
19
|
+
return {
|
|
20
|
+
"Content-Type": "application/json",
|
|
21
|
+
"X-Algolia-Application-Id": options.appId,
|
|
22
|
+
"X-Algolia-API-Key": options.adminApiKey,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
async function requestWithRetry(options, url, init) {
|
|
26
|
+
const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
27
|
+
const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
|
|
28
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
29
|
+
for (let attempt = 0;; attempt += 1) {
|
|
30
|
+
const response = await fetchImpl(url, init);
|
|
31
|
+
if (response.ok || !isRetryableStatus(response.status) || attempt >= maxRetries) {
|
|
32
|
+
return response;
|
|
33
|
+
}
|
|
34
|
+
await wait(retryDelayMs * (attempt + 1));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async function sendBatch(options, requests) {
|
|
38
|
+
const response = await requestWithRetry(options, createBatchUrl(resolveEndpoint(options), options.indexName), {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: createHeaders(options),
|
|
41
|
+
body: JSON.stringify({ requests }),
|
|
42
|
+
});
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
throw new AlgoliaIndexingError(`Algolia indexing failed with status ${response.status}`, response.status);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export async function uploadBatch(options, records) {
|
|
48
|
+
await sendBatch(options, records.map((body) => ({ action: "updateObject", body })));
|
|
49
|
+
}
|
|
50
|
+
export async function deleteBatch(options, objectIDs) {
|
|
51
|
+
await sendBatch(options, objectIDs.map((objectID) => ({
|
|
52
|
+
action: "deleteObject",
|
|
53
|
+
body: { objectID },
|
|
54
|
+
})));
|
|
55
|
+
}
|
|
56
|
+
export async function browseObjectIDs(options) {
|
|
57
|
+
const objectIDs = [];
|
|
58
|
+
let cursor;
|
|
59
|
+
do {
|
|
60
|
+
const response = await requestWithRetry(options, `${resolveEndpoint(options).replace(/\/$/, "")}/1/indexes/${encodeURIComponent(options.indexName)}/browse`, {
|
|
61
|
+
method: "POST",
|
|
62
|
+
headers: createHeaders(options),
|
|
63
|
+
body: JSON.stringify(cursor ? { cursor } : { attributesToRetrieve: ["objectID"] }),
|
|
64
|
+
});
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
throw new AlgoliaIndexingError(`Algolia browse failed with status ${response.status}`, response.status);
|
|
67
|
+
}
|
|
68
|
+
const data = await response.json();
|
|
69
|
+
for (const hit of data.hits ?? []) {
|
|
70
|
+
if (typeof hit.objectID === "string")
|
|
71
|
+
objectIDs.push(hit.objectID);
|
|
72
|
+
}
|
|
73
|
+
cursor = data.cursor;
|
|
74
|
+
} while (cursor);
|
|
75
|
+
return objectIDs;
|
|
76
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
function getRuntimeEnv() {
|
|
2
|
+
const runtime = globalThis;
|
|
3
|
+
return runtime.process?.env ?? runtime.Bun?.env ?? {};
|
|
4
|
+
}
|
|
5
|
+
function requiredIndexerEnv(name, env) {
|
|
6
|
+
const value = env[name];
|
|
7
|
+
if (value)
|
|
8
|
+
return value;
|
|
9
|
+
throw new Error(`Missing required indexing environment variable: ${name}`);
|
|
10
|
+
}
|
|
11
|
+
export function getAlgoliaIndexerOptions(env = getRuntimeEnv()) {
|
|
12
|
+
return {
|
|
13
|
+
appId: requiredIndexerEnv("ALGOLIA_APP_ID", env),
|
|
14
|
+
adminApiKey: requiredIndexerEnv("ALGOLIA_ADMIN_API_KEY", env),
|
|
15
|
+
indexName: requiredIndexerEnv("ALGOLIA_INDEX", env),
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { createAlgoliaIndexer } from "./sync.js";
|
|
2
|
+
export { AlgoliaIndexingError } from "./errors.js";
|
|
3
|
+
export { getAlgoliaIndexerOptions } from "./env.js";
|
|
4
|
+
export type { AlgoliaIndexer, AlgoliaIndexerOptions, AlgoliaSyncOptions, AlgoliaSyncMode, AlgoliaSyncSummary, } from "./types.js";
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { toAlgoliaRecord } from "../records.js";
|
|
2
|
+
import { browseObjectIDs, deleteBatch, uploadBatch } from "./client.js";
|
|
3
|
+
import { AlgoliaIndexingError } from "./errors.js";
|
|
4
|
+
const DEFAULT_BATCH_SIZE = 1000;
|
|
5
|
+
function assertServerRuntime() {
|
|
6
|
+
if (typeof window !== "undefined") {
|
|
7
|
+
throw new AlgoliaIndexingError("Algolia indexing is server-only and cannot run in a browser");
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function splitIntoBatches(items, size) {
|
|
11
|
+
const batches = [];
|
|
12
|
+
for (let index = 0; index < items.length; index += size) {
|
|
13
|
+
batches.push(items.slice(index, index + size));
|
|
14
|
+
}
|
|
15
|
+
return batches;
|
|
16
|
+
}
|
|
17
|
+
function createSummary(total, batches, dryRun) {
|
|
18
|
+
return {
|
|
19
|
+
total,
|
|
20
|
+
batches,
|
|
21
|
+
uploaded: 0,
|
|
22
|
+
deleted: 0,
|
|
23
|
+
deleteBatches: 0,
|
|
24
|
+
failed: 0,
|
|
25
|
+
dryRun,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export function createAlgoliaIndexer(options) {
|
|
29
|
+
const batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE;
|
|
30
|
+
if (!Number.isInteger(batchSize) || batchSize < 1) {
|
|
31
|
+
throw new TypeError("Algolia indexer batchSize must be a positive integer");
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
async sync(pages, syncOptions = {}) {
|
|
35
|
+
assertServerRuntime();
|
|
36
|
+
const records = pages.map(toAlgoliaRecord);
|
|
37
|
+
const batches = splitIntoBatches(records, batchSize);
|
|
38
|
+
const mode = syncOptions.mode ?? "upsert";
|
|
39
|
+
const summary = createSummary(records.length, batches.length, Boolean(syncOptions.dryRun));
|
|
40
|
+
for (const batch of batches) {
|
|
41
|
+
if (syncOptions.dryRun)
|
|
42
|
+
continue;
|
|
43
|
+
try {
|
|
44
|
+
await uploadBatch(options, batch);
|
|
45
|
+
summary.uploaded += batch.length;
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
summary.failed += batch.length;
|
|
49
|
+
if (!syncOptions.continueOnError)
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (mode !== "full" || summary.failed > 0)
|
|
54
|
+
return summary;
|
|
55
|
+
const remoteObjectIDs = await browseObjectIDs(options);
|
|
56
|
+
const desiredObjectIDs = new Set(records.map((record) => String(record.objectID)));
|
|
57
|
+
const staleObjectIDs = remoteObjectIDs.filter((objectID) => !desiredObjectIDs.has(objectID));
|
|
58
|
+
const deleteBatches = splitIntoBatches(staleObjectIDs, batchSize);
|
|
59
|
+
summary.deleteBatches = deleteBatches.length;
|
|
60
|
+
for (const deleteBatchObjectIDs of deleteBatches) {
|
|
61
|
+
if (syncOptions.dryRun)
|
|
62
|
+
continue;
|
|
63
|
+
try {
|
|
64
|
+
await deleteBatch(options, deleteBatchObjectIDs);
|
|
65
|
+
summary.deleted += deleteBatchObjectIDs.length;
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
summary.failed += deleteBatchObjectIDs.length;
|
|
69
|
+
if (!syncOptions.continueOnError)
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (syncOptions.dryRun)
|
|
74
|
+
summary.deleted = staleObjectIDs.length;
|
|
75
|
+
return summary;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { FolioPage } from "../types.js";
|
|
2
|
+
export interface AlgoliaIndexerOptions {
|
|
3
|
+
appId: string;
|
|
4
|
+
adminApiKey: string;
|
|
5
|
+
indexName: string;
|
|
6
|
+
endpoint?: string;
|
|
7
|
+
batchSize?: number;
|
|
8
|
+
maxRetries?: number;
|
|
9
|
+
retryDelayMs?: number;
|
|
10
|
+
fetch?: typeof globalThis.fetch;
|
|
11
|
+
}
|
|
12
|
+
export type AlgoliaSyncMode = "upsert" | "full";
|
|
13
|
+
export interface AlgoliaSyncOptions {
|
|
14
|
+
dryRun?: boolean;
|
|
15
|
+
mode?: AlgoliaSyncMode;
|
|
16
|
+
continueOnError?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface AlgoliaSyncSummary {
|
|
19
|
+
total: number;
|
|
20
|
+
batches: number;
|
|
21
|
+
uploaded: number;
|
|
22
|
+
deleted: number;
|
|
23
|
+
deleteBatches: number;
|
|
24
|
+
failed: number;
|
|
25
|
+
dryRun: boolean;
|
|
26
|
+
}
|
|
27
|
+
export interface AlgoliaIndexer {
|
|
28
|
+
sync(pages: FolioPage[], options?: AlgoliaSyncOptions): Promise<AlgoliaSyncSummary>;
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nikala-ui/folio-algolia",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Algolia search adapter for Folio documentation sites",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
".": {
|
|
21
21
|
"types": "./dist/index.d.ts",
|
|
22
22
|
"import": "./dist/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./indexing": {
|
|
25
|
+
"types": "./dist/indexing/index.d.ts",
|
|
26
|
+
"import": "./dist/indexing/index.js"
|
|
23
27
|
}
|
|
24
28
|
},
|
|
25
29
|
"files": ["dist"],
|
|
@@ -31,10 +35,10 @@
|
|
|
31
35
|
"package:check": "bun run check && npm pack --dry-run"
|
|
32
36
|
},
|
|
33
37
|
"peerDependencies": {
|
|
34
|
-
"@nikala-ui/folio": ">=0.
|
|
38
|
+
"@nikala-ui/folio": ">=0.14.0"
|
|
35
39
|
},
|
|
36
40
|
"devDependencies": {
|
|
37
|
-
"@nikala-ui/folio": "^0.
|
|
41
|
+
"@nikala-ui/folio": "^0.14.0",
|
|
38
42
|
"typescript": "^7.0.2"
|
|
39
43
|
},
|
|
40
44
|
"publishConfig": {
|