@pintawebware/strapi-sync 1.0.4
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 +322 -0
- package/bin/cli.js +275 -0
- package/index.js +5 -0
- package/lib/async.js +22 -0
- package/lib/config.js +183 -0
- package/lib/constants.js +20 -0
- package/lib/format.js +46 -0
- package/lib/preview.js +373 -0
- package/lib/schema-writer.js +356 -0
- package/lib/snapshot-io.js +343 -0
- package/lib/snapshot-utils.js +392 -0
- package/lib/strapi-client.js +347 -0
- package/lib/sync-engine.js +379 -0
- package/package.json +34 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const { parseComponentType, parseRelationType, parseDynamicZoneType, isLocalizedSnapshotBlock, expandRelationTarget } = require('./snapshot-utils');
|
|
4
|
+
|
|
5
|
+
function simpleTypeToStrapiAttr(simpleType) {
|
|
6
|
+
if (Array.isArray(simpleType)) return { type: 'dynamiczone', components: simpleType };
|
|
7
|
+
if (!simpleType || typeof simpleType !== 'string') return { type: 'json' };
|
|
8
|
+
const parsed = parseComponentType(simpleType);
|
|
9
|
+
if (parsed) return { type: 'component', component: parsed.uid, repeatable: parsed.repeatable };
|
|
10
|
+
const relation = parseRelationType(simpleType);
|
|
11
|
+
if (relation) return { type: 'relation', relation: relation.relType, target: expandRelationTarget(relation.target) };
|
|
12
|
+
const t = simpleType;
|
|
13
|
+
if (t === 'string') return { type: 'text' };
|
|
14
|
+
if (t === 'number') return { type: 'integer' };
|
|
15
|
+
if (t === 'boolean') return { type: 'boolean' };
|
|
16
|
+
if (t === 'date') return { type: 'datetime' };
|
|
17
|
+
if (t === 'media') return { type: 'media' };
|
|
18
|
+
if (t === 'media[]') return { type: 'media', multiple: true };
|
|
19
|
+
return { type: 'json' };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function pluralize(word) {
|
|
23
|
+
const w = word.toLowerCase();
|
|
24
|
+
if (/\w[^aeiou]y$/.test(w)) return w.slice(0, -1) + 'ies';
|
|
25
|
+
if (/[sxz]$|ch$|sh$/.test(w)) return w + 'es';
|
|
26
|
+
return w + 's';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function setLocalizationPluginOptions(pluginOptions, enable) {
|
|
30
|
+
const next = { ...(pluginOptions || {}) };
|
|
31
|
+
if (enable) {
|
|
32
|
+
next.i18n = { localized: true };
|
|
33
|
+
return next;
|
|
34
|
+
}
|
|
35
|
+
delete next.i18n;
|
|
36
|
+
return next;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function applyLocalizationToSchema(rawSchema, enable) {
|
|
40
|
+
const attrs = {};
|
|
41
|
+
for (const [k, v] of Object.entries(rawSchema.attributes ?? {})) {
|
|
42
|
+
const attr = { ...v };
|
|
43
|
+
const nextPluginOptions = setLocalizationPluginOptions(attr.pluginOptions, enable);
|
|
44
|
+
if (Object.keys(nextPluginOptions).length > 0) attr.pluginOptions = nextPluginOptions;
|
|
45
|
+
else delete attr.pluginOptions;
|
|
46
|
+
attrs[k] = attr;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const info = rawSchema.info ?? {
|
|
50
|
+
singularName: rawSchema.singularName,
|
|
51
|
+
pluralName: rawSchema.pluralName,
|
|
52
|
+
displayName: rawSchema.displayName,
|
|
53
|
+
...(rawSchema.description != null ? { description: rawSchema.description } : {})
|
|
54
|
+
};
|
|
55
|
+
const options = rawSchema.options ?? {
|
|
56
|
+
draftAndPublish: rawSchema.draftAndPublish
|
|
57
|
+
};
|
|
58
|
+
const nextPluginOptions = setLocalizationPluginOptions(rawSchema.pluginOptions, enable);
|
|
59
|
+
|
|
60
|
+
const result = {
|
|
61
|
+
kind: rawSchema.kind,
|
|
62
|
+
collectionName: rawSchema.collectionName,
|
|
63
|
+
info,
|
|
64
|
+
options,
|
|
65
|
+
pluginOptions: nextPluginOptions,
|
|
66
|
+
attributes: attrs
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
if (rawSchema.visible !== undefined) result.visible = rawSchema.visible;
|
|
70
|
+
if (rawSchema.restrictRelationsTo !== undefined) result.restrictRelationsTo = rawSchema.restrictRelationsTo;
|
|
71
|
+
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buildStrapiSchema(contentType, snapshotBlock) {
|
|
76
|
+
const { singleType = false, attributes = {} } = snapshotBlock;
|
|
77
|
+
const localized = isLocalizedSnapshotBlock(snapshotBlock);
|
|
78
|
+
const kind = singleType ? 'singleType' : 'collectionType';
|
|
79
|
+
const singularName = contentType;
|
|
80
|
+
const displayName = contentType.charAt(0).toUpperCase() + contentType.slice(1).toLowerCase();
|
|
81
|
+
const collectionName = pluralize(contentType);
|
|
82
|
+
const pluralName = collectionName;
|
|
83
|
+
const i18nPluginOptions = { i18n: { localized: true } };
|
|
84
|
+
const attrs = {};
|
|
85
|
+
for (const [k, v] of Object.entries(attributes)) {
|
|
86
|
+
if (k === 'id') continue;
|
|
87
|
+
const attr = simpleTypeToStrapiAttr(v);
|
|
88
|
+
if (localized) attr.pluginOptions = i18nPluginOptions;
|
|
89
|
+
attrs[k] = attr;
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
kind,
|
|
93
|
+
collectionName,
|
|
94
|
+
info: {
|
|
95
|
+
singularName,
|
|
96
|
+
pluralName,
|
|
97
|
+
displayName
|
|
98
|
+
},
|
|
99
|
+
options: { draftAndPublish: false },
|
|
100
|
+
pluginOptions: localized ? i18nPluginOptions : {},
|
|
101
|
+
attributes: attrs
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function componentUidToPath(componentUid) {
|
|
106
|
+
const parts = componentUid.split('.');
|
|
107
|
+
const name = parts[parts.length - 1];
|
|
108
|
+
const category = parts.slice(0, -1).join('/');
|
|
109
|
+
return path.join('src', 'components', category, `${name}.json`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function buildStrapiComponentSchema(componentUid, snapshotBlock) {
|
|
113
|
+
const { attributes = {} } = snapshotBlock;
|
|
114
|
+
const parts = componentUid.split('.');
|
|
115
|
+
const name = parts[parts.length - 1] || 'component';
|
|
116
|
+
const displayName = name.charAt(0).toUpperCase() + name.slice(1).replace(/-/g, ' ');
|
|
117
|
+
const collectionName = componentUid.replace(/\./g, '_');
|
|
118
|
+
const attrs = {};
|
|
119
|
+
for (const [k, v] of Object.entries(attributes)) {
|
|
120
|
+
if (k === 'id') continue;
|
|
121
|
+
attrs[k] = simpleTypeToStrapiAttr(v);
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
collectionName,
|
|
125
|
+
info: { displayName },
|
|
126
|
+
attributes: attrs
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function schemaPath(basePath, contentType) {
|
|
131
|
+
return path.join(basePath, 'src', 'api', contentType, 'content-types', contentType, 'schema.json');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function componentSchemaPath(basePath, componentUid) {
|
|
135
|
+
return path.join(basePath, componentUidToPath(componentUid));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function apiDir(basePath, contentType) {
|
|
139
|
+
return path.join(basePath, 'src', 'api', contentType);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function componentFile(basePath, componentUid) {
|
|
143
|
+
return path.join(basePath, componentUidToPath(componentUid));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const CORE_TEMPLATES = {
|
|
147
|
+
routes: (uid) => `'use strict';\n\nmodule.exports = require('@strapi/strapi').factories.createCoreRouter('${uid}');\n`,
|
|
148
|
+
controllers: (uid) => `'use strict';\n\nmodule.exports = require('@strapi/strapi').factories.createCoreController('${uid}');\n`,
|
|
149
|
+
services: (uid) => `'use strict';\n\nmodule.exports = require('@strapi/strapi').factories.createCoreService('${uid}');\n`
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
function writeCoreFilesLocal(base, contentType) {
|
|
153
|
+
const uid = `api::${contentType}.${contentType}`;
|
|
154
|
+
const apiPath = apiDir(base, contentType);
|
|
155
|
+
const routesDir = path.join(apiPath, 'routes');
|
|
156
|
+
const controllersDir = path.join(apiPath, 'controllers');
|
|
157
|
+
const servicesDir = path.join(apiPath, 'services');
|
|
158
|
+
[routesDir, controllersDir, servicesDir].forEach((dir) => {
|
|
159
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
160
|
+
});
|
|
161
|
+
fs.writeFileSync(path.join(routesDir, `${contentType}.js`), CORE_TEMPLATES.routes(uid), 'utf8');
|
|
162
|
+
fs.writeFileSync(path.join(controllersDir, `${contentType}.js`), CORE_TEMPLATES.controllers(uid), 'utf8');
|
|
163
|
+
fs.writeFileSync(path.join(servicesDir, `${contentType}.js`), CORE_TEMPLATES.services(uid), 'utf8');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function createLocalUpdater(strapiProjectPath, projectPath) {
|
|
167
|
+
const base = path.isAbsolute(strapiProjectPath)
|
|
168
|
+
? strapiProjectPath
|
|
169
|
+
: path.resolve(projectPath || process.cwd(), strapiProjectPath);
|
|
170
|
+
return {
|
|
171
|
+
async writeSchema(contentType, schemaObj) {
|
|
172
|
+
const schemaFilePath = schemaPath(base, contentType);
|
|
173
|
+
const dir = path.dirname(schemaFilePath);
|
|
174
|
+
if (!fs.existsSync(dir)) {
|
|
175
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
176
|
+
}
|
|
177
|
+
fs.writeFileSync(schemaFilePath, JSON.stringify(schemaObj, null, 2), 'utf8');
|
|
178
|
+
},
|
|
179
|
+
async writeCoreFiles(contentType) {
|
|
180
|
+
writeCoreFilesLocal(base, contentType);
|
|
181
|
+
},
|
|
182
|
+
async deleteContentType(contentType) {
|
|
183
|
+
const dir = apiDir(base, contentType);
|
|
184
|
+
if (fs.existsSync(dir)) {
|
|
185
|
+
fs.rmSync(dir, { recursive: true });
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
async listContentTypes() {
|
|
189
|
+
const apiPath = path.join(base, 'src', 'api');
|
|
190
|
+
if (!fs.existsSync(apiPath)) return [];
|
|
191
|
+
return fs.readdirSync(apiPath).filter((name) => {
|
|
192
|
+
const ctPath = path.join(apiPath, name, 'content-types', name);
|
|
193
|
+
return fs.existsSync(ctPath);
|
|
194
|
+
});
|
|
195
|
+
},
|
|
196
|
+
async writeComponent(componentUid, schemaObj) {
|
|
197
|
+
const filePath = componentFile(base, componentUid);
|
|
198
|
+
const dir = path.dirname(filePath);
|
|
199
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
200
|
+
fs.writeFileSync(filePath, JSON.stringify(schemaObj, null, 2), 'utf8');
|
|
201
|
+
},
|
|
202
|
+
async listComponents() {
|
|
203
|
+
const componentsPath = path.join(base, 'src', 'components');
|
|
204
|
+
if (!fs.existsSync(componentsPath)) return [];
|
|
205
|
+
const uids = [];
|
|
206
|
+
const categories = fs.readdirSync(componentsPath);
|
|
207
|
+
for (const cat of categories) {
|
|
208
|
+
const catPath = path.join(componentsPath, cat);
|
|
209
|
+
if (!fs.statSync(catPath).isDirectory()) continue;
|
|
210
|
+
for (const file of fs.readdirSync(catPath)) {
|
|
211
|
+
if (file.endsWith('.json')) uids.push(`${cat}.${path.basename(file, '.json')}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return uids;
|
|
215
|
+
},
|
|
216
|
+
async deleteComponent(componentUid) {
|
|
217
|
+
const filePath = componentFile(base, componentUid);
|
|
218
|
+
if (fs.existsSync(filePath)) fs.rmSync(filePath);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function createSSHUpdater(remote, options) {
|
|
224
|
+
let Client;
|
|
225
|
+
try {
|
|
226
|
+
Client = require('ssh2').Client;
|
|
227
|
+
} catch (e) {
|
|
228
|
+
throw new Error('SSH schema update requires ssh2: npm install ssh2');
|
|
229
|
+
}
|
|
230
|
+
const conn = new Client();
|
|
231
|
+
const buildConnectionError = (error, config) => {
|
|
232
|
+
const parts = [];
|
|
233
|
+
if (config.username) parts.push(`user=${config.username}`);
|
|
234
|
+
if (config.host) parts.push(`host=${config.host}`);
|
|
235
|
+
if (config.port != null) parts.push(`port=${config.port}`);
|
|
236
|
+
if (config.password) parts.push('auth=password');
|
|
237
|
+
else if (config.privateKey) parts.push('auth=privateKey');
|
|
238
|
+
else parts.push('auth=default');
|
|
239
|
+
const message = `SSH connection failed (${parts.join(', ')}): ${error.message}`;
|
|
240
|
+
const wrapped = new Error(message);
|
|
241
|
+
wrapped.code = error.code;
|
|
242
|
+
wrapped.level = error.level;
|
|
243
|
+
wrapped.description = error.description;
|
|
244
|
+
wrapped.host = config.host;
|
|
245
|
+
wrapped.port = config.port;
|
|
246
|
+
wrapped.username = config.username;
|
|
247
|
+
wrapped.cause = error;
|
|
248
|
+
return wrapped;
|
|
249
|
+
};
|
|
250
|
+
const connect = () => new Promise((resolve, reject) => {
|
|
251
|
+
const config = {
|
|
252
|
+
host: remote.host,
|
|
253
|
+
username: options.strapiSSHUser || 'root',
|
|
254
|
+
port: options.strapiSSHPort || 22
|
|
255
|
+
};
|
|
256
|
+
if (options.strapiSSHPassword) {
|
|
257
|
+
config.password = options.strapiSSHPassword;
|
|
258
|
+
} else if (options.strapiSSHPrivateKeyPath) {
|
|
259
|
+
config.privateKey = fs.readFileSync(options.strapiSSHPrivateKeyPath, 'utf8');
|
|
260
|
+
}
|
|
261
|
+
conn.on('ready', () => resolve()).on('error', (error) => reject(buildConnectionError(error, config))).connect(config);
|
|
262
|
+
});
|
|
263
|
+
const exec = (cmd) => new Promise((resolve, reject) => {
|
|
264
|
+
conn.exec(cmd, (err, stream) => {
|
|
265
|
+
if (err) return reject(err);
|
|
266
|
+
let out = '';
|
|
267
|
+
stream.on('data', (d) => { out += d.toString(); }).on('close', (code) => resolve({ code, out }));
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
const base = remote.path;
|
|
271
|
+
const uid = (ct) => `api::${ct}.${ct}`;
|
|
272
|
+
const writeRemoteFile = async (filePath, content) => {
|
|
273
|
+
const b64 = Buffer.from(content, 'utf8').toString('base64');
|
|
274
|
+
const r = await exec(`mkdir -p $(dirname '${filePath}') && echo '${b64}' | base64 -d > '${filePath}'`);
|
|
275
|
+
if (r.code !== 0) throw new Error(r.out || `SSH exit ${r.code}`);
|
|
276
|
+
};
|
|
277
|
+
return {
|
|
278
|
+
async writeSchema(contentType, schemaObj) {
|
|
279
|
+
await connect();
|
|
280
|
+
const schemaFilePath = `${base}/src/api/${contentType}/content-types/${contentType}/schema.json`;
|
|
281
|
+
const dir = `${base}/src/api/${contentType}/content-types/${contentType}`;
|
|
282
|
+
const json = JSON.stringify(schemaObj, null, 2);
|
|
283
|
+
const b64 = Buffer.from(json, 'utf8').toString('base64');
|
|
284
|
+
const r = await exec(`mkdir -p '${dir}' && echo '${b64}' | base64 -d > '${schemaFilePath}'`);
|
|
285
|
+
if (r.code !== 0) {
|
|
286
|
+
conn.end();
|
|
287
|
+
throw new Error(r.out || `SSH exit ${r.code}`);
|
|
288
|
+
}
|
|
289
|
+
conn.end();
|
|
290
|
+
},
|
|
291
|
+
async writeCoreFiles(contentType) {
|
|
292
|
+
await connect();
|
|
293
|
+
const u = uid(contentType);
|
|
294
|
+
await writeRemoteFile(`${base}/src/api/${contentType}/routes/${contentType}.js`, CORE_TEMPLATES.routes(u));
|
|
295
|
+
await writeRemoteFile(`${base}/src/api/${contentType}/controllers/${contentType}.js`, CORE_TEMPLATES.controllers(u));
|
|
296
|
+
await writeRemoteFile(`${base}/src/api/${contentType}/services/${contentType}.js`, CORE_TEMPLATES.services(u));
|
|
297
|
+
conn.end();
|
|
298
|
+
},
|
|
299
|
+
async deleteContentType(contentType) {
|
|
300
|
+
await connect();
|
|
301
|
+
const dir = `${base}/src/api/${contentType}`;
|
|
302
|
+
const r = await exec(`rm -rf '${dir}'`);
|
|
303
|
+
conn.end();
|
|
304
|
+
if (r.code !== 0) throw new Error(r.out || `SSH exit ${r.code}`);
|
|
305
|
+
},
|
|
306
|
+
async listContentTypes() {
|
|
307
|
+
await connect();
|
|
308
|
+
const apiPath = `${base}/src/api`;
|
|
309
|
+
const r = await exec(`ls -1 '${apiPath}' 2>/dev/null || true`);
|
|
310
|
+
conn.end();
|
|
311
|
+
if (r.code !== 0) return [];
|
|
312
|
+
return r.out.trim().split('\n').filter(Boolean);
|
|
313
|
+
},
|
|
314
|
+
async writeComponent(componentUid, schemaObj) {
|
|
315
|
+
await connect();
|
|
316
|
+
const filePath = `${base}/${componentUidToPath(componentUid)}`.replace(/\\/g, '/');
|
|
317
|
+
const dir = filePath.substring(0, filePath.lastIndexOf('/'));
|
|
318
|
+
const json = JSON.stringify(schemaObj, null, 2);
|
|
319
|
+
const b64 = Buffer.from(json, 'utf8').toString('base64');
|
|
320
|
+
const r = await exec(`mkdir -p '${dir}' && echo '${b64}' | base64 -d > '${filePath}'`);
|
|
321
|
+
conn.end();
|
|
322
|
+
if (r.code !== 0) throw new Error(r.out || `SSH exit ${r.code}`);
|
|
323
|
+
},
|
|
324
|
+
async listComponents() {
|
|
325
|
+
await connect();
|
|
326
|
+
const compPath = `${base}/src/components`;
|
|
327
|
+
const r = await exec(`find '${compPath}' -name '*.json' 2>/dev/null | sed 's|.*/src/components/||;s|\\.json||;s|/|\\.|g'` + " || true");
|
|
328
|
+
conn.end();
|
|
329
|
+
if (r.code !== 0 || !r.out) return [];
|
|
330
|
+
return r.out.trim().split('\n').filter(Boolean);
|
|
331
|
+
},
|
|
332
|
+
async deleteComponent(componentUid) {
|
|
333
|
+
await connect();
|
|
334
|
+
const filePath = `${base}/${componentUidToPath(componentUid)}`.replace(/\\/g, '/');
|
|
335
|
+
const r = await exec(`rm -f '${filePath}'`);
|
|
336
|
+
conn.end();
|
|
337
|
+
if (r.code !== 0) throw new Error(r.out || `SSH exit ${r.code}`);
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function getSchemaUpdater(options) {
|
|
343
|
+
const raw = options.strapiProjectPath;
|
|
344
|
+
if (!raw || typeof raw !== 'string') return null;
|
|
345
|
+
if (options.strapiSSHHost) {
|
|
346
|
+
return createSSHUpdater({ host: options.strapiSSHHost, path: raw.trim() }, options);
|
|
347
|
+
}
|
|
348
|
+
return createLocalUpdater(raw, options.projectPath);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
module.exports = {
|
|
352
|
+
buildStrapiSchema,
|
|
353
|
+
buildStrapiComponentSchema,
|
|
354
|
+
applyLocalizationToSchema,
|
|
355
|
+
getSchemaUpdater
|
|
356
|
+
};
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const { SNAPSHOT_VERSION } = require('./constants');
|
|
3
|
+
const { mapWithConcurrency } = require('./async');
|
|
4
|
+
const {
|
|
5
|
+
simplifyAttributeType,
|
|
6
|
+
entryToSnapshot,
|
|
7
|
+
buildSampleFromSimpleAttributes,
|
|
8
|
+
normalizeSimpleAttributeType,
|
|
9
|
+
validateSimpleAttributeType,
|
|
10
|
+
isPlainObject,
|
|
11
|
+
isSingleTypeLocaleMap
|
|
12
|
+
} = require('./snapshot-utils');
|
|
13
|
+
|
|
14
|
+
function getPluralNameFromSchema(type, schema, contentType) {
|
|
15
|
+
const ct = schema?.data?.contentType || schema?.contentType || schema?.data;
|
|
16
|
+
const p = ct?.pluralName ?? ct?.info?.pluralName ?? schema?.data?.contentType?.pluralName;
|
|
17
|
+
const plural = (p && String(p).toLowerCase()) || (contentType.endsWith('s') ? contentType : `${contentType}s`);
|
|
18
|
+
return plural;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function simplifyComponentAttributes(rawAttrs) {
|
|
22
|
+
if (!rawAttrs || typeof rawAttrs !== 'object') return {};
|
|
23
|
+
return Object.fromEntries(
|
|
24
|
+
Object.entries(rawAttrs).map(([k, v]) => [k, simplifyAttributeType(v)])
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeAttributes(attributes) {
|
|
29
|
+
if (!attributes || typeof attributes !== 'object' || Array.isArray(attributes)) return {};
|
|
30
|
+
return Object.fromEntries(
|
|
31
|
+
Object.entries(attributes).map(([key, value]) => [key, normalizeSimpleAttributeType(value)])
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function validateEntryList(entries, fieldPath, errors) {
|
|
36
|
+
if (!Array.isArray(entries)) {
|
|
37
|
+
errors.push(`${fieldPath} must be an array`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
entries.forEach((entry, index) => {
|
|
41
|
+
if (!isPlainObject(entry)) {
|
|
42
|
+
errors.push(`${fieldPath}[${index}] must be an object`);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function validateAttributes(attributes, fieldPath, errors) {
|
|
48
|
+
if (!isPlainObject(attributes)) {
|
|
49
|
+
errors.push(`${fieldPath} must be an object`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const [name, type] of Object.entries(attributes)) {
|
|
54
|
+
if (!name || typeof name !== 'string') {
|
|
55
|
+
errors.push(`${fieldPath} contains an invalid attribute name`);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
errors.push(...validateSimpleAttributeType(type, `${fieldPath}.${name}`));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isSingleTypeEntryObject(entries, attributes) {
|
|
63
|
+
if (!isPlainObject(entries)) return false;
|
|
64
|
+
const entryKeys = Object.keys(entries);
|
|
65
|
+
const attributeKeys = Object.keys(attributes || {});
|
|
66
|
+
const systemKeys = new Set(['id', 'documentId', 'locale']);
|
|
67
|
+
return entryKeys.some((key) => attributeKeys.includes(key) || systemKeys.has(key));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function validateSnapshotBlock(block, fieldPath, errors, { requireSingleType }) {
|
|
71
|
+
if (!isPlainObject(block)) {
|
|
72
|
+
errors.push(`${fieldPath} must be an object`);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (requireSingleType) {
|
|
77
|
+
if (typeof block.singleType !== 'boolean') {
|
|
78
|
+
errors.push(`${fieldPath}.singleType must be a boolean`);
|
|
79
|
+
}
|
|
80
|
+
} else if ('singleType' in block && typeof block.singleType !== 'boolean') {
|
|
81
|
+
errors.push(`${fieldPath}.singleType must be a boolean`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if ('attributes' in block) {
|
|
85
|
+
validateAttributes(block.attributes, `${fieldPath}.attributes`, errors);
|
|
86
|
+
} else if (requireSingleType) {
|
|
87
|
+
errors.push(`${fieldPath}.attributes is required`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!('entries' in block)) return;
|
|
91
|
+
|
|
92
|
+
if (block.singleType === true) {
|
|
93
|
+
if (!isPlainObject(block.entries)) {
|
|
94
|
+
errors.push(`${fieldPath}.entries must be an object for singleType`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (isSingleTypeEntryObject(block.entries, block.attributes)) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const entryPairs = Object.entries(block.entries);
|
|
101
|
+
const localePairs = entryPairs.filter(([key]) => /^[a-z]{2}(?:-[A-Z]{2})?$/.test(key));
|
|
102
|
+
if (localePairs.length > 0) {
|
|
103
|
+
if (localePairs.length !== entryPairs.length) {
|
|
104
|
+
errors.push(`${fieldPath}.entries must be either a single object or a locale-to-object map for singleType`);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
for (const [locale, entry] of localePairs) {
|
|
108
|
+
if (!locale.trim()) errors.push(`${fieldPath}.entries has an empty locale key`);
|
|
109
|
+
if (!isPlainObject(entry)) {
|
|
110
|
+
errors.push(`${fieldPath}.entries.${locale} must be an object for localized singleType`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (Array.isArray(block.entries)) {
|
|
119
|
+
validateEntryList(block.entries, `${fieldPath}.entries`, errors);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!isPlainObject(block.entries)) {
|
|
124
|
+
errors.push(`${fieldPath}.entries must be an array or a locale map`);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const localePairs = Object.entries(block.entries).filter(([key]) => /^[a-z]{2}(?:-[A-Z]{2})?$/.test(key));
|
|
129
|
+
if (localePairs.length === 0 || localePairs.length !== Object.keys(block.entries).length) {
|
|
130
|
+
errors.push(`${fieldPath}.entries must be an array or an object with locale keys`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (const [locale, list] of Object.entries(block.entries)) {
|
|
135
|
+
if (!locale.trim()) {
|
|
136
|
+
errors.push(`${fieldPath}.entries has an empty locale key`);
|
|
137
|
+
}
|
|
138
|
+
validateEntryList(list, `${fieldPath}.entries.${locale}`, errors);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function validateSnapshotSyntax(snapshot) {
|
|
143
|
+
const errors = [];
|
|
144
|
+
|
|
145
|
+
if (!isPlainObject(snapshot)) {
|
|
146
|
+
errors.push('Snapshot root must be an object');
|
|
147
|
+
return errors;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!isPlainObject(snapshot.contentTypes)) {
|
|
151
|
+
errors.push('contentTypes must be an object');
|
|
152
|
+
} else {
|
|
153
|
+
for (const [contentType, block] of Object.entries(snapshot.contentTypes)) {
|
|
154
|
+
if (!contentType.trim()) {
|
|
155
|
+
errors.push('contentTypes contains an empty content type key');
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
validateSnapshotBlock(block, `contentTypes.${contentType}`, errors, { requireSingleType: true });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if ('components' in snapshot) {
|
|
163
|
+
if (!isPlainObject(snapshot.components)) {
|
|
164
|
+
errors.push('components must be an object');
|
|
165
|
+
} else {
|
|
166
|
+
for (const [uid, block] of Object.entries(snapshot.components)) {
|
|
167
|
+
if (!uid.trim()) {
|
|
168
|
+
errors.push('components contains an empty component UID');
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
validateSnapshotBlock(block, `components.${uid}`, errors, { requireSingleType: false });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return errors;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function exportSnapshot(client, outputPath) {
|
|
180
|
+
const apiTypes = await client.getAllContentTypes();
|
|
181
|
+
const apiComponents = await client.getAllComponents();
|
|
182
|
+
const contentTypes = {};
|
|
183
|
+
const components = {};
|
|
184
|
+
for (const comp of apiComponents) {
|
|
185
|
+
const rawAttrs = comp.schema?.attributes ?? comp.attributes ?? {};
|
|
186
|
+
components[comp.uid] = { attributes: simplifyComponentAttributes(rawAttrs) };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const exportedTypes = await mapWithConcurrency(apiTypes, 4, async (type) => {
|
|
190
|
+
const contentType = type.uid?.includes('.') ? type.uid.split('.')[1] : (type.apiID || type.name);
|
|
191
|
+
const schema = await client.getContentTypeSchema(contentType);
|
|
192
|
+
const kind = type.kind ?? schema?.data?.contentType?.kind ?? schema?.data?.kind;
|
|
193
|
+
const singleType = kind === 'singleType';
|
|
194
|
+
const localized = !!(
|
|
195
|
+
type.schema?.pluginOptions?.i18n?.localized ??
|
|
196
|
+
(schema?.data?.contentType ?? schema?.contentType ?? schema?.data)?.pluginOptions?.i18n?.localized
|
|
197
|
+
);
|
|
198
|
+
const apiId = type.pluralName ?? getPluralNameFromSchema(type, schema, contentType);
|
|
199
|
+
|
|
200
|
+
const ct = schema?.data?.contentType || schema?.contentType || schema?.data;
|
|
201
|
+
const rawAttrs =
|
|
202
|
+
schema?.data?.contentType?.attributes ??
|
|
203
|
+
schema?.data?.schema?.attributes ??
|
|
204
|
+
schema?.attributes ??
|
|
205
|
+
ct?.attributes ??
|
|
206
|
+
{};
|
|
207
|
+
const attributes = normalizeAttributes(
|
|
208
|
+
Object.fromEntries(Object.entries(rawAttrs).map(([k, v]) => [k, simplifyAttributeType(v)]))
|
|
209
|
+
);
|
|
210
|
+
let entries = singleType ? {} : [];
|
|
211
|
+
try {
|
|
212
|
+
const withLocale = await client.getEntries(contentType, {}, {
|
|
213
|
+
singleType,
|
|
214
|
+
pageSize: singleType ? undefined : 500,
|
|
215
|
+
apiId,
|
|
216
|
+
locale: 'all',
|
|
217
|
+
localized
|
|
218
|
+
});
|
|
219
|
+
const hasLocale = Array.isArray(withLocale) && withLocale.length > 0 &&
|
|
220
|
+
withLocale.some((e) => e?.locale ?? e?.localization);
|
|
221
|
+
const locKey = (e) => e?.locale ?? e?.localization?.locale ?? 'en';
|
|
222
|
+
|
|
223
|
+
if (singleType && hasLocale) {
|
|
224
|
+
entries = {};
|
|
225
|
+
for (const entry of withLocale) {
|
|
226
|
+
const locale = locKey(entry);
|
|
227
|
+
entries[locale] = entryToSnapshot(entry, attributes);
|
|
228
|
+
}
|
|
229
|
+
} else if (singleType) {
|
|
230
|
+
entries = withLocale[0] ? entryToSnapshot(withLocale[0], attributes) : {};
|
|
231
|
+
} else if (hasLocale) {
|
|
232
|
+
const byLocale = {};
|
|
233
|
+
for (const e of withLocale) {
|
|
234
|
+
const loc = locKey(e);
|
|
235
|
+
if (!byLocale[loc]) byLocale[loc] = [];
|
|
236
|
+
byLocale[loc].push(entryToSnapshot(e, attributes));
|
|
237
|
+
}
|
|
238
|
+
entries = Object.keys(byLocale).length > 0 ? byLocale : withLocale.map((e) => entryToSnapshot(e, attributes));
|
|
239
|
+
} else {
|
|
240
|
+
entries = withLocale.map((e) => entryToSnapshot(e, attributes));
|
|
241
|
+
}
|
|
242
|
+
} catch (err) {
|
|
243
|
+
console.warn(`⚠️ Could not fetch entries for "${contentType}":`, err.message);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return [contentType, { singleType, attributes, entries }];
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
exportedTypes.forEach(([contentType, block]) => {
|
|
250
|
+
contentTypes[contentType] = block;
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const snapshot = { version: SNAPSHOT_VERSION, contentTypes, components };
|
|
254
|
+
fs.writeFileSync(outputPath, JSON.stringify(snapshot, null, 2), 'utf8');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function normalizeSnapshotEntries(block) {
|
|
258
|
+
const { singleType = true, attributes = {}, entries = singleType ? {} : [], schema = null } = block;
|
|
259
|
+
const normalizedAttributes = normalizeAttributes(attributes);
|
|
260
|
+
let rawEntries = [];
|
|
261
|
+
|
|
262
|
+
if (singleType && isPlainObject(entries)) {
|
|
263
|
+
if (isSingleTypeLocaleMap(entries)) {
|
|
264
|
+
for (const [locale, entry] of Object.entries(entries)) {
|
|
265
|
+
rawEntries.push({ data: entry, locale });
|
|
266
|
+
}
|
|
267
|
+
} else if (Object.keys(entries).length > 0) {
|
|
268
|
+
rawEntries.push({ data: entries, source: 'snapshot' });
|
|
269
|
+
}
|
|
270
|
+
} else if (entries && typeof entries === 'object' && !Array.isArray(entries)) {
|
|
271
|
+
for (const [locale, list] of Object.entries(entries)) {
|
|
272
|
+
if (Array.isArray(list)) list.forEach((e) => rawEntries.push({ data: e, locale }));
|
|
273
|
+
}
|
|
274
|
+
} else if (Array.isArray(entries)) {
|
|
275
|
+
rawEntries = (entries || []).map((e) => ({ data: e, source: 'snapshot' }));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
let objects = rawEntries.length > 0
|
|
279
|
+
? rawEntries.map((o) =>
|
|
280
|
+
o.locale != null ? { data: o.data, locale: o.locale, source: 'snapshot' } : { data: o.data, source: 'snapshot' }
|
|
281
|
+
)
|
|
282
|
+
: (Array.isArray(entries) ? (entries || []).map((e) => ({ data: e, source: 'snapshot' })) : []);
|
|
283
|
+
|
|
284
|
+
if (objects.length === 0) {
|
|
285
|
+
const attrs =
|
|
286
|
+
Object.keys(attributes).length > 0
|
|
287
|
+
? normalizedAttributes
|
|
288
|
+
: schema?.data?.contentType?.attributes ?? schema?.data?.schema?.attributes ?? schema?.attributes;
|
|
289
|
+
const simpleAttrs =
|
|
290
|
+
attrs && typeof attrs === 'object' && !Array.isArray(attrs)
|
|
291
|
+
? Object.fromEntries(
|
|
292
|
+
Object.entries(attrs).map(([k, v]) => [k, typeof v === 'string' ? v : simplifyAttributeType(v)])
|
|
293
|
+
)
|
|
294
|
+
: {};
|
|
295
|
+
if (Object.keys(simpleAttrs).length > 0) {
|
|
296
|
+
objects = [{ data: buildSampleFromSimpleAttributes(simpleAttrs), source: 'schema-only' }];
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return objects.map((o) => ({ ...o, singleType }));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function loadSnapshotFile(snapshotPath) {
|
|
304
|
+
const content = fs.readFileSync(snapshotPath, 'utf8');
|
|
305
|
+
const snap = JSON.parse(content);
|
|
306
|
+
const validationErrors = validateSnapshotSyntax(snap);
|
|
307
|
+
if (validationErrors.length > 0) {
|
|
308
|
+
throw new Error(`Invalid snapshot syntax:\n- ${validationErrors.join('\n- ')}`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const groupedObjects = {};
|
|
312
|
+
const snapshotContentTypes = {};
|
|
313
|
+
for (const [contentType, block] of Object.entries(snap.contentTypes)) {
|
|
314
|
+
snapshotContentTypes[contentType] = {
|
|
315
|
+
...block,
|
|
316
|
+
attributes: normalizeAttributes(block.attributes ?? {})
|
|
317
|
+
};
|
|
318
|
+
const objects = normalizeSnapshotEntries(block);
|
|
319
|
+
if (objects.length > 0) groupedObjects[contentType] = objects;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const snapshotComponents = Object.fromEntries(
|
|
323
|
+
Object.entries(snap.components || {}).map(([uid, block]) => [
|
|
324
|
+
uid,
|
|
325
|
+
{
|
|
326
|
+
...block,
|
|
327
|
+
attributes: normalizeAttributes(block.attributes ?? {})
|
|
328
|
+
}
|
|
329
|
+
])
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
groupedObjects,
|
|
334
|
+
snapshotContentTypes,
|
|
335
|
+
snapshotComponents
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
module.exports = {
|
|
340
|
+
exportSnapshot,
|
|
341
|
+
loadSnapshotFile,
|
|
342
|
+
validateSnapshotSyntax
|
|
343
|
+
};
|