@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/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