@easy-cms/core 0.3.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,6 +345,23 @@ 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
+ }
356
+ /**
357
+ * Where a document is shown on the site, for live preview in the admin: an absolute URL or a
358
+ * path on the site's origin. Return `null` when the document has no page.
359
+ */
360
+ type PreviewURL = (args: {
361
+ readonly doc: Record<string, unknown>;
362
+ /** The content locale being edited (with `localization`), otherwise the admin locale. */
363
+ readonly locale: string;
364
+ }) => string | null | undefined;
335
365
  interface VersionsConfig {
336
366
  /** Versions kept per document; older ones are deleted. Default 50. */
337
367
  readonly max?: number;
@@ -354,6 +384,8 @@ interface CollectionConfig {
354
384
  * it is published again.
355
385
  */
356
386
  readonly versions?: boolean | VersionsConfig;
387
+ /** Live preview: the page that shows a document, e.g. `({ doc }) => \`/posts/${doc.slug}\``. */
388
+ readonly preview?: PreviewURL;
357
389
  readonly access?: CollectionAccess;
358
390
  readonly hooks?: CollectionHooks;
359
391
  }
@@ -364,6 +396,8 @@ interface GlobalConfig {
364
396
  readonly drafts?: boolean;
365
397
  /** Keep a snapshot of every save. See `CollectionConfig.versions`. */
366
398
  readonly versions?: boolean | VersionsConfig;
399
+ /** Live preview: the page that shows the global. See `CollectionConfig.preview`. */
400
+ readonly preview?: PreviewURL;
367
401
  readonly access?: GlobalAccess;
368
402
  readonly hooks?: GlobalHooks;
369
403
  }
@@ -430,6 +464,8 @@ interface Config {
430
464
  * `auth.trustedOrigins` are always allowed, with cookies. Default: none.
431
465
  */
432
466
  readonly cors?: readonly string[] | '*';
467
+ /** Content in several languages: fields with `localized: true` hold one value per locale. */
468
+ readonly localization?: LocalizationConfig | null;
433
469
  readonly routes?: RoutesConfig;
434
470
  readonly admin?: AdminConfig;
435
471
  readonly upload?: UploadConfig;
@@ -443,8 +479,10 @@ interface Config {
443
479
  readonly plugins?: readonly Plugin[];
444
480
  }
445
481
  /** The config after plugins ran, validation passed and defaults were applied. */
446
- 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'> {
447
483
  readonly cors: readonly string[] | '*';
484
+ /** `null` without localization. */
485
+ readonly localization: Required<LocalizationConfig> | null;
448
486
  readonly routes: Required<RoutesConfig>;
449
487
  readonly admin: Required<AdminConfig>;
450
488
  readonly upload: Required<Omit<UploadConfig, 'storage'>> & Pick<UploadConfig, 'storage'>;
@@ -696,6 +734,11 @@ interface AccessOptions {
696
734
  interface DepthOptions extends AccessOptions {
697
735
  /** How many levels of relationships to populate. Default 1, maximum 3. */
698
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;
699
742
  }
700
743
  interface ReadOptions extends DepthOptions {
701
744
  /**
@@ -703,6 +746,8 @@ interface ReadOptions extends DepthOptions {
703
746
  * `drafts: true` are returned, including when populating relationships (FR-DRF-04).
704
747
  */
705
748
  readonly draft?: boolean;
749
+ /** Use the default locale's value when a localized value is empty. Default: `localization.fallback`. */
750
+ readonly fallbackLocale?: boolean;
706
751
  }
707
752
  interface FindOptions extends ReadOptions {
708
753
  readonly where?: Where;
@@ -738,6 +783,7 @@ declare class EasyCMS<C extends Config = Config> {
738
783
  count<S extends Slug<C>>(collection: S, options?: {
739
784
  where?: Where;
740
785
  draft?: boolean;
786
+ locale?: string;
741
787
  } & AccessOptions): Promise<number>;
742
788
  create<S extends Slug<C>>(collection: S, data: Create<C, S>, options?: DepthOptions): Promise<Doc<C, S>>;
743
789
  /**
@@ -771,6 +817,34 @@ declare class EasyCMS<C extends Config = Config> {
771
817
  * a draft (publish it to put it live); access, hooks and validation run as for `update`.
772
818
  */
773
819
  restoreVersion<S extends Slug<C>>(collection: S, id: ID, versionId: ID, options?: DepthOptions): Promise<Doc<C, S>>;
820
+ /**
821
+ * Live preview: the document as it would be read if `data` were saved on top of its current
822
+ * state (or as a new document when `id` is `null`), with relationships populated and
823
+ * `afterRead` hooks applied. Nothing is written. Also returns the page URL from `preview`.
824
+ */
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;
843
+ /** Live preview of a global. See `preview`. */
844
+ previewGlobal<S extends GSlug<C>>(slug: S, data: Record<string, unknown>, options?: DepthOptions): Promise<LivePreview<GDoc<C, S>>>;
845
+ /** Coerces preview input like a save would, keeping values that would fail validation. */
846
+ private previewDoc;
847
+ private previewURL;
774
848
  private versionTarget;
775
849
  private updateDocument;
776
850
  delete<S extends Slug<C>>(collection: S, id: ID, options?: AccessOptions): Promise<Doc<C, S>>;
@@ -814,6 +888,14 @@ declare class EasyCMS<C extends Config = Config> {
814
888
  private output;
815
889
  /** Creates a document: access, input filtering, hooks, validation, then afterChange. */
816
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;
817
899
  /** Drafts are kept as versions (instead of unpublishing) when both are enabled. */
818
900
  private separateDrafts;
819
901
  /** Replaces published documents that have a newer draft with that draft. */
@@ -839,6 +921,12 @@ declare class EasyCMS<C extends Config = Config> {
839
921
  private isTaken;
840
922
  private checkReferences;
841
923
  }
924
+ /** A document as live preview shows it, and the page to show it on. */
925
+ interface LivePreview<T> {
926
+ readonly doc: T;
927
+ /** From the collection's `preview` function; `null` without one. */
928
+ readonly url: string | null;
929
+ }
842
930
 
843
931
  interface LoginArgs {
844
932
  readonly email: string;
@@ -951,6 +1039,8 @@ declare class PayloadTooLargeError extends EasyCMSError {
951
1039
  constructor(message: string);
952
1040
  }
953
1041
 
1042
+ type Localization = Required<LocalizationConfig>;
1043
+
954
1044
  type Data = Record<string, unknown>;
955
1045
  /**
956
1046
  * Turns text into a URL slug. Keeps letters and digits of any script,
@@ -958,11 +1048,11 @@ type Data = Record<string, unknown>;
958
1048
  */
959
1049
  declare function slugify(text: string): string;
960
1050
  /** Fills in `defaultValue` for fields that are missing, including inside groups and array rows. */
961
- declare function applyDefaults(fields: readonly Field[], data: Data): Data;
1051
+ declare function applyDefaults(fields: readonly Field[], data: Data, localization?: Localization | null): Data;
962
1052
  /** Gives every field a value (`null`, `[]` or a nested object) so documents always have the same shape. */
963
1053
  declare function fillMissing(fields: readonly Field[], data: Data): Data;
964
1054
  /** Fills empty `slug` fields from their `from` field. Does not check uniqueness. */
965
- declare function generateSlugs(fields: readonly Field[], data: Data): Data;
1055
+ declare function generateSlugs(fields: readonly Field[], data: Data, localization?: Localization | null): Data;
966
1056
  /**
967
1057
  * Merges an update into the stored document. Top-level fields and group
968
1058
  * fields merge key by key; arrays and hasMany values are replaced.
@@ -982,6 +1072,13 @@ interface ValidateOptions {
982
1072
  readonly skipRequired?: boolean;
983
1073
  /** The whole top-level document, passed to custom `validate` functions. */
984
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;
985
1082
  }
986
1083
  interface ValidateResult {
987
1084
  /** Only known fields, with values normalized (ids parsed, dates as ISO strings, row ids filled). */
@@ -1066,6 +1163,8 @@ interface AdminField {
1066
1163
  fields?: AdminField[];
1067
1164
  /** The current user may not change this field. */
1068
1165
  readOnly?: boolean;
1166
+ /** One value per content locale. */
1167
+ localized?: boolean;
1069
1168
  }
1070
1169
  interface AdminCollection {
1071
1170
  slug: string;
@@ -1076,6 +1175,8 @@ interface AdminCollection {
1076
1175
  useAsTitle?: string;
1077
1176
  drafts: boolean;
1078
1177
  versions: boolean;
1178
+ /** Has live preview (`preview` in the config). */
1179
+ preview: boolean;
1079
1180
  fields: AdminField[];
1080
1181
  permissions: {
1081
1182
  read: boolean;
@@ -1089,6 +1190,8 @@ interface AdminGlobal {
1089
1190
  label?: Label;
1090
1191
  drafts: boolean;
1091
1192
  versions: boolean;
1193
+ /** Has live preview (`preview` in the config). */
1194
+ preview: boolean;
1092
1195
  fields: AdminField[];
1093
1196
  permissions: {
1094
1197
  read: boolean;
@@ -1097,6 +1200,11 @@ interface AdminGlobal {
1097
1200
  }
1098
1201
  interface AdminSchema {
1099
1202
  locale: AdminLocale;
1203
+ /** Content locales, when the config has `localization`. */
1204
+ localization: {
1205
+ locales: string[];
1206
+ defaultLocale: string;
1207
+ } | null;
1100
1208
  collections: AdminCollection[];
1101
1209
  globals: AdminGlobal[];
1102
1210
  }
@@ -1129,4 +1237,4 @@ declare const BUILTIN_COLLECTIONS: readonly ["users", "media"];
1129
1237
  /** Checks a config and returns every problem found. An empty array means it is valid. */
1130
1238
  declare function validateConfig(config: Config): ConfigIssue[];
1131
1239
 
1132
- 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 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, 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)) {
@@ -1032,6 +1187,14 @@ function validateContainer(container, path, collectionSlugs, add) {
1032
1187
  add(`${path}.versions.max`, "must be a positive integer");
1033
1188
  }
1034
1189
  }
1190
+ if (container.preview !== void 0 && typeof container.preview !== "function") {
1191
+ add(
1192
+ `${path}.preview`,
1193
+ "must be a function ({ doc }) => url",
1194
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: an example shown to the user
1195
+ "preview: ({ doc }) => `/posts/${doc.slug}`"
1196
+ );
1197
+ }
1035
1198
  const reserved = new Set(SYSTEM_FIELD_NAMES);
1036
1199
  if (container.drafts) reserved.add("status");
1037
1200
  validateFields2(container.fields, `${path}.fields`, reserved, collectionSlugs, add);
@@ -1172,6 +1335,11 @@ async function resolveConfig(input) {
1172
1335
  const result = {
1173
1336
  ...rest,
1174
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,
1175
1343
  routes: { api: `/${(config.routes?.api ?? DEFAULT_API_PATH).replace(/^\/+|\/+$/g, "")}` },
1176
1344
  admin: {
1177
1345
  path: config.admin?.path ?? DEFAULT_ADMIN_PATH,
@@ -1253,6 +1421,89 @@ async function loadConfig(options = {}) {
1253
1421
  // src/local-api.ts
1254
1422
  import { randomBytes as randomBytes3 } from "crypto";
1255
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
+
1256
1507
  // src/logger.ts
1257
1508
  var consoleLogger = {
1258
1509
  info: (message) => console.info(`[easy-cms] ${message}`),
@@ -1638,12 +1889,18 @@ var EasyCMS = class {
1638
1889
  if (!Number.isInteger(limit) || limit < 0)
1639
1890
  throw new QueryError("limit must be a non-negative integer");
1640
1891
  if (!Number.isInteger(page) || page < 1) throw new QueryError("page must be a positive integer");
1641
- const where = draftWhere(
1892
+ const where = this.whereFor(
1642
1893
  config,
1643
- options.draft,
1644
- 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
1645
1903
  );
1646
- const sort = options.sort === void 0 ? ["-createdAt"] : [options.sort].flat();
1647
1904
  const result = await this.db.find({ collection: collection2, where, sort, limit, page });
1648
1905
  const found = options.draft ? await this.withDrafts(config, result.docs) : result.docs;
1649
1906
  const docs = await this.output(config, found, guard, options);
@@ -1654,7 +1911,11 @@ var EasyCMS = class {
1654
1911
  const guard = guardOf(options);
1655
1912
  const parsed = parseId(id);
1656
1913
  if (parsed === void 0) return null;
1657
- 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
+ );
1658
1919
  const doc = where ? (await this.db.find({
1659
1920
  collection: collection2,
1660
1921
  where: andWhere(where, { id: { equals: parsed } }),
@@ -1669,10 +1930,14 @@ var EasyCMS = class {
1669
1930
  }
1670
1931
  async count(collection2, options = {}) {
1671
1932
  const config = this.collection(collection2);
1672
- const where = draftWhere(
1933
+ const where = this.whereFor(
1673
1934
  config,
1674
- options.draft,
1675
- 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
1676
1941
  );
1677
1942
  return this.db.count({ collection: collection2, where });
1678
1943
  }
@@ -1822,8 +2087,124 @@ var EasyCMS = class {
1822
2087
  collection2,
1823
2088
  parsed,
1824
2089
  config.drafts ? { ...data, status: "draft" } : data,
2090
+ options,
2091
+ "restore"
2092
+ );
2093
+ }
2094
+ /**
2095
+ * Live preview: the document as it would be read if `data` were saved on top of its current
2096
+ * state (or as a new document when `id` is `null`), with relationships populated and
2097
+ * `afterRead` hooks applied. Nothing is written. Also returns the page URL from `preview`.
2098
+ */
2099
+ async preview(collection2, id, data, options = {}) {
2100
+ const config = this.collection(collection2);
2101
+ const guard = guardOf(options);
2102
+ const raw = asObject(data, collection2);
2103
+ let base;
2104
+ let parsed;
2105
+ if (id === null) {
2106
+ if (guard.enforce) {
2107
+ const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data: raw });
2108
+ if (allowed2 !== true) throw deny(guard.user);
2109
+ }
2110
+ } else {
2111
+ parsed = parseId(id);
2112
+ const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
2113
+ if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
2114
+ await this.checkDocumentAccess(config, "update", guard, parsed, raw);
2115
+ [base] = await this.withDrafts(config, [existing]);
2116
+ }
2117
+ const { input } = splitPassword(config, raw);
2118
+ const filtered = await filterInput(
2119
+ config.fields,
2120
+ input,
2121
+ this.fieldChecker("update", guard, parsed, input)
2122
+ );
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);
2125
+ if (config.drafts) merged.status = filtered.status ?? base?.status ?? "draft";
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
+ },
1825
2133
  options
1826
2134
  );
2135
+ const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
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;
2151
+ }
2152
+ /** Live preview of a global. See `preview`. */
2153
+ async previewGlobal(slug, data, options = {}) {
2154
+ const config = this.global(slug);
2155
+ const guard = guardOf(options);
2156
+ await this.checkGlobalAccess(config, "update", guard);
2157
+ const raw = asObject(data, slug);
2158
+ const input = await filterInput(
2159
+ config.fields,
2160
+ raw,
2161
+ this.fieldChecker("update", guard, void 0, raw)
2162
+ );
2163
+ const saved = await this.db.findGlobal({ slug }) ?? {};
2164
+ const current = await this.globalDraft(config, saved);
2165
+ const merged = generateSlugs(
2166
+ config.fields,
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
2173
+ );
2174
+ if (config.drafts) merged.status = input.status ?? current.status ?? "draft";
2175
+ const doc = await this.previewDoc(config, merged, { id: 0 }, options);
2176
+ const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
2177
+ const { id: _id, ...global2 } = out;
2178
+ return { doc: global2, url: this.previewURL(config, global2, options) };
2179
+ }
2180
+ /** Coerces preview input like a save would, keeping values that would fail validation. */
2181
+ async previewDoc(config, data, system, options = {}) {
2182
+ const locale = this.localeOf(options);
2183
+ const { data: clean } = await validateFields(config.fields, data, {
2184
+ operation: "update",
2185
+ root: data,
2186
+ skipRequired: true,
2187
+ localization: this.config.localization,
2188
+ ...locale !== void 0 && locale !== ALL_LOCALES ? { locale } : {}
2189
+ });
2190
+ return {
2191
+ ...data,
2192
+ ...clean,
2193
+ ...config.drafts ? { status: data.status } : {},
2194
+ ...system,
2195
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2196
+ };
2197
+ }
2198
+ previewURL(config, doc, options) {
2199
+ if (!config.preview) return null;
2200
+ const content = this.localeOf(options);
2201
+ const locale = content && content !== ALL_LOCALES ? content : this.config.admin.locale;
2202
+ try {
2203
+ return config.preview({ doc, locale }) ?? null;
2204
+ } catch (error) {
2205
+ this.logger.error(`preview URL of "${config.slug}" failed: ${error.message}`);
2206
+ return null;
2207
+ }
1827
2208
  }
1828
2209
  async versionTarget(collection2, id, options) {
1829
2210
  const config = this.collection(collection2);
@@ -1848,7 +2229,8 @@ var EasyCMS = class {
1848
2229
  input,
1849
2230
  this.fieldChecker("update", guard, parsed, input)
1850
2231
  );
1851
- 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);
1852
2234
  if (config.drafts)
1853
2235
  merged.status = Object.hasOwn(filtered, "status") ? filtered.status : current.status;
1854
2236
  const base = this.hookArgs(config, guard);
@@ -1860,9 +2242,10 @@ var EasyCMS = class {
1860
2242
  );
1861
2243
  let prepared = await this.prepare(
1862
2244
  config,
1863
- generateSlugs(config.fields, merged),
2245
+ generateSlugs(config.fields, merged, this.config.localization),
1864
2246
  "update",
1865
- parsed
2247
+ parsed,
2248
+ options
1866
2249
  );
1867
2250
  prepared = await this.transform(
1868
2251
  config.hooks?.beforeChange,
@@ -1871,7 +2254,7 @@ var EasyCMS = class {
1871
2254
  prepared
1872
2255
  );
1873
2256
  const now = (/* @__PURE__ */ new Date()).toISOString();
1874
- 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") {
1875
2258
  if (password !== void 0) {
1876
2259
  throw new ValidationError(collection2, [
1877
2260
  { field: "password", message: "cannot be changed in a draft; publish instead" }
@@ -1937,7 +2320,10 @@ var EasyCMS = class {
1937
2320
  await this.checkGlobalAccess(config, "read", guard);
1938
2321
  const saved = await this.db.findGlobal({ slug });
1939
2322
  const stored = saved && options.draft ? await this.globalDraft(config, saved) : saved;
1940
- 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
+ );
1941
2327
  if (!stored) {
1942
2328
  data.updatedAt = null;
1943
2329
  if (config.drafts) data.status = "draft";
@@ -1974,7 +2360,10 @@ var EasyCMS = class {
1974
2360
  const config = await this.globalVersionTarget(slug, options);
1975
2361
  const version = await this.versions.get(globalParent(slug), 0, versionId);
1976
2362
  if (!version) return null;
1977
- 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
+ );
1978
2367
  const [out] = await this.output(config, [{ ...data, id: 0 }], guardOf(options), {
1979
2368
  ...options,
1980
2369
  draft: true
@@ -1991,7 +2380,8 @@ var EasyCMS = class {
1991
2380
  return this.saveGlobal(
1992
2381
  slug,
1993
2382
  config.drafts ? { ...data, status: "draft" } : data,
1994
- options
2383
+ options,
2384
+ "restore"
1995
2385
  );
1996
2386
  }
1997
2387
  async globalVersionTarget(slug, options) {
@@ -2018,13 +2408,19 @@ var EasyCMS = class {
2018
2408
  );
2019
2409
  const existing = await this.db.findGlobal({ slug }) ?? {};
2020
2410
  const current = await this.globalDraft(config, existing);
2411
+ const localized = mode === "restore" ? input : this.toMaps(config, input, current, options);
2021
2412
  const merged = generateSlugs(
2022
2413
  config.fields,
2023
- 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
2024
2420
  );
2025
2421
  if (config.drafts) merged.status = input.status ?? current.status ?? "draft";
2026
2422
  const base = this.hookArgs(config, guard);
2027
- let prepared = await this.prepare(config, merged, "update", void 0);
2423
+ let prepared = await this.prepare(config, merged, "update", void 0, options);
2028
2424
  prepared = await this.transform(
2029
2425
  config.hooks?.beforeChange,
2030
2426
  "data",
@@ -2032,7 +2428,7 @@ var EasyCMS = class {
2032
2428
  prepared
2033
2429
  );
2034
2430
  const now = (/* @__PURE__ */ new Date()).toISOString();
2035
- 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") {
2036
2432
  const draft = { ...prepared, updatedAt: now };
2037
2433
  await this.saveVersion(config, globalParent(slug), 0, draft, guard);
2038
2434
  await this.notify(config.hooks?.afterChange, "afterChange", slug, {
@@ -2146,16 +2542,19 @@ var EasyCMS = class {
2146
2542
  if (where === null) return [];
2147
2543
  const found = await this.db.find({
2148
2544
  collection: target.slug,
2149
- where: draftWhere(target, options.draft, where),
2545
+ where: this.whereFor(target, draftWhere(target, options.draft, where), options),
2150
2546
  sort: [],
2151
2547
  limit: 0,
2152
2548
  page: 1
2153
2549
  });
2154
2550
  const docs2 = options.draft ? await this.withDrafts(target, found.docs) : found.docs;
2155
- 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
+ );
2156
2554
  };
2157
2555
  const depth = Math.max(0, Math.min(MAX_DEPTH, Math.trunc(options.depth ?? DEFAULT_DEPTH)));
2158
- 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);
2159
2558
  return Promise.all(populated.map((d) => finish(config, d)));
2160
2559
  }
2161
2560
  /** Creates a document: access, input filtering, hooks, validation, then afterChange. */
@@ -2178,7 +2577,11 @@ var EasyCMS = class {
2178
2577
  this.fieldChecker("update", guard, void 0, input)
2179
2578
  );
2180
2579
  const base = this.hookArgs(config, hookGuard ?? guard);
2181
- let data = applyDefaults(config.fields, filtered);
2580
+ let data = applyDefaults(
2581
+ config.fields,
2582
+ this.toMaps(config, filtered, {}, options),
2583
+ this.config.localization
2584
+ );
2182
2585
  data = await this.transform(
2183
2586
  config.hooks?.beforeValidate,
2184
2587
  "data",
@@ -2187,9 +2590,10 @@ var EasyCMS = class {
2187
2590
  );
2188
2591
  let prepared = await this.prepare(
2189
2592
  config,
2190
- generateSlugs(config.fields, data),
2593
+ generateSlugs(config.fields, data, this.config.localization),
2191
2594
  "create",
2192
- void 0
2595
+ void 0,
2596
+ options
2193
2597
  );
2194
2598
  prepared = await this.transform(
2195
2599
  config.hooks?.beforeChange,
@@ -2212,6 +2616,43 @@ var EasyCMS = class {
2212
2616
  const [out] = await this.output(config, [doc], hookGuard ?? guard, { ...options, draft: true });
2213
2617
  return out;
2214
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
+ }
2215
2656
  /** Drafts are kept as versions (instead of unpublishing) when both are enabled. */
2216
2657
  separateDrafts(config) {
2217
2658
  return config.drafts === true && versionLimit(config) !== void 0;
@@ -2302,12 +2743,15 @@ var EasyCMS = class {
2302
2743
  return result;
2303
2744
  }
2304
2745
  /** Validates, checks uniqueness and references. Returns clean data or throws `ValidationError`. */
2305
- async prepare(config, data, operation, selfId) {
2746
+ async prepare(config, data, operation, selfId, options = {}) {
2306
2747
  const isDraft = config.drafts === true && (data.status ?? "draft") === "draft";
2748
+ const locale = this.localeOf(options);
2307
2749
  const result = await validateFields(config.fields, data, {
2308
2750
  operation,
2309
2751
  root: data,
2310
- skipRequired: isDraft
2752
+ skipRequired: isDraft,
2753
+ localization: this.config.localization,
2754
+ ...locale !== void 0 && locale !== ALL_LOCALES ? { locale } : {}
2311
2755
  });
2312
2756
  const errors = [...result.errors];
2313
2757
  const clean = { ...result.data };
@@ -2349,14 +2793,26 @@ var EasyCMS = class {
2349
2793
  }
2350
2794
  }
2351
2795
  async makeSlugsUnique(config, data, selfId) {
2352
- for (const field of config.fields) {
2353
- const base = data[field.name];
2354
- if (field.type !== "slug" || typeof base !== "string" || base === "") continue;
2796
+ const unique = async (path, base) => {
2355
2797
  let candidate = base;
2356
- 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++) {
2357
2799
  candidate = `${base}-${n}`;
2358
2800
  }
2359
- 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
+ }
2360
2816
  }
2361
2817
  }
2362
2818
  async checkUnique(config, data, selfId) {
@@ -2364,8 +2820,16 @@ var EasyCMS = class {
2364
2820
  for (const field of config.fields) {
2365
2821
  const value = data[field.name];
2366
2822
  if (!field.unique || field.type === "slug" || value === null || value === void 0) continue;
2367
- if (await this.isTaken(config.slug, field.name, value, selfId)) {
2368
- 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
+ }
2369
2833
  }
2370
2834
  }
2371
2835
  return errors;
@@ -2445,7 +2909,7 @@ function asObject(data, name) {
2445
2909
  async function allowed(access, user) {
2446
2910
  return await evaluateAccess(access, { user }) !== false;
2447
2911
  }
2448
- async function serializeFields(fields, update) {
2912
+ async function serializeFields(fields, update, localized) {
2449
2913
  const out = [];
2450
2914
  for (const field of fields) {
2451
2915
  if (field.hidden) continue;
@@ -2455,6 +2919,7 @@ async function serializeFields(fields, update) {
2455
2919
  if (field.unique) f.unique = true;
2456
2920
  if (field.defaultValue !== void 0) f.defaultValue = field.defaultValue;
2457
2921
  if (!await update.allows(field)) f.readOnly = true;
2922
+ if (field.localized && localized) f.localized = true;
2458
2923
  switch (field.type) {
2459
2924
  case "text":
2460
2925
  case "textarea":
@@ -2484,22 +2949,27 @@ async function serializeFields(fields, update) {
2484
2949
  case "array":
2485
2950
  if (field.minRows !== void 0) f.minRows = field.minRows;
2486
2951
  if (field.maxRows !== void 0) f.maxRows = field.maxRows;
2487
- f.fields = await serializeFields(field.fields, update);
2952
+ f.fields = await serializeFields(field.fields, update, localized);
2488
2953
  break;
2489
2954
  case "group":
2490
- f.fields = await serializeFields(field.fields, update);
2955
+ f.fields = await serializeFields(field.fields, update, localized);
2491
2956
  break;
2492
2957
  }
2493
2958
  out.push(f);
2494
2959
  }
2495
2960
  return out;
2496
2961
  }
2497
- async function collection(config, user) {
2962
+ async function collection(config, user, localized) {
2498
2963
  const result = {
2499
2964
  slug: config.slug,
2500
2965
  drafts: config.drafts === true,
2501
2966
  versions: Boolean(config.versions),
2502
- fields: await serializeFields(config.fields, new FieldAccessChecker("update", { user })),
2967
+ preview: typeof config.preview === "function",
2968
+ fields: await serializeFields(
2969
+ config.fields,
2970
+ new FieldAccessChecker("update", { user }),
2971
+ localized
2972
+ ),
2503
2973
  permissions: {
2504
2974
  read: await allowed(config.access?.read, user),
2505
2975
  create: await allowed(config.access?.create, user),
@@ -2511,12 +2981,17 @@ async function collection(config, user) {
2511
2981
  if (config.useAsTitle) result.useAsTitle = config.useAsTitle;
2512
2982
  return result;
2513
2983
  }
2514
- async function global(config, user) {
2984
+ async function global(config, user, localized) {
2515
2985
  const result = {
2516
2986
  slug: config.slug,
2517
2987
  drafts: config.drafts === true,
2518
2988
  versions: Boolean(config.versions),
2519
- fields: await serializeFields(config.fields, new FieldAccessChecker("update", { user })),
2989
+ preview: typeof config.preview === "function",
2990
+ fields: await serializeFields(
2991
+ config.fields,
2992
+ new FieldAccessChecker("update", { user }),
2993
+ localized
2994
+ ),
2520
2995
  permissions: {
2521
2996
  read: await allowed(config.access?.read, user),
2522
2997
  update: await allowed(config.access?.update, user)
@@ -2527,10 +3002,13 @@ async function global(config, user) {
2527
3002
  }
2528
3003
  async function adminSchema(cms, user) {
2529
3004
  const collections = cms.config.collections.filter((c) => !INTERNAL_COLLECTIONS.has(c.slug));
3005
+ const localization = cms.config.localization;
3006
+ const localized = localization !== null;
2530
3007
  return {
2531
3008
  locale: cms.config.admin.locale,
2532
- collections: await Promise.all(collections.map((c) => collection(c, user))),
2533
- 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)))
2534
3012
  };
2535
3013
  }
2536
3014
 
@@ -2701,7 +3179,7 @@ function withHeaders(response, extra) {
2701
3179
  }
2702
3180
  async function route(cms, ctx, method, segments, options) {
2703
3181
  const [first, second, third] = segments;
2704
- const access = { overrideAccess: false, user: ctx.user };
3182
+ const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
2705
3183
  const draft = ctx.user !== null && ctx.url.searchParams.get("draft") === "true";
2706
3184
  if (segments.length === 0) throw new HttpError("Not found", 404);
2707
3185
  if (first === USERS && second !== void 0 && third === void 0 && !/^\d+$/.test(second)) {
@@ -2766,8 +3244,19 @@ async function route(cms, ctx, method, segments, options) {
2766
3244
  if (!cms.config.globals.some((g) => g.slug === second))
2767
3245
  throw new HttpError(`Unknown global "${second}"`, 404);
2768
3246
  if (third !== void 0) return globalAction(cms, ctx, method, second, segments.slice(2));
2769
- 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
+ }
2770
3258
  return { body: await cms.findGlobal(second, { ...access, ...parseDepth(ctx.url), draft }) };
3259
+ }
2771
3260
  if (method === "POST") {
2772
3261
  const body = await readJson(ctx.request);
2773
3262
  return { body: await cms.updateGlobal(second, body, { ...access, ...parseDepth(ctx.url) }) };
@@ -2792,6 +3281,13 @@ async function route(cms, ctx, method, segments, options) {
2792
3281
  if (second !== void 0 && third !== void 0) {
2793
3282
  return documentAction(cms, ctx, method, collection2, second, segments.slice(2));
2794
3283
  }
3284
+ if (second === "preview") {
3285
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
3286
+ const body = await readJson(ctx.request);
3287
+ return {
3288
+ body: await cms.preview(collection2, null, body, { ...access, ...parseDepth(ctx.url) })
3289
+ };
3290
+ }
2795
3291
  if (second === void 0) {
2796
3292
  if (method === "GET") {
2797
3293
  const query = parseListQuery(ctx.url);
@@ -2807,6 +3303,16 @@ async function route(cms, ctx, method, segments, options) {
2807
3303
  throw methodNotAllowed(ctx, "GET, POST");
2808
3304
  }
2809
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
+ }
2810
3316
  if (method === "GET") {
2811
3317
  const doc = await cms.findById(collection2, id, { ...access, ...parseDepth(ctx.url), draft });
2812
3318
  if (!doc) throw new NotFoundError(collection2, id);
@@ -2826,7 +3332,7 @@ async function route(cms, ctx, method, segments, options) {
2826
3332
  throw methodNotAllowed(ctx, "GET, PATCH, DELETE");
2827
3333
  }
2828
3334
  async function documentAction(cms, ctx, method, collection2, id, path) {
2829
- const access = { overrideAccess: false, user: ctx.user };
3335
+ const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
2830
3336
  const depth = parseDepth(ctx.url);
2831
3337
  const [action, versionId, extra] = path;
2832
3338
  if (action === "versions" && versionId === void 0) {
@@ -2843,6 +3349,13 @@ async function documentAction(cms, ctx, method, collection2, id, path) {
2843
3349
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
2844
3350
  return { body: await cms.restoreVersion(collection2, id, versionId, { ...access, ...depth }) };
2845
3351
  }
3352
+ if (path.length === 1 && action === "preview") {
3353
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
3354
+ const body = await readJson(ctx.request);
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) } };
3358
+ }
2846
3359
  if (path.length === 1 && (action === "unpublish" || action === "discard-draft")) {
2847
3360
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
2848
3361
  const options = { ...access, ...depth };
@@ -2853,7 +3366,7 @@ async function documentAction(cms, ctx, method, collection2, id, path) {
2853
3366
  throw new HttpError("Not found", 404);
2854
3367
  }
2855
3368
  async function globalAction(cms, ctx, method, slug, path) {
2856
- const access = { overrideAccess: false, user: ctx.user };
3369
+ const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
2857
3370
  const depth = parseDepth(ctx.url);
2858
3371
  const [action, versionId, extra] = path;
2859
3372
  if (action === "versions" && versionId === void 0) {
@@ -2870,6 +3383,13 @@ async function globalAction(cms, ctx, method, slug, path) {
2870
3383
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
2871
3384
  return { body: await cms.restoreGlobalVersion(slug, versionId, { ...access, ...depth }) };
2872
3385
  }
3386
+ if (path.length === 1 && action === "preview") {
3387
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
3388
+ const body = await readJson(ctx.request);
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) } };
3392
+ }
2873
3393
  if (path.length === 1 && (action === "unpublish" || action === "discard-draft")) {
2874
3394
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
2875
3395
  const options = { ...access, ...depth };
@@ -2879,6 +3399,26 @@ async function globalAction(cms, ctx, method, slug, path) {
2879
3399
  }
2880
3400
  throw new HttpError("Not found", 404);
2881
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
+ }
2882
3422
  function parsePage(url) {
2883
3423
  const { page, limit } = parseListQuery(url);
2884
3424
  return { page, limit };
@@ -0,0 +1,40 @@
1
+ /** Sent by the admin to the preview frame whenever the form changes. */
2
+ interface LivePreviewMessage<T = Record<string, unknown>> {
3
+ readonly type: 'easy-cms:preview';
4
+ /** Collection slug, or `undefined` for a global. */
5
+ readonly collection?: string;
6
+ /** Global slug, or `undefined` for a collection. */
7
+ readonly global?: string;
8
+ /** The document as it would be read after saving, relationships populated. */
9
+ readonly doc: T;
10
+ }
11
+ /** Sent by the page to the admin when it is ready, so the admin sends the current state. */
12
+ declare const READY_MESSAGE = "easy-cms:preview-ready";
13
+ interface LivePreviewOptions {
14
+ /**
15
+ * Origin(s) of the admin allowed to send updates. Default: the page's own origin, which fits
16
+ * the Nuxt and Next.js adapters (admin and site share it). Set it for a standalone server,
17
+ * e.g. `'https://cms.example.com'`.
18
+ */
19
+ readonly origin?: string | readonly string[];
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;
28
+ /** True when the page is shown inside a frame (e.g. the admin's preview). */
29
+ declare function isLivePreview(): boolean;
30
+ /**
31
+ * Calls `onChange` with the document each time the admin's form changes. Returns a function
32
+ * that stops listening. Does nothing outside a frame, so it is safe to call on every page.
33
+ *
34
+ * ```ts
35
+ * const stop = subscribeLivePreview<Post>((doc) => render(doc))
36
+ * ```
37
+ */
38
+ declare function subscribeLivePreview<T = Record<string, unknown>>(onChange: (doc: T, message: LivePreviewMessage<T>) => void, options?: LivePreviewOptions): () => void;
39
+
40
+ export { type LivePreviewMessage, type LivePreviewOptions, PREVIEW_PARAM, READY_MESSAGE, getPreviewToken, isLivePreview, subscribeLivePreview };
@@ -0,0 +1,31 @@
1
+ // src/live-preview.ts
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
+ }
8
+ function isLivePreview() {
9
+ return typeof window !== "undefined" && window.parent !== window;
10
+ }
11
+ function subscribeLivePreview(onChange, options = {}) {
12
+ if (!isLivePreview()) return () => {
13
+ };
14
+ const allowed = [options.origin ?? window.location.origin].flat();
15
+ const listener = (event) => {
16
+ if (!allowed.includes(event.origin)) return;
17
+ const message = event.data;
18
+ if (message?.type !== "easy-cms:preview" || typeof message.doc !== "object") return;
19
+ onChange(message.doc, message);
20
+ };
21
+ window.addEventListener("message", listener);
22
+ window.parent.postMessage({ type: READY_MESSAGE }, "*");
23
+ return () => window.removeEventListener("message", listener);
24
+ }
25
+ export {
26
+ PREVIEW_PARAM,
27
+ READY_MESSAGE,
28
+ getPreviewToken,
29
+ isLivePreview,
30
+ subscribeLivePreview
31
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "0.3.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",
@@ -10,6 +10,10 @@
10
10
  "types": "./dist/index.d.ts",
11
11
  "default": "./dist/index.js"
12
12
  },
13
+ "./live-preview": {
14
+ "types": "./dist/live-preview.d.ts",
15
+ "default": "./dist/live-preview.js"
16
+ },
13
17
  "./package.json": "./package.json"
14
18
  },
15
19
  "files": [