@easy-cms/core 0.4.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 +80 -5
- package/dist/index.js +499 -70
- package/dist/live-preview.d.ts +8 -1
- package/dist/live-preview.js +7 -0
- package/package.json +1 -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,13 +345,22 @@ 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
|
+
}
|
|
335
356
|
/**
|
|
336
357
|
* Where a document is shown on the site, for live preview in the admin: an absolute URL or a
|
|
337
358
|
* path on the site's origin. Return `null` when the document has no page.
|
|
338
359
|
*/
|
|
339
360
|
type PreviewURL = (args: {
|
|
340
361
|
readonly doc: Record<string, unknown>;
|
|
341
|
-
|
|
362
|
+
/** The content locale being edited (with `localization`), otherwise the admin locale. */
|
|
363
|
+
readonly locale: string;
|
|
342
364
|
}) => string | null | undefined;
|
|
343
365
|
interface VersionsConfig {
|
|
344
366
|
/** Versions kept per document; older ones are deleted. Default 50. */
|
|
@@ -442,6 +464,8 @@ interface Config {
|
|
|
442
464
|
* `auth.trustedOrigins` are always allowed, with cookies. Default: none.
|
|
443
465
|
*/
|
|
444
466
|
readonly cors?: readonly string[] | '*';
|
|
467
|
+
/** Content in several languages: fields with `localized: true` hold one value per locale. */
|
|
468
|
+
readonly localization?: LocalizationConfig | null;
|
|
445
469
|
readonly routes?: RoutesConfig;
|
|
446
470
|
readonly admin?: AdminConfig;
|
|
447
471
|
readonly upload?: UploadConfig;
|
|
@@ -455,8 +479,10 @@ interface Config {
|
|
|
455
479
|
readonly plugins?: readonly Plugin[];
|
|
456
480
|
}
|
|
457
481
|
/** The config after plugins ran, validation passed and defaults were applied. */
|
|
458
|
-
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'> {
|
|
459
483
|
readonly cors: readonly string[] | '*';
|
|
484
|
+
/** `null` without localization. */
|
|
485
|
+
readonly localization: Required<LocalizationConfig> | null;
|
|
460
486
|
readonly routes: Required<RoutesConfig>;
|
|
461
487
|
readonly admin: Required<AdminConfig>;
|
|
462
488
|
readonly upload: Required<Omit<UploadConfig, 'storage'>> & Pick<UploadConfig, 'storage'>;
|
|
@@ -708,6 +734,11 @@ interface AccessOptions {
|
|
|
708
734
|
interface DepthOptions extends AccessOptions {
|
|
709
735
|
/** How many levels of relationships to populate. Default 1, maximum 3. */
|
|
710
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;
|
|
711
742
|
}
|
|
712
743
|
interface ReadOptions extends DepthOptions {
|
|
713
744
|
/**
|
|
@@ -715,6 +746,8 @@ interface ReadOptions extends DepthOptions {
|
|
|
715
746
|
* `drafts: true` are returned, including when populating relationships (FR-DRF-04).
|
|
716
747
|
*/
|
|
717
748
|
readonly draft?: boolean;
|
|
749
|
+
/** Use the default locale's value when a localized value is empty. Default: `localization.fallback`. */
|
|
750
|
+
readonly fallbackLocale?: boolean;
|
|
718
751
|
}
|
|
719
752
|
interface FindOptions extends ReadOptions {
|
|
720
753
|
readonly where?: Where;
|
|
@@ -750,6 +783,7 @@ declare class EasyCMS<C extends Config = Config> {
|
|
|
750
783
|
count<S extends Slug<C>>(collection: S, options?: {
|
|
751
784
|
where?: Where;
|
|
752
785
|
draft?: boolean;
|
|
786
|
+
locale?: string;
|
|
753
787
|
} & AccessOptions): Promise<number>;
|
|
754
788
|
create<S extends Slug<C>>(collection: S, data: Create<C, S>, options?: DepthOptions): Promise<Doc<C, S>>;
|
|
755
789
|
/**
|
|
@@ -789,6 +823,23 @@ declare class EasyCMS<C extends Config = Config> {
|
|
|
789
823
|
* `afterRead` hooks applied. Nothing is written. Also returns the page URL from `preview`.
|
|
790
824
|
*/
|
|
791
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;
|
|
792
843
|
/** Live preview of a global. See `preview`. */
|
|
793
844
|
previewGlobal<S extends GSlug<C>>(slug: S, data: Record<string, unknown>, options?: DepthOptions): Promise<LivePreview<GDoc<C, S>>>;
|
|
794
845
|
/** Coerces preview input like a save would, keeping values that would fail validation. */
|
|
@@ -837,6 +888,14 @@ declare class EasyCMS<C extends Config = Config> {
|
|
|
837
888
|
private output;
|
|
838
889
|
/** Creates a document: access, input filtering, hooks, validation, then afterChange. */
|
|
839
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;
|
|
840
899
|
/** Drafts are kept as versions (instead of unpublishing) when both are enabled. */
|
|
841
900
|
private separateDrafts;
|
|
842
901
|
/** Replaces published documents that have a newer draft with that draft. */
|
|
@@ -980,6 +1039,8 @@ declare class PayloadTooLargeError extends EasyCMSError {
|
|
|
980
1039
|
constructor(message: string);
|
|
981
1040
|
}
|
|
982
1041
|
|
|
1042
|
+
type Localization = Required<LocalizationConfig>;
|
|
1043
|
+
|
|
983
1044
|
type Data = Record<string, unknown>;
|
|
984
1045
|
/**
|
|
985
1046
|
* Turns text into a URL slug. Keeps letters and digits of any script,
|
|
@@ -987,11 +1048,11 @@ type Data = Record<string, unknown>;
|
|
|
987
1048
|
*/
|
|
988
1049
|
declare function slugify(text: string): string;
|
|
989
1050
|
/** Fills in `defaultValue` for fields that are missing, including inside groups and array rows. */
|
|
990
|
-
declare function applyDefaults(fields: readonly Field[], data: Data): Data;
|
|
1051
|
+
declare function applyDefaults(fields: readonly Field[], data: Data, localization?: Localization | null): Data;
|
|
991
1052
|
/** Gives every field a value (`null`, `[]` or a nested object) so documents always have the same shape. */
|
|
992
1053
|
declare function fillMissing(fields: readonly Field[], data: Data): Data;
|
|
993
1054
|
/** Fills empty `slug` fields from their `from` field. Does not check uniqueness. */
|
|
994
|
-
declare function generateSlugs(fields: readonly Field[], data: Data): Data;
|
|
1055
|
+
declare function generateSlugs(fields: readonly Field[], data: Data, localization?: Localization | null): Data;
|
|
995
1056
|
/**
|
|
996
1057
|
* Merges an update into the stored document. Top-level fields and group
|
|
997
1058
|
* fields merge key by key; arrays and hasMany values are replaced.
|
|
@@ -1011,6 +1072,13 @@ interface ValidateOptions {
|
|
|
1011
1072
|
readonly skipRequired?: boolean;
|
|
1012
1073
|
/** The whole top-level document, passed to custom `validate` functions. */
|
|
1013
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;
|
|
1014
1082
|
}
|
|
1015
1083
|
interface ValidateResult {
|
|
1016
1084
|
/** Only known fields, with values normalized (ids parsed, dates as ISO strings, row ids filled). */
|
|
@@ -1095,6 +1163,8 @@ interface AdminField {
|
|
|
1095
1163
|
fields?: AdminField[];
|
|
1096
1164
|
/** The current user may not change this field. */
|
|
1097
1165
|
readOnly?: boolean;
|
|
1166
|
+
/** One value per content locale. */
|
|
1167
|
+
localized?: boolean;
|
|
1098
1168
|
}
|
|
1099
1169
|
interface AdminCollection {
|
|
1100
1170
|
slug: string;
|
|
@@ -1130,6 +1200,11 @@ interface AdminGlobal {
|
|
|
1130
1200
|
}
|
|
1131
1201
|
interface AdminSchema {
|
|
1132
1202
|
locale: AdminLocale;
|
|
1203
|
+
/** Content locales, when the config has `localization`. */
|
|
1204
|
+
localization: {
|
|
1205
|
+
locales: string[];
|
|
1206
|
+
defaultLocale: string;
|
|
1207
|
+
} | null;
|
|
1133
1208
|
collections: AdminCollection[];
|
|
1134
1209
|
globals: AdminGlobal[];
|
|
1135
1210
|
}
|
|
@@ -1162,4 +1237,4 @@ declare const BUILTIN_COLLECTIONS: readonly ["users", "media"];
|
|
|
1162
1237
|
/** Checks a config and returns every problem found. An empty array means it is valid. */
|
|
1163
1238
|
declare function validateConfig(config: Config): ConfigIssue[];
|
|
1164
1239
|
|
|
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 };
|
|
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)) {
|
|
@@ -1036,6 +1191,7 @@ function validateContainer(container, path, collectionSlugs, add) {
|
|
|
1036
1191
|
add(
|
|
1037
1192
|
`${path}.preview`,
|
|
1038
1193
|
"must be a function ({ doc }) => url",
|
|
1194
|
+
// biome-ignore lint/suspicious/noTemplateCurlyInString: an example shown to the user
|
|
1039
1195
|
"preview: ({ doc }) => `/posts/${doc.slug}`"
|
|
1040
1196
|
);
|
|
1041
1197
|
}
|
|
@@ -1179,6 +1335,11 @@ async function resolveConfig(input) {
|
|
|
1179
1335
|
const result = {
|
|
1180
1336
|
...rest,
|
|
1181
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,
|
|
1182
1343
|
routes: { api: `/${(config.routes?.api ?? DEFAULT_API_PATH).replace(/^\/+|\/+$/g, "")}` },
|
|
1183
1344
|
admin: {
|
|
1184
1345
|
path: config.admin?.path ?? DEFAULT_ADMIN_PATH,
|
|
@@ -1260,6 +1421,89 @@ async function loadConfig(options = {}) {
|
|
|
1260
1421
|
// src/local-api.ts
|
|
1261
1422
|
import { randomBytes as randomBytes3 } from "crypto";
|
|
1262
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
|
+
|
|
1263
1507
|
// src/logger.ts
|
|
1264
1508
|
var consoleLogger = {
|
|
1265
1509
|
info: (message) => console.info(`[easy-cms] ${message}`),
|
|
@@ -1645,12 +1889,18 @@ var EasyCMS = class {
|
|
|
1645
1889
|
if (!Number.isInteger(limit) || limit < 0)
|
|
1646
1890
|
throw new QueryError("limit must be a non-negative integer");
|
|
1647
1891
|
if (!Number.isInteger(page) || page < 1) throw new QueryError("page must be a positive integer");
|
|
1648
|
-
const where =
|
|
1892
|
+
const where = this.whereFor(
|
|
1649
1893
|
config,
|
|
1650
|
-
options.draft,
|
|
1651
|
-
|
|
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
|
|
1652
1903
|
);
|
|
1653
|
-
const sort = options.sort === void 0 ? ["-createdAt"] : [options.sort].flat();
|
|
1654
1904
|
const result = await this.db.find({ collection: collection2, where, sort, limit, page });
|
|
1655
1905
|
const found = options.draft ? await this.withDrafts(config, result.docs) : result.docs;
|
|
1656
1906
|
const docs = await this.output(config, found, guard, options);
|
|
@@ -1661,7 +1911,11 @@ var EasyCMS = class {
|
|
|
1661
1911
|
const guard = guardOf(options);
|
|
1662
1912
|
const parsed = parseId(id);
|
|
1663
1913
|
if (parsed === void 0) return null;
|
|
1664
|
-
const where =
|
|
1914
|
+
const where = this.whereFor(
|
|
1915
|
+
config,
|
|
1916
|
+
draftWhere(config, options.draft, await this.readWhere(config, guard, void 0)),
|
|
1917
|
+
options
|
|
1918
|
+
);
|
|
1665
1919
|
const doc = where ? (await this.db.find({
|
|
1666
1920
|
collection: collection2,
|
|
1667
1921
|
where: andWhere(where, { id: { equals: parsed } }),
|
|
@@ -1676,10 +1930,14 @@ var EasyCMS = class {
|
|
|
1676
1930
|
}
|
|
1677
1931
|
async count(collection2, options = {}) {
|
|
1678
1932
|
const config = this.collection(collection2);
|
|
1679
|
-
const where =
|
|
1933
|
+
const where = this.whereFor(
|
|
1680
1934
|
config,
|
|
1681
|
-
|
|
1682
|
-
|
|
1935
|
+
draftWhere(
|
|
1936
|
+
config,
|
|
1937
|
+
options.draft,
|
|
1938
|
+
await this.readWhere(config, guardOf(options), options.where)
|
|
1939
|
+
),
|
|
1940
|
+
options
|
|
1683
1941
|
);
|
|
1684
1942
|
return this.db.count({ collection: collection2, where });
|
|
1685
1943
|
}
|
|
@@ -1829,7 +2087,8 @@ var EasyCMS = class {
|
|
|
1829
2087
|
collection2,
|
|
1830
2088
|
parsed,
|
|
1831
2089
|
config.drafts ? { ...data, status: "draft" } : data,
|
|
1832
|
-
options
|
|
2090
|
+
options,
|
|
2091
|
+
"restore"
|
|
1833
2092
|
);
|
|
1834
2093
|
}
|
|
1835
2094
|
/**
|
|
@@ -1861,14 +2120,34 @@ var EasyCMS = class {
|
|
|
1861
2120
|
input,
|
|
1862
2121
|
this.fieldChecker("update", guard, parsed, input)
|
|
1863
2122
|
);
|
|
1864
|
-
const
|
|
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);
|
|
1865
2125
|
if (config.drafts) merged.status = filtered.status ?? base?.status ?? "draft";
|
|
1866
|
-
const doc = await this.previewDoc(
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
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
|
+
},
|
|
2133
|
+
options
|
|
2134
|
+
);
|
|
1870
2135
|
const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
|
|
1871
|
-
return { doc: out, url: this.previewURL(config, out) };
|
|
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;
|
|
1872
2151
|
}
|
|
1873
2152
|
/** Live preview of a global. See `preview`. */
|
|
1874
2153
|
async previewGlobal(slug, data, options = {}) {
|
|
@@ -1885,20 +2164,28 @@ var EasyCMS = class {
|
|
|
1885
2164
|
const current = await this.globalDraft(config, saved);
|
|
1886
2165
|
const merged = generateSlugs(
|
|
1887
2166
|
config.fields,
|
|
1888
|
-
applyDefaults(
|
|
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
|
|
1889
2173
|
);
|
|
1890
2174
|
if (config.drafts) merged.status = input.status ?? current.status ?? "draft";
|
|
1891
|
-
const doc = await this.previewDoc(config, merged, { id: 0 });
|
|
2175
|
+
const doc = await this.previewDoc(config, merged, { id: 0 }, options);
|
|
1892
2176
|
const [out] = await this.output(config, [doc], guard, { ...options, draft: true });
|
|
1893
2177
|
const { id: _id, ...global2 } = out;
|
|
1894
|
-
return { doc: global2, url: this.previewURL(config, global2) };
|
|
2178
|
+
return { doc: global2, url: this.previewURL(config, global2, options) };
|
|
1895
2179
|
}
|
|
1896
2180
|
/** Coerces preview input like a save would, keeping values that would fail validation. */
|
|
1897
|
-
async previewDoc(config, data, system) {
|
|
2181
|
+
async previewDoc(config, data, system, options = {}) {
|
|
2182
|
+
const locale = this.localeOf(options);
|
|
1898
2183
|
const { data: clean } = await validateFields(config.fields, data, {
|
|
1899
2184
|
operation: "update",
|
|
1900
2185
|
root: data,
|
|
1901
|
-
skipRequired: true
|
|
2186
|
+
skipRequired: true,
|
|
2187
|
+
localization: this.config.localization,
|
|
2188
|
+
...locale !== void 0 && locale !== ALL_LOCALES ? { locale } : {}
|
|
1902
2189
|
});
|
|
1903
2190
|
return {
|
|
1904
2191
|
...data,
|
|
@@ -1908,10 +2195,12 @@ var EasyCMS = class {
|
|
|
1908
2195
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1909
2196
|
};
|
|
1910
2197
|
}
|
|
1911
|
-
previewURL(config, doc) {
|
|
2198
|
+
previewURL(config, doc, options) {
|
|
1912
2199
|
if (!config.preview) return null;
|
|
2200
|
+
const content = this.localeOf(options);
|
|
2201
|
+
const locale = content && content !== ALL_LOCALES ? content : this.config.admin.locale;
|
|
1913
2202
|
try {
|
|
1914
|
-
return config.preview({ doc, locale
|
|
2203
|
+
return config.preview({ doc, locale }) ?? null;
|
|
1915
2204
|
} catch (error) {
|
|
1916
2205
|
this.logger.error(`preview URL of "${config.slug}" failed: ${error.message}`);
|
|
1917
2206
|
return null;
|
|
@@ -1940,7 +2229,8 @@ var EasyCMS = class {
|
|
|
1940
2229
|
input,
|
|
1941
2230
|
this.fieldChecker("update", guard, parsed, input)
|
|
1942
2231
|
);
|
|
1943
|
-
|
|
2232
|
+
const localized = mode === "restore" ? filtered : this.toMaps(config, filtered, current, options);
|
|
2233
|
+
let merged = mergeForUpdate(config.fields, current, localized);
|
|
1944
2234
|
if (config.drafts)
|
|
1945
2235
|
merged.status = Object.hasOwn(filtered, "status") ? filtered.status : current.status;
|
|
1946
2236
|
const base = this.hookArgs(config, guard);
|
|
@@ -1952,9 +2242,10 @@ var EasyCMS = class {
|
|
|
1952
2242
|
);
|
|
1953
2243
|
let prepared = await this.prepare(
|
|
1954
2244
|
config,
|
|
1955
|
-
generateSlugs(config.fields, merged),
|
|
2245
|
+
generateSlugs(config.fields, merged, this.config.localization),
|
|
1956
2246
|
"update",
|
|
1957
|
-
parsed
|
|
2247
|
+
parsed,
|
|
2248
|
+
options
|
|
1958
2249
|
);
|
|
1959
2250
|
prepared = await this.transform(
|
|
1960
2251
|
config.hooks?.beforeChange,
|
|
@@ -1963,7 +2254,7 @@ var EasyCMS = class {
|
|
|
1963
2254
|
prepared
|
|
1964
2255
|
);
|
|
1965
2256
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1966
|
-
if (mode
|
|
2257
|
+
if (mode !== "unpublish" && this.separateDrafts(config) && existing.status === "published" && prepared.status === "draft") {
|
|
1967
2258
|
if (password !== void 0) {
|
|
1968
2259
|
throw new ValidationError(collection2, [
|
|
1969
2260
|
{ field: "password", message: "cannot be changed in a draft; publish instead" }
|
|
@@ -2029,7 +2320,10 @@ var EasyCMS = class {
|
|
|
2029
2320
|
await this.checkGlobalAccess(config, "read", guard);
|
|
2030
2321
|
const saved = await this.db.findGlobal({ slug });
|
|
2031
2322
|
const stored = saved && options.draft ? await this.globalDraft(config, saved) : saved;
|
|
2032
|
-
const data = fillMissing(
|
|
2323
|
+
const data = fillMissing(
|
|
2324
|
+
config.fields,
|
|
2325
|
+
applyDefaults(config.fields, stored ?? {}, this.config.localization)
|
|
2326
|
+
);
|
|
2033
2327
|
if (!stored) {
|
|
2034
2328
|
data.updatedAt = null;
|
|
2035
2329
|
if (config.drafts) data.status = "draft";
|
|
@@ -2066,7 +2360,10 @@ var EasyCMS = class {
|
|
|
2066
2360
|
const config = await this.globalVersionTarget(slug, options);
|
|
2067
2361
|
const version = await this.versions.get(globalParent(slug), 0, versionId);
|
|
2068
2362
|
if (!version) return null;
|
|
2069
|
-
const data = fillMissing(
|
|
2363
|
+
const data = fillMissing(
|
|
2364
|
+
config.fields,
|
|
2365
|
+
applyDefaults(config.fields, version.data, this.config.localization)
|
|
2366
|
+
);
|
|
2070
2367
|
const [out] = await this.output(config, [{ ...data, id: 0 }], guardOf(options), {
|
|
2071
2368
|
...options,
|
|
2072
2369
|
draft: true
|
|
@@ -2083,7 +2380,8 @@ var EasyCMS = class {
|
|
|
2083
2380
|
return this.saveGlobal(
|
|
2084
2381
|
slug,
|
|
2085
2382
|
config.drafts ? { ...data, status: "draft" } : data,
|
|
2086
|
-
options
|
|
2383
|
+
options,
|
|
2384
|
+
"restore"
|
|
2087
2385
|
);
|
|
2088
2386
|
}
|
|
2089
2387
|
async globalVersionTarget(slug, options) {
|
|
@@ -2110,13 +2408,19 @@ var EasyCMS = class {
|
|
|
2110
2408
|
);
|
|
2111
2409
|
const existing = await this.db.findGlobal({ slug }) ?? {};
|
|
2112
2410
|
const current = await this.globalDraft(config, existing);
|
|
2411
|
+
const localized = mode === "restore" ? input : this.toMaps(config, input, current, options);
|
|
2113
2412
|
const merged = generateSlugs(
|
|
2114
2413
|
config.fields,
|
|
2115
|
-
applyDefaults(
|
|
2414
|
+
applyDefaults(
|
|
2415
|
+
config.fields,
|
|
2416
|
+
mergeForUpdate(config.fields, current, localized),
|
|
2417
|
+
this.config.localization
|
|
2418
|
+
),
|
|
2419
|
+
this.config.localization
|
|
2116
2420
|
);
|
|
2117
2421
|
if (config.drafts) merged.status = input.status ?? current.status ?? "draft";
|
|
2118
2422
|
const base = this.hookArgs(config, guard);
|
|
2119
|
-
let prepared = await this.prepare(config, merged, "update", void 0);
|
|
2423
|
+
let prepared = await this.prepare(config, merged, "update", void 0, options);
|
|
2120
2424
|
prepared = await this.transform(
|
|
2121
2425
|
config.hooks?.beforeChange,
|
|
2122
2426
|
"data",
|
|
@@ -2124,7 +2428,7 @@ var EasyCMS = class {
|
|
|
2124
2428
|
prepared
|
|
2125
2429
|
);
|
|
2126
2430
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2127
|
-
if (mode
|
|
2431
|
+
if (mode !== "unpublish" && this.separateDrafts(config) && existing.status === "published" && prepared.status === "draft") {
|
|
2128
2432
|
const draft = { ...prepared, updatedAt: now };
|
|
2129
2433
|
await this.saveVersion(config, globalParent(slug), 0, draft, guard);
|
|
2130
2434
|
await this.notify(config.hooks?.afterChange, "afterChange", slug, {
|
|
@@ -2238,16 +2542,19 @@ var EasyCMS = class {
|
|
|
2238
2542
|
if (where === null) return [];
|
|
2239
2543
|
const found = await this.db.find({
|
|
2240
2544
|
collection: target.slug,
|
|
2241
|
-
where: draftWhere(target, options.draft, where),
|
|
2545
|
+
where: this.whereFor(target, draftWhere(target, options.draft, where), options),
|
|
2242
2546
|
sort: [],
|
|
2243
2547
|
limit: 0,
|
|
2244
2548
|
page: 1
|
|
2245
2549
|
});
|
|
2246
2550
|
const docs2 = options.draft ? await this.withDrafts(target, found.docs) : found.docs;
|
|
2247
|
-
return Promise.all(
|
|
2551
|
+
return Promise.all(
|
|
2552
|
+
docs2.map((d) => finish(target, this.pick(target, d, options)))
|
|
2553
|
+
);
|
|
2248
2554
|
};
|
|
2249
2555
|
const depth = Math.max(0, Math.min(MAX_DEPTH, Math.trunc(options.depth ?? DEFAULT_DEPTH)));
|
|
2250
|
-
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);
|
|
2251
2558
|
return Promise.all(populated.map((d) => finish(config, d)));
|
|
2252
2559
|
}
|
|
2253
2560
|
/** Creates a document: access, input filtering, hooks, validation, then afterChange. */
|
|
@@ -2270,7 +2577,11 @@ var EasyCMS = class {
|
|
|
2270
2577
|
this.fieldChecker("update", guard, void 0, input)
|
|
2271
2578
|
);
|
|
2272
2579
|
const base = this.hookArgs(config, hookGuard ?? guard);
|
|
2273
|
-
let data = applyDefaults(
|
|
2580
|
+
let data = applyDefaults(
|
|
2581
|
+
config.fields,
|
|
2582
|
+
this.toMaps(config, filtered, {}, options),
|
|
2583
|
+
this.config.localization
|
|
2584
|
+
);
|
|
2274
2585
|
data = await this.transform(
|
|
2275
2586
|
config.hooks?.beforeValidate,
|
|
2276
2587
|
"data",
|
|
@@ -2279,9 +2590,10 @@ var EasyCMS = class {
|
|
|
2279
2590
|
);
|
|
2280
2591
|
let prepared = await this.prepare(
|
|
2281
2592
|
config,
|
|
2282
|
-
generateSlugs(config.fields, data),
|
|
2593
|
+
generateSlugs(config.fields, data, this.config.localization),
|
|
2283
2594
|
"create",
|
|
2284
|
-
void 0
|
|
2595
|
+
void 0,
|
|
2596
|
+
options
|
|
2285
2597
|
);
|
|
2286
2598
|
prepared = await this.transform(
|
|
2287
2599
|
config.hooks?.beforeChange,
|
|
@@ -2304,6 +2616,43 @@ var EasyCMS = class {
|
|
|
2304
2616
|
const [out] = await this.output(config, [doc], hookGuard ?? guard, { ...options, draft: true });
|
|
2305
2617
|
return out;
|
|
2306
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
|
+
}
|
|
2307
2656
|
/** Drafts are kept as versions (instead of unpublishing) when both are enabled. */
|
|
2308
2657
|
separateDrafts(config) {
|
|
2309
2658
|
return config.drafts === true && versionLimit(config) !== void 0;
|
|
@@ -2394,12 +2743,15 @@ var EasyCMS = class {
|
|
|
2394
2743
|
return result;
|
|
2395
2744
|
}
|
|
2396
2745
|
/** Validates, checks uniqueness and references. Returns clean data or throws `ValidationError`. */
|
|
2397
|
-
async prepare(config, data, operation, selfId) {
|
|
2746
|
+
async prepare(config, data, operation, selfId, options = {}) {
|
|
2398
2747
|
const isDraft = config.drafts === true && (data.status ?? "draft") === "draft";
|
|
2748
|
+
const locale = this.localeOf(options);
|
|
2399
2749
|
const result = await validateFields(config.fields, data, {
|
|
2400
2750
|
operation,
|
|
2401
2751
|
root: data,
|
|
2402
|
-
skipRequired: isDraft
|
|
2752
|
+
skipRequired: isDraft,
|
|
2753
|
+
localization: this.config.localization,
|
|
2754
|
+
...locale !== void 0 && locale !== ALL_LOCALES ? { locale } : {}
|
|
2403
2755
|
});
|
|
2404
2756
|
const errors = [...result.errors];
|
|
2405
2757
|
const clean = { ...result.data };
|
|
@@ -2441,14 +2793,26 @@ var EasyCMS = class {
|
|
|
2441
2793
|
}
|
|
2442
2794
|
}
|
|
2443
2795
|
async makeSlugsUnique(config, data, selfId) {
|
|
2444
|
-
|
|
2445
|
-
const base = data[field.name];
|
|
2446
|
-
if (field.type !== "slug" || typeof base !== "string" || base === "") continue;
|
|
2796
|
+
const unique = async (path, base) => {
|
|
2447
2797
|
let candidate = base;
|
|
2448
|
-
for (let n = 2; await this.isTaken(config.slug,
|
|
2798
|
+
for (let n = 2; await this.isTaken(config.slug, path, candidate, selfId); n++) {
|
|
2449
2799
|
candidate = `${base}-${n}`;
|
|
2450
2800
|
}
|
|
2451
|
-
|
|
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
|
+
}
|
|
2452
2816
|
}
|
|
2453
2817
|
}
|
|
2454
2818
|
async checkUnique(config, data, selfId) {
|
|
@@ -2456,8 +2820,16 @@ var EasyCMS = class {
|
|
|
2456
2820
|
for (const field of config.fields) {
|
|
2457
2821
|
const value = data[field.name];
|
|
2458
2822
|
if (!field.unique || field.type === "slug" || value === null || value === void 0) continue;
|
|
2459
|
-
|
|
2460
|
-
|
|
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
|
+
}
|
|
2461
2833
|
}
|
|
2462
2834
|
}
|
|
2463
2835
|
return errors;
|
|
@@ -2537,7 +2909,7 @@ function asObject(data, name) {
|
|
|
2537
2909
|
async function allowed(access, user) {
|
|
2538
2910
|
return await evaluateAccess(access, { user }) !== false;
|
|
2539
2911
|
}
|
|
2540
|
-
async function serializeFields(fields, update) {
|
|
2912
|
+
async function serializeFields(fields, update, localized) {
|
|
2541
2913
|
const out = [];
|
|
2542
2914
|
for (const field of fields) {
|
|
2543
2915
|
if (field.hidden) continue;
|
|
@@ -2547,6 +2919,7 @@ async function serializeFields(fields, update) {
|
|
|
2547
2919
|
if (field.unique) f.unique = true;
|
|
2548
2920
|
if (field.defaultValue !== void 0) f.defaultValue = field.defaultValue;
|
|
2549
2921
|
if (!await update.allows(field)) f.readOnly = true;
|
|
2922
|
+
if (field.localized && localized) f.localized = true;
|
|
2550
2923
|
switch (field.type) {
|
|
2551
2924
|
case "text":
|
|
2552
2925
|
case "textarea":
|
|
@@ -2576,23 +2949,27 @@ async function serializeFields(fields, update) {
|
|
|
2576
2949
|
case "array":
|
|
2577
2950
|
if (field.minRows !== void 0) f.minRows = field.minRows;
|
|
2578
2951
|
if (field.maxRows !== void 0) f.maxRows = field.maxRows;
|
|
2579
|
-
f.fields = await serializeFields(field.fields, update);
|
|
2952
|
+
f.fields = await serializeFields(field.fields, update, localized);
|
|
2580
2953
|
break;
|
|
2581
2954
|
case "group":
|
|
2582
|
-
f.fields = await serializeFields(field.fields, update);
|
|
2955
|
+
f.fields = await serializeFields(field.fields, update, localized);
|
|
2583
2956
|
break;
|
|
2584
2957
|
}
|
|
2585
2958
|
out.push(f);
|
|
2586
2959
|
}
|
|
2587
2960
|
return out;
|
|
2588
2961
|
}
|
|
2589
|
-
async function collection(config, user) {
|
|
2962
|
+
async function collection(config, user, localized) {
|
|
2590
2963
|
const result = {
|
|
2591
2964
|
slug: config.slug,
|
|
2592
2965
|
drafts: config.drafts === true,
|
|
2593
2966
|
versions: Boolean(config.versions),
|
|
2594
2967
|
preview: typeof config.preview === "function",
|
|
2595
|
-
fields: await serializeFields(
|
|
2968
|
+
fields: await serializeFields(
|
|
2969
|
+
config.fields,
|
|
2970
|
+
new FieldAccessChecker("update", { user }),
|
|
2971
|
+
localized
|
|
2972
|
+
),
|
|
2596
2973
|
permissions: {
|
|
2597
2974
|
read: await allowed(config.access?.read, user),
|
|
2598
2975
|
create: await allowed(config.access?.create, user),
|
|
@@ -2604,13 +2981,17 @@ async function collection(config, user) {
|
|
|
2604
2981
|
if (config.useAsTitle) result.useAsTitle = config.useAsTitle;
|
|
2605
2982
|
return result;
|
|
2606
2983
|
}
|
|
2607
|
-
async function global(config, user) {
|
|
2984
|
+
async function global(config, user, localized) {
|
|
2608
2985
|
const result = {
|
|
2609
2986
|
slug: config.slug,
|
|
2610
2987
|
drafts: config.drafts === true,
|
|
2611
2988
|
versions: Boolean(config.versions),
|
|
2612
2989
|
preview: typeof config.preview === "function",
|
|
2613
|
-
fields: await serializeFields(
|
|
2990
|
+
fields: await serializeFields(
|
|
2991
|
+
config.fields,
|
|
2992
|
+
new FieldAccessChecker("update", { user }),
|
|
2993
|
+
localized
|
|
2994
|
+
),
|
|
2614
2995
|
permissions: {
|
|
2615
2996
|
read: await allowed(config.access?.read, user),
|
|
2616
2997
|
update: await allowed(config.access?.update, user)
|
|
@@ -2621,10 +3002,13 @@ async function global(config, user) {
|
|
|
2621
3002
|
}
|
|
2622
3003
|
async function adminSchema(cms, user) {
|
|
2623
3004
|
const collections = cms.config.collections.filter((c) => !INTERNAL_COLLECTIONS.has(c.slug));
|
|
3005
|
+
const localization = cms.config.localization;
|
|
3006
|
+
const localized = localization !== null;
|
|
2624
3007
|
return {
|
|
2625
3008
|
locale: cms.config.admin.locale,
|
|
2626
|
-
|
|
2627
|
-
|
|
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)))
|
|
2628
3012
|
};
|
|
2629
3013
|
}
|
|
2630
3014
|
|
|
@@ -2795,7 +3179,7 @@ function withHeaders(response, extra) {
|
|
|
2795
3179
|
}
|
|
2796
3180
|
async function route(cms, ctx, method, segments, options) {
|
|
2797
3181
|
const [first, second, third] = segments;
|
|
2798
|
-
const access = { overrideAccess: false, user: ctx.user };
|
|
3182
|
+
const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
|
|
2799
3183
|
const draft = ctx.user !== null && ctx.url.searchParams.get("draft") === "true";
|
|
2800
3184
|
if (segments.length === 0) throw new HttpError("Not found", 404);
|
|
2801
3185
|
if (first === USERS && second !== void 0 && third === void 0 && !/^\d+$/.test(second)) {
|
|
@@ -2860,8 +3244,19 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
2860
3244
|
if (!cms.config.globals.some((g) => g.slug === second))
|
|
2861
3245
|
throw new HttpError(`Unknown global "${second}"`, 404);
|
|
2862
3246
|
if (third !== void 0) return globalAction(cms, ctx, method, second, segments.slice(2));
|
|
2863
|
-
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
|
+
}
|
|
2864
3258
|
return { body: await cms.findGlobal(second, { ...access, ...parseDepth(ctx.url), draft }) };
|
|
3259
|
+
}
|
|
2865
3260
|
if (method === "POST") {
|
|
2866
3261
|
const body = await readJson(ctx.request);
|
|
2867
3262
|
return { body: await cms.updateGlobal(second, body, { ...access, ...parseDepth(ctx.url) }) };
|
|
@@ -2908,6 +3303,16 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
2908
3303
|
throw methodNotAllowed(ctx, "GET, POST");
|
|
2909
3304
|
}
|
|
2910
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
|
+
}
|
|
2911
3316
|
if (method === "GET") {
|
|
2912
3317
|
const doc = await cms.findById(collection2, id, { ...access, ...parseDepth(ctx.url), draft });
|
|
2913
3318
|
if (!doc) throw new NotFoundError(collection2, id);
|
|
@@ -2927,7 +3332,7 @@ async function route(cms, ctx, method, segments, options) {
|
|
|
2927
3332
|
throw methodNotAllowed(ctx, "GET, PATCH, DELETE");
|
|
2928
3333
|
}
|
|
2929
3334
|
async function documentAction(cms, ctx, method, collection2, id, path) {
|
|
2930
|
-
const access = { overrideAccess: false, user: ctx.user };
|
|
3335
|
+
const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
|
|
2931
3336
|
const depth = parseDepth(ctx.url);
|
|
2932
3337
|
const [action, versionId, extra] = path;
|
|
2933
3338
|
if (action === "versions" && versionId === void 0) {
|
|
@@ -2947,7 +3352,9 @@ async function documentAction(cms, ctx, method, collection2, id, path) {
|
|
|
2947
3352
|
if (path.length === 1 && action === "preview") {
|
|
2948
3353
|
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
2949
3354
|
const body = await readJson(ctx.request);
|
|
2950
|
-
|
|
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) } };
|
|
2951
3358
|
}
|
|
2952
3359
|
if (path.length === 1 && (action === "unpublish" || action === "discard-draft")) {
|
|
2953
3360
|
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
@@ -2959,7 +3366,7 @@ async function documentAction(cms, ctx, method, collection2, id, path) {
|
|
|
2959
3366
|
throw new HttpError("Not found", 404);
|
|
2960
3367
|
}
|
|
2961
3368
|
async function globalAction(cms, ctx, method, slug, path) {
|
|
2962
|
-
const access = { overrideAccess: false, user: ctx.user };
|
|
3369
|
+
const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
|
|
2963
3370
|
const depth = parseDepth(ctx.url);
|
|
2964
3371
|
const [action, versionId, extra] = path;
|
|
2965
3372
|
if (action === "versions" && versionId === void 0) {
|
|
@@ -2979,7 +3386,9 @@ async function globalAction(cms, ctx, method, slug, path) {
|
|
|
2979
3386
|
if (path.length === 1 && action === "preview") {
|
|
2980
3387
|
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
2981
3388
|
const body = await readJson(ctx.request);
|
|
2982
|
-
|
|
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) } };
|
|
2983
3392
|
}
|
|
2984
3393
|
if (path.length === 1 && (action === "unpublish" || action === "discard-draft")) {
|
|
2985
3394
|
if (method !== "POST") throw methodNotAllowed(ctx, "POST");
|
|
@@ -2990,6 +3399,26 @@ async function globalAction(cms, ctx, method, slug, path) {
|
|
|
2990
3399
|
}
|
|
2991
3400
|
throw new HttpError("Not found", 404);
|
|
2992
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
|
+
}
|
|
2993
3422
|
function parsePage(url) {
|
|
2994
3423
|
const { page, limit } = parseListQuery(url);
|
|
2995
3424
|
return { page, limit };
|
package/dist/live-preview.d.ts
CHANGED
|
@@ -18,6 +18,13 @@ interface LivePreviewOptions {
|
|
|
18
18
|
*/
|
|
19
19
|
readonly origin?: string | readonly string[];
|
|
20
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;
|
|
21
28
|
/** True when the page is shown inside a frame (e.g. the admin's preview). */
|
|
22
29
|
declare function isLivePreview(): boolean;
|
|
23
30
|
/**
|
|
@@ -30,4 +37,4 @@ declare function isLivePreview(): boolean;
|
|
|
30
37
|
*/
|
|
31
38
|
declare function subscribeLivePreview<T = Record<string, unknown>>(onChange: (doc: T, message: LivePreviewMessage<T>) => void, options?: LivePreviewOptions): () => void;
|
|
32
39
|
|
|
33
|
-
export { type LivePreviewMessage, type LivePreviewOptions, READY_MESSAGE, isLivePreview, subscribeLivePreview };
|
|
40
|
+
export { type LivePreviewMessage, type LivePreviewOptions, PREVIEW_PARAM, READY_MESSAGE, getPreviewToken, isLivePreview, subscribeLivePreview };
|
package/dist/live-preview.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
// src/live-preview.ts
|
|
2
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
|
+
}
|
|
3
8
|
function isLivePreview() {
|
|
4
9
|
return typeof window !== "undefined" && window.parent !== window;
|
|
5
10
|
}
|
|
@@ -18,7 +23,9 @@ function subscribeLivePreview(onChange, options = {}) {
|
|
|
18
23
|
return () => window.removeEventListener("message", listener);
|
|
19
24
|
}
|
|
20
25
|
export {
|
|
26
|
+
PREVIEW_PARAM,
|
|
21
27
|
READY_MESSAGE,
|
|
28
|
+
getPreviewToken,
|
|
22
29
|
isLivePreview,
|
|
23
30
|
subscribeLivePreview
|
|
24
31
|
};
|