@easy-cms/core 0.2.0 → 0.4.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
@@ -332,6 +332,18 @@ interface GlobalHooks {
332
332
  readonly afterChange?: readonly AfterChangeHook[];
333
333
  readonly afterRead?: readonly AfterReadHook[];
334
334
  }
335
+ /**
336
+ * Where a document is shown on the site, for live preview in the admin: an absolute URL or a
337
+ * path on the site's origin. Return `null` when the document has no page.
338
+ */
339
+ type PreviewURL = (args: {
340
+ readonly doc: Record<string, unknown>;
341
+ readonly locale: AdminLocale;
342
+ }) => string | null | undefined;
343
+ interface VersionsConfig {
344
+ /** Versions kept per document; older ones are deleted. Default 50. */
345
+ readonly max?: number;
346
+ }
335
347
  interface CollectionConfig {
336
348
  /** URL and table name. Lowercase letters, digits, `-` and `_`. */
337
349
  readonly slug: string;
@@ -344,6 +356,14 @@ interface CollectionConfig {
344
356
  readonly useAsTitle?: string;
345
357
  /** Adds a `status` field (`draft` | `published`). */
346
358
  readonly drafts?: boolean;
359
+ /**
360
+ * Keep a snapshot of every save, to view history and restore. With `drafts`, a draft saved
361
+ * on a published document is kept as a version and the published document stays live until
362
+ * it is published again.
363
+ */
364
+ readonly versions?: boolean | VersionsConfig;
365
+ /** Live preview: the page that shows a document, e.g. `({ doc }) => \`/posts/${doc.slug}\``. */
366
+ readonly preview?: PreviewURL;
347
367
  readonly access?: CollectionAccess;
348
368
  readonly hooks?: CollectionHooks;
349
369
  }
@@ -352,6 +372,10 @@ interface GlobalConfig {
352
372
  readonly label?: Label;
353
373
  readonly fields: readonly Field[];
354
374
  readonly drafts?: boolean;
375
+ /** Keep a snapshot of every save. See `CollectionConfig.versions`. */
376
+ readonly versions?: boolean | VersionsConfig;
377
+ /** Live preview: the page that shows the global. See `CollectionConfig.preview`. */
378
+ readonly preview?: PreviewURL;
355
379
  readonly access?: GlobalAccess;
356
380
  readonly hooks?: GlobalHooks;
357
381
  }
@@ -647,6 +671,23 @@ type GlobalInput<C extends Config, S extends GlobalSlug<C>> = Partial<Simplify<F
647
671
  readonly slug: S;
648
672
  }>>>>;
649
673
 
674
+ declare const DEFAULT_MAX_VERSIONS = 50;
675
+ /** One saved state of a document or global. */
676
+ interface VersionSummary {
677
+ readonly id: ID;
678
+ /** `draft` or `published` with drafts enabled, otherwise `null`. */
679
+ readonly status: 'draft' | 'published' | null;
680
+ /** The newest version: the document's current editable state. */
681
+ readonly latest: boolean;
682
+ /** Who saved it (user id), or `null` for saves without a user (scripts, seeds). */
683
+ readonly author: ID | null;
684
+ readonly createdAt: string;
685
+ }
686
+ interface Version<T = Record<string, unknown>> extends VersionSummary {
687
+ /** The document as it was saved (hidden fields such as password hashes are never kept). */
688
+ readonly data: T;
689
+ }
690
+
650
691
  type Data$1 = Record<string, unknown>;
651
692
  type Slug<C extends Config> = string extends CollectionSlug<C> ? string : CollectionSlug<C>;
652
693
  type GSlug<C extends Config> = string extends GlobalSlug<C> ? string : GlobalSlug<C>;
