@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 CHANGED
@@ -10,7 +10,7 @@ Folio.
10
10
 
11
11
  ## Requirements
12
12
 
13
- - Folio `0.13.2` or newer;
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
- Use the Folio page URL as Algolia's stable object ID. The helper preserves the
70
- page metadata needed by Folio:
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
- Indexing is intentionally not part of this package. Run indexing from a
83
- separate trusted server or CI job with an Algolia Admin key.
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
- For environment-based setup, the equivalent factory is available:
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 { createAlgoliaAdapterFromEnv } from "@nikala-ui/folio-algolia";
172
+ import { algoliaAdapter } from "@nikala-ui/folio-algolia";
104
173
 
105
- const adapter = createAlgoliaAdapterFromEnv();
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. Index creation, crawling, admin
143
- credentials, and deployment belong in the consuming project or a separate
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,4 @@
1
+ import type { AlgoliaIndexerOptions } from "./types.js";
2
+ type RuntimeEnv = Record<string, string | undefined>;
3
+ export declare function getAlgoliaIndexerOptions(env?: RuntimeEnv): Pick<AlgoliaIndexerOptions, "appId" | "adminApiKey" | "indexName">;
4
+ export {};
@@ -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 declare class AlgoliaIndexingError extends Error {
2
+ readonly status?: number;
3
+ constructor(message: string, status?: number);
4
+ }
@@ -0,0 +1,8 @@
1
+ export class AlgoliaIndexingError extends Error {
2
+ status;
3
+ constructor(message, status) {
4
+ super(message);
5
+ this.name = "AlgoliaIndexingError";
6
+ this.status = status;
7
+ }
8
+ }
@@ -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,3 @@
1
+ export { createAlgoliaIndexer } from "./sync.js";
2
+ export { AlgoliaIndexingError } from "./errors.js";
3
+ export { getAlgoliaIndexerOptions } from "./env.js";
@@ -0,0 +1,2 @@
1
+ import type { AlgoliaIndexer, AlgoliaIndexerOptions } from "./types.js";
2
+ export declare function createAlgoliaIndexer(options: AlgoliaIndexerOptions): AlgoliaIndexer;
@@ -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.1.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.13.2"
38
+ "@nikala-ui/folio": ">=0.14.0"
35
39
  },
36
40
  "devDependencies": {
37
- "@nikala-ui/folio": "^0.13.2",
41
+ "@nikala-ui/folio": "^0.14.0",
38
42
  "typescript": "^7.0.2"
39
43
  },
40
44
  "publishConfig": {