@azlib/cms 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +114 -0
- package/dist/index.cjs +1803 -0
- package/dist/index.d.cts +851 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +851 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1780 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +44 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1780 @@
|
|
|
1
|
+
//#region src/content/schema.ts
|
|
2
|
+
const fields = {
|
|
3
|
+
text(options) {
|
|
4
|
+
return {
|
|
5
|
+
type: "text",
|
|
6
|
+
...options
|
|
7
|
+
};
|
|
8
|
+
},
|
|
9
|
+
slug(options = {}) {
|
|
10
|
+
return {
|
|
11
|
+
name: options.name ?? "slug",
|
|
12
|
+
type: "slug",
|
|
13
|
+
label: options.label ?? "Slug",
|
|
14
|
+
fromField: options.from ?? "title",
|
|
15
|
+
required: options.required ?? false,
|
|
16
|
+
unique: options.unique ?? true
|
|
17
|
+
};
|
|
18
|
+
},
|
|
19
|
+
richText(options) {
|
|
20
|
+
return {
|
|
21
|
+
type: "richText",
|
|
22
|
+
...options
|
|
23
|
+
};
|
|
24
|
+
},
|
|
25
|
+
number(options) {
|
|
26
|
+
return {
|
|
27
|
+
type: "number",
|
|
28
|
+
...options
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
boolean(options) {
|
|
32
|
+
return {
|
|
33
|
+
type: "boolean",
|
|
34
|
+
defaultValue: false,
|
|
35
|
+
...options
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
select(options) {
|
|
39
|
+
return {
|
|
40
|
+
type: "select",
|
|
41
|
+
...options
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
image(options) {
|
|
45
|
+
return {
|
|
46
|
+
type: "image",
|
|
47
|
+
...options
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
taxonomy(options) {
|
|
51
|
+
return {
|
|
52
|
+
type: "taxonomy",
|
|
53
|
+
...options
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
relationship(options) {
|
|
57
|
+
return {
|
|
58
|
+
type: "relationship",
|
|
59
|
+
...options
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
date(options) {
|
|
63
|
+
return {
|
|
64
|
+
type: "date",
|
|
65
|
+
...options
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
json(options) {
|
|
69
|
+
return {
|
|
70
|
+
type: "json",
|
|
71
|
+
...options
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
repeater(options) {
|
|
75
|
+
return {
|
|
76
|
+
type: "repeater",
|
|
77
|
+
...options
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Define a CMS Collection with field schemas and lifecycle rules.
|
|
83
|
+
*/
|
|
84
|
+
function collection(options) {
|
|
85
|
+
return {
|
|
86
|
+
slug: options.slug,
|
|
87
|
+
label: options.label,
|
|
88
|
+
singularLabel: options.singularLabel ?? options.label,
|
|
89
|
+
description: options.description,
|
|
90
|
+
hierarchical: options.hierarchical ?? false,
|
|
91
|
+
timestamps: options.timestamps ?? true,
|
|
92
|
+
revisions: options.revisions ?? true,
|
|
93
|
+
draftable: options.draftable ?? true,
|
|
94
|
+
fields: options.fields,
|
|
95
|
+
taxonomies: options.taxonomies ?? [],
|
|
96
|
+
defaultSort: options.defaultSort ?? {
|
|
97
|
+
field: "createdAt",
|
|
98
|
+
direction: "desc"
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Validate and apply default values to input data for a collection.
|
|
104
|
+
*/
|
|
105
|
+
function validateAndNormalizeData(fieldsList, inputData) {
|
|
106
|
+
const normalized = { ...inputData };
|
|
107
|
+
const errors = {};
|
|
108
|
+
for (const field of fieldsList) {
|
|
109
|
+
let val = normalized[field.name];
|
|
110
|
+
if (val === void 0 && field.defaultValue !== void 0) {
|
|
111
|
+
val = field.defaultValue;
|
|
112
|
+
normalized[field.name] = val;
|
|
113
|
+
}
|
|
114
|
+
if (field.required && (val === void 0 || val === null || val === "")) {
|
|
115
|
+
errors[field.name] = `${field.label || field.name} is required.`;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (val !== void 0 && val !== null) {
|
|
119
|
+
if (field.type === "number" && typeof val !== "number") {
|
|
120
|
+
const parsed = Number(val);
|
|
121
|
+
if (Number.isNaN(parsed)) errors[field.name] = `${field.label || field.name} must be a valid number.`;
|
|
122
|
+
else normalized[field.name] = parsed;
|
|
123
|
+
} else if (field.type === "boolean" && typeof val !== "boolean") normalized[field.name] = Boolean(val);
|
|
124
|
+
}
|
|
125
|
+
if (field.validate && val !== void 0) {
|
|
126
|
+
const res = field.validate(val, normalized);
|
|
127
|
+
if (typeof res === "string") errors[field.name] = res;
|
|
128
|
+
else if (res === false) errors[field.name] = `${field.label || field.name} is invalid.`;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
data: normalized,
|
|
133
|
+
errors
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
//#endregion
|
|
137
|
+
//#region src/core/config.ts
|
|
138
|
+
/**
|
|
139
|
+
* Default standard collections provided when not explicitly specified.
|
|
140
|
+
*/
|
|
141
|
+
const DEFAULT_COLLECTIONS = [collection({
|
|
142
|
+
slug: "posts",
|
|
143
|
+
label: "Posts",
|
|
144
|
+
singularLabel: "Post",
|
|
145
|
+
revisions: true,
|
|
146
|
+
timestamps: true,
|
|
147
|
+
taxonomies: ["categories", "tags"],
|
|
148
|
+
fields: [
|
|
149
|
+
fields.text({
|
|
150
|
+
name: "title",
|
|
151
|
+
label: "Title",
|
|
152
|
+
required: true
|
|
153
|
+
}),
|
|
154
|
+
fields.slug({ from: "title" }),
|
|
155
|
+
fields.richText({
|
|
156
|
+
name: "content",
|
|
157
|
+
label: "Content"
|
|
158
|
+
}),
|
|
159
|
+
fields.text({
|
|
160
|
+
name: "excerpt",
|
|
161
|
+
label: "Excerpt"
|
|
162
|
+
}),
|
|
163
|
+
fields.image({
|
|
164
|
+
name: "featuredImage",
|
|
165
|
+
label: "Featured Image"
|
|
166
|
+
}),
|
|
167
|
+
fields.select({
|
|
168
|
+
name: "status",
|
|
169
|
+
label: "Status",
|
|
170
|
+
options: [
|
|
171
|
+
"draft",
|
|
172
|
+
"pending_review",
|
|
173
|
+
"scheduled",
|
|
174
|
+
"published",
|
|
175
|
+
"trash"
|
|
176
|
+
],
|
|
177
|
+
defaultValue: "draft"
|
|
178
|
+
})
|
|
179
|
+
]
|
|
180
|
+
}), collection({
|
|
181
|
+
slug: "pages",
|
|
182
|
+
label: "Pages",
|
|
183
|
+
singularLabel: "Page",
|
|
184
|
+
hierarchical: true,
|
|
185
|
+
revisions: true,
|
|
186
|
+
timestamps: true,
|
|
187
|
+
fields: [
|
|
188
|
+
fields.text({
|
|
189
|
+
name: "title",
|
|
190
|
+
label: "Title",
|
|
191
|
+
required: true
|
|
192
|
+
}),
|
|
193
|
+
fields.slug({ from: "title" }),
|
|
194
|
+
fields.richText({
|
|
195
|
+
name: "content",
|
|
196
|
+
label: "Content"
|
|
197
|
+
}),
|
|
198
|
+
fields.select({
|
|
199
|
+
name: "status",
|
|
200
|
+
label: "Status",
|
|
201
|
+
options: [
|
|
202
|
+
"draft",
|
|
203
|
+
"published",
|
|
204
|
+
"trash"
|
|
205
|
+
],
|
|
206
|
+
defaultValue: "draft"
|
|
207
|
+
})
|
|
208
|
+
]
|
|
209
|
+
})];
|
|
210
|
+
/**
|
|
211
|
+
* Type-safe configuration helper function for azlib.config.ts.
|
|
212
|
+
*/
|
|
213
|
+
function defineConfig(config) {
|
|
214
|
+
return config;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Normalize and merge user config with CMS defaults.
|
|
218
|
+
*/
|
|
219
|
+
function normalizeConfig(config) {
|
|
220
|
+
const collections = config.collections && config.collections.length > 0 ? config.collections : DEFAULT_COLLECTIONS;
|
|
221
|
+
return {
|
|
222
|
+
site: {
|
|
223
|
+
name: config.site?.name ?? "Azlib CMS",
|
|
224
|
+
description: config.site?.description ?? "A modern headless CMS powered by @azlib",
|
|
225
|
+
url: config.site?.url ?? "http://localhost:3000",
|
|
226
|
+
locale: config.site?.locale ?? "en-US",
|
|
227
|
+
timezone: config.site?.timezone ?? "UTC",
|
|
228
|
+
...config.site
|
|
229
|
+
},
|
|
230
|
+
collections,
|
|
231
|
+
taxonomies: config.taxonomies ?? [],
|
|
232
|
+
admin: {
|
|
233
|
+
route: config.admin?.route ?? "/admin",
|
|
234
|
+
enableRegistration: config.admin?.enableRegistration ?? false,
|
|
235
|
+
...config.admin
|
|
236
|
+
}
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
//#endregion
|
|
240
|
+
//#region src/core/hooks.ts
|
|
241
|
+
var HooksManager = class {
|
|
242
|
+
actions = /* @__PURE__ */ new Map();
|
|
243
|
+
filters = /* @__PURE__ */ new Map();
|
|
244
|
+
/**
|
|
245
|
+
* Register an action callback.
|
|
246
|
+
* Lower priority numbers execute first (default: 10).
|
|
247
|
+
*/
|
|
248
|
+
addAction(tag, callback, priority = 10) {
|
|
249
|
+
const list = this.actions.get(tag) ?? [];
|
|
250
|
+
list.push({
|
|
251
|
+
callback,
|
|
252
|
+
priority
|
|
253
|
+
});
|
|
254
|
+
list.sort((a, b) => a.priority - b.priority);
|
|
255
|
+
this.actions.set(tag, list);
|
|
256
|
+
return this;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Execute all callbacks registered for the specified action asynchronously.
|
|
260
|
+
*/
|
|
261
|
+
async doAction(tag, ...args) {
|
|
262
|
+
const list = this.actions.get(tag);
|
|
263
|
+
if (!list || list.length === 0) return;
|
|
264
|
+
for (const entry of list) await entry.callback(...args);
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Execute all callbacks registered for the specified action synchronously.
|
|
268
|
+
*/
|
|
269
|
+
doActionSync(tag, ...args) {
|
|
270
|
+
const list = this.actions.get(tag);
|
|
271
|
+
if (!list || list.length === 0) return;
|
|
272
|
+
for (const entry of list) {
|
|
273
|
+
const result = entry.callback(...args);
|
|
274
|
+
if (result instanceof Promise) result.catch((err) => {
|
|
275
|
+
console.error(`[HooksManager] Unhandled error in async action '${tag}':`, err);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Register a filter callback.
|
|
281
|
+
* Filter callbacks receive a value and return the modified value.
|
|
282
|
+
*/
|
|
283
|
+
addFilter(tag, callback, priority = 10) {
|
|
284
|
+
const list = this.filters.get(tag) ?? [];
|
|
285
|
+
list.push({
|
|
286
|
+
callback,
|
|
287
|
+
priority
|
|
288
|
+
});
|
|
289
|
+
list.sort((a, b) => a.priority - b.priority);
|
|
290
|
+
this.filters.set(tag, list);
|
|
291
|
+
return this;
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Apply all filters registered for the specified tag sequentially.
|
|
295
|
+
*/
|
|
296
|
+
async applyFilters(tag, initialValue, ...args) {
|
|
297
|
+
const list = this.filters.get(tag);
|
|
298
|
+
if (!list || list.length === 0) return initialValue;
|
|
299
|
+
let currentValue = initialValue;
|
|
300
|
+
for (const entry of list) currentValue = await entry.callback(currentValue, ...args);
|
|
301
|
+
return currentValue;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Apply all filters registered for the specified tag synchronously.
|
|
305
|
+
*/
|
|
306
|
+
applyFiltersSync(tag, initialValue, ...args) {
|
|
307
|
+
const list = this.filters.get(tag);
|
|
308
|
+
if (!list || list.length === 0) return initialValue;
|
|
309
|
+
let currentValue = initialValue;
|
|
310
|
+
for (const entry of list) {
|
|
311
|
+
const next = entry.callback(currentValue, ...args);
|
|
312
|
+
if (next instanceof Promise) throw new Error(`[HooksManager] Async filter detected during applyFiltersSync for tag '${tag}'. Use applyFilters instead.`);
|
|
313
|
+
currentValue = next;
|
|
314
|
+
}
|
|
315
|
+
return currentValue;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Remove a registered action callback.
|
|
319
|
+
*/
|
|
320
|
+
removeAction(tag, callback) {
|
|
321
|
+
const list = this.actions.get(tag);
|
|
322
|
+
if (!list) return false;
|
|
323
|
+
const initialLen = list.length;
|
|
324
|
+
const filtered = list.filter((e) => e.callback !== callback);
|
|
325
|
+
this.actions.set(tag, filtered);
|
|
326
|
+
return filtered.length < initialLen;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Remove a registered filter callback.
|
|
330
|
+
*/
|
|
331
|
+
removeFilter(tag, callback) {
|
|
332
|
+
const list = this.filters.get(tag);
|
|
333
|
+
if (!list) return false;
|
|
334
|
+
const initialLen = list.length;
|
|
335
|
+
const filtered = list.filter((e) => e.callback !== callback);
|
|
336
|
+
this.filters.set(tag, filtered);
|
|
337
|
+
return filtered.length < initialLen;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Check if any callbacks are registered for an action.
|
|
341
|
+
*/
|
|
342
|
+
hasAction(tag) {
|
|
343
|
+
const list = this.actions.get(tag);
|
|
344
|
+
return Boolean(list && list.length > 0);
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Check if any callbacks are registered for a filter.
|
|
348
|
+
*/
|
|
349
|
+
hasFilter(tag) {
|
|
350
|
+
const list = this.filters.get(tag);
|
|
351
|
+
return Boolean(list && list.length > 0);
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Remove all registered actions and filters.
|
|
355
|
+
*/
|
|
356
|
+
removeAll(tag) {
|
|
357
|
+
if (tag) {
|
|
358
|
+
this.actions.delete(tag);
|
|
359
|
+
this.filters.delete(tag);
|
|
360
|
+
} else {
|
|
361
|
+
this.actions.clear();
|
|
362
|
+
this.filters.clear();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
const defaultHooks = new HooksManager();
|
|
367
|
+
//#endregion
|
|
368
|
+
//#region src/core/options.ts
|
|
369
|
+
var OptionsManager = class {
|
|
370
|
+
storage;
|
|
371
|
+
hooks;
|
|
372
|
+
constructor(storage, hooks) {
|
|
373
|
+
this.storage = storage;
|
|
374
|
+
this.hooks = hooks;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Retrieve an option by key. Returns defaultValue if option does not exist.
|
|
378
|
+
*/
|
|
379
|
+
async get(key, defaultValue) {
|
|
380
|
+
const item = await this.storage.getOption(key);
|
|
381
|
+
let value = item ? item.value : defaultValue;
|
|
382
|
+
if (this.hooks) value = await this.hooks.applyFilters(`cms.option.${key}`, value);
|
|
383
|
+
return value;
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Set or update an option value.
|
|
387
|
+
*/
|
|
388
|
+
async set(key, value, options) {
|
|
389
|
+
let finalValue = value;
|
|
390
|
+
if (this.hooks) finalValue = await this.hooks.applyFilters(`cms.pre_set_option.${key}`, finalValue);
|
|
391
|
+
const item = {
|
|
392
|
+
key,
|
|
393
|
+
value: finalValue,
|
|
394
|
+
autoload: options?.autoload ?? true,
|
|
395
|
+
namespace: options?.namespace,
|
|
396
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
397
|
+
};
|
|
398
|
+
await this.storage.setOption(item);
|
|
399
|
+
if (this.hooks) {
|
|
400
|
+
await this.hooks.doAction("cms.option_updated", key, finalValue);
|
|
401
|
+
await this.hooks.doAction(`cms.option_updated.${key}`, finalValue);
|
|
402
|
+
}
|
|
403
|
+
return item;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Delete an option by key.
|
|
407
|
+
*/
|
|
408
|
+
async delete(key) {
|
|
409
|
+
const deleted = await this.storage.deleteOption(key);
|
|
410
|
+
if (deleted && this.hooks) await this.hooks.doAction("cms.option_deleted", key);
|
|
411
|
+
return deleted;
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Check if an option exists.
|
|
415
|
+
*/
|
|
416
|
+
async has(key) {
|
|
417
|
+
const item = await this.storage.getOption(key);
|
|
418
|
+
return item !== null && item !== void 0;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Get all options, optionally filtered by namespace.
|
|
422
|
+
*/
|
|
423
|
+
async getAll(namespace) {
|
|
424
|
+
const items = await this.storage.getOptions(namespace);
|
|
425
|
+
const result = {};
|
|
426
|
+
for (const item of items) result[item.key] = item.value;
|
|
427
|
+
return result;
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
//#endregion
|
|
431
|
+
//#region src/content/lifecycle.ts
|
|
432
|
+
const VALID_STATUS_TRANSITIONS = {
|
|
433
|
+
draft: [
|
|
434
|
+
"pending_review",
|
|
435
|
+
"scheduled",
|
|
436
|
+
"published",
|
|
437
|
+
"private",
|
|
438
|
+
"trash"
|
|
439
|
+
],
|
|
440
|
+
pending_review: [
|
|
441
|
+
"draft",
|
|
442
|
+
"scheduled",
|
|
443
|
+
"published",
|
|
444
|
+
"trash"
|
|
445
|
+
],
|
|
446
|
+
scheduled: [
|
|
447
|
+
"draft",
|
|
448
|
+
"published",
|
|
449
|
+
"trash"
|
|
450
|
+
],
|
|
451
|
+
published: [
|
|
452
|
+
"draft",
|
|
453
|
+
"trash",
|
|
454
|
+
"private"
|
|
455
|
+
],
|
|
456
|
+
private: [
|
|
457
|
+
"draft",
|
|
458
|
+
"published",
|
|
459
|
+
"trash"
|
|
460
|
+
],
|
|
461
|
+
trash: ["draft"]
|
|
462
|
+
};
|
|
463
|
+
var ContentLifecycle = class {
|
|
464
|
+
storage;
|
|
465
|
+
hooks;
|
|
466
|
+
constructor(storage, hooks) {
|
|
467
|
+
this.storage = storage;
|
|
468
|
+
this.hooks = hooks;
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Validate whether a status transition is permitted.
|
|
472
|
+
*/
|
|
473
|
+
canTransition(currentStatus, nextStatus) {
|
|
474
|
+
if (currentStatus === nextStatus) return true;
|
|
475
|
+
const allowed = VALID_STATUS_TRANSITIONS[currentStatus];
|
|
476
|
+
return Boolean(allowed && allowed.includes(nextStatus));
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Process all scheduled content across a collection (or all collections) and publish mature items.
|
|
480
|
+
*/
|
|
481
|
+
async processScheduledContent(collection, now = /* @__PURE__ */ new Date()) {
|
|
482
|
+
const scheduledRes = await this.storage.findContent(collection, {
|
|
483
|
+
status: "scheduled",
|
|
484
|
+
limit: 1e3
|
|
485
|
+
});
|
|
486
|
+
const nowIso = now.toISOString();
|
|
487
|
+
const publishedItems = [];
|
|
488
|
+
for (const item of scheduledRes.items) if (item.scheduledAt && item.scheduledAt <= nowIso) {
|
|
489
|
+
const updated = await this.storage.updateContent(collection, item.id, {
|
|
490
|
+
status: "published",
|
|
491
|
+
publishedAt: item.scheduledAt
|
|
492
|
+
});
|
|
493
|
+
if (updated) {
|
|
494
|
+
publishedItems.push(updated);
|
|
495
|
+
if (this.hooks) {
|
|
496
|
+
await this.hooks.doAction("cms.content_published", updated);
|
|
497
|
+
await this.hooks.doAction(`cms.${collection}_published`, updated);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return publishedItems;
|
|
502
|
+
}
|
|
503
|
+
};
|
|
504
|
+
//#endregion
|
|
505
|
+
//#region src/content/revisions.ts
|
|
506
|
+
var RevisionManager = class {
|
|
507
|
+
storage;
|
|
508
|
+
hooks;
|
|
509
|
+
constructor(storage, hooks) {
|
|
510
|
+
this.storage = storage;
|
|
511
|
+
this.hooks = hooks;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Save a revision snapshot for a content item.
|
|
515
|
+
*/
|
|
516
|
+
async createRevision(item, authorId, note) {
|
|
517
|
+
const revision = await this.storage.createRevision(item.id, item.collection, item.version, item, authorId, note);
|
|
518
|
+
if (this.hooks) await this.hooks.doAction("cms.revision_created", revision);
|
|
519
|
+
return revision;
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Get all revisions for a content item.
|
|
523
|
+
*/
|
|
524
|
+
async getRevisions(contentId) {
|
|
525
|
+
return this.storage.getRevisions(contentId);
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Get a specific revision by ID.
|
|
529
|
+
*/
|
|
530
|
+
async getRevision(revisionId) {
|
|
531
|
+
return this.storage.getRevision(revisionId);
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Compute differences between two content snapshots.
|
|
535
|
+
*/
|
|
536
|
+
computeDiff(oldSnapshot, newSnapshot) {
|
|
537
|
+
const changes = [];
|
|
538
|
+
if (oldSnapshot.title !== newSnapshot.title) changes.push({
|
|
539
|
+
field: "title",
|
|
540
|
+
oldValue: oldSnapshot.title,
|
|
541
|
+
newValue: newSnapshot.title
|
|
542
|
+
});
|
|
543
|
+
if (oldSnapshot.slug !== newSnapshot.slug) changes.push({
|
|
544
|
+
field: "slug",
|
|
545
|
+
oldValue: oldSnapshot.slug,
|
|
546
|
+
newValue: newSnapshot.slug
|
|
547
|
+
});
|
|
548
|
+
if (oldSnapshot.status !== newSnapshot.status) changes.push({
|
|
549
|
+
field: "status",
|
|
550
|
+
oldValue: oldSnapshot.status,
|
|
551
|
+
newValue: newSnapshot.status
|
|
552
|
+
});
|
|
553
|
+
const allKeys = /* @__PURE__ */ new Set([...Object.keys(oldSnapshot.data || {}), ...Object.keys(newSnapshot.data || {})]);
|
|
554
|
+
const topLevelFields = /* @__PURE__ */ new Set([
|
|
555
|
+
"title",
|
|
556
|
+
"slug",
|
|
557
|
+
"status"
|
|
558
|
+
]);
|
|
559
|
+
for (const key of allKeys) {
|
|
560
|
+
if (topLevelFields.has(key)) continue;
|
|
561
|
+
const oldVal = oldSnapshot.data?.[key];
|
|
562
|
+
const newVal = newSnapshot.data?.[key];
|
|
563
|
+
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) changes.push({
|
|
564
|
+
field: `data.${key}`,
|
|
565
|
+
oldValue: oldVal,
|
|
566
|
+
newValue: newVal
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
return {
|
|
570
|
+
contentId: newSnapshot.id,
|
|
571
|
+
fromVersion: oldSnapshot.version,
|
|
572
|
+
toVersion: newSnapshot.version,
|
|
573
|
+
changes
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Restore a content item to a previous revision snapshot.
|
|
578
|
+
*/
|
|
579
|
+
async restoreRevision(collection, revisionId, authorId) {
|
|
580
|
+
const revision = await this.storage.getRevision(revisionId);
|
|
581
|
+
if (!revision || revision.collection !== collection) return null;
|
|
582
|
+
const snapshot = revision.snapshot;
|
|
583
|
+
const restored = await this.storage.updateContent(collection, revision.contentId, {
|
|
584
|
+
title: snapshot.title,
|
|
585
|
+
slug: snapshot.slug,
|
|
586
|
+
status: snapshot.status,
|
|
587
|
+
parentId: snapshot.parentId,
|
|
588
|
+
data: snapshot.data,
|
|
589
|
+
authorId: authorId ?? snapshot.authorId
|
|
590
|
+
});
|
|
591
|
+
if (restored) {
|
|
592
|
+
await this.createRevision(restored, authorId, `Restored from revision v${revision.version}`);
|
|
593
|
+
if (this.hooks) await this.hooks.doAction("cms.revision_restored", restored, revision);
|
|
594
|
+
}
|
|
595
|
+
return restored;
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
//#endregion
|
|
599
|
+
//#region src/content/slug.ts
|
|
600
|
+
/**
|
|
601
|
+
* @azlib/cms - Slug generation and normalization utility
|
|
602
|
+
*/
|
|
603
|
+
/**
|
|
604
|
+
* Generate a clean, URL-safe slug from any input string.
|
|
605
|
+
*/
|
|
606
|
+
function slugify(input) {
|
|
607
|
+
if (!input) return "";
|
|
608
|
+
return input.toString().normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().trim().replace(/[^a-z0-9\s-_]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Create a unique slug given an existing set of slugs or a check function.
|
|
612
|
+
*/
|
|
613
|
+
async function resolveUniqueSlug(baseSlug, isSlugTaken, currentId) {
|
|
614
|
+
const root = slugify(baseSlug) || "item";
|
|
615
|
+
let candidate = root;
|
|
616
|
+
let counter = 1;
|
|
617
|
+
while (await isSlugTaken(candidate)) {
|
|
618
|
+
counter += 1;
|
|
619
|
+
candidate = `${root}-${counter}`;
|
|
620
|
+
}
|
|
621
|
+
return candidate;
|
|
622
|
+
}
|
|
623
|
+
//#endregion
|
|
624
|
+
//#region src/taxonomy/taxonomy.ts
|
|
625
|
+
var TaxonomyManager = class {
|
|
626
|
+
storage;
|
|
627
|
+
hooks;
|
|
628
|
+
registeredTaxonomies = /* @__PURE__ */ new Map();
|
|
629
|
+
constructor(storage, hooks) {
|
|
630
|
+
this.storage = storage;
|
|
631
|
+
this.hooks = hooks;
|
|
632
|
+
this.registerTaxonomy({
|
|
633
|
+
slug: "categories",
|
|
634
|
+
label: "Categories",
|
|
635
|
+
singularLabel: "Category",
|
|
636
|
+
hierarchical: true,
|
|
637
|
+
postTypes: ["posts"]
|
|
638
|
+
});
|
|
639
|
+
this.registerTaxonomy({
|
|
640
|
+
slug: "tags",
|
|
641
|
+
label: "Tags",
|
|
642
|
+
singularLabel: "Tag",
|
|
643
|
+
hierarchical: false,
|
|
644
|
+
postTypes: ["posts"]
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Register a taxonomy definition.
|
|
649
|
+
*/
|
|
650
|
+
registerTaxonomy(config) {
|
|
651
|
+
this.registeredTaxonomies.set(config.slug, config);
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Get all registered taxonomies.
|
|
655
|
+
*/
|
|
656
|
+
getTaxonomies() {
|
|
657
|
+
return Array.from(this.registeredTaxonomies.values());
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Get a single taxonomy configuration by slug.
|
|
661
|
+
*/
|
|
662
|
+
getTaxonomy(slug) {
|
|
663
|
+
return this.registeredTaxonomies.get(slug);
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Create a new term (e.g. category or tag).
|
|
667
|
+
*/
|
|
668
|
+
async createTerm(taxonomy, data) {
|
|
669
|
+
const taxConfig = this.getTaxonomy(taxonomy);
|
|
670
|
+
if (!taxConfig) throw new Error(`[TaxonomyManager] Taxonomy '${taxonomy}' is not registered.`);
|
|
671
|
+
const finalSlug = await resolveUniqueSlug(data.slug ? slugify(data.slug) : slugify(data.name), async (s) => {
|
|
672
|
+
return await this.storage.getTermBySlug(taxonomy, s) !== null;
|
|
673
|
+
});
|
|
674
|
+
const term = await this.storage.createTerm({
|
|
675
|
+
taxonomy,
|
|
676
|
+
name: data.name.trim(),
|
|
677
|
+
slug: finalSlug,
|
|
678
|
+
description: data.description,
|
|
679
|
+
parentId: taxConfig.hierarchical ? data.parentId ?? null : null,
|
|
680
|
+
meta: data.meta
|
|
681
|
+
});
|
|
682
|
+
if (this.hooks) {
|
|
683
|
+
await this.hooks.doAction("cms.term_created", term);
|
|
684
|
+
await this.hooks.doAction(`cms.${taxonomy}_term_created`, term);
|
|
685
|
+
}
|
|
686
|
+
return term;
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Get a term by ID.
|
|
690
|
+
*/
|
|
691
|
+
async getTermById(id) {
|
|
692
|
+
return this.storage.getTermById(id);
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Get a term by slug within a taxonomy.
|
|
696
|
+
*/
|
|
697
|
+
async getTermBySlug(taxonomy, slug) {
|
|
698
|
+
return this.storage.getTermBySlug(taxonomy, slug);
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Get terms for a taxonomy.
|
|
702
|
+
*/
|
|
703
|
+
async getTerms(taxonomy, options) {
|
|
704
|
+
return this.storage.getTerms(taxonomy, options);
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Build a hierarchical tree of terms for a taxonomy (parent -> children).
|
|
708
|
+
*/
|
|
709
|
+
async getTermTree(taxonomy) {
|
|
710
|
+
const allTerms = await this.storage.getTerms(taxonomy);
|
|
711
|
+
const map = /* @__PURE__ */ new Map();
|
|
712
|
+
const roots = [];
|
|
713
|
+
for (const term of allTerms) map.set(term.id, {
|
|
714
|
+
...term,
|
|
715
|
+
children: []
|
|
716
|
+
});
|
|
717
|
+
for (const term of allTerms) {
|
|
718
|
+
const node = map.get(term.id);
|
|
719
|
+
if (term.parentId && map.has(term.parentId)) map.get(term.parentId).children.push(node);
|
|
720
|
+
else roots.push(node);
|
|
721
|
+
}
|
|
722
|
+
return roots;
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Update a term.
|
|
726
|
+
*/
|
|
727
|
+
async updateTerm(id, updates) {
|
|
728
|
+
const existing = await this.storage.getTermById(id);
|
|
729
|
+
if (!existing) return null;
|
|
730
|
+
let slug = existing.slug;
|
|
731
|
+
if (updates.slug && updates.slug !== existing.slug) slug = await resolveUniqueSlug(updates.slug, async (s) => {
|
|
732
|
+
const check = await this.storage.getTermBySlug(existing.taxonomy, s);
|
|
733
|
+
return check !== null && check.id !== id;
|
|
734
|
+
}, id);
|
|
735
|
+
const updated = await this.storage.updateTerm(id, {
|
|
736
|
+
...updates,
|
|
737
|
+
slug,
|
|
738
|
+
name: updates.name ? updates.name.trim() : existing.name
|
|
739
|
+
});
|
|
740
|
+
if (updated && this.hooks) await this.hooks.doAction("cms.term_updated", updated);
|
|
741
|
+
return updated;
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Delete a term.
|
|
745
|
+
*/
|
|
746
|
+
async deleteTerm(id) {
|
|
747
|
+
const term = await this.storage.getTermById(id);
|
|
748
|
+
if (!term) return false;
|
|
749
|
+
const deleted = await this.storage.deleteTerm(id);
|
|
750
|
+
if (deleted && this.hooks) await this.hooks.doAction("cms.term_deleted", term);
|
|
751
|
+
return deleted;
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Assign term IDs to a content item.
|
|
755
|
+
*/
|
|
756
|
+
async assignTerms(contentId, termIds) {
|
|
757
|
+
await this.storage.assignTermsToContent(contentId, termIds);
|
|
758
|
+
if (this.hooks) await this.hooks.doAction("cms.terms_assigned", contentId, termIds);
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Get all terms assigned to a content item.
|
|
762
|
+
*/
|
|
763
|
+
async getContentTerms(contentId, taxonomy) {
|
|
764
|
+
return this.storage.getContentTerms(contentId, taxonomy);
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Remove terms from a content item.
|
|
768
|
+
*/
|
|
769
|
+
async removeTerms(contentId, termIds) {
|
|
770
|
+
await this.storage.removeTermsFromContent(contentId, termIds);
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
//#endregion
|
|
774
|
+
//#region src/media/media-manager.ts
|
|
775
|
+
var MediaManager = class {
|
|
776
|
+
storage;
|
|
777
|
+
hooks;
|
|
778
|
+
publicBaseUrl;
|
|
779
|
+
allowedMimePrefixes = [
|
|
780
|
+
"image/",
|
|
781
|
+
"video/",
|
|
782
|
+
"audio/",
|
|
783
|
+
"application/pdf",
|
|
784
|
+
"text/"
|
|
785
|
+
];
|
|
786
|
+
constructor(storage, hooks, publicBaseUrl = "/uploads") {
|
|
787
|
+
this.storage = storage;
|
|
788
|
+
this.hooks = hooks;
|
|
789
|
+
this.publicBaseUrl = publicBaseUrl;
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Upload / register a new media item.
|
|
793
|
+
*/
|
|
794
|
+
async upload(input, authorId) {
|
|
795
|
+
if (!this.allowedMimePrefixes.some((prefix) => input.mimeType.startsWith(prefix))) throw new Error(`[MediaManager] Unsupported MIME type: ${input.mimeType}`);
|
|
796
|
+
const extMatch = input.filename.match(/\.([a-zA-Z0-9]+)$/);
|
|
797
|
+
const ext = extMatch ? `.${extMatch[1].toLowerCase()}` : "";
|
|
798
|
+
const cleanFilename = `${slugify(input.filename.replace(/\.[^/.]+$/, ""))}${ext}`;
|
|
799
|
+
const url = input.url ?? `${this.publicBaseUrl}/${cleanFilename}`;
|
|
800
|
+
const media = await this.storage.createMedia({
|
|
801
|
+
filename: cleanFilename,
|
|
802
|
+
originalName: input.filename,
|
|
803
|
+
mimeType: input.mimeType,
|
|
804
|
+
sizeBytes: input.sizeBytes,
|
|
805
|
+
url,
|
|
806
|
+
width: input.width,
|
|
807
|
+
height: input.height,
|
|
808
|
+
altText: input.altText,
|
|
809
|
+
caption: input.caption,
|
|
810
|
+
variants: input.variants,
|
|
811
|
+
authorId
|
|
812
|
+
});
|
|
813
|
+
if (this.hooks) await this.hooks.doAction("cms.media_uploaded", media);
|
|
814
|
+
return media;
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Get a media item by ID.
|
|
818
|
+
*/
|
|
819
|
+
async get(id) {
|
|
820
|
+
return this.storage.getMedia(id);
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Find media items.
|
|
824
|
+
*/
|
|
825
|
+
async find(options) {
|
|
826
|
+
return this.storage.findMedia(options);
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Update media metadata (alt text, caption, dimensions).
|
|
830
|
+
*/
|
|
831
|
+
async updateMetadata(id, updates) {
|
|
832
|
+
const updated = await this.storage.updateMedia(id, updates);
|
|
833
|
+
if (updated && this.hooks) await this.hooks.doAction("cms.media_updated", updated);
|
|
834
|
+
return updated;
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Delete a media item.
|
|
838
|
+
*/
|
|
839
|
+
async delete(id) {
|
|
840
|
+
const media = await this.storage.getMedia(id);
|
|
841
|
+
if (!media) return false;
|
|
842
|
+
const deleted = await this.storage.deleteMedia(id);
|
|
843
|
+
if (deleted && this.hooks) await this.hooks.doAction("cms.media_deleted", media);
|
|
844
|
+
return deleted;
|
|
845
|
+
}
|
|
846
|
+
};
|
|
847
|
+
//#endregion
|
|
848
|
+
//#region src/auth/rbac.ts
|
|
849
|
+
const DEFAULT_ROLE_CAPABILITIES = {
|
|
850
|
+
admin: [
|
|
851
|
+
"manage_options",
|
|
852
|
+
"manage_taxonomies",
|
|
853
|
+
"upload_files",
|
|
854
|
+
"delete_files",
|
|
855
|
+
"edit_posts",
|
|
856
|
+
"edit_others_posts",
|
|
857
|
+
"publish_posts",
|
|
858
|
+
"delete_posts",
|
|
859
|
+
"delete_others_posts",
|
|
860
|
+
"read_private_posts"
|
|
861
|
+
],
|
|
862
|
+
editor: [
|
|
863
|
+
"manage_taxonomies",
|
|
864
|
+
"upload_files",
|
|
865
|
+
"delete_files",
|
|
866
|
+
"edit_posts",
|
|
867
|
+
"edit_others_posts",
|
|
868
|
+
"publish_posts",
|
|
869
|
+
"delete_posts",
|
|
870
|
+
"delete_others_posts",
|
|
871
|
+
"read_private_posts"
|
|
872
|
+
],
|
|
873
|
+
author: [
|
|
874
|
+
"upload_files",
|
|
875
|
+
"edit_posts",
|
|
876
|
+
"publish_posts",
|
|
877
|
+
"delete_posts"
|
|
878
|
+
],
|
|
879
|
+
contributor: ["edit_posts", "delete_posts"],
|
|
880
|
+
subscriber: ["read"]
|
|
881
|
+
};
|
|
882
|
+
var RBACManager = class {
|
|
883
|
+
roleCapabilities = /* @__PURE__ */ new Map();
|
|
884
|
+
constructor() {
|
|
885
|
+
for (const [role, caps] of Object.entries(DEFAULT_ROLE_CAPABILITIES)) this.roleCapabilities.set(role, new Set(caps));
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Register a custom role with its capabilities.
|
|
889
|
+
*/
|
|
890
|
+
registerRole(role, capabilities) {
|
|
891
|
+
this.roleCapabilities.set(role, new Set(capabilities));
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* Add a capability to an existing role.
|
|
895
|
+
*/
|
|
896
|
+
addCapabilityToRole(role, capability) {
|
|
897
|
+
let caps = this.roleCapabilities.get(role);
|
|
898
|
+
if (!caps) {
|
|
899
|
+
caps = /* @__PURE__ */ new Set();
|
|
900
|
+
this.roleCapabilities.set(role, caps);
|
|
901
|
+
}
|
|
902
|
+
caps.add(capability);
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* Get all capabilities assigned to a role.
|
|
906
|
+
*/
|
|
907
|
+
getRoleCapabilities(role) {
|
|
908
|
+
const caps = this.roleCapabilities.get(role);
|
|
909
|
+
return caps ? Array.from(caps) : [];
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Check if a user possesses a specific capability.
|
|
913
|
+
* If a target content item context is provided, checks object ownership rules.
|
|
914
|
+
*/
|
|
915
|
+
can(user, capability, context) {
|
|
916
|
+
if (!user || user.active === false) return false;
|
|
917
|
+
if (user.capabilities && user.capabilities.includes(capability)) return true;
|
|
918
|
+
const roleCaps = this.roleCapabilities.get(user.role);
|
|
919
|
+
if (!roleCaps) return false;
|
|
920
|
+
if (user.role === "admin") return true;
|
|
921
|
+
if (context?.contentItem) {
|
|
922
|
+
const isOwner = context.contentItem.authorId === user.id;
|
|
923
|
+
if (capability === "edit_posts") {
|
|
924
|
+
if (isOwner) return roleCaps.has("edit_posts");
|
|
925
|
+
return roleCaps.has("edit_others_posts");
|
|
926
|
+
}
|
|
927
|
+
if (capability === "delete_posts") {
|
|
928
|
+
if (isOwner) return roleCaps.has("delete_posts");
|
|
929
|
+
return roleCaps.has("delete_others_posts");
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
return roleCaps.has(capability);
|
|
933
|
+
}
|
|
934
|
+
};
|
|
935
|
+
//#endregion
|
|
936
|
+
//#region src/storage/memory-adapter.ts
|
|
937
|
+
function generateId(prefix = "") {
|
|
938
|
+
return `${prefix}${Date.now().toString(36)}_${Math.random().toString(36).substring(2, 9)}`;
|
|
939
|
+
}
|
|
940
|
+
var MemoryStorageAdapter = class {
|
|
941
|
+
content = /* @__PURE__ */ new Map();
|
|
942
|
+
revisions = /* @__PURE__ */ new Map();
|
|
943
|
+
terms = /* @__PURE__ */ new Map();
|
|
944
|
+
contentTerms = /* @__PURE__ */ new Map();
|
|
945
|
+
media = /* @__PURE__ */ new Map();
|
|
946
|
+
options = /* @__PURE__ */ new Map();
|
|
947
|
+
async init() {}
|
|
948
|
+
async close() {}
|
|
949
|
+
async createContent(item) {
|
|
950
|
+
const id = generateId("cnt_");
|
|
951
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
952
|
+
const fullItem = {
|
|
953
|
+
...item,
|
|
954
|
+
id,
|
|
955
|
+
version: 1,
|
|
956
|
+
createdAt: now,
|
|
957
|
+
updatedAt: now
|
|
958
|
+
};
|
|
959
|
+
this.content.set(id, fullItem);
|
|
960
|
+
return JSON.parse(JSON.stringify(fullItem));
|
|
961
|
+
}
|
|
962
|
+
async getContent(collection, id) {
|
|
963
|
+
const item = this.content.get(id);
|
|
964
|
+
if (!item || item.collection !== collection) return null;
|
|
965
|
+
return JSON.parse(JSON.stringify(item));
|
|
966
|
+
}
|
|
967
|
+
async getContentBySlug(collection, slug) {
|
|
968
|
+
for (const item of this.content.values()) if (item.collection === collection && item.slug === slug) return JSON.parse(JSON.stringify(item));
|
|
969
|
+
return null;
|
|
970
|
+
}
|
|
971
|
+
async findContent(collection, options = {}) {
|
|
972
|
+
let list = [];
|
|
973
|
+
for (const item of this.content.values()) {
|
|
974
|
+
if (item.collection !== collection) continue;
|
|
975
|
+
if (options.status) {
|
|
976
|
+
if (!(Array.isArray(options.status) ? options.status : [options.status]).includes(item.status)) continue;
|
|
977
|
+
}
|
|
978
|
+
if (options.authorId && item.authorId !== options.authorId) continue;
|
|
979
|
+
if (options.parentId !== void 0 && item.parentId !== options.parentId) continue;
|
|
980
|
+
if (options.termIds && options.termIds.length > 0) {
|
|
981
|
+
const assigned = this.contentTerms.get(item.id);
|
|
982
|
+
if (!assigned || !options.termIds.some((tId) => assigned.has(tId))) continue;
|
|
983
|
+
}
|
|
984
|
+
if (options.search) {
|
|
985
|
+
const query = options.search.toLowerCase();
|
|
986
|
+
const inTitle = item.title?.toLowerCase().includes(query);
|
|
987
|
+
const inSlug = item.slug.toLowerCase().includes(query);
|
|
988
|
+
const inData = JSON.stringify(item.data).toLowerCase().includes(query);
|
|
989
|
+
if (!inTitle && !inSlug && !inData) continue;
|
|
990
|
+
}
|
|
991
|
+
if (options.where) {
|
|
992
|
+
let match = true;
|
|
993
|
+
for (const [key, val] of Object.entries(options.where)) if (item.data[key] !== val && item[key] !== val) {
|
|
994
|
+
match = false;
|
|
995
|
+
break;
|
|
996
|
+
}
|
|
997
|
+
if (!match) continue;
|
|
998
|
+
}
|
|
999
|
+
list.push(item);
|
|
1000
|
+
}
|
|
1001
|
+
const total = list.length;
|
|
1002
|
+
const orderBy = options.orderBy || "createdAt";
|
|
1003
|
+
const dir = options.orderDirection === "asc" ? 1 : -1;
|
|
1004
|
+
list.sort((a, b) => {
|
|
1005
|
+
const valA = a[orderBy] ?? a.data[orderBy] ?? "";
|
|
1006
|
+
const valB = b[orderBy] ?? b.data[orderBy] ?? "";
|
|
1007
|
+
if (valA < valB) return -1 * dir;
|
|
1008
|
+
if (valA > valB) return 1 * dir;
|
|
1009
|
+
return 0;
|
|
1010
|
+
});
|
|
1011
|
+
const offset = options.offset ?? 0;
|
|
1012
|
+
const limit = options.limit ?? 20;
|
|
1013
|
+
const paged = list.slice(offset, offset + limit);
|
|
1014
|
+
return {
|
|
1015
|
+
items: JSON.parse(JSON.stringify(paged)),
|
|
1016
|
+
total,
|
|
1017
|
+
limit,
|
|
1018
|
+
offset,
|
|
1019
|
+
hasMore: offset + limit < total
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
async updateContent(collection, id, updates) {
|
|
1023
|
+
const existing = this.content.get(id);
|
|
1024
|
+
if (!existing || existing.collection !== collection) return null;
|
|
1025
|
+
const updated = {
|
|
1026
|
+
...existing,
|
|
1027
|
+
...updates,
|
|
1028
|
+
data: {
|
|
1029
|
+
...existing.data,
|
|
1030
|
+
...updates.data || {}
|
|
1031
|
+
},
|
|
1032
|
+
version: existing.version + 1,
|
|
1033
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1034
|
+
};
|
|
1035
|
+
this.content.set(id, updated);
|
|
1036
|
+
return JSON.parse(JSON.stringify(updated));
|
|
1037
|
+
}
|
|
1038
|
+
async deleteContent(collection, id) {
|
|
1039
|
+
const existing = this.content.get(id);
|
|
1040
|
+
if (!existing || existing.collection !== collection) return false;
|
|
1041
|
+
this.content.delete(id);
|
|
1042
|
+
this.contentTerms.delete(id);
|
|
1043
|
+
await this.deleteRevisionsByContentId(id);
|
|
1044
|
+
return true;
|
|
1045
|
+
}
|
|
1046
|
+
async countContent(collection, options = {}) {
|
|
1047
|
+
return (await this.findContent(collection, {
|
|
1048
|
+
...options,
|
|
1049
|
+
limit: 1e6
|
|
1050
|
+
})).total;
|
|
1051
|
+
}
|
|
1052
|
+
async createRevision(contentId, collection, version, snapshot, authorId, note) {
|
|
1053
|
+
const id = generateId("rev_");
|
|
1054
|
+
const record = {
|
|
1055
|
+
id,
|
|
1056
|
+
contentId,
|
|
1057
|
+
collection,
|
|
1058
|
+
version,
|
|
1059
|
+
snapshot: JSON.parse(JSON.stringify(snapshot)),
|
|
1060
|
+
authorId,
|
|
1061
|
+
note,
|
|
1062
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1063
|
+
};
|
|
1064
|
+
this.revisions.set(id, record);
|
|
1065
|
+
return JSON.parse(JSON.stringify(record));
|
|
1066
|
+
}
|
|
1067
|
+
async getRevisions(contentId) {
|
|
1068
|
+
const list = [];
|
|
1069
|
+
for (const rev of this.revisions.values()) if (rev.contentId === contentId) list.push(rev);
|
|
1070
|
+
list.sort((a, b) => b.version - a.version);
|
|
1071
|
+
return JSON.parse(JSON.stringify(list));
|
|
1072
|
+
}
|
|
1073
|
+
async getRevision(revisionId) {
|
|
1074
|
+
const rev = this.revisions.get(revisionId);
|
|
1075
|
+
return rev ? JSON.parse(JSON.stringify(rev)) : null;
|
|
1076
|
+
}
|
|
1077
|
+
async deleteRevisionsByContentId(contentId) {
|
|
1078
|
+
let count = 0;
|
|
1079
|
+
for (const [id, rev] of this.revisions.entries()) if (rev.contentId === contentId) {
|
|
1080
|
+
this.revisions.delete(id);
|
|
1081
|
+
count++;
|
|
1082
|
+
}
|
|
1083
|
+
return count;
|
|
1084
|
+
}
|
|
1085
|
+
async createTerm(term) {
|
|
1086
|
+
const id = generateId("trm_");
|
|
1087
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1088
|
+
const item = {
|
|
1089
|
+
...term,
|
|
1090
|
+
id,
|
|
1091
|
+
count: 0,
|
|
1092
|
+
createdAt: now,
|
|
1093
|
+
updatedAt: now
|
|
1094
|
+
};
|
|
1095
|
+
this.terms.set(id, item);
|
|
1096
|
+
return JSON.parse(JSON.stringify(item));
|
|
1097
|
+
}
|
|
1098
|
+
async getTermById(id) {
|
|
1099
|
+
const term = this.terms.get(id);
|
|
1100
|
+
return term ? JSON.parse(JSON.stringify(term)) : null;
|
|
1101
|
+
}
|
|
1102
|
+
async getTermBySlug(taxonomy, slug) {
|
|
1103
|
+
for (const term of this.terms.values()) if (term.taxonomy === taxonomy && term.slug === slug) return JSON.parse(JSON.stringify(term));
|
|
1104
|
+
return null;
|
|
1105
|
+
}
|
|
1106
|
+
async getTerms(taxonomy, options) {
|
|
1107
|
+
const list = [];
|
|
1108
|
+
for (const term of this.terms.values()) {
|
|
1109
|
+
if (term.taxonomy !== taxonomy) continue;
|
|
1110
|
+
if (options?.parentId !== void 0 && term.parentId !== options.parentId) continue;
|
|
1111
|
+
list.push(term);
|
|
1112
|
+
}
|
|
1113
|
+
return JSON.parse(JSON.stringify(list));
|
|
1114
|
+
}
|
|
1115
|
+
async updateTerm(id, updates) {
|
|
1116
|
+
const term = this.terms.get(id);
|
|
1117
|
+
if (!term) return null;
|
|
1118
|
+
const updated = {
|
|
1119
|
+
...term,
|
|
1120
|
+
...updates,
|
|
1121
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1122
|
+
};
|
|
1123
|
+
this.terms.set(id, updated);
|
|
1124
|
+
return JSON.parse(JSON.stringify(updated));
|
|
1125
|
+
}
|
|
1126
|
+
async deleteTerm(id) {
|
|
1127
|
+
const exists = this.terms.delete(id);
|
|
1128
|
+
if (exists) for (const set of this.contentTerms.values()) set.delete(id);
|
|
1129
|
+
return exists;
|
|
1130
|
+
}
|
|
1131
|
+
async assignTermsToContent(contentId, termIds) {
|
|
1132
|
+
let set = this.contentTerms.get(contentId);
|
|
1133
|
+
if (!set) {
|
|
1134
|
+
set = /* @__PURE__ */ new Set();
|
|
1135
|
+
this.contentTerms.set(contentId, set);
|
|
1136
|
+
}
|
|
1137
|
+
for (const id of termIds) set.add(id);
|
|
1138
|
+
this.recalculateTermCounts();
|
|
1139
|
+
}
|
|
1140
|
+
async getContentTerms(contentId, taxonomy) {
|
|
1141
|
+
const set = this.contentTerms.get(contentId);
|
|
1142
|
+
if (!set) return [];
|
|
1143
|
+
const list = [];
|
|
1144
|
+
for (const termId of set) {
|
|
1145
|
+
const term = this.terms.get(termId);
|
|
1146
|
+
if (term) {
|
|
1147
|
+
if (!taxonomy || term.taxonomy === taxonomy) list.push(term);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
return JSON.parse(JSON.stringify(list));
|
|
1151
|
+
}
|
|
1152
|
+
async removeTermsFromContent(contentId, termIds) {
|
|
1153
|
+
const set = this.contentTerms.get(contentId);
|
|
1154
|
+
if (!set) return;
|
|
1155
|
+
for (const id of termIds) set.delete(id);
|
|
1156
|
+
this.recalculateTermCounts();
|
|
1157
|
+
}
|
|
1158
|
+
recalculateTermCounts() {
|
|
1159
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1160
|
+
for (const set of this.contentTerms.values()) for (const termId of set) counts.set(termId, (counts.get(termId) || 0) + 1);
|
|
1161
|
+
for (const [id, term] of this.terms.entries()) term.count = counts.get(id) || 0;
|
|
1162
|
+
}
|
|
1163
|
+
async createMedia(item) {
|
|
1164
|
+
const id = generateId("med_");
|
|
1165
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1166
|
+
const mediaItem = {
|
|
1167
|
+
...item,
|
|
1168
|
+
id,
|
|
1169
|
+
createdAt: now,
|
|
1170
|
+
updatedAt: now
|
|
1171
|
+
};
|
|
1172
|
+
this.media.set(id, mediaItem);
|
|
1173
|
+
return JSON.parse(JSON.stringify(mediaItem));
|
|
1174
|
+
}
|
|
1175
|
+
async getMedia(id) {
|
|
1176
|
+
const m = this.media.get(id);
|
|
1177
|
+
return m ? JSON.parse(JSON.stringify(m)) : null;
|
|
1178
|
+
}
|
|
1179
|
+
async findMedia(options = {}) {
|
|
1180
|
+
const list = [];
|
|
1181
|
+
for (const item of this.media.values()) {
|
|
1182
|
+
if (options.mimeType && !item.mimeType.startsWith(options.mimeType)) continue;
|
|
1183
|
+
if (options.authorId && item.authorId !== options.authorId) continue;
|
|
1184
|
+
if (options.search) {
|
|
1185
|
+
const q = options.search.toLowerCase();
|
|
1186
|
+
if (!item.filename.toLowerCase().includes(q) && !item.originalName.toLowerCase().includes(q) && !item.altText?.toLowerCase().includes(q)) continue;
|
|
1187
|
+
}
|
|
1188
|
+
list.push(item);
|
|
1189
|
+
}
|
|
1190
|
+
list.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
1191
|
+
const total = list.length;
|
|
1192
|
+
const offset = options.offset ?? 0;
|
|
1193
|
+
const limit = options.limit ?? 20;
|
|
1194
|
+
return {
|
|
1195
|
+
items: JSON.parse(JSON.stringify(list.slice(offset, offset + limit))),
|
|
1196
|
+
total,
|
|
1197
|
+
limit,
|
|
1198
|
+
offset,
|
|
1199
|
+
hasMore: offset + limit < total
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
async updateMedia(id, updates) {
|
|
1203
|
+
const m = this.media.get(id);
|
|
1204
|
+
if (!m) return null;
|
|
1205
|
+
const updated = {
|
|
1206
|
+
...m,
|
|
1207
|
+
...updates,
|
|
1208
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1209
|
+
};
|
|
1210
|
+
this.media.set(id, updated);
|
|
1211
|
+
return JSON.parse(JSON.stringify(updated));
|
|
1212
|
+
}
|
|
1213
|
+
async deleteMedia(id) {
|
|
1214
|
+
return this.media.delete(id);
|
|
1215
|
+
}
|
|
1216
|
+
async getOption(key) {
|
|
1217
|
+
const opt = this.options.get(key);
|
|
1218
|
+
return opt ? JSON.parse(JSON.stringify(opt)) : null;
|
|
1219
|
+
}
|
|
1220
|
+
async setOption(item) {
|
|
1221
|
+
this.options.set(item.key, JSON.parse(JSON.stringify(item)));
|
|
1222
|
+
}
|
|
1223
|
+
async deleteOption(key) {
|
|
1224
|
+
return this.options.delete(key);
|
|
1225
|
+
}
|
|
1226
|
+
async getOptions(namespace) {
|
|
1227
|
+
const list = [];
|
|
1228
|
+
for (const opt of this.options.values()) {
|
|
1229
|
+
if (namespace && opt.namespace !== namespace) continue;
|
|
1230
|
+
list.push(opt);
|
|
1231
|
+
}
|
|
1232
|
+
return JSON.parse(JSON.stringify(list));
|
|
1233
|
+
}
|
|
1234
|
+
};
|
|
1235
|
+
//#endregion
|
|
1236
|
+
//#region src/core/engine.ts
|
|
1237
|
+
var CMSEngine = class {
|
|
1238
|
+
config;
|
|
1239
|
+
hooks;
|
|
1240
|
+
storage;
|
|
1241
|
+
options;
|
|
1242
|
+
taxonomies;
|
|
1243
|
+
media;
|
|
1244
|
+
rbac;
|
|
1245
|
+
revisions;
|
|
1246
|
+
lifecycle;
|
|
1247
|
+
collections = /* @__PURE__ */ new Map();
|
|
1248
|
+
constructor(config = {}, storage, hooks) {
|
|
1249
|
+
this.config = normalizeConfig(config);
|
|
1250
|
+
this.hooks = hooks ?? new HooksManager();
|
|
1251
|
+
this.storage = storage ?? new MemoryStorageAdapter();
|
|
1252
|
+
this.options = new OptionsManager(this.storage, this.hooks);
|
|
1253
|
+
this.taxonomies = new TaxonomyManager(this.storage, this.hooks);
|
|
1254
|
+
this.media = new MediaManager(this.storage, this.hooks);
|
|
1255
|
+
this.rbac = new RBACManager();
|
|
1256
|
+
this.revisions = new RevisionManager(this.storage, this.hooks);
|
|
1257
|
+
this.lifecycle = new ContentLifecycle(this.storage, this.hooks);
|
|
1258
|
+
for (const coll of this.config.collections) this.collections.set(coll.slug, coll);
|
|
1259
|
+
if (this.config.taxonomies) for (const tax of this.config.taxonomies) this.taxonomies.registerTaxonomy(tax);
|
|
1260
|
+
}
|
|
1261
|
+
/**
|
|
1262
|
+
* Initialize storage and trigger bootstrap hooks.
|
|
1263
|
+
*/
|
|
1264
|
+
async init() {
|
|
1265
|
+
await this.storage.init();
|
|
1266
|
+
if (this.config.site) {
|
|
1267
|
+
if (!await this.options.has("site_name")) await this.options.set("site_name", this.config.site.name);
|
|
1268
|
+
}
|
|
1269
|
+
await this.hooks.doAction("cms.init", this);
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* Close storage connections.
|
|
1273
|
+
*/
|
|
1274
|
+
async close() {
|
|
1275
|
+
await this.storage.close();
|
|
1276
|
+
await this.hooks.doAction("cms.close", this);
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* Get all registered collection configurations.
|
|
1280
|
+
*/
|
|
1281
|
+
getCollections() {
|
|
1282
|
+
return Array.from(this.collections.values());
|
|
1283
|
+
}
|
|
1284
|
+
/**
|
|
1285
|
+
* Get a single collection configuration.
|
|
1286
|
+
*/
|
|
1287
|
+
getCollectionConfig(slug) {
|
|
1288
|
+
return this.collections.get(slug);
|
|
1289
|
+
}
|
|
1290
|
+
/**
|
|
1291
|
+
* Access high-level CRUD service for a collection.
|
|
1292
|
+
*/
|
|
1293
|
+
collection(slug) {
|
|
1294
|
+
const collConfig = this.collections.get(slug);
|
|
1295
|
+
if (!collConfig) throw new Error(`[CMSEngine] Collection '${slug}' is not registered.`);
|
|
1296
|
+
const self = this;
|
|
1297
|
+
return {
|
|
1298
|
+
config: collConfig,
|
|
1299
|
+
async create(input, authorId = null) {
|
|
1300
|
+
const inputData = { ...input.data || {} };
|
|
1301
|
+
if (input.title !== void 0) inputData.title = input.title;
|
|
1302
|
+
if (input.slug !== void 0) inputData.slug = input.slug;
|
|
1303
|
+
if (input.status !== void 0) inputData.status = input.status;
|
|
1304
|
+
const { data: normalizedData, errors } = validateAndNormalizeData(collConfig.fields, inputData);
|
|
1305
|
+
if (Object.keys(errors).length > 0) throw new Error(`[CMSEngine] Validation failed for collection '${slug}': ${JSON.stringify(errors)}`);
|
|
1306
|
+
const title = input.title ?? normalizedData.title ?? "";
|
|
1307
|
+
const finalSlug = await resolveUniqueSlug(input.slug ? slugify(input.slug) : slugify(title) || "item", async (s) => {
|
|
1308
|
+
return await self.storage.getContentBySlug(slug, s) !== null;
|
|
1309
|
+
});
|
|
1310
|
+
const status = input.status ?? (collConfig.draftable ? "draft" : "published");
|
|
1311
|
+
const nowIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
1312
|
+
const item = await self.storage.createContent({
|
|
1313
|
+
collection: slug,
|
|
1314
|
+
slug: finalSlug,
|
|
1315
|
+
title,
|
|
1316
|
+
status,
|
|
1317
|
+
parentId: collConfig.hierarchical ? input.parentId ?? null : null,
|
|
1318
|
+
authorId,
|
|
1319
|
+
publishedAt: status === "published" ? nowIso : null,
|
|
1320
|
+
scheduledAt: status === "scheduled" ? input.scheduledAt ?? null : null,
|
|
1321
|
+
data: normalizedData,
|
|
1322
|
+
terms: input.terms
|
|
1323
|
+
});
|
|
1324
|
+
if (input.terms) {
|
|
1325
|
+
const allTermIds = Object.values(input.terms).flat();
|
|
1326
|
+
if (allTermIds.length > 0) await self.taxonomies.assignTerms(item.id, allTermIds);
|
|
1327
|
+
}
|
|
1328
|
+
if (collConfig.revisions) await self.revisions.createRevision(item, authorId, "Initial creation");
|
|
1329
|
+
await self.hooks.doAction("cms.content_created", item);
|
|
1330
|
+
await self.hooks.doAction(`cms.${slug}_created`, item);
|
|
1331
|
+
return item;
|
|
1332
|
+
},
|
|
1333
|
+
async findById(id) {
|
|
1334
|
+
return self.storage.getContent(slug, id);
|
|
1335
|
+
},
|
|
1336
|
+
async findBySlug(contentSlug) {
|
|
1337
|
+
return self.storage.getContentBySlug(slug, contentSlug);
|
|
1338
|
+
},
|
|
1339
|
+
async find(options = {}) {
|
|
1340
|
+
return self.storage.findContent(slug, options);
|
|
1341
|
+
},
|
|
1342
|
+
async update(id, input, authorId = null, revisionNote) {
|
|
1343
|
+
const existing = await self.storage.getContent(slug, id);
|
|
1344
|
+
if (!existing) return null;
|
|
1345
|
+
const mergedData = {
|
|
1346
|
+
...existing.data,
|
|
1347
|
+
...input.data || {}
|
|
1348
|
+
};
|
|
1349
|
+
if (input.title !== void 0) mergedData.title = input.title;
|
|
1350
|
+
else if (existing.title !== void 0 && mergedData.title === void 0) mergedData.title = existing.title;
|
|
1351
|
+
if (input.slug !== void 0) mergedData.slug = input.slug;
|
|
1352
|
+
else if (existing.slug !== void 0 && mergedData.slug === void 0) mergedData.slug = existing.slug;
|
|
1353
|
+
if (input.status !== void 0) mergedData.status = input.status;
|
|
1354
|
+
else if (existing.status !== void 0 && mergedData.status === void 0) mergedData.status = existing.status;
|
|
1355
|
+
const { data: normalizedData, errors } = validateAndNormalizeData(collConfig.fields, mergedData);
|
|
1356
|
+
if (Object.keys(errors).length > 0) throw new Error(`[CMSEngine] Validation failed updating '${slug}': ${JSON.stringify(errors)}`);
|
|
1357
|
+
let updatedSlug = existing.slug;
|
|
1358
|
+
if (input.slug && input.slug !== existing.slug) updatedSlug = await resolveUniqueSlug(input.slug, async (s) => {
|
|
1359
|
+
const check = await self.storage.getContentBySlug(slug, s);
|
|
1360
|
+
return check !== null && check.id !== id;
|
|
1361
|
+
}, id);
|
|
1362
|
+
const nextStatus = input.status ?? existing.status;
|
|
1363
|
+
const nowIso = (/* @__PURE__ */ new Date()).toISOString();
|
|
1364
|
+
let publishedAt = existing.publishedAt;
|
|
1365
|
+
if (nextStatus === "published" && !publishedAt) publishedAt = nowIso;
|
|
1366
|
+
const updated = await self.storage.updateContent(slug, id, {
|
|
1367
|
+
title: input.title !== void 0 ? input.title : existing.title,
|
|
1368
|
+
slug: updatedSlug,
|
|
1369
|
+
status: nextStatus,
|
|
1370
|
+
parentId: collConfig.hierarchical ? input.parentId !== void 0 ? input.parentId : existing.parentId : null,
|
|
1371
|
+
scheduledAt: input.scheduledAt !== void 0 ? input.scheduledAt : existing.scheduledAt,
|
|
1372
|
+
publishedAt,
|
|
1373
|
+
authorId: authorId ?? existing.authorId,
|
|
1374
|
+
data: normalizedData,
|
|
1375
|
+
terms: input.terms !== void 0 ? input.terms : existing.terms
|
|
1376
|
+
});
|
|
1377
|
+
if (updated) {
|
|
1378
|
+
if (input.terms) {
|
|
1379
|
+
const allTermIds = Object.values(input.terms).flat();
|
|
1380
|
+
await self.taxonomies.assignTerms(updated.id, allTermIds);
|
|
1381
|
+
}
|
|
1382
|
+
if (collConfig.revisions) await self.revisions.createRevision(updated, authorId, revisionNote ?? `Updated version ${updated.version}`);
|
|
1383
|
+
await self.hooks.doAction("cms.content_updated", updated);
|
|
1384
|
+
await self.hooks.doAction(`cms.${slug}_updated`, updated);
|
|
1385
|
+
}
|
|
1386
|
+
return updated;
|
|
1387
|
+
},
|
|
1388
|
+
async delete(id) {
|
|
1389
|
+
const item = await self.storage.getContent(slug, id);
|
|
1390
|
+
if (!item) return false;
|
|
1391
|
+
const deleted = await self.storage.deleteContent(slug, id);
|
|
1392
|
+
if (deleted) {
|
|
1393
|
+
await self.hooks.doAction("cms.content_deleted", item);
|
|
1394
|
+
await self.hooks.doAction(`cms.${slug}_deleted`, item);
|
|
1395
|
+
}
|
|
1396
|
+
return deleted;
|
|
1397
|
+
},
|
|
1398
|
+
async publish(id, authorId = null) {
|
|
1399
|
+
return this.update(id, { status: "published" }, authorId, "Published content");
|
|
1400
|
+
},
|
|
1401
|
+
async schedule(id, scheduledAt, authorId = null) {
|
|
1402
|
+
const dateStr = scheduledAt instanceof Date ? scheduledAt.toISOString() : scheduledAt;
|
|
1403
|
+
return this.update(id, {
|
|
1404
|
+
status: "scheduled",
|
|
1405
|
+
scheduledAt: dateStr
|
|
1406
|
+
}, authorId, `Scheduled for publish at ${dateStr}`);
|
|
1407
|
+
},
|
|
1408
|
+
async getRevisions(id) {
|
|
1409
|
+
return self.revisions.getRevisions(id);
|
|
1410
|
+
},
|
|
1411
|
+
async getRevision(revisionId) {
|
|
1412
|
+
return self.revisions.getRevision(revisionId);
|
|
1413
|
+
},
|
|
1414
|
+
async restoreRevision(id, revisionId, authorId) {
|
|
1415
|
+
return self.revisions.restoreRevision(slug, revisionId, authorId);
|
|
1416
|
+
},
|
|
1417
|
+
getDiff(oldVersion, newVersion) {
|
|
1418
|
+
return self.revisions.computeDiff(oldVersion, newVersion);
|
|
1419
|
+
}
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
/**
|
|
1423
|
+
* Run scheduled content publisher for all registered collections.
|
|
1424
|
+
*/
|
|
1425
|
+
async processScheduledContent(now = /* @__PURE__ */ new Date()) {
|
|
1426
|
+
const published = [];
|
|
1427
|
+
for (const coll of this.collections.values()) {
|
|
1428
|
+
const res = await this.lifecycle.processScheduledContent(coll.slug, now);
|
|
1429
|
+
published.push(...res);
|
|
1430
|
+
}
|
|
1431
|
+
return published;
|
|
1432
|
+
}
|
|
1433
|
+
};
|
|
1434
|
+
/**
|
|
1435
|
+
* Factory helper to create a configured CMSEngine instance.
|
|
1436
|
+
*/
|
|
1437
|
+
function createCMSEngine(config, storage, hooks) {
|
|
1438
|
+
return new CMSEngine(config, storage, hooks);
|
|
1439
|
+
}
|
|
1440
|
+
//#endregion
|
|
1441
|
+
//#region src/api/router.ts
|
|
1442
|
+
function jsonResponse(data, status = 200, headers = {}) {
|
|
1443
|
+
return new Response(JSON.stringify(data), {
|
|
1444
|
+
status,
|
|
1445
|
+
headers: {
|
|
1446
|
+
"Content-Type": "application/json",
|
|
1447
|
+
"Access-Control-Allow-Origin": "*",
|
|
1448
|
+
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
|
1449
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
1450
|
+
...headers
|
|
1451
|
+
}
|
|
1452
|
+
});
|
|
1453
|
+
}
|
|
1454
|
+
var CMSRouter = class {
|
|
1455
|
+
engine;
|
|
1456
|
+
constructor(engine) {
|
|
1457
|
+
this.engine = engine;
|
|
1458
|
+
}
|
|
1459
|
+
/**
|
|
1460
|
+
* Universal Web Standards request handler.
|
|
1461
|
+
*/
|
|
1462
|
+
async handle(request) {
|
|
1463
|
+
const url = new URL(request.url);
|
|
1464
|
+
const pathname = url.pathname;
|
|
1465
|
+
const method = request.method.toUpperCase();
|
|
1466
|
+
if (method === "OPTIONS") return new Response(null, {
|
|
1467
|
+
status: 204,
|
|
1468
|
+
headers: {
|
|
1469
|
+
"Access-Control-Allow-Origin": "*",
|
|
1470
|
+
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
|
1471
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization"
|
|
1472
|
+
}
|
|
1473
|
+
});
|
|
1474
|
+
try {
|
|
1475
|
+
if (pathname === "/api/cron/scheduled" && method === "POST") {
|
|
1476
|
+
const published = await this.engine.processScheduledContent();
|
|
1477
|
+
return jsonResponse({
|
|
1478
|
+
success: true,
|
|
1479
|
+
publishedCount: published.length,
|
|
1480
|
+
items: published
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
if (pathname.startsWith("/api/options")) return this.handleOptions(pathname, method, url, request);
|
|
1484
|
+
if (pathname.startsWith("/api/taxonomies")) return this.handleTaxonomies(pathname, method, url, request);
|
|
1485
|
+
if (pathname.startsWith("/api/media")) return this.handleMedia(pathname, method, url, request);
|
|
1486
|
+
if (pathname.startsWith("/api/content")) return this.handleContent(pathname, method, url, request);
|
|
1487
|
+
return jsonResponse({
|
|
1488
|
+
error: "Endpoint not found",
|
|
1489
|
+
path: pathname
|
|
1490
|
+
}, 404);
|
|
1491
|
+
} catch (error) {
|
|
1492
|
+
return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 500);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
async handleContent(pathname, method, url, request) {
|
|
1496
|
+
const parts = pathname.replace(/^\/api\/content\/?/, "").split("/").filter(Boolean);
|
|
1497
|
+
const collectionSlug = parts[0];
|
|
1498
|
+
if (!collectionSlug) return jsonResponse({ collections: this.engine.getCollections().map((c) => ({
|
|
1499
|
+
slug: c.slug,
|
|
1500
|
+
label: c.label,
|
|
1501
|
+
hierarchical: c.hierarchical
|
|
1502
|
+
})) }, 200);
|
|
1503
|
+
const coll = this.engine.collection(collectionSlug);
|
|
1504
|
+
if (parts.length === 1 && method === "GET") {
|
|
1505
|
+
const status = url.searchParams.get("status") || void 0;
|
|
1506
|
+
const search = url.searchParams.get("search") || void 0;
|
|
1507
|
+
const limit = url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : void 0;
|
|
1508
|
+
const offset = url.searchParams.has("offset") ? Number(url.searchParams.get("offset")) : void 0;
|
|
1509
|
+
const termIds = url.searchParams.has("termIds") ? url.searchParams.get("termIds").split(",") : void 0;
|
|
1510
|
+
return jsonResponse(await coll.find({
|
|
1511
|
+
status,
|
|
1512
|
+
search,
|
|
1513
|
+
limit,
|
|
1514
|
+
offset,
|
|
1515
|
+
termIds
|
|
1516
|
+
}));
|
|
1517
|
+
}
|
|
1518
|
+
if (parts.length === 1 && method === "POST") {
|
|
1519
|
+
const body = await request.json();
|
|
1520
|
+
return jsonResponse(await coll.create(body), 201);
|
|
1521
|
+
}
|
|
1522
|
+
const idOrSlug = parts[1];
|
|
1523
|
+
if (parts.length === 3 && parts[2] === "revisions" && method === "GET") return jsonResponse(await coll.getRevisions(idOrSlug));
|
|
1524
|
+
if (parts.length === 4 && parts[2] === "restore" && method === "POST") {
|
|
1525
|
+
const revisionId = parts[3];
|
|
1526
|
+
const restored = await coll.restoreRevision(idOrSlug, revisionId);
|
|
1527
|
+
if (!restored) return jsonResponse({ error: "Revision or content item not found" }, 404);
|
|
1528
|
+
return jsonResponse(restored);
|
|
1529
|
+
}
|
|
1530
|
+
if (parts.length === 2 && method === "GET") {
|
|
1531
|
+
const bySlug = url.searchParams.get("by") === "slug";
|
|
1532
|
+
let item = bySlug ? await coll.findBySlug(idOrSlug) : await coll.findById(idOrSlug);
|
|
1533
|
+
if (!item && !bySlug) item = await coll.findBySlug(idOrSlug);
|
|
1534
|
+
if (!item) return jsonResponse({ error: "Content item not found" }, 404);
|
|
1535
|
+
return jsonResponse(item);
|
|
1536
|
+
}
|
|
1537
|
+
if (parts.length === 2 && method === "PUT") {
|
|
1538
|
+
const body = await request.json();
|
|
1539
|
+
const updated = await coll.update(idOrSlug, body);
|
|
1540
|
+
if (!updated) return jsonResponse({ error: "Content item not found" }, 404);
|
|
1541
|
+
return jsonResponse(updated);
|
|
1542
|
+
}
|
|
1543
|
+
if (parts.length === 2 && method === "DELETE") {
|
|
1544
|
+
if (!await coll.delete(idOrSlug)) return jsonResponse({ error: "Content item not found" }, 404);
|
|
1545
|
+
return jsonResponse({
|
|
1546
|
+
success: true,
|
|
1547
|
+
id: idOrSlug
|
|
1548
|
+
});
|
|
1549
|
+
}
|
|
1550
|
+
return jsonResponse({ error: "Method not allowed" }, 405);
|
|
1551
|
+
}
|
|
1552
|
+
async handleTaxonomies(pathname, method, url, request) {
|
|
1553
|
+
const parts = pathname.replace(/^\/api\/taxonomies\/?/, "").split("/").filter(Boolean);
|
|
1554
|
+
if (parts.length === 0 && method === "GET") return jsonResponse(this.engine.taxonomies.getTaxonomies());
|
|
1555
|
+
const taxonomySlug = parts[0];
|
|
1556
|
+
if (parts.length === 2 && parts[1] === "terms" && method === "GET") {
|
|
1557
|
+
if (url.searchParams.get("tree") === "true") return jsonResponse(await this.engine.taxonomies.getTermTree(taxonomySlug));
|
|
1558
|
+
return jsonResponse(await this.engine.taxonomies.getTerms(taxonomySlug));
|
|
1559
|
+
}
|
|
1560
|
+
if (parts.length === 2 && parts[1] === "terms" && method === "POST") {
|
|
1561
|
+
const body = await request.json();
|
|
1562
|
+
return jsonResponse(await this.engine.taxonomies.createTerm(taxonomySlug, body), 201);
|
|
1563
|
+
}
|
|
1564
|
+
if (parts.length === 3 && parts[1] === "terms" && method === "GET") {
|
|
1565
|
+
const term = await this.engine.taxonomies.getTermById(parts[2]);
|
|
1566
|
+
if (!term) return jsonResponse({ error: "Term not found" }, 404);
|
|
1567
|
+
return jsonResponse(term);
|
|
1568
|
+
}
|
|
1569
|
+
if (parts.length === 3 && parts[1] === "terms" && method === "PUT") {
|
|
1570
|
+
const body = await request.json();
|
|
1571
|
+
const updated = await this.engine.taxonomies.updateTerm(parts[2], body);
|
|
1572
|
+
if (!updated) return jsonResponse({ error: "Term not found" }, 404);
|
|
1573
|
+
return jsonResponse(updated);
|
|
1574
|
+
}
|
|
1575
|
+
if (parts.length === 3 && parts[1] === "terms" && method === "DELETE") {
|
|
1576
|
+
if (!await this.engine.taxonomies.deleteTerm(parts[2])) return jsonResponse({ error: "Term not found" }, 404);
|
|
1577
|
+
return jsonResponse({
|
|
1578
|
+
success: true,
|
|
1579
|
+
id: parts[2]
|
|
1580
|
+
});
|
|
1581
|
+
}
|
|
1582
|
+
return jsonResponse({ error: "Endpoint not found" }, 404);
|
|
1583
|
+
}
|
|
1584
|
+
async handleMedia(pathname, method, url, request) {
|
|
1585
|
+
const parts = pathname.replace(/^\/api\/media\/?/, "").split("/").filter(Boolean);
|
|
1586
|
+
if (parts.length === 0 && method === "GET") {
|
|
1587
|
+
const search = url.searchParams.get("search") || void 0;
|
|
1588
|
+
const mimeType = url.searchParams.get("mimeType") || void 0;
|
|
1589
|
+
const limit = url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : void 0;
|
|
1590
|
+
const offset = url.searchParams.has("offset") ? Number(url.searchParams.get("offset")) : void 0;
|
|
1591
|
+
return jsonResponse(await this.engine.media.find({
|
|
1592
|
+
search,
|
|
1593
|
+
mimeType,
|
|
1594
|
+
limit,
|
|
1595
|
+
offset
|
|
1596
|
+
}));
|
|
1597
|
+
}
|
|
1598
|
+
if (parts.length === 0 && method === "POST") {
|
|
1599
|
+
const body = await request.json();
|
|
1600
|
+
return jsonResponse(await this.engine.media.upload(body), 201);
|
|
1601
|
+
}
|
|
1602
|
+
const id = parts[0];
|
|
1603
|
+
if (parts.length === 1 && method === "GET") {
|
|
1604
|
+
const media = await this.engine.media.get(id);
|
|
1605
|
+
if (!media) return jsonResponse({ error: "Media not found" }, 404);
|
|
1606
|
+
return jsonResponse(media);
|
|
1607
|
+
}
|
|
1608
|
+
if (parts.length === 1 && method === "PUT") {
|
|
1609
|
+
const body = await request.json();
|
|
1610
|
+
const updated = await this.engine.media.updateMetadata(id, body);
|
|
1611
|
+
if (!updated) return jsonResponse({ error: "Media not found" }, 404);
|
|
1612
|
+
return jsonResponse(updated);
|
|
1613
|
+
}
|
|
1614
|
+
if (parts.length === 1 && method === "DELETE") {
|
|
1615
|
+
if (!await this.engine.media.delete(id)) return jsonResponse({ error: "Media not found" }, 404);
|
|
1616
|
+
return jsonResponse({
|
|
1617
|
+
success: true,
|
|
1618
|
+
id
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
return jsonResponse({ error: "Method not allowed" }, 405);
|
|
1622
|
+
}
|
|
1623
|
+
async handleOptions(pathname, method, url, request) {
|
|
1624
|
+
const key = pathname.replace(/^\/api\/options\/?/, "");
|
|
1625
|
+
if (!key && method === "GET") {
|
|
1626
|
+
const namespace = url.searchParams.get("namespace") || void 0;
|
|
1627
|
+
return jsonResponse(await this.engine.options.getAll(namespace));
|
|
1628
|
+
}
|
|
1629
|
+
if (key && method === "GET") {
|
|
1630
|
+
const val = await this.engine.options.get(key);
|
|
1631
|
+
if (val === void 0) return jsonResponse({ error: "Option not found" }, 404);
|
|
1632
|
+
return jsonResponse({
|
|
1633
|
+
key,
|
|
1634
|
+
value: val
|
|
1635
|
+
});
|
|
1636
|
+
}
|
|
1637
|
+
if (key && method === "PUT") {
|
|
1638
|
+
const body = await request.json();
|
|
1639
|
+
return jsonResponse(await this.engine.options.set(key, body.value, {
|
|
1640
|
+
autoload: body.autoload,
|
|
1641
|
+
namespace: body.namespace
|
|
1642
|
+
}));
|
|
1643
|
+
}
|
|
1644
|
+
if (key && method === "DELETE") {
|
|
1645
|
+
if (!await this.engine.options.delete(key)) return jsonResponse({ error: "Option not found" }, 404);
|
|
1646
|
+
return jsonResponse({
|
|
1647
|
+
success: true,
|
|
1648
|
+
key
|
|
1649
|
+
});
|
|
1650
|
+
}
|
|
1651
|
+
return jsonResponse({ error: "Method not allowed" }, 405);
|
|
1652
|
+
}
|
|
1653
|
+
};
|
|
1654
|
+
function createCMSRouter(engine) {
|
|
1655
|
+
return new CMSRouter(engine);
|
|
1656
|
+
}
|
|
1657
|
+
//#endregion
|
|
1658
|
+
//#region src/client/cms-client.ts
|
|
1659
|
+
var CMSClient = class {
|
|
1660
|
+
engine;
|
|
1661
|
+
baseUrl;
|
|
1662
|
+
fetchFn;
|
|
1663
|
+
headers;
|
|
1664
|
+
constructor(options) {
|
|
1665
|
+
this.engine = options.engine;
|
|
1666
|
+
this.baseUrl = options.baseUrl?.replace(/\/+$/, "");
|
|
1667
|
+
this.fetchFn = options.fetch ?? globalThis.fetch?.bind(globalThis);
|
|
1668
|
+
this.headers = {
|
|
1669
|
+
"Content-Type": "application/json",
|
|
1670
|
+
...options.headers || {}
|
|
1671
|
+
};
|
|
1672
|
+
}
|
|
1673
|
+
collection(slug) {
|
|
1674
|
+
const self = this;
|
|
1675
|
+
if (this.engine) {
|
|
1676
|
+
const coll = this.engine.collection(slug);
|
|
1677
|
+
return {
|
|
1678
|
+
find: (opts) => coll.find(opts),
|
|
1679
|
+
findById: (id) => coll.findById(id),
|
|
1680
|
+
findBySlug: (s) => coll.findBySlug(s),
|
|
1681
|
+
create: (data) => coll.create(data),
|
|
1682
|
+
update: (id, data) => coll.update(id, data),
|
|
1683
|
+
delete: (id) => coll.delete(id)
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
return {
|
|
1687
|
+
async find(options = {}) {
|
|
1688
|
+
const params = new URLSearchParams();
|
|
1689
|
+
if (options.status) {
|
|
1690
|
+
const statuses = Array.isArray(options.status) ? options.status.join(",") : options.status;
|
|
1691
|
+
params.set("status", statuses);
|
|
1692
|
+
}
|
|
1693
|
+
if (options.search) params.set("search", options.search);
|
|
1694
|
+
if (options.limit !== void 0) params.set("limit", String(options.limit));
|
|
1695
|
+
if (options.offset !== void 0) params.set("offset", String(options.offset));
|
|
1696
|
+
if (options.termIds && options.termIds.length > 0) params.set("termIds", options.termIds.join(","));
|
|
1697
|
+
const queryStr = params.toString() ? `?${params.toString()}` : "";
|
|
1698
|
+
return self.request(`/api/content/${slug}${queryStr}`);
|
|
1699
|
+
},
|
|
1700
|
+
async findById(id) {
|
|
1701
|
+
return self.request(`/api/content/${slug}/${id}`);
|
|
1702
|
+
},
|
|
1703
|
+
async findBySlug(contentSlug) {
|
|
1704
|
+
return self.request(`/api/content/${slug}/${contentSlug}?by=slug`);
|
|
1705
|
+
},
|
|
1706
|
+
async create(data) {
|
|
1707
|
+
return self.request(`/api/content/${slug}`, {
|
|
1708
|
+
method: "POST",
|
|
1709
|
+
body: JSON.stringify(data)
|
|
1710
|
+
});
|
|
1711
|
+
},
|
|
1712
|
+
async update(id, data) {
|
|
1713
|
+
return self.request(`/api/content/${slug}/${id}`, {
|
|
1714
|
+
method: "PUT",
|
|
1715
|
+
body: JSON.stringify(data)
|
|
1716
|
+
});
|
|
1717
|
+
},
|
|
1718
|
+
async delete(id) {
|
|
1719
|
+
const res = await self.request(`/api/content/${slug}/${id}`, { method: "DELETE" });
|
|
1720
|
+
return Boolean(res?.success);
|
|
1721
|
+
}
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1724
|
+
taxonomies = {
|
|
1725
|
+
getTerms: async (taxonomy) => {
|
|
1726
|
+
if (this.engine) return this.engine.taxonomies.getTerms(taxonomy);
|
|
1727
|
+
return this.request(`/api/taxonomies/${taxonomy}/terms`);
|
|
1728
|
+
},
|
|
1729
|
+
getTree: async (taxonomy) => {
|
|
1730
|
+
if (this.engine) return this.engine.taxonomies.getTermTree(taxonomy);
|
|
1731
|
+
return this.request(`/api/taxonomies/${taxonomy}/terms?tree=true`);
|
|
1732
|
+
}
|
|
1733
|
+
};
|
|
1734
|
+
media = {
|
|
1735
|
+
find: async (options) => {
|
|
1736
|
+
if (this.engine) return this.engine.media.find(options);
|
|
1737
|
+
const params = new URLSearchParams();
|
|
1738
|
+
if (options?.search) params.set("search", options.search);
|
|
1739
|
+
if (options?.limit) params.set("limit", String(options.limit));
|
|
1740
|
+
const q = params.toString() ? `?${params.toString()}` : "";
|
|
1741
|
+
return this.request(`/api/media${q}`);
|
|
1742
|
+
},
|
|
1743
|
+
get: async (id) => {
|
|
1744
|
+
if (this.engine) return this.engine.media.get(id);
|
|
1745
|
+
return this.request(`/api/media/${id}`);
|
|
1746
|
+
}
|
|
1747
|
+
};
|
|
1748
|
+
options = { get: async (key, defaultValue) => {
|
|
1749
|
+
if (this.engine) return this.engine.options.get(key, defaultValue);
|
|
1750
|
+
try {
|
|
1751
|
+
return (await this.request(`/api/options/${key}`))?.value ?? defaultValue;
|
|
1752
|
+
} catch {
|
|
1753
|
+
return defaultValue;
|
|
1754
|
+
}
|
|
1755
|
+
} };
|
|
1756
|
+
async request(endpoint, init) {
|
|
1757
|
+
if (!this.baseUrl) throw new Error(`[CMSClient] baseUrl must be provided when connecting to a remote CMS API.`);
|
|
1758
|
+
const url = `${this.baseUrl}${endpoint}`;
|
|
1759
|
+
const res = await this.fetchFn(url, {
|
|
1760
|
+
...init,
|
|
1761
|
+
headers: {
|
|
1762
|
+
...this.headers,
|
|
1763
|
+
...init?.headers || {}
|
|
1764
|
+
}
|
|
1765
|
+
});
|
|
1766
|
+
if (!res.ok) {
|
|
1767
|
+
if (res.status === 404) return null;
|
|
1768
|
+
const body = await res.text();
|
|
1769
|
+
throw new Error(`[CMSClient] HTTP ${res.status}: ${body}`);
|
|
1770
|
+
}
|
|
1771
|
+
return res.json();
|
|
1772
|
+
}
|
|
1773
|
+
};
|
|
1774
|
+
function createCmsClient(options) {
|
|
1775
|
+
return new CMSClient(options);
|
|
1776
|
+
}
|
|
1777
|
+
//#endregion
|
|
1778
|
+
export { CMSClient, CMSEngine, CMSRouter, ContentLifecycle, DEFAULT_COLLECTIONS, DEFAULT_ROLE_CAPABILITIES, HooksManager, MediaManager, MemoryStorageAdapter, OptionsManager, RBACManager, RevisionManager, TaxonomyManager, VALID_STATUS_TRANSITIONS, collection, createCMSEngine, createCMSRouter, createCmsClient, defaultHooks, defineConfig, fields, normalizeConfig, resolveUniqueSlug, slugify, validateAndNormalizeData };
|
|
1779
|
+
|
|
1780
|
+
//# sourceMappingURL=index.mjs.map
|