@nikala-ui/folio-algolia 0.1.0 → 0.3.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.15.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
 
@@ -43,6 +43,10 @@ export default {
43
43
  The adapter implements Folio's `SearchAdapter` contract. The configuration
44
44
  contains no Algolia request logic and no indexing code.
45
45
 
46
+ The adapter also declares a browser runtime descriptor. Folio uses that
47
+ descriptor to load only the browser-safe adapter module; the consumer's full
48
+ `docs.config.ts` and server-only indexing code are not bundled into the site.
49
+
46
50
  ## Environment variables
47
51
 
48
52
  Create a `.env` file in the documentation project:
@@ -66,8 +70,10 @@ are public by design; use only a Search-Only key.
66
70
 
67
71
  ## Index records
68
72
 
69
- Use the Folio page URL as Algolia's stable object ID. The helper preserves the
70
- page metadata needed by Folio:
73
+ The indexing entrypoint is server-only. It accepts the page catalog produced by
74
+ Folio and uploads records with an Algolia Admin API key. Use the Folio page URL
75
+ as Algolia's stable object ID. The helper preserves the page metadata needed
76
+ by Folio:
71
77
 
72
78
  ```ts
73
79
  import { toAlgoliaRecord } from "@nikala-ui/folio-algolia";
@@ -79,8 +85,68 @@ The generated record contains `objectID`, `url`, `slug`, `title`,
79
85
  `description`, `metadata` (frontmatter), and `headings` (table-of-contents
80
86
  text).
81
87
 
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.
88
+ ## Synchronize the index
89
+
90
+ Create a script in the consuming Folio project, for example
91
+ `scripts/index-search.ts`:
92
+
93
+ ```ts
94
+ import path from "node:path";
95
+ import { DEFAULT_DOCS_CONFIG, loadConfig } from "@nikala-ui/folio/config";
96
+ import { scanContent } from "@nikala-ui/folio/content";
97
+ import {
98
+ createAlgoliaIndexer,
99
+ getAlgoliaIndexerOptions,
100
+ } from "@nikala-ui/folio-algolia/indexing";
101
+
102
+ const projectRoot = process.cwd();
103
+ const config = await loadConfig(projectRoot);
104
+ const contentDir = path.resolve(
105
+ projectRoot,
106
+ config.contentDir ?? DEFAULT_DOCS_CONFIG.contentDir,
107
+ );
108
+ const pages = await scanContent(contentDir);
109
+ const indexer = createAlgoliaIndexer(getAlgoliaIndexerOptions());
110
+ const dryRun = process.argv.includes("--dry-run");
111
+ const summary = await indexer.sync(pages, { dryRun });
112
+
113
+ console.log(summary);
114
+ ```
115
+
116
+ The indexing script runs outside the browser and reads these server-only
117
+ variables:
118
+
119
+ ```bash
120
+ ALGOLIA_APP_ID=your_application_id
121
+ ALGOLIA_ADMIN_API_KEY=your_admin_key
122
+ ALGOLIA_INDEX=your_index_name
123
+ ```
124
+
125
+ Run a write-free validation first, then perform the upload:
126
+
127
+ ```bash
128
+ bun run scripts/index-search.ts --dry-run
129
+ bun run scripts/index-search.ts
130
+ ```
131
+
132
+ The current sync operation uses Algolia's `updateObject` batch action, so
133
+ re-running it safely updates existing records and creates missing records.
134
+
135
+ Use full synchronization when records removed from the documentation should
136
+ also be removed from Algolia:
137
+
138
+ ```ts
139
+ const summary = await indexer.sync(pages, {
140
+ mode: "full",
141
+ });
142
+ ```
143
+
144
+ Full synchronization browses existing Algolia `objectID` values, compares
145
+ them with the current Folio catalog, and deletes stale records. Transient
146
+ `408`, `429`, and `5xx` responses are retried automatically. Configure
147
+ `maxRetries` and `retryDelayMs` when the deployment environment needs a
148
+ different policy. Use `continueOnError: true` only when the caller wants a
149
+ summary containing failed batch counts instead of stopping at the first error.
84
150
 
85
151
  ## Manual adapter construction
86
152
 
@@ -97,16 +163,26 @@ const adapter = createAlgoliaAdapter({
97
163
  });
98
164
  ```
99
165
 
100
- For environment-based setup, the equivalent factory is available:
166
+ Most Folio sites should use the exported `algoliaAdapter` instance instead.
167
+
168
+ ## Migration from 0.1.x
169
+
170
+ Version `0.3.0` adds the browser runtime descriptor and requires Folio
171
+ `0.15.0` or newer. The server-only indexing entrypoint from `0.2.0` remains
172
+ available at `@nikala-ui/folio-algolia/indexing`. Existing browser-side
173
+ adapter configuration remains compatible:
101
174
 
102
175
  ```ts
103
- import { createAlgoliaAdapterFromEnv } from "@nikala-ui/folio-algolia";
176
+ import { algoliaAdapter } from "@nikala-ui/folio-algolia";
104
177
 
105
- const adapter = createAlgoliaAdapterFromEnv();
178
+ export default {
179
+ search: {
180
+ enabled: true,
181
+ provider: algoliaAdapter,
182
+ },
183
+ };
106
184
  ```
107
185
 
108
- Most Folio sites should use the exported `algoliaAdapter` instance instead.
109
-
110
186
  ## Matching behavior
111
187
 
112
188
  Folio passes the query and its local page catalog to the adapter. Algolia
@@ -139,9 +215,9 @@ bun run build
139
215
  ```
140
216
 
141
217
  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.
218
+ server-side batch indexing, environment resolution, and record mapping.
219
+ Crawling and deployment orchestration remain in the consuming project or a
220
+ separate indexing service.
145
221
 
146
222
  ## License
147
223
 
package/dist/adapter.js CHANGED
@@ -1,8 +1,17 @@
1
1
  import { getAlgoliaEnv } from "./env.js";
2
2
  import { searchAlgolia } from "./client.js";
3
+ const BROWSER_RUNTIME_MODULE = "@nikala-ui/folio-algolia/browser";
4
+ function browserRuntime(options) {
5
+ return {
6
+ module: BROWSER_RUNTIME_MODULE,
7
+ exportName: "createAlgoliaAdapter",
8
+ options,
9
+ };
10
+ }
3
11
  export function createAlgoliaAdapter(options) {
4
12
  return {
5
13
  name: "algolia",
14
+ runtime: browserRuntime(options),
6
15
  async search({ query, pages }) {
7
16
  const normalizedQuery = query.trim();
8
17
  if (!normalizedQuery)
@@ -16,6 +25,10 @@ export function createAlgoliaAdapterFromEnv() {
16
25
  }
17
26
  export const algoliaAdapter = {
18
27
  name: "algolia",
28
+ runtime: {
29
+ module: BROWSER_RUNTIME_MODULE,
30
+ exportName: "createAlgoliaAdapterFromEnv",
31
+ },
19
32
  search(context) {
20
33
  return createAlgoliaAdapterFromEnv().search(context);
21
34
  },
@@ -0,0 +1 @@
1
+ export { createAlgoliaAdapter, createAlgoliaAdapterFromEnv } from "./adapter.js";
@@ -0,0 +1 @@
1
+ export { createAlgoliaAdapter, createAlgoliaAdapterFromEnv } from "./adapter.js";
@@ -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/dist/types.d.ts CHANGED
@@ -22,5 +22,10 @@ export interface FolioSearchContext {
22
22
  export interface FolioSearchAdapter {
23
23
  name: string;
24
24
  search: (context: FolioSearchContext) => PageData[] | Promise<PageData[]>;
25
+ runtime?: {
26
+ module: string;
27
+ exportName: string;
28
+ options?: unknown;
29
+ };
25
30
  }
26
31
  export type FolioPage = PageData;
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.3.0",
4
4
  "description": "Algolia search adapter for Folio documentation sites",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,6 +20,14 @@
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"
27
+ },
28
+ "./browser": {
29
+ "types": "./dist/browser.d.ts",
30
+ "import": "./dist/browser.js"
23
31
  }
24
32
  },
25
33
  "files": ["dist"],
@@ -31,10 +39,10 @@
31
39
  "package:check": "bun run check && npm pack --dry-run"
32
40
  },
33
41
  "peerDependencies": {
34
- "@nikala-ui/folio": ">=0.13.2"
42
+ "@nikala-ui/folio": ">=0.15.0"
35
43
  },
36
44
  "devDependencies": {
37
- "@nikala-ui/folio": "^0.13.2",
45
+ "@nikala-ui/folio": "^0.14.0",
38
46
  "typescript": "^7.0.2"
39
47
  },
40
48
  "publishConfig": {