@@ -702,6 +743,7 @@ declare class EasyCMS<C extends Config = Config> {
702
743
  readonly auth: Auth;
703
744
  /** Where uploaded files are stored. */
704
745
  readonly storage: StorageAdapter;
746
+ private readonly versions;
705
747
  constructor(config: ResolvedConfig, db: Database, logger?: Logger, storage?: StorageAdapter);
706
748
  find<S extends Slug<C>>(collection: S, options?: FindOptions): Promise<PaginatedDocs<Doc<C, S>>>;
707
749
  findById<S extends Slug<C>>(collection: S, id: ID, options?: ReadOptions): Promise<Doc<C, S> | null>;
@@ -721,9 +763,58 @@ declare class EasyCMS<C extends Config = Config> {
721
763
  /** Public URL of a stored file. */
722
764
  mediaURL(key: string): string;
723
765
  update<S extends Slug<C>>(collection: S, id: ID, data: Update<C, S>, options?: DepthOptions): Promise<Doc<C, S>>;
766
+ /**
767
+ * Takes a document off the site: its status becomes `draft`. With versions and drafts, a plain
768
+ * `update` with `status: 'draft'` only saves a draft and leaves the published document live;
769
+ * this is how to unpublish it.
770
+ */
771
+ unpublish<S extends Slug<C>>(collection: S, id: ID, options?: DepthOptions): Promise<Doc<C, S>>;
772
+ /** Throws away the unpublished draft of a published document, going back to what is live. */
773
+ discardDraft<S extends Slug<C>>(collection: S, id: ID, options?: DepthOptions): Promise<Doc<C, S>>;
774
+ /** Saved versions of a document, newest first. Needs update access to the document. */
775
+ findVersions<S extends Slug<C>>(collection: S, id: ID, options?: AccessOptions & {
776
+ limit?: number;
777
+ page?: number;
778
+ }): Promise<PaginatedDocs<VersionSummary>>;
779
+ /** One saved version with the document as it was then. */
780
+ findVersion<S extends Slug<C>>(collection: S, id: ID, versionId: ID, options?: DepthOptions): Promise<Version<Doc<C, S>> | null>;
781
+ /**
782
+ * Makes an old version the current content, as a new version. With drafts it is restored as
783
+ * a draft (publish it to put it live); access, hooks and validation run as for `update`.
784
+ */
785
+ restoreVersion<S extends Slug<C>>(collection: S, id: ID, versionId: ID, options?: DepthOptions): Promise<Doc<C, S>>;
786
+ /**
787
+ * Live preview: the document as it would be read if `data` were saved on top of its current
788
+ * state (or as a new document when `id` is `null`), with relationships populated and
789
+ * `afterRead` hooks applied. Nothing is written. Also returns the page URL from `preview`.
790
+ */
791
+ preview<S extends Slug<C>>(collection: S, id: ID | null, data: Record<string, unknown>, options?: DepthOptions): Promise<LivePreview<Doc<C, S>>>;
792
+ /** Live preview of a global. See `preview`. */
793
+ previewGlobal<S extends GSlug<C>>(slug: S, data: Record<string, unknown>, options?: DepthOptions): Promise<LivePreview<GDoc<C, S>>>;
794
+ /** Coerces preview input like a save would, keeping values that would fail validation. */
795
+ private previewDoc;
796
+ private previewURL;
797
+ private versionTarget;
798
+ private updateDocument;
724
799
  delete<S extends Slug<C>>(collection: S, id: ID, options?: AccessOptions): Promise<Doc<C, S>>;
725
800
  findGlobal<S extends GSlug<C>>(slug: S, options?: ReadOptions): Promise<GDoc<C, S>>;
726
801
  updateGlobal<S extends GSlug<C>>(slug: S, data: GInput<C, S>, options?: DepthOptions): Promise<GDoc<C, S>>;
802
+ /** Takes a global off the site (`status: 'draft'`). See `unpublish`. */
803
+ unpublishGlobal<S extends GSlug<C>>(slug: S, options?: DepthOptions): Promise<GDoc<C, S>>;
804
+ /** Throws away the unpublished draft of a published global. */
805
+ discardGlobalDraft<S extends GSlug<C>>(slug: S, options?: DepthOptions): Promise<GDoc<C, S>>;
806
+ /** Saved versions of a global, newest first. Needs update access. */
807
+ findGlobalVersions<S extends GSlug<C>>(slug: S, options?: AccessOptions & {
808
+ limit?: number;
809
+ page?: number;
810
+ }): Promise<PaginatedDocs<VersionSummary>>;
811
+ findGlobalVersion<S extends GSlug<C>>(slug: S, versionId: ID, options?: DepthOptions): Promise<Version<GDoc<C, S>> | null>;
812
+ /** Makes an old version of a global current (as a draft when the global has drafts). */
813
+ restoreGlobalVersion<S extends GSlug<C>>(slug: S, versionId: ID, options?: DepthOptions): Promise<GDoc<C, S>>;
814
+ private globalVersionTarget;
815
+ /** The global's pending draft when it has one (see `withDrafts`), otherwise `saved`. */
816
+ private globalDraft;
817
+ private saveGlobal;
727
818
  /**
728
819
  * What `user` may do with one document. Resolves `where`-style access
729
820
  * against the document, so the admin UI can hide actions precisely.
@@ -746,6 +837,12 @@ declare class EasyCMS<C extends Config = Config> {
746
837
  private output;
747
838
  /** Creates a document: access, input filtering, hooks, validation, then afterChange. */
748
839
  private createDocument;
840
+ /** Drafts are kept as versions (instead of unpublishing) when both are enabled. */
841
+ private separateDrafts;
842
+ /** Replaces published documents that have a newer draft with that draft. */
843
+ private withDrafts;
844
+ /** Records a version after a save, when the collection or global keeps versions. */
845
+ private saveVersion;
749
846
  private hookArgs;
750
847
  /**
751
848
  * Runs hooks that may replace a value, passed as `data` (before hooks) or `doc` (afterRead).
@@ -765,6 +862,12 @@ declare class EasyCMS<C extends Config = Config> {
765
862
  private isTaken;
766
863
  private checkReferences;
767
864
  }
865
+ /** A document as live preview shows it, and the page to show it on. */
866
+ interface LivePreview<T> {
867
+ readonly doc: T;
868
+ /** From the collection's `preview` function; `null` without one. */
869
+ readonly url: string | null;
870
+ }
768
871
 
769
872
  interface LoginArgs {
770
873
  readonly email: string;
@@ -1001,6 +1104,9 @@ interface AdminCollection {
1001
1104
  };
1002
1105
  useAsTitle?: string;
1003
1106
  drafts: boolean;
1107
+ versions: boolean;
1108
+ /** Has live preview (`preview` in the config). */
1109
+ preview: boolean;
1004
1110
  fields: AdminField[];
1005
1111
  permissions: {
1006
1112
  read: boolean;
@@ -1013,6 +1119,9 @@ interface AdminGlobal {
1013
1119
  slug: string;
1014
1120
  label?: Label;
1015
1121
  drafts: boolean;
1122
+ versions: boolean;
1123
+ /** Has live preview (`preview` in the config). */
1124
+ preview: boolean;
1016
1125
  fields: AdminField[];
1017
1126
  permissions: {
1018
1127
  read: boolean;
@@ -1053,4 +1162,4 @@ declare const BUILTIN_COLLECTIONS: readonly ["users", "media"];
1053
1162
  /** Checks a config and returns every problem found. An empty array means it is valid. */
1054
1163
  declare function validateConfig(config: Config): ConfigIssue[];
1055
1164
 
1056
- 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_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 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 };
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 };
package/dist/index.js CHANGED
@@ -160,7 +160,12 @@ var USERS = "users";
160
160
  var MEDIA = "media";
161
161
  var SESSIONS = "sessions";
162
162
  var LOGIN_ATTEMPTS = "login-attempts";
163
- var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([SESSIONS, LOGIN_ATTEMPTS]);
163
+ var VERSIONS = "document-versions";
164
+ var INTERNAL_COLLECTIONS = /* @__PURE__ */ new Set([
165
+ SESSIONS,
166
+ LOGIN_ATTEMPTS,
167
+ VERSIONS
168
+ ]);
164
169
  var DEFAULT_ROLES = ["admin", "editor"];
165
170
  var adminOrSelf = ({ user }) => {
166
171
  if (!user) return false;
@@ -325,6 +330,21 @@ var internalCollections = [
325
330
  fields: [{ name: "key", type: "text", required: true, index: true }]
326
331
  }
327
332
  ];
333
+ var versionsCollection = {
334
+ slug: VERSIONS,
335
+ access: { read: nobody, create: nobody, update: nobody, delete: nobody },
336
+ fields: [
337
+ // Collection slug, or `global:<slug>`.
338
+ { name: "parent", type: "text", required: true, index: true },
339
+ // Document id; 0 for globals.
340
+ { name: "doc", type: "number", required: true, index: true },
341
+ { name: "status", type: "text" },
342
+ // The newest version of the document: its current editable state.
343
+ { name: "latest", type: "boolean", index: true },
344
+ { name: "author", type: "number" },
345
+ { name: "snapshot", type: "json", required: true }
346
+ ]
347
+ };
328
348
 
329
349
  // src/auth/password.ts
330
350
  import { randomBytes, scrypt, timingSafeEqual } from "crypto";
@@ -847,6 +867,7 @@ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
847
867
  "globals",
848
868
  "sessions",
849
869
  "login-attempts",
870
+ "document-versions",
850
871
  "migrations",
851
872
  "access"
852
873
  ]);
@@ -1002,6 +1023,22 @@ function validateContainer(container, path, collectionSlugs, add) {
1002
1023
  add(`${path}.fields`, "must be an array");
1003
1024
  return;
1004
1025
  }
1026
+ const versions = container.versions;
1027
+ if (versions !== void 0 && typeof versions !== "boolean") {
1028
+ const max = typeof versions === "object" && versions !== null ? versions.max : void 0;
1029
+ if (typeof versions !== "object" || versions === null) {
1030
+ add(`${path}.versions`, "must be true, false or { max }");
1031
+ } else if (max !== void 0 && (!Number.isInteger(max) || max < 1)) {
1032
+ add(`${path}.versions.max`, "must be a positive integer");
1033
+ }
1034
+ }
1035
+ if (container.preview !== void 0 && typeof container.preview !== "function") {
1036
+ add(
1037
+ `${path}.preview`,
1038
+ "must be a function ({ doc }) => url",
1039
+ "preview: ({ doc }) => `/posts/${doc.slug}`"
1040
+ );
1041
+ }
1005
1042
  const reserved = new Set(SYSTEM_FIELD_NAMES);
1006
1043
  if (container.drafts) reserved.add("status");
1007
1044
  validateFields2(container.fields, `${path}.fields`, reserved, collectionSlugs, add);
@@ -1161,12 +1198,19 @@ async function resolveConfig(input) {
1161
1198
  lockWindow: config.auth?.lockWindow ?? 15 * 60,
1162
1199
  trustedOrigins: config.auth?.trustedOrigins ?? []
1163
1200
  },
1164
- collections: [...config.collections ?? [], ...internalCollections],
1201
+ collections: [
1202
+ ...config.collections ?? [],
1203
+ ...internalCollections,
1204
+ ...usesVersions(config) ? [versionsCollection] : []
1205
+ ],
1165
1206
  globals: config.globals ?? []
1166
1207
  };
1167
1208
  resolved.add(result);
1168
1209
  return result;
1169
1210
  }
