@createcms/core 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1076,6 +1076,10 @@ const CMS_ERRORS = {
1076
1076
  status: 404,
1077
1077
  message: 'Parent block not found'
1078
1078
  },
1079
+ BLOCK_NOT_ALLOWED_IN_PARENT: {
1080
+ status: 400,
1081
+ message: 'This block type is not allowed inside the target parent'
1082
+ },
1079
1083
  ROOT_NOT_FOUND: {
1080
1084
  status: 404,
1081
1085
  message: 'Root block not found in snapshot'
@@ -4974,6 +4978,88 @@ notFound = 'ROOT_NOT_FOUND') {
4974
4978
  }
4975
4979
  }
4976
4980
 
4981
+ /** Builds the {@link PlacementIndex} from a collection's `structure` + blocks. */ function buildPlacementIndex(structure, blocks) {
4982
+ const rules = new Map();
4983
+ if (structure) {
4984
+ for (const [parent, entry] of Object.entries(structure)){
4985
+ if (!entry) continue;
4986
+ const accepts = entry.accepts;
4987
+ if (Array.isArray(accepts)) {
4988
+ // Concrete whitelist (incl. the empty "holds nothing" list).
4989
+ rules.set(parent, {
4990
+ mode: 'only',
4991
+ set: new Set(accepts)
4992
+ });
4993
+ } else if (entry.excludes && entry.excludes.length > 0) {
4994
+ // Open base ('*' or omitted) minus an explicit blacklist.
4995
+ rules.set(parent, {
4996
+ mode: 'except',
4997
+ set: new Set(entry.excludes)
4998
+ });
4999
+ }
5000
+ // else: open ('*' / nothing, no excludes) — no rule.
5001
+ }
5002
+ }
5003
+ const containers = new Set();
5004
+ if (blocks) {
5005
+ for (const [name, def] of Object.entries(blocks)){
5006
+ if (def.allowChildren === true) containers.add(name);
5007
+ }
5008
+ }
5009
+ return {
5010
+ rules,
5011
+ containers
5012
+ };
5013
+ }
5014
+ /**
5015
+ * Throws `BLOCK_NOT_ALLOWED_IN_PARENT` when placing a `childType` block under a
5016
+ * `parentType` block would violate the collection's rules. `parentType` must be
5017
+ * the literal `'root'` when the parent is the collection root.
5018
+ *
5019
+ * Two gates, in order:
5020
+ * 1. Container gate — a non-root parent whose `allowChildren` is not `true`
5021
+ * rejects every child.
5022
+ * 2. Acceptance gate — the parent's `accepts`/`excludes` rule, if any:
5023
+ * `only` rejects a child not in the set; `except` rejects a child in the set.
5024
+ *
5025
+ * A parent with no rule (open) and the root (always a container) pass gate 1
5026
+ * and/or gate 2 trivially, so collections without a `structure` map only feel
5027
+ * the `allowChildren` gate.
5028
+ */ function assertPlacementAllowed(index, childType, parentType) {
5029
+ if (parentType !== 'root' && !index.containers.has(parentType)) throw new CMSError('BLOCK_NOT_ALLOWED_IN_PARENT', {
5030
+ message: `Block '${parentType}' does not accept child blocks (its 'allowChildren' is not set)`,
5031
+ data: {
5032
+ childType,
5033
+ parentType,
5034
+ reason: 'not-a-container'
5035
+ }
5036
+ });
5037
+ const rule = index.rules.get(parentType);
5038
+ if (!rule) return;
5039
+ if (rule.mode === 'only' && !rule.set.has(childType)) throw new CMSError('BLOCK_NOT_ALLOWED_IN_PARENT', {
5040
+ message: `Block '${parentType}' accepts only [${[
5041
+ ...rule.set
5042
+ ].join(', ')}] — ` + `'${childType}' is not allowed inside it`,
5043
+ data: {
5044
+ childType,
5045
+ parentType,
5046
+ accepts: [
5047
+ ...rule.set
5048
+ ]
5049
+ }
5050
+ });
5051
+ if (rule.mode === 'except' && rule.set.has(childType)) throw new CMSError('BLOCK_NOT_ALLOWED_IN_PARENT', {
5052
+ message: `Block '${childType}' is not allowed inside '${parentType}' (excluded)`,
5053
+ data: {
5054
+ childType,
5055
+ parentType,
5056
+ excludes: [
5057
+ ...rule.set
5058
+ ]
5059
+ }
5060
+ });
5061
+ }
5062
+
4977
5063
  function assembleBlockTree(blocks, rootId) {
4978
5064
  const deletedBlockIds = new Set();
4979
5065
  const nodeMap = new Map();
@@ -5568,6 +5654,10 @@ const blockTreeNodeSchema = z__namespace.lazy(()=>z__namespace.object({
5568
5654
  function createBlocksEndpoints(def, cmsCtx) {
5569
5655
  const { db } = cmsCtx;
5570
5656
  const collectionName = def.name;
5657
+ // Derived once per collection: the placement rules the create/move/duplicate
5658
+ // routes enforce — `accepts`/`excludes` from `structure` plus the
5659
+ // `allowChildren` container gate from the block defs.
5660
+ const placementIndex = buildPlacementIndex(def.structure, def.blocks);
5571
5661
  return {
5572
5662
  /**
5573
5663
  * Creates a new root (page/entry) with initial draft branch and commit.
@@ -5940,6 +6030,10 @@ function createBlocksEndpoints(def, cmsCtx) {
5940
6030
  if (parentVersion.deleted) throw new CMSError('BLOCK_ALREADY_DELETED', {
5941
6031
  message: errorMessages.blockAlreadyDeleted(parentBlockId)
5942
6032
  });
6033
+ // Enforce the collection's placement rules. The root block's id equals
6034
+ // the rootId and is stored with `type === collectionName`, so normalize
6035
+ // it to the literal 'root' the structure map keys on.
6036
+ assertPlacementAllowed(placementIndex, type, parentBlockId === rootId ? 'root' : parentVersion.type);
5943
6037
  const childBlockId = newId('block');
5944
6038
  const blockProps = properties ?? {};
5945
6039
  const newChildrenArray = [
@@ -6147,6 +6241,7 @@ function createBlocksEndpoints(def, cmsCtx) {
6147
6241
  if (newParent.deleted) throw new CMSError('BLOCK_ALREADY_DELETED', {
6148
6242
  message: errorMessages.blockAlreadyDeleted(input.newParentBlockId)
6149
6243
  });
6244
+ assertPlacementAllowed(placementIndex, movedBlock.type, input.newParentBlockId === input.rootId ? 'root' : newParent.type);
6150
6245
  const oldChildren = (oldParent.children ?? []).filter((id)=>id !== input.blockId);
6151
6246
  const newChildren = [
6152
6247
  ...newParent.children ?? []
@@ -6434,6 +6529,7 @@ function createBlocksEndpoints(def, cmsCtx) {
6434
6529
  if (parentVersion.deleted) throw new CMSError('BLOCK_ALREADY_DELETED', {
6435
6530
  message: errorMessages.blockAlreadyDeleted(input.targetParentBlockId)
6436
6531
  });
6532
+ assertPlacementAllowed(placementIndex, sourceVersion.type, input.targetParentBlockId === input.rootId ? 'root' : parentVersion.type);
6437
6533
  const topLevelCopyId = copies[0].newBlockId;
6438
6534
  const updatedChildren = [
6439
6535
  ...parentVersion.children ?? []
@@ -13725,6 +13821,15 @@ function definePluginSchema(schema) {
13725
13821
  /**
13726
13822
  * Defines a content collection with a root block and a set of child blocks.
13727
13823
  *
13824
+ * Blocks may be referenced (`blocks: { hero }`) or written inline
13825
+ * (`blocks: { hero: { label, properties } }`). For the inline form the `blocks`
13826
+ * parameter is shaped as `{ [K in keyof TBlocks]: AnyBlockDefinition } & TBlocks`
13827
+ * — the mapped half gives each block value the concrete `BlockDefinition`
13828
+ * contextual type so editors autocomplete its fields (`label`, `properties`,
13829
+ * `events`, …) instead of falling back to globals, while the `& TBlocks` half
13830
+ * keeps inferring each block's specific shape (so the typed create/update API
13831
+ * and `structure` autocomplete still see the exact block keys and properties).
13832
+ *
13728
13833
  * @example
13729
13834
  * ```ts
13730
13835
  * const pages = defineCollection({
package/dist/index.d.cts CHANGED
@@ -3856,13 +3856,20 @@ type BlockDefinition<TProps extends Record<string, BlockProperty> = Record<strin
3856
3856
  label: string;
3857
3857
  description?: string;
3858
3858
  previewImageUrl?: string;
3859
+ /**
3860
+ * Editor hint: the block-picker category this block is shown under (e.g.
3861
+ * `'Forms'`, `'Layout'`). Purely presentational — the editor groups blocks by
3862
+ * this label; the package never acts on it. Free-form by design; for
3863
+ * consistent, autocompleted group names across blocks, reference a shared
3864
+ * `as const` object (e.g. `group: BLOCK_GROUPS.forms`).
3865
+ */
3866
+ group?: string;
3859
3867
  /** Events this (functional) block can emit — see {@link EventDeclaration}. */
3860
3868
  events?: TEvents;
3861
3869
  } & ({
3862
3870
  allowChildren?: false;
3863
3871
  } | {
3864
3872
  allowChildren: true;
3865
- allowedChildBlocks?: string[];
3866
3873
  });
3867
3874
  type AnyBlockDefinition = BlockDefinition<Record<string, BlockProperty>, Record<string, EventDeclaration>>;
3868
3875
  /** Discriminated union input for creating a block: `{ type: 'paragraph', properties: { text: '...' } }`. */
@@ -4021,6 +4028,51 @@ type ListBranchesResult = {
4021
4028
  type RootDefinition<TProps extends Record<string, BlockProperty> = Record<string, BlockProperty>> = {
4022
4029
  properties: TProps;
4023
4030
  };
4031
+ /**
4032
+ * One PARENT's placement rule inside a collection's {@link CollectionStructure}
4033
+ * — declares which child block types that parent (or the literal `'root'`) may
4034
+ * contain. There are three mutually-exclusive modes, enforced by the type:
4035
+ *
4036
+ * - **open** — `{}` or `{ accepts: '*' }`: holds any block. (Same as having no
4037
+ * entry at all; `'*'` is just an explicit, readable form.)
4038
+ * - **whitelist** — `{ accepts: ['a', 'b'] }`: holds ONLY `a`/`b`. Fail-closed —
4039
+ * a block added to the collection later is rejected until listed. `excludes`
4040
+ * is forbidden here (a concrete `accepts` already says exactly what's allowed).
4041
+ * - **blacklist** — `{ excludes: ['z'] }` (or `{ accepts: '*', excludes: ['z'] }`):
4042
+ * holds anything EXCEPT `z`. Fail-open — a block added later is accepted.
4043
+ *
4044
+ * Whether a parent accepts children AT ALL is the separate, coarser
4045
+ * `allowChildren` gate on the block (the root always accepts children); these
4046
+ * rules only refine WHICH children an accepting parent may hold.
4047
+ */
4048
+ type BlockStructureEntry<TBlockName extends string> = {
4049
+ /** `'*'` = open base (optional, for readability). */
4050
+ accepts?: '*';
4051
+ /** Holds anything except these. */
4052
+ excludes?: readonly TBlockName[];
4053
+ } | {
4054
+ /** Holds ONLY these block types. */
4055
+ accepts: readonly TBlockName[];
4056
+ /**
4057
+ * Forbidden alongside a concrete `accepts` list — the list already names
4058
+ * exactly what is allowed, so `excludes` would be ignored.
4059
+ */
4060
+ excludes?: "Remove 'excludes': a concrete 'accepts' list already defines exactly which blocks are allowed. Use accepts: '*' with excludes for an all-except list.";
4061
+ };
4062
+ /**
4063
+ * Placement rules for a collection, keyed by PARENT block name (or the literal
4064
+ * `'root'` for the top level). Open by default: a parent with no entry holds any
4065
+ * block. The keys and the `accepts` / `excludes` block names autocomplete against
4066
+ * the collection's block names and are checked at compile time by
4067
+ * {@link defineCollection} (the field type alone enforces this — no extra step).
4068
+ *
4069
+ * This is the single source of truth that the visual editor (drop-zone gating)
4070
+ * and the server guard (createBlock / moveBlock / duplicateBlock) both read,
4071
+ * alongside each block's `allowChildren` flag, so they can never diverge.
4072
+ */
4073
+ type CollectionStructure<TBlocks extends Record<string, AnyBlockDefinition>> = {
4074
+ [K in keyof TBlocks | 'root']?: BlockStructureEntry<keyof TBlocks & string>;
4075
+ };
4024
4076
  type SlugConfig = {
4025
4077
  enabled: false;
4026
4078
  } | {
@@ -4053,6 +4105,15 @@ type CollectionDefinition<TProps extends Record<string, BlockProperty> = Record<
4053
4105
  * regardless of this flag). Any collection can still be a reference target.
4054
4106
  */
4055
4107
  reusableBlock?: boolean;
4108
+ /**
4109
+ * Placement rules keyed by PARENT block name (or `'root'`) — which children
4110
+ * each container may hold, via `accepts` (whitelist) / `excludes` (blacklist)
4111
+ * (see {@link CollectionStructure}). Read by the editor and the server guard
4112
+ * together with each block's `allowChildren` flag. Open by default; block
4113
+ * names are checked at compile time by the field type itself, so a typo is a
4114
+ * compile error at the `defineCollection` call site.
4115
+ */
4116
+ structure?: CollectionStructure<TBlocks>;
4056
4117
  };
4057
4118
  type AnyCollectionDefinition = CollectionDefinition<Record<string, BlockProperty>, Record<string, AnyBlockDefinition>>;
4058
4119
  type CollectionWithName = Omit<AnyCollectionDefinition, 'blocks'> & {
@@ -9760,6 +9821,10 @@ declare const CMS_ERRORS: {
9760
9821
  readonly status: 404;
9761
9822
  readonly message: "Parent block not found";
9762
9823
  };
9824
+ readonly BLOCK_NOT_ALLOWED_IN_PARENT: {
9825
+ readonly status: 400;
9826
+ readonly message: "This block type is not allowed inside the target parent";
9827
+ };
9763
9828
  readonly ROOT_NOT_FOUND: {
9764
9829
  readonly status: 404;
9765
9830
  readonly message: "Root block not found in snapshot";
@@ -10220,6 +10285,15 @@ declare function defineRoot<const TProps extends Record<string, BlockProperty>>(
10220
10285
  /**
10221
10286
  * Defines a content collection with a root block and a set of child blocks.
10222
10287
  *
10288
+ * Blocks may be referenced (`blocks: { hero }`) or written inline
10289
+ * (`blocks: { hero: { label, properties } }`). For the inline form the `blocks`
10290
+ * parameter is shaped as `{ [K in keyof TBlocks]: AnyBlockDefinition } & TBlocks`
10291
+ * — the mapped half gives each block value the concrete `BlockDefinition`
10292
+ * contextual type so editors autocomplete its fields (`label`, `properties`,
10293
+ * `events`, …) instead of falling back to globals, while the `& TBlocks` half
10294
+ * keeps inferring each block's specific shape (so the typed create/update API
10295
+ * and `structure` autocomplete still see the exact block keys and properties).
10296
+ *
10223
10297
  * @example
10224
10298
  * ```ts
10225
10299
  * const pages = defineCollection({
@@ -10229,7 +10303,11 @@ declare function defineRoot<const TProps extends Record<string, BlockProperty>>(
10229
10303
  * });
10230
10304
  * ```
10231
10305
  */
10232
- declare function defineCollection<const TProps extends Record<string, BlockProperty>, const TBlocks extends Record<string, AnyBlockDefinition> = Record<string, never>>(collection: CollectionDefinition<TProps, TBlocks>): CollectionDefinition<TProps, TBlocks>;
10306
+ declare function defineCollection<const TProps extends Record<string, BlockProperty>, const TBlocks extends Record<string, AnyBlockDefinition> = Record<string, never>>(collection: Omit<CollectionDefinition<TProps, TBlocks>, 'blocks'> & {
10307
+ blocks?: {
10308
+ [K in keyof TBlocks]: AnyBlockDefinition;
10309
+ } & TBlocks;
10310
+ }): CollectionDefinition<TProps, TBlocks>;
10233
10311
  type ExtractReferencedCollections<T extends Record<string, AnyCollectionDefinition>> = {
10234
10312
  [K in keyof T]: T[K] extends CollectionDefinition<infer _P, infer TBlocks> ? {
10235
10313
  [B in keyof TBlocks]: TBlocks[B] extends BlockDefinition<infer BProps, any> ? {
@@ -10274,4 +10352,4 @@ declare function defineCollections<const TCollections extends Record<string, Any
10274
10352
  declare function defineAuthMiddleware(middleware: CMSMiddleware): CMSMiddleware;
10275
10353
 
10276
10354
  export { CMSClientError, CMSError, CMS_ERRORS, buildFullPath, cmsContext, cmsMeta, createCMS, createCMSClient, createCMSEndpoint, createCMSQuery, defineAuthMiddleware, defineBlock, defineCollection, defineCollections, defineColumns, defineCoreSchema, definePluginSchema, defineRoot, defineTable, getCMSErrorCode, isCMSError, newId, normalizeSlug, registerIdPrefix, rootRevalidateTag, runPruningPass, splitPath, trackingId };
10277
- export type { AnyBlockDefinition, AnyCollectionDefinition, Approval, Asset, AssetFolder, BlockDefinition, BlockEventFire, BlockEventNames, BlockProperty, BlockPropertyType, BlockTreeNode, BlockVersion, Branch, BranchListItem, CMSAPIError, CMSAfterHook, CMSAfterHookContext, CMSAtomListener, CMSBeforeHook, CMSBeforeHookContext, CMSClientInstance, CMSClientOptions, CMSClientPlugin, CMSClientStore, CMSConfigHooks, CMSCoreRootPruningPlan, CMSDefinition, CMSEndpointContext, CMSEndpointCtx, CMSEndpointKey, CMSEndpointMeta, CMSErrorCode, CMSFetch, CMSHandlerCtx, CMSHookAction, CMSHooks, CMSMiddleware, CMSMiddlewareCtx, CMSMiddlewareRequest, CMSOperation, CMSPlugin, CMSPluginContext, CMSPluginInitOptions, CMSPluginInitResult, CMSPluginPruning, CMSPluginPruningExecuteContext, CMSPluginPruningExecuteResult, CMSPluginPruningMetrics, CMSPluginPruningPlanContext, CMSPluginRootPruningPlan, CMSProcedureCtx, CMSSchemaConfig, CMSSystemHandlerCtx, CMSUserConfig, CollectionDefinition, CollectionWithName, CommentMention, CommentMessage, CommentThread, Commit, CommitSnapshot, ContentUsage, DataRetentionConfig, DrizzleInstance, EnumMap, EventDeclaration, IdPrefix, InferBlockInput, InferBlockProperties, InferBlockTreeNode, InferCreateBlockInput, InferEventParams, InferPartialBlockProperties, InferPluginContext, InferPluginEndpoints, InferPluginErrorCodes, InferUpdateBlockInput, ListBranchesResult, ListMergeRequestsResult, ListNotificationsResult, ListRootsResult, MediaUploadFileState, MediaUploadOptions, MediaUploadState, MergeConflict, MergeRequest, MergeRequestListItem, MiddlewareResult, NewApproval, NewAsset, NewAssetFolder, NewBlockVersion, NewBranch, NewCommentMention, NewCommentMessage, NewCommentThread, NewCommit, NewCommitSnapshot, NewContentUsage, NewMergeConflict, NewMergeRequest, NewNotification, NewPublication, NewRedirect, NewRoot, NewSearchIndex, NewTemplate, NewTemplateVariableUsage, NewVariable, Notification, NotificationInput, NotificationListItem, NotificationPayload, NotificationService, NotificationType, OnNotificationHandler, PruningPassOptions, PruningPassResult, PruningResult, Publication, QueryState, Redirect, ResolvedReference, ResolvedScope, ResolvedSlugConfig, RevalidateConfig, RevalidateEvent, RevalidateHandler, Root, RootDefinition, RootListItem, RootSummary, ScalarBlockProperty, SchemaModule, ScopeConditionFactory, SearchIndex, TableMap, TableScope, Template, TemplateVariableUsage, Variable };
10355
+ export type { AnyBlockDefinition, AnyCollectionDefinition, Approval, Asset, AssetFolder, BlockDefinition, BlockEventFire, BlockEventNames, BlockProperty, BlockPropertyType, BlockStructureEntry, BlockTreeNode, BlockVersion, Branch, BranchListItem, CMSAPIError, CMSAfterHook, CMSAfterHookContext, CMSAtomListener, CMSBeforeHook, CMSBeforeHookContext, CMSClientInstance, CMSClientOptions, CMSClientPlugin, CMSClientStore, CMSConfigHooks, CMSCoreRootPruningPlan, CMSDefinition, CMSEndpointContext, CMSEndpointCtx, CMSEndpointKey, CMSEndpointMeta, CMSErrorCode, CMSFetch, CMSHandlerCtx, CMSHookAction, CMSHooks, CMSMiddleware, CMSMiddlewareCtx, CMSMiddlewareRequest, CMSOperation, CMSPlugin, CMSPluginContext, CMSPluginInitOptions, CMSPluginInitResult, CMSPluginPruning, CMSPluginPruningExecuteContext, CMSPluginPruningExecuteResult, CMSPluginPruningMetrics, CMSPluginPruningPlanContext, CMSPluginRootPruningPlan, CMSProcedureCtx, CMSSchemaConfig, CMSSystemHandlerCtx, CMSUserConfig, CollectionDefinition, CollectionStructure, CollectionWithName, CommentMention, CommentMessage, CommentThread, Commit, CommitSnapshot, ContentUsage, DataRetentionConfig, DrizzleInstance, EnumMap, EventDeclaration, IdPrefix, InferBlockInput, InferBlockProperties, InferBlockTreeNode, InferCreateBlockInput, InferEventParams, InferPartialBlockProperties, InferPluginContext, InferPluginEndpoints, InferPluginErrorCodes, InferUpdateBlockInput, ListBranchesResult, ListMergeRequestsResult, ListNotificationsResult, ListRootsResult, MediaUploadFileState, MediaUploadOptions, MediaUploadState, MergeConflict, MergeRequest, MergeRequestListItem, MiddlewareResult, NewApproval, NewAsset, NewAssetFolder, NewBlockVersion, NewBranch, NewCommentMention, NewCommentMessage, NewCommentThread, NewCommit, NewCommitSnapshot, NewContentUsage, NewMergeConflict, NewMergeRequest, NewNotification, NewPublication, NewRedirect, NewRoot, NewSearchIndex, NewTemplate, NewTemplateVariableUsage, NewVariable, Notification, NotificationInput, NotificationListItem, NotificationPayload, NotificationService, NotificationType, OnNotificationHandler, PruningPassOptions, PruningPassResult, PruningResult, Publication, QueryState, Redirect, ResolvedReference, ResolvedScope, ResolvedSlugConfig, RevalidateConfig, RevalidateEvent, RevalidateHandler, Root, RootDefinition, RootListItem, RootSummary, ScalarBlockProperty, SchemaModule, ScopeConditionFactory, SearchIndex, TableMap, TableScope, Template, TemplateVariableUsage, Variable };
package/dist/index.d.ts CHANGED
@@ -3856,13 +3856,20 @@ type BlockDefinition<TProps extends Record<string, BlockProperty> = Record<strin
3856
3856
  label: string;
3857
3857
  description?: string;
3858
3858
  previewImageUrl?: string;
3859
+ /**
3860
+ * Editor hint: the block-picker category this block is shown under (e.g.
3861
+ * `'Forms'`, `'Layout'`). Purely presentational — the editor groups blocks by
3862
+ * this label; the package never acts on it. Free-form by design; for
3863
+ * consistent, autocompleted group names across blocks, reference a shared
3864
+ * `as const` object (e.g. `group: BLOCK_GROUPS.forms`).
3865
+ */
3866
+ group?: string;
3859
3867
  /** Events this (functional) block can emit — see {@link EventDeclaration}. */
3860
3868
  events?: TEvents;
3861
3869
  } & ({
3862
3870
  allowChildren?: false;
3863
3871
  } | {
3864
3872
  allowChildren: true;
3865
- allowedChildBlocks?: string[];
3866
3873
  });
3867
3874
  type AnyBlockDefinition = BlockDefinition<Record<string, BlockProperty>, Record<string, EventDeclaration>>;
3868
3875
  /** Discriminated union input for creating a block: `{ type: 'paragraph', properties: { text: '...' } }`. */
@@ -4021,6 +4028,51 @@ type ListBranchesResult = {
4021
4028
  type RootDefinition<TProps extends Record<string, BlockProperty> = Record<string, BlockProperty>> = {
4022
4029
  properties: TProps;
4023
4030
  };
4031
+ /**
4032
+ * One PARENT's placement rule inside a collection's {@link CollectionStructure}
4033
+ * — declares which child block types that parent (or the literal `'root'`) may
4034
+ * contain. There are three mutually-exclusive modes, enforced by the type:
4035
+ *
4036
+ * - **open** — `{}` or `{ accepts: '*' }`: holds any block. (Same as having no
4037
+ * entry at all; `'*'` is just an explicit, readable form.)
4038
+ * - **whitelist** — `{ accepts: ['a', 'b'] }`: holds ONLY `a`/`b`. Fail-closed —
4039
+ * a block added to the collection later is rejected until listed. `excludes`
4040
+ * is forbidden here (a concrete `accepts` already says exactly what's allowed).
4041
+ * - **blacklist** — `{ excludes: ['z'] }` (or `{ accepts: '*', excludes: ['z'] }`):
4042
+ * holds anything EXCEPT `z`. Fail-open — a block added later is accepted.
4043
+ *
4044
+ * Whether a parent accepts children AT ALL is the separate, coarser
4045
+ * `allowChildren` gate on the block (the root always accepts children); these
4046
+ * rules only refine WHICH children an accepting parent may hold.
4047
+ */
4048
+ type BlockStructureEntry<TBlockName extends string> = {
4049
+ /** `'*'` = open base (optional, for readability). */
4050
+ accepts?: '*';
4051
+ /** Holds anything except these. */
4052
+ excludes?: readonly TBlockName[];
4053
+ } | {
4054
+ /** Holds ONLY these block types. */
4055
+ accepts: readonly TBlockName[];
4056
+ /**
4057
+ * Forbidden alongside a concrete `accepts` list — the list already names
4058
+ * exactly what is allowed, so `excludes` would be ignored.
4059
+ */
4060
+ excludes?: "Remove 'excludes': a concrete 'accepts' list already defines exactly which blocks are allowed. Use accepts: '*' with excludes for an all-except list.";
4061
+ };
4062
+ /**
4063
+ * Placement rules for a collection, keyed by PARENT block name (or the literal
4064
+ * `'root'` for the top level). Open by default: a parent with no entry holds any
4065
+ * block. The keys and the `accepts` / `excludes` block names autocomplete against
4066
+ * the collection's block names and are checked at compile time by
4067
+ * {@link defineCollection} (the field type alone enforces this — no extra step).
4068
+ *
4069
+ * This is the single source of truth that the visual editor (drop-zone gating)
4070
+ * and the server guard (createBlock / moveBlock / duplicateBlock) both read,
4071
+ * alongside each block's `allowChildren` flag, so they can never diverge.
4072
+ */
4073
+ type CollectionStructure<TBlocks extends Record<string, AnyBlockDefinition>> = {
4074
+ [K in keyof TBlocks | 'root']?: BlockStructureEntry<keyof TBlocks & string>;
4075
+ };
4024
4076
  type SlugConfig = {
4025
4077
  enabled: false;
4026
4078
  } | {
@@ -4053,6 +4105,15 @@ type CollectionDefinition<TProps extends Record<string, BlockProperty> = Record<
4053
4105
  * regardless of this flag). Any collection can still be a reference target.
4054
4106
  */
4055
4107
  reusableBlock?: boolean;
4108
+ /**
4109
+ * Placement rules keyed by PARENT block name (or `'root'`) — which children
4110
+ * each container may hold, via `accepts` (whitelist) / `excludes` (blacklist)
4111
+ * (see {@link CollectionStructure}). Read by the editor and the server guard
4112
+ * together with each block's `allowChildren` flag. Open by default; block
4113
+ * names are checked at compile time by the field type itself, so a typo is a
4114
+ * compile error at the `defineCollection` call site.
4115
+ */
4116
+ structure?: CollectionStructure<TBlocks>;
4056
4117
  };
4057
4118
  type AnyCollectionDefinition = CollectionDefinition<Record<string, BlockProperty>, Record<string, AnyBlockDefinition>>;
4058
4119
  type CollectionWithName = Omit<AnyCollectionDefinition, 'blocks'> & {
@@ -9760,6 +9821,10 @@ declare const CMS_ERRORS: {
9760
9821
  readonly status: 404;
9761
9822
  readonly message: "Parent block not found";
9762
9823
  };
9824
+ readonly BLOCK_NOT_ALLOWED_IN_PARENT: {
9825
+ readonly status: 400;
9826
+ readonly message: "This block type is not allowed inside the target parent";
9827
+ };
9763
9828
  readonly ROOT_NOT_FOUND: {
9764
9829
  readonly status: 404;
9765
9830
  readonly message: "Root block not found in snapshot";
@@ -10220,6 +10285,15 @@ declare function defineRoot<const TProps extends Record<string, BlockProperty>>(
10220
10285
  /**
10221
10286
  * Defines a content collection with a root block and a set of child blocks.
10222
10287
  *
10288
+ * Blocks may be referenced (`blocks: { hero }`) or written inline
10289
+ * (`blocks: { hero: { label, properties } }`). For the inline form the `blocks`
10290
+ * parameter is shaped as `{ [K in keyof TBlocks]: AnyBlockDefinition } & TBlocks`
10291
+ * — the mapped half gives each block value the concrete `BlockDefinition`
10292
+ * contextual type so editors autocomplete its fields (`label`, `properties`,
10293
+ * `events`, …) instead of falling back to globals, while the `& TBlocks` half
10294
+ * keeps inferring each block's specific shape (so the typed create/update API
10295
+ * and `structure` autocomplete still see the exact block keys and properties).
10296
+ *
10223
10297
  * @example
10224
10298
  * ```ts
10225
10299
  * const pages = defineCollection({
@@ -10229,7 +10303,11 @@ declare function defineRoot<const TProps extends Record<string, BlockProperty>>(
10229
10303
  * });
10230
10304
  * ```
10231
10305
  */
10232
- declare function defineCollection<const TProps extends Record<string, BlockProperty>, const TBlocks extends Record<string, AnyBlockDefinition> = Record<string, never>>(collection: CollectionDefinition<TProps, TBlocks>): CollectionDefinition<TProps, TBlocks>;
10306
+ declare function defineCollection<const TProps extends Record<string, BlockProperty>, const TBlocks extends Record<string, AnyBlockDefinition> = Record<string, never>>(collection: Omit<CollectionDefinition<TProps, TBlocks>, 'blocks'> & {
10307
+ blocks?: {
10308
+ [K in keyof TBlocks]: AnyBlockDefinition;
10309
+ } & TBlocks;
10310
+ }): CollectionDefinition<TProps, TBlocks>;
10233
10311
  type ExtractReferencedCollections<T extends Record<string, AnyCollectionDefinition>> = {
10234
10312
  [K in keyof T]: T[K] extends CollectionDefinition<infer _P, infer TBlocks> ? {
10235
10313
  [B in keyof TBlocks]: TBlocks[B] extends BlockDefinition<infer BProps, any> ? {
@@ -10274,4 +10352,4 @@ declare function defineCollections<const TCollections extends Record<string, Any
10274
10352
  declare function defineAuthMiddleware(middleware: CMSMiddleware): CMSMiddleware;
10275
10353
 
10276
10354
  export { CMSClientError, CMSError, CMS_ERRORS, buildFullPath, cmsContext, cmsMeta, createCMS, createCMSClient, createCMSEndpoint, createCMSQuery, defineAuthMiddleware, defineBlock, defineCollection, defineCollections, defineColumns, defineCoreSchema, definePluginSchema, defineRoot, defineTable, getCMSErrorCode, isCMSError, newId, normalizeSlug, registerIdPrefix, rootRevalidateTag, runPruningPass, splitPath, trackingId };
10277
- export type { AnyBlockDefinition, AnyCollectionDefinition, Approval, Asset, AssetFolder, BlockDefinition, BlockEventFire, BlockEventNames, BlockProperty, BlockPropertyType, BlockTreeNode, BlockVersion, Branch, BranchListItem, CMSAPIError, CMSAfterHook, CMSAfterHookContext, CMSAtomListener, CMSBeforeHook, CMSBeforeHookContext, CMSClientInstance, CMSClientOptions, CMSClientPlugin, CMSClientStore, CMSConfigHooks, CMSCoreRootPruningPlan, CMSDefinition, CMSEndpointContext, CMSEndpointCtx, CMSEndpointKey, CMSEndpointMeta, CMSErrorCode, CMSFetch, CMSHandlerCtx, CMSHookAction, CMSHooks, CMSMiddleware, CMSMiddlewareCtx, CMSMiddlewareRequest, CMSOperation, CMSPlugin, CMSPluginContext, CMSPluginInitOptions, CMSPluginInitResult, CMSPluginPruning, CMSPluginPruningExecuteContext, CMSPluginPruningExecuteResult, CMSPluginPruningMetrics, CMSPluginPruningPlanContext, CMSPluginRootPruningPlan, CMSProcedureCtx, CMSSchemaConfig, CMSSystemHandlerCtx, CMSUserConfig, CollectionDefinition, CollectionWithName, CommentMention, CommentMessage, CommentThread, Commit, CommitSnapshot, ContentUsage, DataRetentionConfig, DrizzleInstance, EnumMap, EventDeclaration, IdPrefix, InferBlockInput, InferBlockProperties, InferBlockTreeNode, InferCreateBlockInput, InferEventParams, InferPartialBlockProperties, InferPluginContext, InferPluginEndpoints, InferPluginErrorCodes, InferUpdateBlockInput, ListBranchesResult, ListMergeRequestsResult, ListNotificationsResult, ListRootsResult, MediaUploadFileState, MediaUploadOptions, MediaUploadState, MergeConflict, MergeRequest, MergeRequestListItem, MiddlewareResult, NewApproval, NewAsset, NewAssetFolder, NewBlockVersion, NewBranch, NewCommentMention, NewCommentMessage, NewCommentThread, NewCommit, NewCommitSnapshot, NewContentUsage, NewMergeConflict, NewMergeRequest, NewNotification, NewPublication, NewRedirect, NewRoot, NewSearchIndex, NewTemplate, NewTemplateVariableUsage, NewVariable, Notification, NotificationInput, NotificationListItem, NotificationPayload, NotificationService, NotificationType, OnNotificationHandler, PruningPassOptions, PruningPassResult, PruningResult, Publication, QueryState, Redirect, ResolvedReference, ResolvedScope, ResolvedSlugConfig, RevalidateConfig, RevalidateEvent, RevalidateHandler, Root, RootDefinition, RootListItem, RootSummary, ScalarBlockProperty, SchemaModule, ScopeConditionFactory, SearchIndex, TableMap, TableScope, Template, TemplateVariableUsage, Variable };
10355
+ export type { AnyBlockDefinition, AnyCollectionDefinition, Approval, Asset, AssetFolder, BlockDefinition, BlockEventFire, BlockEventNames, BlockProperty, BlockPropertyType, BlockStructureEntry, BlockTreeNode, BlockVersion, Branch, BranchListItem, CMSAPIError, CMSAfterHook, CMSAfterHookContext, CMSAtomListener, CMSBeforeHook, CMSBeforeHookContext, CMSClientInstance, CMSClientOptions, CMSClientPlugin, CMSClientStore, CMSConfigHooks, CMSCoreRootPruningPlan, CMSDefinition, CMSEndpointContext, CMSEndpointCtx, CMSEndpointKey, CMSEndpointMeta, CMSErrorCode, CMSFetch, CMSHandlerCtx, CMSHookAction, CMSHooks, CMSMiddleware, CMSMiddlewareCtx, CMSMiddlewareRequest, CMSOperation, CMSPlugin, CMSPluginContext, CMSPluginInitOptions, CMSPluginInitResult, CMSPluginPruning, CMSPluginPruningExecuteContext, CMSPluginPruningExecuteResult, CMSPluginPruningMetrics, CMSPluginPruningPlanContext, CMSPluginRootPruningPlan, CMSProcedureCtx, CMSSchemaConfig, CMSSystemHandlerCtx, CMSUserConfig, CollectionDefinition, CollectionStructure, CollectionWithName, CommentMention, CommentMessage, CommentThread, Commit, CommitSnapshot, ContentUsage, DataRetentionConfig, DrizzleInstance, EnumMap, EventDeclaration, IdPrefix, InferBlockInput, InferBlockProperties, InferBlockTreeNode, InferCreateBlockInput, InferEventParams, InferPartialBlockProperties, InferPluginContext, InferPluginEndpoints, InferPluginErrorCodes, InferUpdateBlockInput, ListBranchesResult, ListMergeRequestsResult, ListNotificationsResult, ListRootsResult, MediaUploadFileState, MediaUploadOptions, MediaUploadState, MergeConflict, MergeRequest, MergeRequestListItem, MiddlewareResult, NewApproval, NewAsset, NewAssetFolder, NewBlockVersion, NewBranch, NewCommentMention, NewCommentMessage, NewCommentThread, NewCommit, NewCommitSnapshot, NewContentUsage, NewMergeConflict, NewMergeRequest, NewNotification, NewPublication, NewRedirect, NewRoot, NewSearchIndex, NewTemplate, NewTemplateVariableUsage, NewVariable, Notification, NotificationInput, NotificationListItem, NotificationPayload, NotificationService, NotificationType, OnNotificationHandler, PruningPassOptions, PruningPassResult, PruningResult, Publication, QueryState, Redirect, ResolvedReference, ResolvedScope, ResolvedSlugConfig, RevalidateConfig, RevalidateEvent, RevalidateHandler, Root, RootDefinition, RootListItem, RootSummary, ScalarBlockProperty, SchemaModule, ScopeConditionFactory, SearchIndex, TableMap, TableScope, Template, TemplateVariableUsage, Variable };
package/dist/index.js CHANGED
@@ -1051,6 +1051,10 @@ const CMS_ERRORS = {
1051
1051
  status: 404,
1052
1052
  message: 'Parent block not found'
1053
1053
  },
1054
+ BLOCK_NOT_ALLOWED_IN_PARENT: {
1055
+ status: 400,
1056
+ message: 'This block type is not allowed inside the target parent'
1057
+ },
1054
1058
  ROOT_NOT_FOUND: {
1055
1059
  status: 404,
1056
1060
  message: 'Root block not found in snapshot'
@@ -4949,6 +4953,88 @@ notFound = 'ROOT_NOT_FOUND') {
4949
4953
  }
4950
4954
  }
4951
4955
 
4956
+ /** Builds the {@link PlacementIndex} from a collection's `structure` + blocks. */ function buildPlacementIndex(structure, blocks) {
4957
+ const rules = new Map();
4958
+ if (structure) {
4959
+ for (const [parent, entry] of Object.entries(structure)){
4960
+ if (!entry) continue;
4961
+ const accepts = entry.accepts;
4962
+ if (Array.isArray(accepts)) {
4963
+ // Concrete whitelist (incl. the empty "holds nothing" list).
4964
+ rules.set(parent, {
4965
+ mode: 'only',
4966
+ set: new Set(accepts)
4967
+ });
4968
+ } else if (entry.excludes && entry.excludes.length > 0) {
4969
+ // Open base ('*' or omitted) minus an explicit blacklist.
4970
+ rules.set(parent, {
4971
+ mode: 'except',
4972
+ set: new Set(entry.excludes)
4973
+ });
4974
+ }
4975
+ // else: open ('*' / nothing, no excludes) — no rule.
4976
+ }
4977
+ }
4978
+ const containers = new Set();
4979
+ if (blocks) {
4980
+ for (const [name, def] of Object.entries(blocks)){
4981
+ if (def.allowChildren === true) containers.add(name);
4982
+ }
4983
+ }
4984
+ return {
4985
+ rules,
4986
+ containers
4987
+ };
4988
+ }
4989
+ /**
4990
+ * Throws `BLOCK_NOT_ALLOWED_IN_PARENT` when placing a `childType` block under a
4991
+ * `parentType` block would violate the collection's rules. `parentType` must be
4992
+ * the literal `'root'` when the parent is the collection root.
4993
+ *
4994
+ * Two gates, in order:
4995
+ * 1. Container gate — a non-root parent whose `allowChildren` is not `true`
4996
+ * rejects every child.
4997
+ * 2. Acceptance gate — the parent's `accepts`/`excludes` rule, if any:
4998
+ * `only` rejects a child not in the set; `except` rejects a child in the set.
4999
+ *
5000
+ * A parent with no rule (open) and the root (always a container) pass gate 1
5001
+ * and/or gate 2 trivially, so collections without a `structure` map only feel
5002
+ * the `allowChildren` gate.
5003
+ */ function assertPlacementAllowed(index, childType, parentType) {
5004
+ if (parentType !== 'root' && !index.containers.has(parentType)) throw new CMSError('BLOCK_NOT_ALLOWED_IN_PARENT', {
5005
+ message: `Block '${parentType}' does not accept child blocks (its 'allowChildren' is not set)`,
5006
+ data: {
5007
+ childType,
5008
+ parentType,
5009
+ reason: 'not-a-container'
5010
+ }
5011
+ });
5012
+ const rule = index.rules.get(parentType);
5013
+ if (!rule) return;
5014
+ if (rule.mode === 'only' && !rule.set.has(childType)) throw new CMSError('BLOCK_NOT_ALLOWED_IN_PARENT', {
5015
+ message: `Block '${parentType}' accepts only [${[
5016
+ ...rule.set
5017
+ ].join(', ')}] — ` + `'${childType}' is not allowed inside it`,
5018
+ data: {
5019
+ childType,
5020
+ parentType,
5021
+ accepts: [
5022
+ ...rule.set
5023
+ ]
5024
+ }
5025
+ });
5026
+ if (rule.mode === 'except' && rule.set.has(childType)) throw new CMSError('BLOCK_NOT_ALLOWED_IN_PARENT', {
5027
+ message: `Block '${childType}' is not allowed inside '${parentType}' (excluded)`,
5028
+ data: {
5029
+ childType,
5030
+ parentType,
5031
+ excludes: [
5032
+ ...rule.set
5033
+ ]
5034
+ }
5035
+ });
5036
+ }
5037
+
4952
5038
  function assembleBlockTree(blocks, rootId) {
4953
5039
  const deletedBlockIds = new Set();
4954
5040
  const nodeMap = new Map();
@@ -5543,6 +5629,10 @@ const blockTreeNodeSchema = z.lazy(()=>z.object({
5543
5629
  function createBlocksEndpoints(def, cmsCtx) {
5544
5630
  const { db } = cmsCtx;
5545
5631
  const collectionName = def.name;
5632
+ // Derived once per collection: the placement rules the create/move/duplicate
5633
+ // routes enforce — `accepts`/`excludes` from `structure` plus the
5634
+ // `allowChildren` container gate from the block defs.
5635
+ const placementIndex = buildPlacementIndex(def.structure, def.blocks);
5546
5636
  return {
5547
5637
  /**
5548
5638
  * Creates a new root (page/entry) with initial draft branch and commit.
@@ -5915,6 +6005,10 @@ function createBlocksEndpoints(def, cmsCtx) {
5915
6005
  if (parentVersion.deleted) throw new CMSError('BLOCK_ALREADY_DELETED', {
5916
6006
  message: errorMessages.blockAlreadyDeleted(parentBlockId)
5917
6007
  });
6008
+ // Enforce the collection's placement rules. The root block's id equals
6009
+ // the rootId and is stored with `type === collectionName`, so normalize
6010
+ // it to the literal 'root' the structure map keys on.
6011
+ assertPlacementAllowed(placementIndex, type, parentBlockId === rootId ? 'root' : parentVersion.type);
5918
6012
  const childBlockId = newId('block');
5919
6013
  const blockProps = properties ?? {};
5920
6014
  const newChildrenArray = [
@@ -6122,6 +6216,7 @@ function createBlocksEndpoints(def, cmsCtx) {
6122
6216
  if (newParent.deleted) throw new CMSError('BLOCK_ALREADY_DELETED', {
6123
6217
  message: errorMessages.blockAlreadyDeleted(input.newParentBlockId)
6124
6218
  });
6219
+ assertPlacementAllowed(placementIndex, movedBlock.type, input.newParentBlockId === input.rootId ? 'root' : newParent.type);
6125
6220
  const oldChildren = (oldParent.children ?? []).filter((id)=>id !== input.blockId);
6126
6221
  const newChildren = [
6127
6222
  ...newParent.children ?? []
@@ -6409,6 +6504,7 @@ function createBlocksEndpoints(def, cmsCtx) {
6409
6504
  if (parentVersion.deleted) throw new CMSError('BLOCK_ALREADY_DELETED', {
6410
6505
  message: errorMessages.blockAlreadyDeleted(input.targetParentBlockId)
6411
6506
  });
6507
+ assertPlacementAllowed(placementIndex, sourceVersion.type, input.targetParentBlockId === input.rootId ? 'root' : parentVersion.type);
6412
6508
  const topLevelCopyId = copies[0].newBlockId;
6413
6509
  const updatedChildren = [
6414
6510
  ...parentVersion.children ?? []
@@ -13700,6 +13796,15 @@ function definePluginSchema(schema) {
13700
13796
  /**
13701
13797
  * Defines a content collection with a root block and a set of child blocks.
13702
13798
  *
13799
+ * Blocks may be referenced (`blocks: { hero }`) or written inline
13800
+ * (`blocks: { hero: { label, properties } }`). For the inline form the `blocks`
13801
+ * parameter is shaped as `{ [K in keyof TBlocks]: AnyBlockDefinition } & TBlocks`
13802
+ * — the mapped half gives each block value the concrete `BlockDefinition`
13803
+ * contextual type so editors autocomplete its fields (`label`, `properties`,
13804
+ * `events`, …) instead of falling back to globals, while the `& TBlocks` half
13805
+ * keeps inferring each block's specific shape (so the typed create/update API
13806
+ * and `structure` autocomplete still see the exact block keys and properties).
13807
+ *
13703
13808
  * @example
13704
13809
  * ```ts
13705
13810
  * const pages = defineCollection({