@easy-cms/core 0.46.0 → 0.47.1

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.
@@ -0,0 +1,48 @@
1
+ // src/conditions.ts
2
+ var empty = (value) => value === void 0 || value === null || value === "" || value === false || Array.isArray(value) && value.length === 0;
3
+ var plain = (value) => value && typeof value === "object" && !Array.isArray(value) && "id" in value ? value.id : value;
4
+ var same = (a, b) => {
5
+ const x = plain(a);
6
+ const y = plain(b);
7
+ if (Array.isArray(x)) return x.some((v) => same(v, y));
8
+ return x === y || x !== null && y !== null && x !== void 0 && String(x) === String(y);
9
+ };
10
+ function read(data, path) {
11
+ let current = data;
12
+ for (const key of path.split(".")) {
13
+ if (typeof current !== "object" || current === null) return void 0;
14
+ current = current[key];
15
+ }
16
+ return current;
17
+ }
18
+ function matchesCondition(condition, data) {
19
+ if ("and" in condition) return condition.and.every((c) => matchesCondition(c, data));
20
+ if ("or" in condition) return condition.or.some((c) => matchesCondition(c, data));
21
+ if ("not" in condition) return !matchesCondition(condition.not, data);
22
+ const value = read(data, condition.field);
23
+ if (condition.exists !== void 0 && empty(value) === condition.exists) return false;
24
+ if ("equals" in condition && !same(value, condition.equals)) return false;
25
+ if ("notEquals" in condition && same(value, condition.notEquals)) return false;
26
+ if (condition.in && !condition.in.some((v) => same(value, v))) return false;
27
+ return true;
28
+ }
29
+ function conditionIssues(condition, siblings) {
30
+ if (typeof condition !== "object" || condition === null || Array.isArray(condition))
31
+ return ['must be an object, e.g. { field: "type", equals: "link" }'];
32
+ const c = condition;
33
+ if (Array.isArray(c.and)) return c.and.flatMap((x) => conditionIssues(x, siblings));
34
+ if (Array.isArray(c.or)) return c.or.flatMap((x) => conditionIssues(x, siblings));
35
+ if ("not" in c) return conditionIssues(c.not, siblings);
36
+ if (typeof c.field !== "string" || c.field === "") return ["needs `field`, a sibling field"];
37
+ const first = c.field.split(".")[0];
38
+ if (!siblings.includes(first)) return [`"${c.field}" is not a field beside it`];
39
+ if (c.in !== void 0 && !Array.isArray(c.in)) return ["`in` must be a list"];
40
+ if (c.exists !== void 0 && typeof c.exists !== "boolean")
41
+ return ["`exists` must be true or false"];
42
+ return [];
43
+ }
44
+
45
+ export {
46
+ matchesCondition,
47
+ conditionIssues
48
+ };
@@ -0,0 +1,31 @@
1
+ /**
2
+ * When a field is shown, from its sibling fields' values (the fields beside it, in its group,
3
+ * array row or block):
4
+ *
5
+ * - `{ field: 'linkType', equals: 'external' }`, also `notEquals`, `in: [...]`, `exists: true`
6
+ * - `{ and: [...] }`, `{ or: [...] }`, `{ not: … }`
7
+ *
8
+ * A hidden field isn't required, and keeps its value.
9
+ */
10
+ type FieldCondition = {
11
+ /** A sibling field's name; a path with `.` reads inside a group, e.g. `link.type`. */
12
+ readonly field: string;
13
+ readonly equals?: unknown;
14
+ readonly notEquals?: unknown;
15
+ readonly in?: readonly unknown[];
16
+ /** `true`: has a value (not empty); `false`: empty. */
17
+ readonly exists?: boolean;
18
+ } | {
19
+ readonly and: readonly FieldCondition[];
20
+ } | {
21
+ readonly or: readonly FieldCondition[];
22
+ } | {
23
+ readonly not: FieldCondition;
24
+ };
25
+ type Data = Record<string, unknown>;
26
+ /** Whether a condition holds for the sibling data. */
27
+ declare function matchesCondition(condition: FieldCondition, data: Data): boolean;
28
+ /** Problems with a condition, for the config's checks; `[]` when it's fine. */
29
+ declare function conditionIssues(condition: unknown, siblings: readonly string[]): string[];
30
+
31
+ export { type FieldCondition, conditionIssues, matchesCondition };
@@ -0,0 +1,8 @@
1
+ import {
2
+ conditionIssues,
3
+ matchesCondition
4
+ } from "./chunk-C4KJ5TFK.js";
5
+ export {
6
+ conditionIssues,
7
+ matchesCondition
8
+ };
package/dist/index.d.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { FieldCondition } from './conditions.js';
2
+ export { conditionIssues, matchesCondition } from './conditions.js';
3
+
1
4
  /** A document id. SQLite uses integers, Postgres may use either. */
2
5
  type ID = string | number;
3
6
  type Role = 'admin' | 'editor' | (string & {});
@@ -885,7 +888,10 @@ type InputValue<F extends Field> = F extends {
885
888
  readonly type: 'blocks';
886
889
  readonly blocks: infer B extends readonly Block[];
887
890
  } ? readonly BlockInput<B[number]>[] : FieldValue<F>;
