@easy-cms/core 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -82,6 +82,31 @@ var PayloadTooLargeError = class extends EasyCMSError {
82
82
  }
83
83
  };
84
84
 
85
+ // src/fields.ts
86
+ var hasRows = (field) => field.type === "array" || field.type === "blocks";
87
+ function rowFields(field, row) {
88
+ if (field.type === "array") return field.fields;
89
+ const type = row && typeof row === "object" ? row.blockType : void 0;
90
+ return field.blocks.find((block) => block.slug === type)?.fields;
91
+ }
92
+ var FIELD_TYPES = [
93
+ "text",
94
+ "textarea",
95
+ "number",
96
+ "boolean",
97
+ "date",
98
+ "email",
99
+ "json",
100
+ "select",
101
+ "slug",
102
+ "richText",
103
+ "upload",
104
+ "relationship",
105
+ "array",
106
+ "group",
107
+ "blocks"
108
+ ];
109
+
85
110
  // src/access-control.ts
86
111
  async function evaluateAccess(access, args) {
87
112
  const result = await (access ?? isLoggedIn)(args);
@@ -123,11 +148,12 @@ async function stripFields(fields, doc, read) {
123
148
  const value = result[field.name];
124
149
  if (field.type === "group" && value && typeof value === "object") {
125
150
  result[field.name] = await stripFields(field.fields, value, read);
126
- } else if (field.type === "array" && Array.isArray(value)) {
151
+ } else if (hasRows(field) && Array.isArray(value)) {
127
152
  result[field.name] = await Promise.all(
128
- value.map(
129
- (row) => row && typeof row === "object" ? stripFields(field.fields, row, read) : row
130
- )
153
+ value.map((row) => {
154
+ const fields2 = rowFields(field, row);
155
+ return fields2 && row && typeof row === "object" ? stripFields(fields2, row, read) : row;
156
+ })
131
157
  );
132
158
  }
133
159
  }
@@ -144,11 +170,12 @@ async function filterInput(fields, data, update) {
144
170
  const value = result[field.name];
145
171
  if (field.type === "group" && value && typeof value === "object" && !Array.isArray(value)) {
146
172
  result[field.name] = await filterInput(field.fields, value, update);
147
- } else if (field.type === "array" && Array.isArray(value)) {
173
+ } else if (hasRows(field) && Array.isArray(value)) {
148
174
  result[field.name] = await Promise.all(
149
- value.map(
150
- (row) => row && typeof row === "object" ? filterInput(field.fields, row, update) : row
151
- )
175
+ value.map((row) => {
176
+ const fields2 = rowFields(field, row);
177
+ return fields2 && row && typeof row === "object" ? filterInput(fields2, row, update) : row;
178
+ })
152
179
  );
153
180
  }
154
181
  }
@@ -161,10 +188,12 @@ var MEDIA = "media";
161
188
  var SESSIONS = "sessions";
162
189
  var LOGIN_ATTEMPTS = "login-attempts";
163
190
  var VERSIONS = "document-versions";
191
+ var SCHEDULED_JOBS = "scheduled-jobs";
164
192
  var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
165
193
  SESSIONS,
166
194
  LOGIN_ATTEMPTS,
167
- VERSIONS
195
+ VERSIONS,
196
+ SCHEDULED_JOBS
168
197
  ]);
169
198
  var DEFAULT_ROLES = ["admin", "editor"];
170
199
  var adminOrSelf = ({ user }) => {
@@ -345,6 +374,22 @@ var versionsCollection = {
345
374
  { name: "snapshot", type: "json", required: true }
346
375
  ]
347
376
  };
377
+ var scheduledJobsCollection = {
378
+ slug: SCHEDULED_JOBS,
379
+ access: { read: nobody, create: nobody, update: nobody, delete: nobody },
380
+ fields: [
381
+ // Collection slug, or `global:<slug>`.
382
+ { name: "parent", type: "text", required: true, index: true },
383
+ // Document id; 0 for globals.
384
+ { name: "doc", type: "number", required: true, index: true },
385
+ { name: "action", type: "text", required: true },
386
+ { name: "runAt", type: "text", required: true, index: true },
387
+ // pending | done | failed
388
+ { name: "state", type: "text", required: true, index: true },
389
+ { name: "error", type: "text" },
390
+ { name: "author", type: "number" }
391
+ ]
392
+ };
348
393
 
349
394
  // src/auth/password.ts
350
395
  import { randomBytes, scrypt, timingSafeEqual } from "crypto";
@@ -419,6 +464,30 @@ function safeEqual(a, b) {
419
464
  const right = Buffer.from(b);
420
465
  return left.length === right.length && timingSafeEqual2(left, right);
421
466
  }
467
+ function signPreviewToken(secret, target, expiresAt) {
468
+ const payload = Buffer.from(
469
+ JSON.stringify(
470
+ "global" in target ? { g: target.global, e: expiresAt } : { c: target.collection, i: target.id, e: expiresAt }
471
+ )
472
+ ).toString("base64url");
473
+ return `${payload}.${hmac(secret, `preview:${payload}`)}`;
474
+ }
475
+ function verifyPreviewToken(secret, token, now = Date.now()) {
476
+ const dot = token.lastIndexOf(".");
477
+ if (dot <= 0) return null;
478
+ const payload = token.slice(0, dot);
479
+ if (!safeEqual(token.slice(dot + 1), hmac(secret, `preview:${payload}`))) return null;
480
+ try {
481
+ const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
482
+ if (typeof data.e !== "number" || data.e <= now) return null;
483
+ if (typeof data.g === "string") return { global: data.g, expiresAt: data.e };
484
+ if (typeof data.c === "string" && typeof data.i === "string")
485
+ return { collection: data.c, id: data.i, expiresAt: data.e };
486
+ return null;
487
+ } catch {
488
+ return null;
489
+ }
490
+ }
422
491
 
423
492
  // src/auth/auth.ts
424
493
  var INVALID = "Invalid email or password";
@@ -620,17 +689,30 @@ var isEmpty = (value) => value === void 0 || value === null || value === "";
620
689
  function slugify(text) {
621
690
  return text.normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "");
622
691
  }