1211
+ function usesVersions(config) {
1212
+ return [...config.collections ?? [], ...config.globals ?? []].some((c) => c.versions);
1213
+ }
1170
1214
 
1171
1215
  // src/load-config.ts
1172
1216
  var CONFIG_FILE_NAMES = [
@@ -1429,6 +1473,137 @@ function localStorage(options = {}) {
1429
1473
  };
1430
1474
  }
1431
1475
 
1476
+ // src/versions.ts
1477
+ var DEFAULT_MAX_VERSIONS = 50;
1478
+ function versionLimit(config) {
1479
+ const versions = config.versions;
1480
+ if (!versions) return void 0;
1481
+ return versions === true ? DEFAULT_MAX_VERSIONS : versions.max ?? DEFAULT_MAX_VERSIONS;
1482
+ }
1483
+ var collectionParent = (slug) => slug;
1484
+ var globalParent = (slug) => `global:${slug}`;
1485
+ function snapshotOf(fields, doc) {
1486
+ const hidden = new Set(fields.filter((f) => f.hidden).map((f) => f.name));
1487
+ const snapshot = {};
1488
+ for (const [key2, value] of Object.entries(doc)) {
1489
+ if (key2 === "id" || key2 === "createdAt" || key2 === "updatedAt" || hidden.has(key2)) continue;
1490
+ snapshot[key2] = value;
1491
+ }
1492
+ return snapshot;
1493
+ }
1494
+ var toSummary = (row) => ({
1495
+ id: row.id,
1496
+ status: row.status ?? null,
1497
+ latest: row.latest === true,
1498
+ author: row.author ?? null,
1499
+ createdAt: String(row.createdAt)
1500
+ });
1501
+ var VersionStore = class {
1502
+ constructor(db) {
1503
+ this.db = db;
1504
+ }
1505
+ db;
1506
+ /** Saves a new latest version and deletes the oldest beyond `max`. */
1507
+ async save(args) {
1508
+ const { parent, doc } = args;
1509
+ const previous = await this.db.find({
1510
+ collection: VERSIONS,
1511
+ where: { and: [...this.of(parent, doc), { latest: { equals: true } }] },
1512
+ sort: [],
1513
+ limit: 0,
1514
+ page: 1
1515
+ });
1516
+ for (const row of previous.docs) {
1517
+ await this.db.update({ collection: VERSIONS, id: row.id, data: { ...row, latest: false } });
1518
+ }
1519
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1520
+ const created = await this.db.create({
1521
+ collection: VERSIONS,
1522
+ data: {
1523
+ parent,
1524
+ doc,
1525
+ status: args.status,
1526
+ latest: true,
1527
+ author: args.author,
1528
+ snapshot: args.snapshot,
1529
+ createdAt: now,
1530
+ updatedAt: now
1531
+ }
1532
+ });
1533
+ await this.prune(parent, doc, args.max);
1534
+ return toSummary(created);
1535
+ }
1536
+ /** The latest version of each document, keyed by document id. */
1537
+ async latest(parent, docs) {
1538
+ const result = /* @__PURE__ */ new Map();
1539
+ if (docs.length === 0) return result;
1540
+ const rows = await this.db.find({
1541
+ collection: VERSIONS,
1542
+ where: {
1543
+ and: [
1544
+ { parent: { equals: parent } },
1545
+ { doc: { in: [...docs] } },
1546
+ { latest: { equals: true } }
1547
+ ]
1548
+ },
1549
+ sort: [],
1550
+ limit: 0,
1551
+ page: 1
1552
+ });
1553
+ for (const row of rows.docs) result.set(String(row.doc), this.toVersion(row));
1554
+ return result;
1555
+ }
1556
+ async list(parent, doc, limit, page) {
1557
+ const found = await this.db.find({
1558
+ collection: VERSIONS,
1559
+ where: { and: this.of(parent, doc) },
1560
+ sort: ["-id"],
1561
+ limit,
1562
+ page
1563
+ });
1564
+ return { ...found, docs: found.docs.map(toSummary) };
1565
+ }
1566
+ async get(parent, doc, id) {
1567
+ const found = await this.db.find({
1568
+ collection: VERSIONS,
1569
+ where: { and: [...this.of(parent, doc), { id: { equals: id } }] },
1570
+ sort: [],
1571
+ limit: 1,
1572
+ page: 1
1573
+ });
1574
+ const row = found.docs[0];
1575
+ return row ? this.toVersion(row) : null;
1576
+ }
1577
+ async deleteAll(parent, doc) {
1578
+ const rows = await this.db.find({
1579
+ collection: VERSIONS,
1580
+ where: { and: this.of(parent, doc) },
1581
+ sort: [],
1582
+ limit: 0,
1583
+ page: 1
1584
+ });
1585
+ for (const row of rows.docs) await this.db.delete({ collection: VERSIONS, id: row.id });
1586
+ }
1587
+ async prune(parent, doc, max) {
1588
+ const rows = await this.db.find({
1589
+ collection: VERSIONS,
1590
+ where: { and: this.of(parent, doc) },
1591
+ sort: ["-id"],
1592
+ limit: 0,
1593
+ page: 1
1594
+ });
1595
+ for (const row of rows.docs.slice(max)) {
1596
+ await this.db.delete({ collection: VERSIONS, id: row.id });
1597
+ }
1598
+ }
1599
+ of(parent, doc) {
1600
+ return [{ parent: { equals: parent } }, { doc: { equals: doc } }];
1601
+ }
1602
+ toVersion(row) {
1603
+ return { ...toSummary(row), data: row.snapshot ?? {} };
1604
+ }
1605
+ };
1606
+
1432
1607
  // src/local-api.ts