888
- /** Required fields must be given, unless Easy CMS can fill them (default value, slug from another field). */
891
+ /**
892
+ * Required fields must be given, unless Easy CMS can fill them (default value, slug from another
893
+ * field) or a condition may hide them (`admin.condition`).
894
+ */
889
895
  type NeedsInput<F extends Field> = F extends {
890
896
  readonly required: true;
891
897
  } ? F extends {
@@ -893,6 +899,10 @@ type NeedsInput<F extends Field> = F extends {
893
899
  } ? never : F extends {
894
900
  readonly type: 'slug';
895
901
  readonly from: string;
902
+ } ? never : F extends {
903
+ readonly admin: {
904
+ readonly condition: object;
905
+ };
896
906
  } ? never : F : never;
897
907
  type FieldsInput<Fs extends readonly Field[]> = Simplify<{
898
908
  -readonly [F in NeedsInput<Visible<Fs>> as F['name']]: InputValue<F>;
@@ -1853,6 +1863,15 @@ type AdminComponent = string | {
1853
1863
  };
1854
1864
  /** How the admin shows a field, with components from admin modules. */
1855
1865
  interface FieldAdmin {
1866
+ /** Help below the field's label. */
1867
+ readonly description?: Label;
1868
+ /** Its share of a row (`admin.layout`'s `row`). Default: an equal share. */
1869
+ readonly width?: '1/4' | '1/3' | '1/2' | '2/3' | '3/4' | 'full';
1870
+ /**
1871
+ * Shown only when its sibling fields match, e.g. `{ field: 'linkType', equals: 'external' }`.
1872
+ * A hidden field isn't required. See `FieldCondition`.
1873
+ */
1874
+ readonly condition?: FieldCondition;
1856
1875
  /** Shown instead of the field's input; the admin keeps the label and error messages. */
1857
1876
  readonly component?: AdminComponent;
1858
1877
  /** Shown below the field, e.g. a length meter or a preview. */
@@ -2471,18 +2490,72 @@ interface VersionsConfig {
2471
2490
  /** Versions kept per document; older ones are deleted. Default 50. */
2472
2491
  readonly max?: number;
2473
2492
  }
2493
+ /** A panel of an edit page's side column: a component, at the top or (default) below the rest. */
2494
+ type SidebarPanel = AdminComponent | {
2495
+ readonly tag: string;
2496
+ readonly props?: Readonly<Record<string, unknown>>;
2497
+ /** `top`: right below publishing, above the side fields. Default: below them. */
2498
+ readonly position?: 'top';
2499
+ };
2500
+ /**
2501
+ * Where the edit page puts its fields: tabs, collapsible sections and rows, by field name. Only
2502
+ * how they look: the data stays as the fields say. Fields not placed follow at the end (in the
2503
+ * first tab). Top-level fields; sidebar fields stay in the side column.
2504
+ *
2505
+ * ```ts
2506
+ * layout: [
2507
+ * { tab: 'Content', fields: ['title', { row: ['author', 'category'] }, 'body'] },
2508
+ * { tab: 'SEO', fields: ['meta'] },
2509
+ * ]
2510
+ * ```
2511
+ */
2512
+ type LayoutNode = string | {
2513
+ readonly row: readonly string[];
2514
+ } | {
2515
+ readonly collapsible: Label;
2516
+ /** Starts folded. */
2517
+ readonly collapsed?: boolean;
2518
+ readonly description?: Label;
2519
+ readonly fields: readonly LayoutNode[];
2520
+ } | {
2521
+ readonly tab: Label;
2522
+ readonly description?: Label;
2523
+ readonly fields: readonly LayoutNode[];
2524
+ };
2474
2525
  /** Custom admin components for a collection's or global's edit page. */
2475
2526
  interface ContainerAdmin {
2476
- /** Panels in the edit page's side column, below publishing (components from admin modules). */
2477
- readonly sidebar?: readonly AdminComponent[];
2527
+ /** Panels in the edit page's side column (components from admin modules). */
2528
+ readonly sidebar?: readonly SidebarPanel[];
2529
+ /** Tabs, collapsible sections and rows of the edit page. See `LayoutNode`. */
2530
+ readonly layout?: readonly LayoutNode[];
2531
+ /**
2532
+ * Its menu group: a group's id (`admin.nav`), e.g. `shop` or `shop.catalog`; `settings` for
2533
+ * Settings; or a label, which makes a group of that name. Default: Content for collections,
2534
+ * Settings › Site for globals.
2535
+ */
2536
+ readonly group?: 'settings' | Label;
2478
2537
  }
2479
2538
  interface CollectionAdmin extends ContainerAdmin {
2480
2539
  /**
2481
- * `settings` lists the collection under Settings in the admin menu (with Users and API keys)
2482
- * instead of with the content, e.g. for redirects or forms settings. A label (e.g.
2483
- * `{ en: 'Shop', th: 'ร้านค้า' }`) lists it under a heading of that name, after the content.
2540
+ * A number that needs attention, next to its menu item: the documents matching `where` (that
2541
+ * the user may read), e.g. orders to send. `tone`: `accent` (default), `warning` or `danger`.
2484
2542
  */
2485
- readonly group?: 'settings' | Label;
2543
+ readonly badge?: {
2544
+ readonly where: Where;
2545
+ readonly tone?: 'accent' | 'warning' | 'danger';
2546
+ /** What the number counts, for screen readers, e.g. `{ en: 'to send', th: 'รอจัดส่ง' }`. */
2547
+ readonly label?: Label;
2548
+ };
2549
+ /** The number of documents beside its menu item. Default `true`. */
2550
+ readonly count?: boolean;
2551
+ /** The list with no documents yet: what to say, and a link, e.g. to a guide. */
2552
+ readonly empty?: {
2553
+ readonly description?: Label;
2554
+ readonly link?: {
2555
+ readonly label: Label;
2556
+ readonly href: string;
2557
+ };
2558
+ };
2486
2559
  readonly list?: CollectionListAdmin;
2487
2560
  /**
2488
2561
  * With `auth.rbac`: the relationship field to `users` that names a document's owner, e.g.
@@ -2584,6 +2657,26 @@ interface AdminBrand {
2584
2657
  /** Main color as `#rrggbb`; lighter and darker shades are derived from it. */
2585
2658
  readonly color?: string;
2586
2659
  }
2660
+ /** A group of the admin menu (`admin.nav`). */
2661
+ interface NavGroup {
2662
+ /** Lowercase letters, digits and `-`; children are named `<parent>.<child>`, e.g. `shop.catalog`. */
2663
+ readonly id: string;
2664
+ readonly label: Label;
2665
+ readonly icon?: AdminIcon;
2666
+ /** Its place among the groups: lower first. Content is 0, the media library 10, Settings 1000. */
2667
+ readonly order?: number;
2668
+ /** Groups inside it, one level. */
2669
+ readonly children?: readonly Omit<NavGroup, 'children'>[];
2670
+ }
2671
+ /** An entry of the command palette (⌘K) that opens a page of the admin. */
2672
+ interface AdminCommand {
2673
+ readonly label: Label;
2674
+ /** A path in the admin, e.g. `/collections/orders?f_status=paid`. */
2675
+ readonly to: string;
2676
+ readonly icon?: AdminIcon;
2677
+ /** Words that find it besides its label. */
2678
+ readonly keywords?: readonly string[];
2679
+ }
2587
2680
  /** Who may see an admin page or dashboard widget. Default: every logged-in user. */
2588
2681
  type AdminViewAccess = (args: {
2589
2682
  readonly user: AuthUser;
@@ -2598,8 +2691,11 @@ interface AdminPage {
2598
2691
  readonly label: Label;
2599
2692
  /** Icon in the admin menu. Default `file-text`. */
2600
2693
  readonly icon?: AdminIcon;
2601
- /** Listed under Content (default) or Settings in the menu, or not listed (`false`): reached by links. */
2602
- readonly group?: 'content' | 'settings' | false;
2694
+ /**
2695
+ * Its menu group: `content` (default), `settings`, a group's id (`admin.nav`) or a label; `false`:
2696
+ * not listed, reached by links.
2697
+ */
2698
+ readonly group?: 'content' | 'settings' | false | Label;
2603
2699
  /** Who may open it. Pages a user may not open are left out of their admin. */
2604
2700
  readonly access?: AdminViewAccess;
2605
2701
  }
@@ -2631,6 +2727,15 @@ interface AdminConfig {
2631
2727
  * Collections not listed follow in config order, with the media library last.
2632
2728
  */
2633
2729
  readonly menu?: readonly string[];
2730
+ /**
2731
+ * The admin menu's groups, which collections, globals and pages name in `admin.group`, e.g.
2732
+ * `{ id: 'shop', label: 'Shop', icon: 'store', children: [{ id: 'catalog', label: 'Catalog' }] }`.
2733
+ * Built in: `content` and `settings` (with `site`, `people` and `system`); declaring one of
2734
+ * their ids changes its label, icon or order. Plugins declare their own.
2735
+ */
2736
+ readonly nav?: readonly NavGroup[];
2737
+ /** More entries of the command palette (⌘K), e.g. a plugin's page with a filter. */
2738
+ readonly commands?: readonly AdminCommand[];
2634
2739
  /**
2635
2740
  * JavaScript modules the admin loads after login, which define Web Components used by
2636
2741
  * fields (`admin.component`, `admin.after`) and edit pages (`admin.sidebar`). Each entry is a
@@ -3317,6 +3422,9 @@ declare function imageDimensions(data: Uint8Array, type: string): {
3317
3422
  height: number;
3318
3423
  } | undefined;
3319
3424
 
3425
+ /** The menu's groups that are always there. Declaring their ids changes labels, icons, order. */
3426
+ declare const BUILTIN_NAV: readonly NavGroup[];
3427
+
3320
3428
  declare const DEFAULT_DEPTH = 1;
3321
3429
  declare const MAX_DEPTH = 3;
3322
3430
  /** Loads the documents of one collection that the caller may see. */
@@ -3415,7 +3523,51 @@ declare function resolveAdminModule(specifier: string, cwd?: string): string | u
3415
3523
  interface AdminComponentRef {
3416
3524
  tag: string;
3417
3525
  props?: Record<string, unknown>;
3526
+ /** A side panel at the top of the side column (`position: 'top'`). */
3527
+ position?: 'top';
3528
+ }
3529
+ /** Where the edit page puts its fields (`admin.layout`), with unplaced fields filled in. */
3530
+ type AdminLayoutNode = {
3531
+ type: 'field';
3532
+ name: string;
3533
+ } | {
3534
+ type: 'row';
3535
+ fields: string[];
3536
+ } | {
3537
+ type: 'collapsible';
3538
+ label: Label;
3539
+ collapsed?: boolean;
3540
+ description?: Label;
3541
+ nodes: AdminLayoutNode[];
3542
+ } | {
3543
+ type: 'tab';
3544
+ label: Label;
3545
+ description?: Label;
3546
+ nodes: AdminLayoutNode[];
3547
+ };
3548
+ /** An entry of the admin menu. */
3549
+ type AdminNavItem = {
3550
+ kind: 'collection';
3551
+ slug: string;
3552
+ } | {
3553
+ kind: 'global';
3554
+ slug: string;
3555
+ } | {
3556
+ kind: 'page';
3557
+ path: string;
3558
+ } | {
3559
+ kind: 'view';
3560
+ view: 'roles' | 'sso' | 'backups' | 'email' | 'deliveries' | 'audit';
3561
+ };
3562
+ /** A group of the admin menu (`admin.nav`), with what this user may open in it. */
3563
+ interface AdminNavGroup {
3564
+ kind: 'group';
3565
+ id: string;
3566
+ label: Label;
3567
+ icon?: string;
3568
+ items: AdminNavNode[];
3418
3569
  }
3570
+ type AdminNavNode = AdminNavItem | AdminNavGroup;
3419
3571
  /** A field as the admin UI sees it: plain JSON, no functions. */
3420
3572
  interface AdminField {
3421
3573
  name: string;
@@ -3474,6 +3626,12 @@ interface AdminField {
3474
3626
  noCreate?: boolean;
3475
3627
  /** A field of an added type (`fieldTypes`), e.g. `color`; `type` is the base it's stored as. */
3476
3628
  customType?: string;
3629
+ /** Help below its label (`admin.description`). */
3630
+ description?: Label;
3631
+ /** Its share of a row (`admin.width`). */
3632
+ width?: string;
3633
+ /** Shown only when its siblings match (`admin.condition`). */
3634
+ condition?: FieldCondition;
3477
3635
  }
3478
3636
  interface AdminCollection {
3479
3637
  slug: string;
@@ -3494,10 +3652,23 @@ interface AdminCollection {
3494
3652
  fields: AdminField[];
3495
3653
  /** Panels from admin modules in the edit page's side column. */
3496
3654
  sidebar?: AdminComponentRef[];
3497
- /** Listed under Settings in the menu (users, API keys, `admin.group: 'settings'`). */
3498
- group?: 'settings';
3499
- /** Listed under a heading of its own in the menu (`admin.group` with a label). */
3500
- section?: Label;
3655
+ /** Tabs, sections and rows of the edit page (`admin.layout`). */
3656
+ layout?: AdminLayoutNode[];
3657
+ /** A number in the menu (`admin.badge`); the menu's counts come from `GET <api>/admin/counts`. */
3658
+ badge?: {
3659
+ tone: 'accent' | 'warning' | 'danger';
3660
+ label?: Label;
3661
+ };
3662
+ /** No document count beside it in the menu (`admin.count: false`). */
3663
+ noCount?: boolean;
3664
+ /** What the list says while it has no documents (`admin.empty`). */
3665
+ empty?: {
3666
+ description?: Label;
3667
+ link?: {
3668
+ label: Label;
3669
+ href: string;
3670
+ };
3671
+ };
3501
3672
  /** The list is a tree along this relationship field (`admin.list.tree`). */
3502
3673
  tree?: string;
3503
3674
  /** The list's default order (`admin.list.sort`). */
@@ -3530,6 +3701,8 @@ interface AdminGlobal {
3530
3701
  fields: AdminField[];
3531
3702
  /** Panels from admin modules in the edit page's side column. */
3532
3703
  sidebar?: AdminComponentRef[];
3704
+ /** Tabs, sections and rows of the edit page (`admin.layout`). */
3705
+ layout?: AdminLayoutNode[];
3533
3706
  permissions: {
3534
3707
  read: boolean;
3535
3708
  update: boolean;
@@ -3541,8 +3714,6 @@ interface AdminPageRef {
3541
3714
  path: string;
3542
3715
  label: Label;
3543
3716
  icon?: string;
3544
- /** Where it is listed in the menu; `false`: not listed. */
3545
- group: 'content' | 'settings' | false;
3546
3717
  component: AdminComponentRef;
3547
3718
  }
3548
3719
  /** A dashboard panel (`admin.dashboard`) this user may see. */
@@ -3558,6 +3729,15 @@ interface AdminSchema {
3558
3729
  passwordLinks: boolean;
3559
3730
  /** Menu order of collections (`admin.menu`); unlisted ones follow. */
3560
3731
  menu: string[];
3732
+ /** The menu: groups and what this user may open in them, in order. */
3733
+ nav: AdminNavNode[];
3734
+ /** More entries of the command palette (`admin.commands`). */
3735
+ commands: {
3736
+ label: Label;
3737
+ to: string;
3738
+ icon?: string;
3739
+ keywords?: string[];
3740
+ }[];
3561
3741
  /** Content locales, when the config has `localization`. */
3562
3742
  localization: {
3563
3743
  locales: string[];
@@ -3607,6 +3787,13 @@ interface AdminSchema {
3607
3787
  dashboard: AdminWidgetRef[];
3608
3788
  }
3609
3789
 
3790
+ interface SearchHit {
3791
+ readonly collection: string;
3792
+ readonly id: ID;
3793
+ readonly title: string;
3794
+ readonly status?: string;
3795
+ }
3796
+
3610
3797
  /** Something an admin should look at, shown on the dashboard with a link to how to fix it. */
3611
3798
  type AdminAttention =
3612
3799
  /** Webhook deliveries that failed for good in the last 7 days; `url`: the one failing most. */
@@ -3738,4 +3925,4 @@ declare function validateConfig(config: Config): ConfigIssue[];
3738
3925
  /** The version of `@easy-cms/core`, set when the package is built. */
3739
3926
  declare const VERSION: string;
3740
3927
 
3741
- export { ADMIN_ICONS, API_KEYS, API_KEY_GLOBAL_OPERATIONS, API_KEY_OPERATIONS, type Access, type AccessArgs, type AccessOptions, type AdminAttention, type AdminBackup, type AdminBackups, type AdminBrand, type AdminCollection, type AdminComponent, type AdminComponentRef, type AdminConfig, type AdminDeliveries, type AdminDelivery, type AdminEmail, type AdminEmailDelivery, type AdminField, type AdminFieldArgs, type AdminGlobal, type AdminIcon, type AdminLocale, type AdminPage, type AdminPageRef, type AdminRole, type AdminRoleChange, type AdminRoleField, type AdminRoleTarget, type AdminRoleView, type AdminRoles, type AdminSchema, type AdminSso, type AdminStatus, type AdminSwitcher, type AdminViewAccess, type AdminWebhookDelivery, type AdminWidgetRef, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ApiHandlerOptions, type ApiKeyContext, type ApiKeyGlobalOperation, type ApiKeyOperation, type ApiKeyPermissions, type ArrayField, type AuditChange, type AuditConfig, type AuditEntry, AuditLog, type AuditPage, type AuditVerification, Auth, type AuthConfig, type AuthProvider, type AuthProviderProfile, type AuthProviderRequest, type AuthUser, BUILTIN_COLLECTIONS, type BackupState, type BackupsConfig, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type Block, type BlocksField, type BooleanField, type BuiltinApiKeysCollection, type BuiltinMediaCollection, type BuiltinMediaFoldersCollection, type BuiltinUsersCollection, type BundleHints, CONFIG_FILE_NAMES, CSRF_COOKIE, CSRF_HEADER, type CliCommand, type CollectionAccess, type CollectionAdmin, type CollectionConfig, type CollectionDocument, type CollectionHooks, type CollectionListAdmin, type CollectionSlug, type Config, ConfigError, type ConfigIssue, type ContainerAdmin, type CopyProgress, type CopyResult, type CreateEasyCMSOptions, type CreateInput, type CreateMigrationResult, type CustomField, type CustomFieldTypes, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_VERSIONS, DEFAULT_PASSWORD_EMAILS, DEFAULT_TOKEN_EXPIRATION, type DashboardWidget, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DatabaseTransfer, type DateField, type DeliveryKind, type DeliveryState, type DepthOptions, type DirectUpload, type DocumentOf, EXTENSIONS, EasyCMS, EasyCMSError, type EmailAdapter, type EmailCheck, type EmailField, type EmailMessage, type EmailSetting, type Endpoint, type EndpointRequest, FIELD_TYPES, FIELD_TYPE_BASES, FOLDER_LEVELS, type Field, type FieldAccess, type FieldAccessFn, type FieldAdmin, type FieldError, type FieldRule, type FieldType, type FieldTypeBase, type FieldTypeDefinition, type FieldTypeValidateContext, type FieldValidate, type FieldValidateContext, type FieldValue, type FieldsInput, type FieldsValue, type FilterOptions, type FilterOptionsArgs, type FindArgs, type FindOptions, type FolderLevel, type FolderPermissions, ForbiddenError, type GlobalAccess, type GlobalConfig, type GlobalDocument, type GlobalHooks, type GlobalInput, type GlobalSlug, type GroupField, type ID, INTERNAL_COLLECTIONS, type ImageSize, type IncrementOptions, type InferCollection, type InferGlobal, type JobConfig, type JsonField, type Label, type LivePreview, type LoadConfigOptions, type LocalStorageOptions, type LocalizationConfig, type Logger, type LoginArgs, MAX_DEPTH, MEDIA, MEDIA_FOLDERS, MIME_GROUPS, MIN_PASSWORD_LENGTH, MIN_SECRET_LENGTH, type MediaDocument, MediaFolders, type MediaSize, type MembersConfig, type MembersSignup, type MigrationInfo, NotFoundError, type NumberField, type OnRequest, type OnRequestArgs, type Operation, type PaginatedDocs, type PasswordEmail, type PasswordEmailArgs, type PasswordEmailFn, type PasswordLinkOptions, PayloadTooLargeError, type Plugin, type PluginInfo, type PluginTypes, type PreviewTarget, type PreviewURL, QueryError, ROLE_OPERATIONS, type RawDocument, type ReadOptions, type RelationshipField, type RequestContext, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoleOperation, type RolePermissions, Roles, type RoutesConfig, SESSION_COOKIE, SSO_COOKIE, type ScheduledJob, SchemaError, type SchemaMode, type SelectField, type SelectOption, type Session, type SignupArgs, type SignupResult, type SlugField, type SlugOf, type SsoOutcome, type SsoProviderRef, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, type TypedPlugin, UnauthorizedError, type UpdateInput, type UpdateOptions, type UploadConfig, type UploadField, type UploadFromURLConfig, type UserIdentity, VERSION, ValidationError, type Version, type VersionSummary, type VersionsConfig, WEBHOOK_EVENTS, type WebhookConfig, type WebhookEvent, type WebhookPayload, type Where, type WhereOperators, anyone, applyDefaults, applyPlugins, checkFormToken, configSignature, consoleEmail, consoleLogger, copyDatabase, createApiHandler, createEasyCMS, createRestHandler, createRootEndpointHandler, defineConfig, defineFieldType, definePlugin, expandMimeTypes, fillMissing, findConfigFile, formToken, generateSlugs, generateTypes, hasRows, hashPassword, honeypotName, imageDimensions, importConfig, isAdmin, isHasMany, isLoggedIn, isSignedIn, isSystemAdmin, keyAllows, loadConfig, localStorage, mergeForUpdate, mimeAllowed, mimeAllowedBy, parseId, populate, rateKeys, readCookie, resolveAdminModule, resolveConfig, rowFields, sharedEasyCMS, silentLogger, singularize, slugify, sniffMimeType, uniqueWithinOf, validateConfig, validateFields, verifyPassword, verifyTurnstile, writeBackupFile };
3928
+ export { ADMIN_ICONS, API_KEYS, API_KEY_GLOBAL_OPERATIONS, API_KEY_OPERATIONS, type Access, type AccessArgs, type AccessOptions, type AdminAttention, type AdminBackup, type AdminBackups, type AdminBrand, type AdminCollection, type AdminCommand, type AdminComponent, type AdminComponentRef, type AdminConfig, type AdminDeliveries, type AdminDelivery, type AdminEmail, type AdminEmailDelivery, type AdminField, type AdminFieldArgs, type AdminGlobal, type AdminIcon, type AdminLayoutNode, type AdminLocale, type AdminNavGroup, type AdminNavItem, type AdminNavNode, type AdminPage, type AdminPageRef, type AdminRole, type AdminRoleChange, type AdminRoleField, type AdminRoleTarget, type AdminRoleView, type AdminRoles, type AdminSchema, type AdminSso, type AdminStatus, type AdminSwitcher, type AdminViewAccess, type AdminWebhookDelivery, type AdminWidgetRef, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ApiHandlerOptions, type ApiKeyContext, type ApiKeyGlobalOperation, type ApiKeyOperation, type ApiKeyPermissions, type ArrayField, type AuditChange, type AuditConfig, type AuditEntry, AuditLog, type AuditPage, type AuditVerification, Auth, type AuthConfig, type AuthProvider, type AuthProviderProfile, type AuthProviderRequest, type AuthUser, BUILTIN_COLLECTIONS, BUILTIN_NAV, type BackupState, type BackupsConfig, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type Block, type BlocksField, type BooleanField, type BuiltinApiKeysCollection, type BuiltinMediaCollection, type BuiltinMediaFoldersCollection, type BuiltinUsersCollection, type BundleHints, CONFIG_FILE_NAMES, CSRF_COOKIE, CSRF_HEADER, type CliCommand, type CollectionAccess, type CollectionAdmin, type CollectionConfig, type CollectionDocument, type CollectionHooks, type CollectionListAdmin, type CollectionSlug, type Config, ConfigError, type ConfigIssue, type ContainerAdmin, type CopyProgress, type CopyResult, type CreateEasyCMSOptions, type CreateInput, type CreateMigrationResult, type CustomField, type CustomFieldTypes, DEFAULT_ADMIN_PATH, DEFAULT_API_PATH, DEFAULT_DEPTH, DEFAULT_MAX_FILE_SIZE, DEFAULT_MAX_VERSIONS, DEFAULT_PASSWORD_EMAILS, DEFAULT_TOKEN_EXPIRATION, type DashboardWidget, type Database, type DatabaseAdapter, type DatabaseInitArgs, type DatabaseTransfer, type DateField, type DeliveryKind, type DeliveryState, type DepthOptions, type DirectUpload, type DocumentOf, EXTENSIONS, EasyCMS, EasyCMSError, type EmailAdapter, type EmailCheck, type EmailField, type EmailMessage, type EmailSetting, type Endpoint, type EndpointRequest, FIELD_TYPES, FIELD_TYPE_BASES, FOLDER_LEVELS, type Field, type FieldAccess, type FieldAccessFn, type FieldAdmin, FieldCondition, type FieldError, type FieldRule, type FieldType, type FieldTypeBase, type FieldTypeDefinition, type FieldTypeValidateContext, type FieldValidate, type FieldValidateContext, type FieldValue, type FieldsInput, type FieldsValue, type FilterOptions, type FilterOptionsArgs, type FindArgs, type FindOptions, type FolderLevel, type FolderPermissions, ForbiddenError, type GlobalAccess, type GlobalConfig, type GlobalDocument, type GlobalHooks, type GlobalInput, type GlobalSlug, type GroupField, type ID, INTERNAL_COLLECTIONS, type ImageSize, type IncrementOptions, type InferCollection, type InferGlobal, type JobConfig, type JsonField, type Label, type LayoutNode, type LivePreview, type LoadConfigOptions, type LocalStorageOptions, type LocalizationConfig, type Logger, type LoginArgs, MAX_DEPTH, MEDIA, MEDIA_FOLDERS, MIME_GROUPS, MIN_PASSWORD_LENGTH, MIN_SECRET_LENGTH, type MediaDocument, MediaFolders, type MediaSize, type MembersConfig, type MembersSignup, type MigrationInfo, type NavGroup, NotFoundError, type NumberField, type OnRequest, type OnRequestArgs, type Operation, type PaginatedDocs, type PasswordEmail, type PasswordEmailArgs, type PasswordEmailFn, type PasswordLinkOptions, PayloadTooLargeError, type Plugin, type PluginInfo, type PluginTypes, type PreviewTarget, type PreviewURL, QueryError, ROLE_OPERATIONS, type RawDocument, type ReadOptions, type RelationshipField, type RequestContext, type ResolvedConfig, type RestHandler, type RestHandlerOptions, type RichTextDocument, type RichTextField, type Role, type RoleOperation, type RolePermissions, Roles, type RoutesConfig, SESSION_COOKIE, SSO_COOKIE, type ScheduledJob, SchemaError, type SchemaMode, type SearchHit, type SelectField, type SelectOption, type Session, type SidebarPanel, type SignupArgs, type SignupResult, type SlugField, type SlugOf, type SsoOutcome, type SsoProviderRef, type StorageAdapter, type StoredFile, type TextField, type TextareaField, TooManyRequestsError, type TypedPlugin, UnauthorizedError, type UpdateInput, type UpdateOptions, type UploadConfig, type UploadField, type UploadFromURLConfig, type UserIdentity, VERSION, ValidationError, type Version, type VersionSummary, type VersionsConfig, WEBHOOK_EVENTS, type WebhookConfig, type WebhookEvent, type WebhookPayload, type Where, type WhereOperators, anyone, applyDefaults, applyPlugins, checkFormToken, configSignature, consoleEmail, consoleLogger, copyDatabase, createApiHandler, createEasyCMS, createRestHandler, createRootEndpointHandler, defineConfig, defineFieldType, definePlugin, expandMimeTypes, fillMissing, findConfigFile, formToken, generateSlugs, generateTypes, hasRows, hashPassword, honeypotName, imageDimensions, importConfig, isAdmin, isHasMany, isLoggedIn, isSignedIn, isSystemAdmin, keyAllows, loadConfig, localStorage, mergeForUpdate, mimeAllowed, mimeAllowedBy, parseId, populate, rateKeys, readCookie, resolveAdminModule, resolveConfig, rowFields, sharedEasyCMS, silentLogger, singularize, slugify, sniffMimeType, uniqueWithinOf, validateConfig, validateFields, verifyPassword, verifyTurnstile, writeBackupFile };
package/dist/index.js CHANGED
@@ -1,3 +1,8 @@
1
+ import {
2
+ conditionIssues,
3
+ matchesCondition
4
+ } from "./chunk-C4KJ5TFK.js";
5
+
1
6
  // src/access.ts
2
7
  var isSystemAdmin = (user) => user?.role === "admin" && !user.apiKey && user.scoped !== true;
3
8
  var anyone = () => true;
@@ -3003,6 +3008,9 @@ async function validateFields(fields, input, options, prefix = "") {
3003
3008
  const path = prefix + field.name;
3004
3009
  const raw = input[field.name];
3005
3010
  const fail = (message2) => errors.push({ field: path, message: message2 });
3011
+ const condition = field.admin?.condition;
3012
+ const skipRequired = options.skipRequired === true || condition !== void 0 && !matchesCondition(condition, input);
3013
+ const fieldOptions = skipRequired === (options.skipRequired === true) ? options : { ...options, skipRequired };
3006
3014
  if (field.type === "group") {
3007
3015
  if (!isEmpty(raw) && !isPlainObject(raw)) {
3008
3016
  fail("must be an object");
@@ -3011,7 +3019,7 @@ async function validateFields(fields, input, options, prefix = "") {
3011
3019
  const sub = await validateFields(
3012
3020
  field.fields,
3013
3021
  isPlainObject(raw) ? raw : {},
3014
- options,
3022
+ fieldOptions,
3015
3023
  `${path}.`
3016
3024
  );
3017
3025
  data[field.name] = sub.data;
@@ -3034,7 +3042,7 @@ async function validateFields(fields, input, options, prefix = "") {
3034
3042
  const failLocale = (message2) => errors.push({ field: localePath, message: message2 });
3035
3043
  const value2 = map[locale];
3036
3044
  if (isEmpty(value2) || Array.isArray(value2) && value2.length === 0) {
3037
- if (own && field.required && !options.skipRequired) failLocale("is required");
3045
+ if (own && field.required && !skipRequired) failLocale("is required");
3038
3046
  out[locale] = hasRows(field) || isHasMany(field) ? [] : null;
3039
3047
  if (own && !field.required) await runCustom(field, null, options, failLocale);
3040
3048
  continue;
@@ -3043,7 +3051,7 @@ async function validateFields(fields, input, options, prefix = "") {
3043
3051
  field,
3044
3052
  value2,
3045
3053
  localePath,
3046
- options,
3054
+ fieldOptions,
3047
3055
  failLocale,
3048
3056
  errors,
3049
3057
  references2
@@ -3056,14 +3064,14 @@ async function validateFields(fields, input, options, prefix = "") {
3056
3064
  continue;
3057
3065
  }
3058
3066
  if (isEmpty(raw) || Array.isArray(raw) && raw.length === 0) {
3059
- if (field.required && !options.skipRequired) fail("is required");
3060
- else if (!options.skipRequired && "minRows" in field && field.minRows)
3067
+ if (field.required && !skipRequired) fail("is required");
3068
+ else if (!skipRequired && "minRows" in field && field.minRows)
3061
3069
  fail(`must have at least ${itemsOf(field, field.minRows)}`);
3062
3070
  data[field.name] = hasRows(field) || isHasMany(field) ? [] : null;
3063
3071
  if (!field.required) await runCustom(field, null, options, fail);
3064
3072
  continue;
3065
3073
  }
3066
- const value = await normalizeValue(field, raw, path, options, fail, errors, references2);
3074
+ const value = await normalizeValue(field, raw, path, fieldOptions, fail, errors, references2);
3067
3075
  if (value === void 0) continue;
3068
3076
  data[field.name] = value;
3069
3077
  await runCustom(field, value, options, fail);
@@ -4837,6 +4845,70 @@ function rank(role, config) {
4837
4845
  return index === -1 ? config.auth.roles.length : index;
4838
4846
  }
4839
4847
 
4848
+ // src/nav.ts
4849
+ var BUILTIN_NAV = [
4850
+ { id: "content", label: { en: "Content", th: "\u0E40\u0E19\u0E37\u0E49\u0E2D\u0E2B\u0E32" }, icon: "file-text", order: 0 },
4851
+ {
4852
+ id: "settings",
4853
+ label: { en: "Settings", th: "\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32" },
4854
+ icon: "settings",
4855
+ order: 1e3,
4856
+ children: [
4857
+ { id: "site", label: { en: "Site", th: "\u0E40\u0E27\u0E47\u0E1A\u0E44\u0E0B\u0E15\u0E4C" }, icon: "globe" },
4858
+ { id: "people", label: { en: "Users & access", th: "\u0E1C\u0E39\u0E49\u0E43\u0E0A\u0E49\u0E41\u0E25\u0E30\u0E2A\u0E34\u0E17\u0E18\u0E34\u0E4C" }, icon: "users" },
4859
+ { id: "system", label: { en: "System", th: "\u0E23\u0E30\u0E1A\u0E1A" }, icon: "sliders-horizontal" }
4860
+ ]
4861
+ }
4862
+ ];
4863
+ var MEDIA_ORDER = 10;
4864
+ var LABEL_ORDER = 500;
4865
+ var NAV_ID = /^[a-z][a-z0-9-]*$/;
4866
+ function mergeGroups(lists, parent = "") {
4867
+ const byId = /* @__PURE__ */ new Map();
4868
+ let index = 0;
4869
+ for (const list of lists)
4870
+ for (const group of list) {
4871
+ const found = byId.get(group.id);
4872
+ if (found) {
4873
+ found.group = { ...group, ...stripUndefined(found.group) };
4874
+ if (group.children) found.children.push([...group.children]);
4875
+ } else {
4876
+ byId.set(group.id, {
4877
+ group,
4878
+ children: group.children ? [[...group.children]] : [],
4879
+ index: index++
4880
+ });
4881
+ }
4882
+ }
4883
+ return [...byId.values()].map(({ group, children, index: index2 }) => ({
4884
+ id: parent ? `${parent}.${group.id}` : group.id,
4885
+ label: group.label,
4886
+ ...group.icon ? { icon: group.icon } : {},
4887
+ order: group.order ?? 100 + index2,
4888
+ children: mergeGroups(children, parent ? `${parent}.${group.id}` : group.id)
4889
+ })).sort((a, b) => a.order - b.order);
4890
+ }
4891
+ function stripUndefined(value) {
4892
+ return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== void 0));
4893
+ }
4894
+ function navGroups(declared) {
4895
+ return mergeGroups([declared, BUILTIN_NAV]);
4896
+ }
4897
+ function navIds(groups) {
4898
+ const ids = /* @__PURE__ */ new Set();
4899
+ for (const g of groups) {
4900
+ ids.add(g.id);
4901
+ for (const c of g.children) ids.add(c.id);
4902
+ }
4903
+ return ids;
4904
+ }
4905
+ function groupOf(group, ids, fallback) {
4906
+ if (group === void 0 || group === null) return { id: fallback };
4907
+ if (group === "settings") return { id: "settings.site" };
4908
+ if (typeof group === "string" && ids.has(group)) return { id: group };
4909
+ return { id: `label:${JSON.stringify(group)}`, label: group };
4910
+ }
4911
+
4840
4912
  // src/webhooks.ts
4841
4913
  import { createHmac as createHmac5, randomUUID as randomUUID2 } from "crypto";
4842
4914
  var WEBHOOK_EVENTS = [
@@ -5091,11 +5163,13 @@ function validateConfig(config) {
5091
5163
  validateEvents(config, add);
5092
5164
  const apiKeys = config.apiKeys;
5093
5165
  if (apiKeys !== void 0 && typeof apiKeys !== "boolean") add("apiKeys", "must be true or false");
5166
+ const nav = validateNav(config, add);
5094
5167
  collections.forEach((collection2, i) => {
5095
5168
  const path = `collections.${collection2.slug ?? `[${i}]`}`;
5096
5169
  validateContainer(collection2, path, collectionSlugs, add, {
5097
5170
  collection: true,
5098
- rbac: config.auth?.rbac === true
5171
+ rbac: config.auth?.rbac === true,
5172
+ nav
5099
5173
  });
5100
5174
  validateUseAsTitle(collection2, path, add);
5101
5175
  validateIcon(collection2, path, add);
@@ -5107,7 +5181,8 @@ function validateConfig(config) {
5107
5181
  const path = `globals.${global2.slug ?? `[${i}]`}`;
5108
5182
  validateContainer(global2, path, collectionSlugs, add, {
5109
5183
  collection: false,
5110
- rbac: config.auth?.rbac === true
5184
+ rbac: config.auth?.rbac === true,
5185
+ nav
5111
5186
  });
5112
5187
  validateIcon(global2, path, add);
5113
5188
  });
@@ -5240,8 +5315,8 @@ function validateAdminViews(config, add) {
5240
5315
  "e.g. label: { en: 'Stats', th: '\u0E2A\u0E16\u0E34\u0E15\u0E34' }"
5241
5316
  );
5242
5317
  validateIcon(p, path, add);
5243
- if (p.group !== void 0 && p.group !== "content" && p.group !== "settings" && p.group !== false)
5244
- add(`${path}.group`, 'must be "content", "settings" or false');
5318
+ if (p.group !== "content" && p.group !== false)
5319
+ validateGroup(p.group, navIds(navGroups(config.admin?.nav ?? [])), `${path}.group`, add);
5245
5320
  access(p.access, path);
5246
5321
  });
5247
5322
  }
@@ -5688,7 +5763,142 @@ function checkSlug(container, path, seen, add) {
5688
5763
  seen.add(slug2);
5689
5764
  return slug2;
5690
5765
  }
5691
- function validateContainer(container, path, collectionSlugs, add, { collection: collection2, rbac }) {
5766
+ var isLabel = (value) => typeof value === "string" && value.trim() !== "" || typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).length > 0 && Object.values(value).every((v) => typeof v === "string");
5767
+ function validateNav(config, add) {
5768
+ const nav = config.admin?.nav;
5769
+ if (nav !== void 0) {
5770
+ if (!Array.isArray(nav))
5771
+ add("admin.nav", "must be a list of groups", "e.g. nav: [{ id: 'shop', label: 'Shop' }]");
5772
+ else
5773
+ nav.forEach((group, i) => {
5774
+ const check2 = (g, path, child) => {
5775
+ const { id, label, children, order } = g ?? {};
5776
+ if (typeof id !== "string" || !NAV_ID.test(id))
5777
+ add(`${path}.id`, "must be lowercase letters, digits and -", "e.g. 'shop'");
5778
+ if (!isLabel(label)) add(`${path}.label`, "must be a string or { en, th }");
5779
+ if (order !== void 0 && typeof order !== "number")
5780
+ add(`${path}.order`, "must be a number");
5781
+ validateIcon(g, path, add);
5782
+ if (children !== void 0) {
5783
+ if (child) add(`${path}.children`, "groups go one level deep");
5784
+ else if (!Array.isArray(children)) add(`${path}.children`, "must be a list of groups");
5785
+ else
5786
+ children.forEach((c, j) => {
5787
+ check2(c, `${path}.children[${j}]`, true);
5788
+ });
5789
+ }
5790
+ };
5791
+ check2(group, `admin.nav[${i}]`, false);
5792
+ });
5793
+ }
5794
+ const commands = config.admin?.commands;
5795
+ if (commands !== void 0) {
5796
+ if (!Array.isArray(commands)) add("admin.commands", "must be a list");
5797
+ else
5798
+ commands.forEach((c, i) => {
5799
+ const { label, to } = c ?? {};
5800
+ if (!isLabel(label)) add(`admin.commands[${i}].label`, "must be a string or { en, th }");
5801
+ if (typeof to !== "string" || !to.startsWith("/") || to.startsWith("//"))
5802
+ add(
5803
+ `admin.commands[${i}].to`,
5804
+ "must be a path in the admin",
5805
+ "e.g. '/collections/orders'"
5806
+ );
5807
+ });
5808
+ }
5809
+ return navIds(navGroups(Array.isArray(nav) ? nav : []));
5810
+ }
5811
+ function validateGroup(group, ids, path, add) {
5812
+ if (group === void 0 || group === "settings") return;
5813
+ if (typeof group === "string" && group.includes(".") && !ids.has(group)) {
5814
+ add(
5815
+ path,
5816
+ `no menu group "${group}" in admin.nav`,
5817
+ "declare it: nav: [{ id: 'shop', label: 'Shop', children: [{ id: 'catalog', label: 'Catalog' }] }]"
5818
+ );
5819
+ return;
5820
+ }
5821
+ if (!isLabel(group))
5822
+ add(
5823
+ path,
5824
+ `must be a group's id, 'settings' or a label (got ${JSON.stringify(group)})`,
5825
+ "e.g. group: 'shop' or { en: 'Shop', th: '\u0E23\u0E49\u0E32\u0E19\u0E04\u0E49\u0E32' }"
5826
+ );
5827
+ }
5828
+ function validateLayout(layout, container, path, add) {
5829
+ if (!Array.isArray(layout)) {
5830
+ add(path, "must be a list", "e.g. layout: [{ tab: 'Content', fields: ['title'] }]");
5831
+ return;
5832
+ }
5833
+ const fields = new Map((container.fields ?? []).map((f) => [f.name, f]));
5834
+ const seen = /* @__PURE__ */ new Set();
5835
+ const name = (value, at) => {
5836
+ if (typeof value !== "string" || !fields.has(value)) {
5837
+ add(at, `no top-level field ${JSON.stringify(value)}`);
5838
+ return;
5839
+ }
5840
+ if (fields.get(value)?.position === "sidebar")
5841
+ add(at, `"${value}" is in the sidebar (position: 'sidebar')`);
5842
+ if (seen.has(value)) add(at, `"${value}" is placed twice`);
5843
+ seen.add(value);
5844
+ };
5845
+ const nodes = (list, at, top) => {
5846
+ if (!Array.isArray(list)) {
5847
+ add(at, "must be a list");
5848
+ return;
5849
+ }
5850
+ const tabs = list.filter((n) => typeof n === "object" && n !== null && "tab" in n).length;
5851
+ if (top && tabs > 0 && tabs !== list.length)
5852
+ add(at, "with tabs, every entry at the top is a tab");
5853
+ list.forEach((node, i) => {
5854
+ const p = `${at}[${i}]`;
5855
+ if (typeof node === "string") {
5856
+ name(node, p);
5857
+ return;
5858
+ }
5859
+ const n = node ?? {};
5860
+ if ("tab" in n) {
5861
+ if (!top) add(p, "tabs go at the top of the layout only");
5862
+ if (!isLabel(n.tab)) add(`${p}.tab`, "must be a string or { en, th }");
5863
+ nodes(n.fields, `${p}.fields`, false);
5864
+ } else if ("collapsible" in n) {
5865
+ if (!isLabel(n.collapsible)) add(`${p}.collapsible`, "must be a string or { en, th }");
5866
+ nodes(n.fields, `${p}.fields`, false);
5867
+ } else if ("row" in n) {
5868
+ if (!Array.isArray(n.row) || n.row.length === 0)
5869
+ add(`${p}.row`, "must be a list of field names");
5870
+ else
5871
+ n.row.forEach((f, j) => {
5872
+ name(f, `${p}.row[${j}]`);
5873
+ });
5874
+ } else add(p, "must be a field name, { tab }, { collapsible } or { row }");
5875
+ });
5876
+ };
5877
+ nodes(layout, path, true);
5878
+ }
5879
+ function validateCollectionAdmin(admin, path, add) {
5880
+ const { badge, count, empty } = admin;
5881
+ if (badge !== void 0) {
5882
+ const b = badge ?? {};
5883
+ if (typeof b.where !== "object" || b.where === null || Array.isArray(b.where))
5884
+ add(`${path}.badge.where`, "must be a where query", "e.g. { status: { equals: 'paid' } }");
5885
+ if (b.tone !== void 0 && !["accent", "warning", "danger"].includes(b.tone))
5886
+ add(`${path}.badge.tone`, "must be 'accent', 'warning' or 'danger'");
5887
+ }
5888
+ if (count !== void 0 && typeof count !== "boolean")
5889
+ add(`${path}.count`, "must be true or false");
5890
+ if (empty !== void 0) {
5891
+ const e = empty ?? {};
5892
+ if (e.description !== void 0 && !isLabel(e.description))
5893
+ add(`${path}.empty.description`, "must be a string or { en, th }");
5894
+ if (e.link !== void 0) {
5895
+ const l = e.link ?? {};
5896
+ if (!isLabel(l.label) || typeof l.href !== "string")
5897
+ add(`${path}.empty.link`, "needs label and href");
5898
+ }
5899
+ }
5900
+ }
5901
+ function validateContainer(container, path, collectionSlugs, add, { collection: collection2, rbac, nav }) {
5692
5902
  if (!Array.isArray(container.fields)) {
5693
5903
  add(`${path}.fields`, "must be an array");
5694
5904
  return;
@@ -5720,13 +5930,11 @@ function validateContainer(container, path, collectionSlugs, add, { collection:
5720
5930
  } else {
5721
5931
  const sidebar = admin.sidebar;
5722
5932
  if (sidebar !== void 0) validateComponents(sidebar, `${path}.admin.sidebar`, add);
5723
- const group = admin.group;
5724
- if (group !== void 0 && group !== "settings" && !(collection2 && typeof group === "string" && group !== "") && !(collection2 && typeof group === "object" && group !== null && Object.values(group).every((v) => typeof v === "string")))
5725
- add(
5726
- `${path}.admin.group`,
5727
- `must be 'settings' or a label (got ${JSON.stringify(group)})`,
5728
- "e.g. group: { en: 'Shop', th: '\u0E23\u0E49\u0E32\u0E19\u0E04\u0E49\u0E32' }"
5729
- );
5933
+ validateGroup(admin.group, nav, `${path}.admin.group`, add);
5934
+ const layout = admin.layout;
5935
+ if (layout !== void 0) validateLayout(layout, container, `${path}.admin.layout`, add);
5936
+ if (collection2)
5937
+ validateCollectionAdmin(admin, `${path}.admin`, add);
5730
5938
  const list = admin.list;
5731
5939
  if (list !== void 0) validateList(list, container, `${path}.admin.list`, add);
5732
5940
  const owner = admin.ownerField;
@@ -5818,7 +6026,14 @@ function validateField(field, path, siblings, collectionSlugs, add) {
5818
6026
  if (typeof admin !== "object" || admin === null) {
5819
6027
  add(`${path}.admin`, "must be an object", "e.g. admin: { component: 'ecms-color-picker' }");
5820
6028
  } else {
5821
- const { component, after, cell } = admin;
6029
+ const { component, after, cell, width, condition, description } = admin;
6030
+ if (width !== void 0 && !["1/4", "1/3", "1/2", "2/3", "3/4", "full"].includes(width))
6031
+ add(`${path}.admin.width`, "must be '1/4', '1/3', '1/2', '2/3', '3/4' or 'full'");
6032
+ if (description !== void 0 && !isLabel(description))
6033
+ add(`${path}.admin.description`, "must be a string or { en, th }");
6034
+ if (condition !== void 0)
6035
+ for (const issue of conditionIssues(condition, [...siblings.keys()]))
6036
+ add(`${path}.admin.condition`, issue);
5822
6037
  if (component !== void 0) validateComponent(component, `${path}.admin.component`, add);
5823
6038
  if (after !== void 0) validateComponents(after, `${path}.admin.after`, add);
5824
6039
  if (cell !== void 0) validateComponent(cell, `${path}.admin.cell`, add);
@@ -6183,7 +6398,9 @@ async function resolveConfig(input) {
6183
6398
  modules: config.admin?.modules ?? [],
6184
6399
  pages: config.admin?.pages ?? [],
6185
6400
  dashboard: config.admin?.dashboard ?? [],
6186
- switcher: config.admin?.switcher ?? null
6401
+ switcher: config.admin?.switcher ?? null,
6402
+ nav: config.admin?.nav ?? [],
6403
+ commands: config.admin?.commands ?? []
6187
6404
  },
6188
6405
  upload: {
6189
6406
  dir: config.upload?.dir ?? "uploads",
@@ -8825,7 +9042,7 @@ async function readAdminModule(cms, index) {
8825
9042
  }
8826
9043
 
8827
9044
  // src/rest/admin-schema.ts
8828
- var SETTINGS = /* @__PURE__ */ new Set([USERS, API_KEYS]);
9045
+ var PEOPLE = /* @__PURE__ */ new Set([USERS, API_KEYS]);
8829
9046
  function componentRef(component) {
8830
9047
  if (typeof component === "string") return { tag: component };
8831
9048
  return component.props ? { tag: component.tag, props: JSON.parse(JSON.stringify(component.props)) } : { tag: component.tag };
@@ -8861,6 +9078,9 @@ async function serializeFields(fields, checks, localized, folderId, who) {
8861
9078
  };
8862
9079
  }
8863
9080
  if (field.customType) f.customType = field.customType;
9081
+ if (field.admin?.description !== void 0) f.description = field.admin.description;
9082
+ if (field.admin?.width) f.width = field.admin.width;
9083
+ if (field.admin?.condition) f.condition = JSON.parse(JSON.stringify(field.admin.condition));
8864
9084
  switch (field.type) {
8865
9085
  case "text":
8866
9086
  case "textarea":
@@ -8967,10 +9187,13 @@ async function collection(cms, config, user, localized, roles, context) {
8967
9187
  if (config.labels) result.labels = config.labels;
8968
9188
  if (config.useAsTitle) result.useAsTitle = config.useAsTitle;
8969
9189
  if (config.icon) result.icon = config.icon;
8970
- if (config.admin?.sidebar?.length) result.sidebar = config.admin.sidebar.map(componentRef);
8971
- const group = config.admin?.group;
8972
- if (group === "settings" || SETTINGS.has(config.slug)) result.group = "settings";
8973
- else if (group !== void 0) result.section = group;
9190
+ if (config.admin?.sidebar?.length) result.sidebar = config.admin.sidebar.map(panelRef);
9191
+ if (config.admin?.layout?.length) result.layout = layoutOf(config.admin.layout, fields);
9192
+ const badge = config.admin?.badge;
9193
+ if (badge)
9194
+ result.badge = { tone: badge.tone ?? "accent", ...badge.label ? { label: badge.label } : {} };
9195
+ if (config.admin?.count === false) result.noCount = true;
9196
+ if (config.admin?.empty) result.empty = JSON.parse(JSON.stringify(config.admin.empty));
8974
9197
  if (config.admin?.list?.tree) result.tree = config.admin.list.tree;
8975
9198
  if (config.admin?.list?.sort) result.defaultSort = config.admin.list.sort;
8976
9199
  if (config.admin?.confirmDelete) result.confirmDelete = { ...config.admin.confirmDelete };
@@ -9001,7 +9224,8 @@ async function global(cms, config, user, localized, context) {
9001
9224
  };
9002
9225
  if (config.label !== void 0) result.label = config.label;
9003
9226
  if (config.icon) result.icon = config.icon;
9004
- if (config.admin?.sidebar?.length) result.sidebar = config.admin.sidebar.map(componentRef);
9227
+ if (config.admin?.sidebar?.length) result.sidebar = config.admin.sidebar.map(panelRef);
9228
+ if (config.admin?.layout?.length) result.layout = layoutOf(config.admin.layout, result.fields);
9005
9229
  return result;
9006
9230
  }
9007
9231
  async function adminSchema(cms, user, origin, context = {}) {
@@ -9022,18 +9246,23 @@ async function adminSchema(cms, user, origin, context = {}) {
9022
9246
  audit: cms.audit.enabled && await cms.roles.canView(user, "audit")
9023
9247
  };
9024
9248
  const roles = await cms.roles.options();
9249
+ const adminCollections = await Promise.all(
9250
+ collections.map((c) => collection(cms, c, user, localized, roles, context))
9251
+ );
9252
+ const adminGlobals = await Promise.all(
9253
+ cms.config.globals.map((g) => global(cms, g, user, localized, context))
9254
+ );
9255
+ const adminPages = await pages(cms, user);
9025
9256
  return {
9026
9257
  locale: cms.config.admin.locale,
9027
9258
  switcher: cms.config.admin.switcher,
9028
9259
  passwordLinks: cms.auth.canSendPasswordLinks(origin),
9029
9260
  menu: [...cms.config.admin.menu],
9261
+ nav: buildNav(cms, adminCollections, adminGlobals, adminPages, views),
9262
+ commands: cms.config.admin.commands.map((c) => JSON.parse(JSON.stringify(c))),
9030
9263
  localization: localization ? { locales: [...localization.locales], defaultLocale: localization.defaultLocale } : null,
9031
- collections: await Promise.all(
9032
- collections.map((c) => collection(cms, c, user, localized, roles, context))
9033
- ),
9034
- globals: await Promise.all(
9035
- cms.config.globals.map((g) => global(cms, g, user, localized, context))
9036
- ),
9264
+ collections: adminCollections,
9265
+ globals: adminGlobals,
9037
9266
  modules: adminModuleUrls(cms),
9038
9267
  uploadFromURL: cms.config.upload.fromURL !== void 0,
9039
9268
  upload: {
@@ -9056,7 +9285,7 @@ async function adminSchema(cms, user, origin, context = {}) {
9056
9285
  email: cms.config.collections.some((c) => c.slug === EMAIL_DELIVERIES)
9057
9286
  }
9058
9287
  } : {},
9059
- pages: await pages(cms, user),
9288
+ pages: adminPages,
9060
9289
  dashboard: await widgets(cms, user)
9061
9290
  };
9062
9291
  }
@@ -9077,7 +9306,6 @@ async function pages(cms, user) {
9077
9306
  path: page.path,
9078
9307
  label: page.label,
9079
9308
  ...page.icon ? { icon: page.icon } : {},
9080
- group: page.group ?? "content",
9081
9309
  component: componentRef(page.component)
9082
9310
  });
9083
9311
  }
@@ -9096,9 +9324,200 @@ async function widgets(cms, user) {
9096
9324
  function folderIds(cms, user, context) {
9097
9325
  return cms.folders.enabled ? (key2) => cms.keyedFolder(key2, { user, context }) : void 0;
9098
9326
  }
9327
+ function panelRef(panel) {
9328
+ const ref = componentRef(panel);
9329
+ if (typeof panel === "object" && "position" in panel && panel.position === "top")
9330
+ ref.position = "top";
9331
+ return ref;
9332
+ }
9333
+ function layoutOf(layout, fields) {
9334
+ const shown2 = new Set(fields.filter((f) => f.position !== "sidebar").map((f) => f.name));
9335
+ const placed = /* @__PURE__ */ new Set();
9336
+ const convert = (nodes) => nodes.flatMap((node) => {
9337
+ if (typeof node === "string") {
9338
+ if (!shown2.has(node)) return [];
9339
+ placed.add(node);
9340
+ return [{ type: "field", name: node }];
9341
+ }
9342
+ if ("row" in node) {
9343
+ const names2 = node.row.filter((n) => shown2.has(n));
9344
+ for (const n of names2) placed.add(n);
9345
+ return names2.length ? [{ type: "row", fields: names2 }] : [];
9346
+ }
9347
+ if ("collapsible" in node) {
9348
+ const inner2 = convert(node.fields);
9349
+ return inner2.length ? [
9350
+ {
9351
+ type: "collapsible",
9352
+ label: node.collapsible,
9353
+ ...node.collapsed ? { collapsed: true } : {},
9354
+ ...node.description !== void 0 ? { description: node.description } : {},
9355
+ nodes: inner2
9356
+ }
9357
+ ] : [];
9358
+ }
9359
+ const inner = convert(node.fields);
9360
+ return [
9361
+ {
9362
+ type: "tab",
9363
+ label: node.tab,
9364
+ ...node.description !== void 0 ? { description: node.description } : {},
9365
+ nodes: inner
9366
+ }
9367
+ ];
9368
+ });
9369
+ const out = convert(layout);
9370
+ const rest = [...shown2].filter((n) => !placed.has(n)).map((name) => ({ type: "field", name }));
9371
+ const first = out[0];
9372
+ if (first?.type === "tab") first.nodes.push(...rest);
9373
+ else out.push(...rest);
9374
+ return out.filter((n) => n.type !== "tab" || n.nodes.length > 0);
9375
+ }
9376
+ var VIEW_GROUPS = [
9377
+ ["roles", "settings.people"],
9378
+ ["sso", "settings.people"],
9379
+ ["backups", "settings.system"],
9380
+ ["email", "settings.system"],
9381
+ ["deliveries", "settings.system"],
9382
+ ["audit", "settings.system"]
9383
+ ];
9384
+ function buildNav(cms, collections, globals, pageRefs, views) {
9385
+ const groups = navGroups(cms.config.admin.nav);
9386
+ const ids = navIds(groups);
9387
+ const items = /* @__PURE__ */ new Map();
9388
+ const labelGroups = /* @__PURE__ */ new Map();
9389
+ const put = (group, item) => {
9390
+ if (group.label !== void 0 && !labelGroups.has(group.id))
9391
+ labelGroups.set(group.id, group.label);
9392
+ const list = items.get(group.id) ?? [];
9393
+ list.push(item);
9394
+ items.set(group.id, list);
9395
+ };
9396
+ const configOf = (slug2) => cms.config.collections.find((c) => c.slug === slug2);
9397
+ const menu = cms.config.admin.menu;
9398
+ const rank2 = (slug2, index) => {
9399
+ const at = menu.indexOf(slug2);
9400
+ return at === -1 ? menu.length + index : at;
9401
+ };
9402
+ const ordered = collections.map((c, index) => ({ c, key: rank2(c.slug, index) })).sort((a, b) => a.key - b.key).map(({ c }) => c);
9403
+ let media = false;
9404
+ for (const c of ordered) {
9405
+ if (!c.permissions.read || c.slug === MEDIA_FOLDERS) continue;
9406
+ if (c.slug === MEDIA) {
9407
+ media = true;
9408
+ continue;
9409
+ }
9410
+ const group = PEOPLE.has(c.slug) ? { id: "settings.people" } : groupOf(configOf(c.slug)?.admin?.group, ids, "content");
9411
+ put(group, { kind: "collection", slug: c.slug });
9412
+ }
9413
+ for (const g of globals) {
9414
+ if (!g.permissions.read) continue;
9415
+ const config = cms.config.globals.find((x) => x.slug === g.slug);
9416
+ put(groupOf(config?.admin?.group, ids, "settings.site"), { kind: "global", slug: g.slug });
9417
+ }
9418
+ for (const p of pageRefs) {
9419
+ const config = cms.config.admin.pages.find((x) => x.path === p.path);
9420
+ const group = config?.group ?? "content";
9421
+ if (group === false) continue;
9422
+ put(groupOf(group, ids, "content"), { kind: "page", path: p.path });
9423
+ }
9424
+ for (const [view, group] of VIEW_GROUPS)
9425
+ if (views[view]) put({ id: group }, { kind: "view", view });
9426
+ const toGroup = (g) => {
9427
+ const children = g.children.map(toGroup).filter((c) => c !== null);
9428
+ const own = items.get(g.id) ?? [];
9429
+ if (own.length === 0 && children.length === 0) return null;
9430
+ return {
9431
+ kind: "group",
9432
+ id: g.id,
9433
+ label: g.label,
9434
+ ...g.icon ? { icon: g.icon } : {},
9435
+ items: [...own, ...children]
9436
+ };
9437
+ };
9438
+ const root = [];
9439
+ for (const g of groups) {
9440
+ const node = toGroup(g);
9441
+ if (node) root.push({ order: g.order, node });
9442
+ }
9443
+ let i = 0;
9444
+ for (const [id, label] of labelGroups) {
9445
+ const own = items.get(id) ?? [];
9446
+ if (own.length)
9447
+ root.push({ order: LABEL_ORDER + i++, node: { kind: "group", id, label, items: own } });
9448
+ }
9449
+ if (media) root.push({ order: MEDIA_ORDER, node: { kind: "collection", slug: MEDIA } });
9450
+ return root.sort((a, b) => a.order - b.order).map((r) => r.node);
9451
+ }
9452
+
9453
+ // src/rest/admin-search.ts
9454
+ var listed = (cms) => cms.config.collections.filter(
9455
+ (c) => !INTERNAL_COLLECTIONS.has(c.slug) && c.slug !== MEDIA_FOLDERS
9456
+ );
9457
+ async function readable(cms, c, who) {
9458
+ try {
9459
+ await cms.count(c.slug, { ...who, overrideAccess: false, where: { id: { exists: false } } });
9460
+ return true;
9461
+ } catch {
9462
+ return false;
9463
+ }
9464
+ }
9465
+ async function adminCounts(cms, who) {
9466
+ const counts = {};
9467
+ const badges = {};
9468
+ await Promise.all(
9469
+ listed(cms).map(async (c) => {
9470
+ const read2 = { ...who, overrideAccess: false, draft: true };
9471
+ try {
9472
+ if (c.admin?.count !== false) counts[c.slug] = await cms.count(c.slug, read2);
9473
+ if (c.admin?.badge)
9474
+ badges[c.slug] = await cms.count(c.slug, { ...read2, where: c.admin.badge.where });
9475
+ } catch {
9476
+ }
9477
+ })
9478
+ );
9479
+ return { counts, badges };
9480
+ }
9481
+ async function adminSearch(cms, who, q, perCollection = 5) {
9482
+ const query = q.trim().slice(0, 100);
9483
+ if (query.length < 1) return [];
9484
+ const results = await Promise.all(
9485
+ listed(cms).map(async (c) => {
9486
+ const title = c.useAsTitle ?? (c.fields.some((f) => f.name === "title") ? "title" : void 0);
9487
+ const conditions = [];
9488
+ if (title) conditions.push({ [title]: { like: query } });
9489
+ if (/^\d+$/.test(query)) conditions.push({ id: { equals: Number(query) } });
9490
+ if (conditions.length === 0 || !await readable(cms, c, who)) return [];
9491
+ try {
9492
+ const found = await cms.find(c.slug, {
9493
+ ...who,
9494
+ overrideAccess: false,
9495
+ draft: true,
9496
+ depth: 0,
9497
+ limit: perCollection,
9498
+ where: conditions.length === 1 ? conditions[0] : { or: conditions },
9499
+ sort: "-updatedAt"
9500
+ });
9501
+ return found.docs.map((doc) => {
9502
+ const d = doc;
9503
+ const value = title ? d[title] : void 0;
9504
+ return {
9505
+ collection: c.slug,
9506
+ id: d.id,
9507
+ title: typeof value === "string" && value ? value : `#${String(d.id)}`,
9508
+ ...typeof d.status === "string" ? { status: d.status } : {}
9509
+ };
9510
+ });
9511
+ } catch {
9512
+ return [];
9513
+ }
9514
+ })
9515
+ );
9516
+ return results.flat();
9517
+ }
9099
9518
 
9100
9519
  // src/version.ts
9101
- var VERSION = true ? "0.46.0" : "0.0.0-dev";
9520
+ var VERSION = true ? "0.47.1" : "0.0.0-dev";
9102
9521
 
9103
9522
  // src/rest/admin-status.ts
9104
9523
  var DAY2 = 864e5;
@@ -9821,6 +10240,18 @@ async function route(cms, ctx, method, segments, options) {
9821
10240
  if (!ctx.user) throw new UnauthorizedError();
9822
10241
  if (second === "schema" && third === void 0)
9823
10242
  return { body: await adminSchema(cms, ctx.user, ctx.url.origin, ctx.context) };
10243
+ if (second === "counts" && third === void 0)
10244
+ return { body: await adminCounts(cms, { user: ctx.user, context: ctx.context }) };
10245
+ if (second === "search" && third === void 0)
10246
+ return {
10247
+ body: {
10248
+ docs: await adminSearch(
10249
+ cms,
10250
+ { user: ctx.user, context: ctx.context },
10251
+ ctx.url.searchParams.get("q") ?? ""
10252
+ )
10253
+ }
10254
+ };
9824
10255
  if (second === "modules" && third !== void 0 && segments.length === 3) {
9825
10256
  const index = /^(\d+)\.js$/.exec(third)?.[1];
9826
10257
  const file = index === void 0 ? void 0 : await readAdminModule(cms, Number(index));
@@ -10667,6 +11098,7 @@ export {
10667
11098
  AuditLog,
10668
11099
  Auth,
10669
11100
  BUILTIN_COLLECTIONS,
11101
+ BUILTIN_NAV,
10670
11102
  CONFIG_FILE_NAMES,
10671
11103
  CSRF_COOKIE,
10672
11104
  CSRF_HEADER,
@@ -10710,6 +11142,7 @@ export {
10710
11142
  applyDefaults,
10711
11143
  applyPlugins,
10712
11144
  checkFormToken,
11145
+ conditionIssues,
10713
11146
  configSignature,
10714
11147
  consoleEmail,
10715
11148
  consoleLogger,
@@ -10740,6 +11173,7 @@ export {
10740
11173
  keyAllows,
10741
11174
  loadConfig,
10742
11175
  localStorage,
11176
+ matchesCondition,
10743
11177
  mergeForUpdate,
10744
11178
  mimeAllowed,
10745
11179
  mimeAllowedBy,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "0.46.0",
3
+ "version": "0.47.1",
4
4
  "description": "Embedded, code-first headless CMS for Nuxt and Next.js",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,6 +14,10 @@
14
14
  "types": "./dist/live-preview.d.ts",
15
15
  "default": "./dist/live-preview.js"
16
16
  },
17
+ "./conditions": {
18
+ "types": "./dist/conditions.d.ts",
19
+ "default": "./dist/conditions.js"
20
+ },
17
21
  "./package.json": "./package.json"
18
22
  },
19
23
  "files": [