@sequoialabs/payload-plugin-reversia 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +84 -0
- package/dist/collections/sync-pending.d.ts +2 -0
- package/dist/collections/sync-pending.js +27 -0
- package/dist/endpoints/confirm-resources-sync.d.ts +3 -0
- package/dist/endpoints/confirm-resources-sync.js +31 -0
- package/dist/endpoints/resource.d.ts +3 -0
- package/dist/endpoints/resource.js +82 -0
- package/dist/endpoints/resources-definition.d.ts +3 -0
- package/dist/endpoints/resources-definition.js +54 -0
- package/dist/endpoints/resources-insert.d.ts +3 -0
- package/dist/endpoints/resources-insert.js +174 -0
- package/dist/endpoints/resources-sync.d.ts +3 -0
- package/dist/endpoints/resources-sync.js +59 -0
- package/dist/endpoints/resources.d.ts +3 -0
- package/dist/endpoints/resources.js +107 -0
- package/dist/endpoints/settings.d.ts +3 -0
- package/dist/endpoints/settings.js +34 -0
- package/dist/hooks/after-change.d.ts +11 -0
- package/dist/hooks/after-change.js +40 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +82 -0
- package/dist/types.d.ts +238 -0
- package/dist/types.js +12 -0
- package/dist/utils/auth.d.ts +3 -0
- package/dist/utils/auth.js +27 -0
- package/dist/utils/cursor.d.ts +7 -0
- package/dist/utils/cursor.js +30 -0
- package/dist/utils/fields.d.ts +58 -0
- package/dist/utils/fields.js +715 -0
- package/dist/utils/json-extract.d.ts +37 -0
- package/dist/utils/json-extract.js +186 -0
- package/dist/utils/labels.d.ts +12 -0
- package/dist/utils/labels.js +27 -0
- package/dist/utils/path-resolver.d.ts +53 -0
- package/dist/utils/path-resolver.js +157 -0
- package/dist/utils/payload-helpers.d.ts +16 -0
- package/dist/utils/payload-helpers.js +45 -0
- package/package.json +57 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { unauthorizedResponse, validateApiKey } from '../utils/auth.js';
|
|
2
|
+
import { decodeCursor, encodeCursor } from '../utils/cursor.js';
|
|
3
|
+
import { findLocalizedFields, serializeField } from '../utils/fields.js';
|
|
4
|
+
import { parseLimit, resolveDefaultLocale } from '../utils/payload-helpers.js';
|
|
5
|
+
function extractContent(doc, fields) {
|
|
6
|
+
const content = {};
|
|
7
|
+
const contentTypes = {};
|
|
8
|
+
for (const field of fields) {
|
|
9
|
+
const entry = serializeField(field, doc);
|
|
10
|
+
if (!entry) {
|
|
11
|
+
continue;
|
|
12
|
+
}
|
|
13
|
+
content[entry.name] = entry.value;
|
|
14
|
+
if (entry.contentType) {
|
|
15
|
+
contentTypes[entry.name] = entry.contentType;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return { content, contentTypes };
|
|
19
|
+
}
|
|
20
|
+
function getLabelValue(doc, fields) {
|
|
21
|
+
const labelField = fields.find((f) => !f.isContainer && (f.name === 'title' || f.name === 'name'));
|
|
22
|
+
if (!labelField) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
const value = doc?.[labelField.name];
|
|
26
|
+
return typeof value === 'string' ? value : undefined;
|
|
27
|
+
}
|
|
28
|
+
export function createResourcesEndpoint(pluginConfig, collectionsMap, _globalsMap) {
|
|
29
|
+
return {
|
|
30
|
+
path: '/reversia/resources',
|
|
31
|
+
method: 'get',
|
|
32
|
+
handler: async (req) => {
|
|
33
|
+
if (!validateApiKey(req, pluginConfig.apiKey)) {
|
|
34
|
+
return unauthorizedResponse();
|
|
35
|
+
}
|
|
36
|
+
const typesParam = req.searchParams.get('types');
|
|
37
|
+
const cursorParam = req.searchParams.get('cursor');
|
|
38
|
+
const limit = parseLimit(req.searchParams.get('limit'));
|
|
39
|
+
const cursor = decodeCursor(cursorParam);
|
|
40
|
+
const requestedTypes = typesParam ? typesParam.split(',').filter(Boolean) : null;
|
|
41
|
+
const defaultLocale = resolveDefaultLocale(req);
|
|
42
|
+
const response = { content: [], cursor: null };
|
|
43
|
+
let totalFetched = 0;
|
|
44
|
+
let lastType = null;
|
|
45
|
+
let lastId = null;
|
|
46
|
+
let startFromCursor = !cursor;
|
|
47
|
+
for (const [slug, collection] of collectionsMap) {
|
|
48
|
+
const resourceType = `payloadcms:${slug}`;
|
|
49
|
+
if (requestedTypes && !requestedTypes.includes(resourceType)) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!startFromCursor) {
|
|
53
|
+
if (cursor && cursor.type === resourceType) {
|
|
54
|
+
startFromCursor = true;
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (totalFetched >= limit) {
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
const localizedFields = findLocalizedFields(collection.fields);
|
|
64
|
+
if (localizedFields.length === 0) {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const where = {};
|
|
68
|
+
if (cursor && cursor.type === resourceType && cursor.id) {
|
|
69
|
+
where.id = { greater_than: cursor.id };
|
|
70
|
+
}
|
|
71
|
+
const docs = await req.payload.find({
|
|
72
|
+
collection: slug,
|
|
73
|
+
locale: defaultLocale,
|
|
74
|
+
limit: limit - totalFetched,
|
|
75
|
+
sort: 'id',
|
|
76
|
+
where,
|
|
77
|
+
});
|
|
78
|
+
if (docs.docs.length === 0) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const items = [];
|
|
82
|
+
for (const doc of docs.docs) {
|
|
83
|
+
const { content, contentTypes } = extractContent(doc, localizedFields);
|
|
84
|
+
if (Object.keys(content).length === 0) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
items.push({
|
|
88
|
+
id: String(doc.id),
|
|
89
|
+
label: getLabelValue(doc, localizedFields),
|
|
90
|
+
content,
|
|
91
|
+
contentTypes: Object.keys(contentTypes).length > 0 ? contentTypes : undefined,
|
|
92
|
+
});
|
|
93
|
+
lastType = resourceType;
|
|
94
|
+
lastId = String(doc.id);
|
|
95
|
+
totalFetched++;
|
|
96
|
+
}
|
|
97
|
+
if (items.length > 0) {
|
|
98
|
+
response.content.push({ type: resourceType, data: items });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (lastType && lastId && totalFetched >= limit) {
|
|
102
|
+
response.cursor = encodeCursor(lastType, lastId);
|
|
103
|
+
}
|
|
104
|
+
return Response.json(response);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { unauthorizedResponse, validateApiKey } from '../utils/auth.js';
|
|
2
|
+
export function createSettingsEndpoint(pluginConfig) {
|
|
3
|
+
return {
|
|
4
|
+
path: '/reversia/settings',
|
|
5
|
+
method: 'get',
|
|
6
|
+
handler: async (req) => {
|
|
7
|
+
if (!validateApiKey(req, pluginConfig.apiKey)) {
|
|
8
|
+
return unauthorizedResponse();
|
|
9
|
+
}
|
|
10
|
+
const localization = req.payload.config.localization;
|
|
11
|
+
let languages = [];
|
|
12
|
+
if (localization && typeof localization === 'object' && 'locales' in localization) {
|
|
13
|
+
const locales = localization.locales;
|
|
14
|
+
languages = locales.map((locale) => {
|
|
15
|
+
const label = typeof locale.label === 'string'
|
|
16
|
+
? locale.label
|
|
17
|
+
: locale.label && typeof locale.label === 'object' && 'en' in locale.label
|
|
18
|
+
? String(locale.label.en)
|
|
19
|
+
: locale.code;
|
|
20
|
+
return { code: locale.code, label };
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
const defaultLocale = localization && typeof localization === 'object' && 'defaultLocale' in localization
|
|
24
|
+
? String(localization.defaultLocale)
|
|
25
|
+
: 'en';
|
|
26
|
+
return Response.json({
|
|
27
|
+
platform: 'payloadcms',
|
|
28
|
+
pluginVersion: '0.1.0',
|
|
29
|
+
languages,
|
|
30
|
+
defaultLocale,
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CollectionAfterChangeHook } from 'payload';
|
|
2
|
+
/**
|
|
3
|
+
* Records a pending-sync entry whenever a tracked document changes.
|
|
4
|
+
*
|
|
5
|
+
* Each call deletes any existing pending row for the same (resourceType,
|
|
6
|
+
* resourceId) pair and creates a fresh one. This guarantees the row's id is
|
|
7
|
+
* greater than every cursor previously handed out, so a `confirm` call that
|
|
8
|
+
* clears `id ≤ cursor.id` never eats a pending change that arrived after the
|
|
9
|
+
* cursor was issued.
|
|
10
|
+
*/
|
|
11
|
+
export declare function createAfterChangeHook(resourceType: string): CollectionAfterChangeHook;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Records a pending-sync entry whenever a tracked document changes.
|
|
3
|
+
*
|
|
4
|
+
* Each call deletes any existing pending row for the same (resourceType,
|
|
5
|
+
* resourceId) pair and creates a fresh one. This guarantees the row's id is
|
|
6
|
+
* greater than every cursor previously handed out, so a `confirm` call that
|
|
7
|
+
* clears `id ≤ cursor.id` never eats a pending change that arrived after the
|
|
8
|
+
* cursor was issued.
|
|
9
|
+
*/
|
|
10
|
+
export function createAfterChangeHook(resourceType) {
|
|
11
|
+
return async ({ doc, req, context }) => {
|
|
12
|
+
if (context?.reversiaInsertion) {
|
|
13
|
+
return doc;
|
|
14
|
+
}
|
|
15
|
+
const resourceId = String(doc.id);
|
|
16
|
+
try {
|
|
17
|
+
const existing = await req.payload.find({
|
|
18
|
+
collection: 'reversia-sync-pending',
|
|
19
|
+
where: {
|
|
20
|
+
and: [{ resourceType: { equals: resourceType } }, { resourceId: { equals: resourceId } }],
|
|
21
|
+
},
|
|
22
|
+
limit: 100,
|
|
23
|
+
});
|
|
24
|
+
for (const row of existing.docs) {
|
|
25
|
+
await req.payload.delete({
|
|
26
|
+
collection: 'reversia-sync-pending',
|
|
27
|
+
id: row.id,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
await req.payload.create({
|
|
31
|
+
collection: 'reversia-sync-pending',
|
|
32
|
+
data: { resourceType, resourceId },
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
req.payload.logger.error({ err: error, resourceType, resourceId }, '[reversia] Failed to track sync pending');
|
|
37
|
+
}
|
|
38
|
+
return doc;
|
|
39
|
+
};
|
|
40
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Config } from 'payload';
|
|
2
|
+
import type { ReversiaPluginConfig } from './types.js';
|
|
3
|
+
export type { ConfirmResourcesSyncResponse, InsertionRequest, InsertionResponse, ResourceDefinition, ResourceItem, ResourceResponse, ReversiaErrorResponse, ReversiaFieldCustom, ReversiaPluginConfig, SettingsResponse, StreamResponse, TranslatableFieldConfig, } from './types.js';
|
|
4
|
+
export { ReversiaFieldBehavior, ReversiaFieldType } from './types.js';
|
|
5
|
+
export declare const reversiaPlugin: (pluginConfig: ReversiaPluginConfig) => (config: Config) => Config;
|
|
6
|
+
export default reversiaPlugin;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { reversiaSyncPendingCollection } from './collections/sync-pending.js';
|
|
2
|
+
import { createConfirmResourcesSyncEndpoint } from './endpoints/confirm-resources-sync.js';
|
|
3
|
+
import { createResourceEndpoint } from './endpoints/resource.js';
|
|
4
|
+
import { createResourcesEndpoint } from './endpoints/resources.js';
|
|
5
|
+
import { createResourcesDefinitionEndpoint } from './endpoints/resources-definition.js';
|
|
6
|
+
import { createResourcesInsertEndpoint } from './endpoints/resources-insert.js';
|
|
7
|
+
import { createResourcesSyncEndpoint } from './endpoints/resources-sync.js';
|
|
8
|
+
import { createSettingsEndpoint } from './endpoints/settings.js';
|
|
9
|
+
import { createAfterChangeHook } from './hooks/after-change.js';
|
|
10
|
+
import { findLocalizedFields } from './utils/fields.js';
|
|
11
|
+
export { ReversiaFieldBehavior, ReversiaFieldType } from './types.js';
|
|
12
|
+
const APPLIED_MARKER = Symbol.for('payload-plugin-reversia.applied');
|
|
13
|
+
export const reversiaPlugin = (pluginConfig) => (config) => {
|
|
14
|
+
if (pluginConfig.disabled) {
|
|
15
|
+
return config;
|
|
16
|
+
}
|
|
17
|
+
if (typeof pluginConfig.apiKey !== 'string' || pluginConfig.apiKey.length === 0) {
|
|
18
|
+
throw new Error('[reversia] apiKey is required. Set `ReversiaPluginConfig.apiKey` to a non-empty string.');
|
|
19
|
+
}
|
|
20
|
+
const marked = config;
|
|
21
|
+
if (marked[APPLIED_MARKER]) {
|
|
22
|
+
return config;
|
|
23
|
+
}
|
|
24
|
+
marked[APPLIED_MARKER] = true;
|
|
25
|
+
const enabledCollectionSlugs = pluginConfig.enabledCollections
|
|
26
|
+
? new Set(pluginConfig.enabledCollections.map((s) => String(s)))
|
|
27
|
+
: null;
|
|
28
|
+
const enabledGlobalSlugs = pluginConfig.enabledGlobals
|
|
29
|
+
? new Set(pluginConfig.enabledGlobals)
|
|
30
|
+
: null;
|
|
31
|
+
const collectionsMap = new Map();
|
|
32
|
+
const globalsMap = new Map();
|
|
33
|
+
const collections = [...(config.collections ?? [])];
|
|
34
|
+
for (const collection of collections) {
|
|
35
|
+
if (enabledCollectionSlugs && !enabledCollectionSlugs.has(collection.slug)) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (findLocalizedFields(collection.fields).length === 0) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
collectionsMap.set(collection.slug, collection);
|
|
42
|
+
}
|
|
43
|
+
const globals = [...(config.globals ?? [])];
|
|
44
|
+
for (const global of globals) {
|
|
45
|
+
if (enabledGlobalSlugs && !enabledGlobalSlugs.has(global.slug)) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (findLocalizedFields(global.fields).length === 0) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
globalsMap.set(global.slug, global);
|
|
52
|
+
}
|
|
53
|
+
config.collections = collections.map((collection) => {
|
|
54
|
+
if (!collectionsMap.has(collection.slug)) {
|
|
55
|
+
return collection;
|
|
56
|
+
}
|
|
57
|
+
const resourceType = `payloadcms:${collection.slug}`;
|
|
58
|
+
return {
|
|
59
|
+
...collection,
|
|
60
|
+
hooks: {
|
|
61
|
+
...(collection.hooks ?? {}),
|
|
62
|
+
afterChange: [
|
|
63
|
+
...(collection.hooks?.afterChange ?? []),
|
|
64
|
+
createAfterChangeHook(resourceType),
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
config.collections = [...(config.collections ?? []), reversiaSyncPendingCollection];
|
|
70
|
+
config.endpoints = [
|
|
71
|
+
...(config.endpoints ?? []),
|
|
72
|
+
createResourcesDefinitionEndpoint(pluginConfig, collectionsMap, globalsMap),
|
|
73
|
+
createResourcesEndpoint(pluginConfig, collectionsMap, globalsMap),
|
|
74
|
+
createResourcesSyncEndpoint(pluginConfig, collectionsMap),
|
|
75
|
+
createResourceEndpoint(pluginConfig, collectionsMap, globalsMap),
|
|
76
|
+
createResourcesInsertEndpoint(pluginConfig, collectionsMap, globalsMap),
|
|
77
|
+
createConfirmResourcesSyncEndpoint(pluginConfig),
|
|
78
|
+
createSettingsEndpoint(pluginConfig),
|
|
79
|
+
];
|
|
80
|
+
return config;
|
|
81
|
+
};
|
|
82
|
+
export default reversiaPlugin;
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import type { CollectionSlug } from 'payload';
|
|
2
|
+
import type { LeafSegment } from './utils/path-resolver.js';
|
|
3
|
+
export interface ReversiaPluginConfig {
|
|
4
|
+
/**
|
|
5
|
+
* API key used by Reversia SaaS to authenticate requests.
|
|
6
|
+
* Validated against `X-API-Key` header.
|
|
7
|
+
*/
|
|
8
|
+
apiKey: string;
|
|
9
|
+
/**
|
|
10
|
+
* Optional: restrict which collections are exposed to Reversia.
|
|
11
|
+
* If omitted, all collections with localized fields are exposed.
|
|
12
|
+
*/
|
|
13
|
+
enabledCollections?: CollectionSlug[];
|
|
14
|
+
/**
|
|
15
|
+
* Optional: restrict which globals are exposed to Reversia.
|
|
16
|
+
* If omitted, all globals with localized fields are exposed.
|
|
17
|
+
*/
|
|
18
|
+
enabledGlobals?: string[];
|
|
19
|
+
/**
|
|
20
|
+
* Whether the plugin is disabled. Defaults to false.
|
|
21
|
+
*/
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
}
|
|
24
|
+
export declare enum ReversiaFieldType {
|
|
25
|
+
TEXT = "TEXT",
|
|
26
|
+
HTML = "HTML",
|
|
27
|
+
JSON = "JSON",
|
|
28
|
+
LINK = "LINK",
|
|
29
|
+
MEDIUM = "MEDIUM"
|
|
30
|
+
}
|
|
31
|
+
export declare enum ReversiaFieldBehavior {
|
|
32
|
+
SLUG = "slug"
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Custom field metadata for Reversia.
|
|
36
|
+
*
|
|
37
|
+
* Usage on any PayloadCMS field:
|
|
38
|
+
* ```ts
|
|
39
|
+
* import { ReversiaFieldType, ReversiaFieldBehavior } from 'payload-plugin-reversia'
|
|
40
|
+
*
|
|
41
|
+
* {
|
|
42
|
+
* name: 'slug',
|
|
43
|
+
* type: 'text',
|
|
44
|
+
* localized: true,
|
|
45
|
+
* custom: {
|
|
46
|
+
* reversia: {
|
|
47
|
+
* behavior: ReversiaFieldBehavior.SLUG,
|
|
48
|
+
* },
|
|
49
|
+
* },
|
|
50
|
+
* }
|
|
51
|
+
*
|
|
52
|
+
* {
|
|
53
|
+
* name: 'ogImage',
|
|
54
|
+
* type: 'text',
|
|
55
|
+
* localized: true,
|
|
56
|
+
* custom: {
|
|
57
|
+
* reversia: {
|
|
58
|
+
* type: ReversiaFieldType.MEDIUM,
|
|
59
|
+
* },
|
|
60
|
+
* },
|
|
61
|
+
* }
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export interface ReversiaFieldCustom {
|
|
65
|
+
/**
|
|
66
|
+
* How the field value should be treated during translation.
|
|
67
|
+
*/
|
|
68
|
+
behavior?: ReversiaFieldBehavior;
|
|
69
|
+
/**
|
|
70
|
+
* The content type of the field value.
|
|
71
|
+
* Inferred from PayloadCMS field type when omitted (richText → JSON, json → JSON).
|
|
72
|
+
* The Lexical/Slate tree is shipped as a JSON-encoded map of translatable leaves.
|
|
73
|
+
*/
|
|
74
|
+
type?: ReversiaFieldType;
|
|
75
|
+
/**
|
|
76
|
+
* Whether this field is used as the resource label in Reversia.
|
|
77
|
+
*/
|
|
78
|
+
asLabel?: boolean;
|
|
79
|
+
/**
|
|
80
|
+
* Whether this field is selected for translation by default.
|
|
81
|
+
* Defaults to true.
|
|
82
|
+
*/
|
|
83
|
+
selected?: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* For object/array-shaped values (richText, json), restrict which leaf strings
|
|
86
|
+
* are shipped to Reversia.
|
|
87
|
+
*
|
|
88
|
+
* Path syntax:
|
|
89
|
+
* - `text` — any key `text` at any depth (shorthand for `**.text`)
|
|
90
|
+
* - `root.foo` — exact path from the root
|
|
91
|
+
* - `foo.*.bar` — single wildcard segment (one object key or array index)
|
|
92
|
+
* - `foo.**.bar` — deep wildcard (zero or more segments)
|
|
93
|
+
*
|
|
94
|
+
* Default for `richText` when omitted: `['text', 'url', 'alt']`.
|
|
95
|
+
* For `json`, extraction only happens when this is explicitly set (or `extract` is provided).
|
|
96
|
+
*/
|
|
97
|
+
translatableKeys?: string[];
|
|
98
|
+
/**
|
|
99
|
+
* Escape hatch. When provided, bypasses `translatableKeys` matching entirely.
|
|
100
|
+
* `extract` receives the raw field value and returns the string shipped to Reversia.
|
|
101
|
+
* `apply` receives the source-locale value plus the translated string and returns
|
|
102
|
+
* the value stored on the target locale. Both must be set together.
|
|
103
|
+
*/
|
|
104
|
+
extract?: (value: unknown) => string;
|
|
105
|
+
apply?: (sourceValue: unknown, translated: string) => unknown;
|
|
106
|
+
}
|
|
107
|
+
export interface TranslatableFieldConfig {
|
|
108
|
+
label: string;
|
|
109
|
+
asLabel?: boolean;
|
|
110
|
+
behavior?: ReversiaFieldBehavior;
|
|
111
|
+
type?: ReversiaFieldType;
|
|
112
|
+
selected?: boolean;
|
|
113
|
+
}
|
|
114
|
+
export interface ResourceDefinition {
|
|
115
|
+
type: string;
|
|
116
|
+
label: {
|
|
117
|
+
singular: string;
|
|
118
|
+
plural: string;
|
|
119
|
+
};
|
|
120
|
+
group: string;
|
|
121
|
+
version: string;
|
|
122
|
+
configuration: Record<string, TranslatableFieldConfig>;
|
|
123
|
+
configurationType: 'ENTITY' | 'MULTIPLE';
|
|
124
|
+
count?: number;
|
|
125
|
+
synchronizable: boolean;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Describes one localized leaf inside a top-level container field.
|
|
129
|
+
*
|
|
130
|
+
* `segments` walks from the container's *value* to the leaf (so a top-level
|
|
131
|
+
* scalar localized field has `segments: []` because the container value IS
|
|
132
|
+
* the scalar).
|
|
133
|
+
*
|
|
134
|
+
* `kind`:
|
|
135
|
+
* - `scalar` — atomic string-shaped leaf (text, textarea, email, url, …)
|
|
136
|
+
* serialized as-is at its pointer.
|
|
137
|
+
* - `json` — structurally complex leaf (richText, json) that needs internal
|
|
138
|
+
* extraction by `translatableKeys`. Each matching sub-leaf gets its own
|
|
139
|
+
* pointer entry, prefixed by the leaf's own pointer in the container.
|
|
140
|
+
*/
|
|
141
|
+
export interface LocalizedLeaf {
|
|
142
|
+
segments: LeafSegment[];
|
|
143
|
+
kind: 'scalar' | 'json';
|
|
144
|
+
payloadFieldType: string;
|
|
145
|
+
reversia?: ReversiaFieldCustom;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* One translatable top-level field on a collection or global.
|
|
149
|
+
*
|
|
150
|
+
* The plugin emits exactly one resource-content entry per `LocalizedFieldInfo`,
|
|
151
|
+
* keyed by `name`. Scalars ship their value directly. Containers ship a
|
|
152
|
+
* JSON-stringified `{ <jsonPointer>: <translatableString> }` map whose keys
|
|
153
|
+
* address each atomic translatable leaf inside the container's value.
|
|
154
|
+
*/
|
|
155
|
+
export interface LocalizedFieldInfo {
|
|
156
|
+
/** Top-level field name — the key in the document and in the content map. */
|
|
157
|
+
name: string;
|
|
158
|
+
/** Resolved label for the resource configuration. */
|
|
159
|
+
label: string;
|
|
160
|
+
/** PayloadCMS field type at the top level (`text`, `richText`, `array`, …). */
|
|
161
|
+
payloadFieldType: string;
|
|
162
|
+
/**
|
|
163
|
+
* `true` when the value is shipped as a JSON pointer-map (containers).
|
|
164
|
+
* `false` when shipped as a plain primitive (top-level localized scalars).
|
|
165
|
+
*/
|
|
166
|
+
isContainer: boolean;
|
|
167
|
+
/** `custom.reversia` declared on the top-level field, if any. */
|
|
168
|
+
reversia?: ReversiaFieldCustom;
|
|
169
|
+
/** One descriptor per localized leaf inside the container. */
|
|
170
|
+
leaves: LocalizedLeaf[];
|
|
171
|
+
}
|
|
172
|
+
export interface ResourceItem {
|
|
173
|
+
id: string;
|
|
174
|
+
label?: string;
|
|
175
|
+
content: Record<string, unknown>;
|
|
176
|
+
contentTypes?: Record<string, string>;
|
|
177
|
+
}
|
|
178
|
+
export interface StreamResponse {
|
|
179
|
+
content: Array<{
|
|
180
|
+
type: string;
|
|
181
|
+
data: ResourceItem[];
|
|
182
|
+
}>;
|
|
183
|
+
cursor: string | null;
|
|
184
|
+
}
|
|
185
|
+
export interface InsertionRequest {
|
|
186
|
+
type: string;
|
|
187
|
+
id: string;
|
|
188
|
+
sourceLocale: string;
|
|
189
|
+
targetLocale: string;
|
|
190
|
+
data: Record<string, unknown>;
|
|
191
|
+
}
|
|
192
|
+
export interface InsertionResponse {
|
|
193
|
+
errors: string[];
|
|
194
|
+
[key: number]: {
|
|
195
|
+
index: number;
|
|
196
|
+
type: string;
|
|
197
|
+
id: string;
|
|
198
|
+
diff: Record<string, string>;
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
export interface Cursor {
|
|
202
|
+
type: string;
|
|
203
|
+
id: string;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Response shape for `GET /reversia/resource` (single collection doc or global).
|
|
207
|
+
*/
|
|
208
|
+
export interface ResourceResponse {
|
|
209
|
+
id: string;
|
|
210
|
+
label?: string;
|
|
211
|
+
content: Record<string, unknown>;
|
|
212
|
+
contentTypes?: Record<string, string>;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Response shape for `GET /reversia/settings`.
|
|
216
|
+
*/
|
|
217
|
+
export interface SettingsResponse {
|
|
218
|
+
platform: 'payloadcms';
|
|
219
|
+
pluginVersion: string;
|
|
220
|
+
languages: Array<{
|
|
221
|
+
code: string;
|
|
222
|
+
label: string;
|
|
223
|
+
}>;
|
|
224
|
+
defaultLocale: string;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Response shape for `POST /reversia/confirm-resources-sync`.
|
|
228
|
+
*/
|
|
229
|
+
export interface ConfirmResourcesSyncResponse {
|
|
230
|
+
success: true;
|
|
231
|
+
deleted: number;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* 4xx/5xx error response used uniformly across endpoints.
|
|
235
|
+
*/
|
|
236
|
+
export interface ReversiaErrorResponse {
|
|
237
|
+
error: string;
|
|
238
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export var ReversiaFieldType;
|
|
2
|
+
(function (ReversiaFieldType) {
|
|
3
|
+
ReversiaFieldType["TEXT"] = "TEXT";
|
|
4
|
+
ReversiaFieldType["HTML"] = "HTML";
|
|
5
|
+
ReversiaFieldType["JSON"] = "JSON";
|
|
6
|
+
ReversiaFieldType["LINK"] = "LINK";
|
|
7
|
+
ReversiaFieldType["MEDIUM"] = "MEDIUM";
|
|
8
|
+
})(ReversiaFieldType || (ReversiaFieldType = {}));
|
|
9
|
+
export var ReversiaFieldBehavior;
|
|
10
|
+
(function (ReversiaFieldBehavior) {
|
|
11
|
+
ReversiaFieldBehavior["SLUG"] = "slug";
|
|
12
|
+
})(ReversiaFieldBehavior || (ReversiaFieldBehavior = {}));
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
2
|
+
export function validateApiKey(req, expectedKey) {
|
|
3
|
+
if (typeof expectedKey !== 'string' || expectedKey.length === 0) {
|
|
4
|
+
return false;
|
|
5
|
+
}
|
|
6
|
+
const headerKey = req.headers.get('x-api-key');
|
|
7
|
+
const queryKey = req.searchParams.get('apiKey');
|
|
8
|
+
const providedKey = headerKey ?? queryKey;
|
|
9
|
+
if (typeof providedKey !== 'string' || providedKey.length === 0) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
return constantTimeEquals(providedKey, expectedKey);
|
|
13
|
+
}
|
|
14
|
+
function constantTimeEquals(a, b) {
|
|
15
|
+
const aBuf = Buffer.from(a, 'utf8');
|
|
16
|
+
const bBuf = Buffer.from(b, 'utf8');
|
|
17
|
+
if (aBuf.length !== bBuf.length) {
|
|
18
|
+
// Still consume a comparison on a pair of equal-length buffers so callers
|
|
19
|
+
// cannot distinguish "wrong length" from "wrong contents" via timing.
|
|
20
|
+
timingSafeEqual(aBuf, aBuf);
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
return timingSafeEqual(aBuf, bBuf);
|
|
24
|
+
}
|
|
25
|
+
export function unauthorizedResponse() {
|
|
26
|
+
return Response.json({ error: 'Invalid API key' }, { status: 401 });
|
|
27
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Cursor } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Opaque cursor encoding. Uses JSON in base64url so the delimiter concern
|
|
4
|
+
* (types or ids containing `|`) is eliminated.
|
|
5
|
+
*/
|
|
6
|
+
export declare function encodeCursor(type: string, id: string): string;
|
|
7
|
+
export declare function decodeCursor(cursorString: string | null | undefined): Cursor | null;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opaque cursor encoding. Uses JSON in base64url so the delimiter concern
|
|
3
|
+
* (types or ids containing `|`) is eliminated.
|
|
4
|
+
*/
|
|
5
|
+
export function encodeCursor(type, id) {
|
|
6
|
+
return Buffer.from(JSON.stringify({ type, id }), 'utf-8').toString('base64url');
|
|
7
|
+
}
|
|
8
|
+
export function decodeCursor(cursorString) {
|
|
9
|
+
if (!cursorString) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
const decoded = Buffer.from(cursorString, 'base64url').toString('utf-8');
|
|
14
|
+
const parsed = JSON.parse(decoded);
|
|
15
|
+
if (!parsed ||
|
|
16
|
+
typeof parsed !== 'object' ||
|
|
17
|
+
typeof parsed.type !== 'string' ||
|
|
18
|
+
typeof parsed.id !== 'string') {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const { type, id } = parsed;
|
|
22
|
+
if (type.length === 0 || id.length === 0) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return { type, id };
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { Field } from 'payload';
|
|
2
|
+
import type { LocalizedFieldInfo, TranslatableFieldConfig } from '../types.js';
|
|
3
|
+
import { ReversiaFieldType } from '../types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Collects one `LocalizedFieldInfo` per top-level field that is itself
|
|
6
|
+
* localized OR that contains at least one localized descendant.
|
|
7
|
+
*
|
|
8
|
+
* Top-level localized scalars become non-container entries. Everything else
|
|
9
|
+
* (richText, json, group, array, blocks, or any unnamed wrapper containing
|
|
10
|
+
* localized descendants) becomes a container with one or more `leaves`.
|
|
11
|
+
*/
|
|
12
|
+
export declare function findLocalizedFields(fields: Field[]): LocalizedFieldInfo[];
|
|
13
|
+
export declare function resolveContentType(field: LocalizedFieldInfo): ReversiaFieldType | undefined;
|
|
14
|
+
export declare function buildTranslatableConfiguration(localizedFields: LocalizedFieldInfo[]): Record<string, TranslatableFieldConfig>;
|
|
15
|
+
export declare const getContentType: typeof resolveContentType;
|
|
16
|
+
export interface SerialisedFieldEntry {
|
|
17
|
+
/** Top-level field name; doubles as the key in `content` and `contentTypes`. */
|
|
18
|
+
name: string;
|
|
19
|
+
/** Serialised value to ship to Reversia. */
|
|
20
|
+
value: string | number | boolean;
|
|
21
|
+
/** Resolved Reversia content type, if any. */
|
|
22
|
+
contentType?: ReversiaFieldType;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Produces zero or one serialised entry for a top-level localized field.
|
|
26
|
+
*
|
|
27
|
+
* - Scalars: pass-through primitive value (or `extract` result).
|
|
28
|
+
* - Containers: walk every localized leaf, build a JSON-pointer map of only
|
|
29
|
+
* the translatable atoms (so we never ship non-localized siblings or fields
|
|
30
|
+
* the user opted out of), JSON.stringify and emit one entry.
|
|
31
|
+
*
|
|
32
|
+
* Returns `undefined` when there is nothing translatable to send (empty
|
|
33
|
+
* source value, no matching leaves, or filtered out).
|
|
34
|
+
*/
|
|
35
|
+
export declare function serializeField(field: LocalizedFieldInfo, doc: unknown): SerialisedFieldEntry | undefined;
|
|
36
|
+
/**
|
|
37
|
+
* Reverses `serializeField` for one top-level field.
|
|
38
|
+
*
|
|
39
|
+
* - Scalars: return `translatedRaw` as-is (after `apply` if defined).
|
|
40
|
+
* - Containers: parse the JSON pointer map; deep-clone the source-locale
|
|
41
|
+
* container value as the base; overlay each translated leaf at its pointer.
|
|
42
|
+
* For `json` sub-leaves the pointer is split between the leaf location and
|
|
43
|
+
* the sub-extraction pointer — `applyByKeys` replays the standard richText
|
|
44
|
+
* path on the leaf's value before we re-attach it.
|
|
45
|
+
*
|
|
46
|
+
* The source-clone strategy guarantees required non-localized siblings (block
|
|
47
|
+
* structure, array item ids, sub-object scaffolding) are preserved when we
|
|
48
|
+
* write to Payload — same pattern as the PrestaShop module.
|
|
49
|
+
*/
|
|
50
|
+
export declare function deserializeFieldValue(field: LocalizedFieldInfo, sourceValue: unknown, translatedRaw: unknown): unknown;
|
|
51
|
+
/**
|
|
52
|
+
* Builds the base `updateData` for a translation insertion by deep-cloning
|
|
53
|
+
* each top-level localized field's value out of the source-locale doc. This
|
|
54
|
+
* guarantees required nested siblings (block structure, ids, non-localized
|
|
55
|
+
* subfields) are present in the update payload, even when Reversia only sent
|
|
56
|
+
* a subset of leaves.
|
|
57
|
+
*/
|
|
58
|
+
export declare function cloneLocalizedContainersFromSource(sourceDoc: unknown, fields: readonly LocalizedFieldInfo[]): Record<string, unknown>;
|