@bobbykim/manguito-cms-api 0.3.3 → 0.4.1

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 CHANGED
@@ -1,11 +1,32 @@
1
1
  import {
2
2
  buildRelationsMap,
3
+ createFieldKeyMap,
3
4
  deleteParagraphField,
5
+ isColumnBacked,
4
6
  persistJunctionField,
5
7
  persistParagraphField,
6
8
  resolveRelationBareIds,
7
9
  resolveRelationField
8
- } from "./chunk-TK4DNRFF.mjs";
10
+ } from "./chunk-4F2CRXPG.mjs";
11
+
12
+ // src/paths.ts
13
+ function normalizePrefix(prefix) {
14
+ if (prefix === void 0) return "/api";
15
+ const withSlash = prefix.startsWith("/") ? prefix : `/${prefix}`;
16
+ const trimmed = withSlash.replace(/\/+$/, "");
17
+ return trimmed === "" ? "/api" : trimmed;
18
+ }
19
+ function createPublicPaths(prefix) {
20
+ return {
21
+ collection: (basePath) => `${prefix}/${basePath}`,
22
+ item: (basePath) => `${prefix}/${basePath}/:slug`,
23
+ taxonomyCollection: (typeName) => `${prefix}/taxonomy/${typeName}`,
24
+ taxonomyItem: (typeName) => `${prefix}/taxonomy/${typeName}/:id`,
25
+ mediaCollection: () => `${prefix}/media`,
26
+ mediaItem: () => `${prefix}/media/:id`,
27
+ openapi: () => `${prefix}/openapi.json`
28
+ };
29
+ }
9
30
 
10
31
  // src/app.ts
11
32
  import { Hono } from "hono";
@@ -585,6 +606,54 @@ function createProgrammaticResolver(resolvers, options) {
585
606
  return { resolveItem, resolveList, hasSchema };
586
607
  }
587
608
 
609
+ // src/projector.ts
610
+ function isPlainRow(v) {
611
+ return typeof v === "object" && v !== null && !Array.isArray(v) && !(v instanceof Date);
612
+ }
613
+ var MEDIA_FIELD_TYPES = /* @__PURE__ */ new Set(["image", "video", "file"]);
614
+ function nestedTargets(fields) {
615
+ const out = [];
616
+ for (const f of fields) {
617
+ if (MEDIA_FIELD_TYPES.has(f.field_type)) continue;
618
+ if (f.field_type !== "paragraph" && f.field_type !== "reference") continue;
619
+ const ref = f.ui_component.ref;
620
+ if (!ref) continue;
621
+ out.push({ label: f.name, target: ref });
622
+ }
623
+ return out;
624
+ }
625
+ function buildProjectors(registry, fieldKeyMaps) {
626
+ const projectors = {};
627
+ const sources = [
628
+ registry.content_types,
629
+ registry.taxonomy_types,
630
+ registry.paragraph_types
631
+ ];
632
+ for (const source of sources) {
633
+ for (const [typeName, type] of Object.entries(source)) {
634
+ const map = fieldKeyMaps[typeName];
635
+ if (!map) continue;
636
+ projectors[typeName] = { map, nested: nestedTargets(type.fields) };
637
+ }
638
+ }
639
+ return projectors;
640
+ }
641
+ function projectRow(row, typeName, projectors) {
642
+ const p = projectors[typeName];
643
+ if (!p) return row;
644
+ const out = p.map.toLabels(row);
645
+ for (const { label, target } of p.nested) {
646
+ const v = out[label];
647
+ if (v === null || v === void 0) continue;
648
+ if (Array.isArray(v)) {
649
+ out[label] = v.map((item) => isPlainRow(item) ? projectRow(item, target, projectors) : item);
650
+ } else if (isPlainRow(v)) {
651
+ out[label] = projectRow(v, target, projectors);
652
+ }
653
+ }
654
+ return out;
655
+ }
656
+
588
657
  // src/routes/query-params.ts
589
658
  var SORTABLE_FIELDS = /* @__PURE__ */ new Set(["title", "created_at", "updated_at"]);
