@easy-cms/core 0.3.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,14 @@ 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;
335
343
  interface VersionsConfig {
336
344
  /** Versions kept per document; older ones are deleted. Default 50. */
337
345
  readonly max?: number;
@@ -354,6 +362,8 @@ interface CollectionConfig {
354
362
  * it is published again.
355
363
  */
356
364
  readonly versions?: boolean | VersionsConfig;
365
+ /** Live preview: the page that shows a document, e.g. `({ doc }) => \`/posts/${doc.slug}\``. */
366
+ readonly preview?: PreviewURL;
357
367
  readonly access?: CollectionAccess;
358
368
  readonly hooks?: CollectionHooks;
359
369
  }
@@ -364,6 +374,8 @@ interface GlobalConfig {
364
374
  readonly drafts?: boolean;
365
375
  /** Keep a snapshot of every save. See `CollectionConfig.versions`. */
366
376
  readonly versions?: boolean | VersionsConfig;
377
+ /** Live preview: the page that shows the global. See `CollectionConfig.preview`. */
378
+ readonly preview?: PreviewURL;
367
379
  readonly access?: GlobalAccess;
368
380
  readonly hooks?: GlobalHooks;
369
381
  }
@@ -771,6 +783,17 @@ declare class EasyCMS<C extends Config = Config> {
771
783
  * a draft (publish it to put it live); access, hooks and validation run as for `update`.
772
784
  */
773
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;
774
797
  private versionTarget;
775
798
  private updateDocument;
776
799
  delete<S extends Slug<C>>(collection: S, id: ID, options?: AccessOptions): Promise<Doc<C, S>>;
@@ -839,6 +862,12 @@ declare class EasyCMS<C extends Config = Config> {
839
862
  private isTaken;
840
863
  private checkReferences;
841
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
+ }
842
871
 
843
872
  interface LoginArgs {
844
873
  readonly email: string;
@@ -1076,6 +1105,8 @@ interface AdminCollection {
1076
1105
  useAsTitle?: string;
1077
1106
  drafts: boolean;
1078
1107
  versions: boolean;
1108
+ /** Has live preview (`preview` in the config). */
1109
+ preview: boolean;
1079
1110
  fields: AdminField[];
1080
1111
  permissions: {
1081
1112
  read: boolean;
@@ -1089,6 +1120,8 @@ interface AdminGlobal {
1089
1120
  label?: Label;
1090
1121
  drafts: boolean;
1091
1122
  versions: boolean;
1123
+ /** Has live preview (`preview` in the config). */
1124
+ preview: boolean;
1092
1125
  fields: AdminField[];
1093
1126
  permissions: {
1094
1127
  read: boolean;
@@ -1129,4 +1162,4 @@ declare const BUILTIN_COLLECTIONS: readonly ["users", "media"];
1129
1162
  /** Checks a config and returns every problem found. An empty array means it is valid. */
1130
1163
  declare function validateConfig(config: Config): ConfigIssue[];
1131
1164
 
1132
- export { type Access, type AccessArgs, type AccessOptions, type AdminCollection, type AdminConfig, type AdminField, type AdminGlobal, type AdminLocale, type AdminSchema, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ArrayField, Auth, type AuthConfig, type AuthUser, BUILTIN_COLLECTIONS, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type BooleanField, type BuiltinMediaCollection, type BuiltinUsersCollection, type BundleHints, CONFIG_FILE_NAMES, CSRF_COOKIE, CSRF_HEADER, type CollectionAccess, type CollectionConfig, type CollectionDocument, type CollectionHooks, type CollectionSlug, type Config, ConfigError, type ConfigIssue, type CreateEasyCMSOptions, type CreateInput, type CreateMigrationResult, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_VERSIONS, DEFAULT_TOKEN_EXPIRATION, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DateField, type DepthOptions, EXTENSIONS, EasyCMS, EasyCMSError, type EmailField, FIELD_TYPES, type Field, type FieldAccess, type FieldAccessFn, type FieldError, type FieldType, type FieldValidate, type FieldValidateContext, type FieldValue, type FieldsInput, type FieldsValue, type FindArgs, type FindOptions, ForbiddenError, type GlobalAccess, type GlobalConfig, type GlobalDocument, type GlobalHooks, type GlobalInput, type GlobalSlug, type GroupField, type ID, INTERNAL_COLLECTIONS, type ImageSize, type InferCollection, type InferGlobal, type JsonField, type Label, type LoadConfigOptions, type LocalStorageOptions, type Logger, type LoginArgs, MAX_DEPTH, MEDIA, MIN_PASSWORD_LENGTH, MIN_SECRET_LENGTH, type MediaDocument, type MediaSize, type MigrationInfo, NotFoundError, type NumberField, type Operation, type PaginatedDocs, PayloadTooLargeError, type Plugin, QueryError, type RawDocument, type ReadOptions, type RelationshipField, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoutesConfig, SESSION_COOKIE, SchemaError, type SchemaMode, type SelectField, type SelectOption, type Session, type SlugField, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, UnauthorizedError, type UpdateInput, type UploadConfig, type UploadField, ValidationError, type Version, type VersionSummary, type VersionsConfig, type Where, type WhereOperators, anyone, applyDefaults, consoleLogger, createEasyCMS, createRestHandler, defineConfig, fillMissing, findConfigFile, generateSlugs, generateTypes, hashPassword, imageDimensions, importConfig, isAdmin, isLoggedIn, loadConfig, localStorage, mergeForUpdate, mimeAllowed, parseId, populate, readCookie, resolveConfig, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword };
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
@@ -1032,6 +1032,13 @@ function validateContainer(container, path, collectionSlugs, add) {
1032
1032
  add(`${path}.versions.max`, "must be a positive integer");
1033
1033
  }
1034
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
+ }
1035
1042
  const reserved = new Set(SYSTEM_FIELD_NAMES);
1036
1043
  if (container.drafts) reserved.add("status");
1037
1044
  validateFields2(container.fields, `${path}.fields`, reserved, collectionSlugs, add);
@@ -1825,6 +1832,91 @@ var EasyCMS = class {
1825
1832
  options
1826
1833
  );
1827
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 = {}) {
1841
+ const config = this.collection(collection2);
1842
+ const guard = guardOf(options);
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
+ }
1828
1920
  async versionTarget(collection2, id, options) {
1829
1921
  const config = this.collection(collection2);
1830
1922
  if (!versionLimit(config)) throw new QueryError(`"${collection2}" has no versions`);
@@ -2499,6 +2591,7 @@ async function collection(config, user) {
2499
2591
  slug: config.slug,
2500
2592
  drafts: config.drafts === true,
2501
2593
  versions: Boolean(config.versions),
2594
+ preview: typeof config.preview === "function",
2502
2595
  fields: await serializeFields(config.fields, new FieldAccessChecker("update", { user })),
2503
2596
  permissions: {
2504
2597
  read: await allowed(config.access?.read, user),
@@ -2516,6 +2609,7 @@ async function global(config, user) {
2516
2609
  slug: config.slug,
2517
2610
  drafts: config.drafts === true,
2518
2611
  versions: Boolean(config.versions),
2612
+ preview: typeof config.preview === "function",
2519
2613
  fields: await serializeFields(config.fields, new FieldAccessChecker("update", { user })),
2520
2614
  permissions: {
2521
2615
  read: await allowed(config.access?.read, user),
@@ -2792,6 +2886,13 @@ async function route(cms, ctx, method, segments, options) {
2792
2886
  if (second !== void 0 && third !== void 0) {
2793
2887
  return documentAction(cms, ctx, method, collection2, second, segments.slice(2));
2794
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
+ }
2795
2896
  if (second === void 0) {
2796
2897
  if (method === "GET") {
2797
2898
  const query = parseListQuery(ctx.url);
@@ -2843,6 +2944,11 @@ async function documentAction(cms, ctx, method, collection2, id, path) {
2843
2944
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
2844
2945
  return { body: await cms.restoreVersion(collection2, id, versionId, { ...access, ...depth }) };
2845
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
+ }
2846
2952
  if (path.length === 1 && (action === "unpublish" || action === "discard-draft")) {
2847
2953
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
2848
2954
  const options = { ...access, ...depth };
@@ -2870,6 +2976,11 @@ async function globalAction(cms, ctx, method, slug, path) {
2870
2976
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
2871
2977
  return { body: await cms.restoreGlobalVersion(slug, versionId, { ...access, ...depth }) };
2872
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
+ }
2873
2984
  if (path.length === 1 && (action === "unpublish" || action === "discard-draft")) {
2874
2985
  if (method !== "POST") throw methodNotAllowed(ctx, "POST");
2875
2986
  const options = { ...access, ...depth };
@@ -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.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": [