@easy-cms/core 0.12.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;
@@ -213,6 +228,8 @@ interface BaseField<TType extends string, TValue> {
213
228
  readonly localized?: boolean;
214
229
  /** `sidebar`: shown in the edit page's side panel instead of the main form (top-level fields). */
215
230
  readonly position?: 'sidebar';
231
+ /** Custom admin components for this field. */
232
+ readonly admin?: FieldAdmin;
216
233
  }
217
234
  interface TextField extends BaseField<'text', string> {
218
235
  readonly minLength?: number;
@@ -429,6 +446,11 @@ interface VersionsConfig {
429
446
  /** Versions kept per document; older ones are deleted. Default 50. */
430
447
  readonly max?: number;
431
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
+ }
432
454
  interface CollectionConfig {
433
455
  /** URL and table name. Lowercase letters, digits, `-` and `_`. */
434
456
  readonly slug: string;
@@ -461,6 +483,8 @@ interface CollectionConfig {
461
483
  readonly preview?: PreviewURL;
462
484
  readonly access?: CollectionAccess;
463
485
  readonly hooks?: CollectionHooks;
486
+ /** Custom admin components. */
487
+ readonly admin?: ContainerAdmin;
464
488
  }
465
489
  interface GlobalConfig {
466
490
  readonly slug: string;
@@ -477,6 +501,8 @@ interface GlobalConfig {
477
501
  readonly preview?: PreviewURL;
478
502
  readonly access?: GlobalAccess;
479
503
  readonly hooks?: GlobalHooks;
504
+ /** Custom admin components. */
505
+ readonly admin?: ContainerAdmin;
480
506
  }
481
507
  type AdminLocale = 'en' | 'th';
482
508
  /** Icons a collection or global can show in the admin menu ([Lucide](https://lucide.dev) names). */
@@ -508,6 +534,14 @@ interface AdminConfig {
508
534
  * Collections not listed follow in config order, with the media library last.
509
535
  */
510
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[];
511
545
  }
512
546
  interface ImageSize {
513
547
  /** Key in `media.sizes`, e.g. `thumbnail`. */
@@ -548,6 +582,36 @@ interface AuthConfig {
548
582
  */
549
583
  readonly trustedOrigins?: readonly string[];
550
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
+ }
551
615
  /** Receives the config and returns a modified copy. Runs before validation. */
552
616
  type Plugin = (config: Config) => MaybePromise<Config>;
553
617
  interface Config {
@@ -585,10 +649,12 @@ interface Config {
585
649
  */
586
650
  readonly collections?: readonly CollectionConfig[];
587
651
  readonly globals?: readonly GlobalConfig[];
652
+ /** Custom REST endpoints, e.g. from plugins. */
653
+ readonly endpoints?: readonly Endpoint[];
588
654
  readonly plugins?: readonly Plugin[];
589
655
  }
590
656
  /** The config after plugins ran, validation passed and defaults were applied. */
591
- 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'> {
592
658
  readonly cors: readonly string[] | '*';
593
659
  /** `null` without localization. */
594
660
  readonly localization: Required<LocalizationConfig> | null;
@@ -598,6 +664,7 @@ interface ResolvedConfig extends Omit<Config, 'cors' | 'localization' | 'routes'
598
664
  readonly auth: Required<AuthConfig>;
599
665
  readonly collections: readonly CollectionConfig[];
600
666
  readonly globals: readonly GlobalConfig[];
667
+ readonly endpoints: readonly Endpoint[];
601
668
  }
602
669
  /**
603
670
  * Declares the Easy CMS config. Returns it unchanged, keeping literal types
@@ -926,9 +993,11 @@ declare class EasyCMS<C extends Config = Config> {
926
993
  readonly auth: Auth;
927
994
  /** Where uploaded files are stored. */
928
995
  readonly storage: StorageAdapter;
996
+ /** Project root: relative paths in the config (e.g. `admin.modules`) start here. */
997
+ readonly cwd: string;
929
998
  private readonly versions;
930
999
  private readonly webhooks;
931
- constructor(config: ResolvedConfig, db: Database, logger?: Logger, storage?: StorageAdapter);
1000
+ constructor(config: ResolvedConfig, db: Database, logger?: Logger, storage?: StorageAdapter, cwd?: string);
932
1001
  find<S extends Slug<C>>(collection: S, options?: FindOptions): Promise<PaginatedDocs<Doc<C, S>>>;
933
1002
  findById<S extends Slug<C>>(collection: S, id: ID, options?: ReadOptions): Promise<Doc<C, S> | null>;
934
1003
  count<S extends Slug<C>>(collection: S, options?: {
@@ -1355,12 +1424,29 @@ declare const DEFAULT_ADMIN_PATH = "/admin";
1355
1424
  declare const DEFAULT_API_PATH = "/api/cms";
1356
1425
  declare const DEFAULT_MAX_FILE_SIZE: number;
1357
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>;
1358
1432
  /**
1359
1433
  * Runs plugins in order, adds the built-in collections, validates the result
1360
1434
  * and fills in defaults. Throws `ConfigError`. Resolving twice is a no-op.
1361
1435
  */
1362
1436
  declare function resolveConfig(input: Config | ResolvedConfig): Promise<ResolvedConfig>;
1363
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
+ }
1364
1450
  /** A field as the admin UI sees it: plain JSON, no functions. */
1365
1451
  interface AdminField {
1366
1452
  name: string;
@@ -1398,6 +1484,11 @@ interface AdminField {
1398
1484
  localized?: boolean;
1399
1485
  /** Shown in the edit page's side panel. */
1400
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
+ };
1401
1492
  }
1402
1493
  interface AdminCollection {
1403
1494
  slug: string;
@@ -1416,6 +1507,8 @@ interface AdminCollection {
1416
1507
  /** Publishing can be scheduled. */
1417
1508
  schedule: boolean;
1418
1509
  fields: AdminField[];
1510
+ /** Panels from admin modules in the edit page's side column. */
1511
+ sidebar?: AdminComponentRef[];
1419
1512
  permissions: {
1420
1513
  read: boolean;
1421
1514
  create: boolean;
@@ -1434,6 +1527,8 @@ interface AdminGlobal {
1434
1527
  /** Publishing can be scheduled. */
1435
1528
  schedule: boolean;
1436
1529
  fields: AdminField[];
1530
+ /** Panels from admin modules in the edit page's side column. */
1531
+ sidebar?: AdminComponentRef[];
1437
1532
  permissions: {
1438
1533
  read: boolean;
1439
1534
  update: boolean;
@@ -1450,6 +1545,8 @@ interface AdminSchema {
1450
1545
  } | null;
1451
1546
  collections: AdminCollection[];
1452
1547
  globals: AdminGlobal[];
1548
+ /** URLs under the API of the admin modules to load (`admin.modules`). */
1549
+ modules: string[];
1453
1550
  }
1454
1551
 
1455
1552
  declare const SESSION_COOKIE = "ecms-session";
@@ -1480,4 +1577,4 @@ declare const BUILTIN_COLLECTIONS: readonly ["users", "media"];
1480
1577
  /** Checks a config and returns every problem found. An empty array means it is valid. */
1481
1578
  declare function validateConfig(config: Config): ConfigIssue[];
1482
1579
 
1483
- 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
@@ -1294,6 +1294,7 @@ function validateConfig(config) {
1294
1294
  globals.forEach((global2, i) => {
1295
1295
  checkSlug(global2, `globals[${i}]`, seenGlobals, add);
1296
1296
  });
1297
+ validateEndpoints(config, collectionSlugs, add);
1297
1298
  collections.forEach((collection2, i) => {
1298
1299
  const path = `collections.${collection2.slug ?? `[${i}]`}`;
1299
1300
  validateContainer(collection2, path, collectionSlugs, add);
@@ -1343,6 +1344,24 @@ function validateAdmin(config, add) {
1343
1344
  "e.g. siteUrl: 'https://example.com'"
1344
1345
  );
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
+ }
1346
1365
  const brand = admin.brand;
1347
1366
  if (brand === void 0) return;
1348
1367
  if (typeof brand !== "object" || brand === null) {
@@ -1581,6 +1600,15 @@ function validateContainer(container, path, collectionSlugs, add) {
1581
1600
  "preview: ({ doc }) => `/posts/${doc.slug}`"
1582
1601
  );
1583
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
+ }
1584
1612
  const reserved = new Set(SYSTEM_FIELD_NAMES);
1585
1613
  if (container.drafts) reserved.add("status");
1586
1614
  validateFields2(container.fields, `${path}.fields`, reserved, collectionSlugs, add);
@@ -1621,6 +1649,16 @@ function validateField(field, path, siblings, collectionSlugs, add) {
1621
1649
  if (position !== void 0 && position !== "sidebar") {
1622
1650
  add(`${path}.position`, `must be "sidebar" (got ${JSON.stringify(position)})`);
1623
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
+ }
1624
1662
  switch (field.type) {
1625
1663
  case "text":
1626
1664
  case "textarea":
@@ -1700,6 +1738,84 @@ function validateField(field, path, siblings, collectionSlugs, add) {
1700
1738
  }
1701
1739
  }
1702
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
+ }
1703
1819
  function checkRange(min, max, minName, maxName, path, add) {
1704
1820
  if (min !== void 0 && max !== void 0 && min > max) {
1705
1821
  add(path, `${minName} (${min}) is greater than ${maxName} (${max})`);
@@ -1745,14 +1861,18 @@ var DEFAULT_API_PATH = "/api/cms";
1745
1861
  var DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024;
1746
1862
  var DEFAULT_TOKEN_EXPIRATION = 7 * 24 * 60 * 60;
1747
1863
  var resolved = /* @__PURE__ */ new WeakSet();
1748
- async function resolveConfig(input) {
1749
- if (resolved.has(input)) return input;
1864
+ async function applyPlugins(input) {
1750
1865
  let config = input;
1751
1866
  const plugins = Array.isArray(config?.plugins) ? config.plugins : [];
1752
1867
  for (const plugin of plugins) {
1753
1868
  if (typeof plugin !== "function") break;
1754
1869
  config = await plugin(config);
1755
1870
  }
1871
+ return config;
1872
+ }
1873
+ async function resolveConfig(input) {
1874
+ if (resolved.has(input)) return input;
1875
+ let config = await applyPlugins(input);
1756
1876
  if (typeof config === "object" && config !== null && Array.isArray(config.collections ?? [])) {
1757
1877
  config = withMedia(withUsers(config));
1758
1878
  }
@@ -1773,7 +1893,8 @@ async function resolveConfig(input) {
1773
1893
  locale: config.admin?.locale ?? "en",
1774
1894
  brand: config.admin?.brand ?? {},
1775
1895
  siteUrl: config.admin?.siteUrl ?? "",
1776
- menu: config.admin?.menu ?? []
1896
+ menu: config.admin?.menu ?? [],
1897
+ modules: config.admin?.modules ?? []
1777
1898
  },
1778
1899
  upload: {
1779
1900
  dir: config.upload?.dir ?? "uploads",
@@ -1796,7 +1917,8 @@ async function resolveConfig(input) {
1796
1917
  ...usesSchedule(config) ? [scheduledJobsCollection] : [],
1797
1918
  ...(config.webhooks?.length ?? 0) > 0 ? [webhookDeliveriesCollection] : []
1798
1919
  ],
1799
- globals: config.globals ?? []
1920
+ globals: config.globals ?? [],
1921
+ endpoints: config.endpoints ?? []
1800
1922
  };
1801
1923
  resolved.add(result);
1802
1924
  return result;
@@ -2304,20 +2426,20 @@ var VersionStore = class {
2304
2426
 
2305
2427
  // src/local-api.ts
2306
2428
  async function createEasyCMS(config, options = {}) {
2307
- const resolved2 = await resolveConfig(config);
2429
+ const resolved3 = await resolveConfig(config);
2308
2430
  const logger = options.logger ?? consoleLogger;
2309
2431
  const cwd = options.cwd ?? process.cwd();
2310
- const storage = resolved2.upload.storage ?? localStorage({ dir: resolved2.upload.dir });
2432
+ const storage = resolved3.upload.storage ?? localStorage({ dir: resolved3.upload.dir });
2311
2433
  await storage.init?.({ cwd });
2312
- const db = await resolved2.db.init({
2313
- config: resolved2,
2434
+ const db = await resolved3.db.init({
2435
+ config: resolved3,
2314
2436
  cwd,
2315
2437
  schema: options.schema ?? (process.env.NODE_ENV === "production" ? "verify" : "push"),
2316
2438
  logger,
2317
2439
  interactive: options.interactive ?? false
2318
2440
  });
2319
- const cms = new EasyCMS(resolved2, db, logger, storage);
2320
- 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;
2321
2443
  if (scheduling && options.scheduler !== false) cms.startScheduler();
2322
2444
  return cms;
2323
2445
  }
@@ -2337,13 +2459,16 @@ var EasyCMS = class {
2337
2459
  auth;
2338
2460
  /** Where uploaded files are stored. */
2339
2461
  storage;
2462
+ /** Project root: relative paths in the config (e.g. `admin.modules`) start here. */
2463
+ cwd;
2340
2464
  versions;
2341
2465
  webhooks;
2342
- 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()) {
2343
2467
  this.config = config;
2344
2468
  this.db = db;
2345
2469
  this.logger = logger;
2346
2470
  this.storage = storage;
2471
+ this.cwd = cwd;
2347
2472
  this.versions = new VersionStore(db);
2348
2473
  this.webhooks = new Webhooks(
2349
2474
  config.webhooks ?? [],
@@ -3659,7 +3784,56 @@ function asObject(data, name) {
3659
3784
  return data;
3660
3785
  }
3661
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
+
3662
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
+ }
3663
3837
  async function allowed(access, user) {
3664
3838
  return await evaluateAccess(access, { user }) !== false;
3665
3839
  }
@@ -3675,6 +3849,12 @@ async function serializeFields(fields, update, localized) {
3675
3849
  if (!await update.allows(field)) f.readOnly = true;
3676
3850
  if (field.localized && localized) f.localized = true;
3677
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
+ }
3678
3858
  switch (field.type) {
3679
3859
  case "text":
3680
3860
  case "textarea":
@@ -3747,6 +3927,7 @@ async function collection(config, user, localized) {
3747
3927
  if (config.labels) result.labels = config.labels;
3748
3928
  if (config.useAsTitle) result.useAsTitle = config.useAsTitle;
3749
3929
  if (config.icon) result.icon = config.icon;
3930
+ if (config.admin?.sidebar?.length) result.sidebar = config.admin.sidebar.map(componentRef);
3750
3931
  if (config.editIn === "drawer" && !result.drafts && !result.versions && !result.preview)
3751
3932
  result.editIn = "drawer";
3752
3933
  return result;
@@ -3770,6 +3951,7 @@ async function global(config, user, localized) {
3770
3951
  };
3771
3952
  if (config.label !== void 0) result.label = config.label;
3772
3953
  if (config.icon) result.icon = config.icon;
3954
+ if (config.admin?.sidebar?.length) result.sidebar = config.admin.sidebar.map(componentRef);
3773
3955
  return result;
3774
3956
  }
3775
3957
  async function adminSchema(cms, user) {
@@ -3781,7 +3963,8 @@ async function adminSchema(cms, user) {
3781
3963
  menu: [...cms.config.admin.menu],
3782
3964
  localization: localization ? { locales: [...localization.locales], defaultLocale: localization.defaultLocale } : null,
3783
3965
  collections: await Promise.all(collections.map((c) => collection(c, user, localized))),
3784
- 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)
3785
3968
  };
3786
3969
  }
3787
3970
 
@@ -3912,7 +4095,7 @@ function createRestHandler(instance, options = {}) {
3912
4095
  const ctx = { request, url, user, via: user ? via : null, token, headers };
3913
4096
  const method = request.method.toUpperCase();
3914
4097
  if (method !== "GET" && method !== "HEAD") checkCsrf(cms, ctx);
3915
- 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);
3916
4099
  if (result.body instanceof Response) return result.body;
3917
4100
  return new Response(JSON.stringify(result.body), { status: result.status ?? 200, headers });
3918
4101
  } catch (error) {
@@ -3950,6 +4133,43 @@ function withHeaders(response, extra) {
3950
4133
  headers
3951
4134
  });
3952
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
+ }
3953
4173
  async function route(cms, ctx, method, segments, options) {
3954
4174
  const [first, second, third] = segments;
3955
4175
  const access = { overrideAccess: false, user: ctx.user, ...parseLocale(ctx.url) };
@@ -4013,6 +4233,20 @@ async function route(cms, ctx, method, segments, options) {
4013
4233
  if (!ctx.user) throw new UnauthorizedError();
4014
4234
  if (second === "schema" && third === void 0)
4015
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
+ }
4016
4250
  if (second === "scheduled" && third === void 0)
4017
4251
  return { body: await cms.upcomingJobs({ user: ctx.user, overrideAccess: false }) };
4018
4252
  const [, , collection3, id2, extra] = segments;
@@ -4578,6 +4812,7 @@ export {
4578
4812
  WEBHOOK_EVENTS,
4579
4813
  anyone,
4580
4814
  applyDefaults,
4815
+ applyPlugins,
4581
4816
  consoleLogger,
4582
4817
  createEasyCMS,
4583
4818
  createRestHandler,
@@ -4599,6 +4834,7 @@ export {
4599
4834
  parseId,
4600
4835
  populate,
4601
4836
  readCookie,
4837
+ resolveAdminModule,
4602
4838
  resolveConfig,
4603
4839
  rowFields,
4604
4840
  silentLogger,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "0.12.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",