590
659
  var RELATION_FIELD_TYPES = /* @__PURE__ */ new Set([
@@ -605,28 +674,31 @@ function parseInclude(includeParam) {
605
674
  if (!includeParam) return [];
606
675
  return includeParam.split(",").map((s) => s.trim()).filter(Boolean);
607
676
  }
608
- function parseFilters(url, validFields) {
677
+ function parseFilters(url, validFields, columnFor) {
609
678
  const { searchParams } = new URL(url);
610
679
  const filters = {};
680
+ const toKey = (label) => columnFor?.(label) ?? label;
611
681
  for (const [key, value] of searchParams.entries()) {
612
682
  const simpleMatch = /^filter\[([^\]]+)\]$/.exec(key);
613
683
  const opMatch = /^filter\[([^\]]+)\]\[([^\]]+)\]$/.exec(key);
614
684
  if (simpleMatch) {
615
685
  const field = simpleMatch[1];
616
686
  if (!validFields.has(field)) return { ok: false, invalidField: field };
617
- const existing = filters[field];
687
+ const storageKey = toKey(field);
688
+ const existing = filters[storageKey];
618
689
  if (existing !== void 0) {
619
- filters[field] = Array.isArray(existing) ? [...existing, value] : [existing, value];
690
+ filters[storageKey] = Array.isArray(existing) ? [...existing, value] : [existing, value];
620
691
  } else {
621
- filters[field] = value;
692
+ filters[storageKey] = value;
622
693
  }
623
694
  } else if (opMatch) {
624
695
  const field = opMatch[1];
625
696
  const operator = opMatch[2];
626
697
  if (!validFields.has(field)) return { ok: false, invalidField: field };
627
698
  if (!["gt", "gte", "lt", "lte"].includes(operator)) continue;
628
- const existing = filters[field] ?? {};
629
- filters[field] = { ...existing, [operator]: value };
699
+ const storageKey = toKey(field);
700
+ const existing = filters[storageKey] ?? {};
701
+ filters[storageKey] = { ...existing, [operator]: value };
630
702
  }
631
703
  }
632
704
  return { ok: true, filters };
@@ -636,7 +708,7 @@ function parseFilters(url, validFields) {
636
708
  function isPublished(item) {
637
709
  return item["published"] === true;
638
710
  }
639
- function registerPublicContentRoutes(app, registry, repos, listRateLimit, resolver) {
711
+ function registerPublicContentRoutes(app, registry, repos, projectors, paths, listRateLimit, resolver) {
640
712
  function registerListRoute(path, handler) {
641
713
  if (listRateLimit) {
642
714
  app.get(path, listRateLimit, handler);
@@ -644,7 +716,7 @@ function registerPublicContentRoutes(app, registry, repos, listRateLimit, resolv
644
716
  app.get(path, handler);
645
717
  }
646
718
  }
647
- registerListRoute("/api/content", (c) => {
719
+ registerListRoute(paths.collection("content"), (c) => {
648
720
  const data = Object.values(registry.content_types).map((ct) => ({
649
721
  name: ct.name,
650
722
  label: ct.label,
@@ -652,7 +724,7 @@ function registerPublicContentRoutes(app, registry, repos, listRateLimit, resolv
652
724
  }));
653
725
  return c.json({ ok: true, data });
654
726
  });
655
- registerListRoute("/api/taxonomy", (c) => {
727
+ registerListRoute(paths.collection("taxonomy"), (c) => {
656
728
  const data = Object.values(registry.taxonomy_types).map((tt) => ({
657
729
  name: tt.name,
658
730
  label: tt.label
@@ -671,7 +743,7 @@ function registerPublicContentRoutes(app, registry, repos, listRateLimit, resolv
671
743
  ...contentType.system_fields.map((f) => f.name)
672
744
  ]);
673
745
  if (contentType.only_one) {
674
- app.get(`/api/${basePath}`, async (c) => {
746
+ app.get(paths.collection(basePath), async (c) => {
675
747
  const result = await repo.findMany({ published_only: true, page: 1, per_page: 1 });
676
748
  if (result.data.length === 0) {
677
749
  return c.json(
@@ -679,12 +751,14 @@ function registerPublicContentRoutes(app, registry, repos, listRateLimit, resolv
679
751
  404
680
752
  );
681
753
  }
682
- let data = result.data[0];
754
+ let data = projectRow(result.data[0], typeName, projectors);
683
755
  if (resolver?.hasSchema(typeName)) data = await resolver.resolveItem(typeName, data);
684
756
  return c.json({ ok: true, data });
685
757
  });
686
758
  } else {
687
- registerListRoute(`/api/${basePath}`, async (c) => {
759
+ registerListRoute(paths.collection(basePath), async (c) => {
760
+ const projector = projectors[typeName];
761
+ const fieldKeys = projector.map;
688
762
  const pagination = parsePagination(c.req.query("page"), c.req.query("per_page"));
689
763
  if (!pagination.ok) {
690
764
  return c.json(
@@ -724,7 +798,7 @@ function registerPublicContentRoutes(app, registry, repos, listRateLimit, resolv
724
798
  400
725
799
  );
726
800
  }
727
- const filtersResult = parseFilters(c.req.url, filterableFieldNames);
801
+ const filtersResult = parseFilters(c.req.url, filterableFieldNames, fieldKeys.columnFor);
728
802
  if (!filtersResult.ok) {
729
803
  return c.json(
730
804
  {
@@ -752,22 +826,23 @@ function registerPublicContentRoutes(app, registry, repos, listRateLimit, resolv
752
826
  );
753
827
  }
754
828
  }
829
+ const sortColumn = fieldKeys.columnFor(sortBy) ?? sortBy;
755
830
  const result = await repo.findMany({
756
831
  published_only: true,
757
832
  page: pagination.page,
758
833
  per_page: pagination.per_page,
759
- sort_by: sortBy,
834
+ sort_by: sortColumn,
760
835
  sort_order: sortOrder,
761
836
  filters: filtersResult.filters,
762
837
  include
763
838
  });
764
- if (resolver?.hasSchema(typeName)) {
765
- const resolved = await resolver.resolveList(typeName, result.data);
766
- return c.json({ ...result, data: resolved });
767
- }
768
- return c.json(result);
839
+ const labeled = result.data.map(
840
+ (row) => projectRow(row, typeName, projectors)
841
+ );
842
+ const data = resolver?.hasSchema(typeName) ? await resolver.resolveList(typeName, labeled) : labeled;
843
+ return c.json({ ...result, data });
769
844
  });
770
- app.get(`/api/${basePath}/:slug`, async (c) => {
845
+ app.get(paths.item(basePath), async (c) => {
771
846
  const slug = c.req.param("slug");
772
847
  const include = parseInclude(c.req.query("include"));
773
848
  for (const field of include) {
@@ -794,7 +869,7 @@ function registerPublicContentRoutes(app, registry, repos, listRateLimit, resolv
794
869
  404
795
870
  );
796
871
  }
797
- let data = item;
872
+ let data = projectRow(item, typeName, projectors);
798
873
  if (resolver?.hasSchema(typeName)) data = await resolver.resolveItem(typeName, data);
799
874
  return c.json({ ok: true, data });
800
875
  });
@@ -803,7 +878,7 @@ function registerPublicContentRoutes(app, registry, repos, listRateLimit, resolv
803
878
  for (const [typeName] of Object.entries(registry.taxonomy_types)) {
804
879
  const repo = repos[typeName];
805
880
  if (!repo) continue;
806
- registerListRoute(`/api/taxonomy/${typeName}`, async (c) => {
881
+ registerListRoute(paths.taxonomyCollection(typeName), async (c) => {
807
882
  const pagination = parsePagination(c.req.query("page"), c.req.query("per_page"));
808
883
  if (!pagination.ok) {
809
884
  return c.json(
@@ -822,13 +897,13 @@ function registerPublicContentRoutes(app, registry, repos, listRateLimit, resolv
822
897
  page: pagination.page,
823
898
  per_page: pagination.per_page
824
899
  });
825
- if (resolver?.hasSchema(typeName)) {
826
- const resolved = await resolver.resolveList(typeName, result.data);
827
- return c.json({ ...result, data: resolved });
828
- }
829
- return c.json(result);
900
+ const labeled = result.data.map(
901
+ (row) => projectRow(row, typeName, projectors)
902
+ );
903
+ const data = resolver?.hasSchema(typeName) ? await resolver.resolveList(typeName, labeled) : labeled;
904
+ return c.json({ ...result, data });
830
905
  });
831
- app.get(`/api/taxonomy/${typeName}/:id`, async (c) => {
906
+ app.get(paths.taxonomyItem(typeName), async (c) => {
832
907
  const id = c.req.param("id");
833
908
  const item = await repo.findOne(id);
834
909
  if (!item || !isPublished(item)) {
@@ -840,7 +915,7 @@ function registerPublicContentRoutes(app, registry, repos, listRateLimit, resolv
840
915
  404
841
916
  );
842
917
  }
843
- let data = item;
918
+ let data = projectRow(item, typeName, projectors);
844
919
  if (resolver?.hasSchema(typeName)) data = await resolver.resolveItem(typeName, data);
845
920
  return c.json({ ok: true, data });
846
921
  });
@@ -856,7 +931,7 @@ function parsePagination2(pageStr, perPageStr) {
856
931
  if (!Number.isInteger(per_page) || per_page < 1 || per_page > 100) return { ok: false };
857
932
  return { ok: true, page, per_page };
858
933
  }
859
- function registerPublicMediaRoutes(app, mediaRepo, listRateLimit) {
934
+ function registerPublicMediaRoutes(app, mediaRepo, paths, listRateLimit) {
860
935
  const mediaListHandler = async (c) => {
861
936
  const pagination = parsePagination2(c.req.query("page"), c.req.query("per_page"));
862
937
  if (!pagination.ok) {
@@ -895,11 +970,11 @@ function registerPublicMediaRoutes(app, mediaRepo, listRateLimit) {
895
970
  return c.json(result);
896
971
  };
897
972
  if (listRateLimit) {
898
- app.get("/api/media", listRateLimit, mediaListHandler);
973
+ app.get(paths.mediaCollection(), listRateLimit, mediaListHandler);
899
974
  } else {
900
- app.get("/api/media", mediaListHandler);
975
+ app.get(paths.mediaCollection(), mediaListHandler);
901
976
  }
902
- app.get("/api/media/:id", async (c) => {
977
+ app.get(paths.mediaItem(), async (c) => {
903
978
  const id = c.req.param("id");
904
979
  const item = await mediaRepo.findOne(id);
905
980
  if (!item) {
@@ -923,9 +998,11 @@ function topLevelMediaDelta(mediaFields, before, after) {
923
998
  const added = [];
924
999
  const removed = [];
925
1000
  for (const f of mediaFields) {
926
- if (after !== null && !(f.name in after)) continue;
927
- const oldId = before ? extractMediaId(before[f.name]) : null;
928
- const newId = after ? extractMediaId(after[f.name]) : null;
1001
+ const key = f.db_column?.column_name;
1002
+ if (key === void 0 || key === "") continue;
1003
+ if (after !== null && !(key in after)) continue;
1004
+ const oldId = before ? extractMediaId(before[key]) : null;
1005
+ const newId = after ? extractMediaId(after[key]) : null;
929
1006
  if (newId === oldId) continue;
930
1007
  if (oldId) removed.push(oldId);
931
1008
  if (newId) added.push(newId);
@@ -985,7 +1062,7 @@ function isEmpty(value) {
985
1062
  function checkRequiredFields(fields, data) {
986
1063
  return fields.filter((f) => f.required && isEmpty(data[f.name])).map((f) => ({ field: f.name, message: `${f.label} is required` }));
987
1064
  }
988
- function parseListQuery(url, schemaFieldNames, relationFieldNames) {
1065
+ function parseListQuery(url, schemaFieldNames, relationFieldNames, columnFor) {
989
1066
  const searchParams = new URL(url).searchParams;
990
1067
  const pagination = parsePagination(
991
1068
  searchParams.get("page") ?? void 0,
@@ -1020,7 +1097,7 @@ function parseListQuery(url, schemaFieldNames, relationFieldNames) {
1020
1097
  status: 400
1021
1098
  };
1022
1099
  }
1023
- const filtersResult = parseFilters(url, schemaFieldNames);
1100
+ const filtersResult = parseFilters(url, schemaFieldNames, columnFor);
1024
1101
  if (!filtersResult.ok) {
1025
1102
  return {
1026
1103
  ok: false,
@@ -1052,7 +1129,7 @@ function parseListQuery(url, schemaFieldNames, relationFieldNames) {
1052
1129
  search
1053
1130
  };
1054
1131
  }
1055
- function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePermission, db) {
1132
+ function registerAdminContentRoutes(app, registry, repos, projectors, mediaRepo, requirePermission, db) {
1056
1133
  for (const [typeName, contentType] of Object.entries(registry.content_types)) {
1057
1134
  const basePath = `content/${typeName}`;
1058
1135
  const repo = repos[typeName];
@@ -1064,6 +1141,8 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
1064
1141
  const relationFieldNames = new Set(
1065
1142
  contentType.fields.filter((f) => RELATION_FIELD_TYPES.has(f.field_type)).map((f) => f.name)
1066
1143
  );
1144
+ const projector = projectors[typeName];
1145
+ const fieldKeys = projector.map;
1067
1146
  const requiredFields = contentType.fields.filter((f) => f.required);
1068
1147
  const mediaFields = contentType.fields.filter(
1069
1148
  (f) => f.field_type === "image" || f.field_type === "video" || f.field_type === "file"
@@ -1077,17 +1156,18 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
1077
1156
  `/admin/api/${basePath}`,
1078
1157
  requirePermission("content:read"),
1079
1158
  async (c) => {
1080
- const parsed = parseListQuery(c.req.url, schemaFieldNames, relationFieldNames);
1159
+ const parsed = parseListQuery(c.req.url, schemaFieldNames, relationFieldNames, fieldKeys.columnFor);
1081
1160
  if (!parsed.ok) {
1082
1161
  return c.json({ ok: false, error: parsed.response }, parsed.status);
1083
1162
  }
1084
1163
  const publishedParam = c.req.query("published");
1085
1164
  const extraFilters = {};
1086
1165
  if (publishedParam === "false") extraFilters["published"] = false;
1166
+ const sortColumn = fieldKeys.columnFor(parsed.sortBy) ?? parsed.sortBy;
1087
1167
  const findOpts = {
1088
1168
  page: parsed.pagination.page,
1089
1169
  per_page: parsed.pagination.per_page,
1090
- sort_by: parsed.sortBy,
1170
+ sort_by: sortColumn,
1091
1171
  sort_order: parsed.sortOrder,
1092
1172
  filters: { ...parsed.filters, ...extraFilters },
1093
1173
  include: parsed.include
@@ -1097,7 +1177,10 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
1097
1177
  findOpts.search = { term: parsed.search, columns: searchableColumns };
1098
1178
  }
1099
1179
  const result = await repo.findMany(findOpts);
1100
- return c.json(result);
1180
+ const data = result.data.map(
1181
+ (row) => projectRow(row, typeName, projectors)
1182
+ );
1183
+ return c.json({ ...result, data });
1101
1184
  }
1102
1185
  );
1103
1186
  app.get(
@@ -1132,7 +1215,8 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
1132
1215
  }
1133
1216
  }
1134
1217
  }
1135
- return c.json({ ok: true, data: item });
1218
+ const data = projectRow(item, typeName, projectors);
1219
+ return c.json({ ok: true, data });
1136
1220
  }
1137
1221
  );
1138
1222
  app.post(
@@ -1199,6 +1283,7 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
1199
1283
  }
1200
1284
  );
1201
1285
  const writeNewItem = async (c, body) => {
1286
+ const storageBody = fieldKeys.toStorage(body);
1202
1287
  const fieldErrors = checkRequiredFields(requiredFields, body);
1203
1288
  if (fieldErrors.length > 0) {
1204
1289
  return c.json(
@@ -1222,9 +1307,7 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
1222
1307
  const junctionFields = contentType.fields.filter(
1223
1308
  (f) => f.db_column !== null && f.db_column.junction !== void 0
1224
1309
  );
1225
- const columnFields = contentType.fields.filter(
1226
- (f) => f.db_column !== null && f.db_column.junction === void 0
1227
- );
1310
+ const columnBackedFields = contentType.fields.filter(isColumnBacked);
1228
1311
  const columnData = {
1229
1312
  published: body["published"] ?? false
1230
1313
  };
@@ -1233,9 +1316,10 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
1233
1316
  } else {
1234
1317
  columnData["slug"] = body["slug"];
1235
1318
  }
1236
- for (const f of columnFields) {
1237
- const val = body[f.name];
1238
- columnData[f.db_column.column_name] = val !== void 0 ? val : null;
1319
+ for (const f of columnBackedFields) {
1320
+ const key = f.db_column.column_name;
1321
+ if (!(key in storageBody)) continue;
1322
+ columnData[key] = storageBody[key];
1239
1323
  }
1240
1324
  if (db) {
1241
1325
  const basePathId = await lookupBasePathId(db, contentType.default_base_path);
@@ -1255,7 +1339,7 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
1255
1339
  }
1256
1340
  const item = await repo.create(columnData);
1257
1341
  const itemId = item["id"];
1258
- const mediaDeltas = [topLevelMediaDelta(mediaFields, null, body)];
1342
+ const mediaDeltas = [topLevelMediaDelta(mediaFields, null, storageBody)];
1259
1343
  if (db) {
1260
1344
  for (const f of paragraphFields) {
1261
1345
  const comp = f.ui_component;
@@ -1272,7 +1356,8 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
1272
1356
  }
1273
1357
  }
1274
1358
  await applyMediaReferenceDelta(mergeMediaDeltas(...mediaDeltas), mediaRepo);
1275
- return c.json({ ok: true, data: item }, 201);
1359
+ const data = projectRow(item, typeName, projectors);
1360
+ return c.json({ ok: true, data }, 201);
1276
1361
  };
1277
1362
  app.patch(
1278
1363
  `/admin/api/${basePath}/:id`,
@@ -1332,11 +1417,12 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
1332
1417
  }
1333
1418
  );
1334
1419
  const writeExistingItem = async (c, id, existing, body) => {
1420
+ const storageBody = fieldKeys.toStorage(body);
1335
1421
  if (body["published"] === true) {
1336
1422
  const publishDeny = await requirePermission("content:edit")(c, async () => {
1337
1423
  });
1338
1424
  if (publishDeny) return publishDeny;
1339
- const merged = { ...existing, ...body };
1425
+ const merged = { ...fieldKeys.toLabels(existing), ...body };
1340
1426
  const fieldErrors = checkRequiredFields(requiredFields, merged);
1341
1427
  if (fieldErrors.length > 0) {
1342
1428
  return c.json(
@@ -1356,17 +1442,14 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
1356
1442
  const patchJunctionFields = contentType.fields.filter(
1357
1443
  (f) => f.db_column !== null && f.db_column.junction !== void 0
1358
1444
  );
1359
- const patchColumnFields = contentType.fields.filter(
1360
- (f) => f.db_column !== null && f.db_column.junction === void 0
1361
- );
1445
+ const patchColumnBackedFields = contentType.fields.filter(isColumnBacked);
1362
1446
  const patchData = {};
1363
1447
  if (!contentType.only_one && "slug" in body) patchData["slug"] = body["slug"];
1364
1448
  if ("published" in body) patchData["published"] = body["published"];
1365
- for (const f of patchColumnFields) {
1366
- if (f.name in body) {
1367
- const val = body[f.name];
1368
- patchData[f.db_column.column_name] = val !== void 0 ? val : null;
1369
- }
1449
+ for (const f of patchColumnBackedFields) {
1450
+ const key = f.db_column.column_name;
1451
+ if (!(key in storageBody)) continue;
1452
+ patchData[key] = storageBody[key];
1370
1453
  }
1371
1454
  const updated = await repo.update(id, patchData);
1372
1455
  if (!updated) {
@@ -1376,7 +1459,7 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
1376
1459
  );
1377
1460
  }
1378
1461
  const mediaDeltas = [
1379
- topLevelMediaDelta(mediaFields, existing, body)
1462
+ topLevelMediaDelta(mediaFields, existing, storageBody)
1380
1463
  ];
1381
1464
  if (db) {
1382
1465
  for (const f of patchParagraphFields) {
@@ -1394,7 +1477,8 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
1394
1477
  }
1395
1478
  }
1396
1479
  await applyMediaReferenceDelta(mergeMediaDeltas(...mediaDeltas), mediaRepo);
1397
- return c.json({ ok: true, data: updated });
1480
+ const data = projectRow(updated, typeName, projectors);
1481
+ return c.json({ ok: true, data });
1398
1482
  };
1399
1483
  if (contentType.only_one) {
1400
1484
  app.put(
@@ -1454,6 +1538,8 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
1454
1538
  const relationFieldNames = new Set(
1455
1539
  taxonomyType.fields.filter((f) => RELATION_FIELD_TYPES.has(f.field_type)).map((f) => f.name)
1456
1540
  );
1541
+ const projector = projectors[typeName];
1542
+ const fieldKeys = projector.map;
1457
1543
  const requiredFields = taxonomyType.fields.filter((f) => f.required);
1458
1544
  const mediaFields = taxonomyType.fields.filter(
1459
1545
  (f) => f.field_type === "image" || f.field_type === "video" || f.field_type === "file"
@@ -1467,17 +1553,18 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
1467
1553
  `/admin/api/taxonomy/${typeName}`,
1468
1554
  requirePermission("content:read"),
1469
1555
  async (c) => {
1470
- const parsed = parseListQuery(c.req.url, schemaFieldNames, relationFieldNames);
1556
+ const parsed = parseListQuery(c.req.url, schemaFieldNames, relationFieldNames, fieldKeys.columnFor);
1471
1557
  if (!parsed.ok) {
1472
1558
  return c.json({ ok: false, error: parsed.response }, parsed.status);
1473
1559
  }
1474
1560
  const publishedParam = c.req.query("published");
1475
1561
  const extraFilters = {};
1476
1562
  if (publishedParam === "false") extraFilters["published"] = false;
1563
+ const sortColumn = fieldKeys.columnFor(parsed.sortBy) ?? parsed.sortBy;
1477
1564
  const findOpts = {
1478
1565
  page: parsed.pagination.page,
1479
1566
  per_page: parsed.pagination.per_page,
1480
- sort_by: parsed.sortBy,
1567
+ sort_by: sortColumn,
1481
1568
  sort_order: parsed.sortOrder,
1482
1569
  filters: { ...parsed.filters, ...extraFilters },
1483
1570
  include: parsed.include
@@ -1487,7 +1574,10 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
1487
1574
  findOpts.search = { term: parsed.search, columns: searchableColumns };
1488
1575
  }
1489
1576
  const result = await repo.findMany(findOpts);
1490
- return c.json(result);
1577
+ const data = result.data.map(
1578
+ (row) => projectRow(row, typeName, projectors)
1579
+ );
1580
+ return c.json({ ...result, data });
1491
1581
  }
1492
1582
  );
1493
1583
  app.get(
@@ -1502,7 +1592,8 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
1502
1592
  404
1503
1593
  );
1504
1594
  }
1505
- return c.json({ ok: true, data: item });
1595
+ const data = projectRow(item, typeName, projectors);
1596
+ return c.json({ ok: true, data });
1506
1597
  }
1507
1598
  );
1508
1599
  app.post(
@@ -1510,6 +1601,7 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
1510
1601
  requirePermission("content:create"),
1511
1602
  async (c) => {
1512
1603
  const body = await c.req.json();
1604
+ const storageBody = fieldKeys.toStorage(body);
1513
1605
  const fieldErrors = checkRequiredFields(requiredFields, body);
1514
1606
  if (fieldErrors.length > 0) {
1515
1607
  return c.json(
@@ -1529,20 +1621,19 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
1529
1621
  });
1530
1622
  if (publishDeny) return publishDeny;
1531
1623
  }
1532
- const taxColumnFields = taxonomyType.fields.filter(
1533
- (f) => f.db_column !== null && f.db_column.junction === void 0
1534
- );
1535
1624
  const taxParagraphFields = taxonomyType.fields.filter((f) => f.db_column === null);
1625
+ const taxColumnBackedFields = taxonomyType.fields.filter(isColumnBacked);
1536
1626
  const taxColumnData = {
1537
1627
  published: body["published"] ?? false
1538
1628
  };
1539
- for (const f of taxColumnFields) {
1540
- const val = body[f.name];
1541
- taxColumnData[f.db_column.column_name] = val !== void 0 ? val : null;
1629
+ for (const f of taxColumnBackedFields) {
1630
+ const key = f.db_column.column_name;
1631
+ if (!(key in storageBody)) continue;
1632
+ taxColumnData[key] = storageBody[key];
1542
1633
  }
1543
1634
  const item = await repo.create(taxColumnData);
1544
1635
  const taxItemId = item["id"];
1545
- const mediaDeltas = [topLevelMediaDelta(mediaFields, null, body)];
1636
+ const mediaDeltas = [topLevelMediaDelta(mediaFields, null, storageBody)];
1546
1637
  if (db) {
1547
1638
  for (const f of taxParagraphFields) {
1548
1639
  const comp = f.ui_component;
@@ -1554,7 +1645,8 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
1554
1645
  }
1555
1646
  }
1556
1647
  await applyMediaReferenceDelta(mergeMediaDeltas(...mediaDeltas), mediaRepo);
1557
- return c.json({ ok: true, data: item }, 201);
1648
+ const data = projectRow(item, typeName, projectors);
1649
+ return c.json({ ok: true, data }, 201);
1558
1650
  }
1559
1651
  );
1560
1652
  app.patch(
@@ -1563,6 +1655,7 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
1563
1655
  async (c) => {
1564
1656
  const id = c.req.param("id");
1565
1657
  const body = await c.req.json();
1658
+ const storageBody = fieldKeys.toStorage(body);
1566
1659
  const existing = await repo.findOne(id);
1567
1660
  if (!existing) {
1568
1661
  return c.json(
@@ -1574,7 +1667,7 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
1574
1667
  const publishDeny = await requirePermission("content:edit")(c, async () => {
1575
1668
  });
1576
1669
  if (publishDeny) return publishDeny;
1577
- const merged = { ...existing, ...body };
1670
+ const merged = { ...fieldKeys.toLabels(existing), ...body };
1578
1671
  const fieldErrors = checkRequiredFields(requiredFields, merged);
1579
1672
  if (fieldErrors.length > 0) {
1580
1673
  return c.json(
@@ -1590,17 +1683,14 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
1590
1683
  );
1591
1684
  }
1592
1685
  }
1593
- const taxPatchColumnFields = taxonomyType.fields.filter(
1594
- (f) => f.db_column !== null && f.db_column.junction === void 0
1595
- );
1596
1686
  const taxPatchParagraphFields = taxonomyType.fields.filter((f) => f.db_column === null);
1687
+ const taxPatchColumnBackedFields = taxonomyType.fields.filter(isColumnBacked);
1597
1688
  const taxPatchData = {};
1598
1689
  if ("published" in body) taxPatchData["published"] = body["published"];
1599
- for (const f of taxPatchColumnFields) {
1600
- if (f.name in body) {
1601
- const val = body[f.name];
1602
- taxPatchData[f.db_column.column_name] = val !== void 0 ? val : null;
1603
- }
1690
+ for (const f of taxPatchColumnBackedFields) {
1691
+ const key = f.db_column.column_name;
1692
+ if (!(key in storageBody)) continue;
1693
+ taxPatchData[key] = storageBody[key];
1604
1694
  }
1605
1695
  const updated = await repo.update(id, taxPatchData);
1606
1696
  if (!updated) {
@@ -1610,7 +1700,7 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
1610
1700
  );
1611
1701
  }
1612
1702
  const mediaDeltas = [
1613
- topLevelMediaDelta(mediaFields, existing, body)
1703
+ topLevelMediaDelta(mediaFields, existing, storageBody)
1614
1704
  ];
1615
1705
  if (db) {
1616
1706
  for (const f of taxPatchParagraphFields) {
@@ -1623,7 +1713,8 @@ function registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePerm
1623
1713
  }
1624
1714
  }
1625
1715
  await applyMediaReferenceDelta(mergeMediaDeltas(...mediaDeltas), mediaRepo);
1626
- return c.json({ ok: true, data: updated });
1716
+ const data = projectRow(updated, typeName, projectors);
1717
+ return c.json({ ok: true, data });
1627
1718
  }
1628
1719
  );
1629
1720
  app.delete(
@@ -2615,7 +2706,7 @@ function registerUserRoutes(app, db, requirePermission, requireHierarchy) {
2615
2706
  import { sql as sql5 } from "drizzle-orm";
2616
2707
  var API_VERSION = (() => {
2617
2708
  try {
2618
- return "0.3.3";
2709
+ return "0.4.1";
2619
2710
  } catch {
2620
2711
  return "0.0.0";
2621
2712
  }
@@ -2777,7 +2868,7 @@ function whereFragment(conds) {
2777
2868
  return conds.length > 0 ? sql7` WHERE ${sql7.join(conds, sql7` AND `)}` : sql7``;
2778
2869
  }
2779
2870
  function createDrizzleContentRepository(db, tableName, options = {}) {
2780
- const { relations = {}, publishedRelations = false } = options;
2871
+ const { relations = {}, publishedRelations = false, sortableColumns } = options;
2781
2872
  function tableRaw() {
2782
2873
  return sql7.raw(quoteIdent2(tableName));
2783
2874
  }
@@ -2815,8 +2906,9 @@ function createDrizzleContentRepository(db, tableName, options = {}) {
2815
2906
  sort_order = "asc",
2816
2907
  search
2817
2908
  } = opts;
2818
- if (!SORTABLE_FIELDS2.has(sort_by)) {
2819
- throw codeError("INVALID_SORT_FIELD", `'${sort_by}' is not sortable. Allowed: title, created_at, updated_at`);
2909
+ const allowed = sortableColumns ?? SORTABLE_FIELDS2;
2910
+ if (!allowed.has(sort_by)) {
2911
+ throw codeError("INVALID_SORT_FIELD", `'${sort_by}' is not sortable.`);
2820
2912
  }
2821
2913
  const offset = (page - 1) * per_page;
2822
2914
  const conds = buildConditions(published_only, filters, search);
@@ -3026,7 +3118,8 @@ function createCmsApp(options) {
3026
3118
  if (!options.storage) {
3027
3119
  throw new Error(MISSING_STORAGE_ERROR);
3028
3120
  }
3029
- const prefix = options.prefix ?? "/api";
3121
+ const prefix = normalizePrefix(options.prefix);
3122
+ const publicPaths = createPublicPaths(prefix);
3030
3123
  const { storage, registry, db, rateLimit, media, cors } = options;
3031
3124
  const cmsName = options.name ?? "Manguito CMS";
3032
3125
  const maxFileSize = media?.max_file_size;
@@ -3060,16 +3153,38 @@ function createCmsApp(options) {
3060
3153
  return result.rows[0]?.role ?? null;
3061
3154
  };
3062
3155
  const requireHierarchy = createHierarchyMiddleware(rolesRegistry, getUserRole);
3156
+ const fieldKeyMaps = Object.fromEntries([
3157
+ ...Object.entries(registry.content_types).map(([typeName, ct]) => [
3158
+ typeName,
3159
+ createFieldKeyMap(ct.fields)
3160
+ ]),
3161
+ ...Object.entries(registry.taxonomy_types).map(([typeName, tt]) => [
3162
+ typeName,
3163
+ createFieldKeyMap(tt.fields)
3164
+ ]),
3165
+ ...Object.entries(registry.paragraph_types).map(([typeName, pt]) => [
3166
+ typeName,
3167
+ createFieldKeyMap(pt.fields)
3168
+ ])
3169
+ ]);
3170
+ const projectors = buildProjectors(registry, fieldKeyMaps);
3171
+ const sortableColumnsFor = (typeName) => new Set(
3172
+ [...SORTABLE_FIELDS].map((label) => fieldKeyMaps[typeName].columnFor(label) ?? label)
3173
+ );
3063
3174
  const contentRepos = Object.fromEntries(
3064
3175
  Object.entries(registry.content_types).map(([typeName, ct]) => [
3065
3176
  typeName,
3066
- createDrizzleContentRepository(db, ct.db.table_name)
3177
+ createDrizzleContentRepository(db, ct.db.table_name, {
3178
+ sortableColumns: sortableColumnsFor(typeName)
3179
+ })
3067
3180
  ])
3068
3181
  );
3069
3182
  const taxonomyRepos = Object.fromEntries(
3070
3183
  Object.entries(registry.taxonomy_types).map(([typeName, tt]) => [
3071
3184
  typeName,
3072
- createDrizzleContentRepository(db, tt.db.table_name)
3185
+ createDrizzleContentRepository(db, tt.db.table_name, {
3186
+ sortableColumns: sortableColumnsFor(typeName)
3187
+ })
3073
3188
  ])
3074
3189
  );
3075
3190
  const repos = { ...contentRepos, ...taxonomyRepos };
@@ -3079,7 +3194,8 @@ function createCmsApp(options) {
3079
3194
  typeName,
3080
3195
  createDrizzleContentRepository(db, ct.db.table_name, {
3081
3196
  relations: buildRelationsMap(ct.fields, registry),
3082
- publishedRelations: true
3197
+ publishedRelations: true,
3198
+ sortableColumns: sortableColumnsFor(typeName)
3083
3199
  })
3084
3200
  ])
3085
3201
  );
@@ -3088,7 +3204,8 @@ function createCmsApp(options) {
3088
3204
  typeName,
3089
3205
  createDrizzleContentRepository(db, tt.db.table_name, {
3090
3206
  relations: buildRelationsMap(tt.fields, registry),
3091
- publishedRelations: true
3207
+ publishedRelations: true,
3208
+ sortableColumns: sortableColumnsFor(typeName)
3092
3209
  })
3093
3210
  ])
3094
3211
  );
@@ -3096,33 +3213,41 @@ function createCmsApp(options) {
3096
3213
  const graphqlRepos = Object.fromEntries([
3097
3214
  ...Object.entries(registry.content_types).map(([typeName, ct]) => [
3098
3215
  typeName,
3099
- createDrizzleContentRepository(db, ct.db.table_name)
3216
+ createDrizzleContentRepository(db, ct.db.table_name, {
3217
+ sortableColumns: sortableColumnsFor(typeName)
3218
+ })
3100
3219
  ]),
3101
3220
  ...Object.entries(registry.taxonomy_types).map(([typeName, tt]) => [
3102
3221
  typeName,
3103
- createDrizzleContentRepository(db, tt.db.table_name)
3222
+ createDrizzleContentRepository(db, tt.db.table_name, {
3223
+ sortableColumns: sortableColumnsFor(typeName)
3224
+ })
3104
3225
  ])
3105
3226
  ]);
3106
3227
  registerAuthRoutes(app, db);
3107
3228
  app.use("/admin/api/*", authMiddleware);
3108
3229
  app.use("/admin/api/*", mustChangePasswordCheck);
3109
- app.get("/api/openapi.json", (c) => {
3230
+ app.get(publicPaths.openapi(), (c) => {
3110
3231
  const paths = {};
3111
3232
  for (const ct of Object.values(registry.content_types)) {
3112
3233
  const base = ct.default_base_path;
3113
3234
  if (ct.only_one) {
3114
- paths[`/api/${base}`] = { get: { summary: `Get ${ct.label}`, tags: [ct.label] } };
3235
+ paths[publicPaths.collection(base)] = { get: { summary: `Get ${ct.label}`, tags: [ct.label] } };
3115
3236
  } else {
3116
- paths[`/api/${base}`] = { get: { summary: `List published ${ct.label}`, tags: [ct.label] } };
3117
- paths[`/api/${base}/{slug}`] = { get: { summary: `Get ${ct.label} by slug`, tags: [ct.label] } };
3237
+ paths[publicPaths.collection(base)] = { get: { summary: `List published ${ct.label}`, tags: [ct.label] } };
3238
+ paths[publicPaths.item(base).replace(":slug", "{slug}")] = {
3239
+ get: { summary: `Get ${ct.label} by slug`, tags: [ct.label] }
3240
+ };
3118
3241
  }
3119
3242
  }
3120
3243
  for (const tt of Object.values(registry.taxonomy_types)) {
3121
- paths[`/api/taxonomy/${tt.name}`] = { get: { summary: `List published ${tt.label}`, tags: [tt.label] } };
3122
- paths[`/api/taxonomy/${tt.name}/{id}`] = { get: { summary: `Get ${tt.label} by id`, tags: [tt.label] } };
3244
+ paths[publicPaths.taxonomyCollection(tt.name)] = { get: { summary: `List published ${tt.label}`, tags: [tt.label] } };
3245
+ paths[publicPaths.taxonomyItem(tt.name).replace(":id", "{id}")] = {
3246
+ get: { summary: `Get ${tt.label} by id`, tags: [tt.label] }
3247
+ };
3123
3248
  }
3124
- paths["/api/media"] = { get: { summary: "List media items", tags: ["Media"] } };
3125
- paths["/api/media/{id}"] = { get: { summary: "Get media item by id", tags: ["Media"] } };
3249
+ paths[publicPaths.mediaCollection()] = { get: { summary: "List media items", tags: ["Media"] } };
3250
+ paths[publicPaths.mediaItem().replace(":id", "{id}")] = { get: { summary: "Get media item by id", tags: ["Media"] } };
3126
3251
  return c.json({
3127
3252
  openapi: "3.0.3",
3128
3253
  info: { title: "Manguito CMS Public API", version: "1.0.0" },
@@ -3137,14 +3262,14 @@ function createCmsApp(options) {
3137
3262
  paths: {}
3138
3263
  })
3139
3264
  );
3140
- registerPublicContentRoutes(app, registry, publicRepos, listRateLimit, programmaticResolver);
3141
- registerPublicMediaRoutes(app, mediaRepo, listRateLimit);
3265
+ registerPublicContentRoutes(app, registry, publicRepos, projectors, publicPaths, listRateLimit, programmaticResolver);
3266
+ registerPublicMediaRoutes(app, mediaRepo, publicPaths, listRateLimit);
3142
3267
  if (options.graphql?.enabled) {
3143
3268
  const gqlOptions = options.graphql;
3144
3269
  let gqlHandler = null;
3145
3270
  let initError = null;
3146
- const ready = import("./handler-CGHZG3J5.mjs").then(({ createGraphQLHandler }) => {
3147
- gqlHandler = createGraphQLHandler(registry, graphqlRepos, programmaticResolver, db, gqlOptions);
3271
+ const ready = import("./handler-7LVYEU5C.mjs").then(({ createGraphQLHandler }) => {
3272
+ gqlHandler = createGraphQLHandler(registry, graphqlRepos, fieldKeyMaps, programmaticResolver, db, gqlOptions);
3148
3273
  }).catch((err) => {
3149
3274
  initError = err;
3150
3275
  const message = err instanceof Error ? err.message : String(err);
@@ -3184,7 +3309,7 @@ function createCmsApp(options) {
3184
3309
  );
3185
3310
  registerSchemaRoute(app, registry, db);
3186
3311
  registerUserRoutes(app, db, requirePermission, requireHierarchy);
3187
- registerAdminContentRoutes(app, registry, repos, mediaRepo, requirePermission, db);
3312
+ registerAdminContentRoutes(app, registry, repos, projectors, mediaRepo, requirePermission, db);
3188
3313
  registerAdminMediaRoutes(app, mediaRepo, storage, requirePermission, maxFileSize);
3189
3314
  return { prefix, app };
3190
3315
  }
@@ -3225,7 +3350,7 @@ function resolveGraphQL(cfg) {
3225
3350
  };
3226
3351
  }
3227
3352
  function createAPIAdapter(options = {}) {
3228
- const prefix = options.prefix ?? "/api";
3353
+ const prefix = normalizePrefix(options.prefix);
3229
3354
  const media = options.media?.max_file_size !== void 0 ? { max_file_size: options.media.max_file_size } : void 0;
3230
3355
  return {
3231
3356
  prefix,