@azlib/cms 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -12,6 +12,7 @@ A framework-agnostic, modular Content Management System (CMS) runtime inspired b
12
12
  - 🗂️ **Hierarchical Taxonomies**: Nested category trees, flat tags, and custom taxonomy binding.
13
13
  - 🖼️ **Media & Asset Management**: MIME validation, file sanitization, metadata, and asset queries.
14
14
  - 🔒 **RBAC & Capability Matrix**: Built-in Administrator, Editor, Author, Contributor, and Subscriber roles with content ownership checks.
15
+ - 🔌 **Extensible Plugin System**: Package and distribute reusable plugins (`definePlugin`) with schema extensions, custom API routes, lifecycle hooks, and filter pipelines.
15
16
  - 🌐 **Universal Web Standard Router**: `Request` -> `Response` HTTP router ready for Next.js App Router, Remix, Vite, Astro, Express, or Cloudflare Workers.
16
17
  - 🚀 **Type-Safe Headless Client SDK**: Query content in-process or over HTTP with zero boilerplate (`createCmsClient`).
17
18
 
@@ -109,6 +110,69 @@ export async function POST(request: Request) {
109
110
 
110
111
  ---
111
112
 
113
+ ## Plugin System
114
+
115
+ Create modular, decoupled plugins that extend collections, inject custom fields, register custom Web Standard routes, and hook into lifecycle pipelines:
116
+
117
+ ```typescript
118
+ import { definePlugin, fields } from "@azlib/cms";
119
+
120
+ export const seoPlugin = definePlugin<{ defaultTitleSuffix?: string }>((options = {}) => ({
121
+ name: "seo-plugin",
122
+ version: "1.0.0",
123
+
124
+ // 1. Inject custom fields into existing collections
125
+ extendCollections: {
126
+ posts: [
127
+ fields.text({ name: "metaTitle", label: "Meta Title" }),
128
+ fields.text({ name: "metaDescription", label: "Meta Description" }),
129
+ ],
130
+ },
131
+
132
+ // 2. Programmatic setup for hooks and routes
133
+ setup({ hooks, registerRoute, engine }) {
134
+ // Intercept content before saving
135
+ hooks.addFilter("cms.before_create_input", (input: any) => {
136
+ if (input.title && options.defaultTitleSuffix && !input.data?.metaTitle) {
137
+ input.data = {
138
+ ...input.data,
139
+ metaTitle: `${input.title} | ${options.defaultTitleSuffix}`,
140
+ };
141
+ }
142
+ return input;
143
+ });
144
+
145
+ // Expose custom Web Standard API route
146
+ registerRoute("GET", "/api/seo/sitemap", async () => {
147
+ const posts = await engine.collection("posts").find({ status: "published" });
148
+ const urls = posts.items.map((p) => `https://example.com/posts/${p.slug}`);
149
+ return new Response(JSON.stringify({ urls }), {
150
+ headers: { "Content-Type": "application/json" },
151
+ });
152
+ });
153
+ },
154
+
155
+ // 3. Lifecycle callbacks
156
+ async onInit(engine) {
157
+ console.log("[SEO Plugin] Initialized");
158
+ },
159
+ }));
160
+ ```
161
+
162
+ Register plugins declaratively in `defineConfig` or dynamically via `cms.use()`:
163
+
164
+ ```typescript
165
+ // Declarative registration
166
+ export default defineConfig({
167
+ plugins: [seoPlugin({ defaultTitleSuffix: "Alex's Journal" })],
168
+ });
169
+
170
+ // Or dynamic registration
171
+ cms.use(seoPlugin());
172
+ ```
173
+
174
+ ---
175
+
112
176
  ## License
113
177
 
114
178
  MIT © Google / azlib
package/dist/index.cjs CHANGED
@@ -230,6 +230,7 @@ function normalizeConfig(config) {
230
230
  },
231
231
  collections,
232
232
  taxonomies: config.taxonomies ?? [],
233
+ plugins: config.plugins ?? [],
233
234
  admin: {
234
235
  route: config.admin?.route ?? "/admin",
235
236
  enableRegistration: config.admin?.enableRegistration ?? false,
@@ -1246,6 +1247,9 @@ var CMSEngine = class {
1246
1247
  revisions;
1247
1248
  lifecycle;
1248
1249
  collections = /* @__PURE__ */ new Map();
1250
+ plugins = /* @__PURE__ */ new Map();
1251
+ customRoutes = /* @__PURE__ */ new Map();
1252
+ pendingPluginSetups = [];
1249
1253
  constructor(config = {}, storage, hooks) {
1250
1254
  this.config = normalizeConfig(config);
1251
1255
  this.hooks = hooks ?? new HooksManager();
@@ -1258,21 +1262,114 @@ var CMSEngine = class {
1258
1262
  this.lifecycle = new ContentLifecycle(this.storage, this.hooks);
1259
1263
  for (const coll of this.config.collections) this.collections.set(coll.slug, coll);
1260
1264
  if (this.config.taxonomies) for (const tax of this.config.taxonomies) this.taxonomies.registerTaxonomy(tax);
1265
+ if (this.config.plugins) for (const plugin of this.config.plugins) this.use(plugin);
1266
+ }
1267
+ /**
1268
+ * Register a plugin with the CMS engine.
1269
+ */
1270
+ use(plugin) {
1271
+ if (this.plugins.has(plugin.name)) throw new Error(`[CMSEngine] Plugin '${plugin.name}' is already registered.`);
1272
+ this.plugins.set(plugin.name, plugin);
1273
+ if (plugin.collections) for (const coll of plugin.collections) this.registerCollection(coll);
1274
+ if (plugin.taxonomies) for (const tax of plugin.taxonomies) this.taxonomies.registerTaxonomy(tax);
1275
+ if (plugin.extendCollections) for (const [slug, fields] of Object.entries(plugin.extendCollections)) this.extendCollection(slug, fields);
1276
+ if (plugin.setup) {
1277
+ const context = {
1278
+ engine: this,
1279
+ hooks: this.hooks,
1280
+ storage: this.storage,
1281
+ options: this.options,
1282
+ taxonomies: this.taxonomies,
1283
+ media: this.media,
1284
+ rbac: this.rbac,
1285
+ revisions: this.revisions,
1286
+ lifecycle: this.lifecycle,
1287
+ registerCollection: (c) => this.registerCollection(c),
1288
+ extendCollection: (s, f) => this.extendCollection(s, f),
1289
+ registerTaxonomy: (t) => this.taxonomies.registerTaxonomy(t),
1290
+ registerRoute: (m, p, h) => this.registerRoute(m, p, h)
1291
+ };
1292
+ const setupResult = plugin.setup(context);
1293
+ if (setupResult instanceof Promise) this.pendingPluginSetups.push(setupResult);
1294
+ }
1295
+ return this;
1296
+ }
1297
+ /**
1298
+ * Get all registered plugins.
1299
+ */
1300
+ getPlugins() {
1301
+ return Array.from(this.plugins.values());
1302
+ }
1303
+ /**
1304
+ * Get a registered plugin by name.
1305
+ */
1306
+ getPlugin(name) {
1307
+ return this.plugins.get(name);
1308
+ }
1309
+ /**
1310
+ * Check if a plugin is registered.
1311
+ */
1312
+ hasPlugin(name) {
1313
+ return this.plugins.has(name);
1314
+ }
1315
+ /**
1316
+ * Register a collection dynamically.
1317
+ */
1318
+ registerCollection(coll) {
1319
+ this.collections.set(coll.slug, coll);
1320
+ }
1321
+ /**
1322
+ * Inject additional field definitions into an existing collection.
1323
+ */
1324
+ extendCollection(slug, newFields) {
1325
+ const existing = this.collections.get(slug);
1326
+ if (!existing) throw new Error(`[CMSEngine] Cannot extend non-existent collection '${slug}'.`);
1327
+ const existingFieldNames = new Set(existing.fields.map((f) => f.name));
1328
+ const addedFields = newFields.filter((f) => !existingFieldNames.has(f.name));
1329
+ this.collections.set(slug, {
1330
+ ...existing,
1331
+ fields: [...existing.fields, ...addedFields]
1332
+ });
1333
+ }
1334
+ /**
1335
+ * Register a custom Web Standard HTTP route on the engine.
1336
+ */
1337
+ registerRoute(method, path, handler) {
1338
+ const normalizedMethod = method.toUpperCase();
1339
+ let methodMap = this.customRoutes.get(normalizedMethod);
1340
+ if (!methodMap) {
1341
+ methodMap = /* @__PURE__ */ new Map();
1342
+ this.customRoutes.set(normalizedMethod, methodMap);
1343
+ }
1344
+ methodMap.set(path, handler);
1345
+ }
1346
+ /**
1347
+ * Get all custom routes registered across all HTTP methods.
1348
+ */
1349
+ getCustomRoutes() {
1350
+ return this.customRoutes;
1261
1351
  }
1262
1352
  /**
1263
1353
  * Initialize storage and trigger bootstrap hooks.
1264
1354
  */
1265
1355
  async init() {
1266
1356
  await this.storage.init();
1357
+ if (this.pendingPluginSetups.length > 0) {
1358
+ await Promise.all(this.pendingPluginSetups);
1359
+ this.pendingPluginSetups = [];
1360
+ }
1267
1361
  if (this.config.site) {
1268
1362
  if (!await this.options.has("site_name")) await this.options.set("site_name", this.config.site.name);
1269
1363
  }
1364
+ for (const plugin of this.plugins.values()) if (plugin.onInit) await plugin.onInit(this);
1365
+ await this.hooks.doAction("cms.plugins_initialized", this);
1270
1366
  await this.hooks.doAction("cms.init", this);
1271
1367
  }
1272
1368
  /**
1273
- * Close storage connections.
1369
+ * Close storage connections and run plugin teardown hooks.
1274
1370
  */
1275
1371
  async close() {
1372
+ for (const plugin of this.plugins.values()) if (plugin.onClose) await plugin.onClose(this);
1276
1373
  await this.storage.close();
1277
1374
  await this.hooks.doAction("cms.close", this);
1278
1375
  }
@@ -1298,38 +1395,39 @@ var CMSEngine = class {
1298
1395
  return {
1299
1396
  config: collConfig,
1300
1397
  async create(input, authorId = null) {
1301
- const inputData = { ...input.data || {} };
1302
- if (input.title !== void 0) inputData.title = input.title;
1303
- if (input.slug !== void 0) inputData.slug = input.slug;
1304
- if (input.status !== void 0) inputData.status = input.status;
1398
+ const filteredInput = await self.hooks.applyFilters("cms.before_create_input", input, { collection: slug });
1399
+ const inputData = { ...filteredInput.data || {} };
1400
+ if (filteredInput.title !== void 0) inputData.title = filteredInput.title;
1401
+ if (filteredInput.slug !== void 0) inputData.slug = filteredInput.slug;
1402
+ if (filteredInput.status !== void 0) inputData.status = filteredInput.status;
1305
1403
  const { data: normalizedData, errors } = validateAndNormalizeData(collConfig.fields, inputData);
1306
1404
  if (Object.keys(errors).length > 0) throw new Error(`[CMSEngine] Validation failed for collection '${slug}': ${JSON.stringify(errors)}`);
1307
- const title = input.title ?? normalizedData.title ?? "";
1308
- const finalSlug = await resolveUniqueSlug(input.slug ? slugify(input.slug) : slugify(title) || "item", async (s) => {
1405
+ const title = filteredInput.title ?? normalizedData.title ?? "";
1406
+ const finalSlug = await resolveUniqueSlug(filteredInput.slug ? slugify(filteredInput.slug) : slugify(title) || "item", async (s) => {
1309
1407
  return await self.storage.getContentBySlug(slug, s) !== null;
1310
1408
  });
1311
- const status = input.status ?? (collConfig.draftable ? "draft" : "published");
1409
+ const status = filteredInput.status ?? (collConfig.draftable ? "draft" : "published");
1312
1410
  const nowIso = (/* @__PURE__ */ new Date()).toISOString();
1313
1411
  const item = await self.storage.createContent({
1314
1412
  collection: slug,
1315
1413
  slug: finalSlug,
1316
1414
  title,
1317
1415
  status,
1318
- parentId: collConfig.hierarchical ? input.parentId ?? null : null,
1416
+ parentId: collConfig.hierarchical ? filteredInput.parentId ?? null : null,
1319
1417
  authorId,
1320
1418
  publishedAt: status === "published" ? nowIso : null,
1321
- scheduledAt: status === "scheduled" ? input.scheduledAt ?? null : null,
1419
+ scheduledAt: status === "scheduled" ? filteredInput.scheduledAt ?? null : null,
1322
1420
  data: normalizedData,
1323
- terms: input.terms
1421
+ terms: filteredInput.terms
1324
1422
  });
1325
- if (input.terms) {
1326
- const allTermIds = Object.values(input.terms).flat();
1423
+ if (filteredInput.terms) {
1424
+ const allTermIds = Object.values(filteredInput.terms).flat();
1327
1425
  if (allTermIds.length > 0) await self.taxonomies.assignTerms(item.id, allTermIds);
1328
1426
  }
1329
1427
  if (collConfig.revisions) await self.revisions.createRevision(item, authorId, "Initial creation");
1330
1428
  await self.hooks.doAction("cms.content_created", item);
1331
1429
  await self.hooks.doAction(`cms.${slug}_created`, item);
1332
- return item;
1430
+ return self.hooks.applyFilters("cms.after_create_item", item, { collection: slug });
1333
1431
  },
1334
1432
  async findById(id) {
1335
1433
  return self.storage.getContent(slug, id);
@@ -1338,51 +1436,58 @@ var CMSEngine = class {
1338
1436
  return self.storage.getContentBySlug(slug, contentSlug);
1339
1437
  },
1340
1438
  async find(options = {}) {
1341
- return self.storage.findContent(slug, options);
1439
+ const filteredOptions = await self.hooks.applyFilters("cms.find_options", options, { collection: slug });
1440
+ return self.storage.findContent(slug, filteredOptions);
1342
1441
  },
1343
1442
  async update(id, input, authorId = null, revisionNote) {
1344
1443
  const existing = await self.storage.getContent(slug, id);
1345
1444
  if (!existing) return null;
1445
+ const filteredInput = await self.hooks.applyFilters("cms.before_update_input", input, {
1446
+ collection: slug,
1447
+ id,
1448
+ existing
1449
+ });
1346
1450
  const mergedData = {
1347
1451
  ...existing.data,
1348
- ...input.data || {}
1452
+ ...filteredInput.data || {}
1349
1453
  };
1350
- if (input.title !== void 0) mergedData.title = input.title;
1454
+ if (filteredInput.title !== void 0) mergedData.title = filteredInput.title;
1351
1455
  else if (existing.title !== void 0 && mergedData.title === void 0) mergedData.title = existing.title;
1352
- if (input.slug !== void 0) mergedData.slug = input.slug;
1456
+ if (filteredInput.slug !== void 0) mergedData.slug = filteredInput.slug;
1353
1457
  else if (existing.slug !== void 0 && mergedData.slug === void 0) mergedData.slug = existing.slug;
1354
- if (input.status !== void 0) mergedData.status = input.status;
1458
+ if (filteredInput.status !== void 0) mergedData.status = filteredInput.status;
1355
1459
  else if (existing.status !== void 0 && mergedData.status === void 0) mergedData.status = existing.status;
1356
1460
  const { data: normalizedData, errors } = validateAndNormalizeData(collConfig.fields, mergedData);
1357
1461
  if (Object.keys(errors).length > 0) throw new Error(`[CMSEngine] Validation failed updating '${slug}': ${JSON.stringify(errors)}`);
1358
1462
  let updatedSlug = existing.slug;
1359
- if (input.slug && input.slug !== existing.slug) updatedSlug = await resolveUniqueSlug(input.slug, async (s) => {
1463
+ if (filteredInput.slug && filteredInput.slug !== existing.slug) updatedSlug = await resolveUniqueSlug(filteredInput.slug, async (s) => {
1360
1464
  const check = await self.storage.getContentBySlug(slug, s);
1361
1465
  return check !== null && check.id !== id;
1362
1466
  }, id);
1363
- const nextStatus = input.status ?? existing.status;
1467
+ const nextStatus = filteredInput.status ?? existing.status;
1364
1468
  const nowIso = (/* @__PURE__ */ new Date()).toISOString();
1365
1469
  let publishedAt = existing.publishedAt;
1366
1470
  if (nextStatus === "published" && !publishedAt) publishedAt = nowIso;
1367
1471
  const updated = await self.storage.updateContent(slug, id, {
1368
- title: input.title !== void 0 ? input.title : existing.title,
1472
+ title: filteredInput.title !== void 0 ? filteredInput.title : existing.title,
1369
1473
  slug: updatedSlug,
1370
1474
  status: nextStatus,
1371
- parentId: collConfig.hierarchical ? input.parentId !== void 0 ? input.parentId : existing.parentId : null,
1372
- scheduledAt: input.scheduledAt !== void 0 ? input.scheduledAt : existing.scheduledAt,
1475
+ parentId: collConfig.hierarchical ? filteredInput.parentId !== void 0 ? filteredInput.parentId : existing.parentId : null,
1476
+ scheduledAt: filteredInput.scheduledAt !== void 0 ? filteredInput.scheduledAt : existing.scheduledAt,
1373
1477
  publishedAt,
1374
1478
  authorId: authorId ?? existing.authorId,
1375
1479
  data: normalizedData,
1376
- terms: input.terms !== void 0 ? input.terms : existing.terms
1480
+ terms: filteredInput.terms !== void 0 ? filteredInput.terms : existing.terms
1377
1481
  });
1378
1482
  if (updated) {
1379
- if (input.terms) {
1380
- const allTermIds = Object.values(input.terms).flat();
1483
+ if (filteredInput.terms) {
1484
+ const allTermIds = Object.values(filteredInput.terms).flat();
1381
1485
  await self.taxonomies.assignTerms(updated.id, allTermIds);
1382
1486
  }
1383
1487
  if (collConfig.revisions) await self.revisions.createRevision(updated, authorId, revisionNote ?? `Updated version ${updated.version}`);
1384
1488
  await self.hooks.doAction("cms.content_updated", updated);
1385
1489
  await self.hooks.doAction(`cms.${slug}_updated`, updated);
1490
+ return self.hooks.applyFilters("cms.after_update_item", updated, { collection: slug });
1386
1491
  }
1387
1492
  return updated;
1388
1493
  },
@@ -1439,7 +1544,33 @@ function createCMSEngine(config, storage, hooks) {
1439
1544
  return new CMSEngine(config, storage, hooks);
1440
1545
  }
1441
1546
  //#endregion
1547
+ //#region src/core/plugin.ts
1548
+ /**
1549
+ * Type-safe helper for authoring reusable CMS plugins and plugin factories.
1550
+ */
1551
+ function definePlugin(factory) {
1552
+ return factory;
1553
+ }
1554
+ //#endregion
1442
1555
  //#region src/api/router.ts
1556
+ function compilePath(path) {
1557
+ const keys = [];
1558
+ const pattern = path.replace(/:([a-zA-Z0-9_]+)/g, (_, key) => {
1559
+ keys.push(key);
1560
+ return "([^/]+)";
1561
+ });
1562
+ return {
1563
+ regex: new RegExp(`^${pattern}$`),
1564
+ keys
1565
+ };
1566
+ }
1567
+ function matchRoute(entry, pathname) {
1568
+ const match = pathname.match(entry.regex);
1569
+ if (!match) return null;
1570
+ const params = {};
1571
+ for (let i = 0; i < entry.keys.length; i++) params[entry.keys[i]] = decodeURIComponent(match[i + 1]);
1572
+ return params;
1573
+ }
1443
1574
  function jsonResponse(data, status = 200, headers = {}) {
1444
1575
  return new Response(JSON.stringify(data), {
1445
1576
  status,
@@ -1454,10 +1585,25 @@ function jsonResponse(data, status = 200, headers = {}) {
1454
1585
  }
1455
1586
  var CMSRouter = class {
1456
1587
  engine;
1588
+ localRoutes = [];
1457
1589
  constructor(engine) {
1458
1590
  this.engine = engine;
1459
1591
  }
1460
1592
  /**
1593
+ * Register a custom Web Standard route on the router.
1594
+ */
1595
+ registerRoute(method, path, handler) {
1596
+ const { regex, keys } = compilePath(path);
1597
+ this.localRoutes.push({
1598
+ method: method.toUpperCase(),
1599
+ path,
1600
+ regex,
1601
+ keys,
1602
+ handler
1603
+ });
1604
+ return this;
1605
+ }
1606
+ /**
1461
1607
  * Universal Web Standards request handler.
1462
1608
  */
1463
1609
  async handle(request) {
@@ -1485,6 +1631,8 @@ var CMSRouter = class {
1485
1631
  if (pathname.startsWith("/api/taxonomies")) return this.handleTaxonomies(pathname, method, url, request);
1486
1632
  if (pathname.startsWith("/api/media")) return this.handleMedia(pathname, method, url, request);
1487
1633
  if (pathname.startsWith("/api/content")) return this.handleContent(pathname, method, url, request);
1634
+ const customResponse = await this.handleCustomRoute(pathname, method, url, request);
1635
+ if (customResponse) return customResponse;
1488
1636
  return jsonResponse({
1489
1637
  error: "Endpoint not found",
1490
1638
  path: pathname
@@ -1493,6 +1641,29 @@ var CMSRouter = class {
1493
1641
  return jsonResponse({ error: error instanceof Error ? error.message : String(error) }, 500);
1494
1642
  }
1495
1643
  }
1644
+ async handleCustomRoute(pathname, method, url, request) {
1645
+ for (const route of this.localRoutes) if (route.method === method) {
1646
+ const params = matchRoute(route, pathname);
1647
+ if (params !== null) {
1648
+ const context = {
1649
+ params,
1650
+ url,
1651
+ engine: this.engine
1652
+ };
1653
+ return await route.handler(request, context);
1654
+ }
1655
+ }
1656
+ const engineRoutes = this.engine.getCustomRoutes().get(method);
1657
+ if (engineRoutes) for (const [pathPattern, handler] of engineRoutes) {
1658
+ const params = matchRoute(compilePath(pathPattern), pathname);
1659
+ if (params !== null) return await handler(request, {
1660
+ params,
1661
+ url,
1662
+ engine: this.engine
1663
+ });
1664
+ }
1665
+ return null;
1666
+ }
1496
1667
  async handleContent(pathname, method, url, request) {
1497
1668
  const parts = pathname.replace(/^\/api\/content\/?/, "").split("/").filter(Boolean);
1498
1669
  const collectionSlug = parts[0];
@@ -1796,6 +1967,7 @@ exports.createCMSRouter = createCMSRouter;
1796
1967
  exports.createCmsClient = createCmsClient;
1797
1968
  exports.defaultHooks = defaultHooks;
1798
1969
  exports.defineConfig = defineConfig;
1970
+ exports.definePlugin = definePlugin;
1799
1971
  exports.fields = fields;
1800
1972
  exports.normalizeConfig = normalizeConfig;
1801
1973
  exports.resolveUniqueSlug = resolveUniqueSlug;