opacacms 0.1.21 → 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.
Files changed (137) hide show
  1. package/README.md +792 -50
  2. package/dist/admin/auth-client.d.ts +39 -39
  3. package/dist/admin/index.js +2360 -1392
  4. package/dist/admin/react.d.ts +1 -1
  5. package/dist/admin/react.js +8 -0
  6. package/dist/admin/router.d.ts +1 -0
  7. package/dist/admin/stores/ui.d.ts +10 -0
  8. package/dist/admin/ui/admin-layout.d.ts +4 -4
  9. package/dist/admin/ui/components/DataDetailView.d.ts +1 -1
  10. package/dist/admin/ui/components/DetailSheet.d.ts +19 -0
  11. package/dist/admin/ui/components/PluginSettingsForm.d.ts +11 -0
  12. package/dist/admin/ui/components/fields/BooleanField.d.ts +2 -1
  13. package/dist/admin/ui/components/fields/DateField.d.ts +1 -1
  14. package/dist/admin/ui/components/fields/FieldLabel.d.ts +11 -0
  15. package/dist/admin/ui/components/fields/FileField.d.ts +1 -1
  16. package/dist/admin/ui/components/fields/NumberField.d.ts +1 -1
  17. package/dist/admin/ui/components/fields/RadioField.d.ts +1 -1
  18. package/dist/admin/ui/components/fields/RelationshipField.d.ts +3 -1
  19. package/dist/admin/ui/components/fields/SelectField.d.ts +1 -1
  20. package/dist/admin/ui/components/fields/TextAreaField.d.ts +1 -1
  21. package/dist/admin/ui/components/fields/TextField.d.ts +1 -1
  22. package/dist/admin/ui/components/fields/VirtualField.d.ts +1 -0
  23. package/dist/admin/ui/components/fields/index.d.ts +16 -16
  24. package/dist/admin/ui/components/fields/richtext-editor/index.d.ts +1 -1
  25. package/dist/admin/ui/components/media/AssetManagerModal.d.ts +1 -1
  26. package/dist/admin/ui/components/toast.d.ts +1 -1
  27. package/dist/admin/ui/components/ui/accordion.d.ts +1 -1
  28. package/dist/admin/ui/components/ui/button.d.ts +1 -1
  29. package/dist/admin/ui/components/ui/collapsible.d.ts +1 -1
  30. package/dist/admin/ui/components/ui/dialog.d.ts +1 -1
  31. package/dist/admin/ui/components/ui/group.d.ts +1 -1
  32. package/dist/admin/ui/components/ui/index.d.ts +17 -17
  33. package/dist/admin/ui/components/ui/input.d.ts +1 -1
  34. package/dist/admin/ui/components/ui/label.d.ts +1 -1
  35. package/dist/admin/ui/components/ui/radio-group.d.ts +1 -1
  36. package/dist/admin/ui/components/ui/relationship.d.ts +4 -4
  37. package/dist/admin/ui/components/ui/select.d.ts +1 -1
  38. package/dist/admin/ui/components/ui/sheet.d.ts +1 -1
  39. package/dist/admin/ui/components/ui/tabs.d.ts +1 -1
  40. package/dist/admin/ui/components/versions-sheet.d.ts +11 -0
  41. package/dist/admin/ui/views/media-registry-view.d.ts +1 -1
  42. package/dist/admin/ui/views/settings-view.d.ts +2 -2
  43. package/dist/admin/vue.js +8 -0
  44. package/dist/admin/webcomponent.js +2 -2
  45. package/dist/admin.css +1 -1
  46. package/dist/auth/index.d.ts +101 -41
  47. package/dist/{chunk-0sdceeys.js → chunk-0bq155dy.js} +86 -6
  48. package/dist/{chunk-59sg3pw9.js → chunk-0gtxnxmd.js} +90 -7
  49. package/dist/{chunk-v521d72w.js → chunk-3rdhbedb.js} +1 -1
  50. package/dist/chunk-51z3x7kq.js +20 -0
  51. package/dist/{chunk-7fyepksb.js → chunk-526a3gqx.js} +1 -1
  52. package/dist/{chunk-wmvjvn7b.js → chunk-6qq3ne6b.js} +39 -1
  53. package/dist/{chunk-0am1m47g.js → chunk-6v1fw7q7.js} +5 -5
  54. package/dist/{chunk-t9v845m2.js → chunk-7y1nbmw6.js} +34 -3
  55. package/dist/chunk-8scgdznr.js +44 -0
  56. package/dist/{chunk-mycmsjd9.js → chunk-b3kr8w41.js} +57 -6
  57. package/dist/chunk-bexcv7xe.js +36 -0
  58. package/dist/{chunk-16vgcf3k.js → chunk-byq8g0rd.js} +1 -1
  59. package/dist/{chunk-fqastxq9.js → chunk-d1asgtke.js} +86 -6
  60. package/dist/{chunk-cpw2y3pn.js → chunk-dykn5hr6.js} +7 -7
  61. package/dist/{chunk-61kwqve4.js → chunk-esrg9qj0.js} +90 -9
  62. package/dist/chunk-fj19qccp.js +78 -0
  63. package/dist/{chunk-ekxkvqjm.js → chunk-gmee4mdc.js} +90 -9
  64. package/dist/{chunk-xa7rjsn2.js → chunk-j53pz21t.js} +2 -2
  65. package/dist/{chunk-xrfhhz85.js → chunk-kc4jfnv7.js} +480 -85
  66. package/dist/chunk-mkn49zmy.js +102 -0
  67. package/dist/{chunk-n1xraw7j.js → chunk-qb6ztvw9.js} +1 -1
  68. package/dist/{chunk-2kyhqvhc.js → chunk-qxt9vge8.js} +1 -1
  69. package/dist/chunk-r39em4yj.js +29 -0
  70. package/dist/chunk-rqyjjqgy.js +91 -0
  71. package/dist/chunk-rsf0tpy1.js +8 -0
  72. package/dist/chunk-swtcpvhf.js +2442 -0
  73. package/dist/chunk-t0zg026p.js +71 -0
  74. package/dist/chunk-twpvxfce.js +64 -0
  75. package/dist/{chunk-ybbbqj63.js → chunk-v9z61v3g.js} +15 -0
  76. package/dist/{chunk-jwjk85ze.js → chunk-ywm4t2gm.js} +6 -2
  77. package/dist/cli/commands/plugin-build.d.ts +1 -0
  78. package/dist/cli/commands/plugin-init.d.ts +1 -0
  79. package/dist/cli/commands/plugin-sync.d.ts +1 -0
  80. package/dist/cli/index.js +24 -6
  81. package/dist/config-utils.d.ts +1 -1
  82. package/dist/config.d.ts +21 -4
  83. package/dist/db/better-sqlite.d.ts +1 -1
  84. package/dist/db/better-sqlite.js +5 -5
  85. package/dist/db/bun-sqlite.d.ts +1 -1
  86. package/dist/db/bun-sqlite.js +5 -5
  87. package/dist/db/d1.d.ts +1 -1
  88. package/dist/db/d1.js +5 -5
  89. package/dist/db/index.js +9 -9
  90. package/dist/db/postgres.d.ts +1 -1
  91. package/dist/db/postgres.js +5 -5
  92. package/dist/db/sqlite.d.ts +1 -1
  93. package/dist/db/sqlite.js +5 -5
  94. package/dist/index.js +4 -3
  95. package/dist/plugins/index.d.ts +1 -0
  96. package/dist/plugins/ui-bridge.d.ts +12 -0
  97. package/dist/plugins/utils.d.ts +5 -0
  98. package/dist/runtimes/bun.js +13 -7
  99. package/dist/runtimes/cloudflare-workers.js +5 -5
  100. package/dist/runtimes/next.js +5 -5
  101. package/dist/runtimes/node.js +13 -7
  102. package/dist/schema/collection.d.ts +9 -26
  103. package/dist/schema/fields/base.d.ts +3 -2
  104. package/dist/schema/fields/index.d.ts +12 -0
  105. package/dist/schema/fields/validation.test.d.ts +1 -0
  106. package/dist/schema/global.d.ts +10 -7
  107. package/dist/schema/index.js +22 -6
  108. package/dist/server/admin-router.d.ts +2 -2
  109. package/dist/server/admin.d.ts +2 -1
  110. package/dist/server/collection-router.d.ts +1 -1
  111. package/dist/server/handlers.d.ts +10 -0
  112. package/dist/server/middlewares/admin.d.ts +2 -2
  113. package/dist/server/middlewares/auth.d.ts +1 -1
  114. package/dist/server/middlewares/context.d.ts +2 -0
  115. package/dist/server/middlewares/rate-limit.d.ts +1 -1
  116. package/dist/server/openapi.d.ts +2 -0
  117. package/dist/server/plugins-loader.d.ts +6 -0
  118. package/dist/server/router.d.ts +3 -3
  119. package/dist/server/routers/admin.d.ts +2 -2
  120. package/dist/server/routers/auth.d.ts +1 -1
  121. package/dist/server/routers/collections.d.ts +1 -1
  122. package/dist/server/routers/plugins.d.ts +18 -0
  123. package/dist/server/setup-middlewares.d.ts +2 -2
  124. package/dist/server/system-router.d.ts +1 -1
  125. package/dist/server.js +11 -7
  126. package/dist/storage/adapters/local.d.ts +1 -1
  127. package/dist/storage/adapters/s3.d.ts +1 -1
  128. package/dist/types.d.ts +222 -15
  129. package/dist/utils/logger.d.ts +13 -35
  130. package/dist/validation.d.ts +40 -0
  131. package/dist/validator.d.ts +1 -1
  132. package/package.json +21 -7
  133. package/dist/admin/ui/components/DataDetailSheet.d.ts +0 -13
  134. package/dist/admin/ui/components/ui/relationship-detail-sheet.d.ts +0 -9
  135. package/dist/chunk-62ev8gnc.js +0 -41
  136. package/dist/chunk-j4d50hrx.js +0 -20
  137. package/dist/chunk-nb7ctdg8.js +0 -311
