@easy-cms/core 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +112 -4
- package/dist/index.js +594 -54
- package/dist/live-preview.d.ts +40 -0
- package/dist/live-preview.js +31 -0
- package/package.json +5 -1
package/dist/index.d.ts
CHANGED
|
@@ -56,6 +56,14 @@ declare const anyone: Access;
|
|
|
56
56
|
declare const isLoggedIn: Access;
|
|
57
57
|
declare const isAdmin: Access;
|
|
58
58
|
|
|
59
|
+
/** What a preview token opens: one document of a collection, or one global. */
|
|
60
|
+
type PreviewTarget = {
|
|
61
|
+
readonly collection: string;
|
|
62
|
+
readonly id: string;
|
|
63
|
+
} | {
|
|
64
|
+
readonly global: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
59
67
|
interface Logger {
|
|
60
68
|
info(message: string): void;
|
|
61
69
|
warn(message: string): void;
|
|
@@ -193,6 +201,11 @@ interface BaseField<TType extends string, TValue> {
|
|
|
193
201
|
readonly access?: FieldAccess;
|
|
194
202
|
/** Stored but never returned by the API nor accepted as input (e.g. a password hash). */
|
|
195
203
|
readonly hidden?: boolean;
|
|
204
|
+
/**
|
|
205
|
+
* One value per locale (needs `localization` in the config). Not for groups, arrays or
|
|
206
|
+
* hasMany fields: localize the fields inside a group or array instead.
|
|
207
|
+
*/
|
|
208
|
+
readonly localized?: boolean;
|
|
196
209
|
}
|
|
197
210
|
interface TextField extends BaseField<'text', string> {
|
|
198
211
|
readonly minLength?: number;
|
|
@@ -332,6 +345,23 @@ interface GlobalHooks {
|
|
|
332
345
|
readonly afterChange?: readonly AfterChangeHook[];
|
|
333
346
|
readonly afterRead?: readonly AfterReadHook[];
|
|
334
347
|
}
|
|
348
|
+
interface LocalizationConfig {
|
|
349
|
+
/** Content locales, e.g. `['th', 'en']`. */
|
|
350
|
+
readonly locales: readonly string[];
|
|
351
|
+
/** The locale used when none is given; its values also fill empty ones. Default: the first. */
|
|
352
|
+
readonly defaultLocale?: string;
|
|
353
|
+
/** Reads return the default locale's value when a locale's value is empty. Default true. */
|
|
354
|
+
readonly fallback?: boolean;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Where a document is shown on the site, for live preview in the admin: an absolute URL or a
|
|
358
|
+
* path on the site's origin. Return `null` when the document has no page.
|
|
359
|
+
*/
|
|
360
|
+
type PreviewURL = (args: {
|
|
361
|
+
readonly doc: Record<string, unknown>;
|
|
362
|
+
/** The content locale being edited (with `localization`), otherwise the admin locale. */
|
|
363
|
+
readonly locale: string;
|
|
364
|
+
}) => string | null | undefined;
|
|
335
365
|
interface VersionsConfig {
|
|
336
366
|
/** Versions kept per document; older ones are deleted. Default 50. */
|
|
337
367
|
readonly max?: number;
|
|
@@ -354,6 +384,8 @@ interface CollectionConfig {
|
|
|
354
384
|
* it is published again.
|
|
355
385
|
*/
|
|
356
386
|
readonly versions?: boolean | VersionsConfig;
|
|
387
|
+
/** Live preview: the page that shows a document, e.g. `({ doc }) => \`/posts/${doc.slug}\``. */
|
|
388
|
+
readonly preview?: PreviewURL;
|
|
357
389
|
readonly access?: CollectionAccess;
|
|
358
390
|
readonly hooks?: CollectionHooks;
|
|
359
391
|
}
|
|
@@ -364,6 +396,8 @@ interface GlobalConfig {
|
|
|
364
396
|
readonly drafts?: boolean;
|
|
365
397
|
/** Keep a snapshot of every save. See `CollectionConfig.versions`. */
|
|
366
398
|
readonly versions?: boolean | VersionsConfig;
|
|
399
|
+
/** Live preview: the page that shows the global. See `CollectionConfig.preview`. */
|
|
400
|
+
readonly preview?: PreviewURL;
|
|
367
401
|
readonly access?: GlobalAccess;
|
|
368
402
|
readonly hooks?: GlobalHooks;
|
|
369
403
|
}
|
|
@@ -430,6 +464,8 @@ interface Config {
|
|
|
430
464
|
* `auth.trustedOrigins` are always allowed, with cookies. Default: none.
|
|
431
465
|
*/
|
|
432
466
|
readonly cors?: readonly string[] | '*';
|
|
467
|
+
/** Content in several languages: fields with `localized: true` hold one value per locale. */
|
|
468
|
+
readonly localization?: LocalizationConfig | null;
|
|
433
469
|
readonly routes?: RoutesConfig;
|
|
434
470
|
readonly admin?: AdminConfig;
|
|
435
471
|
readonly upload?: UploadConfig;
|
|
@@ -443,8 +479,10 @@ interface Config {
|
|
|
443
479
|
readonly plugins?: readonly Plugin[];
|
|
444
480
|
}
|
|
445
481
|
/** The config after plugins ran, validation passed and defaults were applied. */
|
|
446
|
-
interface ResolvedConfig extends Omit<Config, 'cors' | 'routes' | 'admin' | 'upload' | 'auth' | 'collections' | 'globals' | 'plugins'> {
|
|
482
|
+
interface ResolvedConfig extends Omit<Config, 'cors' | 'localization' | 'routes' | 'admin' | 'upload' | 'auth' | 'collections' | 'globals' | 'plugins'> {
|
|
447
483
|
readonly cors: readonly string[] | '*';
|
|
484
|
+
/** `null` without localization. */
|
|
485
|
+
readonly localization: Required<LocalizationConfig> | null;
|
|
448
486
|
readonly routes: Required<RoutesConfig>;
|
|
449
487
|
readonly admin: Required<AdminConfig>;
|
|
450
488
|
readonly upload: Required<Omit<UploadConfig, 'storage'>> & Pick<UploadConfig, 'storage'>;
|
|
@@ -696,6 +734,11 @@ interface AccessOptions {
|
|
|
696
734
|
interface DepthOptions extends AccessOptions {
|
|
697
735
|
/** How many levels of relationships to populate. Default 1, maximum 3. */
|
|
698
736
|
readonly depth?: number;
|
|
737
|
+
/**
|
|
738
|
+
* With `localization`: the locale to read, and for writes the locale the data is in. Default:
|
|
739
|
+
* the default locale. `'all'` reads (and writes) localized fields as `{ [locale]: value }`.
|
|
740
|
+
*/
|
|
741
|
+
readonly locale?: string;
|
|
699
742
|
}
|
|
700
743
|
interface ReadOptions extends DepthOptions {
|
|
701
744
|
/**
|
|
@@ -703,6 +746,8 @@ interface ReadOptions extends DepthOptions {
|
|
|
703
746
|
* `drafts: true` are returned, including when populating relationships (FR-DRF-04).
|
|
704
747
|
*/
|
|
705
748
|
readonly draft?: boolean;
|
|
749
|
+
/** Use the default locale's value when a localized value is empty. Default: `localization.fallback`. */
|
|
750
|
+
readonly fallbackLocale?: boolean;
|
|
706
751
|
}
|
|
707
752
|
interface FindOptions extends ReadOptions {
|
|
708
753
|
readonly where?: Where;
|
|
@@ -738,6 +783,7 @@ declare class EasyCMS<C extends Config = Config> {
|
|
|
738
783
|
count<S extends Slug<C>>(collection: S, options?: {
|
|
739
784
|
where?: Where;
|
|
740
785
|
draft?: boolean;
|
|
786
|
+
locale?: string;
|
|
741
787
|
} & AccessOptions): Promise<number>;
|
|
742
788
|
create<S extends Slug<C>>(collection: S, data: Create<C, S>, options?: DepthOptions): Promise<Doc<C, S>>;
|
|
743
789
|
/**
|
|
@@ -771,6 +817,34 @@ declare class EasyCMS<C extends Config = Config> {
|
|
|
771
817
|
* a draft (publish it to put it live); access, hooks and validation run as for `update`.
|
|
772
818
|
*/
|
|
773
819
|
restoreVersion<S extends Slug<C>>(collection: S, id: ID, versionId: ID, options?: DepthOptions): Promise<Doc<C, S>>;
|
|
820
|
+
/**
|
|
821
|
+
* Live preview: the document as it would be read if `data` were saved on top of its current
|
|
822
|
+
* state (or as a new document when `id` is `null`), with relationships populated and
|
|
823
|
+
* `afterRead` hooks applied. Nothing is written. Also returns the page URL from `preview`.
|
|
824
|
+
*/
|
|
825
|
+
preview<S extends Slug<C>>(collection: S, id: ID | null, data: Record<string, unknown>, options?: DepthOptions): Promise<LivePreview<Doc<C, S>>>;
|
|
826
|
+
/**
|
|
827
|
+
* A token that lets a page read one document's current draft for a while (default one hour),
|
|
828
|
+
* without a login: `GET /api/cms/:collection/:id?preview=<token>`. The admin adds one to the
|
|
829
|
+
* preview URL as `easy-cms-preview`, for sites on another origin.
|
|
830
|
+
*/
|
|
831
|
+
createPreviewToken(target: {
|
|
832
|
+
collection: string;
|
|
833
|
+
id: ID;
|
|
834
|
+
} | {
|
|
835
|
+
global: string;
|
|
836
|
+
}, options?: {
|
|
837
|
+
expiresIn?: number;
|
|
838
|
+
}): string;
|
|
839
|
+
/** What a preview token opens, or `null` when it is invalid or expired. */
|
|
840
|
+
verifyPreviewToken(token: string | null | undefined): (PreviewTarget & {
|
|
841
|
+
expiresAt: number;
|
|
842
|
+
}) | null;
|
|
843
|
+
/** Live preview of a global. See `preview`. */
|
|
844
|
+
previewGlobal<S extends GSlug<C>>(slug: S, data: Record<string, unknown>, options?: DepthOptions): Promise<LivePreview<GDoc<C, S>>>;
|
|
845
|
+
/** Coerces preview input like a save would, keeping values that would fail validation. */
|
|
846
|
+
private previewDoc;
|
|
847
|
+
private previewURL;
|
|
774
848
|
private versionTarget;
|
|
775
849
|
private updateDocument;
|
|
776
850
|
delete<S extends Slug<C>>(collection: S, id: ID, options?: AccessOptions): Promise<Doc<C, S>>;
|
|
@@ -814,6 +888,14 @@ declare class EasyCMS<C extends Config = Config> {
|
|
|
814
888
|
private output;
|
|
815
889
|
/** Creates a document: access, input filtering, hooks, validation, then afterChange. */
|
|
816
890
|
private createDocument;
|
|
891
|
+
/** The locale for an operation; `undefined` without localization. Throws on unknown locales. */
|
|
892
|
+
private localeOf;
|
|
893
|
+
/** Stored localized maps → the values of the requested locale. */
|
|
894
|
+
private pick;
|
|
895
|
+
/** Input for one locale → stored localized maps, keeping the other locales from `current`. */
|
|
896
|
+
private toMaps;
|
|
897
|
+
/** Makes `where` match localized fields in the operation's locale. */
|
|
898
|
+
private whereFor;
|
|
817
899
|
/** Drafts are kept as versions (instead of unpublishing) when both are enabled. */
|
|
818
900
|
private separateDrafts;
|
|
819
901
|
/** Replaces published documents that have a newer draft with that draft. */
|
|
@@ -839,6 +921,12 @@ declare class EasyCMS<C extends Config = Config> {
|
|
|
839
921
|
private isTaken;
|
|
840
922
|
private checkReferences;
|
|
841
923
|
}
|
|
924
|
+
/** A document as live preview shows it, and the page to show it on. */
|
|
925
|
+
interface LivePreview<T> {
|
|
926
|
+
readonly doc: T;
|
|
927
|
+
/** From the collection's `preview` function; `null` without one. */
|
|
928
|
+
readonly url: string | null;
|
|
929
|
+
}
|
|
842
930
|
|
|
843
931
|
interface LoginArgs {
|
|
844
932
|
readonly email: string;
|
|
@@ -951,6 +1039,8 @@ declare class PayloadTooLargeError extends EasyCMSError {
|
|
|
951
1039
|
constructor(message: string);
|
|
952
1040
|
}
|
|
953
1041
|
|
|
1042
|
+
type Localization = Required<LocalizationConfig>;
|
|
1043
|
+
|
|
954
1044
|
type Data = Record<string, unknown>;
|
|
955
1045
|
/**
|
|
956
1046
|
* Turns text into a URL slug. Keeps letters and digits of any script,
|
|
@@ -958,11 +1048,11 @@ type Data = Record<string, unknown>;
|
|
|
958
1048
|
*/
|
|
959
1049
|
declare function slugify(text: string): string;
|
|
960
1050
|
/** Fills in `defaultValue` for fields that are missing, including inside groups and array rows. */
|
|
961
|
-
declare function applyDefaults(fields: readonly Field[], data: Data): Data;
|
|
1051
|
+
declare function applyDefaults(fields: readonly Field[], data: Data, localization?: Localization | null): Data;
|
|
962
1052
|
/** Gives every field a value (`null`, `[]` or a nested object) so documents always have the same shape. */
|
|
963
1053
|
declare function fillMissing(fields: readonly Field[], data: Data): Data;
|
|
964
1054
|
/** Fills empty `slug` fields from their `from` field. Does not check uniqueness. */
|
|
965
|
-
declare function generateSlugs(fields: readonly Field[], data: Data): Data;
|
|
1055
|
+
declare function generateSlugs(fields: readonly Field[], data: Data, localization?: Localization | null): Data;
|
|
966
1056
|
/**
|
|
967
1057
|
* Merges an update into the stored document. Top-level fields and group
|
|
968
1058
|
* fields merge key by key; arrays and hasMany values are replaced.
|
|
@@ -982,6 +1072,13 @@ interface ValidateOptions {
|
|
|
982
1072
|
readonly skipRequired?: boolean;
|
|
983
1073
|
/** The whole top-level document, passed to custom `validate` functions. */
|
|
984
1074
|
readonly root: Data;
|
|
1075
|
+
/** Localized fields hold `{ [locale]: value }`; each value is validated. */
|
|
1076
|
+
readonly localization?: Localization | null;
|
|
1077
|
+
/**
|
|
1078
|
+
* The locale being written: `required` and custom `validate` apply to its values, and its
|
|
1079
|
+
* errors use the plain field path. Default: the default locale.
|
|
1080
|
+
*/
|
|
1081
|
+
readonly locale?: string;
|
|
985
1082
|
}
|
|
986
1083
|
interface ValidateResult {
|
|
987
1084
|
/** Only known fields, with values normalized (ids parsed, dates as ISO strings, row ids filled). */
|
|
@@ -1066,6 +1163,8 @@ interface AdminField {
|
|
|
1066
1163
|
fields?: AdminField[];
|
|
1067
1164
|
/** The current user may not change this field. */
|
|
1068
1165
|
readOnly?: boolean;
|
|
1166
|
+
/** One value per content locale. */
|
|
1167
|
+
localized?: boolean;
|
|
1069
1168
|
}
|
|
1070
1169
|
interface AdminCollection {
|
|
1071
1170
|
slug: string;
|
|
@@ -1076,6 +1175,8 @@ interface AdminCollection {
|
|
|
1076
1175
|
useAsTitle?: string;
|
|
1077
1176
|
drafts: boolean;
|
|
1078
1177
|
versions: boolean;
|
|
1178
|
+
/** Has live preview (`preview` in the config). */
|
|
1179
|
+
preview: boolean;
|
|
1079
1180
|
fields: AdminField[];
|
|
1080
1181
|
permissions: {
|
|
1081
1182
|
read: boolean;
|
|
@@ -1089,6 +1190,8 @@ interface AdminGlobal {
|
|
|
1089
1190
|
label?: Label;
|
|
1090
1191
|
drafts: boolean;
|
|
1091
1192
|
versions: boolean;
|
|
1193
|
+
/** Has live preview (`preview` in the config). */
|
|
1194
|
+
preview: boolean;
|
|
1092
1195
|
fields: AdminField[];
|
|
1093
1196
|
permissions: {
|
|
1094
1197
|
read: boolean;
|
|
@@ -1097,6 +1200,11 @@ interface AdminGlobal {
|
|
|
1097
1200
|
}
|
|
1098
1201
|
interface AdminSchema {
|
|
1099
1202
|
locale: AdminLocale;
|
|
1203
|
+
/** Content locales, when the config has `localization`. */
|
|
1204
|
+
localization: {
|
|
1205
|
+
locales: string[];
|
|
1206
|
+
defaultLocale: string;
|
|
1207
|
+
} | null;
|
|
1100
1208
|
collections: AdminCollection[];
|
|
1101
1209
|
globals: AdminGlobal[];
|
|
1102
1210
|
}
|
|
@@ -1129,4 +1237,4 @@ declare const BUILTIN_COLLECTIONS: readonly ["users", "media"];
|
|
|
1129
1237
|
/** Checks a config and returns every problem found. An empty array means it is valid. */
|
|
1130
1238
|
declare function validateConfig(config: Config): ConfigIssue[];
|
|
1131
1239
|
|
|
1132
|
-
export { type Access, type AccessArgs, type AccessOptions, type AdminCollection, type AdminConfig, type AdminField, type AdminGlobal, type AdminLocale, type AdminSchema, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ArrayField, Auth, type AuthConfig, type AuthUser, BUILTIN_COLLECTIONS, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type BooleanField, type BuiltinMediaCollection, type BuiltinUsersCollection, type BundleHints, CONFIG_FILE_NAMES, CSRF_COOKIE, CSRF_HEADER, type CollectionAccess, type CollectionConfig, type CollectionDocument, type CollectionHooks, type CollectionSlug, type Config, ConfigError, type ConfigIssue, type CreateEasyCMSOptions, type CreateInput, type CreateMigrationResult, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_VERSIONS, DEFAULT_TOKEN_EXPIRATION, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DateField, type DepthOptions, EXTENSIONS, EasyCMS, EasyCMSError, type EmailField, FIELD_TYPES, type Field, type FieldAccess, type FieldAccessFn, type FieldError, type FieldType, type FieldValidate, type FieldValidateContext, type FieldValue, type FieldsInput, type FieldsValue, type FindArgs, type FindOptions, ForbiddenError, type GlobalAccess, type GlobalConfig, type GlobalDocument, type GlobalHooks, type GlobalInput, type GlobalSlug, type GroupField, type ID, INTERNAL_COLLECTIONS, type ImageSize, type InferCollection, type InferGlobal, type JsonField, type Label, type LoadConfigOptions, type LocalStorageOptions, type Logger, type LoginArgs, MAX_DEPTH, MEDIA, MIN_PASSWORD_LENGTH, MIN_SECRET_LENGTH, type MediaDocument, type MediaSize, type MigrationInfo, NotFoundError, type NumberField, type Operation, type PaginatedDocs, PayloadTooLargeError, type Plugin, QueryError, type RawDocument, type ReadOptions, type RelationshipField, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoutesConfig, SESSION_COOKIE, SchemaError, type SchemaMode, type SelectField, type SelectOption, type Session, type SlugField, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, UnauthorizedError, type UpdateInput, type UploadConfig, type UploadField, ValidationError, type Version, type VersionSummary, type VersionsConfig, type Where, type WhereOperators, anyone, applyDefaults, consoleLogger, createEasyCMS, createRestHandler, defineConfig, fillMissing, findConfigFile, generateSlugs, generateTypes, hashPassword, imageDimensions, importConfig, isAdmin, isLoggedIn, loadConfig, localStorage, mergeForUpdate, mimeAllowed, parseId, populate, readCookie, resolveConfig, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword };
|
|
1240
|
+
export { type Access, type AccessArgs, type AccessOptions, type AdminCollection, type AdminConfig, type AdminField, type AdminGlobal, type AdminLocale, type AdminSchema, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ArrayField, Auth, type AuthConfig, type AuthUser, BUILTIN_COLLECTIONS, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type BooleanField, type BuiltinMediaCollection, type BuiltinUsersCollection, type BundleHints, CONFIG_FILE_NAMES, CSRF_COOKIE, CSRF_HEADER, type CollectionAccess, type CollectionConfig, type CollectionDocument, type CollectionHooks, type CollectionSlug, type Config, ConfigError, type ConfigIssue, type CreateEasyCMSOptions, type CreateInput, type CreateMigrationResult, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_VERSIONS, DEFAULT_TOKEN_EXPIRATION, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DateField, type DepthOptions, EXTENSIONS, EasyCMS, EasyCMSError, type EmailField, FIELD_TYPES, type Field, type FieldAccess, type FieldAccessFn, type FieldError, type FieldType, type FieldValidate, type FieldValidateContext, type FieldValue, type FieldsInput, type FieldsValue, type FindArgs, type FindOptions, ForbiddenError, type GlobalAccess, type GlobalConfig, type GlobalDocument, type GlobalHooks, type GlobalInput, type GlobalSlug, type GroupField, type ID, INTERNAL_COLLECTIONS, type ImageSize, type InferCollection, type InferGlobal, type JsonField, type Label, type LivePreview, type LoadConfigOptions, type LocalStorageOptions, type LocalizationConfig, type Logger, type LoginArgs, MAX_DEPTH, MEDIA, MIN_PASSWORD_LENGTH, MIN_SECRET_LENGTH, type MediaDocument, type MediaSize, type MigrationInfo, NotFoundError, type NumberField, type Operation, type PaginatedDocs, PayloadTooLargeError, type Plugin, type PreviewTarget, type PreviewURL, QueryError, type RawDocument, type ReadOptions, type RelationshipField, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoutesConfig, SESSION_COOKIE, SchemaError, type SchemaMode, type SelectField, type SelectOption, type Session, type SlugField, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, UnauthorizedError, type UpdateInput, type UploadConfig, type UploadField, ValidationError, type Version, type VersionSummary, type VersionsConfig, type Where, type WhereOperators, anyone, applyDefaults, consoleLogger, createEasyCMS, createRestHandler, defineConfig, fillMissing, findConfigFile, generateSlugs, generateTypes, hashPassword, imageDimensions, importConfig, isAdmin, isLoggedIn, loadConfig, localStorage, mergeForUpdate, mimeAllowed, parseId, populate, readCookie, resolveConfig, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword };
|
package/dist/index.js
CHANGED
|
@@ -419,6 +419,30 @@ function safeEqual(a, b) {
|
|
|
419
419
|
const right = Buffer.from(b);
|
|
420
420
|
return left.length === right.length && timingSafeEqual2(left, right);
|
|
421
421
|
}
|
|
422
|
+
function signPreviewToken(secret, target, expiresAt) {
|
|
423
|
+
const payload = Buffer.from(
|
|
424
|
+
JSON.stringify(
|
|
425
|
+
"global" in target ? { g: target.global, e: expiresAt } : { c: target.collection, i: target.id, e: expiresAt }
|
|
426
|
+
)
|
|
427
|
+
).toString("base64url");
|
|
428
|
+
return `${payload}.${hmac(secret, `preview:${payload}`)}`;
|
|
429
|
+
}
|
|
430
|
+
function verifyPreviewToken(secret, token, now = Date.now()) {
|
|
431
|
+
const dot = token.lastIndexOf(".");
|
|
432
|
+
if (dot <= 0) return null;
|
|
433
|
+
const payload = token.slice(0, dot);
|
|
434
|
+
if (!safeEqual(token.slice(dot + 1), hmac(secret, `preview:${payload}`))) return null;
|
|
435
|
+
try {
|
|
436
|
+
const data = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
437
|
+
if (typeof data.e !== "number" || data.e <= now) return null;
|
|
438
|
+
if (typeof data.g === "string") return { global: data.g, expiresAt: data.e };
|
|
439
|
+
if (typeof data.c === "string" && typeof data.i === "string")
|
|
440
|
+
return { collection: data.c, id: data.i, expiresAt: data.e };
|
|
441
|
+
return null;
|
|
442
|
+
} catch {
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
422
446
|
|
|
423
447
|
// src/auth/auth.ts
|
|
424
448
|
var INVALID = "Invalid email or password";
|
|
@@ -620,18 +644,30 @@ var isEmpty = (value) => value === void 0 || value === null || value === "";
|
|
|
620
644
|
function slugify(text) {
|
|
621
645
|
return text.normalize("NFKC").toLowerCase().replace(/[^\p{L}\p{M}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "");
|
|
622
646
|
}
|
|
623
|
-
function applyDefaults(fields, data) {
|
|
647
|
+
function applyDefaults(fields, data, localization = null) {
|
|
624
648
|
const result = { ...data };
|
|
625
649
|
for (const field of fields) {
|
|
626
650
|
const value = result[field.name];
|
|
627
651
|
if (field.type === "group") {
|
|
628
|
-
result[field.name] = applyDefaults(
|
|
652
|
+
result[field.name] = applyDefaults(
|
|
653
|
+
field.fields,
|
|
654
|
+
isPlainObject(value) ? value : {},
|
|
655
|
+
localization
|
|
656
|
+
);
|
|
629
657
|
} else if (field.type === "array") {
|
|
630
658
|
if (Array.isArray(value)) {
|
|
631
659
|
result[field.name] = value.map(
|
|
632
|
-
(row) => isPlainObject(row) ? applyDefaults(field.fields, row) : row
|
|
660
|
+
(row) => isPlainObject(row) ? applyDefaults(field.fields, row, localization) : row
|
|
633
661
|
);
|
|
634
662
|
}
|
|
663
|
+
} else if (field.localized && localization && field.defaultValue !== void 0) {
|
|
664
|
+
const map = isPlainObject(value) ? value : {};
|
|
665
|
+
if (map[localization.defaultLocale] === void 0) {
|
|
666
|
+
result[field.name] = {
|
|
667
|
+
...map,
|
|
668
|
+
[localization.defaultLocale]: structuredClone(field.defaultValue)
|
|
669
|
+
};
|
|
670
|
+
}
|
|
635
671
|
} else if (value === void 0 && field.defaultValue !== void 0) {
|
|
636
672
|
result[field.name] = structuredClone(field.defaultValue);
|
|
637
673
|
}
|
|
@@ -650,22 +686,34 @@ function fillMissing(fields, data) {
|
|
|
650
686
|
}
|
|
651
687
|
return result;
|
|
652
688
|
}
|
|
653
|
-
function generateSlugs(fields, data) {
|
|
689
|
+
function generateSlugs(fields, data, localization = null) {
|
|
654
690
|
const result = { ...data };
|
|
655
691
|
for (const field of fields) {
|
|
656
|
-
if (field.type === "slug") {
|
|
692
|
+
if (field.type === "slug" && field.localized && localization) {
|
|
693
|
+
const map = isPlainObject(result[field.name]) ? result[field.name] : {};
|
|
694
|
+
const source = field.from ? result[field.from] : void 0;
|
|
695
|
+
const next = { ...map };
|
|
696
|
+
for (const locale of localization.locales) {
|
|
697
|
+
const current = map[locale];
|
|
698
|
+
const from = isPlainObject(source) ? source[locale] : source;
|
|
699
|
+
if (typeof current === "string" && current !== "") next[locale] = slugify(current);
|
|
700
|
+
else if (typeof from === "string" && from.trim() !== "") next[locale] = slugify(from);
|
|
701
|
+
}
|
|
702
|
+
result[field.name] = next;
|
|
703
|
+
} else if (field.type === "slug") {
|
|
657
704
|
const current = result[field.name];
|
|
658
705
|
if (typeof current === "string" && current !== "") {
|
|
659
706
|
result[field.name] = slugify(current);
|
|
660
707
|
} else if (field.from) {
|
|
661
|
-
const
|
|
708
|
+
const raw = result[field.from];
|
|
709
|
+
const source = isPlainObject(raw) && localization ? raw[localization.defaultLocale] : raw;
|
|
662
710
|
if (typeof source === "string" && source.trim() !== "") result[field.name] = slugify(source);
|
|
663
711
|
}
|
|
664
712
|
} else if (field.type === "group" && isPlainObject(result[field.name])) {
|
|
665
|
-
result[field.name] = generateSlugs(field.fields, result[field.name]);
|
|
713
|
+
result[field.name] = generateSlugs(field.fields, result[field.name], localization);
|
|
666
714
|
} else if (field.type === "array" && Array.isArray(result[field.name])) {
|
|
667
715
|
result[field.name] = result[field.name].map(
|
|
668
|
-
(row) => isPlainObject(row) ? generateSlugs(field.fields, row) : row
|
|
716
|
+
(row) => isPlainObject(row) ? generateSlugs(field.fields, row, localization) : row
|
|
669
717
|
);
|
|
670
718
|
}
|
|
671
719
|
}
|
|
@@ -717,6 +765,42 @@ async function validateFields(fields, input, options, prefix = "") {
|
|
|
717
765
|
references.push(...sub.references);
|
|
718
766
|
continue;
|
|
719
767
|
}
|
|
768
|
+
const localization = options.localization;
|
|
769
|
+
if (field.localized && localization) {
|
|
770
|
+
if (!isEmpty(raw) && !isPlainObject(raw)) {
|
|
771
|
+
fail("must be an object with one value per locale");
|
|
772
|
+
continue;
|
|
773
|
+
}
|
|
774
|
+
const map = isPlainObject(raw) ? raw : {};
|
|
775
|
+
const written = options.locale ?? localization.defaultLocale;
|
|
776
|
+
const out = {};
|
|
777
|
+
for (const locale of localization.locales) {
|
|
778
|
+
const own = locale === written;
|
|
779
|
+
const localePath = own ? path : `${path}.${locale}`;
|
|
780
|
+
const failLocale = (message) => errors.push({ field: localePath, message });
|
|
781
|
+
const value2 = map[locale];
|
|
782
|
+
if (isEmpty(value2)) {
|
|
783
|
+
if (own && field.required && !options.skipRequired) failLocale("is required");
|
|
784
|
+
out[locale] = null;
|
|
785
|
+
if (own && !field.required) await runCustom(field, null, options, failLocale);
|
|
786
|
+
continue;
|
|
787
|
+
}
|
|
788
|
+
const normalized = await normalizeValue(
|
|
789
|
+
field,
|
|
790
|
+
value2,
|
|
791
|
+
localePath,
|
|
792
|
+
options,
|
|
793
|
+
failLocale,
|
|
794
|
+
errors,
|
|
795
|
+
references
|
|
796
|
+
);
|
|
797
|
+
if (normalized === void 0) continue;
|
|
798
|
+
out[locale] = normalized;
|
|
799
|
+
if (own) await runCustom(field, normalized, options, failLocale);
|
|
800
|
+
}
|
|
801
|
+
data[field.name] = out;
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
720
804
|
if (isEmpty(raw) || Array.isArray(raw) && raw.length === 0) {
|
|
721
805
|
if (field.required && !options.skipRequired) fail("is required");
|
|
722
806
|
data[field.name] = field.type === "array" || isHasMany(field) ? [] : null;
|
|
@@ -889,6 +973,7 @@ function validateConfig(config) {
|
|
|
889
973
|
validateUpload(config, add);
|
|
890
974
|
validateAuth(config, add);
|
|
891
975
|
validateCors(config.cors, add);
|
|
976
|
+
validateLocalization(config, add);
|
|
892
977
|
const collections = asArray(config.collections, "collections", add);
|
|
893
978
|
const globals = asArray(config.globals, "globals", add);
|
|
894
979
|
const collectionSlugs = /* @__PURE__ */ new Set([...BUILTIN_COLLECTIONS]);
|
|
@@ -965,6 +1050,76 @@ function validateAuth(config, add) {
|
|
|
965
1050
|
}
|
|
966
1051
|
}
|
|
967
1052
|
}
|
|
1053
|
+
var LOCALE_PATTERN = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
|
|
1054
|
+
var LOCALIZABLE = /* @__PURE__ */ new Set([
|
|
1055
|
+
"text",
|
|
1056
|
+
"textarea",
|
|
1057
|
+
"email",
|
|
1058
|
+
"slug",
|
|
1059
|
+
"richText",
|
|
1060
|
+
"number",
|
|
1061
|
+
"boolean",
|
|
1062
|
+
"date",
|
|
1063
|
+
"json",
|
|
1064
|
+
"select",
|
|
1065
|
+
"upload",
|
|
1066
|
+
"relationship"
|
|
1067
|
+
]);
|
|
1068
|
+
function validateLocalization(config, add) {
|
|
1069
|
+
const localization = config.localization;
|
|
1070
|
+
if (localization !== void 0 && localization !== null) {
|
|
1071
|
+
const locales = localization?.locales;
|
|
1072
|
+
if (!Array.isArray(locales) || locales.length === 0) {
|
|
1073
|
+
add("localization.locales", "must be a non-empty list of locales", "locales: ['th', 'en']");
|
|
1074
|
+
} else {
|
|
1075
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1076
|
+
for (const [i, locale] of locales.entries()) {
|
|
1077
|
+
if (typeof locale !== "string" || !LOCALE_PATTERN.test(locale)) {
|
|
1078
|
+
add(
|
|
1079
|
+
`localization.locales[${i}]`,
|
|
1080
|
+
`must be a locale code like "th" or "en-US" (got ${JSON.stringify(locale)})`
|
|
1081
|
+
);
|
|
1082
|
+
} else if (seen.has(locale)) {
|
|
1083
|
+
add(`localization.locales[${i}]`, `duplicate locale "${locale}"`);
|
|
1084
|
+
}
|
|
1085
|
+
seen.add(locale);
|
|
1086
|
+
}
|
|
1087
|
+
const fallbackDefault = localization.defaultLocale;
|
|
1088
|
+
if (fallbackDefault !== void 0 && !locales.includes(fallbackDefault)) {
|
|
1089
|
+
add("localization.defaultLocale", `must be one of the locales (got "${fallbackDefault}")`);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
const walk = (fields, path) => {
|
|
1094
|
+
if (!Array.isArray(fields)) return;
|
|
1095
|
+
for (const field of fields) {
|
|
1096
|
+
if (typeof field?.name !== "string") continue;
|
|
1097
|
+
const fieldPath = `${path}.${field.name}`;
|
|
1098
|
+
if (field.localized) {
|
|
1099
|
+
if (!localization) {
|
|
1100
|
+
add(
|
|
1101
|
+
`${fieldPath}.localized`,
|
|
1102
|
+
"needs `localization` in the config",
|
|
1103
|
+
"localization: { locales: ['th', 'en'] }"
|
|
1104
|
+
);
|
|
1105
|
+
} else if (!LOCALIZABLE.has(field.type)) {
|
|
1106
|
+
add(
|
|
1107
|
+
`${fieldPath}.localized`,
|
|
1108
|
+
`${field.type} fields cannot be localized`,
|
|
1109
|
+
"localize the fields inside it instead"
|
|
1110
|
+
);
|
|
1111
|
+
} else if ("hasMany" in field && field.hasMany) {
|
|
1112
|
+
add(`${fieldPath}.localized`, "hasMany fields cannot be localized yet");
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
if (field.type === "group" || field.type === "array")
|
|
1116
|
+
walk(field.fields, `${fieldPath}.fields`);
|
|
1117
|
+
}
|
|
1118
|
+
};
|
|
1119
|
+
for (const [i, c] of (config.collections ?? []).entries())
|
|
1120
|
+
walk(c?.fields, `collections[${i}].fields`);
|
|
1121
|
+
for (const [i, g] of (config.globals ?? []).entries()) walk(g?.fields, `globals[${i}].fields`);
|
|
1122
|
+
}
|
|
968
1123
|
function validateCors(cors, add) {
|
|
969
1124
|
if (cors === void 0 || cors === "*") return;
|
|
970
1125
|
if (!Array.isArray(cors)) {
|
|
@@ -1032,6 +1187,14 @@ function validateContainer(container, path, collectionSlugs, add) {
|
|
|
1032
1187
|
add(`${path}.versions.max`, "must be a positive integer");
|
|
1033
1188
|
}
|
|
1034
1189
|
}
|
|
1190
|
+
if (container.preview !== void 0 && typeof container.preview !== "function") {
|
|
1191
|
+
add(
|
|
1192
|
+
`${path}.preview`,
|
|
1193
|
+
"must be a function ({ doc }) => url",
|
|
1194
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: an example shown to the user
|
|
1195
|
+
"preview: ({ doc }) => `/posts/${doc.slug}`"
|
|
1196
|
+
);
|
|
1197
|
+
}
|
|
1035
1198
|
const reserved = new Set(SYSTEM_FIELD_NAMES);
|
|
1036
1199
|
if (container.drafts) reserved.add("status");
|
|
1037
1200
|
validateFields2(container.fields, `${path}.fields`, reserved, collectionSlugs, add);
|
|
@@ -1172,6 +1335,11 @@ async function resolveConfig(input) {
|
|
|
1172
1335
|
const result = {
|
|
1173
1336
|
...rest,
|
|
1174
1337
|
cors: config.cors ?? [],
|
|
1338
|
+
localization: config.localization ? {
|
|
1339
|
+
locales: config.localization.locales,
|
|
1340
|
+
defaultLocale: config.localization.defaultLocale ?? config.localization.locales[0] ?? "",
|
|
1341
|
+
fallback: config.localization.fallback ?? true
|
|
1342
|
+
} : null,
|
|
1175
1343
|
routes: { api: `/${(config.routes?.api ?? DEFAULT_API_PATH).replace(/^\/+|\/+$/g, "")}` },
|
|
1176
1344
|
admin: {
|
|
1177
1345
|
path: config.admin?.path ?? DEFAULT_ADMIN_PATH,
|
|
@@ -1253,6 +1421,89 @@ async function loadConfig(options = {}) {
|
|
|
1253
1421
|
// src/local-api.ts
|
|
1254
1422
|
import { randomBytes as randomBytes3 } from "crypto";
|
|
1255
1423
|
|
|
1424
|
+
// src/localization.ts
|
|
1425
|
+
var ALL_LOCALES = "all";
|
|
1426
|
+
var isPlainObject2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1427
|
+
var isEmpty2 = (value) => value === void 0 || value === null || value === "";
|
|
1428
|
+
function pickLocale(fields, data, locale, localization, fallback) {
|
|
1429
|
+
if (locale === ALL_LOCALES) return data;
|
|
1430
|
+
const result = { ...data };
|
|
1431
|
+
for (const field of fields) {
|
|
1432
|
+
const value = result[field.name];
|
|
1433
|
+
if (field.type === "group" && isPlainObject2(value)) {
|
|
1434
|
+
result[field.name] = pickLocale(field.fields, value, locale, localization, fallback);
|
|
1435
|
+
} else if (field.type === "array" && Array.isArray(value)) {
|
|
1436
|
+
result[field.name] = value.map(
|
|
1437
|
+
(row) => isPlainObject2(row) ? pickLocale(field.fields, row, locale, localization, fallback) : row
|
|
1438
|
+
);
|
|
1439
|
+
} else if (field.localized) {
|
|
1440
|
+
const map = isPlainObject2(value) ? value : {};
|
|
1441
|
+
const own = map[locale];
|
|
1442
|
+
result[field.name] = isEmpty2(own) && fallback ? map[localization.defaultLocale] ?? null : own ?? null;
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
return result;
|
|
1446
|
+
}
|
|
1447
|
+
function toLocaleMaps(fields, input, current, locale) {
|
|
1448
|
+
const result = { ...input };
|
|
1449
|
+
for (const field of fields) {
|
|
1450
|
+
if (!Object.hasOwn(input, field.name)) continue;
|
|
1451
|
+
const value = input[field.name];
|
|
1452
|
+
const previous = current[field.name];
|
|
1453
|
+
if (field.type === "group" && isPlainObject2(value)) {
|
|
1454
|
+
result[field.name] = toLocaleMaps(
|
|
1455
|
+
field.fields,
|
|
1456
|
+
value,
|
|
1457
|
+
isPlainObject2(previous) ? previous : {},
|
|
1458
|
+
locale
|
|
1459
|
+
);
|
|
1460
|
+
} else if (field.type === "array" && Array.isArray(value)) {
|
|
1461
|
+
const rows = new Map(
|
|
1462
|
+
(Array.isArray(previous) ? previous : []).filter(isPlainObject2).map((row) => [String(row.id), row])
|
|
1463
|
+
);
|
|
1464
|
+
result[field.name] = value.map(
|
|
1465
|
+
(row) => isPlainObject2(row) ? toLocaleMaps(field.fields, row, rows.get(String(row.id)) ?? {}, locale) : row
|
|
1466
|
+
);
|
|
1467
|
+
} else if (field.localized) {
|
|
1468
|
+
const map = isPlainObject2(previous) ? previous : {};
|
|
1469
|
+
result[field.name] = { ...map, [locale]: value };
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
return result;
|
|
1473
|
+
}
|
|
1474
|
+
function localizePath(fields, path, locale) {
|
|
1475
|
+
const segments = path.split(".");
|
|
1476
|
+
let list = fields;
|
|
1477
|
+
for (let i = 0; i < segments.length; i++) {
|
|
1478
|
+
const field = list.find((f) => f.name === segments[i]);
|
|
1479
|
+
if (!field) return path;
|
|
1480
|
+
if (field.type === "group" || field.type === "array") {
|
|
1481
|
+
list = field.fields;
|
|
1482
|
+
continue;
|
|
1483
|
+
}
|
|
1484
|
+
if (field.localized && i === segments.length - 1) return `${path}.${locale}`;
|
|
1485
|
+
return path;
|
|
1486
|
+
}
|
|
1487
|
+
return path;
|
|
1488
|
+
}
|
|
1489
|
+
function localizeWhere(fields, where, locale) {
|
|
1490
|
+
if (!where) return where;
|
|
1491
|
+
const out = {};
|
|
1492
|
+
for (const [key2, value] of Object.entries(where)) {
|
|
1493
|
+
if ((key2 === "and" || key2 === "or") && Array.isArray(value)) {
|
|
1494
|
+
out[key2] = value.map((w) => localizeWhere(fields, w, locale));
|
|
1495
|
+
} else {
|
|
1496
|
+
out[localizePath(fields, key2, locale)] = value;
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
return out;
|
|
1500
|
+
}
|
|
1501
|
+
function localizeSort(fields, sort, locale) {
|
|
1502
|
+
return sort.map(
|
|
1503
|
+
(entry) => entry.startsWith("-") ? `-${localizePath(fields, entry.slice(1), locale)}` : localizePath(fields, entry, locale)
|
|
1504
|
+
);
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1256
1507
|
// src/logger.ts
|
|
1257
1508
|
var consoleLogger = {
|
|
1258
1509
|
info: (message) => console.info(`[easy-cms] ${message}`),
|
|
@@ -1638,12 +1889,18 @@ var EasyCMS = class {
|
|
|
1638
1889
|
if (!Number.isInteger(limit) || limit < 0)
|
|
1639
1890
|
throw new QueryError("limit must be a non-negative integer");
|
|
1640
1891
|
if (!Number.isInteger(page) || page < 1) throw new QueryError("page must be a positive integer");
|
|
1641
|
-
const where =
|
|
1892
|
+
const where = this.whereFor(
|
|
1642
1893
|
config,
|
|
1643
|
-
options.draft,
|
|
1644
|
-
|
|
1894
|
+
draftWhere(config, options.draft, await this.readWhere(config, guard, options.where)),
|
|
1895
|
+
options
|
|
1896
|
+
);
|
|
1897
|
+
const requested = options.sort === void 0 ? ["-createdAt"] : [options.sort].flat();
|
|
1898
|
+
const locale = this.localeOf(options);
|
|
1899
|
+
const sort = locale === void 0 ? requested : localizeSort(
|
|
1900
|
+
config.fields,
|
|
1901
|
+
requested,
|
|
1902
|
+
locale === ALL_LOCALES ? this.config.localization?.defaultLocale ?? locale : locale
|
|
1645
1903
|
);
|
|
1646
|
-
const sort = options.sort === void 0 ? ["-createdAt"] : [options.sort].flat();
|
|
1647
1904
|
const result = await this.db.find({ collection: collection2, where, sort, limit, page });
|
|
1648
1905
|
const found = options.draft ? await this.withDrafts(config, result.docs) : result.docs;
|
|
1649
1906
|
const docs = await this.output(config, found, guard, options);
|
|
@@ -1654,7 +1911,11 @@ var EasyCMS = class {
|
|
|
1654
1911
|
const guard = guardOf(options);
|
|
1655
1912
|
const parsed = parseId(id);
|
|
1656
1913
|
if (parsed === void 0) return null;
|
|
1657
|
-
const where =
|
|
1914
|
+
const where = this.whereFor(
|
|
1915
|
+
config,
|
|
1916
|
+
draftWhere(config, options.draft, await this.readWhere(config, guard, void 0)),
|
|
1917
|
+
options
|
|
1918
|
+
);
|
|
1658
1919
|
const doc = where ? (await this.db.find({
|
|
1659
1920
|
collection: collection2,
|
|
1660
1921
|
where: andWhere(where, { id: { equals: parsed } }),
|
|
@@ -1669,10 +1930,14 @@ var EasyCMS = class {
|
|
|
1669
1930
|
}
|
|
1670
1931
|
async count(collection2, options = {}) {
|
|
1671
1932
|
const config = this.collection(collection2);
|
|
1672
|
-
const where =
|
|
1933
|
+
const where = this.whereFor(
|
|
1673
1934
|
config,
|
|
1674
|
-
|
|
1675
|
-
|
|
1935
|
+
draftWhere(
|
|
1936
|
+
config,
|
|
1937
|
+
options.draft,
|
|
1938
|
+
await this.readWhere(config, guardOf(options), options.where)
|
|
1939
|
+
),
|
|
1940
|
+
options
|
|
1676
1941
|
);
|
|
1677
1942
|
return this.db.count({ collection: collection2, where });
|
|
1678
1943
|
}
|
|
@@ -1822,8 +2087,124 @@ var EasyCMS = class {
|
|
|
1822
2087
|
collection2,
|
|
1823
2088
|
parsed,
|
|
1824
2089
|
config.drafts ? { ...data, status: "draft" } : data,
|
|
2090
|
+
options,
|
|
2091
|
+
"restore"
|
|
2092
|
+
);
|
|
2093
|
+
}
|
|
2094
|
+
/**
|
|
2095
|
+
* Live preview: the document as it would be read if `data` were saved on top of its current
|
|
2096
|
+
* state (or as a new document when `id` is `null`), with relationships populated and
|
|
2097
|
+
* `afterRead` hooks applied. Nothing is written. Also returns the page URL from `preview`.
|
|
2098
|
+
*/
|
|
2099
|
+
async preview(collection2, id, data, options = {}) {
|
|
2100
|
+
const config = this.collection(collection2);
|
|
2101
|
+
const guard = guardOf(options);
|
|
2102
|
+
const raw = asObject(data, collection2);
|
|
2103
|
+
let base;
|
|
2104
|
+
let parsed;
|
|
2105
|
+
if (id === null) {
|
|
2106
|
+
if (guard.enforce) {
|
|
2107
|
+
const allowed2 = await evaluateAccess(config.access?.create, { user: guard.user, data: raw });
|
|
2108
|
+
if (allowed2 !== true) throw deny(guard.user);
|
|
2109
|
+
}
|
|
2110
|
+
} else {
|
|
2111
|
+
parsed = parseId(id);
|
|
2112
|
+
const existing = parsed === void 0 ? null : await this.db.findById({ collection: collection2, id: parsed });
|
|
2113
|
+
if (!existing || parsed === void 0) throw new NotFoundError(collection2, id);
|
|
2114
|
+
await this.checkDocumentAccess(config, "update", guard, parsed, raw);
|
|
2115
|
+
[base] = await this.withDrafts(config, [existing]);
|
|
2116
|
+
}
|
|
2117
|
+
const { input } = splitPassword(config, raw);
|
|
2118
|
+
const filtered = await filterInput(
|
|
2119
|
+
config.fields,
|
|
2120
|
+
input,
|
|
2121
|
+
this.fieldChecker("update", guard, parsed, input)
|
|
2122
|
+
);
|
|
2123
|
+
const localized = this.toMaps(config, filtered, base ?? {}, options);
|
|
2124
|
+
const merged = base ? mergeForUpdate(config.fields, base, localized) : applyDefaults(config.fields, localized, this.config.localization);
|
|
2125
|
+
if (config.drafts) merged.status = filtered.status ?? base?.status ?? "draft";
|
|
2126
|
+
const doc = await this.previewDoc(
|
|
2127
|
+
config,
|
|
2128
|
+
generateSlugs(config.fields, merged, this.config.localization),
|
|
2129
|
+
{
|
|
2130
|
+
id: parsed ?? 0,
|
|
2131
|
+
createdAt: base?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
2132
|
+
},
|
|
1825
2133
|
options
|
|
1826
2134
|
);
|
|
2135
|
+
const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
|
|
2136
|
+
return { doc: out, url: this.previewURL(config, out, options) };
|
|
2137
|
+
}
|
|
2138
|
+
/**
|
|
2139
|
+
* A token that lets a page read one document's current draft for a while (default one hour),
|
|
2140
|
+
* without a login: `GET /api/cms/:collection/:id?preview=<token>`. The admin adds one to the
|
|
2141
|
+
* preview URL as `easy-cms-preview`, for sites on another origin.
|
|
2142
|
+
*/
|
|
2143
|
+
createPreviewToken(target, options = {}) {
|
|
2144
|
+
const seconds = options.expiresIn ?? 60 * 60;
|
|
2145
|
+
const normalized = "global" in target ? target : { collection: target.collection, id: String(target.id) };
|
|
2146
|
+
return signPreviewToken(this.config.secret, normalized, Date.now() + seconds * 1e3);
|
|
2147
|
+
}
|
|
2148
|
+
/** What a preview token opens, or `null` when it is invalid or expired. */
|
|
2149
|
+
verifyPreviewToken(token) {
|
|
2150
|
+
return token ? verifyPreviewToken(this.config.secret, token) : null;
|
|
2151
|
+
}
|
|
2152
|
+
/** Live preview of a global. See `preview`. */
|
|
2153
|
+
async previewGlobal(slug, data, options = {}) {
|
|
2154
|
+
const config = this.global(slug);
|
|
2155
|
+
const guard = guardOf(options);
|
|
2156
|
+
await this.checkGlobalAccess(config, "update", guard);
|
|
2157
|
+
const raw = asObject(data, slug);
|
|
2158
|
+
const input = await filterInput(
|
|
2159
|
+
config.fields,
|
|
2160
|
+
raw,
|
|
2161
|
+
this.fieldChecker("update", guard, void 0, raw)
|
|
2162
|
+
);
|
|
2163
|
+
const saved = await this.db.findGlobal({ slug }) ?? {};
|
|
2164
|
+
const current = await this.globalDraft(config, saved);
|
|
2165
|
+
const merged = generateSlugs(
|
|
2166
|
+
config.fields,
|
|
2167
|
+
applyDefaults(
|
|
2168
|
+
config.fields,
|
|
2169
|
+
mergeForUpdate(config.fields, current, this.toMaps(config, input, current, options)),
|
|
2170
|
+
this.config.localization
|
|
2171
|
+
),
|
|
2172
|
+
this.config.localization
|
|
2173
|
+
);
|
|
2174
|
+
if (config.drafts) merged.status = input.status ?? current.status ?? "draft";
|
|
2175
|
+
const doc = await this.previewDoc(config, merged, { id: 0 }, options);
|
|
2176
|
+
const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
|
|
2177
|
+
const { id: _id, ...global2 } = out;
|
|
2178
|
+
return { doc: global2, url: this.previewURL(config, global2, options) };
|
|
2179
|
+
}
|
|
2180
|
+
/** Coerces preview input like a save would, keeping values that would fail validation. */
|
|
2181
|
+
async previewDoc(config, data, system, options = {}) {
|
|
2182
|
+
const locale = this.localeOf(options);
|
|
2183
|
+
const { data: clean } = await validateFields(config.fields, data, {
|
|
2184
|
+
operation: "update",
|
|
2185
|
+
root: data,
|
|
2186
|
+
skipRequired: true,
|
|
2187
|
+
localization: this.config.localization,
|
|
2188
|
+
...locale !== void 0 && locale !== ALL_LOCALES ? { locale } : {}
|
|
2189
|
+
});
|
|
2190
|
+
return {
|
|
2191
|
+
...data,
|
|
2192
|
+
...clean,
|
|
2193
|
+
...config.drafts ? { status: data.status } : {},
|
|
2194
|
+
...system,
|
|
2195
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2196
|
+
};
|
|
2197
|
+
}
|
|
2198
|
+
previewURL(config, doc, options) {
|
|
2199
|
+
if (!config.preview) return null;
|
|
2200
|
+
const content = this.localeOf(options);
|
|
2201
|
+
const locale = content && content !== ALL_LOCALES ? content : this.config.admin.locale;
|
|
2202
|
+
try {
|
|
2203
|
+
return config.preview({ doc, locale }) ?? null;
|
|
2204
|
+
} catch (error) {
|
|
2205
|
+
this.logger.error(`preview URL of "${config.slug}" failed: ${error.message}`);
|
|
2206
|
+
return null;
|
|
2207
|
+
}
|
|
1827
2208
|
}
|
|
1828
2209
|
async versionTarget(collection2, id, options) {
|
|
1829
2210
|
const config = this.collection(collection2);
|
|
@@ -1848,7 +2229,8 @@ var EasyCMS = class {
|
|
|
1848
2229
|
input,
|
|
1849
2230
|
this.fieldChecker("update", guard, parsed, input)
|
|
1850
2231
|
);
|
|
1851
|
-
|
|
2232
|
+
const localized = mode === "restore" ? filtered : this.toMaps(config, filtered, current, options);
|
|
2233
|
+
let merged = mergeForUpdate(config.fields, current, localized);
|
|
1852
2234
|
if (config.drafts)
|
|
1853
2235
|
merged.status = Object.hasOwn(filtered, "status") ? filtered.status : current.status;
|
|
1854
2236
|
const base = this.hookArgs(config, guard);
|
|
@@ -1860,9 +2242,10 @@ var EasyCMS = class {
|
|
|
1860
2242
|
);
|
|
1861
2243
|
let prepared = await this.prepare(
|
|
1862
2244
|
config,
|
|
1863
|
-
generateSlugs(config.fields, merged),
|
|
2245
|
+
generateSlugs(config.fields, merged, this.config.localization),
|
|
1864
2246
|
"update",
|
|
1865
|
-
parsed
|
|
2247
|
+
parsed,
|
|
2248
|
+
options
|
|
1866
2249
|
);
|
|
1867
2250
|
prepared = await this.transform(
|
|
1868
2251
|
config.hooks?.beforeChange,
|
|
@@ -1871,7 +2254,7 @@ var EasyCMS = class {
|
|
|
1871
2254
|
prepared
|
|
1872
2255
|
);
|
|
1873
2256
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1874
|
-
if (mode
|
|
2257
|
+
if (mode !== "unpublish" && this.separateDrafts(config) && existing.status === "published" && prepared.status === "draft") {
|
|
1875
2258
|
if (password !== void 0) {
|
|
1876
2259
|
throw new ValidationError(collection2, [
|
|
1877
2260
|
{ field: "password", message: "cannot be changed in a draft; publish instead" }
|
|
@@ -1937,7 +2320,10 @@ var EasyCMS = class {
|
|
|
1937
2320
|
await this.checkGlobalAccess(config, "read", guard);
|
|
1938
2321
|
const saved = await this.db.findGlobal({ slug });
|
|
1939
2322
|
const stored = saved && options.draft ? await this.globalDraft(config, saved) : saved;
|
|
1940
|
-
const data = fillMissing(
|
|
2323
|
+
const data = fillMissing(
|
|
2324
|
+
config.fields,
|
|
2325
|
+
applyDefaults(config.fields, stored ?? {}, this.config.localization)
|
|
2326
|
+
);
|
|
1941
2327
|
if (!stored) {
|
|
1942
2328
|
data.updatedAt = null;
|
|
1943
2329
|
if (config.drafts) data.status = "draft";
|
|
@@ -1974,7 +2360,10 @@ var EasyCMS = class {
|
|
|
1974
2360
|
const config = await this.globalVersionTarget(slug, options);
|
|
1975
2361
|
const version = await this.versions.get(globalParent(slug), 0, versionId);
|
|
1976
2362
|
if (!version) return null;
|
|
1977
|
-
const data = fillMissing(
|
|
2363
|
+
const data = fillMissing(
|
|
2364
|
+
config.fields,
|
|
2365
|
+
applyDefaults(config.fields, version.data, this.config.localization)
|
|
2366
|
+
);
|
|
1978
2367
|
const [out] = await this.output(config, [{ ...data, id: 0 }], guardOf(options), {
|
|
1979
2368
|
...options,
|
|
1980
2369
|
draft: true
|
|
@@ -1991,7 +2380,8 @@ var EasyCMS = class {
|
|
|
1991
2380
|
return this.saveGlobal(
|
|
1992
2381
|
slug,
|
|
1993
2382
|
config.drafts ? { ...data, status: "draft" } : data,
|
|
1994
|
-
options
|
|
2383
|
+
options,
|
|
2384
|
+
"restore"
|
|
1995
2385
|
);
|
|
1996
2386
|
}
|
|
1997
2387
|
async globalVersionTarget(slug, options) {
|
|
@@ -2018,13 +2408,19 @@ var EasyCMS = class {
|
|
|
2018
2408
|
);
|
|
2019
2409
|
const existing = await this.db.findGlobal({ slug }) ?? {};
|
|
2020
2410
|
const current = await this.globalDraft(config, existing);
|
|
2411
|
+
const localized = mode === "restore" ? input : this.toMaps(config, input, current, options);
|
|
2021
2412
|
const merged = generateSlugs(
|
|
2022
2413
|
config.fields,
|
|
2023
|
-
applyDefaults(
|
|
2414
|
+
applyDefaults(
|
|
2415
|
+
config.fields,
|
|
2416
|
+
mergeForUpdate(config.fields, current, localized),
|
|
2417
|
+
this.config.localization
|
|
2418
|
+
),
|
|
2419
|
+
this.config.localization
|
|
2024
2420
|
);
|
|
2025
2421
|
if (config.drafts) merged.status = input.status ?? current.status ?? "draft";
|
|
2026
2422
|
const base = this.hookArgs(config, guard);
|
|
2027
|
-
let prepared = await this.prepare(config, merged, "update", void 0);
|
|
2423
|
+
let prepared = await this.prepare(config, merged, "update", void 0, options);
|
|
2028
2424
|
prepared = await this.transform(
|
|
2029
2425
|
config.hooks?.beforeChange,
|
|
2030
2426
|
"data",
|
|
@@ -2032,7 +2428,7 @@ var EasyCMS = class {
|
|
|
2032
2428
|
prepared
|
|
2033
2429
|
);
|
|
2034
2430
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2035
|
-
if (mode
|
|
2431
|
+
if (mode !== "unpublish" && this.separateDrafts(config) && existing.status === "published" && prepared.status === "draft") {
|
|
2036
2432
|
const draft = { ...prepared, updatedAt: now };
|
|
2037
2433
|
await this.saveVersion(config, globalParent(slug), 0, draft, guard);
|
|
2038
2434
|
await this.notify(config.hooks?.afterChange, "afterChange", slug, {
|
|
@@ -2146,16 +2542,19 @@ var EasyCMS = class {
|
|
|
2146
2542
|
if (where === null) return [];
|
|
2147
2543
|
const found = await this.db.find({
|
|
2148
2544
|
collection: target.slug,
|
|
2149
|
-
where: draftWhere(target, options.draft, where),
|
|
2545
|
+
where: this.whereFor(target, draftWhere(target, options.draft, where), options),
|
|
2150
2546
|
sort: [],
|
|
2151
2547
|
limit: 0,
|
|
2152
2548
|
page: 1
|
|
2153
2549
|
});
|
|
2154
2550
|
const docs2 = options.draft ? await this.withDrafts(target, found.docs) : found.docs;
|
|
2155
|
-
return Promise.all(
|
|
2551
|
+
return Promise.all(
|
|
2552
|
+
docs2.map((d) => finish(target, this.pick(target, d, options)))
|
|
2553
|
+
);
|
|
2156
2554
|
};
|
|
2157
2555
|
const depth = Math.max(0, Math.min(MAX_DEPTH, Math.trunc(options.depth ?? DEFAULT_DEPTH)));
|
|
2158
|
-
const
|
|
2556
|
+
const localized = docs.map((d) => this.pick(config, d, options));
|
|
2557
|
+
const populated = await populate(load, this.config.collections, config.fields, localized, depth);
|
|
2159
2558
|
return Promise.all(populated.map((d) => finish(config, d)));
|
|
2160
2559
|
}
|
|
2161
2560
|
/** Creates a document: access, input filtering, hooks, validation, then afterChange. */
|
|
@@ -2178,7 +2577,11 @@ var EasyCMS = class {
|
|
|
2178
2577
|
this.fieldChecker("update", guard, void 0, input)
|
|
2179
2578
|
);
|
|
2180
2579
|
const base = this.hookArgs(config, hookGuard ?? guard);
|
|
2181
|
-
let data = applyDefaults(
|
|
2580
|
+
let data = applyDefaults(
|
|
2581
|
+
config.fields,
|
|
2582
|
+
this.toMaps(config, filtered, {}, options),
|
|
2583
|
+
this.config.localization
|
|
2584
|
+
);
|
|
2182
2585
|
data = await this.transform(
|
|
2183
2586
|
config.hooks?.beforeValidate,
|
|
2184
2587
|
"data",
|
|
@@ -2187,9 +2590,10 @@ var EasyCMS = class {
|
|
|
2187
2590
|
);
|
|
2188
2591
|
let prepared = await this.prepare(
|
|
2189
2592
|
config,
|
|
2190
|
-
generateSlugs(config.fields, data),
|
|
2593
|
+
generateSlugs(config.fields, data, this.config.localization),
|
|
2191
2594
|
"create",
|
|
2192
|
-
void 0
|
|
2595
|
+
void 0,
|
|
2596
|
+
options
|
|
2193
2597
|
);
|
|
2194
2598
|
prepared = await this.transform(
|
|
2195
2599
|
config.hooks?.beforeChange,
|
|
@@ -2212,6 +2616,43 @@ var EasyCMS = class {
|
|
|
2212
2616
|
const [out] = await this.output(config, [doc], hookGuard ?? guard, { ...options, draft: true });
|
|
2213
2617
|
return out;
|
|
2214
2618
|
}
|
|
2619
|
+
/** The locale for an operation; `undefined` without localization. Throws on unknown locales. */
|
|
2620
|
+
localeOf(options) {
|
|
2621
|
+
const localization = this.config.localization;
|
|
2622
|
+
if (!localization) return void 0;
|
|
2623
|
+
const locale = options.locale ?? localization.defaultLocale;
|
|
2624
|
+
if (locale !== ALL_LOCALES && !localization.locales.includes(locale)) {
|
|
2625
|
+
throw new QueryError(
|
|
2626
|
+
`Unknown locale "${locale}" (use one of: ${localization.locales.join(", ")}, ${ALL_LOCALES})`
|
|
2627
|
+
);
|
|
2628
|
+
}
|
|
2629
|
+
return locale;
|
|
2630
|
+
}
|
|
2631
|
+
/** Stored localized maps → the values of the requested locale. */
|
|
2632
|
+
pick(config, doc, options) {
|
|
2633
|
+
const localization = this.config.localization;
|
|
2634
|
+
const locale = this.localeOf(options);
|
|
2635
|
+
if (!localization || locale === void 0) return doc;
|
|
2636
|
+
const fallback = options.fallbackLocale ?? localization.fallback;
|
|
2637
|
+
return pickLocale(config.fields, doc, locale, localization, fallback);
|
|
2638
|
+
}
|
|
2639
|
+
/** Input for one locale → stored localized maps, keeping the other locales from `current`. */
|
|
2640
|
+
toMaps(config, input, current, options) {
|
|
2641
|
+
const locale = this.localeOf(options);
|
|
2642
|
+
if (locale === void 0 || locale === ALL_LOCALES) return input;
|
|
2643
|
+
return toLocaleMaps(config.fields, input, current, locale);
|
|
2644
|
+
}
|
|
2645
|
+
/** Makes `where` match localized fields in the operation's locale. */
|
|
2646
|
+
whereFor(config, where, options) {
|
|
2647
|
+
const locale = this.localeOf(options);
|
|
2648
|
+
const localization = this.config.localization;
|
|
2649
|
+
if (locale === void 0 || !localization) return where;
|
|
2650
|
+
return localizeWhere(
|
|
2651
|
+
config.fields,
|
|
2652
|
+
where,
|
|
2653
|
+
locale === ALL_LOCALES ? localization.defaultLocale : locale
|
|
2654
|
+
);
|
|
2655
|
+
}
|
|
2215
2656
|
/** Drafts are kept as versions (instead of unpublishing) when both are enabled. */
|
|
2216
2657
|
separateDrafts(config) {
|
|
2217
2658
|
return config.drafts === true && versionLimit(config) !== void 0;
|
|
@@ -2302,12 +2743,15 @@ var EasyCMS = class {
|
|
|
2302
2743
|
return result;
|
|
2303
2744
|
}
|
|
2304
2745
|
/** Validates, checks uniqueness and references. Returns clean data or throws `ValidationError`. */
|
|
2305
|
-
async prepare(config, data, operation, selfId) {
|
|
2746
|
+
async prepare(config, data, operation, selfId, options = {}) {
|
|
2306
2747
|
const isDraft = config.drafts === true && (data.status ?? "draft") === "draft";
|
|
2748
|
+
const locale = this.localeOf(options);
|
|
2307
2749
|
const result = await validateFields(config.fields, data, {
|
|
2308
2750
|
operation,
|
|
2309
2751
|
root: data,
|
|
2310
|
-
skipRequired: isDraft
|
|
2752
|
+
skipRequired: isDraft,
|
|
2753
|
+
localization: this.config.localization,
|
|
2754
|
+
...locale !== void 0 && locale !== ALL_LOCALES ? { locale } : {}
|
|
2311
2755
|
});
|
|
2312
2756
|
const errors = [...result.errors];
|
|
2313
2757
|
const clean = { ...result.data };
|
|
@@ -2349,14 +2793,26 @@ var EasyCMS = class {
|
|
|
2349
2793
|
}
|
|
2350
2794
|
}
|
|
2351
2795
|
async makeSlugsUnique(config, data, selfId) {
|
|
2352
|
-
|
|
2353
|
-
const base = data[field.name];
|
|
2354
|
-
if (field.type !== "slug" || typeof base !== "string" || base === "") continue;
|
|
2796
|
+
const unique = async (path, base) => {
|
|
2355
2797
|
let candidate = base;
|
|
2356
|
-
for (let n = 2; await this.isTaken(config.slug,
|
|
2798
|
+
for (let n = 2; await this.isTaken(config.slug, path, candidate, selfId); n++) {
|
|
2357
2799
|
candidate = `${base}-${n}`;
|
|
2358
2800
|
}
|
|
2359
|
-
|
|
2801
|
+
return candidate;
|
|
2802
|
+
};
|
|
2803
|
+
for (const field of config.fields) {
|
|
2804
|
+
if (field.type !== "slug") continue;
|
|
2805
|
+
const value = data[field.name];
|
|
2806
|
+
if (field.localized && value && typeof value === "object") {
|
|
2807
|
+
const map = { ...value };
|
|
2808
|
+
for (const [locale, slug] of Object.entries(map)) {
|
|
2809
|
+
if (typeof slug === "string" && slug !== "")
|
|
2810
|
+
map[locale] = await unique(`${field.name}.${locale}`, slug);
|
|
2811
|
+
}
|
|
2812
|
+
data[field.name] = map;
|
|
2813
|
+
} else if (typeof value === "string" && value !== "") {
|
|
2814
|
+
data[field.name] = await unique(field.name, value);
|
|
2815
|
+
}
|
|
2360
2816
|
}
|
|
2361
2817
|
}
|
|
2362
2818
|
async checkUnique(config, data, selfId) {
|
|
@@ -2364,8 +2820,16 @@ var EasyCMS = class {
|
|
|
2364
2820
|
for (const field of config.fields) {
|
|
2365
2821
|
const value = data[field.name];
|
|
2366
2822
|
if (!field.unique || field.type === "slug" || value === null || value === void 0) continue;
|
|
2367
|
-
|
|
2368
|
-
|
|
2823
|
+
const checks = field.localized && typeof value === "object" ? Object.entries(value).map(([locale, v]) => [
|
|
2824
|
+
`${field.name}.${locale}`,
|
|
2825
|
+
locale === this.config.localization?.defaultLocale ? field.name : `${field.name}.${locale}`,
|
|
2826
|
+
v
|
|
2827
|
+
]) : [[field.name, field.name, value]];
|
|
2828
|
+
for (const [path, errorField, v] of checks) {
|
|
2829
|
+
if (v === null || v === void 0) continue;
|
|
2830
|
+
if (await this.isTaken(config.slug, path, v, selfId)) {
|
|
2831
|
+
errors.push({ field: errorField, message: "must be unique" });
|
|
2832
|
+
}
|
|
2369
2833
|
}
|
|
2370
2834
|
}
|
|
2371
2835
|
return errors;
|
|
@@ -2445,7 +2909,7 @@ function asObject(data, name) {
|
|
|
2445
2909
|
async function allowed(access, user) {
|
|
2446
2910
|
return await evaluateAccess(access, { user }) !== false;
|
|
2447
2911
|
}
|
|
2448
|
-
async function serializeFields(fields, update) {
|
|
2912
|
+
async function serializeFields(fields, update, localized) {
|
|
2449
2913
|
const out = [];
|
|
2450
2914
|
for (const field of fields) {
|
|
2451
2915
|
if (field.hidden) continue;
|
|
@@ -2455,6 +2919,7 @@ async function serializeFields(fields, update) {
|
|
|
2455
2919
|
if (field.unique) f.unique = true;
|
|
2456
2920
|
if (field.defaultValue !== void 0) f.defaultValue = field.defaultValue;
|
|
2457
2921
|
if (!await update.allows(field)) f.readOnly = true;
|
|
2922
|
+
if (field.localized && localized) f.localized = true;
|
|
2458
2923
|
switch (field.type) {
|
|
2459
2924
|
case "text":
|
|
2460
2925
|
case "textarea":
|
|
@@ -2484,22 +2949,27 @@ async function serializeFields(fields, update) {
|
|
|
2484
2949
|
case "array":
|
|
2485
2950
|
if (field.minRows !== void 0) f.minRows = field.minRows;
|
|
2486
2951
|
if (field.maxRows !== void 0) f.maxRows = field.maxRows;
|
|
2487
|
-
f.fields = await serializeFields(field.fields, update);
|
|
2952
|
+
f.fields = await serializeFields(field.fields, update, localized);
|
|
2488
2953
|
break;
|
|
2489
2954
|
case "group":
|
|
2490
|
-
f.fields = await serializeFields(field.fields, update);
|
|
2955
|
+
f.fields = await serializeFields(field.fields, update, localized);
|
|
2491
2956
|
break;
|
|
2492
2957
|
}
|
|
2493
2958
|
out.push(f);
|
|
2494
2959
|
}
|
|
2495
2960
|
return out;
|
|
2496
2961
|
}
|
|
2497
|
-
async function collection(config, user) {
|
|
2962
|
+
async function collection(config, user, localized) {
|
|
2498
2963
|
const result = {
|
|
2499
2964
|
slug: config.slug,
|
|
2500
2965
|
drafts: config.drafts === true,
|
|
2501
2966
|
versions: Boolean(config.versions),
|
|
2502
|
-
|
|
2967
|
+
preview: typeof config.preview === "function",
|
|
2968
|
+
fields: await serializeFields(
|
|
2969
|
+
config.fields,
|
|
2970
|
+
new FieldAccessChecker("update", { user }),
|
|
2971
|
+
localized
|
|
2972
|
+
),
|
|
2503
2973
|
permissions: {
|
|
2504
2974
|
read: await allowed(config.access?.read, user),
|
|
2505
2975
|
create: await allowed(config.access?.create, user),
|
|
@@ -2511,12 +2981,17 @@ async function collection(config, user) {
|
|
|
2511
2981
|
if (config.useAsTitle) result.useAsTitle = config.useAsTitle;
|
|
2512
2982
|
return result;
|
|
2513
2983
|
}
|
|
2514
|
-
async function global(config, user) {
|
|
2984
|
+
async function global(config, user, localized) {
|
|
2515
2985
|
const result = {
|
|
2516
2986
|
slug: config.slug,
|
|
2517
2987
|
drafts: config.drafts === true,
|
|
2518
2988
|
versions: Boolean(config.versions),
|
|
2519
|
-
|
|
2989
|
+
preview: typeof config.preview === "function",
|
|
2990
|
+
fields: await serializeFields(
|
|
2991
|
+
config.fields,
|
|
2992
|
+
new FieldAccessChecker("update", { user }),
|
|
2993
|
+
localized
|
|
2994
|
+
),
|
|
2520
2995
|
permissions: {
|
|
2521
2996
|
read: await allowed(config.access?.read, user),
|
|
2522
2997
|
update: await allowed(config.access?.update, user)
|
|
@@ -2527,10 +3002,13 @@ async function global(config, user) {
|
|
|
2527
3002
|
}
|
|
2528
3003
|
async function adminSchema(cms, user) {
|
|
2529
3004
|
const collections = cms.config.collections.filter((c) => !INTERNAL_COLLECTIONS.has(c.slug));
|
|
3005
|
+
const localization = cms.config.localization;
|
|
3006
|
+
const localized = localization !== null;
|
|
2530
3007
|
return {
|
|
2531
3008
|
locale: cms.config.admin.locale,
|
|
2532
|
-
|
|
2533
|
-
|
|
3009
|
+
localization: localization ? { locales: [...localization.locales], defaultLocale: localization.defaultLocale } : null,
|
|
3010
|
+
collections: await Promise.all(collections.map((c) => collection(c, user, localized))),
|
|
3011
|
+
globals: await Promise.all(cms.config.globals.map((g) => global(g, user, localized)))
|
|
2534
3012
|
};
|
|
2535
3013
|
}
|
|
2536
3014
|
|
|
@@ -2701,7 +3179,7 @@ function withHeaders(response, extra) {
|
|
|
2701
3179
|
}
|
|
2702
3180
|
async function route(cms, ctx, method, segments, options) {
|
|
2703
3181
|
const [first, second, third] = segments;
|
|
2704
|
-
const access = { overrideAccess: false, user: ctx.user };
|
|
3182
|
+
const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
|
|
2705
3183
|
const draft = ctx.user !== null && ctx.url.searchParams.get("draft") === "true";
|
|
2706
3184
|
if (segments.length === 0) throw new HttpError("Not found", 404);
|
|
2707
3185
|
if (first === USERS && second !== void 0 && third === void 0 && !/^\d+$/.test(second)) {
|
|
@@ -2766,8 +3244,19 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
2766
3244
|
if (!cms.config.globals.some((g) => g.slug === second))
|
|
2767
3245
|
throw new HttpError(`Unknown global "${second}"`, 404);
|
|
2768
3246
|
if (third !== void 0) return globalAction(cms, ctx, method, second, segments.slice(2));
|
|
2769
|
-
if (method === "GET")
|
|
3247
|
+
if (method === "GET") {
|
|
3248
|
+
if (ctx.url.searchParams.has("preview")) {
|
|
3249
|
+
requirePreview(cms, ctx, { global: second });
|
|
3250
|
+
return {
|
|
3251
|
+
body: await cms.findGlobal(second, {
|
|
3252
|
+
...parseDepth(ctx.url),
|
|
3253
|
+
...parseLocale(ctx.url),
|
|
3254
|
+
draft: true
|
|
3255
|
+
})
|
|
3256
|
+
};
|
|
3257
|
+
}
|
|
2770
3258
|
return { body: await cms.findGlobal(second, { ...access, ...parseDepth(ctx.url), draft }) };
|
|
3259
|
+
}
|
|
2771
3260
|
if (method === "POST") {
|
|
2772
3261
|
const body = await readJson(ctx.request);
|
|
2773
3262
|
return { body: await cms.updateGlobal(second, body, { ...access, ...parseDepth(ctx.url) }) };
|
|
@@ -2792,6 +3281,13 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
2792
3281
|
if (second !== void 0 && third !== void 0) {
|
|
2793
3282
|
return documentAction(cms, ctx, method, collection2, second, segments.slice(2));
|
|
2794
3283
|
}
|
|
3284
|
+
if (second === "preview") {
|
|
3285
|
+
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
3286
|
+
const body = await readJson(ctx.request);
|
|
3287
|
+
return {
|
|
3288
|
+
body: await cms.preview(collection2, null, body, { ...access, ...parseDepth(ctx.url) })
|
|
3289
|
+
};
|
|
3290
|
+
}
|
|
2795
3291
|
if (second === void 0) {
|
|
2796
3292
|
if (method === "GET") {
|
|
2797
3293
|
const query = parseListQuery(ctx.url);
|
|
@@ -2807,6 +3303,16 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
2807
3303
|
throw methodNotAllowed(ctx, "GET, POST");
|
|
2808
3304
|
}
|
|
2809
3305
|
const id = second;
|
|
3306
|
+
if (method === "GET" && ctx.url.searchParams.has("preview")) {
|
|
3307
|
+
requirePreview(cms, ctx, { collection: collection2, id });
|
|
3308
|
+
const doc = await cms.findById(collection2, id, {
|
|
3309
|
+
...parseDepth(ctx.url),
|
|
3310
|
+
...parseLocale(ctx.url),
|
|
3311
|
+
draft: true
|
|
3312
|
+
});
|
|
3313
|
+
if (!doc) throw new NotFoundError(collection2, id);
|
|
3314
|
+
return { body: doc };
|
|
3315
|
+
}
|
|
2810
3316
|
if (method === "GET") {
|
|
2811
3317
|
const doc = await cms.findById(collection2, id, { ...access, ...parseDepth(ctx.url), draft });
|
|
2812
3318
|
if (!doc) throw new NotFoundError(collection2, id);
|
|
@@ -2826,7 +3332,7 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
2826
3332
|
throw methodNotAllowed(ctx, "GET, PATCH, DELETE");
|
|
2827
3333
|
}
|
|
2828
3334
|
async function documentAction(cms, ctx, method, collection2, id, path) {
|
|
2829
|
-
const access = { overrideAccess: false, user: ctx.user };
|
|
3335
|
+
const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
|
|
2830
3336
|
const depth = parseDepth(ctx.url);
|
|
2831
3337
|
const [action, versionId, extra] = path;
|
|
2832
3338
|
if (action === "versions" && versionId === void 0) {
|
|
@@ -2843,6 +3349,13 @@ async function documentAction(cms, ctx, method, collection2, id, path) {
|
|
|
2843
3349
|
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
2844
3350
|
return { body: await cms.restoreVersion(collection2, id, versionId, { ...access, ...depth }) };
|
|
2845
3351
|
}
|
|
3352
|
+
if (path.length === 1 && action === "preview") {
|
|
3353
|
+
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
3354
|
+
const body = await readJson(ctx.request);
|
|
3355
|
+
const result = await cms.preview(collection2, id, body, { ...access, ...depth });
|
|
3356
|
+
const token = cms.createPreviewToken({ collection: collection2, id });
|
|
3357
|
+
return { body: { ...result, url: result.url && withPreviewToken(result.url, token) } };
|
|
3358
|
+
}
|
|
2846
3359
|
if (path.length === 1 && (action === "unpublish" || action === "discard-draft")) {
|
|
2847
3360
|
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
2848
3361
|
const options = { ...access, ...depth };
|
|
@@ -2853,7 +3366,7 @@ async function documentAction(cms, ctx, method, collection2, id, path) {
|
|
|
2853
3366
|
throw new HttpError("Not found", 404);
|
|
2854
3367
|
}
|
|
2855
3368
|
async function globalAction(cms, ctx, method, slug, path) {
|
|
2856
|
-
const access = { overrideAccess: false, user: ctx.user };
|
|
3369
|
+
const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
|
|
2857
3370
|
const depth = parseDepth(ctx.url);
|
|
2858
3371
|
const [action, versionId, extra] = path;
|
|
2859
3372
|
if (action === "versions" && versionId === void 0) {
|
|
@@ -2870,6 +3383,13 @@ async function globalAction(cms, ctx, method, slug, path) {
|
|
|
2870
3383
|
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
2871
3384
|
return { body: await cms.restoreGlobalVersion(slug, versionId, { ...access, ...depth }) };
|
|
2872
3385
|
}
|
|
3386
|
+
if (path.length === 1 && action === "preview") {
|
|
3387
|
+
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
3388
|
+
const body = await readJson(ctx.request);
|
|
3389
|
+
const result = await cms.previewGlobal(slug, body, { ...access, ...depth });
|
|
3390
|
+
const token = cms.createPreviewToken({ global: slug });
|
|
3391
|
+
return { body: { ...result, url: result.url && withPreviewToken(result.url, token) } };
|
|
3392
|
+
}
|
|
2873
3393
|
if (path.length === 1 && (action === "unpublish" || action === "discard-draft")) {
|
|
2874
3394
|
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
2875
3395
|
const options = { ...access, ...depth };
|
|
@@ -2879,6 +3399,26 @@ async function globalAction(cms, ctx, method, slug, path) {
|
|
|
2879
3399
|
}
|
|
2880
3400
|
throw new HttpError("Not found", 404);
|
|
2881
3401
|
}
|
|
3402
|
+
function parseLocale(url) {
|
|
3403
|
+
const locale = url.searchParams.get("locale");
|
|
3404
|
+
const fallback = url.searchParams.get("fallback-locale");
|
|
3405
|
+
return {
|
|
3406
|
+
...locale ? { locale } : {},
|
|
3407
|
+
...fallback === "false" || fallback === "true" ? { fallbackLocale: fallback === "true" } : {}
|
|
3408
|
+
};
|
|
3409
|
+
}
|
|
3410
|
+
var PREVIEW_PARAM = "easy-cms-preview";
|
|
3411
|
+
function withPreviewToken(url, token) {
|
|
3412
|
+
const hash = url.indexOf("#");
|
|
3413
|
+
const [base, fragment] = hash === -1 ? [url, ""] : [url.slice(0, hash), url.slice(hash)];
|
|
3414
|
+
const separator = base.includes("?") ? "&" : "?";
|
|
3415
|
+
return `${base}${separator}${PREVIEW_PARAM}=${encodeURIComponent(token)}${fragment}`;
|
|
3416
|
+
}
|
|
3417
|
+
function requirePreview(cms, ctx, target) {
|
|
3418
|
+
const opened = cms.verifyPreviewToken(ctx.url.searchParams.get("preview"));
|
|
3419
|
+
const matches = opened !== null && ("global" in target ? "global" in opened && opened.global === target.global : "collection" in opened && opened.collection === target.collection && opened.id === target.id);
|
|
3420
|
+
if (!matches) throw new UnauthorizedError("Invalid or expired preview token");
|
|
3421
|
+
}
|
|
2882
3422
|
function parsePage(url) {
|
|
2883
3423
|
const { page, limit } = parseListQuery(url);
|
|
2884
3424
|
return { page, limit };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Sent by the admin to the preview frame whenever the form changes. */
|
|
2
|
+
interface LivePreviewMessage<T = Record<string, unknown>> {
|
|
3
|
+
readonly type: 'easy-cms:preview';
|
|
4
|
+
/** Collection slug, or `undefined` for a global. */
|
|
5
|
+
readonly collection?: string;
|
|
6
|
+
/** Global slug, or `undefined` for a collection. */
|
|
7
|
+
readonly global?: string;
|
|
8
|
+
/** The document as it would be read after saving, relationships populated. */
|
|
9
|
+
readonly doc: T;
|
|
10
|
+
}
|
|
11
|
+
/** Sent by the page to the admin when it is ready, so the admin sends the current state. */
|
|
12
|
+
declare const READY_MESSAGE = "easy-cms:preview-ready";
|
|
13
|
+
interface LivePreviewOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Origin(s) of the admin allowed to send updates. Default: the page's own origin, which fits
|
|
16
|
+
* the Nuxt and Next.js adapters (admin and site share it). Set it for a standalone server,
|
|
17
|
+
* e.g. `'https://cms.example.com'`.
|
|
18
|
+
*/
|
|
19
|
+
readonly origin?: string | readonly string[];
|
|
20
|
+
}
|
|
21
|
+
/** Query parameter with the preview token that the admin adds to preview URLs. */
|
|
22
|
+
declare const PREVIEW_PARAM = "easy-cms-preview";
|
|
23
|
+
/**
|
|
24
|
+
* The preview token from the page URL, or `null`. Pass it on to read the document's draft
|
|
25
|
+
* without a login, e.g. `fetch(\`${api}/posts/${id}?preview=${token}&depth=2\`)`.
|
|
26
|
+
*/
|
|
27
|
+
declare function getPreviewToken(): string | null;
|
|
28
|
+
/** True when the page is shown inside a frame (e.g. the admin's preview). */
|
|
29
|
+
declare function isLivePreview(): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Calls `onChange` with the document each time the admin's form changes. Returns a function
|
|
32
|
+
* that stops listening. Does nothing outside a frame, so it is safe to call on every page.
|
|
33
|
+
*
|
|
34
|
+
* ```ts
|
|
35
|
+
* const stop = subscribeLivePreview<Post>((doc) => render(doc))
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
declare function subscribeLivePreview<T = Record<string, unknown>>(onChange: (doc: T, message: LivePreviewMessage<T>) => void, options?: LivePreviewOptions): () => void;
|
|
39
|
+
|
|
40
|
+
export { type LivePreviewMessage, type LivePreviewOptions, PREVIEW_PARAM, READY_MESSAGE, getPreviewToken, isLivePreview, subscribeLivePreview };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// src/live-preview.ts
|
|
2
|
+
var READY_MESSAGE = "easy-cms:preview-ready";
|
|
3
|
+
var PREVIEW_PARAM = "easy-cms-preview";
|
|
4
|
+
function getPreviewToken() {
|
|
5
|
+
if (typeof window === "undefined") return null;
|
|
6
|
+
return new URLSearchParams(window.location.search).get(PREVIEW_PARAM);
|
|
7
|
+
}
|
|
8
|
+
function isLivePreview() {
|
|
9
|
+
return typeof window !== "undefined" && window.parent !== window;
|
|
10
|
+
}
|
|
11
|
+
function subscribeLivePreview(onChange, options = {}) {
|
|
12
|
+
if (!isLivePreview()) return () => {
|
|
13
|
+
};
|
|
14
|
+
const allowed = [options.origin ?? window.location.origin].flat();
|
|
15
|
+
const listener = (event) => {
|
|
16
|
+
if (!allowed.includes(event.origin)) return;
|
|
17
|
+
const message = event.data;
|
|
18
|
+
if (message?.type !== "easy-cms:preview" || typeof message.doc !== "object") return;
|
|
19
|
+
onChange(message.doc, message);
|
|
20
|
+
};
|
|
21
|
+
window.addEventListener("message", listener);
|
|
22
|
+
window.parent.postMessage({ type: READY_MESSAGE }, "*");
|
|
23
|
+
return () => window.removeEventListener("message", listener);
|
|
24
|
+
}
|
|
25
|
+
export {
|
|
26
|
+
PREVIEW_PARAM,
|
|
27
|
+
READY_MESSAGE,
|
|
28
|
+
getPreviewToken,
|
|
29
|
+
isLivePreview,
|
|
30
|
+
subscribeLivePreview
|
|
31
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@easy-cms/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Embedded, code-first headless CMS for Nuxt and Next.js",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
"types": "./dist/index.d.ts",
|
|
11
11
|
"default": "./dist/index.js"
|
|
12
12
|
},
|
|
13
|
+
"./live-preview": {
|
|
14
|
+
"types": "./dist/live-preview.d.ts",
|
|
15
|
+
"default": "./dist/live-preview.js"
|
|
16
|
+
},
|
|
13
17
|
"./package.json": "./package.json"
|
|
14
18
|
},
|
|
15
19
|
"files": [
|