@easy-cms/core 0.9.1 → 0.11.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @easy-cms/core
2
2
 
3
- Core of [Easy CMS](https://github.com/maritonx/easy-crm) — an embedded, code-first headless CMS for Nuxt and Next.js.
3
+ Core of [Easy CMS](https://github.com/maritonx/easy-cms) — an embedded, code-first headless CMS for Nuxt and Next.js.
4
4
 
5
5
  > ⚠️ Pre-release. Not ready for production.
6
6
 
package/dist/index.d.ts CHANGED
@@ -164,6 +164,11 @@ interface Database {
164
164
  /** Applies pending migrations in order. Returns the names applied. */
165
165
  migrate(): Promise<string[]>;
166
166
  migrationStatus(): Promise<MigrationInfo[]>;
167
+ /**
168
+ * Copies the database to `file` (an absolute path) while it is in use, when the database can
169
+ * do that itself (SQLite). Others (Postgres) leave it out; use their own tools.
170
+ */
171
+ readonly backup?: ((file: string) => Promise<void>) | undefined;
167
172
  destroy(): Promise<void>;
168
173
  }
169
174
  /** What `sqlite()` / `postgres()` return and what `config.db` holds. */
@@ -429,6 +434,8 @@ interface CollectionConfig {
429
434
  readonly singular?: Label;
430
435
  readonly plural?: Label;
431
436
  };
437
+ /** Icon in the admin menu. Default `file-text`. */
438
+ readonly icon?: AdminIcon;
432
439
  readonly fields: readonly Field[];
433
440
  /** Top-level field shown as the document title in the admin UI. */
434
441
  readonly useAsTitle?: string;
@@ -450,6 +457,8 @@ interface CollectionConfig {
450
457
  interface GlobalConfig {
451
458
  readonly slug: string;
452
459
  readonly label?: Label;
460
+ /** Icon in the admin menu. Default `settings`. */
461
+ readonly icon?: AdminIcon;
453
462
  readonly fields: readonly Field[];
454
463
  readonly drafts?: boolean;
455
464
  /** Keep a snapshot of every save. See `CollectionConfig.versions`. */
@@ -462,11 +471,25 @@ interface GlobalConfig {
462
471
  readonly hooks?: GlobalHooks;
463
472
  }
464
473
  type AdminLocale = 'en' | 'th';
474
+ /** Icons a collection or global can show in the admin menu ([Lucide](https://lucide.dev) names). */
475
+ declare const ADMIN_ICONS: readonly ["file-text", "newspaper", "book-open", "notebook", "folder", "tag", "tags", "image", "images", "video", "music", "file", "users", "user", "building", "store", "shopping-bag", "shopping-cart", "package", "box", "calendar", "calendar-days", "map-pin", "globe", "house", "layout-grid", "layers", "star", "heart", "message-square", "mail", "phone", "briefcase", "graduation-cap", "utensils", "car", "settings", "sliders-horizontal", "palette", "megaphone", "bell", "link", "quote", "circle-help", "award", "ticket", "camera"];
476
+ type AdminIcon = (typeof ADMIN_ICONS)[number];
477
+ /** Your or your client's brand in the admin UI. */
478
+ interface AdminBrand {
479
+ /** Shown in the menu, on the login page and in the browser tab. Default "Easy CMS". */
480
+ readonly name?: string;
481
+ /** Logo URL: a path on your site (`/logo.svg`) or an `https://` address. */
482
+ readonly logo?: string;
483
+ /** Main color as `#rrggbb`; lighter and darker shades are derived from it. */
484
+ readonly color?: string;
485
+ }
465
486
  interface AdminConfig {
466
487
  /** Where the admin UI is served. Default `/admin`. */
467
488
  readonly path?: string;
468
489
  /** Default admin UI language. Default `en`. */
469
490
  readonly locale?: AdminLocale;
491
+ /** Name, logo and color of the admin UI. */
492
+ readonly brand?: AdminBrand;
470
493
  }
471
494
  interface ImageSize {
472
495
  /** Key in `media.sizes`, e.g. `thumbnail`. */
@@ -1042,6 +1065,17 @@ declare class EasyCMS<C extends Config = Config> {
1042
1065
  private scheduleTarget;
1043
1066
  private globalScheduleTarget;
1044
1067
  private addJob;
1068
+ /**
1069
+ * The next pending jobs across all collections and globals, soonest first; with access
1070
+ * enforced, only those the user may update.
1071
+ */
1072
+ upcomingJobs(options?: AccessOptions & {
1073
+ limit?: number;
1074
+ }): Promise<(ScheduledJob & {
1075
+ collection?: string;
1076
+ global?: string;
1077
+ doc: ID;
1078
+ })[]>;
1045
1079
  private pendingJobs;
1046
1080
  private removeJob;
1047
1081
  /** @internal */
@@ -1351,6 +1385,7 @@ interface AdminCollection {
1351
1385
  singular?: Label;
1352
1386
  plural?: Label;
1353
1387
  };
1388
+ icon?: string;
1354
1389
  useAsTitle?: string;
1355
1390
  drafts: boolean;
1356
1391
  versions: boolean;
@@ -1369,6 +1404,7 @@ interface AdminCollection {
1369
1404
  interface AdminGlobal {
1370
1405
  slug: string;
1371
1406
  label?: Label;
1407
+ icon?: string;
1372
1408
  drafts: boolean;
1373
1409
  versions: boolean;
1374
1410
  /** Has live preview (`preview` in the config). */
@@ -1420,4 +1456,4 @@ declare const BUILTIN_COLLECTIONS: readonly ["users", "media"];
1420
1456
  /** Checks a config and returns every problem found. An empty array means it is valid. */
1421
1457
  declare function validateConfig(config: Config): ConfigIssue[];
1422
1458
 
1423
- export { type Access, type AccessArgs, type AccessOptions, type AdminCollection, type AdminConfig, type AdminField, type AdminGlobal, type AdminLocale, type AdminSchema, type AfterChangeHook, type AfterDeleteHook, type AfterReadHook, type ArrayField, Auth, type AuthConfig, type AuthUser, BUILTIN_COLLECTIONS, type BeforeChangeHook, type BeforeDeleteHook, type BeforeValidateHook, type 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 };
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 };
package/dist/index.js CHANGED
@@ -242,6 +242,7 @@ function withUsers(config) {
242
242
  slug: USERS,
243
243
  labels: { singular: { en: "User", th: "\u0E1C\u0E39\u0E49\u0E43\u0E0A\u0E49" }, plural: { en: "Users", th: "\u0E1C\u0E39\u0E49\u0E43\u0E0A\u0E49" } },
244
244
  useAsTitle: "email",
245
+ icon: "users",
245
246
  ...custom,
246
247
  fields: [...userFields(roles), ...custom?.fields ?? []],
247
248
  access: {
@@ -302,6 +303,7 @@ function withMedia(config) {
302
303
  const media = {
303
304
  slug: MEDIA,
304
305
  labels: { singular: { en: "Media", th: "\u0E2A\u0E37\u0E48\u0E2D" }, plural: { en: "Media", th: "\u0E04\u0E25\u0E31\u0E07\u0E2A\u0E37\u0E48\u0E2D" } },
306
+ icon: "image",
305
307
  useAsTitle: "filename",
306
308
  ...custom,
307
309
  fields: [...mediaFields(), ...custom?.fields ?? []],
@@ -695,6 +697,55 @@ var Auth = class {
695
697
  };
696
698
 
697
699
  // src/config.ts
700
+ var ADMIN_ICONS = [
701
+ "file-text",
702
+ "newspaper",
703
+ "book-open",
704
+ "notebook",
705
+ "folder",
706
+ "tag",
707
+ "tags",
708
+ "image",
709
+ "images",
710
+ "video",
711
+ "music",
712
+ "file",
713
+ "users",
714
+ "user",
715
+ "building",
716
+ "store",
717
+ "shopping-bag",
718
+ "shopping-cart",
719
+ "package",
720
+ "box",
721
+ "calendar",
722
+ "calendar-days",
723
+ "map-pin",
724
+ "globe",
725
+ "house",
726
+ "layout-grid",
727
+ "layers",
728
+ "star",
729
+ "heart",
730
+ "message-square",
731
+ "mail",
732
+ "phone",
733
+ "briefcase",
734
+ "graduation-cap",
735
+ "utensils",
736
+ "car",
737
+ "settings",
738
+ "sliders-horizontal",
739
+ "palette",
740
+ "megaphone",
741
+ "bell",
742
+ "link",
743
+ "quote",
744
+ "circle-help",
745
+ "award",
746
+ "ticket",
747
+ "camera"
748
+ ];
698
749
  function defineConfig(config) {
699
750
  return config;
700
751
  }
@@ -1236,9 +1287,12 @@ function validateConfig(config) {
1236
1287
  const path = `collections.${collection2.slug ?? `[${i}]`}`;
1237
1288
  validateContainer(collection2, path, collectionSlugs, add);
1238
1289
  validateUseAsTitle(collection2, path, add);
1290
+ validateIcon(collection2, path, add);
1239
1291
  });
1240
1292
  globals.forEach((global2, i) => {
1241
- validateContainer(global2, `globals.${global2.slug ?? `[${i}]`}`, collectionSlugs, add);
1293
+ const path = `globals.${global2.slug ?? `[${i}]`}`;
1294
+ validateContainer(global2, path, collectionSlugs, add);
1295
+ validateIcon(global2, path, add);
1242
1296
  });
1243
1297
  const plugins = config.plugins;
1244
1298
  if (plugins !== void 0 && (!Array.isArray(plugins) || plugins.some((p) => typeof p !== "function"))) {
@@ -1267,6 +1321,31 @@ function validateAdmin(config, add) {
1267
1321
  if (admin.locale !== void 0 && admin.locale !== "en" && admin.locale !== "th") {
1268
1322
  add("admin.locale", `must be "en" or "th" (got ${JSON.stringify(admin.locale)})`);
1269
1323
  }
1324
+ const brand = admin.brand;
1325
+ if (brand === void 0) return;
1326
+ if (typeof brand !== "object" || brand === null) {
1327
+ add("admin.brand", "must be an object", "e.g. brand: { name: 'Acme', color: '#0f766e' }");
1328
+ return;
1329
+ }
1330
+ const { name, logo, color } = brand;
1331
+ if (name !== void 0 && (typeof name !== "string" || name.trim() === "")) {
1332
+ add("admin.brand.name", "must be a non-empty string");
1333
+ }
1334
+ if (logo !== void 0 && (typeof logo !== "string" || !/^(\/|https?:\/\/|data:image\/)/.test(logo))) {
1335
+ add(
1336
+ "admin.brand.logo",
1337
+ 'must be a path starting with "/" or an http(s) URL',
1338
+ "e.g. logo: '/logo.svg'"
1339
+ );
1340
+ }
1341
+ if (color !== void 0 && (typeof color !== "string" || !/^#[0-9a-f]{6}$/i.test(color))) {
1342
+ add("admin.brand.color", `must be a hex color like "#0f766e" (got ${JSON.stringify(color)})`);
1343
+ }
1344
+ }
1345
+ function validateIcon(container, path, add) {
1346
+ const icon = container.icon;
1347
+ if (icon === void 0 || ADMIN_ICONS.includes(icon)) return;
1348
+ add(`${path}.icon`, `unknown icon ${JSON.stringify(icon)}`, `one of: ${ADMIN_ICONS.join(", ")}`);
1270
1349
  }
1271
1350
  function validateAuth(config, add) {
1272
1351
  const auth = config.auth;
@@ -1665,7 +1744,8 @@ async function resolveConfig(input) {
1665
1744
  routes: { api: `/${(config.routes?.api ?? DEFAULT_API_PATH).replace(/^\/+|\/+$/g, "")}` },
1666
1745
  admin: {
1667
1746
  path: config.admin?.path ?? DEFAULT_ADMIN_PATH,
1668
- locale: config.admin?.locale ?? "en"
1747
+ locale: config.admin?.locale ?? "en",
1748
+ brand: config.admin?.brand ?? {}
1669
1749
  },
1670
1750
  upload: {
1671
1751
  dir: config.upload?.dir ?? "uploads",
@@ -3040,6 +3120,42 @@ var EasyCMS = class {
3040
3120
  });
3041
3121
  return toJob(row);
3042
3122
  }
3123
+ /**
3124
+ * The next pending jobs across all collections and globals, soonest first; with access
3125
+ * enforced, only those the user may update.
3126
+ */
3127
+ async upcomingJobs(options = {}) {
3128
+ if (!this.config.collections.some((c) => c.slug === SCHEDULED_JOBS)) return [];
3129
+ const guard = guardOf(options);
3130
+ const limit = options.limit ?? 10;
3131
+ const rows = await this.db.find({
3132
+ collection: SCHEDULED_JOBS,
3133
+ where: { state: { equals: "pending" } },
3134
+ sort: ["runAt"],
3135
+ limit: limit * 5,
3136
+ page: 1
3137
+ });
3138
+ const allowed2 = /* @__PURE__ */ new Map();
3139
+ const may = async (parent) => {
3140
+ if (!guard.enforce) return true;
3141
+ const cached = allowed2.get(parent);
3142
+ if (cached !== void 0) return cached;
3143
+ const config = parent.startsWith("global:") ? this.config.globals.find((g) => g.slug === parent.slice("global:".length)) : this.config.collections.find((c) => c.slug === parent);
3144
+ const access = config ? await evaluateAccess(config.access?.update, { user: guard.user }) : false;
3145
+ const result = access !== false;
3146
+ allowed2.set(parent, result);
3147
+ return result;
3148
+ };
3149
+ const jobs = [];
3150
+ for (const row of rows.docs) {
3151
+ const parent = String(row.parent);
3152
+ if (!await may(parent)) continue;
3153
+ const target = parent.startsWith("global:") ? { global: parent.slice("global:".length) } : { collection: parent };
3154
+ jobs.push({ ...toJob(row), ...target, doc: row.doc });
3155
+ if (jobs.length === limit) break;
3156
+ }
3157
+ return jobs;
3158
+ }
3043
3159
  async pendingJobs(parent, doc) {
3044
3160
  const rows = await this.db.find({
3045
3161
  collection: SCHEDULED_JOBS,
@@ -3601,6 +3717,7 @@ async function collection(config, user, localized) {
3601
3717
  };
3602
3718
  if (config.labels) result.labels = config.labels;
3603
3719
  if (config.useAsTitle) result.useAsTitle = config.useAsTitle;
3720
+ if (config.icon) result.icon = config.icon;
3604
3721
  return result;
3605
3722
  }
3606
3723
  async function global(config, user, localized) {
@@ -3621,6 +3738,7 @@ async function global(config, user, localized) {
3621
3738
  }
3622
3739
  };
3623
3740
  if (config.label !== void 0) result.label = config.label;
3741
+ if (config.icon) result.icon = config.icon;
3624
3742
  return result;
3625
3743
  }
3626
3744
  async function adminSchema(cms, user) {
@@ -3863,6 +3981,8 @@ async function route(cms, ctx, method, segments, options) {
3863
3981
  if (!ctx.user) throw new UnauthorizedError();
3864
3982
  if (second === "schema" && third === void 0)
3865
3983
  return { body: await adminSchema(cms, ctx.user) };
3984
+ if (second === "scheduled" && third === void 0)
3985
+ return { body: await cms.upcomingJobs({ user: ctx.user, overrideAccess: false }) };
3866
3986
  const [, , collection3, id2, extra] = segments;
3867
3987
  if (second === "access" && collection3 && id2 && extra === void 0) {
3868
3988
  if (INTERNAL_COLLECTIONS.has(collection3) || !cms.config.collections.some((c) => c.slug === collection3)) {
@@ -4392,6 +4512,7 @@ ${map(n.globals)}
4392
4512
  return parts.join("\n");
4393
4513
  }
4394
4514
  export {
4515
+ ADMIN_ICONS,
4395
4516
  Auth,
4396
4517
  BUILTIN_COLLECTIONS,
4397
4518
  CONFIG_FILE_NAMES,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@easy-cms/core",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "description": "Embedded, code-first headless CMS for Nuxt and Next.js",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,7 +30,7 @@
30
30
  },
31
31
  "repository": {
32
32
  "type": "git",
33
- "url": "git+https://github.com/maritonx/easy-crm.git",
33
+ "url": "git+https://github.com/maritonx/easy-cms.git",
34
34
  "directory": "packages/core"
35
35
  },
36
36
  "keywords": [