@easy-cms/core 0.4.0 → 0.5.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.d.ts CHANGED
@@ -56,6 +56,14 @@ declare const anyone: Access;
56
56
  declare const isLoggedIn: Access;
57
57
  declare const isAdmin: Access;
58
58
 
59
+ /** What a preview token opens: one document of a collection, or one global. */
60
+ type PreviewTarget = {
61
+ readonly collection: string;
62
+ readonly id: string;
63
+ } | {
64
+ readonly global: string;
65
+ };
66
+
59
67
  interface Logger {
60
68
  info(message: string): void;
61
69
  warn(message: string): void;
@@ -193,6 +201,11 @@ interface BaseField<TType extends string, TValue> {
193
201
  readonly access?: FieldAccess;
194
202
  /** Stored but never returned by the API nor accepted as input (e.g. a password hash). */
195
203
  readonly hidden?: boolean;
204
+ /**
205
+ * One value per locale (needs `localization` in the config). Not for groups, arrays or
206
+ * hasMany fields: localize the fields inside a group or array instead.
207
+ */
208
+ readonly localized?: boolean;
196
209
  }
197
210
  interface TextField extends BaseField<'text', string> {
198
211
  readonly minLength?: number;
@@ -332,13 +345,22 @@ interface GlobalHooks {
332
345
  readonly afterChange?: readonly AfterChangeHook[];
333
346
  readonly afterRead?: readonly AfterReadHook[];
334
347
  }
348
+ interface LocalizationConfig {
349
+ /** Content locales, e.g. `['th', 'en']`. */
350
+ readonly locales: readonly string[];
351
+ /** The locale used when none is given; its values also fill empty ones. Default: the first. */
352
+ readonly defaultLocale?: string;
353
+ /** Reads return the default locale's value when a locale's value is empty. Default true. */
354
+ readonly fallback?: boolean;
355
+ }
335
356
  /**
336
357
  * Where a document is shown on the site, for live preview in the admin: an absolute URL or a
337
358
  * path on the site's origin. Return `null` when the document has no page.
338
359
  */
339
360
  type PreviewURL = (args: {
340
361
  readonly doc: Record<string, unknown>;
341
- readonly locale: AdminLocale;
362
+ /** The content locale being edited (with `localization`), otherwise the admin locale. */
363
+ readonly locale: string;
342
364
  }) => string | null | undefined;
343
365
  interface VersionsConfig {
344
366
  /** Versions kept per document; older ones are deleted. Default 50. */
@@ -442,6 +464,8 @@ interface Config {
442
464
  * `auth.trustedOrigins` are always allowed, with cookies. Default: none.
443
465
  */
444
466
  readonly cors?: readonly string[] | '*';
467
+ /** Content in several languages: fields with `localized: true` hold one value per locale. */
468
+ readonly localization?: LocalizationConfig | null;
445
469
  readonly routes?: RoutesConfig;
446
470
  readonly admin?: AdminConfig;
447
471
  readonly upload?: UploadConfig;
@@ -455,8 +479,10 @@ interface Config {
455
479
  readonly plugins?: readonly Plugin[];
456
480
  }
457
481
  /** The config after plugins ran, validation passed and defaults were applied. */
458
- interface ResolvedConfig extends Omit<Config, 'cors' | 'routes' | 'admin' | 'upload' | 'auth' | 'collections' | 'globals' | 'plugins'> {
482
+ interface ResolvedConfig extends Omit<Config, 'cors' | 'localization' | 'routes' | 'admin' | 'upload' | 'auth' | 'collections' | 'globals' | 'plugins'> {
459
483
  readonly cors: readonly string[] | '*';
484
+ /** `null` without localization. */
485
+ readonly localization: Required<LocalizationConfig> | null;
460
486
  readonly routes: Required<RoutesConfig>;
461
487
  readonly admin: Required<AdminConfig>;
462
488
  readonly upload: Required<Omit<UploadConfig, 'storage'>> & Pick<UploadConfig, 'storage'>;
@@ -708,6 +734,11 @@ interface AccessOptions {
708
734
  interface DepthOptions extends AccessOptions {
709
735
  /** How many levels of relationships to populate. Default 1, maximum 3. */
710
736
  readonly depth?: number;
737
+ /**
738
+ * With `localization`: the locale to read, and for writes the locale the data is in. Default:
739
+ * the default locale. `'all'` reads (and writes) localized fields as `{ [locale]: value }`.
740
+ */
741
+ readonly locale?: string;
711
742
  }
712
743
  interface ReadOptions extends DepthOptions {
713
744
  /**
@@ -715,6 +746,8 @@ interface ReadOptions extends DepthOptions {
715
746
  * `drafts: true` are returned, including when populating relationships (FR-DRF-04).
716
747
  */
717
748
  readonly draft?: boolean;
749
+ /** Use the default locale's value when a localized value is empty. Default: `localization.fallback`. */
750
+ readonly fallbackLocale?: boolean;
718
751
  }
719
752
  interface FindOptions extends ReadOptions {
720
753
  readonly where?: Where;
@@ -750,6 +783,7 @@ declare class EasyCMS<C extends Config = Config> {
750
783
  count<S extends Slug<C>>(collection: S, options?: {
751
784
  where?: Where;
752
785
  draft?: boolean;
786
+ locale?: string;
753
787
  } & AccessOptions): Promise<number>;
754
788
  create<S extends Slug<C>>(collection: S, data: Create<C, S>, options?: DepthOptions): Promise<Doc<C, S>>;
755
789
  /**
@@ -789,6 +823,23 @@ declare class EasyCMS<C extends Config = Config> {
789
823
  * `afterRead` hooks applied. Nothing is written. Also returns the page URL from `preview`.
790
824
  */
791
825
  preview<S extends Slug<C>>(collection: S, id: ID | null, data: Record<string, unknown>, options?: DepthOptions): Promise<LivePreview<Doc<C, S>>>;
826
+ /**
827
+ * A token that lets a page read one document's current draft for a while (default one hour),
828
+ * without a login: `GET /api/cms/:collection/:id?preview=<token>`. The admin adds one to the
829
+ * preview URL as `easy-cms-preview`, for sites on another origin.
830
+ */
831
+ createPreviewToken(target: {
832
+ collection: string;
833
+ id: ID;
834
+ } | {
835
+ global: string;
836
+ }, options?: {
837
+ expiresIn?: number;
838
+ }): string;
839
+ /** What a preview token opens, or `null` when it is invalid or expired. */
840
+ verifyPreviewToken(token: string | null | undefined): (PreviewTarget & {
841
+ expiresAt: number;
842
+ }) | null;
792
843
  /** Live preview of a global. See `preview`. */
793
844
  previewGlobal<S extends GSlug<C>>(slug: S, data: Record<string, unknown>, options?: DepthOptions): Promise<LivePreview<GDoc<C, S>>>;
794
845
  /** Coerces preview input like a save would, keeping values that would fail validation. */
@@ -837,6 +888,14 @@ declare class EasyCMS<C extends Config = Config> {
837
888
  private output;
838
889
  /** Creates a document: access, input filtering, hooks, validation, then afterChange. */
839
890
  private createDocument;
891
+ /** The locale for an operation; `undefined` without localization. Throws on unknown locales. */
892
+ private localeOf;
893
+ /** Stored localized maps → the values of the requested locale. */
894
+ private pick;
895
+ /** Input for one locale → stored localized maps, keeping the other locales from `current`. */
896
+ private toMaps;
897
+ /** Makes `where` match localized fields in the operation's locale. */
898
+ private whereFor;
840
899
  /** Drafts are kept as versions (instead of unpublishing) when both are enabled. */
841
900
  private separateDrafts;
842
901
  /** Replaces published documents that have a newer draft with that draft. */
@@ -980,6 +1039,8 @@ declare class PayloadTooLargeError extends EasyCMSError {
980
1039
  constructor(message: string);
981
1040
  }
982
1041
 
1042
+ type Localization = Required<LocalizationConfig>;
1043
+
983
1044
  type Data = Record<string, unknown>;
984
1045
  /**
985
1046
  * Turns text into a URL slug. Keeps letters and digits of any script,
@@ -987,11 +1048,11 @@ type Data = Record<string, unknown>;
987
1048
  */
988
1049
  declare function slugify(text: string): string;
989
1050
  /** Fills in `defaultValue` for fields that are missing, including inside groups and array rows. */
990
- declare function applyDefaults(fields: readonly Field[], data: Data): Data;
1051
+ declare function applyDefaults(fields: readonly Field[], data: Data, localization?: Localization | null): Data;
991
1052
  /** Gives every field a value (`null`, `[]` or a nested object) so documents always have the same shape. */
992
1053
  declare function fillMissing(fields: readonly Field[], data: Data): Data;
993
1054
  /** Fills empty `slug` fields from their `from` field. Does not check uniqueness. */
994
- declare function generateSlugs(fields: readonly Field[], data: Data): Data;
1055
+ declare function generateSlugs(fields: readonly Field[], data: Data, localization?: Localization | null): Data;
995
1056
  /**
996
1057
  * Merges an update into the stored document. Top-level fields and group
997
1058
  * fields merge key by key; arrays and hasMany values are replaced.
@@ -1011,6 +1072,13 @@ interface ValidateOptions {
1011
1072
  readonly skipRequired?: boolean;
1012
1073
  /** The whole top-level document, passed to custom `validate` functions. */
1013
1074
  readonly root: Data;
1075
+ /** Localized fields hold `{ [locale]: value }`; each value is validated. */
1076
+ readonly localization?: Localization | null;
1077
+ /**
1078
+ * The locale being written: `required` and custom `validate` apply to its values, and its
1079
+ * errors use the plain field path. Default: the default locale.
1080
+ */
1081
+ readonly locale?: string;
1014
1082
  }
1015
1083
  interface ValidateResult {
1016
1084
  /** Only known fields, with values normalized (ids parsed, dates as ISO strings, row ids filled). */
@@ -1095,6 +1163,8 @@ interface AdminField {
1095
1163
  fields?: AdminField[];
1096
1164
  /** The current user may not change this field. */
1097
1165
  readOnly?: boolean;
1166
+ /** One value per content locale. */
1167
+ localized?: boolean;
1098
1168
  }
1099
1169
  interface AdminCollection {
1100
1170
  slug: string;
@@ -1130,6 +1200,11 @@ interface AdminGlobal {
1130
1200
  }
1131
1201
  interface AdminSchema {
1132
1202
  locale: AdminLocale;
1203
+ /** Content locales, when the config has `localization`. */
1204
+ localization: {
1205
+ locales: string[];
1206
+ defaultLocale: string;
1207
+ } | null;
1133
1208
  collections: AdminCollection[];
1134
1209
  globals: AdminGlobal[];
1135
1210
  }
@@ -1162,4 +1237,4 @@ declare const BUILTIN_COLLECTIONS: readonly ["users", "media"];
1162
1237
  /** Checks a config and returns every problem found. An empty array means it is valid. */
1163
1238
  declare function validateConfig(config: Config): ConfigIssue[];
1164
1239
 
1165
- export { type Access, type AccessArgs, type AccessOptions, type AdminCollection, type AdminConfig, type AdminField, type AdminGlobal, type AdminLocale, type AdminSchema, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ArrayField, Auth, type AuthConfig, type AuthUser, BUILTIN_COLLECTIONS, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type BooleanField, type BuiltinMediaCollection, type BuiltinUsersCollection, type BundleHints, CONFIG_FILE_NAMES, CSRF_COOKIE, CSRF_HEADER, type CollectionAccess, type CollectionConfig, type CollectionDocument, type CollectionHooks, type CollectionSlug, type Config, ConfigError, type ConfigIssue, type CreateEasyCMSOptions, type CreateInput, type CreateMigrationResult, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_VERSIONS, DEFAULT_TOKEN_EXPIRATION, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DateField, type DepthOptions, EXTENSIONS, EasyCMS, EasyCMSError, type EmailField, FIELD_TYPES, type Field, type FieldAccess, type FieldAccessFn, type FieldError, type FieldType, type FieldValidate, type FieldValidateContext, type FieldValue, type FieldsInput, type FieldsValue, type FindArgs, type FindOptions, ForbiddenError, type GlobalAccess, type GlobalConfig, type GlobalDocument, type GlobalHooks, type GlobalInput, type GlobalSlug, type GroupField, type ID, INTERNAL_COLLECTIONS, type ImageSize, type InferCollection, type InferGlobal, type JsonField, type Label, type LivePreview, type LoadConfigOptions, type LocalStorageOptions, type Logger, type LoginArgs, MAX_DEPTH, MEDIA, MIN_PASSWORD_LENGTH, MIN_SECRET_LENGTH, type MediaDocument, type MediaSize, type MigrationInfo, NotFoundError, type NumberField, type Operation, type PaginatedDocs, PayloadTooLargeError, type Plugin, type PreviewURL, QueryError, type RawDocument, type ReadOptions, type RelationshipField, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoutesConfig, SESSION_COOKIE, SchemaError, type SchemaMode, type SelectField, type SelectOption, type Session, type SlugField, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, UnauthorizedError, type UpdateInput, type UploadConfig, type UploadField, ValidationError, type Version, type VersionSummary, type VersionsConfig, type Where, type WhereOperators, anyone, applyDefaults, consoleLogger, createEasyCMS, createRestHandler, defineConfig, fillMissing, findConfigFile, generateSlugs, generateTypes, hashPassword, imageDimensions, importConfig, isAdmin, isLoggedIn, loadConfig, localStorage, mergeForUpdate, mimeAllowed, parseId, populate, readCookie, resolveConfig, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword };
1240
+ export { type Access, type AccessArgs, type AccessOptions, type AdminCollection, type AdminConfig, type AdminField, type AdminGlobal, type AdminLocale, type AdminSchema, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ArrayField, Auth, type AuthConfig, type AuthUser, BUILTIN_COLLECTIONS, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type BooleanField, type BuiltinMediaCollection, type BuiltinUsersCollection, type BundleHints, CONFIG_FILE_NAMES, CSRF_COOKIE, CSRF_HEADER, type CollectionAccess, type CollectionConfig, type CollectionDocument, type CollectionHooks, type CollectionSlug, type Config, ConfigError, type ConfigIssue, type CreateEasyCMSOptions, type CreateInput, type CreateMigrationResult, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_VERSIONS, DEFAULT_TOKEN_EXPIRATION, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DateField, type DepthOptions, EXTENSIONS, EasyCMS, EasyCMSError, type EmailField, FIELD_TYPES, type Field, type FieldAccess, type FieldAccessFn, type FieldError, type FieldType, type FieldValidate, type FieldValidateContext, type FieldValue, type FieldsInput, type FieldsValue, type FindArgs, type FindOptions, ForbiddenError, type GlobalAccess, type GlobalConfig, type GlobalDocument, type GlobalHooks, type GlobalInput, type GlobalSlug, type GroupField, type ID, INTERNAL_COLLECTIONS, type ImageSize, type InferCollection, type InferGlobal, type JsonField, type Label, type LivePreview, type LoadConfigOptions, type LocalStorageOptions, type LocalizationConfig, type Logger, type LoginArgs, MAX_DEPTH, MEDIA, MIN_PASSWORD_LENGTH, MIN_SECRET_LENGTH, type MediaDocument, type MediaSize, type MigrationInfo, NotFoundError, type NumberField, type Operation, type PaginatedDocs, PayloadTooLargeError, type Plugin, type PreviewTarget, type PreviewURL, QueryError, type RawDocument, type ReadOptions, type RelationshipField, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoutesConfig, SESSION_COOKIE, SchemaError, type SchemaMode, type SelectField, type SelectOption, type Session, type SlugField, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, UnauthorizedError, type UpdateInput, type UploadConfig, type UploadField, ValidationError, type Version, type VersionSummary, type VersionsConfig, type Where, type WhereOperators, anyone, applyDefaults, consoleLogger, createEasyCMS, createRestHandler, defineConfig, fillMissing, findConfigFile, generateSlugs, generateTypes, hashPassword, imageDimensions, importConfig, isAdmin, isLoggedIn, loadConfig, localStorage, mergeForUpdate, mimeAllowed, parseId, populate, readCookie, resolveConfig, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword };
package/dist/index.js CHANGED
@@ -419,6 +419,30 @@ function safeEqual(a, b) {
419
419
  const right = Buffer.from(b);
420
420
  return left.length === right.length && timingSafeEqual2(left, right);
421
421
  }
422
+ function signPreviewToken(secret, target, expiresAt) {
423
+ const payload = Buffer.from(
424
+ JSON.stringify(
425
+ "global" in target ? { g: target.global, e: expiresAt } : { c: target.collection, i: target.id, e: expiresAt }
426
+ )
427
+ ).toString("base64url");
428
+ return `${payload}.${hmac(secret, `preview:${payload}`)}`;
429
+ }
430
+ function verifyPreviewToken(secret, token, now = Date.now()) {
431
+ const dot = token.lastIndexOf(".");
432
+ if (dot <= 0) return null;
433
+ const payload = token.slice(0, dot);
434
+ if (!safeEqual(token.slice(dot + 1), hmac(secret, `preview:${payload}`))) return null;
435
+ try {
436
+ const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
437
+ if (typeof data.e !== "number" || data.e <= now) return null;
438
+ if (typeof data.g === "string") return { global: data.g, expiresAt: data.e };
439
+ if (typeof data.c === "string" && typeof data.i === "string")
440
+ return { collection: data.c, id: data.i, expiresAt: data.e };
441
+ return null;
442
+ } catch {
443
+ return null;
444
+ }
445
+ }
422
446
 
423
447
  // src/auth/auth.ts
424
448
  var INVALID = "Invalid email or password";
@@ -620,18 +644,30 @@ var isEmpty = (value) => value === void 0 || value === null || value === "";
620
644
  function slugify(text) {
621
645
  return text.normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "");
622
646
  }
623
- function applyDefaults(fields, data) {
647
+ function applyDefaults(fields, data, localization = null) {
624
648
  const result = { ...data };
625
649
  for (const field of fields) {
626
650
  const value = result[field.name];
627
651
  if (field.type === "group") {
628
- result[field.name] = applyDefaults(field.fields, isPlainObject(value) ? value : {});
652
+ result[field.name] = applyDefaults(
653
+ field.fields,
654
+ isPlainObject(value) ? value : {},
655
+ localization
656
+ );
629
657
  } else if (field.type === "array") {
630
658
  if (Array.isArray(value)) {
631
659
  result[field.name] = value.map(
632
- (row) => isPlainObject(row) ? applyDefaults(field.fields, row) : row
660
+ (row) => isPlainObject(row) ? applyDefaults(field.fields, row, localization) : row
633
661
  );
634
662
  }
663
+ } else if (field.localized && localization && field.defaultValue !== void 0) {
664
+ const map = isPlainObject(value) ? value : {};
665
+ if (map[localization.defaultLocale] === void 0) {
666
+ result[field.name] = {
667
+ ...map,
668
+ [localization.defaultLocale]: structuredClone(field.defaultValue)
669
+ };
670
+ }
635
671
  } else if (value === void 0 && field.defaultValue !== void 0) {
636
672
  result[field.name] = structuredClone(field.defaultValue);
637
673
  }
@@ -650,22 +686,34 @@ function fillMissing(fields, data) {
650
686
  }
651
687
  return result;
652
688
  }
653
- function generateSlugs(fields, data) {
689
+ function generateSlugs(fields, data, localization = null) {
654
690
  const result = { ...data };
655
691
  for (const field of fields) {
656
- if (field.type === "slug") {
692
+ if (field.type === "slug" && field.localized && localization) {
693
+ const map = isPlainObject(result[field.name]) ? result[field.name] : {};
694
+ const source = field.from ? result[field.from] : void 0;
695
+ const next = { ...map };
696
+ for (const locale of localization.locales) {
697
+ const current = map[locale];
698
+ const from = isPlainObject(source) ? source[locale] : source;
699
+ if (typeof current === "string" && current !== "") next[locale] = slugify(current);
700
+ else if (typeof from === "string" && from.trim() !== "") next[locale] = slugify(from);
701
+ }
702
+ result[field.name] = next;
703
+ } else if (field.type === "slug") {
657
704
  const current = result[field.name];
658
705
  if (typeof current === "string" && current !== "") {
659
706
  result[field.name] = slugify(current);
660
707
  } else if (field.from) {
661
- const source = result[field.from];
708
+ const raw = result[field.from];
709
+ const source = isPlainObject(raw) && localization ? raw[localization.defaultLocale] : raw;
662
710
  if (typeof source === "string" && source.trim() !== "") result[field.name] = slugify(source);
663
711
  }
664
712
  } else if (field.type === "group" && isPlainObject(result[field.name])) {
665
- result[field.name] = generateSlugs(field.fields, result[field.name]);
713
+ result[field.name] = generateSlugs(field.fields, result[field.name], localization);
666
714
  } else if (field.type === "array" && Array.isArray(result[field.name])) {
667
715
  result[field.name] = result[field.name].map(
668
- (row) => isPlainObject(row) ? generateSlugs(field.fields, row) : row
716
+ (row) => isPlainObject(row) ? generateSlugs(field.fields, row, localization) : row
669
717
  );
670
718
  }
671
719
  }
@@ -717,6 +765,42 @@ async function validateFields(fields, input, options, prefix = "") {
717
765
  references.push(...sub.references);
718
766
  continue;
719
767
  }
768
+ const localization = options.localization;
769
+ if (field.localized && localization) {
770
+ if (!isEmpty(raw) && !isPlainObject(raw)) {
771
+ fail("must be an object with one value per locale");
772
+ continue;
773
+ }
774
+ const map = isPlainObject(raw) ? raw : {};
775
+ const written = options.locale ?? localization.defaultLocale;
776
+ const out = {};
777
+ for (const locale of localization.locales) {
778
+ const own = locale === written;
779
+ const localePath = own ? path : `${path}.${locale}`;
780
+ const failLocale = (message) => errors.push({ field: localePath, message });
781
+ const value2 = map[locale];
782
+ if (isEmpty(value2)) {
783
+ if (own && field.required && !options.skipRequired) failLocale("is required");
784
+ out[locale] = null;
785
+ if (own && !field.required) await runCustom(field, null, options, failLocale);
786
+ continue;
787
+ }
788
+ const normalized = await normalizeValue(
789
+ field,
790
+ value2,
791
+ localePath,
792
+ options,
793
+ failLocale,
794
+ errors,
795
+ references
796
+ );
797
+ if (normalized === void 0) continue;
798
+ out[locale] = normalized;
799
+ if (own) await runCustom(field, normalized, options, failLocale);
800
+ }
801
+ data[field.name] = out;
802
+ continue;
803
+ }
720
804
  if (isEmpty(raw) || Array.isArray(raw) && raw.length === 0) {
721
805
  if (field.required && !options.skipRequired) fail("is required");
722
806
  data[field.name] = field.type === "array" || isHasMany(field) ? [] : null;
@@ -889,6 +973,7 @@ function validateConfig(config) {
889
973
  validateUpload(config, add);
890
974
  validateAuth(config, add);
891
975
  validateCors(config.cors, add);
976
+ validateLocalization(config, add);
892
977
  const collections = asArray(config.collections, "collections", add);
893
978
  const globals = asArray(config.globals, "globals", add);
894
979
  const collectionSlugs = /* @__PURE__ */ new Set([...BUILTIN_COLLECTIONS]);
@@ -965,6 +1050,76 @@ function validateAuth(config, add) {
965
1050
  }
966
1051
  }
967
1052
  }
1053
+ var LOCALE_PATTERN = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
1054
+ var LOCALIZABLE = /* @__PURE__ */ new Set([
1055
+ "text",
1056
+ "textarea",
1057
+ "email",
1058
+ "slug",
1059
+ "richText",
1060
+ "number",
1061
+ "boolean",
1062
+ "date",
1063
+ "json",
1064
+ "select",
1065
+ "upload",
1066
+ "relationship"
1067
+ ]);
1068
+ function validateLocalization(config, add) {
1069
+ const localization = config.localization;
1070
+ if (localization !== void 0 && localization !== null) {
1071
+ const locales = localization?.locales;
1072
+ if (!Array.isArray(locales) || locales.length === 0) {
1073
+ add("localization.locales", "must be a non-empty list of locales", "locales: ['th', 'en']");
1074
+ } else {
1075
+ const seen = /* @__PURE__ */ new Set();
1076
+ for (const [i, locale] of locales.entries()) {
1077
+ if (typeof locale !== "string" || !LOCALE_PATTERN.test(locale)) {
1078
+ add(
1079
+ `localization.locales[${i}]`,
1080
+ `must be a locale code like "th" or "en-US" (got ${JSON.stringify(locale)})`
1081
+ );
1082
+ } else if (seen.has(locale)) {
1083
+ add(`localization.locales[${i}]`, `duplicate locale "${locale}"`);
1084
+ }
1085
+ seen.add(locale);
1086
+ }
1087
+ const fallbackDefault = localization.defaultLocale;
1088
+ if (fallbackDefault !== void 0 && !locales.includes(fallbackDefault)) {
1089
+ add("localization.defaultLocale", `must be one of the locales (got "${fallbackDefault}")`);
1090
+ }
1091
+ }
1092
+ }
1093
+ const walk = (fields, path) => {
1094
+ if (!Array.isArray(fields)) return;
1095
+ for (const field of fields) {
1096
+ if (typeof field?.name !== "string") continue;
1097
+ const fieldPath = `${path}.${field.name}`;
1098
+ if (field.localized) {
1099
+ if (!localization) {
1100
+ add(
1101
+ `${fieldPath}.localized`,
1102
+ "needs `localization` in the config",
1103
+ "localization: { locales: ['th', 'en'] }"
1104
+ );
1105
+ } else if (!LOCALIZABLE.has(field.type)) {
1106
+ add(
1107
+ `${fieldPath}.localized`,
1108
+ `${field.type} fields cannot be localized`,
1109
+ "localize the fields inside it instead"
1110
+ );
1111
+ } else if ("hasMany" in field && field.hasMany) {
1112
+ add(`${fieldPath}.localized`, "hasMany fields cannot be localized yet");
1113
+ }
1114
+ }
1115
+ if (field.type === "group" || field.type === "array")
1116
+ walk(field.fields, `${fieldPath}.fields`);
1117
+ }
1118
+ };
1119
+ for (const [i, c] of (config.collections ?? []).entries())
1120
+ walk(c?.fields, `collections[${i}].fields`);
1121
+ for (const [i, g] of (config.globals ?? []).entries()) walk(g?.fields, `globals[${i}].fields`);
1122
+ }
968
1123
  function validateCors(cors, add) {
969
1124
  if (cors === void 0 || cors === "*") return;
970
1125
  if (!Array.isArray(cors)) {
@@ -1036,6 +1191,7 @@ function validateContainer(container, path, collectionSlugs, add) {
1036
1191
  add(
1037
1192
  `${path}.preview`,
1038
1193
  "must be a function ({ doc }) => url",
1194
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: an example shown to the user
1039
1195
  "preview: ({ doc }) => `/posts/${doc.slug}`"
1040
1196
  );
1041
1197
  }
@@ -1179,6 +1335,11 @@ async function resolveConfig(input) {
1179
1335
  const result = {
1180
1336
  ...rest,
1181
1337
  cors: config.cors ?? [],
1338
+ localization: config.localization ? {
1339
+ locales: config.localization.locales,
1340
+ defaultLocale: config.localization.defaultLocale ?? config.localization.locales[0] ?? "",
1341
+ fallback: config.localization.fallback ?? true
1342
+ } : null,
1182
1343
  routes: { api: `/${(config.routes?.api ?? DEFAULT_API_PATH).replace(/^\/+|\/+$/g, "")}` },
1183
1344
  admin: {
1184
1345
  path: config.admin?.path ?? DEFAULT_ADMIN_PATH,
@@ -1260,6 +1421,89 @@ async function loadConfig(options = {}) {
1260
1421
  // src/local-api.ts
1261
1422
  import { randomBytes as randomBytes3 } from "crypto";
1262
1423
 
1424
+ // src/localization.ts
1425
+ var ALL_LOCALES = "all";
1426
+ var isPlainObject2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1427
+ var isEmpty2 = (value) => value === void 0 || value === null || value === "";
1428
+ function pickLocale(fields, data, locale, localization, fallback) {
1429
+ if (locale === ALL_LOCALES) return data;
1430
+ const result = { ...data };
1431
+ for (const field of fields) {
1432
+ const value = result[field.name];
1433
+ if (field.type === "group" && isPlainObject2(value)) {
1434
+ result[field.name] = pickLocale(field.fields, value, locale, localization, fallback);
1435
+ } else if (field.type === "array" && Array.isArray(value)) {
1436
+ result[field.name] = value.map(
1437
+ (row) => isPlainObject2(row) ? pickLocale(field.fields, row, locale, localization, fallback) : row
1438
+ );
1439
+ } else if (field.localized) {
1440
+ const map = isPlainObject2(value) ? value : {};
1441
+ const own = map[locale];
1442
+ result[field.name] = isEmpty2(own) && fallback ? map[localization.defaultLocale] ?? null : own ?? null;
1443
+ }
1444
+ }
1445
+ return result;
1446
+ }
1447
+ function toLocaleMaps(fields, input, current, locale) {
1448
+ const result = { ...input };
1449
+ for (const field of fields) {
1450
+ if (!Object.hasOwn(input, field.name)) continue;
1451
+ const value = input[field.name];
1452
+ const previous = current[field.name];
1453
+ if (field.type === "group" && isPlainObject2(value)) {
1454
+ result[field.name] = toLocaleMaps(
1455
+ field.fields,
1456
+ value,
1457
+ isPlainObject2(previous) ? previous : {},
1458
+ locale
1459
+ );
1460
+ } else if (field.type === "array" && Array.isArray(value)) {
1461
+ const rows = new Map(
1462
+ (Array.isArray(previous) ? previous : []).filter(isPlainObject2).map((row) => [String(row.id), row])
1463
+ );
1464
+ result[field.name] = value.map(
1465
+ (row) => isPlainObject2(row) ? toLocaleMaps(field.fields, row, rows.get(String(row.id)) ?? {}, locale) : row
1466
+ );
1467
+ } else if (field.localized) {
1468
+ const map = isPlainObject2(previous) ? previous : {};
1469
+ result[field.name] = { ...map, [locale]: value };
1470
+ }
1471
+ }
1472
+ return result;
1473
+ }
1474
+ function localizePath(fields, path, locale) {
1475
+ const segments = path.split(".");
1476
+ let list = fields;
1477
+ for (let i = 0; i < segments.length; i++) {
1478
+ const field = list.find((f) => f.name === segments[i]);
1479
+ if (!field) return path;
1480
+ if (field.type === "group" || field.type === "array") {
1481
+ list = field.fields;
1482
+ continue;
1483
+ }
1484
+ if (field.localized && i === segments.length - 1) return `${path}.${locale}`;
1485
+ return path;
1486
+ }
1487
+ return path;
1488
+ }
1489
+ function localizeWhere(fields, where, locale) {
1490
+ if (!where) return where;
1491
+ const out = {};
1492
+ for (const [key2, value] of Object.entries(where)) {
1493
+ if ((key2 === "and" || key2 === "or") && Array.isArray(value)) {
1494
+ out[key2] = value.map((w) => localizeWhere(fields, w, locale));
1495
+ } else {
1496
+ out[localizePath(fields, key2, locale)] = value;
1497
+ }
1498
+ }
1499
+ return out;
1500
+ }
1501
+ function localizeSort(fields, sort, locale) {
1502
+ return sort.map(
1503
+ (entry) => entry.startsWith("-") ? `-${localizePath(fields, entry.slice(1), locale)}` : localizePath(fields, entry, locale)
1504
+ );
1505
+ }
1506
+
1263
1507
  // src/logger.ts
1264
1508
  var consoleLogger = {
1265
1509
  info: (message) => console.info(`[easy-cms] ${message}`),
@@ -1645,12 +1889,18 @@ var EasyCMS = class {
1645
1889
  if (!Number.isInteger(limit) || limit < 0)
1646
1890
  throw new QueryError("limit must be a non-negative integer");
1647
1891
  if (!Number.isInteger(page) || page < 1) throw new QueryError("page must be a positive integer");
1648
- const where = draftWhere(
1892
+ const where = this.whereFor(
1649
1893
  config,
1650
- options.draft,
1651
- await this.readWhere(config, guard, options.where)
1894
+ draftWhere(config, options.draft, await this.readWhere(config, guard, options.where)),
1895
+ options
1896
+ );
1897
+ const requested = options.sort === void 0 ? ["-createdAt"] : [options.sort].flat();
1898
+ const locale = this.localeOf(options);
1899
+ const sort = locale === void 0 ? requested : localizeSort(
1900
+ config.fields,
1901
+ requested,
1902
+ locale === ALL_LOCALES ? this.config.localization?.defaultLocale ?? locale : locale
1652
1903
  );
1653
- const sort = options.sort === void 0 ? ["-createdAt"] : [options.sort].flat();
1654
1904
  const result = await this.db.find({ collection: collection2, where, sort, limit, page });
1655
1905
  const found = options.draft ? await this.withDrafts(config, result.docs) : result.docs;
1656
1906
  const docs = await this.output(config, found, guard, options);
@@ -1661,7 +1911,11 @@ var EasyCMS = class {
1661
1911
  const guard = guardOf(options);
1662
1912
  const parsed = parseId(id);
1663
1913
  if (parsed === void 0) return null;
1664
- const where = draftWhere(config, options.draft, await this.readWhere(config, guard, void 0));
1914
+ const where = this.whereFor(
1915
+ config,
1916
+ draftWhere(config, options.draft, await this.readWhere(config, guard, void 0)),
1917
+ options
1918
+ );
1665
1919
  const doc = where ? (await this.db.find({
1666
1920
  collection: collection2,
1667
1921
  where: andWhere(where, { id: { equals: parsed } }),
@@ -1676,10 +1930,14 @@ var EasyCMS = class {
1676
1930
  }
1677
1931
  async count(collection2, options = {}) {
1678
1932
  const config = this.collection(collection2);
1679
- const where = draftWhere(
1933
+ const where = this.whereFor(
1680
1934
  config,
1681
- options.draft,
1682
- await this.readWhere(config, guardOf(options), options.where)
1935
+ draftWhere(
1936
+ config,
1937
+ options.draft,
1938
+ await this.readWhere(config, guardOf(options), options.where)
1939
+ ),
1940
+ options
1683
1941
  );
1684
1942
  return this.db.count({ collection: collection2, where });
1685
1943
  }
@@ -1829,7 +2087,8 @@ var EasyCMS = class {
1829
2087
  collection2,
1830
2088
  parsed,
1831
2089
  config.drafts ? { ...data, status: "draft" } : data,
1832
- options
2090
+ options,
2091
+ "restore"
1833
2092
  );
1834
2093
  }
1835
2094
  /**
@@ -1861,14 +2120,34 @@ var EasyCMS = class {
1861
2120
  input,
1862
2121
  this.fieldChecker("update", guard, parsed, input)
1863
2122
  );
1864
- const merged = base ? mergeForUpdate(config.fields, base, filtered) : applyDefaults(config.fields, filtered);
2123
+ const localized = this.toMaps(config, filtered, base ?? {}, options);
2124
+ const merged = base ? mergeForUpdate(config.fields, base, localized) : applyDefaults(config.fields, localized, this.config.localization);
1865
2125
  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
- });
2126
+ const doc = await this.previewDoc(
2127
+ config,
2128
+ generateSlugs(config.fields, merged, this.config.localization),
2129
+ {
2130
+ id: parsed ?? 0,
2131
+ createdAt: base?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
2132
+ },
2133
+ options
2134
+ );
1870
2135
  const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
1871
- return { doc: out, url: this.previewURL(config, out) };
2136
+ return { doc: out, url: this.previewURL(config, out, options) };
2137
+ }
2138
+ /**
2139
+ * A token that lets a page read one document's current draft for a while (default one hour),
2140
+ * without a login: `GET /api/cms/:collection/:id?preview=<token>`. The admin adds one to the
2141
+ * preview URL as `easy-cms-preview`, for sites on another origin.
2142
+ */
2143
+ createPreviewToken(target, options = {}) {
2144
+ const seconds = options.expiresIn ?? 60 * 60;
2145
+ const normalized = "global" in target ? target : { collection: target.collection, id: String(target.id) };
2146
+ return signPreviewToken(this.config.secret, normalized, Date.now() + seconds * 1e3);
2147
+ }
2148
+ /** What a preview token opens, or `null` when it is invalid or expired. */
2149
+ verifyPreviewToken(token) {
2150
+ return token ? verifyPreviewToken(this.config.secret, token) : null;
1872
2151
  }
1873
2152
  /** Live preview of a global. See `preview`. */
1874
2153
  async previewGlobal(slug, data, options = {}) {
@@ -1885,20 +2164,28 @@ var EasyCMS = class {
1885
2164
  const current = await this.globalDraft(config, saved);
1886
2165
  const merged = generateSlugs(
1887
2166
  config.fields,
1888
- applyDefaults(config.fields, mergeForUpdate(config.fields, current, input))
2167
+ applyDefaults(
2168
+ config.fields,
2169
+ mergeForUpdate(config.fields, current, this.toMaps(config, input, current, options)),
2170
+ this.config.localization
2171
+ ),
2172
+ this.config.localization
1889
2173
  );
1890
2174
  if (config.drafts) merged.status = input.status ?? current.status ?? "draft";
1891
- const doc = await this.previewDoc(config, merged, { id: 0 });
2175
+ const doc = await this.previewDoc(config, merged, { id: 0 }, options);
1892
2176
  const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
1893
2177
  const { id: _id, ...global2 } = out;
1894
- return { doc: global2, url: this.previewURL(config, global2) };
2178
+ return { doc: global2, url: this.previewURL(config, global2, options) };
1895
2179
  }
1896
2180
  /** Coerces preview input like a save would, keeping values that would fail validation. */
1897
- async previewDoc(config, data, system) {
2181
+ async previewDoc(config, data, system, options = {}) {
2182
+ const locale = this.localeOf(options);
1898
2183
  const { data: clean } = await validateFields(config.fields, data, {
1899
2184
  operation: "update",
1900
2185
  root: data,
1901
- skipRequired: true
2186
+ skipRequired: true,
2187
+ localization: this.config.localization,
2188
+ ...locale !== void 0 && locale !== ALL_LOCALES ? { locale } : {}
1902
2189
  });
1903
2190
  return {
1904
2191
  ...data,
@@ -1908,10 +2195,12 @@ var EasyCMS = class {
1908
2195
  updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1909
2196
  };
1910
2197
  }
1911
- previewURL(config, doc) {
2198
+ previewURL(config, doc, options) {
1912
2199
  if (!config.preview) return null;
2200
+ const content = this.localeOf(options);
2201
+ const locale = content && content !== ALL_LOCALES ? content : this.config.admin.locale;
1913
2202
  try {
1914
- return config.preview({ doc, locale: this.config.admin.locale }) ?? null;
2203
+ return config.preview({ doc, locale }) ?? null;
1915
2204
  } catch (error) {
1916
2205
  this.logger.error(`preview URL of "${config.slug}" failed: ${error.message}`);
1917
2206
  return null;
@@ -1940,7 +2229,8 @@ var EasyCMS = class {
1940
2229
  input,
1941
2230
  this.fieldChecker("update", guard, parsed, input)
1942
2231
  );
1943
- let merged = mergeForUpdate(config.fields, current, filtered);
2232
+ const localized = mode === "restore" ? filtered : this.toMaps(config, filtered, current, options);
2233
+ let merged = mergeForUpdate(config.fields, current, localized);
1944
2234
  if (config.drafts)
1945
2235
  merged.status = Object.hasOwn(filtered, "status") ? filtered.status : current.status;
1946
2236
  const base = this.hookArgs(config, guard);
@@ -1952,9 +2242,10 @@ var EasyCMS = class {
1952
2242
  );
1953
2243
  let prepared = await this.prepare(
1954
2244
  config,
1955
- generateSlugs(config.fields, merged),
2245
+ generateSlugs(config.fields, merged, this.config.localization),
1956
2246
  "update",
1957
- parsed
2247
+ parsed,
2248
+ options
1958
2249
  );
1959
2250
  prepared = await this.transform(
1960
2251
  config.hooks?.beforeChange,
@@ -1963,7 +2254,7 @@ var EasyCMS = class {
1963
2254
  prepared
1964
2255
  );
1965
2256
  const now = (/* @__PURE__ */ new Date()).toISOString();
1966
- if (mode === "save" && this.separateDrafts(config) && existing.status === "published" && prepared.status === "draft") {
2257
+ if (mode !== "unpublish" && this.separateDrafts(config) && existing.status === "published" && prepared.status === "draft") {
1967
2258
  if (password !== void 0) {
1968
2259
  throw new ValidationError(collection2, [
1969
2260
  { field: "password", message: "cannot be changed in a draft; publish instead" }
@@ -2029,7 +2320,10 @@ var EasyCMS = class {
2029
2320
  await this.checkGlobalAccess(config, "read", guard);
2030
2321
  const saved = await this.db.findGlobal({ slug });
2031
2322
  const stored = saved && options.draft ? await this.globalDraft(config, saved) : saved;
2032
- const data = fillMissing(config.fields, applyDefaults(config.fields, stored ?? {}));
2323
+ const data = fillMissing(
2324
+ config.fields,
2325
+ applyDefaults(config.fields, stored ?? {}, this.config.localization)
2326
+ );
2033
2327
  if (!stored) {
2034
2328
  data.updatedAt = null;
2035
2329
  if (config.drafts) data.status = "draft";
@@ -2066,7 +2360,10 @@ var EasyCMS = class {
2066
2360
  const config = await this.globalVersionTarget(slug, options);
2067
2361
  const version = await this.versions.get(globalParent(slug), 0, versionId);
2068
2362
  if (!version) return null;
2069
- const data = fillMissing(config.fields, applyDefaults(config.fields, version.data));
2363
+ const data = fillMissing(
2364
+ config.fields,
2365
+ applyDefaults(config.fields, version.data, this.config.localization)
2366
+ );
2070
2367
  const [out] = await this.output(config, [{ ...data, id: 0 }], guardOf(options), {
2071
2368
  ...options,
2072
2369
  draft: true
@@ -2083,7 +2380,8 @@ var EasyCMS = class {
2083
2380
  return this.saveGlobal(
2084
2381
  slug,
2085
2382
  config.drafts ? { ...data, status: "draft" } : data,
2086
- options
2383
+ options,
2384
+ "restore"
2087
2385
  );
2088
2386
  }
2089
2387
  async globalVersionTarget(slug, options) {
@@ -2110,13 +2408,19 @@ var EasyCMS = class {
2110
2408
  );
2111
2409
  const existing = await this.db.findGlobal({ slug }) ?? {};
2112
2410
  const current = await this.globalDraft(config, existing);
2411
+ const localized = mode === "restore" ? input : this.toMaps(config, input, current, options);
2113
2412
  const merged = generateSlugs(
2114
2413
  config.fields,
2115
- applyDefaults(config.fields, mergeForUpdate(config.fields, current, input))
2414
+ applyDefaults(
2415
+ config.fields,
2416
+ mergeForUpdate(config.fields, current, localized),
2417
+ this.config.localization
2418
+ ),
2419
+ this.config.localization
2116
2420
  );
2117
2421
  if (config.drafts) merged.status = input.status ?? current.status ?? "draft";
2118
2422
  const base = this.hookArgs(config, guard);
2119
- let prepared = await this.prepare(config, merged, "update", void 0);
2423
+ let prepared = await this.prepare(config, merged, "update", void 0, options);
2120
2424
  prepared = await this.transform(
2121
2425
  config.hooks?.beforeChange,
2122
2426
  "data",
@@ -2124,7 +2428,7 @@ var EasyCMS = class {
2124
2428
  prepared
2125
2429
  );
2126
2430
  const now = (/* @__PURE__ */ new Date()).toISOString();
2127
- if (mode === "save" && this.separateDrafts(config) && existing.status === "published" && prepared.status === "draft") {
2431
+ if (mode !== "unpublish" && this.separateDrafts(config) && existing.status === "published" && prepared.status === "draft") {
2128
2432
  const draft = { ...prepared, updatedAt: now };
2129
2433
  await this.saveVersion(config, globalParent(slug), 0, draft, guard);
2130
2434
  await this.notify(config.hooks?.afterChange, "afterChange", slug, {
@@ -2238,16 +2542,19 @@ var EasyCMS = class {
2238
2542
  if (where === null) return [];
2239
2543
  const found = await this.db.find({
2240
2544
  collection: target.slug,
2241
- where: draftWhere(target, options.draft, where),
2545
+ where: this.whereFor(target, draftWhere(target, options.draft, where), options),
2242
2546
  sort: [],
2243
2547
  limit: 0,
2244
2548
  page: 1
2245
2549
  });
2246
2550
  const docs2 = options.draft ? await this.withDrafts(target, found.docs) : found.docs;
2247
- return Promise.all(docs2.map((d) => finish(target, d)));
2551
+ return Promise.all(
2552
+ docs2.map((d) => finish(target, this.pick(target, d, options)))
2553
+ );
2248
2554
  };
2249
2555
  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);
2556
+ const localized = docs.map((d) => this.pick(config, d, options));
2557
+ const populated = await populate(load, this.config.collections, config.fields, localized, depth);
2251
2558
  return Promise.all(populated.map((d) => finish(config, d)));
2252
2559
  }
2253
2560
  /** Creates a document: access, input filtering, hooks, validation, then afterChange. */
@@ -2270,7 +2577,11 @@ var EasyCMS = class {
2270
2577
  this.fieldChecker("update", guard, void 0, input)
2271
2578
  );
2272
2579
  const base = this.hookArgs(config, hookGuard ?? guard);
2273
- let data = applyDefaults(config.fields, filtered);
2580
+ let data = applyDefaults(
2581
+ config.fields,
2582
+ this.toMaps(config, filtered, {}, options),
2583
+ this.config.localization
2584
+ );
2274
2585
  data = await this.transform(
2275
2586
  config.hooks?.beforeValidate,
2276
2587
  "data",
@@ -2279,9 +2590,10 @@ var EasyCMS = class {
2279
2590
  );
2280
2591
  let prepared = await this.prepare(
2281
2592
  config,
2282
- generateSlugs(config.fields, data),
2593
+ generateSlugs(config.fields, data, this.config.localization),
2283
2594
  "create",
2284
- void 0
2595
+ void 0,
2596
+ options
2285
2597
  );
2286
2598
  prepared = await this.transform(
2287
2599
  config.hooks?.beforeChange,
@@ -2304,6 +2616,43 @@ var EasyCMS = class {
2304
2616
  const [out] = await this.output(config, [doc], hookGuard ?? guard, { ...options, draft: true });
2305
2617
  return out;
2306
2618
  }
2619
+ /** The locale for an operation; `undefined` without localization. Throws on unknown locales. */
2620
+ localeOf(options) {
2621
+ const localization = this.config.localization;
2622
+ if (!localization) return void 0;
2623
+ const locale = options.locale ?? localization.defaultLocale;
2624
+ if (locale !== ALL_LOCALES && !localization.locales.includes(locale)) {
2625
+ throw new QueryError(
2626
+ `Unknown locale "${locale}" (use one of: ${localization.locales.join(", ")}, ${ALL_LOCALES})`
2627
+ );
2628
+ }
2629
+ return locale;
2630
+ }
2631
+ /** Stored localized maps → the values of the requested locale. */
2632
+ pick(config, doc, options) {
2633
+ const localization = this.config.localization;
2634
+ const locale = this.localeOf(options);
2635
+ if (!localization || locale === void 0) return doc;
2636
+ const fallback = options.fallbackLocale ?? localization.fallback;
2637
+ return pickLocale(config.fields, doc, locale, localization, fallback);
2638
+ }
2639
+ /** Input for one locale → stored localized maps, keeping the other locales from `current`. */
2640
+ toMaps(config, input, current, options) {
2641
+ const locale = this.localeOf(options);
2642
+ if (locale === void 0 || locale === ALL_LOCALES) return input;
2643
+ return toLocaleMaps(config.fields, input, current, locale);
2644
+ }
2645
+ /** Makes `where` match localized fields in the operation's locale. */
2646
+ whereFor(config, where, options) {
2647
+ const locale = this.localeOf(options);
2648
+ const localization = this.config.localization;
2649
+ if (locale === void 0 || !localization) return where;
2650
+ return localizeWhere(
2651
+ config.fields,
2652
+ where,
2653
+ locale === ALL_LOCALES ? localization.defaultLocale : locale
2654
+ );
2655
+ }
2307
2656
  /** Drafts are kept as versions (instead of unpublishing) when both are enabled. */
2308
2657
  separateDrafts(config) {
2309
2658
  return config.drafts === true && versionLimit(config) !== void 0;
@@ -2394,12 +2743,15 @@ var EasyCMS = class {
2394
2743
  return result;
2395
2744
  }
2396
2745
  /** Validates, checks uniqueness and references. Returns clean data or throws `ValidationError`. */
2397
- async prepare(config, data, operation, selfId) {
2746
+ async prepare(config, data, operation, selfId, options = {}) {
2398
2747
  const isDraft = config.drafts === true && (data.status ?? "draft") === "draft";
2748
+ const locale = this.localeOf(options);
2399
2749
  const result = await validateFields(config.fields, data, {
2400
2750
  operation,
2401
2751
  root: data,
2402
- skipRequired: isDraft
2752
+ skipRequired: isDraft,
2753
+ localization: this.config.localization,
2754
+ ...locale !== void 0 && locale !== ALL_LOCALES ? { locale } : {}
2403
2755
  });
2404
2756
  const errors = [...result.errors];
2405
2757
  const clean = { ...result.data };
@@ -2441,14 +2793,26 @@ var EasyCMS = class {
2441
2793
  }
2442
2794
  }
2443
2795
  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;
2796
+ const unique = async (path, base) => {
2447
2797
  let candidate = base;
2448
- for (let n = 2; await this.isTaken(config.slug, field.name, candidate, selfId); n++) {
2798
+ for (let n = 2; await this.isTaken(config.slug, path, candidate, selfId); n++) {
2449
2799
  candidate = `${base}-${n}`;
2450
2800
  }
2451
- data[field.name] = candidate;
2801
+ return candidate;
2802
+ };
2803
+ for (const field of config.fields) {
2804
+ if (field.type !== "slug") continue;
2805
+ const value = data[field.name];
2806
+ if (field.localized && value && typeof value === "object") {
2807
+ const map = { ...value };
2808
+ for (const [locale, slug] of Object.entries(map)) {
2809
+ if (typeof slug === "string" && slug !== "")
2810
+ map[locale] = await unique(`${field.name}.${locale}`, slug);
2811
+ }
2812
+ data[field.name] = map;
2813
+ } else if (typeof value === "string" && value !== "") {
2814
+ data[field.name] = await unique(field.name, value);
2815
+ }
2452
2816
  }
2453
2817
  }
2454
2818
  async checkUnique(config, data, selfId) {
@@ -2456,8 +2820,16 @@ var EasyCMS = class {
2456
2820
  for (const field of config.fields) {
2457
2821
  const value = data[field.name];
2458
2822
  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" });
2823
+ const checks = field.localized && typeof value === "object" ? Object.entries(value).map(([locale, v]) => [
2824
+ `${field.name}.${locale}`,
2825
+ locale === this.config.localization?.defaultLocale ? field.name : `${field.name}.${locale}`,
2826
+ v
2827
+ ]) : [[field.name, field.name, value]];
2828
+ for (const [path, errorField, v] of checks) {
2829
+ if (v === null || v === void 0) continue;
2830
+ if (await this.isTaken(config.slug, path, v, selfId)) {
2831
+ errors.push({ field: errorField, message: "must be unique" });
2832
+ }
2461
2833
  }
2462
2834
  }
2463
2835
  return errors;
@@ -2537,7 +2909,7 @@ function asObject(data, name) {
2537
2909
  async function allowed(access, user) {
2538
2910
  return await evaluateAccess(access, { user }) !== false;
2539
2911
  }
2540
- async function serializeFields(fields, update) {
2912
+ async function serializeFields(fields, update, localized) {
2541
2913
  const out = [];
2542
2914
  for (const field of fields) {
2543
2915
  if (field.hidden) continue;
@@ -2547,6 +2919,7 @@ async function serializeFields(fields, update) {
2547
2919
  if (field.unique) f.unique = true;
2548
2920
  if (field.defaultValue !== void 0) f.defaultValue = field.defaultValue;
2549
2921
  if (!await update.allows(field)) f.readOnly = true;
2922
+ if (field.localized && localized) f.localized = true;
2550
2923
  switch (field.type) {
2551
2924
  case "text":
2552
2925
  case "textarea":
@@ -2576,23 +2949,27 @@ async function serializeFields(fields, update) {
2576
2949
  case "array":
2577
2950
  if (field.minRows !== void 0) f.minRows = field.minRows;
2578
2951
  if (field.maxRows !== void 0) f.maxRows = field.maxRows;
2579
- f.fields = await serializeFields(field.fields, update);
2952
+ f.fields = await serializeFields(field.fields, update, localized);
2580
2953
  break;
2581
2954
  case "group":
2582
- f.fields = await serializeFields(field.fields, update);
2955
+ f.fields = await serializeFields(field.fields, update, localized);
2583
2956
  break;
2584
2957
  }
2585
2958
  out.push(f);
2586
2959
  }
2587
2960
  return out;
2588
2961
  }
2589
- async function collection(config, user) {
2962
+ async function collection(config, user, localized) {
2590
2963
  const result = {
2591
2964
  slug: config.slug,
2592
2965
  drafts: config.drafts === true,
2593
2966
  versions: Boolean(config.versions),
2594
2967
  preview: typeof config.preview === "function",
2595
- fields: await serializeFields(config.fields, new FieldAccessChecker("update", { user })),
2968
+ fields: await serializeFields(
2969
+ config.fields,
2970
+ new FieldAccessChecker("update", { user }),
2971
+ localized
2972
+ ),
2596
2973
  permissions: {
2597
2974
  read: await allowed(config.access?.read, user),
2598
2975
  create: await allowed(config.access?.create, user),
@@ -2604,13 +2981,17 @@ async function collection(config, user) {
2604
2981
  if (config.useAsTitle) result.useAsTitle = config.useAsTitle;
2605
2982
  return result;
2606
2983
  }
2607
- async function global(config, user) {
2984
+ async function global(config, user, localized) {
2608
2985
  const result = {
2609
2986
  slug: config.slug,
2610
2987
  drafts: config.drafts === true,
2611
2988
  versions: Boolean(config.versions),
2612
2989
  preview: typeof config.preview === "function",
2613
- fields: await serializeFields(config.fields, new FieldAccessChecker("update", { user })),
2990
+ fields: await serializeFields(
2991
+ config.fields,
2992
+ new FieldAccessChecker("update", { user }),
2993
+ localized
2994
+ ),
2614
2995
  permissions: {
2615
2996
  read: await allowed(config.access?.read, user),
2616
2997
  update: await allowed(config.access?.update, user)
@@ -2621,10 +3002,13 @@ async function global(config, user) {
2621
3002
  }
2622
3003
  async function adminSchema(cms, user) {
2623
3004
  const collections = cms.config.collections.filter((c) => !INTERNAL_COLLECTIONS.has(c.slug));
3005
+ const localization = cms.config.localization;
3006
+ const localized = localization !== null;
2624
3007
  return {
2625
3008
  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)))
3009
+ localization: localization ? { locales: [...localization.locales], defaultLocale: localization.defaultLocale } : null,
3010
+ collections: await Promise.all(collections.map((c) => collection(c, user, localized))),
3011
+ globals: await Promise.all(cms.config.globals.map((g) => global(g, user, localized)))
2628
3012
  };
2629
3013
  }
2630
3014
 
@@ -2795,7 +3179,7 @@ function withHeaders(response, extra) {
2795
3179
  }
2796
3180
  async function route(cms, ctx, method, segments, options) {
2797
3181
  const [first, second, third] = segments;
2798
- const access = { overrideAccess: false, user: ctx.user };
3182
+ const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
2799
3183
  const draft = ctx.user !== null && ctx.url.searchParams.get("draft") === "true";
2800
3184
  if (segments.length === 0) throw new HttpError("Not found", 404);
2801
3185
  if (first === USERS && second !== void 0 && third === void 0 && !/^\d+$/.test(second)) {
@@ -2860,8 +3244,19 @@ async function route(cms, ctx, method, segments, options) {
2860
3244
  if (!cms.config.globals.some((g) => g.slug === second))
2861
3245
  throw new HttpError(`Unknown global "${second}"`, 404);
2862
3246
  if (third !== void 0) return globalAction(cms, ctx, method, second, segments.slice(2));
2863
- if (method === "GET")
3247
+ if (method === "GET") {
3248
+ if (ctx.url.searchParams.has("preview")) {
3249
+ requirePreview(cms, ctx, { global: second });
3250
+ return {
3251
+ body: await cms.findGlobal(second, {
3252
+ ...parseDepth(ctx.url),
3253
+ ...parseLocale(ctx.url),
3254
+ draft: true
3255
+ })
3256
+ };
3257
+ }
2864
3258
  return { body: await cms.findGlobal(second, { ...access, ...parseDepth(ctx.url), draft }) };
3259
+ }
2865
3260
  if (method === "POST") {
2866
3261
  const body = await readJson(ctx.request);
2867
3262
  return { body: await cms.updateGlobal(second, body, { ...access, ...parseDepth(ctx.url) }) };
@@ -2908,6 +3303,16 @@ async function route(cms, ctx, method, segments, options) {
2908
3303
  throw methodNotAllowed(ctx, "GET, POST");
2909
3304
  }
2910
3305
  const id = second;
3306
+ if (method === "GET" && ctx.url.searchParams.has("preview")) {
3307
+ requirePreview(cms, ctx, { collection: collection2, id });
3308
+ const doc = await cms.findById(collection2, id, {
3309
+ ...parseDepth(ctx.url),
3310
+ ...parseLocale(ctx.url),
3311
+ draft: true
3312
+ });
3313
+ if (!doc) throw new NotFoundError(collection2, id);
3314
+ return { body: doc };
3315
+ }
2911
3316
  if (method === "GET") {
2912
3317
  const doc = await cms.findById(collection2, id, { ...access, ...parseDepth(ctx.url), draft });
2913
3318
  if (!doc) throw new NotFoundError(collection2, id);
@@ -2927,7 +3332,7 @@ async function route(cms, ctx, method, segments, options) {
2927
3332
  throw methodNotAllowed(ctx, "GET, PATCH, DELETE");
2928
3333
  }
2929
3334
  async function documentAction(cms, ctx, method, collection2, id, path) {
2930
- const access = { overrideAccess: false, user: ctx.user };
3335
+ const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
2931
3336
  const depth = parseDepth(ctx.url);
2932
3337
  const [action, versionId, extra] = path;
2933
3338
  if (action === "versions" && versionId === void 0) {
@@ -2947,7 +3352,9 @@ async function documentAction(cms, ctx, method, collection2, id, path) {
2947
3352
  if (path.length === 1 && action === "preview") {
2948
3353
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
2949
3354
  const body = await readJson(ctx.request);
2950
- return { body: await cms.preview(collection2, id, body, { ...access, ...depth }) };
3355
+ const result = await cms.preview(collection2, id, body, { ...access, ...depth });
3356
+ const token = cms.createPreviewToken({ collection: collection2, id });
3357
+ return { body: { ...result, url: result.url && withPreviewToken(result.url, token) } };
2951
3358
  }
2952
3359
  if (path.length === 1 && (action === "unpublish" || action === "discard-draft")) {
2953
3360
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
@@ -2959,7 +3366,7 @@ async function documentAction(cms, ctx, method, collection2, id, path) {
2959
3366
  throw new HttpError("Not found", 404);
2960
3367
  }
2961
3368
  async function globalAction(cms, ctx, method, slug, path) {
2962
- const access = { overrideAccess: false, user: ctx.user };
3369
+ const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
2963
3370
  const depth = parseDepth(ctx.url);
2964
3371
  const [action, versionId, extra] = path;
2965
3372
  if (action === "versions" && versionId === void 0) {
@@ -2979,7 +3386,9 @@ async function globalAction(cms, ctx, method, slug, path) {
2979
3386
  if (path.length === 1 && action === "preview") {
2980
3387
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
2981
3388
  const body = await readJson(ctx.request);
2982
- return { body: await cms.previewGlobal(slug, body, { ...access, ...depth }) };
3389
+ const result = await cms.previewGlobal(slug, body, { ...access, ...depth });
3390
+ const token = cms.createPreviewToken({ global: slug });
3391
+ return { body: { ...result, url: result.url && withPreviewToken(result.url, token) } };
2983
3392
  }
2984
3393
  if (path.length === 1 && (action === "unpublish" || action === "discard-draft")) {
2985
3394
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
@@ -2990,6 +3399,26 @@ async function globalAction(cms, ctx, method, slug, path) {
2990
3399
  }
2991
3400
  throw new HttpError("Not found", 404);
2992
3401
  }
3402
+ function parseLocale(url) {
3403
+ const locale = url.searchParams.get("locale");
3404
+ const fallback = url.searchParams.get("fallback-locale");
3405
+ return {
3406
+ ...locale ? { locale } : {},
3407
+ ...fallback === "false" || fallback === "true" ? { fallbackLocale: fallback === "true" } : {}
3408
+ };
3409
+ }
3410
+ var PREVIEW_PARAM = "easy-cms-preview";
3411
+ function withPreviewToken(url, token) {
3412
+ const hash = url.indexOf("#");
3413
+ const [base, fragment] = hash === -1 ? [url, ""] : [url.slice(0, hash), url.slice(hash)];
3414
+ const separator = base.includes("?") ? "&" : "?";
3415
+ return `${base}${separator}${PREVIEW_PARAM}=${encodeURIComponent(token)}${fragment}`;
3416
+ }
3417
+ function requirePreview(cms, ctx, target) {
3418
+ const opened = cms.verifyPreviewToken(ctx.url.searchParams.get("preview"));
3419
+ const matches = opened !== null && ("global" in target ? "global" in opened && opened.global === target.global : "collection" in opened && opened.collection === target.collection && opened.id === target.id);
3420
+ if (!matches) throw new UnauthorizedError("Invalid or expired preview token");
3421
+ }
2993
3422
  function parsePage(url) {
2994
3423
  const { page, limit } = parseListQuery(url);
2995
3424
  return { page, limit };
@@ -18,6 +18,13 @@ interface LivePreviewOptions {
18
18
  */
19
19
  readonly origin?: string | readonly string[];
20
20
  }
21
+ /** Query parameter with the preview token that the admin adds to preview URLs. */
22
+ declare const PREVIEW_PARAM = "easy-cms-preview";
23
+ /**
24
+ * The preview token from the page URL, or `null`. Pass it on to read the document's draft
25
+ * without a login, e.g. `fetch(\`${api}/posts/${id}?preview=${token}&depth=2\`)`.
26
+ */
27
+ declare function getPreviewToken(): string | null;
21
28
  /** True when the page is shown inside a frame (e.g. the admin's preview). */
22
29
  declare function isLivePreview(): boolean;
23
30
  /**
@@ -30,4 +37,4 @@ declare function isLivePreview(): boolean;
30
37
  */
31
38
  declare function subscribeLivePreview<T = Record<string, unknown>>(onChange: (doc: T, message: LivePreviewMessage<T>) => void, options?: LivePreviewOptions): () => void;
32
39
 
33
- export { type LivePreviewMessage, type LivePreviewOptions, READY_MESSAGE, isLivePreview, subscribeLivePreview };
40
+ export { type LivePreviewMessage, type LivePreviewOptions, PREVIEW_PARAM, READY_MESSAGE, getPreviewToken, isLivePreview, subscribeLivePreview };
@@ -1,5 +1,10 @@
1
1
  // src/live-preview.ts
2
2
  var READY_MESSAGE = "easy-cms:preview-ready";
3
+ var PREVIEW_PARAM = "easy-cms-preview";
4
+ function getPreviewToken() {
5
+ if (typeof window === "undefined") return null;
6
+ return new URLSearchParams(window.location.search).get(PREVIEW_PARAM);
7
+ }
3
8
  function isLivePreview() {
4
9
  return typeof window !== "undefined" && window.parent !== window;
5
10
  }
@@ -18,7 +23,9 @@ function subscribeLivePreview(onChange, options = {}) {
18
23
  return () => window.removeEventListener("message", listener);
19
24
  }
20
25
  export {
26
+ PREVIEW_PARAM,
21
27
  READY_MESSAGE,
28
+ getPreviewToken,
22
29
  isLivePreview,
23
30
  subscribeLivePreview
24
31
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Embedded, code-first headless CMS for Nuxt and Next.js",
5
5
  "license": "MIT",
6
6
  "type": "module",