strapi-plugin-algolia-sync 1.0.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 +182 -0
- package/admin/src/components/AlgoliaIndexAllButton.jsx +89 -0
- package/admin/src/index.js +20 -0
- package/package.json +49 -0
- package/server/config.js +101 -0
- package/server/controllers/algolia-admin.js +88 -0
- package/server/controllers/index.js +5 -0
- package/server/index.js +89 -0
- package/server/routes/algolia-admin.js +27 -0
- package/server/routes/index.js +3 -0
- package/server/services/algolia-client.js +119 -0
- package/server/services/algolia-config.js +93 -0
- package/server/services/algolia-sync.js +392 -0
- package/server/services/index.js +7 -0
- package/strapi-admin.js +1 -0
- package/strapi-server.js +3 -0
package/README.md
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# strapi-plugin-algolia
|
|
2
|
+
|
|
3
|
+
Strapi v5 plugin that syncs content to Algolia automatically via lifecycle hooks, with a manual "Index all items" button in the admin panel.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Auto-sync on publish, unpublish, and delete via lifecycle hooks
|
|
8
|
+
- Bulk reindex button in content-manager list view (per collection type)
|
|
9
|
+
- Optional full reindex on boot
|
|
10
|
+
- Multi-locale support
|
|
11
|
+
- Domain/tag filtering support
|
|
12
|
+
- Rich-text (CKEditor) HTML stripping
|
|
13
|
+
- Configurable per-content-type: index name, title field, URL pattern
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
### Option A — Local path (recommended for development)
|
|
20
|
+
|
|
21
|
+
Copy or symlink this folder anywhere, then reference it by path.
|
|
22
|
+
|
|
23
|
+
### Option B — npm (after publishing)
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install strapi-plugin-algolia
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Setup
|
|
32
|
+
|
|
33
|
+
### 1. Register the plugin in `config/plugins.js`
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
// config/plugins.js
|
|
37
|
+
module.exports = ({ env }) => ({
|
|
38
|
+
algolia: {
|
|
39
|
+
enabled: true,
|
|
40
|
+
resolve: '../strapi-plugin-algolia', // path to plugin folder (local) OR omit for npm
|
|
41
|
+
config: {
|
|
42
|
+
// Algolia credentials — prefer env vars over hardcoding
|
|
43
|
+
appId: env('ALGOLIA_APP_ID', ''),
|
|
44
|
+
apiKey: env('ALGOLIA_ADMIN_API_KEY', ''),
|
|
45
|
+
|
|
46
|
+
// Optional: prefix every index name (e.g. 'prod_', 'staging_')
|
|
47
|
+
indexPrefix: env('ALGOLIA_INDEX_PREFIX', ''),
|
|
48
|
+
|
|
49
|
+
// Run a full reindex every time Strapi boots (default: false)
|
|
50
|
+
reindexOnBoot: env.bool('ALGOLIA_REINDEX_ON_BOOT', false),
|
|
51
|
+
|
|
52
|
+
// Index name shared by all single-type content types (default: 'static_pages')
|
|
53
|
+
singleTypeIndexName: 'static_pages',
|
|
54
|
+
|
|
55
|
+
// UIDs to never index, even if listed in contentTypes
|
|
56
|
+
skipContentTypes: [
|
|
57
|
+
'api::tag.tag',
|
|
58
|
+
'api::category.category',
|
|
59
|
+
],
|
|
60
|
+
|
|
61
|
+
// Content types to index
|
|
62
|
+
contentTypes: [
|
|
63
|
+
// Collection type — each entry becomes its own Algolia record
|
|
64
|
+
{
|
|
65
|
+
uid: 'api::article.article',
|
|
66
|
+
indexName: 'articles', // Algolia index name
|
|
67
|
+
titleField: 'title', // field (dot-notation ok, e.g. 'banner.title')
|
|
68
|
+
urlPattern: '/articles/{slug}', // {slug} → entry.slug
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
// Collection type with a static title
|
|
72
|
+
{
|
|
73
|
+
uid: 'api::team-member.team-member',
|
|
74
|
+
indexName: 'team',
|
|
75
|
+
titleField: 'name',
|
|
76
|
+
urlPattern: '/about/team/{slug}',
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
// Single type — goes into the shared singleTypeIndexName index
|
|
80
|
+
{
|
|
81
|
+
uid: 'api::home-page.home-page',
|
|
82
|
+
staticTitle: 'Home', // hard-coded; no titleField needed
|
|
83
|
+
urlPattern: '/',
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
{
|
|
87
|
+
uid: 'api::about-page.about-page',
|
|
88
|
+
staticTitle: 'About',
|
|
89
|
+
urlPattern: '/about',
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### 2. Add environment variables
|
|
98
|
+
|
|
99
|
+
```env
|
|
100
|
+
ALGOLIA_APP_ID=your_app_id
|
|
101
|
+
ALGOLIA_ADMIN_API_KEY=your_admin_api_key
|
|
102
|
+
ALGOLIA_INDEX_PREFIX=prod_ # optional
|
|
103
|
+
ALGOLIA_REINDEX_ON_BOOT=false # optional
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## How it works
|
|
109
|
+
|
|
110
|
+
### Lifecycle hooks (automatic sync)
|
|
111
|
+
|
|
112
|
+
| Event | Behaviour |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `afterCreate` | Indexes the entry if it is published |
|
|
115
|
+
| `afterUpdate` | Indexes if published; removes from index if unpublished |
|
|
116
|
+
| `afterDelete` | Removes from index if it was published |
|
|
117
|
+
|
|
118
|
+
### Algolia record shape
|
|
119
|
+
|
|
120
|
+
Every indexed entry produces:
|
|
121
|
+
|
|
122
|
+
```json
|
|
123
|
+
{
|
|
124
|
+
"objectID": "api::article.article::abc123::en",
|
|
125
|
+
"title": "My Article",
|
|
126
|
+
"content": "Plain text extracted from all string/richtext/component fields",
|
|
127
|
+
"url": "/articles/my-article",
|
|
128
|
+
"domains": ["godrej-industries", "another-domain"]
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Admin panel — "Index all items"
|
|
133
|
+
|
|
134
|
+
A button appears in the content-manager list view for every collection type listed in `contentTypes`. Clicking it bulk-reindexes all published entries (paginated, 100 per batch).
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## contentTypes config reference
|
|
139
|
+
|
|
140
|
+
| Key | Type | Required | Description |
|
|
141
|
+
|---|---|---|---|
|
|
142
|
+
| `uid` | string | Yes | Strapi content type UID e.g. `api::article.article` |
|
|
143
|
+
| `indexName` | string | No | Algolia index name. Defaults to the schema's `pluralName` (collections) or `singleTypeIndexName` (single types) |
|
|
144
|
+
| `titleField` | string | No | Dot-notation path to the title field e.g. `banner.title` |
|
|
145
|
+
| `staticTitle` | string | No | Hard-coded title string (useful for single types) |
|
|
146
|
+
| `urlPattern` | string | No | Frontend URL with `{slug}` placeholder. Defaults to `/{pluralName}/{slug}` |
|
|
147
|
+
|
|
148
|
+
If both `titleField` and `staticTitle` are set, `staticTitle` takes precedence.
|
|
149
|
+
If neither is set, the plugin falls back to the first of `title`, `name`, `heading` fields found on the entry, then `banner.title`, then the schema's `displayName`.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## Plugin services (for advanced use)
|
|
154
|
+
|
|
155
|
+
All services are accessible within custom Strapi code:
|
|
156
|
+
|
|
157
|
+
```js
|
|
158
|
+
// Index a single entry manually
|
|
159
|
+
await strapi.plugin('algolia').service('algoliaSync').indexEntry(uid, documentId, locale);
|
|
160
|
+
|
|
161
|
+
// Remove a single entry
|
|
162
|
+
await strapi.plugin('algolia').service('algoliaSync').removeEntry(uid, documentId, locale);
|
|
163
|
+
|
|
164
|
+
// Reindex an entire collection
|
|
165
|
+
await strapi.plugin('algolia').service('algoliaSync').reindexCollectionUid(uid);
|
|
166
|
+
|
|
167
|
+
// Full reindex of all configured content types
|
|
168
|
+
await strapi.plugin('algolia').service('algoliaSync').fullReindex();
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## Migrating from the inline integration (gil-corp-strapi)
|
|
174
|
+
|
|
175
|
+
If you are migrating this project away from the old inline utilities:
|
|
176
|
+
|
|
177
|
+
1. Add the plugin config to `config/plugins.js` and copy the content type definitions from the old `src/utils/algolia-config.js`.
|
|
178
|
+
2. Remove `src/utils/algolia.js`, `src/utils/algolia-sync.js`, `src/utils/algolia-config.js`.
|
|
179
|
+
3. Remove the Algolia code from `src/index.js` (lifecycle hooks + boot reindex).
|
|
180
|
+
4. Remove `src/api/algolia-admin/` (controller + routes).
|
|
181
|
+
5. Remove the `AlgoliaIndexAllButton` injection from `src/admin/app.js`.
|
|
182
|
+
6. Set `resolve` to the plugin folder path in `config/plugins.js`.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { useParams } from 'react-router-dom';
|
|
3
|
+
import { Button, Box } from '@strapi/design-system';
|
|
4
|
+
import {
|
|
5
|
+
useFetchClient,
|
|
6
|
+
useNotification,
|
|
7
|
+
useAPIErrorHandler,
|
|
8
|
+
} from '@strapi/strapi/admin';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Renders an "Index all items" button in the content-manager list view actions bar.
|
|
12
|
+
* Only visible for collection types that are configured in the plugin's contentTypes array.
|
|
13
|
+
*/
|
|
14
|
+
const AlgoliaIndexAllButton = () => {
|
|
15
|
+
const { slug } = useParams();
|
|
16
|
+
const { get, post } = useFetchClient();
|
|
17
|
+
const { toggleNotification } = useNotification();
|
|
18
|
+
const { formatAPIError } = useAPIErrorHandler();
|
|
19
|
+
const [loading, setLoading] = useState(false);
|
|
20
|
+
const [allowedUids, setAllowedUids] = useState(null);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
let cancelled = false;
|
|
24
|
+
(async () => {
|
|
25
|
+
try {
|
|
26
|
+
const { data } = await get(
|
|
27
|
+
'/api/algolia/algolia-admin/configured-collection-uids'
|
|
28
|
+
);
|
|
29
|
+
const uids = data?.data?.uids ?? data?.uids ?? [];
|
|
30
|
+
if (!cancelled) {
|
|
31
|
+
setAllowedUids(new Set(Array.isArray(uids) ? uids : []));
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
if (!cancelled) setAllowedUids(new Set());
|
|
35
|
+
}
|
|
36
|
+
})();
|
|
37
|
+
return () => {
|
|
38
|
+
cancelled = true;
|
|
39
|
+
};
|
|
40
|
+
}, [get]);
|
|
41
|
+
|
|
42
|
+
if (
|
|
43
|
+
allowedUids === null ||
|
|
44
|
+
!slug ||
|
|
45
|
+
!slug.startsWith('api::') ||
|
|
46
|
+
!allowedUids.has(slug)
|
|
47
|
+
) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const handleClick = async () => {
|
|
52
|
+
setLoading(true);
|
|
53
|
+
try {
|
|
54
|
+
const { data } = await post(
|
|
55
|
+
'/api/algolia/algolia-admin/reindex-collection',
|
|
56
|
+
{ uid: slug }
|
|
57
|
+
);
|
|
58
|
+
const indexed = data?.data?.indexed ?? data?.indexed;
|
|
59
|
+
const indexName = data?.data?.indexName ?? data?.indexName;
|
|
60
|
+
toggleNotification({
|
|
61
|
+
type: 'success',
|
|
62
|
+
message: `Indexed ${indexed ?? 0} published entr${indexed === 1 ? 'y' : 'ies'}${indexName ? ` → ${indexName}` : ''}.`,
|
|
63
|
+
});
|
|
64
|
+
} catch (err) {
|
|
65
|
+
toggleNotification({
|
|
66
|
+
type: 'danger',
|
|
67
|
+
message: formatAPIError(err),
|
|
68
|
+
});
|
|
69
|
+
} finally {
|
|
70
|
+
setLoading(false);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<Box paddingRight={2}>
|
|
76
|
+
<Button
|
|
77
|
+
type="button"
|
|
78
|
+
variant="primary"
|
|
79
|
+
size="S"
|
|
80
|
+
loading={loading}
|
|
81
|
+
onClick={handleClick}
|
|
82
|
+
>
|
|
83
|
+
Index all items
|
|
84
|
+
</Button>
|
|
85
|
+
</Box>
|
|
86
|
+
);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export default AlgoliaIndexAllButton;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import AlgoliaIndexAllButton from './components/AlgoliaIndexAllButton';
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
register(app) {
|
|
5
|
+
app.registerPlugin({
|
|
6
|
+
id: 'algolia',
|
|
7
|
+
name: 'Algolia Search',
|
|
8
|
+
});
|
|
9
|
+
},
|
|
10
|
+
|
|
11
|
+
bootstrap(app) {
|
|
12
|
+
const contentManager = app.getPlugin('content-manager');
|
|
13
|
+
if (contentManager) {
|
|
14
|
+
contentManager.injectComponent('listView', 'actions', {
|
|
15
|
+
name: 'algolia.index-all',
|
|
16
|
+
Component: AlgoliaIndexAllButton,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "strapi-plugin-algolia-sync",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Algolia search integration plugin for Strapi v5 — auto-syncs content via lifecycle hooks, supports bulk reindex from the admin panel.",
|
|
5
|
+
"main": "./strapi-server.js",
|
|
6
|
+
"strapi": {
|
|
7
|
+
"name": "algolia",
|
|
8
|
+
"displayName": "Algolia Search",
|
|
9
|
+
"description": "Automatically sync Strapi content to Algolia indexes with lifecycle hooks and admin UI.",
|
|
10
|
+
"kind": "plugin"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "strapi-plugin build",
|
|
14
|
+
"watch": "strapi-plugin watch"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"strapi-server.js",
|
|
18
|
+
"strapi-admin.js",
|
|
19
|
+
"server/",
|
|
20
|
+
"admin/",
|
|
21
|
+
"dist/"
|
|
22
|
+
],
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"algoliasearch": "^5.50.1"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@strapi/plugin-sdk": "^0.0.7"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"@strapi/strapi": "^5.0.0"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"strapi",
|
|
34
|
+
"algolia",
|
|
35
|
+
"search",
|
|
36
|
+
"plugin",
|
|
37
|
+
"strapi-plugin"
|
|
38
|
+
],
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"author": "akashkcode7",
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "https://github.com/akashkcode7/strapi-plugin-algolia.git"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/akashkcode7/strapi-plugin-algolia#readme",
|
|
46
|
+
"bugs": {
|
|
47
|
+
"url": "https://github.com/akashkcode7/strapi-plugin-algolia/issues"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/server/config.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default plugin configuration.
|
|
5
|
+
*
|
|
6
|
+
* Users override these values in their project's config/plugins.js:
|
|
7
|
+
*
|
|
8
|
+
* module.exports = ({ env }) => ({
|
|
9
|
+
* algolia: {
|
|
10
|
+
* enabled: true,
|
|
11
|
+
* resolve: '../strapi-plugin-algolia', // path to this plugin
|
|
12
|
+
* config: {
|
|
13
|
+
* appId: env('ALGOLIA_APP_ID'),
|
|
14
|
+
* apiKey: env('ALGOLIA_ADMIN_API_KEY'),
|
|
15
|
+
* indexPrefix: env('ALGOLIA_INDEX_PREFIX', ''),
|
|
16
|
+
* reindexOnBoot: env.bool('ALGOLIA_REINDEX_ON_BOOT', false),
|
|
17
|
+
* singleTypeIndexName: 'static_pages',
|
|
18
|
+
* skipContentTypes: ['api::tag.tag'],
|
|
19
|
+
* contentTypes: [
|
|
20
|
+
* {
|
|
21
|
+
* uid: 'api::article.article',
|
|
22
|
+
* indexName: 'articles',
|
|
23
|
+
* titleField: 'title',
|
|
24
|
+
* urlPattern: '/articles/{slug}',
|
|
25
|
+
* },
|
|
26
|
+
* {
|
|
27
|
+
* uid: 'api::home-page.home-page',
|
|
28
|
+
* indexName: 'static_pages',
|
|
29
|
+
* staticTitle: 'Home',
|
|
30
|
+
* urlPattern: '/',
|
|
31
|
+
* },
|
|
32
|
+
* ],
|
|
33
|
+
* },
|
|
34
|
+
* },
|
|
35
|
+
* });
|
|
36
|
+
*/
|
|
37
|
+
module.exports = {
|
|
38
|
+
default: {
|
|
39
|
+
/**
|
|
40
|
+
* Algolia Application ID.
|
|
41
|
+
* Falls back to process.env.ALGOLIA_APP_ID if not set here.
|
|
42
|
+
*/
|
|
43
|
+
appId: '',
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Algolia Admin API Key (write access).
|
|
47
|
+
* Falls back to process.env.ALGOLIA_ADMIN_API_KEY if not set here.
|
|
48
|
+
*/
|
|
49
|
+
apiKey: '',
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Optional prefix added to every index name (e.g. "prod_", "dev_").
|
|
53
|
+
* Falls back to process.env.ALGOLIA_INDEX_PREFIX.
|
|
54
|
+
*/
|
|
55
|
+
indexPrefix: '',
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* When true, a full reindex runs on every Strapi boot.
|
|
59
|
+
*/
|
|
60
|
+
reindexOnBoot: false,
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* When true, the plugin recursively extracts domain/domains relation values
|
|
64
|
+
* from each entry and stores them as a `domains` array in Algolia.
|
|
65
|
+
* Only enable this if your project has domain-based content filtering.
|
|
66
|
+
* Default: false
|
|
67
|
+
*/
|
|
68
|
+
enableDomains: false,
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Index name shared by all single-type content types.
|
|
72
|
+
*/
|
|
73
|
+
singleTypeIndexName: 'static_pages',
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* UIDs to explicitly exclude from indexing (lifecycle hooks + bulk reindex).
|
|
77
|
+
* Example: ['api::tag.tag', 'api::category.category']
|
|
78
|
+
*/
|
|
79
|
+
skipContentTypes: [],
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Content types to index. Each entry:
|
|
83
|
+
* uid {string} — Strapi content type UID, e.g. 'api::article.article'
|
|
84
|
+
* indexName {string} — Algolia index name (optional; defaults to pluralName for
|
|
85
|
+
* collections, singleTypeIndexName for single types)
|
|
86
|
+
* titleField {string} — Dot-notation path to the title field, e.g. 'banner.title'
|
|
87
|
+
* staticTitle {string} — Hard-coded title (useful for single-type pages)
|
|
88
|
+
* urlPattern {string} — Frontend URL template; {slug} is replaced with the entry slug
|
|
89
|
+
*/
|
|
90
|
+
contentTypes: [],
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
validator(config) {
|
|
94
|
+
if (!Array.isArray(config.contentTypes)) {
|
|
95
|
+
throw new Error('[strapi-plugin-algolia] `config.contentTypes` must be an array');
|
|
96
|
+
}
|
|
97
|
+
if (!Array.isArray(config.skipContentTypes)) {
|
|
98
|
+
throw new Error('[strapi-plugin-algolia] `config.skipContentTypes` must be an array');
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
module.exports = ({ strapi }) => {
|
|
4
|
+
/**
|
|
5
|
+
* Validates the request carries a valid Strapi admin access token.
|
|
6
|
+
* Uses the same session manager that the admin panel uses internally.
|
|
7
|
+
* NOTE: Routes are auth:false so we perform auth manually here to allow
|
|
8
|
+
* the Strapi admin JWT (not the public API token) to authorize these calls.
|
|
9
|
+
*/
|
|
10
|
+
function isValidAdminAccessToken(ctx) {
|
|
11
|
+
const auth = ctx.request.header.authorization;
|
|
12
|
+
if (!auth || !auth.startsWith('Bearer ')) return false;
|
|
13
|
+
|
|
14
|
+
const token = auth.slice(7).trim();
|
|
15
|
+
if (!token) return false;
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const session = strapi.sessionManager?.('admin');
|
|
19
|
+
if (!session?.validateAccessToken) return false;
|
|
20
|
+
return session.validateAccessToken(token).isValid === true;
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
/**
|
|
28
|
+
* GET /api/algolia/algolia-admin/configured-collection-uids
|
|
29
|
+
* Returns collection type UIDs configured for indexing.
|
|
30
|
+
* Used by the admin panel to decide whether to show "Index all" button.
|
|
31
|
+
*/
|
|
32
|
+
async configuredCollectionUids(ctx) {
|
|
33
|
+
if (!isValidAdminAccessToken(ctx)) {
|
|
34
|
+
return ctx.unauthorized('Admin authentication required');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
ctx.body = {
|
|
38
|
+
data: {
|
|
39
|
+
uids: strapi
|
|
40
|
+
.plugin('algolia')
|
|
41
|
+
.service('algoliaConfig')
|
|
42
|
+
.getConfiguredCollectionTypeUIDs(),
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* POST /api/algolia/algolia-admin/reindex-collection
|
|
49
|
+
* Body: { uid: 'api::article.article' }
|
|
50
|
+
* Triggers a bulk reindex for the given collection type.
|
|
51
|
+
*/
|
|
52
|
+
async reindexCollection(ctx) {
|
|
53
|
+
if (!isValidAdminAccessToken(ctx)) {
|
|
54
|
+
return ctx.unauthorized('Admin authentication required');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const uid = ctx.request.body?.uid;
|
|
58
|
+
if (!uid || typeof uid !== 'string') {
|
|
59
|
+
return ctx.badRequest('Request body must include string "uid"');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Validate uid against the configured allowlist before touching the service
|
|
63
|
+
const allowedUids = strapi
|
|
64
|
+
.plugin('algolia')
|
|
65
|
+
.service('algoliaConfig')
|
|
66
|
+
.getConfiguredCollectionTypeUIDs();
|
|
67
|
+
|
|
68
|
+
if (!allowedUids.includes(uid)) {
|
|
69
|
+
return ctx.badRequest('The provided uid is not configured for indexing');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const { count, indexName } = await strapi
|
|
74
|
+
.plugin('algolia')
|
|
75
|
+
.service('algoliaSync')
|
|
76
|
+
.reindexCollectionUid(uid);
|
|
77
|
+
|
|
78
|
+
ctx.body = {
|
|
79
|
+
data: { uid, indexName, indexed: count },
|
|
80
|
+
};
|
|
81
|
+
} catch (err) {
|
|
82
|
+
strapi.log.warn(`[Algolia] reindexCollection: ${err.message}`);
|
|
83
|
+
// Return a generic message — don't leak internal details to the client
|
|
84
|
+
return ctx.badRequest('Reindex failed. Check server logs for details.');
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
};
|
package/server/index.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const config = require('./config');
|
|
4
|
+
const services = require('./services');
|
|
5
|
+
const controllers = require('./controllers');
|
|
6
|
+
const routes = require('./routes');
|
|
7
|
+
|
|
8
|
+
module.exports = {
|
|
9
|
+
config,
|
|
10
|
+
services,
|
|
11
|
+
controllers,
|
|
12
|
+
routes,
|
|
13
|
+
|
|
14
|
+
register({ strapi: _strapi }) {},
|
|
15
|
+
|
|
16
|
+
bootstrap({ strapi: _strapi }) {
|
|
17
|
+
const algoliaConfig = _strapi.plugin('algolia').service('algoliaConfig');
|
|
18
|
+
const algoliaSync = _strapi.plugin('algolia').service('algoliaSync');
|
|
19
|
+
const algoliaClient = _strapi.plugin('algolia').service('algoliaClient');
|
|
20
|
+
|
|
21
|
+
// ── Warn early if credentials are missing ──────────────────────────────
|
|
22
|
+
|
|
23
|
+
if (!algoliaClient.getClient()) {
|
|
24
|
+
_strapi.log.warn(
|
|
25
|
+
'[Algolia] Plugin is active but credentials are not configured. ' +
|
|
26
|
+
'Set ALGOLIA_APP_ID and ALGOLIA_ADMIN_API_KEY (or appId/apiKey in config/plugins.js). ' +
|
|
27
|
+
'Indexing will be silently skipped until credentials are provided.'
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Lifecycle hooks ────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
_strapi.db.lifecycles.subscribe({
|
|
34
|
+
async afterCreate(event) {
|
|
35
|
+
const { model, result } = event;
|
|
36
|
+
if (!result?.publishedAt) return;
|
|
37
|
+
if (!algoliaConfig.shouldIndex(model.uid)) return;
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
await algoliaSync.indexEntry(model.uid, result.documentId, result.locale);
|
|
41
|
+
} catch (e) {
|
|
42
|
+
_strapi.log.error(`[Algolia] afterCreate: ${e.message}`);
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
async afterUpdate(event) {
|
|
47
|
+
const { model, result } = event;
|
|
48
|
+
if (!algoliaConfig.shouldIndex(model.uid)) return;
|
|
49
|
+
|
|
50
|
+
if (result?.publishedAt) {
|
|
51
|
+
try {
|
|
52
|
+
await algoliaSync.indexEntry(model.uid, result.documentId, result.locale);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
_strapi.log.error(`[Algolia] afterUpdate: ${e.message}`);
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
try {
|
|
58
|
+
await algoliaSync.removeEntry(model.uid, result.documentId);
|
|
59
|
+
} catch (e) {
|
|
60
|
+
_strapi.log.error(`[Algolia] afterUpdate (unpublish): ${e.message}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
async afterDelete(event) {
|
|
66
|
+
const { model, result } = event;
|
|
67
|
+
if (!algoliaConfig.shouldIndex(model.uid)) return;
|
|
68
|
+
if (!result?.publishedAt) return;
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
await algoliaSync.removeEntry(model.uid, result.documentId);
|
|
72
|
+
} catch (e) {
|
|
73
|
+
_strapi.log.error(`[Algolia] afterDelete: ${e.message}`);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ── Boot-time reindex ──────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
const pluginCfg = _strapi.config.get('plugin.algolia') || {};
|
|
81
|
+
if (pluginCfg.reindexOnBoot) {
|
|
82
|
+
algoliaSync
|
|
83
|
+
.fullReindex()
|
|
84
|
+
.catch((err) =>
|
|
85
|
+
_strapi.log.error(`[Algolia] Boot reindex failed: ${err.message}`)
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
type: 'content-api',
|
|
5
|
+
routes: [
|
|
6
|
+
{
|
|
7
|
+
method: 'GET',
|
|
8
|
+
path: '/algolia-admin/configured-collection-uids',
|
|
9
|
+
handler: 'algoliaAdmin.configuredCollectionUids',
|
|
10
|
+
config: {
|
|
11
|
+
auth: false,
|
|
12
|
+
policies: [],
|
|
13
|
+
middlewares: [],
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
method: 'POST',
|
|
18
|
+
path: '/algolia-admin/reindex-collection',
|
|
19
|
+
handler: 'algoliaAdmin.reindexCollection',
|
|
20
|
+
config: {
|
|
21
|
+
auth: false,
|
|
22
|
+
policies: [],
|
|
23
|
+
middlewares: [],
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { algoliasearch } = require('algoliasearch');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Algolia client service.
|
|
7
|
+
*
|
|
8
|
+
* Credentials are read from plugin config first, then fall back to env vars:
|
|
9
|
+
* ALGOLIA_APP_ID, ALGOLIA_ADMIN_API_KEY, ALGOLIA_INDEX_PREFIX
|
|
10
|
+
*/
|
|
11
|
+
module.exports = ({ strapi }) => {
|
|
12
|
+
let client = null;
|
|
13
|
+
|
|
14
|
+
function getPluginCfg() {
|
|
15
|
+
return strapi.config.get('plugin.algolia') || {};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getClient() {
|
|
19
|
+
if (client) return client;
|
|
20
|
+
|
|
21
|
+
const cfg = getPluginCfg();
|
|
22
|
+
const appId = cfg.appId || process.env.ALGOLIA_APP_ID;
|
|
23
|
+
const apiKey = cfg.apiKey || process.env.ALGOLIA_ADMIN_API_KEY;
|
|
24
|
+
|
|
25
|
+
if (!appId || !apiKey) return null;
|
|
26
|
+
|
|
27
|
+
client = algoliasearch(appId, apiKey);
|
|
28
|
+
return client;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getIndexPrefix() {
|
|
32
|
+
const cfg = getPluginCfg();
|
|
33
|
+
return cfg.indexPrefix || process.env.ALGOLIA_INDEX_PREFIX || '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function prefixedIndex(name) {
|
|
37
|
+
return `${getIndexPrefix()}${name}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function stripHtml(html) {
|
|
41
|
+
if (!html || typeof html !== 'string') return '';
|
|
42
|
+
return html
|
|
43
|
+
// Remove script/style blocks and their content entirely
|
|
44
|
+
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
45
|
+
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
46
|
+
// Remove HTML comments
|
|
47
|
+
.replace(/<!--[\s\S]*?-->/g, ' ')
|
|
48
|
+
// Remove all remaining tags (including ones with nested angle brackets)
|
|
49
|
+
.replace(/<[^>]*>/g, ' ')
|
|
50
|
+
// Decode common HTML entities
|
|
51
|
+
.replace(/ /gi, ' ')
|
|
52
|
+
.replace(/&/gi, '&')
|
|
53
|
+
.replace(/</gi, '<')
|
|
54
|
+
.replace(/>/gi, '>')
|
|
55
|
+
.replace(/"/gi, '"')
|
|
56
|
+
.replace(/&#?\w+;/gi, ' ')
|
|
57
|
+
// Collapse whitespace
|
|
58
|
+
.replace(/\s+/g, ' ')
|
|
59
|
+
.trim();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function saveObjects(indexName, objects) {
|
|
63
|
+
const algolia = getClient();
|
|
64
|
+
if (!algolia) {
|
|
65
|
+
strapi.log.warn('[Algolia] Client not configured — skipping save');
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const result = await algolia.saveObjects({
|
|
71
|
+
indexName: prefixedIndex(indexName),
|
|
72
|
+
objects,
|
|
73
|
+
});
|
|
74
|
+
strapi.log.info(
|
|
75
|
+
`[Algolia] Saved ${objects.length} object(s) → "${prefixedIndex(indexName)}" | taskID: ${JSON.stringify(result)}`
|
|
76
|
+
);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
strapi.log.error(`[Algolia] Save error (${indexName}): ${err.message}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function deleteObject(indexName, objectID) {
|
|
83
|
+
const algolia = getClient();
|
|
84
|
+
if (!algolia) return;
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
await algolia.deleteObject({
|
|
88
|
+
indexName: prefixedIndex(indexName),
|
|
89
|
+
objectID,
|
|
90
|
+
});
|
|
91
|
+
strapi.log.info(
|
|
92
|
+
`[Algolia] Deleted "${objectID}" ← "${prefixedIndex(indexName)}"`
|
|
93
|
+
);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
strapi.log.error(`[Algolia] Delete error (${indexName}): ${err.message}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function clearIndex(indexName) {
|
|
100
|
+
const algolia = getClient();
|
|
101
|
+
if (!algolia) return;
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
await algolia.clearObjects({ indexName: prefixedIndex(indexName) });
|
|
105
|
+
strapi.log.info(`[Algolia] Cleared index "${prefixedIndex(indexName)}"`);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
strapi.log.error(`[Algolia] Clear error (${indexName}): ${err.message}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
getClient,
|
|
113
|
+
stripHtml,
|
|
114
|
+
saveObjects,
|
|
115
|
+
deleteObject,
|
|
116
|
+
clearIndex,
|
|
117
|
+
prefixedIndex,
|
|
118
|
+
};
|
|
119
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Config service — replaces the hardcoded algolia-config.js from the original project.
|
|
5
|
+
*
|
|
6
|
+
* All content type configuration is driven by the plugin config defined in the
|
|
7
|
+
* consuming project's config/plugins.js under the `contentTypes` array.
|
|
8
|
+
*/
|
|
9
|
+
module.exports = ({ strapi }) => {
|
|
10
|
+
function getPluginCfg() {
|
|
11
|
+
return strapi.config.get('plugin.algolia') || {};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function getConfiguredContentTypes() {
|
|
15
|
+
return getPluginCfg().contentTypes || [];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function getSkipContentTypes() {
|
|
19
|
+
return getPluginCfg().skipContentTypes || [];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getSingleTypeIndexName() {
|
|
23
|
+
return getPluginCfg().singleTypeIndexName || 'static_pages';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
27
|
+
|
|
28
|
+
/** Build a uid → override map from the configured array. Uses null-prototype object to prevent prototype pollution. */
|
|
29
|
+
function getContentTypeMap() {
|
|
30
|
+
const map = Object.create(null);
|
|
31
|
+
for (const ct of getConfiguredContentTypes()) {
|
|
32
|
+
if (ct && typeof ct.uid === 'string' && ct.uid.startsWith('api::') && !DANGEROUS_KEYS.has(ct.uid)) {
|
|
33
|
+
map[ct.uid] = ct;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return map;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function shouldIndex(uid) {
|
|
40
|
+
if (!uid || typeof uid !== 'string') return false;
|
|
41
|
+
if (!uid.startsWith('api::')) return false;
|
|
42
|
+
if (DANGEROUS_KEYS.has(uid)) return false;
|
|
43
|
+
if (getSkipContentTypes().includes(uid)) return false;
|
|
44
|
+
return uid in getContentTypeMap();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getConfig(uid) {
|
|
48
|
+
if (!shouldIndex(uid)) return null;
|
|
49
|
+
|
|
50
|
+
const schema = strapi.contentTypes[uid];
|
|
51
|
+
if (!schema) return null;
|
|
52
|
+
|
|
53
|
+
const override = getContentTypeMap()[uid];
|
|
54
|
+
if (!override) return null;
|
|
55
|
+
|
|
56
|
+
const isSingleType = schema.kind === 'singleType';
|
|
57
|
+
const singleTypeIndexName = getSingleTypeIndexName();
|
|
58
|
+
|
|
59
|
+
const indexName =
|
|
60
|
+
override.indexName ||
|
|
61
|
+
(isSingleType
|
|
62
|
+
? singleTypeIndexName
|
|
63
|
+
: schema.info.pluralName.replace(/-/g, '_'));
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
indexName,
|
|
67
|
+
titleField: override.titleField || null,
|
|
68
|
+
staticTitle: override.staticTitle || null,
|
|
69
|
+
urlPattern: override.urlPattern || `/${schema.info.pluralName}/{slug}`,
|
|
70
|
+
isSingleType,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function getAllIndexableUIDs() {
|
|
75
|
+
return getConfiguredContentTypes()
|
|
76
|
+
.map((ct) => ct.uid)
|
|
77
|
+
.filter((uid) => uid && strapi.contentTypes[uid]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Collection type UIDs only — used by the admin "Index all" allowlist. */
|
|
81
|
+
function getConfiguredCollectionTypeUIDs() {
|
|
82
|
+
return getAllIndexableUIDs().filter(
|
|
83
|
+
(uid) => strapi.contentTypes[uid]?.kind === 'collectionType'
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
shouldIndex,
|
|
89
|
+
getConfig,
|
|
90
|
+
getAllIndexableUIDs,
|
|
91
|
+
getConfiguredCollectionTypeUIDs,
|
|
92
|
+
};
|
|
93
|
+
};
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const SKIP_KEYS = new Set([
|
|
4
|
+
'id',
|
|
5
|
+
'documentId',
|
|
6
|
+
'createdAt',
|
|
7
|
+
'updatedAt',
|
|
8
|
+
'publishedAt',
|
|
9
|
+
'locale',
|
|
10
|
+
'createdBy',
|
|
11
|
+
'updatedBy',
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Fields skipped when extracting searchable text content.
|
|
16
|
+
* Add project-specific URL/ID fields here if needed.
|
|
17
|
+
*/
|
|
18
|
+
const SKIP_CONTENT_FIELDS = new Set([
|
|
19
|
+
'slug',
|
|
20
|
+
'order',
|
|
21
|
+
'date',
|
|
22
|
+
'linkedin_url',
|
|
23
|
+
'vimeo_id',
|
|
24
|
+
'vimeo_mobile_id',
|
|
25
|
+
'external_url',
|
|
26
|
+
'youtube_url',
|
|
27
|
+
'video_url',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
module.exports = ({ strapi }) => {
|
|
31
|
+
function clientService() {
|
|
32
|
+
return strapi.plugin('algolia').service('algoliaClient');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function configService() {
|
|
36
|
+
return strapi.plugin('algolia').service('algoliaConfig');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Text extraction helpers ──────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
function isMediaOrRelation(value) {
|
|
42
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
43
|
+
return !!(value.mime || (value.documentId && value.id));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function extractTextFromObject(obj) {
|
|
47
|
+
if (!obj || typeof obj !== 'object') return '';
|
|
48
|
+
|
|
49
|
+
const { stripHtml } = clientService();
|
|
50
|
+
const parts = [];
|
|
51
|
+
|
|
52
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
53
|
+
if (SKIP_KEYS.has(key)) continue;
|
|
54
|
+
if (isMediaOrRelation(value)) continue;
|
|
55
|
+
|
|
56
|
+
if (key === '__component' && value != null) {
|
|
57
|
+
parts.push(String(value).trim());
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (typeof value === 'string') {
|
|
62
|
+
parts.push(stripHtml(value));
|
|
63
|
+
} else if (Array.isArray(value)) {
|
|
64
|
+
for (const item of value) {
|
|
65
|
+
if (typeof item === 'string') {
|
|
66
|
+
parts.push(stripHtml(item));
|
|
67
|
+
} else if (item && typeof item === 'object' && !isMediaOrRelation(item)) {
|
|
68
|
+
parts.push(extractTextFromObject(item));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} else if (typeof value === 'object' && !isMediaOrRelation(value)) {
|
|
72
|
+
parts.push(extractTextFromObject(value));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return parts.filter(Boolean).join(' ');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const DANGEROUS_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
80
|
+
|
|
81
|
+
function resolveNestedField(obj, path) {
|
|
82
|
+
return path.split('.').reduce((cur, key) => {
|
|
83
|
+
if (!cur || typeof cur !== 'object') return undefined;
|
|
84
|
+
if (DANGEROUS_KEYS.has(key)) return undefined;
|
|
85
|
+
return cur[key];
|
|
86
|
+
}, obj);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function findTitle(entry, schema, config) {
|
|
90
|
+
const { stripHtml } = clientService();
|
|
91
|
+
|
|
92
|
+
if (config.staticTitle) return config.staticTitle;
|
|
93
|
+
|
|
94
|
+
if (config.titleField) {
|
|
95
|
+
const raw = resolveNestedField(entry, config.titleField);
|
|
96
|
+
if (raw) return stripHtml(String(raw));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (const field of ['title', 'name', 'heading']) {
|
|
100
|
+
if (entry[field] && typeof entry[field] === 'string') {
|
|
101
|
+
return stripHtml(entry[field]);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (entry.banner) {
|
|
106
|
+
if (entry.banner.title) return stripHtml(String(entry.banner.title));
|
|
107
|
+
if (entry.banner.label) return stripHtml(String(entry.banner.label));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return schema.info.displayName || '';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function extractContent(entry, schema) {
|
|
114
|
+
const { stripHtml } = clientService();
|
|
115
|
+
const parts = [];
|
|
116
|
+
|
|
117
|
+
for (const [key, attr] of Object.entries(schema.attributes)) {
|
|
118
|
+
if (SKIP_CONTENT_FIELDS.has(key)) continue;
|
|
119
|
+
const value = entry[key];
|
|
120
|
+
if (value == null) continue;
|
|
121
|
+
|
|
122
|
+
if (attr.type === 'uid') continue;
|
|
123
|
+
if (attr.type === 'relation' || attr.type === 'media') continue;
|
|
124
|
+
|
|
125
|
+
if (
|
|
126
|
+
attr.type === 'string' ||
|
|
127
|
+
attr.type === 'text' ||
|
|
128
|
+
attr.type === 'richtext' ||
|
|
129
|
+
(attr.type === 'customField' &&
|
|
130
|
+
attr.customField === 'plugin::ckeditor5.CKEditor')
|
|
131
|
+
) {
|
|
132
|
+
parts.push(stripHtml(String(value)));
|
|
133
|
+
} else if (attr.type === 'component') {
|
|
134
|
+
if (attr.repeatable && Array.isArray(value)) {
|
|
135
|
+
for (const item of value) parts.push(extractTextFromObject(item));
|
|
136
|
+
} else if (typeof value === 'object') {
|
|
137
|
+
parts.push(extractTextFromObject(value));
|
|
138
|
+
}
|
|
139
|
+
} else if (attr.type === 'dynamiczone' && Array.isArray(value)) {
|
|
140
|
+
for (const item of value) parts.push(extractTextFromObject(item));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return parts.filter(Boolean).join(' ');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Populate builder ────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
function buildPopulate(uid) {
|
|
150
|
+
const schema = strapi.contentTypes[uid];
|
|
151
|
+
if (!schema) return {};
|
|
152
|
+
|
|
153
|
+
const populate = {};
|
|
154
|
+
for (const [key, attr] of Object.entries(schema.attributes)) {
|
|
155
|
+
if (attr.type === 'component' || attr.type === 'dynamiczone') {
|
|
156
|
+
populate[key] = { populate: '*' };
|
|
157
|
+
}
|
|
158
|
+
if (attr.type === 'relation' && (key === 'domains' || key === 'domain')) {
|
|
159
|
+
populate[key] = { fields: ['title', 'slug', 'documentId'] };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return populate;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Object ID + URL ──────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
function buildObjectID(uid, documentId, locale) {
|
|
168
|
+
const parts = [uid, documentId];
|
|
169
|
+
if (locale) parts.push(locale);
|
|
170
|
+
return parts.join('::');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function buildUrl(entry, config) {
|
|
174
|
+
return config.urlPattern.replace('{slug}', entry.slug || entry.documentId || '');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── Domain extraction ────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
function normalizeDomainValue(item) {
|
|
180
|
+
if (!item || typeof item !== 'object') return null;
|
|
181
|
+
return item.slug || item.title || item.documentId || null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function collectDomainValues(value, set) {
|
|
185
|
+
if (!value) return;
|
|
186
|
+
if (Array.isArray(value)) {
|
|
187
|
+
for (const item of value) {
|
|
188
|
+
const normalized = normalizeDomainValue(item);
|
|
189
|
+
if (normalized) set.add(normalized);
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const normalized = normalizeDomainValue(value);
|
|
194
|
+
if (normalized) set.add(normalized);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const MAX_TRAVERSE_DEPTH = 10;
|
|
198
|
+
|
|
199
|
+
function traverseForDomains(node, set, visited, depth = 0) {
|
|
200
|
+
if (depth > MAX_TRAVERSE_DEPTH) return;
|
|
201
|
+
if (!node || typeof node !== 'object') return;
|
|
202
|
+
if (visited.has(node)) return;
|
|
203
|
+
visited.add(node);
|
|
204
|
+
|
|
205
|
+
if (Array.isArray(node)) {
|
|
206
|
+
for (const item of node) traverseForDomains(item, set, visited, depth + 1);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
for (const [key, value] of Object.entries(node)) {
|
|
211
|
+
if (key === 'domain' || key === 'domains') {
|
|
212
|
+
collectDomainValues(value, set);
|
|
213
|
+
}
|
|
214
|
+
if (value && typeof value === 'object') {
|
|
215
|
+
traverseForDomains(value, set, visited, depth + 1);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function extractDomains(entry) {
|
|
221
|
+
const domains = new Set();
|
|
222
|
+
traverseForDomains(entry, domains, new Set());
|
|
223
|
+
return [...domains];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function isDomainsEnabled() {
|
|
227
|
+
return !!(strapi.config.get('plugin.algolia') || {}).enableDomains;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function buildRecord(entry, schema, cfg, uid, documentId, locale) {
|
|
231
|
+
const record = {
|
|
232
|
+
objectID: buildObjectID(uid, documentId, locale),
|
|
233
|
+
title: findTitle(entry, schema, cfg),
|
|
234
|
+
content: extractContent(entry, schema),
|
|
235
|
+
url: buildUrl(entry, cfg),
|
|
236
|
+
};
|
|
237
|
+
if (isDomainsEnabled()) {
|
|
238
|
+
record.domains = extractDomains(entry);
|
|
239
|
+
}
|
|
240
|
+
return record;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ── Public sync methods ──────────────────────────────────────────────────
|
|
244
|
+
|
|
245
|
+
async function indexEntry(uid, documentId, locale) {
|
|
246
|
+
const cfg = configService().getConfig(uid);
|
|
247
|
+
if (!cfg) return;
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
const populate = buildPopulate(uid);
|
|
251
|
+
const query = { documentId, populate, status: 'published' };
|
|
252
|
+
if (locale) query.locale = locale;
|
|
253
|
+
|
|
254
|
+
const entry = await strapi
|
|
255
|
+
.documents(/** @type {any} */ (uid))
|
|
256
|
+
.findOne(/** @type {any} */ (query));
|
|
257
|
+
if (!entry) return;
|
|
258
|
+
|
|
259
|
+
const schema = strapi.contentTypes[uid];
|
|
260
|
+
const loc = locale || entry.locale;
|
|
261
|
+
|
|
262
|
+
await clientService().saveObjects(cfg.indexName, [
|
|
263
|
+
buildRecord(entry, schema, cfg, uid, documentId, loc),
|
|
264
|
+
]);
|
|
265
|
+
} catch (err) {
|
|
266
|
+
strapi.log.error(
|
|
267
|
+
`[Algolia] indexEntry failed for ${uid} (${documentId}): ${err.message}`
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function removeEntry(uid, documentId) {
|
|
273
|
+
const cfg = configService().getConfig(uid);
|
|
274
|
+
if (!cfg) return;
|
|
275
|
+
await clientService().deleteObject(cfg.indexName, documentId);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Reindex all published documents for one collection type UID.
|
|
280
|
+
* Returns { count, indexName }.
|
|
281
|
+
*/
|
|
282
|
+
async function reindexCollectionUid(uid) {
|
|
283
|
+
if (!clientService().getClient()) {
|
|
284
|
+
throw new Error(
|
|
285
|
+
'Algolia is not configured (missing appId / apiKey)'
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const schema = strapi.contentTypes[uid];
|
|
290
|
+
if (!schema || schema.kind !== 'collectionType') {
|
|
291
|
+
throw new Error('Not a collection type');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (!configService().shouldIndex(uid)) {
|
|
295
|
+
throw new Error('This content type is not configured for Algolia indexing');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const cfg = configService().getConfig(uid);
|
|
299
|
+
if (!cfg || cfg.isSingleType) {
|
|
300
|
+
throw new Error('Not an indexable collection type');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const populate = buildPopulate(uid);
|
|
304
|
+
let total = 0;
|
|
305
|
+
const PAGE_SIZE = 100;
|
|
306
|
+
let start = 0;
|
|
307
|
+
let hasMore = true;
|
|
308
|
+
|
|
309
|
+
while (hasMore) {
|
|
310
|
+
const entries = await strapi
|
|
311
|
+
.documents(/** @type {any} */ (uid))
|
|
312
|
+
.findMany({ populate, status: 'published', limit: PAGE_SIZE, start });
|
|
313
|
+
|
|
314
|
+
if (!entries || entries.length === 0) {
|
|
315
|
+
hasMore = false;
|
|
316
|
+
break;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const objects = entries.map((entry) =>
|
|
320
|
+
buildRecord(entry, schema, cfg, uid, entry.documentId, entry.locale)
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
await clientService().saveObjects(cfg.indexName, objects);
|
|
324
|
+
total += entries.length;
|
|
325
|
+
start += entries.length;
|
|
326
|
+
if (entries.length < PAGE_SIZE) hasMore = false;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
strapi.log.info(
|
|
330
|
+
`[Algolia] Bulk reindex ${uid} → ${cfg.indexName} (${total} documents)`
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
return { count: total, indexName: cfg.indexName };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Reindex every configured content type. */
|
|
337
|
+
async function fullReindex() {
|
|
338
|
+
if (!clientService().getClient()) {
|
|
339
|
+
strapi.log.warn('[Algolia] Skipping full reindex — client not configured');
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
strapi.log.info('[Algolia] Starting full reindex …');
|
|
344
|
+
const uids = configService().getAllIndexableUIDs();
|
|
345
|
+
|
|
346
|
+
for (const uid of uids) {
|
|
347
|
+
const cfg = configService().getConfig(uid);
|
|
348
|
+
if (!cfg) continue;
|
|
349
|
+
|
|
350
|
+
const schema = strapi.contentTypes[uid];
|
|
351
|
+
const populate = buildPopulate(uid);
|
|
352
|
+
|
|
353
|
+
try {
|
|
354
|
+
const PAGE_SIZE = 100;
|
|
355
|
+
let start = 0;
|
|
356
|
+
let hasMore = true;
|
|
357
|
+
|
|
358
|
+
while (hasMore) {
|
|
359
|
+
const entries = await strapi
|
|
360
|
+
.documents(/** @type {any} */ (uid))
|
|
361
|
+
.findMany({ populate, status: 'published', limit: PAGE_SIZE, start });
|
|
362
|
+
|
|
363
|
+
if (!entries || entries.length === 0) {
|
|
364
|
+
hasMore = false;
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const objects = entries.map((entry) =>
|
|
369
|
+
buildRecord(entry, schema, cfg, uid, entry.documentId, entry.locale)
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
await clientService().saveObjects(cfg.indexName, objects);
|
|
373
|
+
start += entries.length;
|
|
374
|
+
if (entries.length < PAGE_SIZE) hasMore = false;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
strapi.log.info(`[Algolia] Reindexed ${uid}`);
|
|
378
|
+
} catch (err) {
|
|
379
|
+
strapi.log.error(`[Algolia] Reindex error for ${uid}: ${err.message}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
strapi.log.info('[Algolia] Full reindex complete');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
indexEntry,
|
|
388
|
+
removeEntry,
|
|
389
|
+
reindexCollectionUid,
|
|
390
|
+
fullReindex,
|
|
391
|
+
};
|
|
392
|
+
};
|
package/strapi-admin.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from './admin/src/index';
|
package/strapi-server.js
ADDED