1433
1608
  async function createEasyCMS(config, options = {}) {
1434
1609
  const resolved2 = await resolveConfig(config);
@@ -1453,11 +1628,13 @@ var EasyCMS = class {
1453
1628
  auth;
1454
1629
  /** Where uploaded files are stored. */
1455
1630
  storage;
1631
+ versions;
1456
1632
  constructor(config, db, logger = consoleLogger, storage = localStorage({ dir: config.upload.dir })) {
1457
1633
  this.config = config;
1458
1634
  this.db = db;
1459
1635
  this.logger = logger;
1460
1636
  this.storage = storage;
1637
+ this.versions = new VersionStore(db);
1461
1638
  this.auth = new Auth(this);
1462
1639
  }
1463
1640
  async find(collection2, options = {}) {
@@ -1475,7 +1652,8 @@ var EasyCMS = class {
1475
1652
  );
1476
1653
  const sort = options.sort === void 0 ? ["-createdAt"] : [options.sort].flat();
1477
1654
  const result = await this.db.find({ collection: collection2, where, sort, limit, page });
1478
- const docs = await this.output(config, result.docs, guard, options);
1655
+ const found = options.draft ? await this.withDrafts(config, result.docs) : result.docs;
1656
+ const docs = await this.output(config, found, guard, options);
1479
1657
  return { ...result, docs };
1480
1658
  }
1481
1659
  async findById(collection2, id, options = {}) {
@@ -1492,7 +1670,8 @@ var EasyCMS = class {
1492
1670
  page: 1
1493
1671
  })).docs[0] : await this.db.findById({ collection: collection2, id: parsed });
1494
1672
  if (!doc) return null;
1495
- const [out] = await this.output(config, [doc], guard, options);
1673
+ const [current] = options.draft ? await this.withDrafts(config, [doc]) : [doc];
1674
+ const [out] = await this.output(config, [current], guard, options);
1496
1675
  return out ?? null;
1497
1676
  }