@@ -1,27 +1,28 @@
1
1
  import {
2
2
  createAuth,
3
3
  sanitizeConfig
4
- } from "./chunk-mycmsjd9.js";
5
- import {
6
- logger
7
- } from "./chunk-62ev8gnc.js";
4
+ } from "./chunk-b3kr8w41.js";
8
5
  import {
9
6
  toSnakeCase
10
- } from "./chunk-2kyhqvhc.js";
7
+ } from "./chunk-qxt9vge8.js";
11
8
  import {
12
9
  exports_system_schema,
13
10
  getSystemCollections,
14
11
  init_system_schema
15
- } from "./chunk-ybbbqj63.js";
12
+ } from "./chunk-v9z61v3g.js";
13
+ import {
14
+ OpacaLogger,
15
+ logger
16
+ } from "./chunk-t0zg026p.js";
16
17
  import {
17
18
  __require,
18
19
  __toCommonJS
19
20
  } from "./chunk-8sqjbsgt.js";
20
21
 
21
22
  // src/server/admin.ts
22
- function createAdminHandlers(config, getAuth) {
23
+ function createAdminHandlers(config, settings, getAuth) {
23
24
  const getMetadata = (c) => {
24
- return c.json(sanitizeConfig(config));
25
+ return c.json(sanitizeConfig(config, settings));
25
26
  };
26
27
  const getCollections = (c) => {
27
28
  const collections = [...config.collections];
@@ -101,19 +102,27 @@ function createAdminHandlers(config, getAuth) {
101
102
  return c.json({ message, detail: err }, 400);
102
103
  }
103
104
  };
105
+ const getPluginSettings = async (c) => {
106
+ const name = c.req.param("name");
107
+ if (!name)
108
+ return c.json({ error: "Plugin name is required" }, 400);
109
+ const pluginSettings = settings[name] || {};
110
+ return c.json(pluginSettings);
111
+ };
104
112
  return {
105
113
  getMetadata,
106
114
  getCollections,
107
115
  getConfig,
108
116
  getSetupStatus,
109
- createApiKey
117
+ createApiKey,
118
+ getPluginSettings
110
119
  };
111
120
  }
112
121
 
113
122
  // src/validator.ts
114
123
  import { z } from "zod";
115
- function generateSchemaForCollection(collection, isUpdate = false) {
116
- const shape = mapFieldsToShape(collection.fields, isUpdate);
124
+ function generateSchemaForCollection(collection, isUpdate = false, forDocs = false) {
125
+ const shape = mapFieldsToShape(collection.fields, isUpdate, forDocs);
117
126
  if (collection.versions) {
118
127
  shape._status = z.enum(["draft", "published"]).optional().nullable();
119
128
  }
@@ -122,46 +131,49 @@ function generateSchemaForCollection(collection, isUpdate = false) {
122
131
  const config = typeof ts === "object" ? ts : {};
123
132
  const createdField = config.createdAt || "createdAt";
124
133
  const updatedField = config.updatedAt || "updatedAt";
125
- shape[createdField] = z.union([z.string(), z.date()]).optional().nullable();
126
- shape[updatedField] = z.union([z.string(), z.date()]).optional().nullable();
134
+ const dateSchema = forDocs ? z.string() : z.union([z.string(), z.date()]);
135
+ shape[createdField] = dateSchema.optional().nullable();
136
+ shape[updatedField] = dateSchema.optional().nullable();
127
137
  }
128
138
  return z.object(shape);
129
139
  }
130
- function mapFieldsToShape(fields, isUpdate = false) {
140
+ function mapFieldsToShape(fields, isUpdate = false, forDocs = false) {
131
141
  const shape = {};
132
142
  for (const field of fields) {
143
+ if (field.type === "virtual" || field.type === "ui")
144
+ continue;
133
145
  if (!field.name) {
134
146
  if (field.type === "tabs" && field.tabs) {
135
147
  for (const tab of field.tabs) {
136
- Object.assign(shape, mapFieldsToShape(tab.fields, isUpdate));
148
+ Object.assign(shape, mapFieldsToShape(tab.fields, isUpdate, forDocs));
137
149
  }
138
150
  } else if (field.type === "group" && field.fields) {
139
- Object.assign(shape, mapFieldsToShape(field.fields, isUpdate));
151
+ Object.assign(shape, mapFieldsToShape(field.fields, isUpdate, forDocs));
140
152
  } else if (field.type === "row" && field.fields) {
141
- Object.assign(shape, mapFieldsToShape(field.fields, isUpdate));
153
+ Object.assign(shape, mapFieldsToShape(field.fields, isUpdate, forDocs));
142
154
  } else if (field.type === "collapsible" && field.fields) {
143
- Object.assign(shape, mapFieldsToShape(field.fields, isUpdate));
155
+ Object.assign(shape, mapFieldsToShape(field.fields, isUpdate, forDocs));
144
156
  }
145
157
  continue;
146
158
  }
147
159
  const fieldName = field.name;
148
160
  let schema;
149
161
  if (field.type === "group" && field.fields) {
150
- schema = z.object(mapFieldsToShape(field.fields, isUpdate));
162
+ schema = z.object(mapFieldsToShape(field.fields, isUpdate, forDocs));
151
163
  } else if (field.type === "blocks" && field.blocks) {
152
164
  const blockSchemas = field.blocks.map((block) => z.object({
153
165
  blockType: z.literal(block.slug),
154
- ...mapFieldsToShape(block.fields, isUpdate)
166
+ ...mapFieldsToShape(block.fields, isUpdate, forDocs)
155
167
  }));
156
168
  schema = z.array(z.union(blockSchemas));
157
169
  } else if (field.type === "row" || field.type === "collapsible") {
158
170
  if (field.fields) {
159
- schema = z.object(mapFieldsToShape(field.fields, isUpdate));
171
+ schema = z.object(mapFieldsToShape(field.fields, isUpdate, forDocs));
160
172
  } else {
161
173
  schema = z.any();
162
174
  }
163
175
  } else {
164
- schema = mapFieldToZod(field);
176
+ schema = mapFieldToZod(field, forDocs);
165
177
  }
166
178
  if (field.localized) {
167
179
  schema = z.union([z.record(z.string(), schema), schema]);
@@ -177,23 +189,36 @@ function mapFieldsToShape(fields, isUpdate = false) {
177
189
  schema = schema.default(field.defaultValue);
178
190
  }
179
191
  if (field.validate) {
180
- schema = schema.superRefine((val, ctx) => {
181
- if (val === undefined || val === null)
182
- return;
183
- const result = field.validate(val);
184
- if (result !== true) {
185
- ctx.addIssue({
186
- code: z.ZodIssueCode.custom,
187
- message: typeof result === "string" ? result : "Invalid field"
188
- });
189
- }
190
- });
192
+ if (typeof field.validate === "function") {
193
+ schema = schema.superRefine((val, ctx) => {
194
+ if (val === undefined || val === null)
195
+ return;
196
+ const result = field.validate(val);
197
+ if (result !== true) {
198
+ ctx.addIssue({
199
+ code: z.ZodIssueCode.custom,
200
+ message: typeof result === "string" ? result : "Invalid field"
201
+ });
202
+ }
203
+ });
204
+ } else if (typeof field.validate === "object" && field.validate !== null && "safeParse" in field.validate) {
205
+ schema = schema.superRefine((val, ctx) => {
206
+ if (val === undefined || val === null)
207
+ return;
208
+ const result = field.validate.safeParse(val);
209
+ if (!result.success) {
210
+ for (const issue of result.error.issues) {
211
+ ctx.addIssue(issue);
212
+ }
213
+ }
214
+ });
215
+ }
191
216
  }
192
217
  shape[fieldName] = schema;
193
218
  }
194
219
  return shape;
195
220
  }
196
- function mapFieldToZod(field) {
221
+ function mapFieldToZod(field, forDocs = false) {
197
222
  switch (field.type) {
198
223
  case "text":
199
224
  case "slug":
@@ -202,7 +227,7 @@ function mapFieldToZod(field) {
202
227
  return z.string();
203
228
  case "relationship": {
204
229
  const isHasMany = "hasMany" in field && field.hasMany;
205
- const base = z.union([z.string(), z.number(), z.undefined(), z.null()]);
230
+ const base = forDocs ? z.union([z.string(), z.number(), z.null()]) : z.union([z.string(), z.number(), z.undefined(), z.null()]);
206
231
  const schema = isHasMany ? z.array(base.optional().nullable()) : base;
207
232
  if (isHasMany) {
208
233
  return z.preprocess((val) => {
@@ -215,6 +240,18 @@ function mapFieldToZod(field) {
215
240
  }
216
241
  return schema;
217
242
  }
243
+ case "file":
244
+ return z.object({
245
+ id: z.string(),
246
+ url: z.string(),
247
+ filename: z.string(),
248
+ mime_type: z.string(),
249
+ filesize: z.number(),
250
+ width: z.number().optional().nullable(),
251
+ height: z.number().optional().nullable(),
252
+ focal_x: z.number().optional().nullable(),
253
+ focal_y: z.number().optional().nullable()
254
+ });
218
255
  case "number":
219
256
  return z.preprocess((val) => {
220
257
  if (val === "" || val === undefined || val === null)
@@ -224,14 +261,16 @@ function mapFieldToZod(field) {
224
261
  return Number.isNaN(num) ? undefined : num;
225
262
  }
226
263
  return val;
227
- }, z.union([z.number(), z.undefined(), z.null()]));
264
+ }, z.number().optional().nullable());
228
265
  case "boolean":
229
266
  return z.preprocess((val) => {
230
267
  if (typeof val === "string")
231
268
  return val === "true";
232
269
  return val;
233
270
  }, z.boolean());
234
- case "date":
271
+ case "date": {
272
+ if (forDocs)
273
+ return z.string().describe("ISO 8601 Date");
235
274
  return z.preprocess((val) => {
236
275
  if (val === "" || val === undefined || val === null)
237
276
  return;
@@ -242,8 +281,20 @@ function mapFieldToZod(field) {
242
281
  z.undefined(),
243
282
  z.null()
244
283
  ]));
284
+ }
245
285
  case "select":
286
+ case "radio": {
287
+ const choices = field.options?.choices;
288
+ if (choices && Array.isArray(choices)) {
289
+ const values = choices.map((c) => typeof c === "string" ? c : c.value);
290
+ if (values.length > 0) {
291
+ return z.enum(values);
292
+ }
293
+ }
246
294
  return z.string();
295
+ }
296
+ case "json":
297
+ return z.any();
247
298
  case "array":
248
299
  return z.preprocess((val) => {
249
300
  if (val === undefined || val === null || val === "")
@@ -258,6 +309,7 @@ function mapFieldToZod(field) {
258
309
  }
259
310
 
260
311
  // src/server/handlers.ts
312
+ init_system_schema();
261
313
  var hydrateDoc = async (doc, fields, c, config) => {
262
314
  if (!doc)
263
315
  return doc;
@@ -317,24 +369,119 @@ var hydrateDoc = async (doc, fields, c, config) => {
317
369
  await Promise.all(hydratePromises);
318
370
  return doc;
319
371
  };
320
- var populateDoc = async (db, fields, doc, populateKeys) => {
321
- if (!doc)
322
- return doc;
323
- const populatePromises = fields.filter((f) => {
324
- const field = f;
325
- return field.type === "relationship" && field.relationTo && populateKeys.includes(field.name) && doc[field.name];
326
- }).map(async (f) => {
327
- const field = f;
372
+ function parsePopulate(populate) {
373
+ if (!populate)
374
+ return {};
375
+ if (typeof populate === "object" && !Array.isArray(populate)) {
376
+ return populate;
377
+ }
378
+ if (typeof populate === "string" && (populate.startsWith("{") || populate.startsWith("["))) {
328
379
  try {
329
- const relatedDoc = await db.findOne(field.relationTo, { id: doc[field.name] });
330
- if (relatedDoc) {
331
- doc[field.name] = relatedDoc;
380
+ const parsed = JSON.parse(populate);
381
+ if (Array.isArray(parsed)) {
382
+ const result2 = {};
383
+ for (const item of parsed) {
384
+ if (typeof item === "string") {
385
+ Object.assign(result2, parsePopulate(item));
386
+ }
387
+ }
388
+ return result2;
389
+ }
390
+ return parsed;
391
+ } catch {}
392
+ }
393
+ const result = {};
394
+ const keys = typeof populate === "string" ? populate.split(",") : [];
395
+ for (const key of keys) {
396
+ const parts = key.trim().split(".");
397
+ let current = result;
398
+ for (let i = 0;i < parts.length; i++) {
399
+ const part = parts[i];
400
+ if (!part)
401
+ continue;
402
+ if (i === parts.length - 1) {
403
+ current[part] = current[part] || true;
404
+ } else {
405
+ if (typeof current[part] !== "object") {
406
+ current[part] = { populate: {} };
407
+ } else if (!current[part].populate) {
408
+ current[part].populate = { ...current[part].populate };
409
+ }
410
+ current = current[part].populate;
411
+ }
412
+ }
413
+ }
414
+ return result;
415
+ }
416
+ var populateDoc = async (db, fields, doc, populate, config) => {
417
+ if (!doc || !populate || Object.keys(populate).length === 0)
418
+ return doc;
419
+ const populatePromises = fields.map(async (field) => {
420
+ const pValue = populate[field.name];
421
+ if (field.type === "relationship" && field.relationTo && pValue) {
422
+ const relationTo = field.relationTo;
423
+ const nestedPopulate = typeof pValue === "object" ? pValue.populate : undefined;
424
+ const fetchAndPopulate = async (id) => {
425
+ if (!id)
426
+ return id;
427
+ try {
428
+ const systemCollections = getSystemCollections();
429
+ const allCollections = [...config.collections, ...systemCollections];
430
+ const relatedCollection = allCollections.find((c) => c.slug === relationTo || c.apiPath === relationTo);
431
+ const targetSlug = relatedCollection ? relatedCollection.slug : relationTo;
432
+ let relatedDoc = await db.findOne(targetSlug, { id });
433
+ if (relatedDoc && nestedPopulate && relatedCollection) {
434
+ relatedDoc = await populateDoc(db, relatedCollection.fields, relatedDoc, nestedPopulate, config);
435
+ }
436
+ return relatedDoc || id;
437
+ } catch (err) {
438
+ console.error(`[OpacaCMS] Failed to populate relationship ${field.name}`, err);
439
+ return id;
440
+ }
441
+ };
442
+ if (field.hasMany && Array.isArray(doc[field.name])) {
443
+ doc[field.name] = await Promise.all(doc[field.name].map(fetchAndPopulate));
444
+ } else if (doc[field.name]) {
445
+ doc[field.name] = await fetchAndPopulate(doc[field.name]);
446
+ }
447
+ }
448
+ if (field.type === "group" && field.fields && doc[field.name]) {
449
+ if (typeof pValue === "object" && pValue.populate) {
450
+ await populateDoc(db, field.fields, doc[field.name], pValue.populate, config);
451
+ }
452
+ }
453
+ if (field.type === "array" && field.fields && Array.isArray(doc[field.name])) {
454
+ if (typeof pValue === "object" && pValue.populate) {
455
+ await Promise.all(doc[field.name].map((item) => populateDoc(db, field.fields, item, pValue.populate, config)));
456
+ }
457
+ }
458
+ if (field.type === "blocks" && field.blocks && Array.isArray(doc[field.name])) {
459
+ if (typeof pValue === "object" && pValue.populate) {
460
+ await Promise.all(doc[field.name].map((item) => {
461
+ const block = field.blocks.find((b) => b.slug === item.block_type);
462
+ if (block && block.fields) {
463
+ return populateDoc(db, block.fields, item, pValue.populate, config);
464
+ }
465
+ return Promise.resolve();
466
+ }));
332
467
  }
333
- } catch (err) {
334
- console.error(`[OpacaCMS] Failed to populate relationship ${field.name} `, err);
335
468
  }
336
469
  });
337
470
  await Promise.all(populatePromises);
471
+ const pKeys = Object.keys(populate);
472
+ const hasSelection = pKeys.some((k) => {
473
+ const field = fields.find((f) => f.name === k);
474
+ return field && !["relationship", "group", "array", "blocks"].includes(field.type);
475
+ });
476
+ if (hasSelection) {
477
+ const filtered = {};
478
+ for (const key of pKeys) {
479
+ if (populate[key]) {
480
+ filtered[key] = doc[key];
481
+ }
482
+ }
483
+ return filtered;
484
+ }
338
485
  return doc;
339
486
  };
340
487
  function createHandlers(config, collection, getAuth) {
@@ -415,7 +562,7 @@ function createHandlers(config, collection, getAuth) {
415
562
  const saveVersion = async (doc, status) => {
416
563
  if (!collection.versions)
417
564
  return;
418
- const versionsTable = `${collection.slug}_versions`.toLowerCase();
565
+ const versionsTable = `${toSnakeCase(collection.slug)}_versions`;
419
566
  const versionDoc = {
420
567
  id: crypto.randomUUID(),
421
568
  _parent_id: doc.id,
@@ -447,7 +594,7 @@ function createHandlers(config, collection, getAuth) {
447
594
  if (limit > maxLimit)
448
595
  limit = maxLimit;
449
596
  const sort = queries.sort;
450
- const populate = queries.populate ? queries.populate.split(",") : [];
597
+ const populate = parsePopulate(queries.populate);
451
598
  const filter = {};
452
599
  for (const [key, value] of Object.entries(queries)) {
453
600
  if (["page", "limit", "sort", "populate", "locale", "draft"].includes(key))
@@ -464,8 +611,8 @@ function createHandlers(config, collection, getAuth) {
464
611
  }
465
612
  }
466
613
  const results = await db.find(collection.slug, filter, { page, limit, sort });
467
- if (populate.length > 0) {
468
- results.docs = await Promise.all(results.docs.map((doc) => populateDoc(db, collection.fields, doc, populate)));
614
+ if (Object.keys(populate).length > 0) {
615
+ results.docs = await Promise.all(results.docs.map((doc) => populateDoc(db, collection.fields, doc, populate, config)));
469
616
  }
470
617
  results.docs = await Promise.all(results.docs.map((doc) => hydrateDoc(doc, collection.fields, c, config)));
471
618
  return c.json(results);
@@ -475,13 +622,13 @@ function createHandlers(config, collection, getAuth) {
475
622
  return c.json({ message: "Forbidden" }, 403);
476
623
  }
477
624
  const queries = c.req.query();
478
- const populate = queries.populate ? queries.populate.split(",") : [];
625
+ const populate = parsePopulate(queries.populate);
479
626
  const id = c.req.param("id");
480
627
  let doc = await db.findOne(collection.slug, { id });
481
628
  if (!doc)
482
629
  return c.json({ message: "Not found" }, 404);
483
- if (populate.length > 0) {
484
- doc = await populateDoc(db, collection.fields, doc, populate);
630
+ if (Object.keys(populate).length > 0) {
631
+ doc = await populateDoc(db, collection.fields, doc, populate, config);
485
632
  }
486
633
  doc = await hydrateDoc(doc, collection.fields, c, config);
487
634
  const permissions = await getFieldAccessPermissions(c, "read", collection.fields, doc);
@@ -645,7 +792,7 @@ function createHandlers(config, collection, getAuth) {
645
792
  return c.json({ message: "Forbidden" }, 403);
646
793
  }
647
794
  const parentId = c.req.query("parentId");
648
- const versionsTable = `${collection.slug} _versions`.toLowerCase();
795
+ const versionsTable = `${toSnakeCase(collection.slug)}_versions`;
649
796
  const query = parentId ? `SELECT * FROM ${versionsTable} WHERE _parent_id = ? ORDER BY created_at DESC` : `SELECT * FROM ${versionsTable} ORDER BY created_at DESC`;
650
797
  const params = parentId ? [parentId] : [];
651
798
  try {
@@ -660,7 +807,7 @@ function createHandlers(config, collection, getAuth) {
660
807
  return c.json({ message: "Forbidden" }, 403);
661
808
  }
662
809
  const versionId = c.req.param("versionId");
663
- const versionsTable = `${collection.slug} _versions`.toLowerCase();
810
+ const versionsTable = `${toSnakeCase(collection.slug)}_versions`;
664
811
  try {
665
812
  const versionRows = await db.unsafe(`SELECT * FROM ${versionsTable} WHERE id = ? `, [versionId]);
666
813
  if (versionRows.length === 0)
@@ -744,29 +891,43 @@ import { Hono } from "hono";
744
891
  // src/server/middlewares/admin.ts
745
892
  var adminMiddleware = async (c, next) => {
746
893
  const user = c.get("user");
747
- const isPublicAdmin = c.req.path.endsWith("/__admin/metadata") || c.req.path.endsWith("/__admin/setup");
748
- if (!user && !isPublicAdmin) {
749
- return c.json({ message: "Unauthorized" }, 401);
750
- }
751
- if (isPublicAdmin) {
894
+ const config = c.get("config");
895
+ const isDevelopment = true;
896
+ const path = c.req.path;
897
+ const isMetadata = path.endsWith("/__admin/metadata");
898
+ const isSetup = path.endsWith("/__admin/setup");
899
+ const secretHeader = c.req.header("X-Opaca-Secret");
900
+ const isSecretValid = config?.secret && secretHeader && secretHeader === config.secret;
901
+ if (isSetup) {
752
902
  await next();
753
903
  return;
754
904
  }
755
- if (user.role === "admin" || user.role?.includes("admin")) {
756
- await next();
757
- return;
905
+ if (isMetadata) {
906
+ if (isDevelopment || isSecretValid) {
907
+ await next();
908
+ return;
909
+ }
910
+ }
911
+ if (user) {
912
+ const isAdmin = user.role === "admin" || user.role?.includes("admin");
913
+ if (isAdmin) {
914
+ await next();
915
+ return;
916
+ }
917
+ return c.json({ message: "Forbidden" }, 403);
758
918
  }
759
- return c.json({ message: "Forbidden" }, 403);
919
+ return c.json({ message: "Unauthorized" }, 401);
760
920
  };
761
921
 
762
922
  // src/server/routers/admin.ts
763
- function createAdminRouter(config, state) {
923
+ function createAdminRouter(config, settings, state) {
764
924
  const adminRouter = new Hono;
765
- const adminHandlers = createAdminHandlers(config, () => state.auth);
925
+ const adminHandlers = createAdminHandlers(config, settings, () => state.auth);
766
926
  adminRouter.get("/collections", adminMiddleware, adminHandlers.getCollections);
767
- adminRouter.get("/metadata", adminHandlers.getMetadata);
927
+ adminRouter.get("/metadata", adminMiddleware, adminHandlers.getMetadata);
768
928
  adminRouter.get("/config", adminMiddleware, adminHandlers.getConfig);
769
929
  adminRouter.get("/setup", adminHandlers.getSetupStatus);
930
+ adminRouter.get("/plugin-settings/:name", adminMiddleware, adminHandlers.getPluginSettings);
770
931
  adminRouter.post("/api-key/create", adminMiddleware, adminHandlers.createApiKey);
771
932
  return adminRouter;
772
933
  }
@@ -917,14 +1078,11 @@ function createAssetsHandlers(config) {
917
1078
  const rows = result.docs;
918
1079
  const total = result.totalDocs;
919
1080
  let folderRows = [];
920
- const bucketFilter = bucket !== "all" ? `AND bucket = ?` : "";
921
- const bucketParam = bucket !== "all" ? [bucket] : [];
922
1081
  if (config.db.name === "postgres") {
923
- const pgBucketFilter = bucketFilter.replace("?", "$1");
924
1082
  if (folder === null || folder === "") {
925
- folderRows = await config.db.unsafe(`SELECT DISTINCT split_part(folder, '/', 1) as subfolder, bucket FROM _opaca_assets WHERE folder IS NOT NULL AND folder != '' ${pgBucketFilter}`, bucketParam);
1083
+ folderRows = await config.db.unsafe("SELECT DISTINCT split_part(folder, '/', 1) as subfolder, bucket FROM _opaca_assets WHERE folder IS NOT NULL AND folder != '' AND (bucket = $1 OR $1 = 'all')", [bucket]);
926
1084
  } else {
927
- folderRows = await config.db.unsafe(`SELECT DISTINCT split_part(substring(folder from length($1) + 2), '/', 1) as subfolder, bucket FROM _opaca_assets WHERE folder LIKE $2 ${bucket !== "all" ? "AND bucket = $3" : ""}`, [folder, `${folder}/%`, ...bucketParam]);
1085
+ folderRows = await config.db.unsafe("SELECT DISTINCT split_part(substring(folder from length($1) + 2), '/', 1) as subfolder, bucket FROM _opaca_assets WHERE folder LIKE $2 AND (bucket = $3 OR $3 = 'all')", [folder, `${folder}/%`, bucket]);
928
1086
  }
929
1087
  } else {
930
1088
  if (folder === null || folder === "") {
@@ -935,8 +1093,8 @@ function createAssetsHandlers(config) {
935
1093
  ELSE folder
936
1094
  END as subfolder,
937
1095
  bucket
938
- FROM _opaca_assets WHERE folder IS NOT NULL AND folder != '' ${bucketFilter}
939
- `, bucketParam);
1096
+ FROM _opaca_assets WHERE folder IS NOT NULL AND folder != '' AND (bucket = ? OR ? = 'all')
1097
+ `, [bucket, bucket]);
940
1098
  } else {
941
1099
  const skipLen = folder.length + 2;
942
1100
  folderRows = await config.db.unsafe(`
@@ -946,8 +1104,8 @@ function createAssetsHandlers(config) {
946
1104
  ELSE SUBSTR(folder, ?)
947
1105
  END as subfolder,
948
1106
  bucket
949
- FROM _opaca_assets WHERE folder LIKE ? ${bucketFilter}
950
- `, [skipLen, skipLen, skipLen, skipLen, `${folder}/%`, ...bucketParam]);
1107
+ FROM _opaca_assets WHERE folder LIKE ? AND (bucket = ? OR ? = 'all')
1108
+ `, [skipLen, skipLen, skipLen, skipLen, `${folder}/%`, bucket, bucket]);
951
1109
  }
952
1110
  }
953
1111
  const folderMap = {};
@@ -1057,6 +1215,28 @@ function mountGlobalRoutes(router, config, state) {
1057
1215
  }
1058
1216
  function createSystemRouter(config) {
1059
1217
  const systemRouter = new Hono3;
1218
+ systemRouter.get("/plugins/assets", adminMiddleware, (c) => {
1219
+ const response = {
1220
+ scripts: [],
1221
+ styles: []
1222
+ };
1223
+ if (config.plugins && Array.isArray(config.plugins)) {
1224
+ for (const plugin of config.plugins) {
1225
+ if (plugin.adminAssets) {
1226
+ try {
1227
+ const assets = plugin.adminAssets();
1228
+ if (assets.scripts)
1229
+ response.scripts.push(...assets.scripts);
1230
+ if (assets.styles)
1231
+ response.styles.push(...assets.styles);
1232
+ } catch (e) {
1233
+ console.error(`[Plugin] ${plugin.name} failed to expose adminAssets: `, e);
1234
+ }
1235
+ }
1236
+ }
1237
+ }
1238
+ return c.json(response);
1239
+ });
1060
1240
  if (config.storages) {
1061
1241
  const assetsHandlers = createAssetsHandlers(config);
1062
1242
  systemRouter.post("/assets/upload", adminMiddleware, assetsHandlers.upload);
@@ -1115,7 +1295,7 @@ function createAuthMiddleware(getAuth) {
1115
1295
  const ownerResult = await auth.options.database?.findOne?.("_users", {
1116
1296
  id: result.key.referenceId
1117
1297
  });
1118
- c.set("user", ownerResult || null);
1298
+ c.set("user", ownerResult);
1119
1299
  } catch (e) {
1120
1300
  logger.warn("Failed to fetch API key owner from database:", e);
1121
1301
  c.set("user", null);
@@ -1138,9 +1318,11 @@ function createAuthMiddleware(getAuth) {
1138
1318
 
1139
1319
  // src/server/middlewares/context.ts
1140
1320
  function createContextMiddleware(config) {
1321
+ const logger2 = new OpacaLogger(config.logger);
1141
1322
  return async (c, next) => {
1142
1323
  c.set("config", config);
1143
1324
  c.set("db", config.db);
1325
+ c.set("logger", logger2);
1144
1326
  await next();
1145
1327
  };
1146
1328
  }
@@ -1329,7 +1511,8 @@ function setupMiddlewares(router, config, state) {
1329
1511
  router.use("*", createCorsMiddleware(config));
1330
1512
  router.use("*", createDatabaseInitMiddleware(config, state));
1331
1513
  router.onError((err, c) => {
1332
- logger.error(`API Error: ${err.message}`, err);
1514
+ const ctxLogger = c.get("logger");
1515
+ ctxLogger.error(`API Error: ${err.message}`, err);
1333
1516
  return c.json({ message: "Internal Server Error", error: err.message }, 500);
1334
1517
  });
1335
1518
  }
@@ -1337,8 +1520,196 @@ function setupAuthMiddlewares(router, config, state) {
1337
1520
  router.use("*", createAuthMiddleware(() => state.auth));
1338
1521
  }
1339
1522
 
1523
+ // src/server/openapi.ts
1524
+ import { z as z2 } from "zod";
1525
+ import { zodToJsonSchema } from "zod-to-json-schema";
1526
+ function generateOpenAPISchema(config) {
1527
+ const openapi = {
1528
+ openapi: "3.1.0",
1529
+ info: {
1530
+ title: config.appName || "OpacaCMS API",
1531
+ version: "1.0.0",
1532
+ description: "Automatically generated OpenAPI schema for OpacaCMS Collections and Globals."
1533
+ },
1534
+ paths: {},
1535
+ components: {
1536
+ schemas: {},
1537
+ securitySchemes: {
1538
+ bearerAuth: {
1539
+ type: "http",
1540
+ scheme: "bearer"
1541
+ }
1542
+ }
1543
+ }
1544
+ };
1545
+ for (const collection of config.collections || []) {
1546
+ const isHidden = collection.hidden === true;
1547
+ if (isHidden)
1548
+ continue;
1549
+ const pathBase = `/api/${collection.apiPath || collection.slug}`;
1550
+ const tag = collection.label || collection.slug;
1551
+ const zodSchema = generateSchemaForCollection(collection, false, true);
1552
+ const schemaName = collection.slug.charAt(0).toUpperCase() + collection.slug.slice(1);
1553
+ const jsonSchema = typeof z2.toJSONSchema === "function" ? z2.toJSONSchema(zodSchema, { target: "openapi-3.0" }) : zodToJsonSchema(zodSchema, { target: "openApi3" });
1554
+ openapi.components.schemas[schemaName] = jsonSchema;
1555
+ const ref = `#/components/schemas/${schemaName}`;
1556
+ openapi.paths[pathBase] = {
1557
+ get: {
1558
+ tags: [tag],
1559
+ summary: `Find ${tag}`,
1560
+ parameters: [
1561
+ { name: "limit", in: "query", schema: { type: "integer" } },
1562
+ { name: "page", in: "query", schema: { type: "integer" } }
1563
+ ],
1564
+ responses: {
1565
+ "200": {
1566
+ description: "Successful response",
1567
+ content: {
1568
+ "application/json": {
1569
+ schema: {
1570
+ type: "object",
1571
+ properties: {
1572
+ docs: { type: "array", items: { $ref: ref } },
1573
+ totalDocs: { type: "integer" }
1574
+ }
1575
+ }
1576
+ }
1577
+ }
1578
+ }
1579
+ }
1580
+ },
1581
+ post: {
1582
+ tags: [tag],
1583
+ summary: `Create ${tag}`,
1584
+ requestBody: {
1585
+ required: true,
1586
+ content: {
1587
+ "application/json": { schema: { $ref: ref } }
1588
+ }
1589
+ },
1590
+ responses: {
1591
+ "201": {
1592
+ description: "Created successfully",
1593
+ content: { "application/json": { schema: { $ref: ref } } }
1594
+ }
1595
+ }
1596
+ }
1597
+ };
1598
+ openapi.paths[`${pathBase}/{id}`] = {
1599
+ get: {
1600
+ tags: [tag],
1601
+ summary: `Find ${tag} by ID`,
1602
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
1603
+ responses: {
1604
+ "200": {
1605
+ description: "Successful response",
1606
+ content: { "application/json": { schema: { $ref: ref } } }
1607
+ },
1608
+ "404": { description: "Not found" }
1609
+ }
1610
+ },
1611
+ patch: {
1612
+ tags: [tag],
1613
+ summary: `Update ${tag}`,
1614
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
1615
+ requestBody: {
1616
+ content: { "application/json": { schema: { $ref: ref } } }
1617
+ },
1618
+ responses: {
1619
+ "200": {
1620
+ description: "Updated successfully",
1621
+ content: { "application/json": { schema: { $ref: ref } } }
1622
+ }
1623
+ }
1624
+ },
1625
+ delete: {
1626
+ tags: [tag],
1627
+ summary: `Delete ${tag}`,
1628
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
1629
+ responses: {
1630
+ "200": { description: "Deleted successfully" }
1631
+ }
1632
+ }
1633
+ };
1634
+ }
1635
+ for (const global of config.globals || []) {
1636
+ const pathBase = `/api/globals/${global.slug}`;
1637
+ const tag = global.label || global.slug;
1638
+ const zodSchema = generateSchemaForCollection(global, false, true);
1639
+ const schemaName = global.slug.charAt(0).toUpperCase() + global.slug.slice(1);
1640
+ const jsonSchema = typeof z2.toJSONSchema === "function" ? z2.toJSONSchema(zodSchema, { target: "openapi-3.0" }) : zodToJsonSchema(zodSchema, { target: "openApi3" });
1641
+ openapi.components.schemas[schemaName] = jsonSchema;
1642
+ const ref = `#/components/schemas/${schemaName}`;
1643
+ openapi.paths[pathBase] = {
1644
+ get: {
1645
+ tags: [tag],
1646
+ summary: `Find ${tag}`,
1647
+ responses: {
1648
+ "200": {
1649
+ description: "Successful response",
1650
+ content: { "application/json": { schema: { $ref: ref } } }
1651
+ }
1652
+ }
1653
+ },
1654
+ post: {
1655
+ tags: [tag],
1656
+ summary: `Update ${tag}`,
1657
+ requestBody: {
1658
+ content: { "application/json": { schema: { $ref: ref } } }
1659
+ },
1660
+ responses: {
1661
+ "200": {
1662
+ description: "Updated successfully",
1663
+ content: { "application/json": { schema: { $ref: ref } } }
1664
+ }
1665
+ }
1666
+ }
1667
+ };
1668
+ }
1669
+ return openapi;
1670
+ }
1671
+
1340
1672
  // src/server/router.ts
1341
- function createAPIRouter(config) {
1673
+ import { Scalar } from "@scalar/hono-api-reference";
1674
+
1675
+ // src/server/routers/plugins.ts
1676
+ function mountPluginRoutes(config, settings, logger2, router) {
1677
+ if (config.plugins && Array.isArray(config.plugins)) {
1678
+ for (const plugin of config.plugins) {
1679
+ const pluginSettings = settings[plugin.name] || {};
1680
+ const pluginContext = { config, logger: logger2, settings: pluginSettings };
1681
+ if (plugin.onRequest) {
1682
+ router.use("*", async (c, next) => {
1683
+ const result = await plugin.onRequest(c);
1684
+ if (result === false) {
1685
+ return c.json({ error: "Blocked by plugin: " + plugin.name }, 403);
1686
+ }
1687
+ await next();
1688
+ });
1689
+ }
1690
+ if (plugin.onRouterInit) {
1691
+ try {
1692
+ plugin.onRouterInit(router, pluginContext);
1693
+ } catch (e) {
1694
+ logger2.error(`[Plugin] ${plugin.name} failed during onRouterInit: `, e);
1695
+ }
1696
+ }
1697
+ }
1698
+ }
1699
+ }
1700
+ function firePluginInitComplete(config, settings, logger2) {
1701
+ if (config.plugins && Array.isArray(config.plugins)) {
1702
+ for (const plugin of config.plugins) {
1703
+ if (plugin.onInitComplete) {
1704
+ const pluginSettings = settings[plugin.name] || {};
1705
+ plugin.onInitComplete({ config, logger: logger2, settings: pluginSettings });
1706
+ }
1707
+ }
1708
+ }
1709
+ }
1710
+
1711
+ // src/server/router.ts
1712
+ function createAPIRouter(config, settings = {}) {
1342
1713
  const state = { auth: undefined, migrated: false };
1343
1714
  const router = new Hono4().basePath("/api");
1344
1715
  setupMiddlewares(router, config, state);
@@ -1347,12 +1718,36 @@ function createAPIRouter(config) {
1347
1718
  return c.json({ status: "ok", version: "1.0.0", appName: config.appName });
1348
1719
  });
1349
1720
  router.route("/auth", createAuthRouter(config, state));
1350
- router.route("/__admin", createAdminRouter(config, state));
1721
+ router.route("/__admin", createAdminRouter(config, settings, state));
1351
1722
  router.route("/__system", createSystemRouter(config));
1352
1723
  router.route("/", createAssetsServingRouter(config));
1724
+ mountPluginRoutes(config, settings, logger, router);
1353
1725
  mountCollectionRoutes(router, config, state);
1354
1726
  mountGlobalRoutes(router, config, state);
1727
+ if (config.api?.openAPI?.enabled) {
1728
+ router.get("/open-api.json", (c) => {
1729
+ const schema = generateOpenAPISchema(config);
1730
+ return c.json(schema);
1731
+ });
1732
+ const referencePath = config.api.openAPI.path || "/reference";
1733
+ const safeRefPath = referencePath.startsWith("/") ? referencePath : `/${referencePath}`;
1734
+ router.get(safeRefPath, Scalar({
1735
+ pageTitle: `${config.appName || "OpacaCMS"} API Documentation`,
1736
+ theme: config.api.openAPI.theme || "default",
1737
+ layout: config.api.openAPI.layout === "classic" ? "classic" : "modern",
1738
+ hideModels: config.api.openAPI.hideModels,
1739
+ hideDownloadButton: config.api.openAPI.hideDownloadButton,
1740
+ customCss: config.api.openAPI.customCss,
1741
+ ...{
1742
+ sources: [
1743
+ { url: "/api/open-api.json", title: "CMS Collections" },
1744
+ { url: "/api/auth/open-api/generate-schema", title: "Auth APIs" }
1745
+ ]
1746
+ }
1747
+ }));
1748
+ }
1749
+ firePluginInitComplete(config, settings, logger);
1355
1750
  return router;
1356
1751
  }
1357
1752
 
1358
- export { createAdminHandlers, hydrateDoc, createHandlers, createGlobalHandlers, createAPIRouter };
1753
+ export { createAdminHandlers, hydrateDoc, parsePopulate, populateDoc, createHandlers, createGlobalHandlers, createAPIRouter };