623
- function applyDefaults(fields, data) {
692
+ function applyDefaults(fields, data, localization = null) {
624
693
  const result = { ...data };
625
694
  for (const field of fields) {
626
695
  const value = result[field.name];
627
696
  if (field.type === "group") {
628
- result[field.name] = applyDefaults(field.fields, isPlainObject(value) ? value : {});
629
- } else if (field.type === "array") {
697
+ result[field.name] = applyDefaults(
698
+ field.fields,
699
+ isPlainObject(value) ? value : {},
700
+ localization
701
+ );
702
+ } else if (hasRows(field) && !field.localized) {
630
703
  if (Array.isArray(value)) {
631
- result[field.name] = value.map(
632
- (row) => isPlainObject(row) ? applyDefaults(field.fields, row) : row
633
- );
704
+ result[field.name] = value.map((row) => {
705
+ const fields2 = rowFields(field, row);
706
+ return fields2 && isPlainObject(row) ? applyDefaults(fields2, row, localization) : row;
707
+ });
708
+ }
709
+ } else if (field.localized && localization && field.defaultValue !== void 0) {
710
+ const map = isPlainObject(value) ? value : {};
711
+ if (map[localization.defaultLocale] === void 0) {
712
+ result[field.name] = {
713
+ ...map,
714
+ [localization.defaultLocale]: structuredClone(field.defaultValue)
715
+ };
634
716
  }
635
717
  } else if (value === void 0 && field.defaultValue !== void 0) {
636
718
  result[field.name] = structuredClone(field.defaultValue);
@@ -645,28 +727,41 @@ function fillMissing(fields, data) {
645
727
  if (field.type === "group") {
646
728
  result[field.name] = fillMissing(field.fields, isPlainObject(value) ? value : {});
647
729
  } else if (value === void 0) {
648
- result[field.name] = field.type === "array" || isHasMany(field) ? [] : null;
730
+ result[field.name] = hasRows(field) || isHasMany(field) ? [] : null;
649
731
  }
650
732
  }
651
733
  return result;
652
734
  }
653
- function generateSlugs(fields, data) {
735
+ function generateSlugs(fields, data, localization = null) {
654
736
  const result = { ...data };
655
737
  for (const field of fields) {
656
- if (field.type === "slug") {
738
+ if (field.type === "slug" && field.localized && localization) {
739
+ const map = isPlainObject(result[field.name]) ? result[field.name] : {};
740
+ const source = field.from ? result[field.from] : void 0;
741
+ const next = { ...map };
742
+ for (const locale of localization.locales) {
743
+ const current = map[locale];
744
+ const from = isPlainObject(source) ? source[locale] : source;
745
+ if (typeof current === "string" && current !== "") next[locale] = slugify(current);
746
+ else if (typeof from === "string" && from.trim() !== "") next[locale] = slugify(from);
747
+ }
748
+ result[field.name] = next;
749
+ } else if (field.type === "slug") {
657
750
  const current = result[field.name];
658
751
  if (typeof current === "string" && current !== "") {
659
752
  result[field.name] = slugify(current);
660
753
  } else if (field.from) {
661
- const source = result[field.from];
754
+ const raw = result[field.from];
755
+ const source = isPlainObject(raw) && localization ? raw[localization.defaultLocale] : raw;
662
756
  if (typeof source === "string" && source.trim() !== "") result[field.name] = slugify(source);
663
757
  }
664
758
  } else if (field.type === "group" && isPlainObject(result[field.name])) {
665
- result[field.name] = generateSlugs(field.fields, result[field.name]);
666
- } else if (field.type === "array" && Array.isArray(result[field.name])) {
667
- result[field.name] = result[field.name].map(
668
- (row) => isPlainObject(row) ? generateSlugs(field.fields, row) : row
669
- );
759
+ result[field.name] = generateSlugs(field.fields, result[field.name], localization);
760
+ } else if (hasRows(field) && Array.isArray(result[field.name])) {
761
+ result[field.name] = result[field.name].map((row) => {
762
+ const fields2 = rowFields(field, row);
763
+ return fields2 && isPlainObject(row) ? generateSlugs(fields2, row, localization) : row;
764
+ });
670
765
  }
671
766
  }
672
767
  return result;
@@ -717,9 +812,45 @@ async function validateFields(fields, input, options, prefix = "") {
717
812
  references.push(...sub.references);
718
813
  continue;
719
814
  }
815
+ const localization = options.localization;
816
+ if (field.localized && localization) {
817
+ if (!isEmpty(raw) && !isPlainObject(raw)) {
818
+ fail("must be an object with one value per locale");
819
+ continue;
820
+ }
821
+ const map = isPlainObject(raw) ? raw : {};
822
+ const written = options.locale ?? localization.defaultLocale;
823
+ const out = {};
824
+ for (const locale of localization.locales) {
825
+ const own = locale === written;
826
+ const localePath = own ? path : `${path}.${locale}`;
827
+ const failLocale = (message) => errors.push({ field: localePath, message });
828
+ const value2 = map[locale];
829
+ if (isEmpty(value2) || Array.isArray(value2) && value2.length === 0) {
830
+ if (own && field.required && !options.skipRequired) failLocale("is required");
831
+ out[locale] = hasRows(field) || isHasMany(field) ? [] : null;
832
+ if (own && !field.required) await runCustom(field, null, options, failLocale);
833
+ continue;
834
+ }
835
+ const normalized = await normalizeValue(
836
+ field,
837
+ value2,
838
+ localePath,
839
+ options,
840
+ failLocale,
841
+ errors,
842
+ references
843
+ );
844
+ if (normalized === void 0) continue;
845
+ out[locale] = normalized;
846
+ if (own) await runCustom(field, normalized, options, failLocale);
847
+ }
848
+ data[field.name] = out;
849
+ continue;
850
+ }
720
851
  if (isEmpty(raw) || Array.isArray(raw) && raw.length === 0) {
721
852
  if (field.required && !options.skipRequired) fail("is required");
722
- data[field.name] = field.type === "array" || isHasMany(field) ? [] : null;
853
+ data[field.name] = hasRows(field) || isHasMany(field) ? [] : null;
723
854
  if (!field.required) await runCustom(field, null, options, fail);
724
855
  continue;
725
856
  }
@@ -821,10 +952,36 @@ async function normalizeValue(field, raw, path, options, fail, errors, reference
821
952
  }
822
953
  return rows;
823
954
  }
955
+ case "blocks":
956
+ return normalizeBlocks(field, raw, path, options, fail, errors, references);
824
957
  case "group":
825
958
  return raw;
826
959
  }
827
960
  }
961
+ async function normalizeBlocks(field, raw, path, options, fail, errors, references) {
962
+ if (!Array.isArray(raw)) return fail("must be an array of blocks");
963
+ if (field.minRows !== void 0 && raw.length < field.minRows)
964
+ return fail(`must have at least ${field.minRows} blocks`);
965
+ if (field.maxRows !== void 0 && raw.length > field.maxRows)
966
+ return fail(`must have at most ${field.maxRows} blocks`);
967
+ const rows = [];
968
+ for (const [i, row] of raw.entries()) {
969
+ const fields = rowFields(field, row);
970
+ if (!isPlainObject(row) || !fields) {
971
+ errors.push({
972
+ field: `${path}.${i}.blockType`,
973
+ message: `must be one of: ${field.blocks.map((b) => b.slug).join(", ")}`
974
+ });
975
+ continue;
976
+ }
977
+ const sub = await validateFields(fields, row, options, `${path}.${i}.`);
978
+ errors.push(...sub.errors);
979
+ references.push(...sub.references);
980
+ const id = typeof row.id === "string" && row.id !== "" ? row.id : randomUUID();
981
+ rows.push({ id, blockType: row.blockType, ...sub.data });
982
+ }
983
+ return rows;
984
+ }
828
985
  function normalizeSelect(field, raw, fail) {
829
986
  const allowed2 = field.options.map((o) => typeof o === "string" ? o : o.value);
830
987
  const values = field.hasMany ? raw : [raw];
@@ -836,29 +993,94 @@ function normalizeSelect(field, raw, fail) {
836
993
  return field.hasMany ? [...new Set(values)] : values[0];
837
994
  }
838
995
 
839
- // src/fields.ts
840
- var FIELD_TYPES = [
841
- "text",
842
- "textarea",
843
- "number",
844
- "boolean",
845
- "date",
846
- "email",
847
- "json",
848
- "select",
849
- "slug",
850
- "richText",
851
- "upload",
852
- "relationship",
853
- "array",
854
- "group"
855
- ];
856
-
857
996
  // src/load-config.ts
858
997
  import { existsSync } from "fs";
859
998
  import { resolve } from "path";
860
999
  import { createJiti } from "jiti";
861
1000
 
1001
+ // src/webhooks.ts
1002
+ import { createHmac as createHmac2, randomUUID as randomUUID2 } from "crypto";
1003
+ var WEBHOOK_EVENTS = [
1004
+ "create",
1005
+ "update",
1006
+ "delete",
1007
+ "publish",
1008
+ "unpublish",
1009
+ "draft"
1010
+ ];
1011
+ var RETRY_DELAYS = [1e3, 5e3];
1012
+ var TIMEOUT = 1e4;
1013
+ var Webhooks = class {
1014
+ constructor(hooks, logger, fetcher = (...args) => fetch(...args), retryDelays = RETRY_DELAYS) {
1015
+ this.hooks = hooks;
1016
+ this.logger = logger;
1017
+ this.fetcher = fetcher;
1018
+ this.retryDelays = retryDelays;
1019
+ }
1020
+ hooks;
1021
+ logger;
1022
+ fetcher;
1023
+ retryDelays;
1024
+ pending = /* @__PURE__ */ new Set();
1025
+ emit(event, target, doc) {
1026
+ if (this.hooks.length === 0) return;
1027
+ const payload = { event, ...target, doc, timestamp: (/* @__PURE__ */ new Date()).toISOString() };
1028
+ for (const hook of this.hooks) {
1029
+ if (!matches(hook, payload)) continue;
1030
+ const delivery = this.deliver(hook, payload).finally(() => this.pending.delete(delivery));
1031
+ this.pending.add(delivery);
1032
+ }
1033
+ }
1034
+ /** Waits for deliveries in progress, e.g. before a serverless function returns. */
1035
+ async flush() {
1036
+ while (this.pending.size > 0) await Promise.allSettled([...this.pending]);
1037
+ }
1038
+ async deliver(hook, payload) {
1039
+ const body = JSON.stringify(payload);
1040
+ const headers = {
1041
+ "content-type": "application/json",
1042
+ "user-agent": "easy-cms-webhooks",
1043
+ "x-easy-cms-event": payload.event,
1044
+ "x-easy-cms-delivery": randomUUID2(),
1045
+ ...hook.headers
1046
+ };
1047
+ if (hook.secret) {
1048
+ headers["x-easy-cms-signature"] = `sha256=${createHmac2("sha256", hook.secret).update(body).digest("hex")}`;
1049
+ }
1050
+ for (let attempt = 0; ; attempt++) {
1051
+ try {
1052
+ const response = await this.fetcher(hook.url, {
1053
+ method: "POST",
1054
+ headers,
1055
+ body,
1056
+ signal: AbortSignal.timeout(TIMEOUT)
1057
+ });
1058
+ if (response.ok || response.status < 500 && response.status !== 429) {
1059
+ if (!response.ok)
1060
+ this.logger.warn(`Webhook ${hook.url} answered ${response.status} to ${payload.event}`);
1061
+ return;
1062
+ }
1063
+ throw new Error(`status ${response.status}`);
1064
+ } catch (error) {
1065
+ const delay = this.retryDelays[attempt];
1066
+ if (delay === void 0) {
1067
+ this.logger.error(
1068
+ `Webhook ${hook.url} failed for ${payload.event}: ${error.message}`
1069
+ );
1070
+ return;
1071
+ }
1072
+ await new Promise((resolve3) => setTimeout(resolve3, delay));
1073
+ }
1074
+ }
1075
+ }
1076
+ };
1077
+ function matches(hook, payload) {
1078
+ if (hook.events && !hook.events.includes(payload.event)) return false;
1079
+ if (payload.collection !== void 0)
1080
+ return !hook.collections || hook.collections.includes(payload.collection);
1081
+ return !hook.globals || hook.globals.includes(payload.global);
1082
+ }
1083
+
862
1084
  // src/validate-config.ts
863
1085
  var MIN_SECRET_LENGTH = 32;
864
1086
  var BUILTIN_COLLECTIONS = ["users", "media"];
@@ -868,6 +1090,8 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
868
1090
  "sessions",
869
1091
  "login-attempts",
870
1092
  "document-versions",
1093
+ "scheduled-jobs",
1094
+ "jobs",
871
1095
  "migrations",
872
1096
  "access"
873
1097
  ]);
@@ -889,6 +1113,8 @@ function validateConfig(config) {
889
1113
  validateUpload(config, add);
890
1114
  validateAuth(config, add);
891
1115
  validateCors(config.cors, add);
1116
+ validateLocalization(config, add);
1117
+ validateWebhooks(config, add);
892
1118
  const collections = asArray(config.collections, "collections", add);
893
1119
  const globals = asArray(config.globals, "globals", add);
894
1120
  const collectionSlugs = /* @__PURE__ */ new Set([...BUILTIN_COLLECTIONS]);
@@ -965,6 +1191,112 @@ function validateAuth(config, add) {
965
1191
  }
966
1192
  }
967
1193
  }
1194
+ var LOCALE_PATTERN = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
1195
+ var LOCALIZABLE = /* @__PURE__ */ new Set([
1196
+ "text",
1197
+ "textarea",
1198
+ "email",
1199
+ "slug",
1200
+ "richText",
1201
+ "number",
1202
+ "boolean",
1203
+ "date",
1204
+ "json",
1205
+ "select",
1206
+ "upload",
1207
+ "relationship",
1208
+ "blocks",
1209
+ "array"
1210
+ ]);
1211
+ function validateLocalization(config, add) {
1212
+ const localization = config.localization;
1213
+ if (localization !== void 0 && localization !== null) {
1214
+ const locales = localization?.locales;
1215
+ if (!Array.isArray(locales) || locales.length === 0) {
1216
+ add("localization.locales", "must be a non-empty list of locales", "locales: ['th', 'en']");
1217
+ } else {
1218
+ const seen = /* @__PURE__ */ new Set();
1219
+ for (const [i, locale] of locales.entries()) {
1220
+ if (typeof locale !== "string" || !LOCALE_PATTERN.test(locale)) {
1221
+ add(
1222
+ `localization.locales[${i}]`,
1223
+ `must be a locale code like "th" or "en-US" (got ${JSON.stringify(locale)})`
1224
+ );
1225
+ } else if (seen.has(locale)) {
1226
+ add(`localization.locales[${i}]`, `duplicate locale "${locale}"`);
1227
+ }
1228
+ seen.add(locale);
1229
+ }
1230
+ const fallbackDefault = localization.defaultLocale;
1231
+ if (fallbackDefault !== void 0 && !locales.includes(fallbackDefault)) {
1232
+ add("localization.defaultLocale", `must be one of the locales (got "${fallbackDefault}")`);
1233
+ }
1234
+ }
1235
+ }
1236
+ const walk = (fields, path, insideLocalized = false) => {
1237
+ if (!Array.isArray(fields)) return;
1238
+ for (const field of fields) {
1239
+ if (typeof field?.name !== "string") continue;
1240
+ const fieldPath = `${path}.${field.name}`;
1241
+ if (field.localized) {
1242
+ if (!localization) {
1243
+ add(
1244
+ `${fieldPath}.localized`,
1245
+ "needs `localization` in the config",
1246
+ "localization: { locales: ['th', 'en'] }"
1247
+ );
1248
+ } else if (!LOCALIZABLE.has(field.type)) {
1249
+ add(
1250
+ `${fieldPath}.localized`,
1251
+ `${field.type} fields cannot be localized`,
1252
+ "localize the fields inside it instead"
1253
+ );
1254
+ } else if (insideLocalized) {
1255
+ add(
1256
+ `${fieldPath}.localized`,
1257
+ "is inside a localized field, which already holds one value per locale"
1258
+ );
1259
+ }
1260
+ }
1261
+ const inside = insideLocalized || field.localized === true;
1262
+ if (field.type === "group" || field.type === "array")
1263
+ walk(field.fields, `${fieldPath}.fields`, inside);
1264
+ if (field.type === "blocks")
1265
+ for (const block of field.blocks ?? [])
1266
+ walk(block?.fields, `${fieldPath}.blocks.${block?.slug}`, inside);
1267
+ }
1268
+ };
1269
+ for (const [i, c] of (config.collections ?? []).entries())
1270
+ walk(c?.fields, `collections[${i}].fields`);
1271
+ for (const [i, g] of (config.globals ?? []).entries()) walk(g?.fields, `globals[${i}].fields`);
1272
+ }
1273
+ function validateWebhooks(config, add) {
1274
+ const hooks = config.webhooks;
1275
+ if (hooks === void 0) return;
1276
+ if (!Array.isArray(hooks)) {
1277
+ add("webhooks", "must be a list", "webhooks: [{ url: 'https://example.com/hook' }]");
1278
+ return;
1279
+ }
1280
+ const collections = /* @__PURE__ */ new Set([
1281
+ ...BUILTIN_COLLECTIONS,
1282
+ ...(config.collections ?? []).map((c) => c?.slug)
1283
+ ]);
1284
+ const globals = new Set((config.globals ?? []).map((g) => g?.slug));
1285
+ for (const [i, hook] of hooks.entries()) {
1286
+ const path = `webhooks[${i}]`;
1287
+ if (typeof hook?.url !== "string" || !/^https?:\/\/\S+$/.test(hook.url)) {
1288
+ add(`${path}.url`, "must be an http(s) URL");
1289
+ }
1290
+ for (const event of hook?.events ?? []) {
1291
+ if (!WEBHOOK_EVENTS.includes(event))
1292
+ add(`${path}.events`, `unknown event "${event}"`, `use: ${WEBHOOK_EVENTS.join(", ")}`);
1293
+ }
1294
+ for (const slug of hook?.collections ?? [])
1295
+ if (!collections.has(slug)) add(`${path}.collections`, `unknown collection "${slug}"`);
1296
+ for (const slug of hook?.globals ?? [])
1297
+ if (!globals.has(slug)) add(`${path}.globals`, `unknown global "${slug}"`);
1298
+ }
1299
+ }
968
1300
  function validateCors(cors, add) {
969
1301
  if (cors === void 0 || cors === "*") return;
970
1302
  if (!Array.isArray(cors)) {
@@ -1032,10 +1364,14 @@ function validateContainer(container, path, collectionSlugs, add) {
1032
1364
  add(`${path}.versions.max`, "must be a positive integer");
1033
1365
  }
1034
1366
  }
1367
+ if (container.schedule && !container.drafts) {
1368
+ add(`${path}.schedule`, "needs `drafts: true`", "scheduling publishes and unpublishes drafts");
1369
+ }
1035
1370
  if (container.preview !== void 0 && typeof container.preview !== "function") {
1036
1371
  add(
1037
1372
  `${path}.preview`,
1038
1373
  "must be a function ({ doc }) => url",
1374
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: an example shown to the user
1039
1375
  "preview: ({ doc }) => `/posts/${doc.slug}`"
1040
1376
  );
1041
1377
  }
@@ -1115,6 +1451,43 @@ function validateField(field, path, siblings, collectionSlugs, add) {
1115
1451
  checkRange(field.minRows, field.maxRows, "minRows", "maxRows", path, add);
1116
1452
  validateFields2(field.fields, `${path}.fields`, /* @__PURE__ */ new Set(["id"]), collectionSlugs, add);
1117
1453
  break;
1454
+ case "blocks": {
1455
+ if (!Array.isArray(field.blocks) || field.blocks.length === 0) {
1456
+ add(
1457
+ `${path}.blocks`,
1458
+ "must contain at least one block",
1459
+ "blocks: [{ slug: 'hero', fields: [...] }]"
1460
+ );
1461
+ break;
1462
+ }
1463
+ checkRange(field.minRows, field.maxRows, "minRows", "maxRows", path, add);
1464
+ const seen = /* @__PURE__ */ new Set();
1465
+ for (const [i, block] of field.blocks.entries()) {
1466
+ const blockPath = `${path}.blocks[${i}]`;
1467
+ if (typeof block?.slug !== "string" || !SLUG_PATTERN.test(block.slug)) {
1468
+ add(
1469
+ `${blockPath}.slug`,
1470
+ 'must be lowercase letters, digits, "-" or "_", starting with a letter'
1471
+ );
1472
+ } else if (seen.has(block.slug)) {
1473
+ add(`${blockPath}.slug`, `duplicate block "${block.slug}"`);
1474
+ } else {
1475
+ seen.add(block.slug);
1476
+ }
1477
+ if (!Array.isArray(block?.fields)) {
1478
+ add(`${blockPath}.fields`, "must be an array");
1479
+ continue;
1480
+ }
1481
+ validateFields2(
1482
+ block.fields,
1483
+ `${blockPath}.fields`,
1484
+ /* @__PURE__ */ new Set(["id", "blockType"]),
1485
+ collectionSlugs,
1486
+ add
1487
+ );
1488
+ }
1489
+ break;
1490
+ }
1118
1491
  }
1119
1492
  }
1120
1493
  function checkRange(min, max, minName, maxName, path, add) {
@@ -1179,6 +1552,11 @@ async function resolveConfig(input) {
1179
1552
  const result = {
1180
1553
  ...rest,
1181
1554
  cors: config.cors ?? [],
1555
+ localization: config.localization ? {
1556
+ locales: config.localization.locales,
1557
+ defaultLocale: config.localization.defaultLocale ?? config.localization.locales[0] ?? "",
1558
+ fallback: config.localization.fallback ?? true
1559
+ } : null,
1182
1560
  routes: { api: `/${(config.routes?.api ?? DEFAULT_API_PATH).replace(/^\/+|\/+$/g, "")}` },
1183
1561
  admin: {
1184
1562
  path: config.admin?.path ?? DEFAULT_ADMIN_PATH,
@@ -1201,7 +1579,8 @@ async function resolveConfig(input) {
1201
1579
  collections: [
1202
1580
  ...config.collections ?? [],
1203
1581
  ...internalCollections,
1204
- ...usesVersions(config) ? [versionsCollection] : []
1582
+ ...usesVersions(config) ? [versionsCollection] : [],
1583
+ ...usesSchedule(config) ? [scheduledJobsCollection] : []
1205
1584
  ],
1206
1585
  globals: config.globals ?? []
1207
1586
  };
@@ -1211,6 +1590,9 @@ async function resolveConfig(input) {
1211
1590
  function usesVersions(config) {
1212
1591
  return [...config.collections ?? [], ...config.globals ?? []].some((c) => c.versions);
1213
1592
  }
1593
+ function usesSchedule(config) {
1594
+ return [...config.collections ?? [], ...config.globals ?? []].some((c) => c.schedule);
1595
+ }
1214
1596
 
1215
1597
  // src/load-config.ts
1216
1598
  var CONFIG_FILE_NAMES = [
@@ -1260,6 +1642,98 @@ async function loadConfig(options = {}) {
1260
1642
  // src/local-api.ts
1261
1643
  import { randomBytes as randomBytes3 } from "crypto";
1262
1644
 
1645
+ // src/localization.ts
1646
+ var ALL_LOCALES = "all";
1647
+ var isPlainObject2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1648
+ var isEmpty2 = (value) => value === void 0 || value === null || value === "";
1649
+ function pickLocale(fields, data, locale, localization, fallback) {
1650
+ if (locale === ALL_LOCALES) return data;
1651
+ const result = { ...data };
1652
+ for (const field of fields) {
1653
+ const value = result[field.name];
1654
+ if (field.localized) {
1655
+ const map = isPlainObject2(value) ? value : {};
1656
+ const own = map[locale];
1657
+ const blank = isEmpty2(own) || Array.isArray(own) && own.length === 0;
1658
+ const picked = blank && fallback ? map[localization.defaultLocale] ?? null : own ?? null;
1659
+ const list = hasRows(field) || "hasMany" in field && field.hasMany === true;
1660
+ result[field.name] = picked === null && list ? [] : picked;
1661
+ } else if (field.type === "group" && isPlainObject2(value)) {
1662
+ result[field.name] = pickLocale(field.fields, value, locale, localization, fallback);
1663
+ } else if (hasRows(field) && Array.isArray(value)) {
1664
+ result[field.name] = value.map((row) => {
1665
+ const fields2 = rowFields(field, row);
1666
+ return fields2 && isPlainObject2(row) ? pickLocale(fields2, row, locale, localization, fallback) : row;
1667
+ });
1668
+ }
1669
+ }
1670
+ return result;
1671
+ }
1672
+ function toLocaleMaps(fields, input, current, locale) {
1673
+ const result = { ...input };
1674
+ for (const field of fields) {
1675
+ if (!Object.hasOwn(input, field.name)) continue;
1676
+ const value = input[field.name];
1677
+ const previous = current[field.name];
1678
+ if (field.localized) {
1679
+ const map = isPlainObject2(previous) ? previous : {};
1680
+ result[field.name] = { ...map, [locale]: value };
1681
+ } else if (field.type === "group" && isPlainObject2(value)) {
1682
+ result[field.name] = toLocaleMaps(
1683
+ field.fields,
1684
+ value,
1685
+ isPlainObject2(previous) ? previous : {},
1686
+ locale
1687
+ );
1688
+ } else if (hasRows(field) && Array.isArray(value)) {
1689
+ const rows = new Map(
1690
+ (Array.isArray(previous) ? previous : []).filter(isPlainObject2).map((row) => [String(row.id), row])
1691
+ );
1692
+ result[field.name] = value.map((row) => {
1693
+ const fields2 = rowFields(field, row);
1694
+ return fields2 && isPlainObject2(row) ? toLocaleMaps(fields2, row, rows.get(String(row.id)) ?? {}, locale) : row;
1695
+ });
1696
+ }
1697
+ }
1698
+ return result;
1699
+ }
1700
+ function localizePath(fields, path, locale, locales = []) {
1701
+ const segments = path.split(".");
1702
+ let list = fields;
1703
+ for (let i = 0; i < segments.length; i++) {
1704
+ const field = list.find((f) => f.name === segments[i]);
1705
+ if (!field) return path;
1706
+ if (field.localized) {
1707
+ const next = segments[i + 1];
1708
+ if (next !== void 0 && locales.includes(next)) return path;
1709
+ return [...segments.slice(0, i + 1), locale, ...segments.slice(i + 1)].join(".");
1710
+ }
1711
+ if (field.type === "group" || field.type === "array") {
1712
+ list = field.fields;
1713
+ continue;
1714
+ }
1715
+ return path;
1716
+ }
1717
+ return path;
1718
+ }
1719
+ function localizeWhere(fields, where, locale, locales = []) {
1720
+ if (!where) return where;
1721
+ const out = {};
1722
+ for (const [key2, value] of Object.entries(where)) {
1723
+ if ((key2 === "and" || key2 === "or") && Array.isArray(value)) {
1724
+ out[key2] = value.map((w) => localizeWhere(fields, w, locale, locales));
1725
+ } else {
1726
+ out[localizePath(fields, key2, locale, locales)] = value;
1727
+ }
1728
+ }
1729
+ return out;
1730
+ }
1731
+ function localizeSort(fields, sort, locale, locales = []) {
1732
+ return sort.map(
1733
+ (entry) => entry.startsWith("-") ? `-${localizePath(fields, entry.slice(1), locale, locales)}` : localizePath(fields, entry, locale, locales)
1734
+ );
1735
+ }
1736
+
1263
1737
  // src/logger.ts
1264
1738
  var consoleLogger = {
1265
1739
  info: (message) => console.info(`[easy-cms] ${message}`),
@@ -1400,10 +1874,16 @@ function collectSlots(fields, data, bySlug, slots) {
1400
1874
  if (value === null || value === void 0) continue;
1401
1875
  if (field.type === "group") {
1402
1876
  if (typeof value === "object") collectSlots(field.fields, value, bySlug, slots);
1403
- } else if (field.type === "array") {
1404
- if (Array.isArray(value))
1405
- for (const row of value) collectSlots(field.fields, row, bySlug, slots);
1877
+ } else if (hasRows(field)) {
1878
+ if (Array.isArray(value)) {
1879
+ for (const row of value) {
1880
+ const fields2 = rowFields(field, row);
1881
+ if (fields2 && row && typeof row === "object")
1882
+ collectSlots(fields2, row, bySlug, slots);
1883
+ }
1884
+ }
1406
1885
  } else if (field.type === "relationship" || field.type === "upload") {
1886
+ if (typeof value === "object" && !Array.isArray(value)) continue;
1407
1887
  const collection2 = field.type === "upload" ? "media" : field.to;
1408
1888
  if (!bySlug.has(collection2)) continue;
1409
1889
  if (Array.isArray(value)) {
@@ -1618,8 +2098,19 @@ async function createEasyCMS(config, options = {}) {
1618
2098
  logger,
1619
2099
  interactive: options.interactive ?? false
1620
2100
  });
1621
- return new EasyCMS(resolved2, db, logger, storage);
2101
+ const cms = new EasyCMS(resolved2, db, logger, storage);
2102
+ const scheduling = [...resolved2.collections, ...resolved2.globals].some((c) => c.schedule);
2103
+ if (scheduling && options.scheduler !== false) cms.startScheduler();
2104
+ return cms;
1622
2105
  }
2106
+ var toJob = (row) => ({
2107
+ id: row.id,
2108
+ action: row.action,
2109
+ runAt: String(row.runAt),
2110
+ state: row.state,
2111
+ error: row.error ?? null,
2112
+ author: row.author ?? null
2113
+ });
1623
2114
  var EasyCMS = class {
1624
2115
  config;
1625
2116
  db;
@@ -1629,12 +2120,14 @@ var EasyCMS = class {
1629
2120
  /** Where uploaded files are stored. */
1630
2121
  storage;
1631
2122
  versions;
2123
+ webhooks;
1632
2124
  constructor(config, db, logger = consoleLogger, storage = localStorage({ dir: config.upload.dir })) {
1633
2125
  this.config = config;
1634
2126
  this.db = db;
1635
2127
  this.logger = logger;
1636
2128
  this.storage = storage;
1637
2129
  this.versions = new VersionStore(db);
2130
+ this.webhooks = new Webhooks(config.webhooks ?? [], logger);
1638
2131
  this.auth = new Auth(this);
1639
2132
  }
1640
2133
  async find(collection2, options = {}) {
@@ -1645,12 +2138,19 @@ var EasyCMS = class {
1645
2138
  if (!Number.isInteger(limit) || limit < 0)
1646
2139
  throw new QueryError("limit must be a non-negative integer");
1647
2140
  if (!Number.isInteger(page) || page < 1) throw new QueryError("page must be a positive integer");
1648
- const where = draftWhere(
2141
+ const where = this.whereFor(
1649
2142
  config,
1650
- options.draft,
1651
- await this.readWhere(config, guard, options.where)
2143
+ draftWhere(config, options.draft, await this.readWhere(config, guard, options.where)),
2144
+ options
2145
+ );
2146
+ const requested = options.sort === void 0 ? ["-createdAt"] : [options.sort].flat();
2147
+ const locale = this.localeOf(options);
2148
+ const sort = locale === void 0 ? requested : localizeSort(
2149
+ config.fields,
2150
+ requested,
2151
+ locale === ALL_LOCALES ? this.config.localization?.defaultLocale ?? locale : locale,
2152
+ this.config.localization?.locales ?? []
1652
2153
  );
1653
- const sort = options.sort === void 0 ? ["-createdAt"] : [options.sort].flat();
1654
2154
  const result = await this.db.find({ collection: collection2, where, sort, limit, page });
1655
2155
  const found = options.draft ? await this.withDrafts(config, result.docs) : result.docs;
1656
2156
  const docs = await this.output(config, found, guard, options);
@@ -1661,7 +2161,11 @@ var EasyCMS = class {
1661
2161
  const guard = guardOf(options);
1662
2162
  const parsed = parseId(id);
1663
2163
  if (parsed === void 0) return null;
1664
- const where = draftWhere(config, options.draft, await this.readWhere(config, guard, void 0));
2164
+ const where = this.whereFor(
2165
+ config,
2166
+ draftWhere(config, options.draft, await this.readWhere(config, guard, void 0)),
2167
+ options
2168
+ );
1665
2169
  const doc = where ? (await this.db.find({
1666
2170
  collection: collection2,
1667
2171
  where: andWhere(where, { id: { equals: parsed } }),
@@ -1676,10 +2180,14 @@ var EasyCMS = class {
1676
2180
  }
1677
2181
  async count(collection2, options = {}) {
1678
2182
  const config = this.collection(collection2);
1679
- const where = draftWhere(
2183
+ const where = this.whereFor(
1680
2184
  config,
1681
- options.draft,
1682
- await this.readWhere(config, guardOf(options), options.where)
2185
+ draftWhere(
2186
+ config,
2187
+ options.draft,
2188
+ await this.readWhere(config, guardOf(options), options.where)
2189
+ ),
2190
+ options
1683
2191
  );
1684
2192
  return this.db.count({ collection: collection2, where });
1685
2193
  }
@@ -1829,7 +2337,8 @@ var EasyCMS = class {
1829
2337
  collection2,
1830
2338
  parsed,
1831
2339
  config.drafts ? { ...data, status: "draft" } : data,
1832
- options
2340
+ options,
2341
+ "restore"
1833
2342
  );
1834
2343
  }
1835
2344
  /**
@@ -1861,14 +2370,34 @@ var EasyCMS = class {
1861
2370
  input,
1862
2371
  this.fieldChecker("update", guard, parsed, input)
1863
2372
  );
1864
- const merged = base ? mergeForUpdate(config.fields, base, filtered) : applyDefaults(config.fields, filtered);
2373
+ const localized = this.toMaps(config, filtered, base ?? {}, options);
2374
+ const merged = base ? mergeForUpdate(config.fields, base, localized) : applyDefaults(config.fields, localized, this.config.localization);
1865
2375
  if (config.drafts) merged.status = filtered.status ?? base?.status ?? "draft";
1866
- const doc = await this.previewDoc(config, generateSlugs(config.fields, merged), {
1867
- id: parsed ?? 0,
1868
- createdAt: base?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
1869
- });
2376
+ const doc = await this.previewDoc(
2377
+ config,
2378
+ generateSlugs(config.fields, merged, this.config.localization),
2379
+ {
2380
+ id: parsed ?? 0,
2381
+ createdAt: base?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
2382
+ },
2383
+ options
2384
+ );
1870
2385
  const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
1871
- return { doc: out, url: this.previewURL(config, out) };
2386
+ return { doc: out, url: this.previewURL(config, out, options) };
2387
+ }
2388
+ /**
2389
+ * A token that lets a page read one document's current draft for a while (default one hour),
2390
+ * without a login: `GET /api/cms/:collection/:id?preview=<token>`. The admin adds one to the
2391
+ * preview URL as `easy-cms-preview`, for sites on another origin.
2392
+ */
2393
+ createPreviewToken(target, options = {}) {
2394
+ const seconds = options.expiresIn ?? 60 * 60;
2395
+ const normalized = "global" in target ? target : { collection: target.collection, id: String(target.id) };
2396
+ return signPreviewToken(this.config.secret, normalized, Date.now() + seconds * 1e3);
2397
+ }
2398
+ /** What a preview token opens, or `null` when it is invalid or expired. */
2399
+ verifyPreviewToken(token) {
2400
+ return token ? verifyPreviewToken(this.config.secret, token) : null;
1872
2401
  }
1873
2402
  /** Live preview of a global. See `preview`. */
1874
2403
  async previewGlobal(slug, data, options = {}) {
@@ -1885,20 +2414,28 @@ var EasyCMS = class {
1885
2414
  const current = await this.globalDraft(config, saved);
1886
2415
  const merged = generateSlugs(
1887
2416
  config.fields,
1888
- applyDefaults(config.fields, mergeForUpdate(config.fields, current, input))
2417
+ applyDefaults(
2418
+ config.fields,
2419
+ mergeForUpdate(config.fields, current, this.toMaps(config, input, current, options)),
2420
+ this.config.localization
2421
+ ),
2422
+ this.config.localization
1889
2423
  );
1890
2424
  if (config.drafts) merged.status = input.status ?? current.status ?? "draft";
1891
- const doc = await this.previewDoc(config, merged, { id: 0 });
2425
+ const doc = await this.previewDoc(config, merged, { id: 0 }, options);
1892
2426
  const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
1893
2427
  const { id: _id, ...global2 } = out;
1894
- return { doc: global2, url: this.previewURL(config, global2) };
2428
+ return { doc: global2, url: this.previewURL(config, global2, options) };
1895
2429
  }
1896
2430
  /** Coerces preview input like a save would, keeping values that would fail validation. */
1897
- async previewDoc(config, data, system) {
2431
+ async previewDoc(config, data, system, options = {}) {
2432
+ const locale = this.localeOf(options);
1898
2433
  const { data: clean } = await validateFields(config.fields, data, {
1899
2434
  operation: "update",
1900
2435
  root: data,
1901
- skipRequired: true
2436
+ skipRequired: true,
2437
+ localization: this.config.localization,
2438
+ ...locale !== void 0 && locale !== ALL_LOCALES ? { locale } : {}
1902
2439
  });
1903
2440
  return {
1904
2441
  ...data,
@@ -1908,10 +2445,12 @@ var EasyCMS = class {
1908
2445
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1909
2446
  };
1910
2447
  }
1911
- previewURL(config, doc) {
2448
+ previewURL(config, doc, options) {
1912
2449
  if (!config.preview) return null;
2450
+ const content = this.localeOf(options);
2451
+ const locale = content && content !== ALL_LOCALES ? content : this.config.admin.locale;
1913
2452
  try {
1914
- return config.preview({ doc, locale: this.config.admin.locale }) ?? null;
2453
+ return config.preview({ doc, locale }) ?? null;
1915
2454
  } catch (error) {
1916
2455
  this.logger.error(`preview URL of "${config.slug}" failed: ${error.message}`);
1917
2456
  return null;
@@ -1940,7 +2479,8 @@ var EasyCMS = class {
1940
2479
  input,
1941
2480
  this.fieldChecker("update", guard, parsed, input)
1942
2481
  );
1943
- let merged = mergeForUpdate(config.fields, current, filtered);
2482
+ const localized = mode === "restore" ? filtered : this.toMaps(config, filtered, current, options);
2483
+ let merged = mergeForUpdate(config.fields, current, localized);
1944
2484
  if (config.drafts)
1945
2485
  merged.status = Object.hasOwn(filtered, "status") ? filtered.status : current.status;
1946
2486
  const base = this.hookArgs(config, guard);
@@ -1952,9 +2492,10 @@ var EasyCMS = class {
1952
2492
  );
1953
2493
  let prepared = await this.prepare(
1954
2494
  config,
1955
- generateSlugs(config.fields, merged),
2495
+ generateSlugs(config.fields, merged, this.config.localization),
1956
2496
  "update",
1957
- parsed
2497
+ parsed,
2498
+ options
1958
2499
  );
1959
2500
  prepared = await this.transform(
1960
2501
  config.hooks?.beforeChange,
@@ -1963,7 +2504,7 @@ var EasyCMS = class {
1963
2504
  prepared
1964
2505
  );
1965
2506
  const now = (/* @__PURE__ */ new Date()).toISOString();
1966
- if (mode === "save" && this.separateDrafts(config) && existing.status === "published" && prepared.status === "draft") {
2507
+ if (mode !== "unpublish" && this.separateDrafts(config) && existing.status === "published" && prepared.status === "draft") {
1967
2508
  if (password !== void 0) {
1968
2509
  throw new ValidationError(collection2, [
1969
2510
  { field: "password", message: "cannot be changed in a draft; publish instead" }
@@ -1977,6 +2518,7 @@ var EasyCMS = class {
1977
2518
  previousDoc: current,
1978
2519
  operation: "update"
1979
2520
  });
2521
+ this.emit(config, "draft", draft);
1980
2522
  const [out2] = await this.output(config, [draft], guard, {
1981
2523
  ...options,
1982
2524
  draft: true
@@ -1998,6 +2540,7 @@ var EasyCMS = class {
1998
2540
  previousDoc: current,
1999
2541
  operation: "update"
2000
2542
  });
2543
+ this.emit(config, "update", doc, existing.status);
2001
2544
  const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
2002
2545
  return out;
2003
2546
  }
@@ -2015,11 +2558,16 @@ var EasyCMS = class {
2015
2558
  if (config.slug === USERS) await this.auth.revokeSessions(parsed);
2016
2559
  await this.db.delete({ collection: collection2, id: parsed });
2017
2560
  if (versionLimit(config)) await this.versions.deleteAll(collectionParent(collection2), parsed);
2561
+ if (config.schedule) {
2562
+ for (const job of await this.pendingJobs(collection2, parsed))
2563
+ await this.db.delete({ collection: SCHEDULED_JOBS, id: job.id });
2564
+ }
2018
2565
  await this.notify(config.hooks?.afterDelete, "afterDelete", config.slug, {
2019
2566
  ...base,
2020
2567
  id: parsed,
2021
2568
  doc: existing
2022
2569
  });
2570
+ this.emit(config, "delete", existing);
2023
2571
  const [out] = await this.output(config, [existing], guard, { depth: 0, draft: true });
2024
2572
  return out;
2025
2573
  }
@@ -2029,7 +2577,10 @@ var EasyCMS = class {
2029
2577
  await this.checkGlobalAccess(config, "read", guard);
2030
2578
  const saved = await this.db.findGlobal({ slug });
2031
2579
  const stored = saved && options.draft ? await this.globalDraft(config, saved) : saved;
2032
- const data = fillMissing(config.fields, applyDefaults(config.fields, stored ?? {}));
2580
+ const data = fillMissing(
2581
+ config.fields,
2582
+ applyDefaults(config.fields, stored ?? {}, this.config.localization)
2583
+ );
2033
2584
  if (!stored) {
2034
2585
  data.updatedAt = null;
2035
2586
  if (config.drafts) data.status = "draft";
@@ -2066,7 +2617,10 @@ var EasyCMS = class {
2066
2617
  const config = await this.globalVersionTarget(slug, options);
2067
2618
  const version = await this.versions.get(globalParent(slug), 0, versionId);
2068
2619
  if (!version) return null;
2069
- const data = fillMissing(config.fields, applyDefaults(config.fields, version.data));
2620
+ const data = fillMissing(
2621
+ config.fields,
2622
+ applyDefaults(config.fields, version.data, this.config.localization)
2623
+ );
2070
2624
  const [out] = await this.output(config, [{ ...data, id: 0 }], guardOf(options), {
2071
2625
  ...options,
2072
2626
  draft: true
@@ -2083,7 +2637,8 @@ var EasyCMS = class {
2083
2637
  return this.saveGlobal(
2084
2638
  slug,
2085
2639
  config.drafts ? { ...data, status: "draft" } : data,
2086
- options
2640
+ options,
2641
+ "restore"
2087
2642
  );
2088
2643
  }
2089
2644
  async globalVersionTarget(slug, options) {
@@ -2110,13 +2665,19 @@ var EasyCMS = class {
2110
2665
  );
2111
2666
  const existing = await this.db.findGlobal({ slug }) ?? {};
2112
2667
  const current = await this.globalDraft(config, existing);
2668
+ const localized = mode === "restore" ? input : this.toMaps(config, input, current, options);
2113
2669
  const merged = generateSlugs(
2114
2670
  config.fields,
2115
- applyDefaults(config.fields, mergeForUpdate(config.fields, current, input))
2671
+ applyDefaults(
2672
+ config.fields,
2673
+ mergeForUpdate(config.fields, current, localized),
2674
+ this.config.localization
2675
+ ),
2676
+ this.config.localization
2116
2677
  );
2117
2678
  if (config.drafts) merged.status = input.status ?? current.status ?? "draft";
2118
2679
  const base = this.hookArgs(config, guard);
2119
- let prepared = await this.prepare(config, merged, "update", void 0);
2680
+ let prepared = await this.prepare(config, merged, "update", void 0, options);
2120
2681
  prepared = await this.transform(
2121
2682
  config.hooks?.beforeChange,
2122
2683
  "data",
@@ -2124,7 +2685,7 @@ var EasyCMS = class {
2124
2685
  prepared
2125
2686
  );
2126
2687
  const now = (/* @__PURE__ */ new Date()).toISOString();
2127
- if (mode === "save" && this.separateDrafts(config) && existing.status === "published" && prepared.status === "draft") {
2688
+ if (mode !== "unpublish" && this.separateDrafts(config) && existing.status === "published" && prepared.status === "draft") {
2128
2689
  const draft = { ...prepared, updatedAt: now };
2129
2690
  await this.saveVersion(config, globalParent(slug), 0, draft, guard);
2130
2691
  await this.notify(config.hooks?.afterChange, "afterChange", slug, {
@@ -2133,6 +2694,7 @@ var EasyCMS = class {
2133
2694
  previousDoc: current,
2134
2695
  operation: "update"
2135
2696
  });
2697
+ this.emit(config, "draft", draft);
2136
2698
  return this.findGlobal(slug, { ...options, draft: true });
2137
2699
  }
2138
2700
  const doc = await this.db.updateGlobal({ slug, data: { ...prepared, updatedAt: now } });
@@ -2143,6 +2705,7 @@ var EasyCMS = class {
2143
2705
  previousDoc: current,
2144
2706
  operation: "update"
2145
2707
  });
2708
+ this.emit(config, "update", doc, existing.status);
2146
2709
  return this.findGlobal(slug, { ...options, draft: true });
2147
2710
  }
2148
2711
  /**
@@ -2164,9 +2727,177 @@ var EasyCMS = class {
2164
2727
  return { update: await check("update"), delete: await check("delete") };
2165
2728
  }
2166
2729
  /** Closes the database connection. */
2730
+ /**
2731
+ * Waits until webhook deliveries in progress are done. Call it before a serverless function
2732
+ * returns, which may otherwise stop them.
2733
+ */
2734
+ flushWebhooks() {
2735
+ return this.webhooks.flush();
2736
+ }
2167
2737
  async destroy() {
2738
+ if (this.schedulerTimer) clearInterval(this.schedulerTimer);
2739
+ await this.running;
2740
+ await this.webhooks.flush();
2168
2741
  await this.db.destroy();
2169
2742
  }
2743
+ // --- Scheduled publishing --------------------------------------------------
2744
+ schedulerTimer;
2745
+ running = Promise.resolve();
2746
+ /** Runs due jobs every `interval` ms in this process (`createEasyCMS` does this by default). */
2747
+ startScheduler(interval = 6e4) {
2748
+ if (this.schedulerTimer) return;
2749
+ this.schedulerTimer = setInterval(() => {
2750
+ this.running = this.running.then(
2751
+ () => this.runScheduled().catch(
2752
+ (error) => this.logger.error(`Scheduled jobs failed: ${error.message}`)
2753
+ )
2754
+ );
2755
+ }, interval);
2756
+ this.schedulerTimer.unref?.();
2757
+ }
2758
+ /**
2759
+ * Publishes or unpublishes documents whose scheduled time has come. Safe to call often, e.g.
2760
+ * from a cron job (`GET <api>/jobs/run`) where no process keeps running.
2761
+ */
2762
+ async runScheduled(now = /* @__PURE__ */ new Date()) {
2763
+ if (!this.config.collections.some((c) => c.slug === SCHEDULED_JOBS))
2764
+ return { ran: 0, failed: 0 };
2765
+ const due = await this.db.find({
2766
+ collection: SCHEDULED_JOBS,
2767
+ where: {
2768
+ and: [{ state: { equals: "pending" } }, { runAt: { lte: now.toISOString() } }]
2769
+ },
2770
+ sort: ["runAt"],
2771
+ limit: 100,
2772
+ page: 1
2773
+ });
2774
+ let failed = 0;
2775
+ for (const row of due.docs) {
2776
+ const parent = String(row.parent);
2777
+ const action = row.action;
2778
+ try {
2779
+ if (parent.startsWith("global:")) {
2780
+ const slug = parent.slice("global:".length);
2781
+ if (action === "publish") await this.saveGlobal(slug, { status: "published" }, {});
2782
+ else await this.saveGlobal(slug, { status: "draft" }, {}, "unpublish");
2783
+ } else if (action === "publish") {
2784
+ await this.updateDocument(parent, row.doc, { status: "published" }, { depth: 0 });
2785
+ } else {
2786
+ await this.updateDocument(
2787
+ parent,
2788
+ row.doc,
2789
+ { status: "draft" },
2790
+ { depth: 0 },
2791
+ "unpublish"
2792
+ );
2793
+ }
2794
+ await this.db.update({
2795
+ collection: SCHEDULED_JOBS,
2796
+ id: row.id,
2797
+ data: { ...row, state: "done" }
2798
+ });
2799
+ } catch (error) {
2800
+ failed++;
2801
+ const message = error.message;
2802
+ this.logger.error(`Scheduled ${action} of ${parent} ${row.doc} failed: ${message}`);
2803
+ await this.db.update({
2804
+ collection: SCHEDULED_JOBS,
2805
+ id: row.id,
2806
+ data: { ...row, state: "failed", error: message.slice(0, 500) }
2807
+ });
2808
+ }
2809
+ }
2810
+ return { ran: due.docs.length - failed, failed };
2811
+ }
2812
+ /** Schedules a publish or unpublish, replacing a pending job with the same action. */
2813
+ async schedule(collection2, id, job, options = {}) {
2814
+ const { parsed } = await this.scheduleTarget(collection2, id, options);
2815
+ return this.addJob(collection2, parsed, job, options);
2816
+ }
2817
+ /** Pending jobs of a document, soonest first. */
2818
+ async scheduled(collection2, id, options = {}) {
2819
+ const { parsed } = await this.scheduleTarget(collection2, id, options);
2820
+ return this.pendingJobs(collection2, parsed);
2821
+ }
2822
+ async cancelSchedule(collection2, id, jobId, options = {}) {
2823
+ const { parsed } = await this.scheduleTarget(collection2, id, options);
2824
+ await this.removeJob(collection2, parsed, jobId);
2825
+ }
2826
+ async scheduleGlobal(slug, job, options = {}) {
2827
+ await this.globalScheduleTarget(slug, options);
2828
+ return this.addJob(`global:${slug}`, 0, job, options);
2829
+ }
2830
+ async scheduledGlobal(slug, options = {}) {
2831
+ await this.globalScheduleTarget(slug, options);
2832
+ return this.pendingJobs(`global:${slug}`, 0);
2833
+ }
2834
+ async cancelGlobalSchedule(slug, jobId, options = {}) {
2835
+ await this.globalScheduleTarget(slug, options);
2836
+ await this.removeJob(`global:${slug}`, 0, jobId);
2837
+ }
2838
+ async scheduleTarget(collection2, id, options) {
2839
+ const config = this.collection(collection2);
2840
+ if (!config.schedule) throw new QueryError(`"${collection2}" has no schedule`);
2841
+ const parsed = parseId(id);
2842
+ const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
2843
+ if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
2844
+ await this.checkDocumentAccess(config, "update", guardOf(options), parsed, void 0);
2845
+ return { config, parsed };
2846
+ }
2847
+ async globalScheduleTarget(slug, options) {
2848
+ const config = this.global(slug);
2849
+ if (!config.schedule) throw new QueryError(`"${slug}" has no schedule`);
2850
+ await this.checkGlobalAccess(config, "update", guardOf(options));
2851
+ }
2852
+ async addJob(parent, doc, job, options) {
2853
+ const runAt = new Date(job.at);
2854
+ const errors = [];
2855
+ if (job.action !== "publish" && job.action !== "unpublish")
2856
+ errors.push({ field: "action", message: 'must be "publish" or "unpublish"' });
2857
+ if (Number.isNaN(runAt.getTime())) errors.push({ field: "at", message: "must be a valid date" });
2858
+ if (errors.length > 0) throw new ValidationError(SCHEDULED_JOBS, errors);
2859
+ for (const pending of await this.pendingJobs(parent, doc)) {
2860
+ if (pending.action === job.action)
2861
+ await this.db.delete({ collection: SCHEDULED_JOBS, id: pending.id });
2862
+ }
2863
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2864
+ const row = await this.db.create({
2865
+ collection: SCHEDULED_JOBS,
2866
+ data: {
2867
+ parent,
2868
+ doc,
2869
+ action: job.action,
2870
+ runAt: runAt.toISOString(),
2871
+ state: "pending",
2872
+ error: null,
2873
+ author: guardOf(options).user?.id ?? null,
2874
+ createdAt: now,
2875
+ updatedAt: now
2876
+ }
2877
+ });
2878
+ return toJob(row);
2879
+ }
2880
+ async pendingJobs(parent, doc) {
2881
+ const rows = await this.db.find({
2882
+ collection: SCHEDULED_JOBS,
2883
+ where: {
2884
+ and: [
2885
+ { parent: { equals: parent } },
2886
+ { doc: { equals: doc } },
2887
+ { state: { equals: "pending" } }
2888
+ ]
2889
+ },
2890
+ sort: ["runAt"],
2891
+ limit: 0,
2892
+ page: 1
2893
+ });
2894
+ return rows.docs.map(toJob);
2895
+ }
2896
+ async removeJob(parent, doc, jobId) {
2897
+ const job = (await this.pendingJobs(parent, doc)).find((j) => String(j.id) === String(jobId));
2898
+ if (!job) throw new NotFoundError("scheduled job", jobId);
2899
+ await this.db.delete({ collection: SCHEDULED_JOBS, id: job.id });
2900
+ }
2170
2901
  // -------------------------------------------------------------------------
2171
2902
  /** @internal */
2172
2903
  collection(slug) {
@@ -2195,11 +2926,11 @@ var EasyCMS = class {
2195
2926
  });
2196
2927
  if (access === true) return;
2197
2928
  if (access !== false) {
2198
- const matches = await this.db.count({
2929
+ const matches2 = await this.db.count({
2199
2930
  collection: config.slug,
2200
2931
  where: andWhere(access, { id: { equals: id } })
2201
2932
  });
2202
- if (matches > 0) return;
2933
+ if (matches2 > 0) return;
2203
2934
  }
2204
2935
  throw deny(guard.user);
2205
2936
  }
@@ -2238,16 +2969,19 @@ var EasyCMS = class {
2238
2969
  if (where === null) return [];
2239
2970
  const found = await this.db.find({
2240
2971
  collection: target.slug,
2241
- where: draftWhere(target, options.draft, where),
2972
+ where: this.whereFor(target, draftWhere(target, options.draft, where), options),
2242
2973
  sort: [],
2243
2974
  limit: 0,
2244
2975
  page: 1
2245
2976
  });
2246
2977
  const docs2 = options.draft ? await this.withDrafts(target, found.docs) : found.docs;
2247
- return Promise.all(docs2.map((d) => finish(target, d)));
2978
+ return Promise.all(
2979
+ docs2.map((d) => finish(target, this.pick(target, d, options)))
2980
+ );
2248
2981
  };
2249
2982
  const depth = Math.max(0, Math.min(MAX_DEPTH, Math.trunc(options.depth ?? DEFAULT_DEPTH)));
2250
- const populated = await populate(load, this.config.collections, config.fields, docs, depth);
2983
+ const localized = docs.map((d) => this.pick(config, d, options));
2984
+ const populated = await populate(load, this.config.collections, config.fields, localized, depth);
2251
2985
  return Promise.all(populated.map((d) => finish(config, d)));
2252
2986
  }
2253
2987
  /** Creates a document: access, input filtering, hooks, validation, then afterChange. */
@@ -2270,7 +3004,11 @@ var EasyCMS = class {
2270
3004
  this.fieldChecker("update", guard, void 0, input)
2271
3005
  );
2272
3006
  const base = this.hookArgs(config, hookGuard ?? guard);
2273
- let data = applyDefaults(config.fields, filtered);
3007
+ let data = applyDefaults(
3008
+ config.fields,
3009
+ this.toMaps(config, filtered, {}, options),
3010
+ this.config.localization
3011
+ );
2274
3012
  data = await this.transform(
2275
3013
  config.hooks?.beforeValidate,
2276
3014
  "data",
@@ -2279,9 +3017,10 @@ var EasyCMS = class {
2279
3017
  );
2280
3018
  let prepared = await this.prepare(
2281
3019
  config,
2282
- generateSlugs(config.fields, data),
3020
+ generateSlugs(config.fields, data, this.config.localization),
2283
3021
  "create",
2284
- void 0
3022
+ void 0,
3023
+ options
2285
3024
  );
2286
3025
  prepared = await this.transform(
2287
3026
  config.hooks?.beforeChange,
@@ -2301,9 +3040,48 @@ var EasyCMS = class {
2301
3040
  doc,
2302
3041
  operation: "create"
2303
3042
  });
3043
+ this.emit(config, "create", doc);
2304
3044
  const [out] = await this.output(config, [doc], hookGuard ?? guard, { ...options, draft: true });
2305
3045
  return out;
2306
3046
  }
3047
+ /** The locale for an operation; `undefined` without localization. Throws on unknown locales. */
3048
+ localeOf(options) {
3049
+ const localization = this.config.localization;
3050
+ if (!localization) return void 0;
3051
+ const locale = options.locale ?? localization.defaultLocale;
3052
+ if (locale !== ALL_LOCALES && !localization.locales.includes(locale)) {
3053
+ throw new QueryError(
3054
+ `Unknown locale "${locale}" (use one of: ${localization.locales.join(", ")}, ${ALL_LOCALES})`
3055
+ );
3056
+ }
3057
+ return locale;
3058
+ }
3059
+ /** Stored localized maps → the values of the requested locale. */
3060
+ pick(config, doc, options) {
3061
+ const localization = this.config.localization;
3062
+ const locale = this.localeOf(options);
3063
+ if (!localization || locale === void 0) return doc;
3064
+ const fallback = options.fallbackLocale ?? localization.fallback;
3065
+ return pickLocale(config.fields, doc, locale, localization, fallback);
3066
+ }
3067
+ /** Input for one locale → stored localized maps, keeping the other locales from `current`. */
3068
+ toMaps(config, input, current, options) {
3069
+ const locale = this.localeOf(options);
3070
+ if (locale === void 0 || locale === ALL_LOCALES) return input;
3071
+ return toLocaleMaps(config.fields, input, current, locale);
3072
+ }
3073
+ /** Makes `where` match localized fields in the operation's locale. */
3074
+ whereFor(config, where, options) {
3075
+ const locale = this.localeOf(options);
3076
+ const localization = this.config.localization;
3077
+ if (locale === void 0 || !localization) return where;
3078
+ return localizeWhere(
3079
+ config.fields,
3080
+ where,
3081
+ locale === ALL_LOCALES ? localization.defaultLocale : locale,
3082
+ localization.locales
3083
+ );
3084
+ }
2307
3085
  /** Drafts are kept as versions (instead of unpublishing) when both are enabled. */
2308
3086
  separateDrafts(config) {
2309
3087
  return config.drafts === true && versionLimit(config) !== void 0;
@@ -2318,6 +3096,24 @@ var EasyCMS = class {
2318
3096
  return version && doc.status === "published" && version.status === "draft" ? draftOf(doc, version) : doc;
2319
3097
  });
2320
3098
  }
3099
+ /** Sends webhook events for a change; `before` is the stored status before it, if any. */
3100
+ emit(config, event, doc, before) {
3101
+ const isGlobal = !this.config.collections.includes(config);
3102
+ if (!isGlobal && INTERNAL_COLLECTIONS.has(config.slug)) return;
3103
+ const body = isGlobal ? { ...snapshotOf(config.fields, doc), updatedAt: doc.updatedAt } : {
3104
+ id: doc.id,
3105
+ ...snapshotOf(config.fields, doc),
3106
+ createdAt: doc.createdAt,
3107
+ updatedAt: doc.updatedAt
3108
+ };
3109
+ const target = isGlobal ? { global: config.slug } : { collection: config.slug, id: doc.id };
3110
+ this.webhooks.emit(event, target, body);
3111
+ if (event === "draft" || event === "delete" || !config.drafts) return;
3112
+ const after = doc.status;
3113
+ if (after === "published" && before !== "published") this.webhooks.emit("publish", target, body);
3114
+ if (after !== "published" && before === "published")
3115
+ this.webhooks.emit("unpublish", target, body);
3116
+ }
2321
3117
  /** Records a version after a save, when the collection or global keeps versions. */
2322
3118
  async saveVersion(config, parent, id, doc, guard) {
2323
3119
  const max = versionLimit(config);
@@ -2394,12 +3190,15 @@ var EasyCMS = class {
2394
3190
  return result;
2395
3191
  }
2396
3192
  /** Validates, checks uniqueness and references. Returns clean data or throws `ValidationError`. */
2397
- async prepare(config, data, operation, selfId) {
3193
+ async prepare(config, data, operation, selfId, options = {}) {
2398
3194
  const isDraft = config.drafts === true && (data.status ?? "draft") === "draft";
3195
+ const locale = this.localeOf(options);
2399
3196
  const result = await validateFields(config.fields, data, {
2400
3197
  operation,
2401
3198
  root: data,
2402
- skipRequired: isDraft
3199
+ skipRequired: isDraft,
3200
+ localization: this.config.localization,
3201
+ ...locale !== void 0 && locale !== ALL_LOCALES ? { locale } : {}
2403
3202
  });
2404
3203
  const errors = [...result.errors];
2405
3204
  const clean = { ...result.data };
@@ -2441,14 +3240,26 @@ var EasyCMS = class {
2441
3240
  }
2442
3241
  }
2443
3242
  async makeSlugsUnique(config, data, selfId) {
2444
- for (const field of config.fields) {
2445
- const base = data[field.name];
2446
- if (field.type !== "slug" || typeof base !== "string" || base === "") continue;
3243
+ const unique = async (path, base) => {
2447
3244
  let candidate = base;
2448
- for (let n = 2; await this.isTaken(config.slug, field.name, candidate, selfId); n++) {
3245
+ for (let n = 2; await this.isTaken(config.slug, path, candidate, selfId); n++) {
2449
3246
  candidate = `${base}-${n}`;
2450
3247
  }
2451
- data[field.name] = candidate;
3248
+ return candidate;
3249
+ };
3250
+ for (const field of config.fields) {
3251
+ if (field.type !== "slug") continue;
3252
+ const value = data[field.name];
3253
+ if (field.localized && value && typeof value === "object") {
3254
+ const map = { ...value };
3255
+ for (const [locale, slug] of Object.entries(map)) {
3256
+ if (typeof slug === "string" && slug !== "")
3257
+ map[locale] = await unique(`${field.name}.${locale}`, slug);
3258
+ }
3259
+ data[field.name] = map;
3260
+ } else if (typeof value === "string" && value !== "") {
3261
+ data[field.name] = await unique(field.name, value);
3262
+ }
2452
3263
  }
2453
3264
  }
2454
3265
  async checkUnique(config, data, selfId) {
@@ -2456,8 +3267,16 @@ var EasyCMS = class {
2456
3267
  for (const field of config.fields) {
2457
3268
  const value = data[field.name];
2458
3269
  if (!field.unique || field.type === "slug" || value === null || value === void 0) continue;
2459
- if (await this.isTaken(config.slug, field.name, value, selfId)) {
2460
- errors.push({ field: field.name, message: "must be unique" });
3270
+ const checks = field.localized && typeof value === "object" ? Object.entries(value).map(([locale, v]) => [
3271
+ `${field.name}.${locale}`,
3272
+ locale === this.config.localization?.defaultLocale ? field.name : `${field.name}.${locale}`,
3273
+ v
3274
+ ]) : [[field.name, field.name, value]];
3275
+ for (const [path, errorField, v] of checks) {
3276
+ if (v === null || v === void 0) continue;
3277
+ if (await this.isTaken(config.slug, path, v, selfId)) {
3278
+ errors.push({ field: errorField, message: "must be unique" });
3279
+ }
2461
3280
  }
2462
3281
  }
2463
3282
  return errors;
@@ -2537,7 +3356,7 @@ function asObject(data, name) {
2537
3356
  async function allowed(access, user) {
2538
3357
  return await evaluateAccess(access, { user }) !== false;
2539
3358
  }
2540
- async function serializeFields(fields, update) {
3359
+ async function serializeFields(fields, update, localized) {
2541
3360
  const out = [];
2542
3361
  for (const field of fields) {
2543
3362
  if (field.hidden) continue;
@@ -2547,6 +3366,7 @@ async function serializeFields(fields, update) {
2547
3366
  if (field.unique) f.unique = true;
2548
3367
  if (field.defaultValue !== void 0) f.defaultValue = field.defaultValue;
2549
3368
  if (!await update.allows(field)) f.readOnly = true;
3369
+ if (field.localized && localized) f.localized = true;
2550
3370
  switch (field.type) {
2551
3371
  case "text":
2552
3372
  case "textarea":
@@ -2576,23 +3396,39 @@ async function serializeFields(fields, update) {
2576
3396
  case "array":
2577
3397
  if (field.minRows !== void 0) f.minRows = field.minRows;
2578
3398
  if (field.maxRows !== void 0) f.maxRows = field.maxRows;
2579
- f.fields = await serializeFields(field.fields, update);
3399
+ f.fields = await serializeFields(field.fields, update, localized);
3400
+ break;
3401
+ case "blocks":
3402
+ if (field.minRows !== void 0) f.minRows = field.minRows;
3403
+ if (field.maxRows !== void 0) f.maxRows = field.maxRows;
3404
+ f.blocks = await Promise.all(
3405
+ field.blocks.map(async (block) => ({
3406
+ slug: block.slug,
3407
+ ...block.labels ? { labels: block.labels } : {},
3408
+ fields: await serializeFields(block.fields, update, localized)
3409
+ }))
3410
+ );
2580
3411
  break;
2581
3412
  case "group":
2582
- f.fields = await serializeFields(field.fields, update);
3413
+ f.fields = await serializeFields(field.fields, update, localized);
2583
3414
  break;
2584
3415
  }
2585
3416
  out.push(f);
2586
3417
  }
2587
3418
  return out;
2588
3419
  }
2589
- async function collection(config, user) {
3420
+ async function collection(config, user, localized) {
2590
3421
  const result = {
2591
3422
  slug: config.slug,
2592
3423
  drafts: config.drafts === true,
2593
3424
  versions: Boolean(config.versions),
2594
3425
  preview: typeof config.preview === "function",
2595
- fields: await serializeFields(config.fields, new FieldAccessChecker("update", { user })),
3426
+ schedule: config.schedule === true,
3427
+ fields: await serializeFields(
3428
+ config.fields,
3429
+ new FieldAccessChecker("update", { user }),
3430
+ localized
3431
+ ),
2596
3432
  permissions: {
2597
3433
  read: await allowed(config.access?.read, user),
2598
3434
  create: await allowed(config.access?.create, user),
@@ -2604,13 +3440,18 @@ async function collection(config, user) {
2604
3440
  if (config.useAsTitle) result.useAsTitle = config.useAsTitle;
2605
3441
  return result;
2606
3442
  }
2607
- async function global(config, user) {
3443
+ async function global(config, user, localized) {
2608
3444
  const result = {
2609
3445
  slug: config.slug,
2610
3446
  drafts: config.drafts === true,
2611
3447
  versions: Boolean(config.versions),
2612
3448
  preview: typeof config.preview === "function",
2613
- fields: await serializeFields(config.fields, new FieldAccessChecker("update", { user })),
3449
+ schedule: config.schedule === true,
3450
+ fields: await serializeFields(
3451
+ config.fields,
3452
+ new FieldAccessChecker("update", { user }),
3453
+ localized
3454
+ ),
2614
3455
  permissions: {
2615
3456
  read: await allowed(config.access?.read, user),
2616
3457
  update: await allowed(config.access?.update, user)
@@ -2621,10 +3462,13 @@ async function global(config, user) {
2621
3462
  }
2622
3463
  async function adminSchema(cms, user) {
2623
3464
  const collections = cms.config.collections.filter((c) => !INTERNAL_COLLECTIONS.has(c.slug));
3465
+ const localization = cms.config.localization;
3466
+ const localized = localization !== null;
2624
3467
  return {
2625
3468
  locale: cms.config.admin.locale,
2626
- collections: await Promise.all(collections.map((c) => collection(c, user))),
2627
- globals: await Promise.all(cms.config.globals.map((g) => global(g, user)))
3469
+ localization: localization ? { locales: [...localization.locales], defaultLocale: localization.defaultLocale } : null,
3470
+ collections: await Promise.all(collections.map((c) => collection(c, user, localized))),
3471
+ globals: await Promise.all(cms.config.globals.map((g) => global(g, user, localized)))
2628
3472
  };
2629
3473
  }
2630
3474
 
@@ -2795,7 +3639,7 @@ function withHeaders(response, extra) {
2795
3639
  }
2796
3640
  async function route(cms, ctx, method, segments, options) {
2797
3641
  const [first, second, third] = segments;
2798
- const access = { overrideAccess: false, user: ctx.user };
3642
+ const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
2799
3643
  const draft = ctx.user !== null && ctx.url.searchParams.get("draft") === "true";
2800
3644
  if (segments.length === 0) throw new HttpError("Not found", 404);
2801
3645
  if (first === USERS && second !== void 0 && third === void 0 && !/^\d+$/.test(second)) {
@@ -2841,6 +3685,16 @@ async function route(cms, ctx, method, segments, options) {
2841
3685
  }
2842
3686
  throw new HttpError("Not found", 404);
2843
3687
  }
3688
+ if (first === "jobs" && second === "run" && third === void 0) {
3689
+ if (method !== "GET" && method !== "POST") throw methodNotAllowed(ctx, "GET, POST");
3690
+ const secret = cms.config.cronSecret ?? process.env.CRON_SECRET;
3691
+ const header = ctx.request.headers.get("authorization") ?? "";
3692
+ const byCron = !!secret && safeEqual(header, `Bearer ${secret}`);
3693
+ if (!byCron && ctx.user?.role !== "admin") {
3694
+ throw ctx.user ? new HttpError("Forbidden", 403) : new UnauthorizedError();
3695
+ }
3696
+ return { body: await cms.runScheduled() };
3697
+ }
2844
3698
  if (first === "admin") {
2845
3699
  if (method !== "GET") throw methodNotAllowed(ctx, "GET");
2846
3700
  if (!ctx.user) throw new UnauthorizedError();
@@ -2860,8 +3714,19 @@ async function route(cms, ctx, method, segments, options) {
2860
3714
  if (!cms.config.globals.some((g) => g.slug === second))
2861
3715
  throw new HttpError(`Unknown global "${second}"`, 404);
2862
3716
  if (third !== void 0) return globalAction(cms, ctx, method, second, segments.slice(2));
2863
- if (method === "GET")
3717
+ if (method === "GET") {
3718
+ if (ctx.url.searchParams.has("preview")) {
3719
+ requirePreview(cms, ctx, { global: second });
3720
+ return {
3721
+ body: await cms.findGlobal(second, {
3722
+ ...parseDepth(ctx.url),
3723
+ ...parseLocale(ctx.url),
3724
+ draft: true
3725
+ })
3726
+ };
3727
+ }
2864
3728
  return { body: await cms.findGlobal(second, { ...access, ...parseDepth(ctx.url), draft }) };
3729
+ }
2865
3730
  if (method === "POST") {
2866
3731
  const body = await readJson(ctx.request);
2867
3732
  return { body: await cms.updateGlobal(second, body, { ...access, ...parseDepth(ctx.url) }) };
@@ -2908,6 +3773,16 @@ async function route(cms, ctx, method, segments, options) {
2908
3773
  throw methodNotAllowed(ctx, "GET, POST");
2909
3774
  }
2910
3775
  const id = second;
3776
+ if (method === "GET" && ctx.url.searchParams.has("preview")) {
3777
+ requirePreview(cms, ctx, { collection: collection2, id });
3778
+ const doc = await cms.findById(collection2, id, {
3779
+ ...parseDepth(ctx.url),
3780
+ ...parseLocale(ctx.url),
3781
+ draft: true
3782
+ });
3783
+ if (!doc) throw new NotFoundError(collection2, id);
3784
+ return { body: doc };
3785
+ }
2911
3786
  if (method === "GET") {
2912
3787
  const doc = await cms.findById(collection2, id, { ...access, ...parseDepth(ctx.url), draft });
2913
3788
  if (!doc) throw new NotFoundError(collection2, id);
@@ -2927,7 +3802,7 @@ async function route(cms, ctx, method, segments, options) {
2927
3802
  throw methodNotAllowed(ctx, "GET, PATCH, DELETE");
2928
3803
  }
2929
3804
  async function documentAction(cms, ctx, method, collection2, id, path) {
2930
- const access = { overrideAccess: false, user: ctx.user };
3805
+ const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
2931
3806
  const depth = parseDepth(ctx.url);
2932
3807
  const [action, versionId, extra] = path;
2933
3808
  if (action === "versions" && versionId === void 0) {
@@ -2944,10 +3819,26 @@ async function documentAction(cms, ctx, method, collection2, id, path) {
2944
3819
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
2945
3820
  return { body: await cms.restoreVersion(collection2, id, versionId, { ...access, ...depth }) };
2946
3821
  }
3822
+ if (action === "schedule" && path.length <= 2) {
3823
+ if (path.length === 2) {
3824
+ if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
3825
+ await cms.cancelSchedule(collection2, id, versionId, access);
3826
+ return { body: { message: "Cancelled" } };
3827
+ }
3828
+ if (method === "GET") return { body: await cms.scheduled(collection2, id, access) };
3829
+ if (method !== "POST") throw methodNotAllowed(ctx, "GET, POST");
3830
+ const body = await readJson(ctx.request);
3831
+ return {
3832
+ status: 201,
3833
+ body: await cms.schedule(collection2, id, scheduleJob(body), access)
3834
+ };
3835
+ }
2947
3836
  if (path.length === 1 && action === "preview") {
2948
3837
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
2949
3838
  const body = await readJson(ctx.request);
2950
- return { body: await cms.preview(collection2, id, body, { ...access, ...depth }) };
3839
+ const result = await cms.preview(collection2, id, body, { ...access, ...depth });
3840
+ const token = cms.createPreviewToken({ collection: collection2, id });
3841
+ return { body: { ...result, url: result.url && withPreviewToken(result.url, token) } };
2951
3842
  }
2952
3843
  if (path.length === 1 && (action === "unpublish" || action === "discard-draft")) {
2953
3844
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
@@ -2959,7 +3850,7 @@ async function documentAction(cms, ctx, method, collection2, id, path) {
2959
3850
  throw new HttpError("Not found", 404);
2960
3851
  }
2961
3852
  async function globalAction(cms, ctx, method, slug, path) {
2962
- const access = { overrideAccess: false, user: ctx.user };
3853
+ const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
2963
3854
  const depth = parseDepth(ctx.url);
2964
3855
  const [action, versionId, extra] = path;
2965
3856
  if (action === "versions" && versionId === void 0) {
@@ -2976,10 +3867,23 @@ async function globalAction(cms, ctx, method, slug, path) {
2976
3867
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
2977
3868
  return { body: await cms.restoreGlobalVersion(slug, versionId, { ...access, ...depth }) };
2978
3869
  }
3870
+ if (action === "schedule" && path.length <= 2) {
3871
+ if (path.length === 2) {
3872
+ if (method !== "DELETE") throw methodNotAllowed(ctx, "DELETE");
3873
+ await cms.cancelGlobalSchedule(slug, versionId, access);
3874
+ return { body: { message: "Cancelled" } };
3875
+ }
3876
+ if (method === "GET") return { body: await cms.scheduledGlobal(slug, access) };
3877
+ if (method !== "POST") throw methodNotAllowed(ctx, "GET, POST");
3878
+ const body = await readJson(ctx.request);
3879
+ return { status: 201, body: await cms.scheduleGlobal(slug, scheduleJob(body), access) };
3880
+ }
2979
3881
  if (path.length === 1 && action === "preview") {
2980
3882
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
2981
3883
  const body = await readJson(ctx.request);
2982
- return { body: await cms.previewGlobal(slug, body, { ...access, ...depth }) };
3884
+ const result = await cms.previewGlobal(slug, body, { ...access, ...depth });
3885
+ const token = cms.createPreviewToken({ global: slug });
3886
+ return { body: { ...result, url: result.url && withPreviewToken(result.url, token) } };
2983
3887
  }
2984
3888
  if (path.length === 1 && (action === "unpublish" || action === "discard-draft")) {
2985
3889
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
@@ -2990,6 +3894,32 @@ async function globalAction(cms, ctx, method, slug, path) {
2990
3894
  }
2991
3895
  throw new HttpError("Not found", 404);
2992
3896
  }
3897
+ function scheduleJob(body) {
3898
+ return {
3899
+ action: body.action,
3900
+ at: typeof body.at === "string" ? body.at : ""
3901
+ };
3902
+ }
3903
+ function parseLocale(url) {
3904
+ const locale = url.searchParams.get("locale");
3905
+ const fallback = url.searchParams.get("fallback-locale");
3906
+ return {
3907
+ ...locale ? { locale } : {},
3908
+ ...fallback === "false" || fallback === "true" ? { fallbackLocale: fallback === "true" } : {}
3909
+ };
3910
+ }
3911
+ var PREVIEW_PARAM = "easy-cms-preview";
3912
+ function withPreviewToken(url, token) {
3913
+ const hash = url.indexOf("#");
3914
+ const [base, fragment] = hash === -1 ? [url, ""] : [url.slice(0, hash), url.slice(hash)];
3915
+ const separator = base.includes("?") ? "&" : "?";
3916
+ return `${base}${separator}${PREVIEW_PARAM}=${encodeURIComponent(token)}${fragment}`;
3917
+ }
3918
+ function requirePreview(cms, ctx, target) {
3919
+ const opened = cms.verifyPreviewToken(ctx.url.searchParams.get("preview"));
3920
+ const matches2 = opened !== null && ("global" in target ? "global" in opened && opened.global === target.global : "collection" in opened && opened.collection === target.collection && opened.id === target.id);
3921
+ if (!matches2) throw new UnauthorizedError("Invalid or expired preview token");
3922
+ }
2993
3923
  function parsePage(url) {
2994
3924
  const { page, limit } = parseListQuery(url);
2995
3925
  return { page, limit };
@@ -3204,6 +4134,16 @@ ${indent}}`;
3204
4134
  ${indent} id: string
3205
4135
  ${fieldLines(field.fields, n, `${indent} `).join("\n")}
3206
4136
  ${indent}}[]`;
4137
+ case "blocks": {
4138
+ const kinds = field.blocks.map(
4139
+ (block) => `{
4140
+ ${indent} id: string
4141
+ ${indent} blockType: ${quote(block.slug)}
4142
+ ${fieldLines(block.fields, n, `${indent} `).join("\n")}
4143
+ ${indent}}`
4144
+ );
4145
+ return `(${kinds.join(" | ")})[]`;
4146
+ }
3207
4147
  }
3208
4148
  }
3209
4149
  function fieldLines(fields, n, indent) {
@@ -3211,7 +4151,7 @@ function fieldLines(fields, n, indent) {
3211
4151
  for (const field of fields) {
3212
4152
  if (field.hidden) continue;
3213
4153
  const type = fieldType(field, n, indent);
3214
- const alwaysPresent = field.type === "array" || field.type === "group" || (field.type === "select" || field.type === "relationship") && field.hasMany;
4154
+ const alwaysPresent = field.type === "array" || field.type === "blocks" || field.type === "group" || (field.type === "select" || field.type === "relationship") && field.hasMany;
3215
4155
  if (field.required || alwaysPresent) lines.push(`${indent}${key(field.name)}: ${type}`);
3216
4156
  else lines.push(`${indent}${key(field.name)}?: ${type} | null`);
3217
4157
  }
@@ -3319,6 +4259,7 @@ export {
3319
4259
  TooManyRequestsError,
3320
4260
  UnauthorizedError,
3321
4261
  ValidationError,
4262
+ WEBHOOK_EVENTS,
3322
4263
  anyone,
3323
4264
  applyDefaults,
3324
4265
  consoleLogger,
@@ -3329,6 +4270,7 @@ export {
3329
4270
  findConfigFile,
3330
4271
  generateSlugs,
3331
4272
  generateTypes,
4273
+ hasRows,
3332
4274
  hashPassword,
3333
4275
  imageDimensions,
3334
4276
  importConfig,
@@ -3342,6 +4284,7 @@ export {
3342
4284
  populate,
3343
4285
  readCookie,
3344
4286
  resolveConfig,
4287
+ rowFields,
3345
4288
  silentLogger,
3346
4289
  singularize,
3347
4290
  slugify,