@easy-cms/core 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -194,6 +194,21 @@ interface FieldValidateContext {
194
194
  }
195
195
  /** Return `true` when valid, or an error message to show on the field. */
196
196
  type FieldValidate<TValue> = (value: TValue | null | undefined, ctx: FieldValidateContext) => true | string | Promise<true | string>;
197
+ /**
198
+ * A Web Component from an admin module (`admin.modules`), by its tag name, which must start
199
+ * with `ecms-`. `props` (plain JSON) reach the element as its `options` property.
200
+ */
201
+ type AdminComponent = string | {
202
+ readonly tag: string;
203
+ readonly props?: Readonly<Record<string, unknown>>;
204
+ };
205
+ /** How the admin shows a field, with components from admin modules. */
206
+ interface FieldAdmin {
207
+ /** Shown instead of the field's input; the admin keeps the label and error messages. */
208
+ readonly component?: AdminComponent;
209
+ /** Shown below the field, e.g. a length meter or a preview. */
210
+ readonly after?: readonly AdminComponent[];
211
+ }
197
212
  interface BaseField<TType extends string, TValue> {
198
213
  readonly type: TType;
199
214
  readonly name: string;
@@ -211,6 +226,10 @@ interface BaseField<TType extends string, TValue> {
211
226
  * hasMany fields: localize the fields inside a group or array instead.
212
227
  */
213
228
  readonly localized?: boolean;
229
+ /** `sidebar`: shown in the edit page's side panel instead of the main form (top-level fields). */
230
+ readonly position?: 'sidebar';
231
+ /** Custom admin components for this field. */
232
+ readonly admin?: FieldAdmin;
214
233
  }
215
234
  interface TextField extends BaseField<'text', string> {
216
235
  readonly minLength?: number;
@@ -427,6 +446,11 @@ interface VersionsConfig {
427
446
  /** Versions kept per document; older ones are deleted. Default 50. */
428
447
  readonly max?: number;
429
448
  }
449
+ /** Custom admin components for a collection's or global's edit page. */
450
+ interface ContainerAdmin {
451
+ /** Panels in the edit page's side column, below publishing (components from admin modules). */
452
+ readonly sidebar?: readonly AdminComponent[];
453
+ }
430
454
  interface CollectionConfig {
431
455
  /** URL and table name. Lowercase letters, digits, `-` and `_`. */
432
456
  readonly slug: string;
@@ -436,6 +460,12 @@ interface CollectionConfig {
436
460
  };
437
461
  /** Icon in the admin menu. Default `file-text`. */
438
462
  readonly icon?: AdminIcon;
463
+ /**
464
+ * How the admin opens documents from the list: `drawer` slides a panel over the list, handy
465
+ * for small collections (categories, tags). Default `page`. Collections with drafts, versions
466
+ * or live preview always use the page.
467
+ */
468
+ readonly editIn?: 'page' | 'drawer';
439
469
  readonly fields: readonly Field[];
440
470
  /** Top-level field shown as the document title in the admin UI. */
441
471
  readonly useAsTitle?: string;
@@ -453,6 +483,8 @@ interface CollectionConfig {
453
483
  readonly preview?: PreviewURL;
454
484
  readonly access?: CollectionAccess;
455
485
  readonly hooks?: CollectionHooks;
486
+ /** Custom admin components. */
487
+ readonly admin?: ContainerAdmin;
456
488
  }
457
489
  interface GlobalConfig {
458
490
  readonly slug: string;
@@ -469,6 +501,8 @@ interface GlobalConfig {
469
501
  readonly preview?: PreviewURL;
470
502
  readonly access?: GlobalAccess;
471
503
  readonly hooks?: GlobalHooks;
504
+ /** Custom admin components. */
505
+ readonly admin?: ContainerAdmin;
472
506
  }
473
507
  type AdminLocale = 'en' | 'th';
474
508
  /** Icons a collection or global can show in the admin menu ([Lucide](https://lucide.dev) names). */
@@ -490,6 +524,24 @@ interface AdminConfig {
490
524
  readonly locale?: AdminLocale;
491
525
  /** Name, logo and color of the admin UI. */
492
526
  readonly brand?: AdminBrand;
527
+ /**
528
+ * The public site, for the admin's "View site" link: a path (`/`) or an `https://` URL.
529
+ * Default `/` with Nuxt and Next.js; none with the standalone server.
530
+ */
531
+ readonly siteUrl?: string;
532
+ /**
533
+ * Order of collections in the admin menu, by slug, e.g. `['posts', 'categories', 'media']`.
534
+ * Collections not listed follow in config order, with the media library last.
535
+ */
536
+ readonly menu?: readonly string[];
537
+ /**
538
+ * JavaScript modules the admin loads after login, which define Web Components used by
539
+ * fields (`admin.component`, `admin.after`) and edit pages (`admin.sidebar`). Each entry is a
540
+ * package export (`'@easy-cms/plugin-seo/admin'`) or a file path relative to the project root
541
+ * (`'./admin/color-picker.js'`), resolved on the server. A module must be one self-contained ES
542
+ * module file. Remote URLs are not allowed.
543
+ */
544
+ readonly modules?: readonly string[];
493
545
  }
494
546
  interface ImageSize {
495
547
  /** Key in `media.sizes`, e.g. `thumbnail`. */
@@ -530,6 +582,36 @@ interface AuthConfig {
530
582
  */
531
583
  readonly trustedOrigins?: readonly string[];
532
584
  }
585
+ /** What an endpoint's handler receives. */
586
+ interface EndpointRequest {
587
+ readonly request: Request;
588
+ readonly url: URL;
589
+ /** Values of `:name` segments in the endpoint's path. */
590
+ readonly params: Readonly<Record<string, string>>;
591
+ /** The logged-in user (session cookie or Bearer token), or `null`. */
592
+ readonly user: AuthUser | null;
593
+ /** The Local API. Pass `{ user, overrideAccess: false }` to apply the user's access rules. */
594
+ readonly cms: EasyCMS;
595
+ /** The request's JSON body, which must be an object (at most 1 MB). */
596
+ json(): Promise<Record<string, unknown>>;
597
+ }
598
+ /**
599
+ * A custom REST endpoint, served under `routes.api`. Writes from the browser pass the same
600
+ * CSRF check as the built-in endpoints.
601
+ */
602
+ interface Endpoint {
603
+ /**
604
+ * Path under the API, e.g. `/seo/generate` or `/stats/:collection`. The first segment must
605
+ * not be a collection slug or one of `users`, `globals`, `admin`, `jobs`.
606
+ */
607
+ readonly path: string;
608
+ readonly method: 'get' | 'post' | 'put' | 'patch' | 'delete';
609
+ /**
610
+ * Returns a `Response`, or a value that is sent as JSON. Throw an Easy CMS error
611
+ * (`UnauthorizedError`, `ForbiddenError`, `ValidationError`, `NotFoundError`) for error responses.
612
+ */
613
+ readonly handler: (request: EndpointRequest) => MaybePromise<unknown>;
614
+ }
533
615
  /** Receives the config and returns a modified copy. Runs before validation. */
534
616
  type Plugin = (config: Config) => MaybePromise<Config>;
535
617
  interface Config {
@@ -567,10 +649,12 @@ interface Config {
567
649
  */
568
650
  readonly collections?: readonly CollectionConfig[];
569
651
  readonly globals?: readonly GlobalConfig[];
652
+ /** Custom REST endpoints, e.g. from plugins. */
653
+ readonly endpoints?: readonly Endpoint[];
570
654
  readonly plugins?: readonly Plugin[];
571
655
  }
572
656
  /** The config after plugins ran, validation passed and defaults were applied. */
573
- interface ResolvedConfig extends Omit<Config, 'cors' | 'localization' | 'routes' | 'admin' | 'upload' | 'auth' | 'collections' | 'globals' | 'plugins'> {
657
+ interface ResolvedConfig extends Omit<Config, 'cors' | 'localization' | 'routes' | 'admin' | 'upload' | 'auth' | 'collections' | 'globals' | 'endpoints' | 'plugins'> {
574
658
  readonly cors: readonly string[] | '*';
575
659
  /** `null` without localization. */
576
660
  readonly localization: Required<LocalizationConfig> | null;
@@ -580,6 +664,7 @@ interface ResolvedConfig extends Omit<Config, 'cors' | 'localization' | 'routes'
580
664
  readonly auth: Required<AuthConfig>;
581
665
  readonly collections: readonly CollectionConfig[];
582
666
  readonly globals: readonly GlobalConfig[];
667
+ readonly endpoints: readonly Endpoint[];
583
668
  }
584
669
  /**
585
670
  * Declares the Easy CMS config. Returns it unchanged, keeping literal types
@@ -908,9 +993,11 @@ declare class EasyCMS<C extends Config = Config> {
908
993
  readonly auth: Auth;
909
994
  /** Where uploaded files are stored. */
910
995
  readonly storage: StorageAdapter;
996
+ /** Project root: relative paths in the config (e.g. `admin.modules`) start here. */
997
+ readonly cwd: string;
911
998
  private readonly versions;
912
999
  private readonly webhooks;
913
- constructor(config: ResolvedConfig, db: Database, logger?: Logger, storage?: StorageAdapter);
1000
+ constructor(config: ResolvedConfig, db: Database, logger?: Logger, storage?: StorageAdapter, cwd?: string);
914
1001
  find<S extends Slug<C>>(collection: S, options?: FindOptions): Promise<PaginatedDocs<Doc<C, S>>>;
915
1002
  findById<S extends Slug<C>>(collection: S, id: ID, options?: ReadOptions): Promise<Doc<C, S> | null>;
916
1003
  count<S extends Slug<C>>(collection: S, options?: {
@@ -1337,12 +1424,29 @@ declare const DEFAULT_ADMIN_PATH = "/admin";
1337
1424
  declare const DEFAULT_API_PATH = "/api/cms";
1338
1425
  declare const DEFAULT_MAX_FILE_SIZE: number;
1339
1426
  declare const DEFAULT_TOKEN_EXPIRATION: number;
1427
+ /**
1428
+ * Runs the config's plugins in order, without validating the result. Build tools use it to
1429
+ * read what plugins add (e.g. `admin.modules`) when the config is not complete yet.
1430
+ */
1431
+ declare function applyPlugins(input: Config): Promise<Config>;
1340
1432
  /**
1341
1433
  * Runs plugins in order, adds the built-in collections, validates the result
1342
1434
  * and fills in defaults. Throws `ConfigError`. Resolving twice is a no-op.
1343
1435
  */
1344
1436
  declare function resolveConfig(input: Config | ResolvedConfig): Promise<ResolvedConfig>;
1345
1437
 
1438
+ /**
1439
+ * Finds the file of an admin module (`admin.modules`): a package export or a path relative to
1440
+ * the project root. Looks from the project root first, then from the server's entry file
1441
+ * (a Nuxt `.output/server` build keeps traced packages next to it). `undefined` when not found.
1442
+ */
1443
+ declare function resolveAdminModule(specifier: string, cwd?: string): string | undefined;
1444
+
1445
+ /** A Web Component from an admin module, with its `props` (plain JSON). */
1446
+ interface AdminComponentRef {
1447
+ tag: string;
1448
+ props?: Record<string, unknown>;
1449
+ }
1346
1450
  /** A field as the admin UI sees it: plain JSON, no functions. */
1347
1451
  interface AdminField {
1348
1452
  name: string;
@@ -1378,6 +1482,13 @@ interface AdminField {
1378
1482
  readOnly?: boolean;
1379
1483
  /** One value per content locale. */
1380
1484
  localized?: boolean;
1485
+ /** Shown in the edit page's side panel. */
1486
+ position?: 'sidebar';
1487
+ /** Components from admin modules: instead of the input, and below the field. */
1488
+ admin?: {
1489
+ component?: AdminComponentRef;
1490
+ after?: AdminComponentRef[];
1491
+ };
1381
1492
  }
1382
1493
  interface AdminCollection {
1383
1494
  slug: string;
@@ -1387,6 +1498,8 @@ interface AdminCollection {
1387
1498
  };
1388
1499
  icon?: string;
1389
1500
  useAsTitle?: string;
1501
+ /** Documents open in a panel over the list. */
1502
+ editIn?: 'drawer';
1390
1503
  drafts: boolean;
1391
1504
  versions: boolean;
1392
1505
  /** Has live preview (`preview` in the config). */
@@ -1394,6 +1507,8 @@ interface AdminCollection {
1394
1507
  /** Publishing can be scheduled. */
1395
1508
  schedule: boolean;
1396
1509
  fields: AdminField[];
1510
+ /** Panels from admin modules in the edit page's side column. */
1511
+ sidebar?: AdminComponentRef[];
1397
1512
  permissions: {
1398
1513
  read: boolean;
1399
1514
  create: boolean;
@@ -1412,6 +1527,8 @@ interface AdminGlobal {
1412
1527
  /** Publishing can be scheduled. */
1413
1528
  schedule: boolean;
1414
1529
  fields: AdminField[];
1530
+ /** Panels from admin modules in the edit page's side column. */
1531
+ sidebar?: AdminComponentRef[];
1415
1532
  permissions: {
1416
1533
  read: boolean;
1417
1534
  update: boolean;
@@ -1419,6 +1536,8 @@ interface AdminGlobal {
1419
1536
  }
1420
1537
  interface AdminSchema {
1421
1538
  locale: AdminLocale;
1539
+ /** Menu order of collections (`admin.menu`); unlisted ones follow. */
1540
+ menu: string[];
1422
1541
  /** Content locales, when the config has `localization`. */
1423
1542
  localization: {
1424
1543
  locales: string[];
@@ -1426,6 +1545,8 @@ interface AdminSchema {
1426
1545
  } | null;
1427
1546
  collections: AdminCollection[];
1428
1547
  globals: AdminGlobal[];
1548
+ /** URLs under the API of the admin modules to load (`admin.modules`). */
1549
+ modules: string[];
1429
1550
  }
1430
1551
 
1431
1552
  declare const SESSION_COOKIE = "ecms-session";
@@ -1456,4 +1577,4 @@ declare const BUILTIN_COLLECTIONS: readonly ["users", "media"];
1456
1577
  /** Checks a config and returns every problem found. An empty array means it is valid. */
1457
1578
  declare function validateConfig(config: Config): ConfigIssue[];
1458
1579
 
1459
- export { ADMIN_ICONS, type Access, type AccessArgs, type AccessOptions, type AdminBrand, type AdminCollection, type AdminConfig, type AdminField, type AdminGlobal, type AdminIcon, 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 Block, type BlocksField, 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, type ScheduledJob, 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, WEBHOOK_EVENTS, type WebhookConfig, type WebhookEvent, type WebhookPayload, type Where, type WhereOperators, anyone, applyDefaults, consoleLogger, createEasyCMS, createRestHandler, defineConfig, fillMissing, findConfigFile, generateSlugs, generateTypes, hasRows, hashPassword, imageDimensions, importConfig, isAdmin, isLoggedIn, loadConfig, localStorage, mergeForUpdate, mimeAllowed, parseId, populate, readCookie, resolveConfig, rowFields, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword };
1580
+ export { ADMIN_ICONS, type Access, type AccessArgs, type AccessOptions, type AdminBrand, type AdminCollection, type AdminComponent, type AdminComponentRef, type AdminConfig, type AdminField, type AdminGlobal, type AdminIcon, 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 Block, type BlocksField, 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 ContainerAdmin, 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, type Endpoint, type EndpointRequest, FIELD_TYPES, type Field, type FieldAccess, type FieldAccessFn, type FieldAdmin, 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, type ScheduledJob, 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, WEBHOOK_EVENTS, type WebhookConfig, type WebhookEvent, type WebhookPayload, type Where, type WhereOperators, anyone, applyDefaults, applyPlugins, consoleLogger, createEasyCMS, createRestHandler, defineConfig, fillMissing, findConfigFile, generateSlugs, generateTypes, hasRows, hashPassword, imageDimensions, importConfig, isAdmin, isLoggedIn, loadConfig, localStorage, mergeForUpdate, mimeAllowed, parseId, populate, readCookie, resolveAdminModule, resolveConfig, rowFields, silentLogger, singularize, slugify, sniffMimeType, validateConfig, validateFields, verifyPassword };
package/dist/index.js CHANGED
@@ -1279,15 +1279,30 @@ function validateConfig(config) {
1279
1279
  const slug = checkSlug(collection2, `collections[${i}]`, seenCollections, add);
1280
1280
  if (slug) collectionSlugs.add(slug);
1281
1281
  });
1282
+ const menu = config.admin?.menu;
1283
+ if (menu !== void 0) {
1284
+ if (!Array.isArray(menu)) {
1285
+ add("admin.menu", "must be an array of collection slugs", "e.g. menu: ['posts', 'media']");
1286
+ } else {
1287
+ for (const [i, slug] of menu.entries()) {
1288
+ if (typeof slug !== "string" || !collectionSlugs.has(slug))
1289
+ add(`admin.menu[${i}]`, `unknown collection ${JSON.stringify(slug)}`);
1290
+ }
1291
+ }
1292
+ }
1282
1293
  const seenGlobals = /* @__PURE__ */ new Set();
1283
1294
  globals.forEach((global2, i) => {
1284
1295
  checkSlug(global2, `globals[${i}]`, seenGlobals, add);
1285
1296
  });
1297
+ validateEndpoints(config, collectionSlugs, add);
1286
1298
  collections.forEach((collection2, i) => {
1287
1299
  const path = `collections.${collection2.slug ?? `[${i}]`}`;
1288
1300
  validateContainer(collection2, path, collectionSlugs, add);
1289
1301
  validateUseAsTitle(collection2, path, add);
1290
1302
  validateIcon(collection2, path, add);
1303
+ const editIn = collection2.editIn;
1304
+ if (editIn !== void 0 && editIn !== "page" && editIn !== "drawer")
1305
+ add(`${path}.editIn`, `must be "page" or "drawer" (got ${JSON.stringify(editIn)})`);
1291
1306
  });
1292
1307
  globals.forEach((global2, i) => {
1293
1308
  const path = `globals.${global2.slug ?? `[${i}]`}`;
@@ -1321,6 +1336,32 @@ function validateAdmin(config, add) {
1321
1336
  if (admin.locale !== void 0 && admin.locale !== "en" && admin.locale !== "th") {
1322
1337
  add("admin.locale", `must be "en" or "th" (got ${JSON.stringify(admin.locale)})`);
1323
1338
  }
1339
+ const siteUrl = admin.siteUrl;
1340
+ if (siteUrl !== void 0 && (typeof siteUrl !== "string" || !/^(\/|https?:\/\/)/.test(siteUrl))) {
1341
+ add(
1342
+ "admin.siteUrl",
1343
+ 'must be a path starting with "/" or an http(s) URL',
1344
+ "e.g. siteUrl: 'https://example.com'"
1345
+ );
1346
+ }
1347
+ const modules = admin.modules;
1348
+ if (modules !== void 0) {
1349
+ if (!Array.isArray(modules)) {
1350
+ add("admin.modules", "must be an array", "e.g. modules: ['@easy-cms/plugin-seo/admin']");
1351
+ } else {
1352
+ for (const [i, module] of modules.entries()) {
1353
+ if (typeof module !== "string" || module.trim() === "") {
1354
+ add(`admin.modules[${i}]`, "must be a package export or a file path");
1355
+ } else if (/^[a-z][a-z0-9+.-]*:/i.test(module) || module.startsWith("//")) {
1356
+ add(
1357
+ `admin.modules[${i}]`,
1358
+ "must be a package export or a file path, not a URL",
1359
+ "admin modules are served from your server, e.g. '@easy-cms/plugin-seo/admin'"
1360
+ );
1361
+ }
1362
+ }
1363
+ }
1364
+ }
1324
1365
  const brand = admin.brand;
1325
1366
  if (brand === void 0) return;
1326
1367
  if (typeof brand !== "object" || brand === null) {
@@ -1559,6 +1600,15 @@ function validateContainer(container, path, collectionSlugs, add) {
1559
1600
  "preview: ({ doc }) => `/posts/${doc.slug}`"
1560
1601
  );
1561
1602
  }
1603
+ const admin = container.admin;
1604
+ if (admin !== void 0) {
1605
+ if (typeof admin !== "object" || admin === null) {
1606
+ add(`${path}.admin`, "must be an object", "e.g. admin: { sidebar: ['ecms-my-panel'] }");
1607
+ } else {
1608
+ const sidebar = admin.sidebar;
1609
+ if (sidebar !== void 0) validateComponents(sidebar, `${path}.admin.sidebar`, add);
1610
+ }
1611
+ }
1562
1612
  const reserved = new Set(SYSTEM_FIELD_NAMES);
1563
1613
  if (container.drafts) reserved.add("status");
1564
1614
  validateFields2(container.fields, `${path}.fields`, reserved, collectionSlugs, add);
@@ -1595,6 +1645,20 @@ function validateFields2(fields, path, reserved, collectionSlugs, add) {
1595
1645
  }
1596
1646
  }
1597
1647
  function validateField(field, path, siblings, collectionSlugs, add) {
1648
+ const position = field.position;
1649
+ if (position !== void 0 && position !== "sidebar") {
1650
+ add(`${path}.position`, `must be "sidebar" (got ${JSON.stringify(position)})`);
1651
+ }
1652
+ const admin = field.admin;
1653
+ if (admin !== void 0) {
1654
+ if (typeof admin !== "object" || admin === null) {
1655
+ add(`${path}.admin`, "must be an object", "e.g. admin: { component: 'ecms-color-picker' }");
1656
+ } else {
1657
+ const { component, after } = admin;
1658
+ if (component !== void 0) validateComponent(component, `${path}.admin.component`, add);
1659
+ if (after !== void 0) validateComponents(after, `${path}.admin.after`, add);
1660
+ }
1661
+ }
1598
1662
  switch (field.type) {
1599
1663
  case "text":
1600
1664
  case "textarea":
@@ -1674,6 +1738,84 @@ function validateField(field, path, siblings, collectionSlugs, add) {
1674
1738
  }
1675
1739
  }
1676
1740
  }
1741
+ var ADMIN_COMPONENT_TAG = /^ecms-[a-z0-9]+(-[a-z0-9]+)*$/;
1742
+ function validateComponent(component, path, add) {
1743
+ const tag = typeof component === "object" && component !== null ? component.tag : component;
1744
+ if (typeof tag !== "string" || !ADMIN_COMPONENT_TAG.test(tag)) {
1745
+ add(
1746
+ path,
1747
+ `must be a custom element name starting with "ecms-" (got ${JSON.stringify(tag)})`,
1748
+ "e.g. 'ecms-color-picker' or { tag: 'ecms-color-picker', props: { palette: 'brand' } }"
1749
+ );
1750
+ return;
1751
+ }
1752
+ if (typeof component === "object" && component !== null) {
1753
+ const props = component.props;
1754
+ if (props !== void 0 && (typeof props !== "object" || props === null || Array.isArray(props)))
1755
+ add(`${path}.props`, "must be an object of plain JSON values");
1756
+ }
1757
+ }
1758
+ function validateComponents(components, path, add) {
1759
+ if (!Array.isArray(components)) {
1760
+ add(path, "must be an array of components", "e.g. ['ecms-seo-preview']");
1761
+ return;
1762
+ }
1763
+ for (const [i, component] of components.entries()) {
1764
+ validateComponent(component, `${path}[${i}]`, add);
1765
+ }
1766
+ }
1767
+ var ENDPOINT_METHODS = ["get", "post", "put", "patch", "delete"];
1768
+ var ENDPOINT_SEGMENT = /^(:[A-Za-z_][A-Za-z0-9_]*|[A-Za-z0-9._~-]+)$/;
1769
+ var RESERVED_ENDPOINT_ROOTS = /* @__PURE__ */ new Set(["users", "globals", "admin", "jobs", "media"]);
1770
+ function validateEndpoints(config, collectionSlugs, add) {
1771
+ const endpoints = config.endpoints;
1772
+ if (endpoints === void 0) return;
1773
+ if (!Array.isArray(endpoints)) {
1774
+ add(
1775
+ "endpoints",
1776
+ "must be an array",
1777
+ "e.g. endpoints: [{ path: '/hello', method: 'get', handler }]"
1778
+ );
1779
+ return;
1780
+ }
1781
+ const seen = /* @__PURE__ */ new Set();
1782
+ endpoints.forEach((endpoint, i) => {
1783
+ const path = `endpoints[${i}]`;
1784
+ if (typeof endpoint !== "object" || endpoint === null) {
1785
+ add(path, "must be an object { path, method, handler }");
1786
+ return;
1787
+ }
1788
+ const { path: route2, method, handler } = endpoint;
1789
+ const segments = typeof route2 === "string" ? route2.split("/").slice(1) : [];
1790
+ if (typeof route2 !== "string" || !route2.startsWith("/") || segments.length === 0 || !segments.every((s) => ENDPOINT_SEGMENT.test(s))) {
1791
+ add(`${path}.path`, `must be a path like "/seo/generate" (got ${JSON.stringify(route2)})`);
1792
+ } else {
1793
+ const root = segments[0];
1794
+ if (root.startsWith(":")) {
1795
+ add(`${path}.path`, "must start with a fixed segment, not a parameter");
1796
+ } else if (RESERVED_ENDPOINT_ROOTS.has(root) || collectionSlugs.has(root)) {
1797
+ add(
1798
+ `${path}.path`,
1799
+ `"/${root}" is used by the built-in API`,
1800
+ 'start the path with your plugin or feature name, e.g. "/seo/generate"'
1801
+ );
1802
+ }
1803
+ }
1804
+ const verb = typeof method === "string" ? method.toLowerCase() : method;
1805
+ if (typeof verb !== "string" || !ENDPOINT_METHODS.includes(verb)) {
1806
+ add(
1807
+ `${path}.method`,
1808
+ `must be one of ${ENDPOINT_METHODS.join(", ")} (got ${JSON.stringify(method)})`
1809
+ );
1810
+ }
1811
+ if (typeof handler !== "function") add(`${path}.handler`, "must be a function");
1812
+ if (typeof route2 === "string" && typeof verb === "string") {
1813
+ const key2 = `${verb} ${route2.replace(/:[A-Za-z_][A-Za-z0-9_]*/g, ":")}`;
1814
+ if (seen.has(key2)) add(`${path}.path`, `duplicate endpoint ${verb.toUpperCase()} ${route2}`);
1815
+ seen.add(key2);
1816
+ }
1817
+ });
1818
+ }
1677
1819
  function checkRange(min, max, minName, maxName, path, add) {
1678
1820
  if (min !== void 0 && max !== void 0 && min > max) {
1679
1821
  add(path, `${minName} (${min}) is greater than ${maxName} (${max})`);
@@ -1719,14 +1861,18 @@ var DEFAULT_API_PATH = "/api/cms";
1719
1861
  var DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024;
1720
1862
  var DEFAULT_TOKEN_EXPIRATION = 7 * 24 * 60 * 60;
1721
1863
  var resolved = /* @__PURE__ */ new WeakSet();
1722
- async function resolveConfig(input) {
1723
- if (resolved.has(input)) return input;
1864
+ async function applyPlugins(input) {
1724
1865
  let config = input;
1725
1866
  const plugins = Array.isArray(config?.plugins) ? config.plugins : [];
1726
1867
  for (const plugin of plugins) {
1727
1868
  if (typeof plugin !== "function") break;
1728
1869
  config = await plugin(config);
1729
1870
  }
1871
+ return config;
1872
+ }
1873
+ async function resolveConfig(input) {
1874
+ if (resolved.has(input)) return input;
1875
+ let config = await applyPlugins(input);
1730
1876
  if (typeof config === "object" && config !== null && Array.isArray(config.collections ?? [])) {
1731
1877
  config = withMedia(withUsers(config));
1732
1878
  }
@@ -1745,7 +1891,10 @@ async function resolveConfig(input) {
1745
1891
  admin: {
1746
1892
  path: config.admin?.path ?? DEFAULT_ADMIN_PATH,
1747
1893
  locale: config.admin?.locale ?? "en",
1748
- brand: config.admin?.brand ?? {}
1894
+ brand: config.admin?.brand ?? {},
1895
+ siteUrl: config.admin?.siteUrl ?? "",
1896
+ menu: config.admin?.menu ?? [],
1897
+ modules: config.admin?.modules ?? []
1749
1898
  },
1750
1899
  upload: {
1751
1900
  dir: config.upload?.dir ?? "uploads",
@@ -1768,7 +1917,8 @@ async function resolveConfig(input) {
1768
1917
  ...usesSchedule(config) ? [scheduledJobsCollection] : [],
1769
1918
  ...(config.webhooks?.length ?? 0) > 0 ? [webhookDeliveriesCollection] : []
1770
1919
  ],
1771
- globals: config.globals ?? []
1920
+ globals: config.globals ?? [],
1921
+ endpoints: config.endpoints ?? []
1772
1922
  };
1773
1923
  resolved.add(result);
1774
1924
  return result;
@@ -2276,20 +2426,20 @@ var VersionStore = class {
2276
2426
 
2277
2427
  // src/local-api.ts
2278
2428
  async function createEasyCMS(config, options = {}) {
2279
- const resolved2 = await resolveConfig(config);
2429
+ const resolved3 = await resolveConfig(config);
2280
2430
  const logger = options.logger ?? consoleLogger;
2281
2431
  const cwd = options.cwd ?? process.cwd();
2282
- const storage = resolved2.upload.storage ?? localStorage({ dir: resolved2.upload.dir });
2432
+ const storage = resolved3.upload.storage ?? localStorage({ dir: resolved3.upload.dir });
2283
2433
  await storage.init?.({ cwd });
2284
- const db = await resolved2.db.init({
2285
- config: resolved2,
2434
+ const db = await resolved3.db.init({
2435
+ config: resolved3,
2286
2436
  cwd,
2287
2437
  schema: options.schema ?? (process.env.NODE_ENV === "production" ? "verify" : "push"),
2288
2438
  logger,
2289
2439
  interactive: options.interactive ?? false
2290
2440
  });
2291
- const cms = new EasyCMS(resolved2, db, logger, storage);
2292
- const scheduling = [...resolved2.collections, ...resolved2.globals].some((c) => c.schedule) || (resolved2.webhooks?.length ?? 0) > 0;
2441
+ const cms = new EasyCMS(resolved3, db, logger, storage, cwd);
2442
+ const scheduling = [...resolved3.collections, ...resolved3.globals].some((c) => c.schedule) || (resolved3.webhooks?.length ?? 0) > 0;
2293
2443
  if (scheduling && options.scheduler !== false) cms.startScheduler();
2294
2444
  return cms;
2295
2445
  }
@@ -2309,13 +2459,16 @@ var EasyCMS = class {
2309
2459
  auth;
2310
2460
  /** Where uploaded files are stored. */
2311
2461
  storage;
2462
+ /** Project root: relative paths in the config (e.g. `admin.modules`) start here. */
2463
+ cwd;
2312
2464
  versions;
2313
2465
  webhooks;
2314
- constructor(config, db, logger = consoleLogger, storage = localStorage({ dir: config.upload.dir })) {
2466
+ constructor(config, db, logger = consoleLogger, storage = localStorage({ dir: config.upload.dir }), cwd = process.cwd()) {
2315
2467
  this.config = config;
2316
2468
  this.db = db;
2317
2469
  this.logger = logger;
2318
2470
  this.storage = storage;
2471
+ this.cwd = cwd;
2319
2472
  this.versions = new VersionStore(db);
2320
2473
  this.webhooks = new Webhooks(
2321
2474
  config.webhooks ?? [],
@@ -3631,7 +3784,56 @@ function asObject(data, name) {
3631
3784
  return data;
3632
3785
  }
3633
3786
 
3787
+ // src/rest/admin-modules.ts
3788
+ import { createHash as createHash2 } from "crypto";
3789
+ import { readFile as readFile2 } from "fs/promises";
3790
+ import { createRequire } from "module";
3791
+ import { join as join2 } from "path";
3792
+ function resolveAdminModule(specifier, cwd = process.cwd()) {
3793
+ const bases = [join2(cwd, "package.json")];
3794
+ const entry = process.argv[1];
3795
+ if (entry && !specifier.startsWith(".")) bases.push(entry);
3796
+ for (const base of bases) {
3797
+ try {
3798
+ return createRequire(base).resolve(specifier);
3799
+ } catch {
3800
+ }
3801
+ }
3802
+ return void 0;
3803
+ }
3804
+ var resolved2 = /* @__PURE__ */ new WeakMap();
3805
+ function files(cms) {
3806
+ let result = resolved2.get(cms);
3807
+ if (!result) {
3808
+ result = cms.config.admin.modules.map((specifier) => {
3809
+ const file = resolveAdminModule(specifier, cms.cwd);
3810
+ if (!file) {
3811
+ cms.logger.error(
3812
+ `Admin module "${specifier}" not found: install the package, or check the path (relative to ${cms.cwd}). The admin loads without it.`
3813
+ );
3814
+ }
3815
+ return file;
3816
+ });
3817
+ resolved2.set(cms, result);
3818
+ }
3819
+ return result;
3820
+ }
3821
+ function adminModuleUrls(cms) {
3822
+ return files(cms).flatMap((file, i) => file ? [`/admin/modules/${i}.js`] : []);
3823
+ }
3824
+ async function readAdminModule(cms, index) {
3825
+ const file = files(cms)[index];
3826
+ if (!file) return void 0;
3827
+ const body = await readFile2(file, "utf8").catch(() => void 0);
3828
+ if (body === void 0) return void 0;
3829
+ return { body, etag: `"${createHash2("sha1").update(body).digest("base64url")}"` };
3830
+ }
3831
+
3634
3832
  // src/rest/admin-schema.ts
3833
+ function componentRef(component) {
3834
+ if (typeof component === "string") return { tag: component };
3835
+ return component.props ? { tag: component.tag, props: JSON.parse(JSON.stringify(component.props)) } : { tag: component.tag };
3836
+ }
3635
3837
  async function allowed(access, user) {
3636
3838
  return await evaluateAccess(access, { user }) !== false;
3637
3839
  }
@@ -3646,6 +3848,13 @@ async function serializeFields(fields, update, localized) {
3646
3848
  if (field.defaultValue !== void 0) f.defaultValue = field.defaultValue;
3647
3849
  if (!await update.allows(field)) f.readOnly = true;
3648
3850
  if (field.localized && localized) f.localized = true;
3851
+ if (field.position === "sidebar") f.position = "sidebar";
3852
+ if (field.admin?.component || field.admin?.after?.length) {
3853
+ f.admin = {
3854
+ ...field.admin.component ? { component: componentRef(field.admin.component) } : {},
3855
+ ...field.admin.after?.length ? { after: field.admin.after.map(componentRef) } : {}
3856
+ };
3857
+ }
3649
3858
  switch (field.type) {
3650
3859
  case "text":
3651
3860
  case "textarea":
@@ -3718,6 +3927,9 @@ async function collection(config, user, localized) {
3718
3927
  if (config.labels) result.labels = config.labels;
3719
3928
  if (config.useAsTitle) result.useAsTitle = config.useAsTitle;
3720
3929
  if (config.icon) result.icon = config.icon;
3930
+ if (config.admin?.sidebar?.length) result.sidebar = config.admin.sidebar.map(componentRef);
3931
+ if (config.editIn === "drawer" && !result.drafts && !result.versions && !result.preview)
3932
+ result.editIn = "drawer";
3721
3933
  return result;
3722
3934
  }
3723
3935
  async function global(config, user, localized) {
@@ -3739,6 +3951,7 @@ async function global(config, user, localized) {
3739
3951
  };
3740
3952
  if (config.label !== void 0) result.label = config.label;
3741
3953
  if (config.icon) result.icon = config.icon;
3954
+ if (config.admin?.sidebar?.length) result.sidebar = config.admin.sidebar.map(componentRef);
3742
3955
  return result;
3743
3956
  }
3744
3957
  async function adminSchema(cms, user) {
@@ -3747,9 +3960,11 @@ async function adminSchema(cms, user) {
3747
3960
  const localized = localization !== null;
3748
3961
  return {
3749
3962
  locale: cms.config.admin.locale,
3963
+ menu: [...cms.config.admin.menu],
3750
3964
  localization: localization ? { locales: [...localization.locales], defaultLocale: localization.defaultLocale } : null,
3751
3965
  collections: await Promise.all(collections.map((c) => collection(c, user, localized))),
3752
- globals: await Promise.all(cms.config.globals.map((g) => global(g, user, localized)))
3966
+ globals: await Promise.all(cms.config.globals.map((g) => global(g, user, localized))),
3967
+ modules: adminModuleUrls(cms)
3753
3968
  };
3754
3969
  }
3755
3970
 
@@ -3880,7 +4095,7 @@ function createRestHandler(instance, options = {}) {
3880
4095
  const ctx = { request, url, user, via: user ? via : null, token, headers };
3881
4096
  const method = request.method.toUpperCase();
3882
4097
  if (method !== "GET" && method !== "HEAD") checkCsrf(cms, ctx);
3883
- const result = await route(cms, ctx, method, segments, options);
4098
+ const result = await customEndpoint(cms, ctx, method, segments) ?? await route(cms, ctx, method, segments, options);
3884
4099
  if (result.body instanceof Response) return result.body;
3885
4100
  return new Response(JSON.stringify(result.body), { status: result.status ?? 200, headers });
3886
4101
  } catch (error) {
@@ -3918,6 +4133,43 @@ function withHeaders(response, extra) {
3918
4133
  headers
3919
4134
  });
3920
4135
  }
4136
+ async function customEndpoint(cms, ctx, method, segments) {
4137
+ let pathMatched = false;
4138
+ const endpoints = [...cms.config.endpoints].sort((a, b) => fixedSegments(b) - fixedSegments(a));
4139
+ for (const endpoint of endpoints) {
4140
+ const params = matchPath(endpoint.path, segments);
4141
+ if (!params) continue;
4142
+ pathMatched = true;
4143
+ const verb = endpoint.method.toUpperCase();
4144
+ if (verb !== method && !(verb === "GET" && method === "HEAD")) continue;
4145
+ const body = await endpoint.handler({
4146
+ request: ctx.request,
4147
+ url: ctx.url,
4148
+ params,
4149
+ user: ctx.user,
4150
+ cms,
4151
+ json: () => readJson(ctx.request)
4152
+ });
4153
+ return { body: body === void 0 ? null : body };
4154
+ }
4155
+ if (pathMatched) {
4156
+ const allow = cms.config.endpoints.filter((e) => matchPath(e.path, segments)).map((e) => e.method.toUpperCase());
4157
+ throw methodNotAllowed(ctx, [...new Set(allow)].join(", "));
4158
+ }
4159
+ return void 0;
4160
+ }
4161
+ var fixedSegments = (endpoint) => endpoint.path.split("/").filter((s) => s && !s.startsWith(":")).length;
4162
+ function matchPath(path, segments) {
4163
+ const parts = path.split("/").filter(Boolean);
4164
+ if (parts.length !== segments.length) return void 0;
4165
+ const params = {};
4166
+ for (const [i, part] of parts.entries()) {
4167
+ const segment = segments[i];
4168
+ if (part.startsWith(":")) params[part.slice(1)] = segment;
4169
+ else if (part !== segment) return void 0;
4170
+ }
4171
+ return params;
4172
+ }
3921
4173
  async function route(cms, ctx, method, segments, options) {
3922
4174
  const [first, second, third] = segments;
3923
4175
  const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
@@ -3981,6 +4233,20 @@ async function route(cms, ctx, method, segments, options) {
3981
4233
  if (!ctx.user) throw new UnauthorizedError();
3982
4234
  if (second === "schema" && third === void 0)
3983
4235
  return { body: await adminSchema(cms, ctx.user) };
4236
+ if (second === "modules" && third !== void 0 && segments.length === 3) {
4237
+ const index = /^(\d+)\.js$/.exec(third)?.[1];
4238
+ const file = index === void 0 ? void 0 : await readAdminModule(cms, Number(index));
4239
+ if (!file) throw new HttpError("Not found", 404);
4240
+ const headers = {
4241
+ "content-type": "text/javascript; charset=utf-8",
4242
+ "cache-control": "private, no-cache",
4243
+ etag: file.etag,
4244
+ "x-content-type-options": "nosniff"
4245
+ };
4246
+ if (ctx.request.headers.get("if-none-match") === file.etag)
4247
+ return { body: new Response(null, { status: 304, headers }) };
4248
+ return { body: new Response(file.body, { headers }) };
4249
+ }
3984
4250
  if (second === "scheduled" && third === void 0)
3985
4251
  return { body: await cms.upcomingJobs({ user: ctx.user, overrideAccess: false }) };
3986
4252
  const [, , collection3, id2, extra] = segments;
@@ -4546,6 +4812,7 @@ export {
4546
4812
  WEBHOOK_EVENTS,
4547
4813
  anyone,
4548
4814
  applyDefaults,
4815
+ applyPlugins,
4549
4816
  consoleLogger,
4550
4817
  createEasyCMS,
4551
4818
  createRestHandler,
@@ -4567,6 +4834,7 @@ export {
4567
4834
  parseId,
4568
4835
  populate,
4569
4836
  readCookie,
4837
+ resolveAdminModule,
4570
4838
  resolveConfig,
4571
4839
  rowFields,
4572
4840
  silentLogger,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Embedded, code-first headless CMS for Nuxt and Next.js",
5
5
  "license": "MIT",
6
6
  "type": "module",