1498
1677
  async count(collection2, options = {}) {
@@ -1577,27 +1756,198 @@ var EasyCMS = class {
1577
1756
  return `${this.config.serverURL?.replace(/\/+$/, "") ?? ""}${this.config.routes.api}/media/file/${encodeURIComponent(key2)}`;
1578
1757
  }
1579
1758
  async update(collection2, id, data, options = {}) {
1759
+ return await this.updateDocument(collection2, id, asObject(data, collection2), options);
1760
+ }
1761
+ /**
1762
+ * Takes a document off the site: its status becomes `draft`. With versions and drafts, a plain
1763
+ * `update` with `status: 'draft'` only saves a draft and leaves the published document live;
1764
+ * this is how to unpublish it.
1765
+ */
1766
+ async unpublish(collection2, id, options = {}) {
1767
+ const config = this.collection(collection2);
1768
+ if (!config.drafts) throw new QueryError(`"${collection2}" has no drafts`);
1769
+ return await this.updateDocument(
1770
+ collection2,
1771
+ id,
1772
+ { status: "draft" },
1773
+ options,
1774
+ "unpublish"
1775
+ );
1776
+ }
1777
+ /** Throws away the unpublished draft of a published document, going back to what is live. */
1778
+ async discardDraft(collection2, id, options = {}) {
1779
+ const config = this.collection(collection2);
1780
+ const guard = guardOf(options);
1781
+ const parsed = parseId(id);
1782
+ const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
1783
+ if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
1784
+ await this.checkDocumentAccess(config, "update", guard, parsed, void 0);
1785
+ const [current] = await this.withDrafts(config, [existing]);
1786
+ if (current !== existing) {
1787
+ await this.saveVersion(config, collectionParent(collection2), parsed, existing, guard);
1788
+ }
1789
+ const [out] = await this.output(config, [existing], guard, { ...options, draft: true });
1790
+ return out;
1791
+ }
1792
+ /** Saved versions of a document, newest first. Needs update access to the document. */
1793
+ async findVersions(collection2, id, options = {}) {
1794
+ const { config, parsed } = await this.versionTarget(collection2, id, options);
1795
+ return this.versions.list(
1796
+ collectionParent(config.slug),
1797
+ parsed,
1798
+ options.limit ?? 20,
1799
+ options.page ?? 1
1800
+ );
1801
+ }
1802
+ /** One saved version with the document as it was then. */
1803
+ async findVersion(collection2, id, versionId, options = {}) {
1804
+ const { config, parsed, existing } = await this.versionTarget(collection2, id, options);
1805
+ const version = await this.versions.get(collectionParent(config.slug), parsed, versionId);
1806
+ if (!version) return null;
1807
+ const doc = {
1808
+ ...version.data,
1809
+ id: parsed,
1810
+ createdAt: existing.createdAt,
1811
+ updatedAt: version.createdAt
1812
+ };
1813
+ const [out] = await this.output(config, [doc], guardOf(options), {
1814
+ ...options,
1815
+ draft: true
1816
+ });
1817
+ return { ...version, data: out };
1818
+ }
1819
+ /**
1820
+ * Makes an old version the current content, as a new version. With drafts it is restored as
1821
+ * a draft (publish it to put it live); access, hooks and validation run as for `update`.
1822
+ */
1823
+ async restoreVersion(collection2, id, versionId, options = {}) {
1824
+ const { config, parsed } = await this.versionTarget(collection2, id, options);
1825
+ const version = await this.versions.get(collectionParent(config.slug), parsed, versionId);
1826
+ if (!version) throw new NotFoundError(`${collection2} version`, versionId);
1827
+ const { status: _status, ...data } = version.data;
1828
+ return await this.updateDocument(
1829
+ collection2,
1830
+ parsed,
1831
+ config.drafts ? { ...data, status: "draft" } : data,
1832
+ options
1833
+ );
1834
+ }
1835
+ /**
1836
+ * Live preview: the document as it would be read if `data` were saved on top of its current
1837
+ * state (or as a new document when `id` is `null`), with relationships populated and
1838
+ * `afterRead` hooks applied. Nothing is written. Also returns the page URL from `preview`.
1839
+ */
1840
+ async preview(collection2, id, data, options = {}) {
1580
1841
  const config = this.collection(collection2);
1581
1842
  const guard = guardOf(options);
1582
1843
  const raw = asObject(data, collection2);
1844
+ let base;
1845
+ let parsed;
1846
+ if (id === null) {
1847
+ if (guard.enforce) {
1848
+ const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data: raw });
1849
+ if (allowed2 !== true) throw deny(guard.user);
1850
+ }
1851
+ } else {
1852
+ parsed = parseId(id);
1853
+ const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
1854
+ if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
1855
+ await this.checkDocumentAccess(config, "update", guard, parsed, raw);
1856
+ [base] = await this.withDrafts(config, [existing]);
1857
+ }
1858
+ const { input } = splitPassword(config, raw);
1859
+ const filtered = await filterInput(
1860
+ config.fields,
1861
+ input,
1862
+ this.fieldChecker("update", guard, parsed, input)
1863
+ );
1864
+ const merged = base ? mergeForUpdate(config.fields, base, filtered) : applyDefaults(config.fields, filtered);
1865
+ 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
+ });
1870
+ const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
1871
+ return { doc: out, url: this.previewURL(config, out) };
1872
+ }
1873
+ /** Live preview of a global. See `preview`. */
1874
+ async previewGlobal(slug, data, options = {}) {
1875
+ const config = this.global(slug);
1876
+ const guard = guardOf(options);
1877
+ await this.checkGlobalAccess(config, "update", guard);
1878
+ const raw = asObject(data, slug);
1879
+ const input = await filterInput(
1880
+ config.fields,
1881
+ raw,
1882
+ this.fieldChecker("update", guard, void 0, raw)
1883
+ );
1884
+ const saved = await this.db.findGlobal({ slug }) ?? {};
1885
+ const current = await this.globalDraft(config, saved);
1886
+ const merged = generateSlugs(
1887
+ config.fields,
1888
+ applyDefaults(config.fields, mergeForUpdate(config.fields, current, input))
1889
+ );
1890
+ if (config.drafts) merged.status = input.status ?? current.status ?? "draft";
1891
+ const doc = await this.previewDoc(config, merged, { id: 0 });
1892
+ const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
1893
+ const { id: _id, ...global2 } = out;
1894
+ return { doc: global2, url: this.previewURL(config, global2) };
1895
+ }
1896
+ /** Coerces preview input like a save would, keeping values that would fail validation. */
1897
+ async previewDoc(config, data, system) {
1898
+ const { data: clean } = await validateFields(config.fields, data, {
1899
+ operation: "update",
1900
+ root: data,
1901
+ skipRequired: true
1902
+ });
1903
+ return {
1904
+ ...data,
1905
+ ...clean,
1906
+ ...config.drafts ? { status: data.status } : {},
1907
+ ...system,
1908
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
1909
+ };
1910
+ }
1911
+ previewURL(config, doc) {
1912
+ if (!config.preview) return null;
1913
+ try {
1914
+ return config.preview({ doc, locale: this.config.admin.locale }) ?? null;
1915
+ } catch (error) {
1916
+ this.logger.error(`preview URL of "${config.slug}" failed: ${error.message}`);
1917
+ return null;
1918
+ }
1919
+ }
1920
+ async versionTarget(collection2, id, options) {
1921
+ const config = this.collection(collection2);
1922
+ if (!versionLimit(config)) throw new QueryError(`"${collection2}" has no versions`);
1923
+ const parsed = parseId(id);
1924
+ const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
1925
+ if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
1926
+ await this.checkDocumentAccess(config, "update", guardOf(options), parsed, void 0);
1927
+ return { config, parsed, existing };
1928
+ }
1929
+ async updateDocument(collection2, id, raw, options, mode = "save") {
1930
+ const config = this.collection(collection2);
1931
+ const guard = guardOf(options);
1583
1932
  const parsed = parseId(id);
1584
1933
  const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
1585
1934
  if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
1586
1935
  await this.checkDocumentAccess(config, "update", guard, parsed, raw);
1936
+ const [current] = await this.withDrafts(config, [existing]);
1587
1937
  const { input, password } = splitPassword(config, raw);
1588
1938
  const filtered = await filterInput(
1589
1939
  config.fields,
1590
1940
  input,
1591
1941
  this.fieldChecker("update", guard, parsed, input)
1592
1942
  );
1593
- let merged = mergeForUpdate(config.fields, existing, filtered);
1943
+ let merged = mergeForUpdate(config.fields, current, filtered);
1594
1944
  if (config.drafts)
1595
- merged.status = Object.hasOwn(filtered, "status") ? filtered.status : existing.status;
1945
+ merged.status = Object.hasOwn(filtered, "status") ? filtered.status : current.status;
1596
1946
  const base = this.hookArgs(config, guard);
1597
1947
  merged = await this.transform(
1598
1948
  config.hooks?.beforeValidate,
1599
1949
  "data",
1600
- { ...base, operation: "update", originalDoc: existing },
1950
+ { ...base, operation: "update", originalDoc: current },
1601
1951
  merged
1602
1952
  );
1603
1953
  let prepared = await this.prepare(
@@ -1609,21 +1959,43 @@ var EasyCMS = class {
1609
1959
  prepared = await this.transform(
1610
1960
  config.hooks?.beforeChange,
1611
1961
  "data",
1612
- { ...base, operation: "update", originalDoc: existing },
1962
+ { ...base, operation: "update", originalDoc: current },
1613
1963
  prepared
1614
1964
  );
1965
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1966
+ if (mode === "save" && this.separateDrafts(config) && existing.status === "published" && prepared.status === "draft") {
1967
+ if (password !== void 0) {
1968
+ throw new ValidationError(collection2, [
1969
+ { field: "password", message: "cannot be changed in a draft; publish instead" }
1970
+ ]);
1971
+ }
1972
+ const draft = { ...prepared, id: parsed, createdAt: existing.createdAt, updatedAt: now };
1973
+ await this.saveVersion(config, collectionParent(collection2), parsed, draft, guard);
1974
+ await this.notify(config.hooks?.afterChange, "afterChange", config.slug, {
1975
+ ...base,
1976
+ doc: draft,
1977
+ previousDoc: current,
1978
+ operation: "update"
1979
+ });
1980
+ const [out2] = await this.output(config, [draft], guard, {
1981
+ ...options,
1982
+ draft: true
1983
+ });
1984
+ return out2;
1985
+ }
1615
1986
  if (config.slug === USERS) await this.guardLastAdmin(parsed, existing, prepared);
1616
1987
  if (password !== void 0) prepared.passwordHash = await hashPassword(password);
1617
1988
  const doc = await this.db.update({
1618
1989
  collection: collection2,
1619
1990
  id: parsed,
1620
- data: { ...prepared, createdAt: existing.createdAt, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
1991
+ data: { ...prepared, createdAt: existing.createdAt, updatedAt: now }
1621
1992
  });
1993
+ await this.saveVersion(config, collectionParent(collection2), parsed, doc, guard);
1622
1994
  if (password !== void 0) await this.auth.revokeSessions(parsed);
1623
1995
  await this.notify(config.hooks?.afterChange, "afterChange", config.slug, {
1624
1996
  ...base,
1625
1997
  doc,
1626
- previousDoc: existing,
1998
+ previousDoc: current,
1627
1999
  operation: "update"
1628
2000
  });
1629
2001
  const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
@@ -1642,6 +2014,7 @@ var EasyCMS = class {
1642
2014
  for (const hook of config.hooks?.beforeDelete ?? []) await hook({ ...base, id: parsed });
1643
2015
  if (config.slug === USERS) await this.auth.revokeSessions(parsed);
1644
2016
  await this.db.delete({ collection: collection2, id: parsed });
2017
+ if (versionLimit(config)) await this.versions.deleteAll(collectionParent(collection2), parsed);
1645
2018
  await this.notify(config.hooks?.afterDelete, "afterDelete", config.slug, {
1646
2019
  ...base,
1647
2020
  id: parsed,
@@ -1654,7 +2027,8 @@ var EasyCMS = class {
1654
2027
  const config = this.global(slug);
1655
2028
  const guard = guardOf(options);
1656
2029
  await this.checkGlobalAccess(config, "read", guard);
1657
- const stored = await this.db.findGlobal({ slug });
2030
+ const saved = await this.db.findGlobal({ slug });
2031
+ const stored = saved && options.draft ? await this.globalDraft(config, saved) : saved;
1658
2032
  const data = fillMissing(config.fields, applyDefaults(config.fields, stored ?? {}));
1659
2033
  if (!stored) {
1660
2034
  data.updatedAt = null;
@@ -1665,37 +2039,108 @@ var EasyCMS = class {
1665
2039
  return doc;
1666
2040
  }
1667
2041
  async updateGlobal(slug, data, options = {}) {
2042
+ return this.saveGlobal(slug, asObject(data, slug), options);
2043
+ }
2044
+ /** Takes a global off the site (`status: 'draft'`). See `unpublish`. */
2045
+ async unpublishGlobal(slug, options = {}) {
2046
+ if (!this.global(slug).drafts) throw new QueryError(`"${slug}" has no drafts`);
2047
+ return this.saveGlobal(slug, { status: "draft" }, options, "unpublish");
2048
+ }
2049
+ /** Throws away the unpublished draft of a published global. */
2050
+ async discardGlobalDraft(slug, options = {}) {
2051
+ const config = this.global(slug);
2052
+ const guard = guardOf(options);
2053
+ await this.checkGlobalAccess(config, "update", guard);
2054
+ const saved = await this.db.findGlobal({ slug });
2055
+ if (saved && await this.globalDraft(config, saved) !== saved) {
2056
+ await this.saveVersion(config, globalParent(slug), 0, saved, guard);
2057
+ }
2058
+ return this.findGlobal(slug, { ...options, draft: true });
2059
+ }
2060
+ /** Saved versions of a global, newest first. Needs update access. */
2061
+ async findGlobalVersions(slug, options = {}) {
2062
+ await this.globalVersionTarget(slug, options);
2063
+ return this.versions.list(globalParent(slug), 0, options.limit ?? 20, options.page ?? 1);
2064
+ }
2065
+ async findGlobalVersion(slug, versionId, options = {}) {
2066
+ const config = await this.globalVersionTarget(slug, options);
2067
+ const version = await this.versions.get(globalParent(slug), 0, versionId);
2068
+ if (!version) return null;
2069
+ const data = fillMissing(config.fields, applyDefaults(config.fields, version.data));
2070
+ const [out] = await this.output(config, [{ ...data, id: 0 }], guardOf(options), {
2071
+ ...options,
2072
+ draft: true
2073
+ });
2074
+ const { id: _id, ...doc } = out;
2075
+ return { ...version, data: { ...doc, updatedAt: version.createdAt } };
2076
+ }
2077
+ /** Makes an old version of a global current (as a draft when the global has drafts). */
2078
+ async restoreGlobalVersion(slug, versionId, options = {}) {
2079
+ const config = await this.globalVersionTarget(slug, options);
2080
+ const version = await this.versions.get(globalParent(slug), 0, versionId);
2081
+ if (!version) throw new NotFoundError(`${slug} version`, versionId);
2082
+ const { status: _status, ...data } = version.data;
2083
+ return this.saveGlobal(
2084
+ slug,
2085
+ config.drafts ? { ...data, status: "draft" } : data,
2086
+ options
2087
+ );
2088
+ }
2089
+ async globalVersionTarget(slug, options) {
2090
+ const config = this.global(slug);
2091
+ if (!versionLimit(config)) throw new QueryError(`"${slug}" has no versions`);
2092
+ await this.checkGlobalAccess(config, "update", guardOf(options));
2093
+ return config;
2094
+ }
2095
+ /** The global's pending draft when it has one (see `withDrafts`), otherwise `saved`. */
2096
+ async globalDraft(config, saved) {
2097
+ if (!this.separateDrafts(config) || saved.status !== "published") return saved;
2098
+ const version = (await this.versions.latest(globalParent(config.slug), [0])).get("0");
2099
+ if (version?.status !== "draft") return saved;
2100
+ return { ...version.data, updatedAt: version.createdAt, status: "draft" };
2101
+ }
2102
+ async saveGlobal(slug, raw, options, mode = "save") {
1668
2103
  const config = this.global(slug);
1669
2104
  const guard = guardOf(options);
1670
2105
  await this.checkGlobalAccess(config, "update", guard);
1671
- const raw = asObject(data, slug);
1672
2106
  const input = await filterInput(
1673
2107
  config.fields,
1674
2108
  raw,
1675
2109
  this.fieldChecker("update", guard, void 0, raw)
1676
2110
  );
1677
2111
  const existing = await this.db.findGlobal({ slug }) ?? {};
2112
+ const current = await this.globalDraft(config, existing);
1678
2113
  const merged = generateSlugs(
1679
2114
  config.fields,
1680
- applyDefaults(config.fields, mergeForUpdate(config.fields, existing, input))
2115
+ applyDefaults(config.fields, mergeForUpdate(config.fields, current, input))
1681
2116
  );
1682
- if (config.drafts) merged.status = input.status ?? existing.status ?? "draft";
2117
+ if (config.drafts) merged.status = input.status ?? current.status ?? "draft";
1683
2118
  const base = this.hookArgs(config, guard);
1684
2119
  let prepared = await this.prepare(config, merged, "update", void 0);
1685
2120
  prepared = await this.transform(
1686
2121
  config.hooks?.beforeChange,
1687
2122
  "data",
1688
- { ...base, operation: "update", originalDoc: existing },
2123
+ { ...base, operation: "update", originalDoc: current },
1689
2124
  prepared
1690
2125
  );
1691
- const doc = await this.db.updateGlobal({
1692
- slug,
1693
- data: { ...prepared, updatedAt: (/* @__PURE__ */ new Date()).toISOString() }
1694
- });
2126
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2127
+ if (mode === "save" && this.separateDrafts(config) && existing.status === "published" && prepared.status === "draft") {
2128
+ const draft = { ...prepared, updatedAt: now };
2129
+ await this.saveVersion(config, globalParent(slug), 0, draft, guard);
2130
+ await this.notify(config.hooks?.afterChange, "afterChange", slug, {
2131
+ ...base,
2132
+ doc: draft,
2133
+ previousDoc: current,
2134
+ operation: "update"
2135
+ });
2136
+ return this.findGlobal(slug, { ...options, draft: true });
2137
+ }
2138
+ const doc = await this.db.updateGlobal({ slug, data: { ...prepared, updatedAt: now } });
2139
+ await this.saveVersion(config, globalParent(slug), 0, doc, guard);
1695
2140
  await this.notify(config.hooks?.afterChange, "afterChange", slug, {
1696
2141
  ...base,
1697
2142
  doc,
1698
- previousDoc: existing,
2143
+ previousDoc: current,
1699
2144
  operation: "update"
1700
2145
  });
1701
2146
  return this.findGlobal(slug, { ...options, draft: true });
@@ -1798,7 +2243,8 @@ var EasyCMS = class {
1798
2243
  limit: 0,
1799
2244
  page: 1
1800
2245
  });
1801
- return Promise.all(found.docs.map((d) => finish(target, d)));
2246
+ const docs2 = options.draft ? await this.withDrafts(target, found.docs) : found.docs;
2247
+ return Promise.all(docs2.map((d) => finish(target, d)));
1802
2248
  };
1803
2249
  const depth = Math.max(0, Math.min(MAX_DEPTH, Math.trunc(options.depth ?? DEFAULT_DEPTH)));
1804
2250
  const populated = await populate(load, this.config.collections, config.fields, docs, depth);
@@ -1849,6 +2295,7 @@ var EasyCMS = class {
1849
2295
  collection: collection2,
1850
2296
  data: { ...prepared, createdAt: now, updatedAt: now }
1851
2297
  });
2298
+ await this.saveVersion(config, collectionParent(collection2), doc.id, doc, guard);
1852
2299
  await this.notify(config.hooks?.afterChange, "afterChange", collection2, {
1853
2300
  ...base,
1854
2301
  doc,
@@ -1857,6 +2304,33 @@ var EasyCMS = class {
1857
2304
  const [out] = await this.output(config, [doc], hookGuard ?? guard, { ...options, draft: true });
1858
2305
  return out;
1859
2306
  }
2307
+ /** Drafts are kept as versions (instead of unpublishing) when both are enabled. */
2308
+ separateDrafts(config) {
2309
+ return config.drafts === true && versionLimit(config) !== void 0;
2310
+ }
2311
+ /** Replaces published documents that have a newer draft with that draft. */
2312
+ async withDrafts(config, docs) {
2313
+ if (!this.separateDrafts(config)) return docs;
2314
+ const published = docs.filter((d) => d.status === "published").map((d) => d.id);
2315
+ const latest = await this.versions.latest(collectionParent(config.slug), published);
2316
+ return docs.map((doc) => {
2317
+ const version = latest.get(String(doc.id));
2318
+ return version && doc.status === "published" && version.status === "draft" ? draftOf(doc, version) : doc;
2319
+ });
2320
+ }
2321
+ /** Records a version after a save, when the collection or global keeps versions. */
2322
+ async saveVersion(config, parent, id, doc, guard) {
2323
+ const max = versionLimit(config);
2324
+ if (max === void 0) return;
2325
+ await this.versions.save({
2326
+ parent,
2327
+ doc: id,
2328
+ status: config.drafts ? doc.status ?? "draft" : null,
2329
+ snapshot: snapshotOf(config.fields, doc),
2330
+ author: guard.user?.id ?? null,
2331
+ max
2332
+ });
2333
+ }
1860
2334
  hookArgs(config, guard) {
1861
2335
  return { user: guard.user, cms: this, slug: config.slug };
1862
2336
  }
@@ -2014,6 +2488,15 @@ var EasyCMS = class {
2014
2488
  return errors;
2015
2489
  }
2016
2490
  };
2491
+ function draftOf(doc, version) {
2492
+ return {
2493
+ ...version.data,
2494
+ id: doc.id,
2495
+ createdAt: doc.createdAt,
2496
+ updatedAt: version.createdAt,
2497
+ status: "draft"
2498
+ };
2499
+ }
2017
2500
  function draftWhere(config, draft, where) {
2018
2501
  if (!config.drafts || draft) return where;
2019
2502
  return andWhere(where, { status: { equals: "published" } });
@@ -2107,6 +2590,8 @@ async function collection(config, user) {
2107
2590
  const result = {
2108
2591
  slug: config.slug,
2109
2592
  drafts: config.drafts === true,
2593
+ versions: Boolean(config.versions),
2594
+ preview: typeof config.preview === "function",
2110
2595
  fields: await serializeFields(config.fields, new FieldAccessChecker("update", { user })),
2111
2596
  permissions: {
2112
2597
  read: await allowed(config.access?.read, user),
@@ -2123,6 +2608,8 @@ async function global(config, user) {
2123
2608
  const result = {
2124
2609
  slug: config.slug,
2125
2610
  drafts: config.drafts === true,
2611
+ versions: Boolean(config.versions),
2612
+ preview: typeof config.preview === "function",
2126
2613
  fields: await serializeFields(config.fields, new FieldAccessChecker("update", { user })),
2127
2614
  permissions: {
2128
2615
  read: await allowed(config.access?.read, user),
@@ -2369,9 +2856,10 @@ async function route(cms, ctx, method, segments, options) {
2369
2856
  throw new HttpError("Not found", 404);
2370
2857
  }
2371
2858
  if (first === "globals") {
2372
- if (!second || third !== void 0) throw new HttpError("Not found", 404);
2859
+ if (!second) throw new HttpError("Not found", 404);
2373
2860
  if (!cms.config.globals.some((g) => g.slug === second))
2374
2861
  throw new HttpError(`Unknown global "${second}"`, 404);
2862
+ if (third !== void 0) return globalAction(cms, ctx, method, second, segments.slice(2));
2375
2863
  if (method === "GET")
2376
2864
  return { body: await cms.findGlobal(second, { ...access, ...parseDepth(ctx.url), draft }) };
2377
2865
  if (method === "POST") {
@@ -2395,7 +2883,16 @@ async function route(cms, ctx, method, segments, options) {
2395
2883
  if (INTERNAL_COLLECTIONS.has(collection2) || !cms.config.collections.some((c) => c.slug === collection2)) {
2396
2884
  throw new HttpError(`Unknown collection "${collection2}"`, 404);
2397
2885
  }
2398
- if (third !== void 0) throw new HttpError("Not found", 404);
2886
+ if (second !== void 0 && third !== void 0) {
2887
+ return documentAction(cms, ctx, method, collection2, second, segments.slice(2));
2888
+ }
2889
+ if (second === "preview") {
2890
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
2891
+ const body = await readJson(ctx.request);
2892
+ return {
2893
+ body: await cms.preview(collection2, null, body, { ...access, ...parseDepth(ctx.url) })
2894
+ };
2895
+ }
2399
2896
  if (second === void 0) {
2400
2897
  if (method === "GET") {
2401
2898
  const query = parseListQuery(ctx.url);
@@ -2429,6 +2926,74 @@ async function route(cms, ctx, method, segments, options) {
2429
2926
  }
2430
2927
  throw methodNotAllowed(ctx, "GET, PATCH, DELETE");
2431
2928
  }
2929
+ async function documentAction(cms, ctx, method, collection2, id, path) {
2930
+ const access = { overrideAccess: false, user: ctx.user };
2931
+ const depth = parseDepth(ctx.url);
2932
+ const [action, versionId, extra] = path;
2933
+ if (action === "versions" && versionId === void 0) {
2934
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
2935
+ return { body: await cms.findVersions(collection2, id, { ...access, ...parsePage(ctx.url) }) };
2936
+ }
2937
+ if (action === "versions" && versionId !== void 0 && extra === void 0) {
2938
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
2939
+ const version = await cms.findVersion(collection2, id, versionId, { ...access, ...depth });
2940
+ if (!version) throw new NotFoundError(`${collection2} version`, versionId);
2941
+ return { body: version };
2942
+ }
2943
+ if (action === "versions" && versionId !== void 0 && extra === "restore" && path.length === 3) {
2944
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
2945
+ return { body: await cms.restoreVersion(collection2, id, versionId, { ...access, ...depth }) };
2946
+ }
2947
+ if (path.length === 1 && action === "preview") {
2948
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
2949
+ const body = await readJson(ctx.request);
2950
+ return { body: await cms.preview(collection2, id, body, { ...access, ...depth }) };
2951
+ }
2952
+ if (path.length === 1 && (action === "unpublish" || action === "discard-draft")) {
2953
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
2954
+ const options = { ...access, ...depth };
2955
+ return {
2956
+ body: action === "unpublish" ? await cms.unpublish(collection2, id, options) : await cms.discardDraft(collection2, id, options)
2957
+ };
2958
+ }
2959
+ throw new HttpError("Not found", 404);
2960
+ }
2961
+ async function globalAction(cms, ctx, method, slug, path) {
2962
+ const access = { overrideAccess: false, user: ctx.user };
2963
+ const depth = parseDepth(ctx.url);
2964
+ const [action, versionId, extra] = path;
2965
+ if (action === "versions" && versionId === void 0) {
2966
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
2967
+ return { body: await cms.findGlobalVersions(slug, { ...access, ...parsePage(ctx.url) }) };
2968
+ }
2969
+ if (action === "versions" && versionId !== void 0 && extra === void 0) {
2970
+ if (method !== "GET") throw methodNotAllowed(ctx, "GET");
2971
+ const version = await cms.findGlobalVersion(slug, versionId, { ...access, ...depth });
2972
+ if (!version) throw new NotFoundError(`${slug} version`, versionId);
2973
+ return { body: version };
2974
+ }
2975
+ if (action === "versions" && versionId !== void 0 && extra === "restore" && path.length === 3) {
2976
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
2977
+ return { body: await cms.restoreGlobalVersion(slug, versionId, { ...access, ...depth }) };
2978
+ }
2979
+ if (path.length === 1 && action === "preview") {
2980
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
2981
+ const body = await readJson(ctx.request);
2982
+ return { body: await cms.previewGlobal(slug, body, { ...access, ...depth }) };
2983
+ }
2984
+ if (path.length === 1 && (action === "unpublish" || action === "discard-draft")) {
2985
+ if (method !== "POST") throw methodNotAllowed(ctx, "POST");
2986
+ const options = { ...access, ...depth };
2987
+ return {
2988
+ body: action === "unpublish" ? await cms.unpublishGlobal(slug, options) : await cms.discardGlobalDraft(slug, options)
2989
+ };
2990
+ }
2991
+ throw new HttpError("Not found", 404);
2992
+ }
2993
+ function parsePage(url) {
2994
+ const { page, limit } = parseListQuery(url);
2995
+ return { page, limit };
2996
+ }
2432
2997
  function methodNotAllowed(ctx, allow) {
2433
2998
  ctx.headers.set("allow", allow);
2434
2999
  return new HttpError("Method not allowed", 405);
@@ -2734,6 +3299,7 @@ export {
2734
3299
  DEFAULT_API_PATH,
2735
3300
  DEFAULT_DEPTH,
2736
3301
  DEFAULT_MAX_FILE_SIZE,
3302
+ DEFAULT_MAX_VERSIONS,
2737
3303
  DEFAULT_TOKEN_EXPIRATION,
2738
3304
  EXTENSIONS,
2739
3305
  EasyCMS,
@@ -0,0 +1,33 @@
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
+ /** True when the page is shown inside a frame (e.g. the admin's preview). */
22
+ declare function isLivePreview(): boolean;
23
+ /**
24
+ * Calls `onChange` with the document each time the admin's form changes. Returns a function
25
+ * that stops listening. Does nothing outside a frame, so it is safe to call on every page.
26
+ *
27
+ * ```ts
28
+ * const stop = subscribeLivePreview<Post>((doc) => render(doc))
29
+ * ```
30
+ */
31
+ declare function subscribeLivePreview<T = Record<string, unknown>>(onChange: (doc: T, message: LivePreviewMessage<T>) => void, options?: LivePreviewOptions): () => void;
32
+
33
+ export { type LivePreviewMessage, type LivePreviewOptions, READY_MESSAGE, isLivePreview, subscribeLivePreview };
@@ -0,0 +1,24 @@
1
+ // src/live-preview.ts
2
+ var READY_MESSAGE = "easy-cms:preview-ready";
3
+ function isLivePreview() {
4
+ return typeof window !== "undefined" && window.parent !== window;
5
+ }
6
+ function subscribeLivePreview(onChange, options = {}) {
7
+ if (!isLivePreview()) return () => {
8
+ };
9
+ const allowed = [options.origin ?? window.location.origin].flat();
10
+ const listener = (event) => {
11
+ if (!allowed.includes(event.origin)) return;
12
+ const message = event.data;
13
+ if (message?.type !== "easy-cms:preview" || typeof message.doc !== "object") return;
14
+ onChange(message.doc, message);
15
+ };
16
+ window.addEventListener("message", listener);
17
+ window.parent.postMessage({ type: READY_MESSAGE }, "*");
18
+ return () => window.removeEventListener("message", listener);
19
+ }
20
+ export {
21
+ READY_MESSAGE,
22
+ isLivePreview,
23
+ subscribeLivePreview
24
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "0.2.0",
3
+ "version": "0.4.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": [