@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 +110 -1
- package/dist/index.js +589 -23
- package/dist/live-preview.d.ts +33 -0
- package/dist/live-preview.js +24 -0
- package/package.json +5 -1
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
|
|
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: [
|
|
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
|
|
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 [
|
|
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,
|
|
1943
|
+
let merged = mergeForUpdate(config.fields, current, filtered);
|
|
1594
1944
|
if (config.drafts)
|
|
1595
|
-
merged.status = Object.hasOwn(filtered, "status") ? filtered.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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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,
|
|
2115
|
+
applyDefaults(config.fields, mergeForUpdate(config.fields, current, input))
|
|
1681
2116
|
);
|
|
1682
|
-
if (config.drafts) merged.status = input.status ??
|
|
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:
|
|
2123
|
+
{ ...base, operation: "update", originalDoc: current },
|
|
1689
2124
|
prepared
|
|
1690
2125
|
);
|
|
1691
|
-
const
|
|
1692
|
-
|
|
1693
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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.
|
|
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": [
|