Changelog
Release notes for @createcms/core, generated from Changesets.
0.2.11
Patch Changes
-
#29
ba326e3Thanks @weepaho3! - FixcreateNotificationRouter<typeof cms>typing when no plugin contributes notification types: the empty plugin registry resolved toRecord<string, never>(a string index signature), which widened the known-type union tostringand collapsed every resolver'sn.metatonever. The registry's index signature is now stripped, so core (and appNotificationMetaMap) meta types correctly even with no notification-type plugins.Notification
metanow also carries everything a deep link needs, so the synchronouscreateNotificationRouternever has to look anything up:approvalRequested/approvalApproved/approvalRejectednow includerootIdandbranchName(previously onlybranchId).commentnow includesrootId(it already hadmessageId/threadId), and the reply-pathmentionnotification carriesrootIdtoo.
CoreNotificationMetaMapis updated to match, so router resolvers get the new fields typed. No schema change — these aremeta(jsonb) fields populated from data already joined at emit time.
0.2.10
Patch Changes
-
#26
ba13c71Thanks @weepaho3! - Notifications now carry everything you need for deep links and showing the responsible user, with types to match:createNotificationRouter(new, from@createcms/core/react) — define a resolver per notificationtypethat builds a deep link from the item's fields; each resolver getsmetanarrowed to that type. PasscreateNotificationRouter<typeof cms>(…)to type core and plugin-contributed types. A requiredfallbackkeeps routing total. Pure and client-side — no realtime peer, no server or schema change.- Plugin-extensible notification types — a new
notificationTypesplugin seam (a Zod meta map): its keys fold into thenotification_typeenum atcreatecms generate(so a plugin persists its owntype) and are inferred intotypeof cmsso the router types each pluginmeta. The emit side (cms.notify/notificationService.notify) accepts plugin/app type strings. App-onlycustomtypes can also be typed by augmentingNotificationMetaMap. - Typed
actorUser—listNotifications(withwithUser) anduseNotificationsnow typeactorUseroff youruserconfig (a partial of the user-table row) instead ofunknown, inferred straight fromtypeof cms. - Actor on the live push — the realtime notification event now carries
actorUser, resolved server-side from theuserconfig'sexposeColumns(batched). The responsible user's name/avatar are available the instant a push lands, no second poll.actorUseris also passed toonNotificationhandlers.
-
#26
87b41c0Thanks @weepaho3! -useNotificationsnow takes your typedcreateCMSClientinstance directly — noas unknown as Parameters<typeof useNotifications>[0]cast. The hook's internal client shape brandsquery.withUserastrue(matching the client'sWithUserQuery) instead of plainboolean, so a real typed client is structurally assignable.userIdis now optional. Pass it straight from your auth session (session?.user?.id) instead of the?? ''workaround: while it's undefined the hook stays poll-only (seeded fromlistNotifications) and opens thenotif:<userId>subscription once it resolves. The CMS has no current-user endpoint, so your app still supplies the id.
0.2.9
Patch Changes
-
#24
238208fThanks @weepaho3! - Add an optional c15t adapter for the consent gate at@createcms/core/plugins/consent/c15t.c15t is a consent-management platform (banner + storage + Consent Mode); the createCMS consent gate is the consumer-side layer that buffers the CMS's own A/B + analytics effects until consent is decided. This adapter bridges them:
consentModeFromC15t(consents, mapping?)— pure mapper from c15t's categories to Consent Mode v2 signals (default:measurement→analytics_storage,marketing→ad_storage/ad_user_data/ad_personalization;necessary/functionality/experienceignored). The mapping is overridable.useC15tConsentBridge(client, { consents, hasConsented }, mapping?)— a React hook that pushes c15t's decision into the gate once the visitor has decided.
It takes c15t's consent record as input and has no
@c15t/*dependency, so it works with any c15t version — the consumer wiresuseConsentManager()in. (If c15t already emits Consent Mode commands ontowindow.dataLayervia GTM, the gate's auto-read picks them up and no adapter is needed; this is for the offline / no-dataLayer case or driving the gate explicitly.) -
#24
57029a5Thanks @weepaho3! - Media gate is now addressed by the asset id, and content images are served with no read-path resolution.- Gate by id. The public gate is
GET /media/asset/{id}(the stable asset id, which is exactly what content stores), a 302 redirect to the object. An<img src="/media/asset/{id}">survives swapping the bytes behind an asset id (new slug/object key, same id) with no content change and no re-render — the gate re-resolves the id to the current object. The redirect is short-cached (max-age=300, no longerimmutable) so such a swap propagates within minutes, while the object bytes stay long-cached at the CDN (each version has its own object key). A CDN in front of the gate must include the query string in its cache key (the redirect target varies by?format/?w/?download). - Two latent gate bugs fixed along the way (the gate never worked over real HTTP before, because content used direct CDN URLs): the route was registered with OpenAPI
{param}braces — rou3 only matches:param, so every request 404'd at the router before the handler ran — and the handler set the redirect via a returned{ headers, body }object that better-call never applies to the HTTP response (it answered200with an empty body). Both are fixed; the gate is now covered by tests that drive a realRequestthroughcms.router.handler. - Reverted the read-path image→
{ id, slug }resolution shipped in 0.2.8 (resolveImageAssets/ResolvedImage). With the id-addressed gate the renderer builds the URL straight from the stored id, so no read-time resolution is needed; animageblock property is a plain asset-id string on both the write and read paths.
- Gate by id. The public gate is
-
#24
584d981Thanks @weepaho3! - Addmedia.moveAssets— move assets between folders (and to the root).moveAssets({ assetIds, folderId })sets the folder of one or more assets (folderId: nullmoves them to the root) — the missing write counterpart tomoveFolderfor drag-and-drop in a media library. Bulk-by-ids and scoped likeupdateAssetStatus: non-existent, out-of-scope, and archived ids are skipped and returned inskippedso a batch partially succeeds; a moved asset's variants follow it into the same folder so an original and its variants are never split apart (and a variant id passed on its own is skipped — variants are not moved directly). Returns{ moved, movedIds, skipped }. ThrowsFOLDER_NOT_FOUNDfor an unknown (or out-of-scope) target folder,ASSET_NOT_FOUNDif none of the ids reference a live asset. -
#24
584d981Thanks @weepaho3! - Addmedia.replaceAsset— swap the bytes behind an existing asset, keeping its id.replaceAsset({ assetId, file })replaces an asset's content while keeping itsid(andfolderId/status) stable, so every content reference picks up the new image with no content change and no re-render — content stores the id and the id-addressed gate re-resolves it (the short-cached redirect propagates the swap within minutes). The classic use case: a logo / brand image changes — replace it once, and it updates everywhere it's used.A new slug / object key is minted (not an overwrite) so the long CDN cache on the old object can't keep serving the stale image. The endpoint is server-side and atomic: the new object is uploaded first, then — only on success — the row is repointed in a single transaction that also archives the asset's old variants (they depict the old bytes and are unreachable from the new slug, so callers should regenerate variants afterward). The old object is left in the bucket for a future pruning pass. Throws
CANNOT_REPLACE_VARIANTif the target is itself a variant (replace the original instead),ASSET_NOT_FOUND,FILE_TOO_LARGE/INVALID_FILE_TYPE, orUPLOAD_FAILED(which leaves the asset unchanged). -
#24
7cdf688Thanks @weepaho3! - A/B live results ride the shared realtime connection (fixes a never-working path).The A/B live-dashboard delta stream previously lived inside the plugin (its own mis-constructed realtime instance + a bare
EventSource) and never actually delivered. It now uses the shared realtime layer end-to-end: thetrackEventingest publishes each delta overctx.realtimeto the publicab:live:<testId>channel — decoupled from the analytics storage adapter, so it works with the Postgres adapter too — anduseLiveResultsrides the sameRealtimeProviderconnection asuseNotifications.useLiveResultsmoves off the client proxy to its own subpath,@createcms/core/plugins/ab-test/live(which pulls in the optional@upstash/realtimepeer, keeping the main A/B client peer-free). It applies live increments and reconciles against the absolutegetResultsaggregate on (re)connect; withoutrealtimethe stream never connects and the SSRinitial(+ anygetResultsreconcile) stands. -
#24
8c43239Thanks @weepaho3! - Real-time notifications — automatic per-user push + a realtime-onlyuseNotificationshook.When
realtimeis configured, every notification is pushed to its recipient's private channel automatically — a built-in handler rides the existing dispatch, so all notification sources (comments, merges, approvals, …) deliver live with no extra wiring.On the client, wrap your app once in
RealtimeProvider(from@createcms/core/react/realtime) —<RealtimeProvider baseURL="/api/cms">— to open one shared connection, then calluseNotifications(client, { userId }). It seeds list + unread count from thelistNotificationspoll, prepends live pushes de-duped by id, and the provider replays anything missed across a reconnect.useNotificationsis realtime-only and type-requiresclient.notifications, so it only compiles when notifications are enabled. Without realtime there's no built-in polling hook — read the durable list yourself viaclient.notifications.listNotifications. -
#24
5bdaa2cThanks @weepaho3! - Add an optional, Upstash-backed realtime layer — and anotificationson/off switch.Configure
realtime: { url, token }(your Upstash Redis credentials;@upstash/realtime+@upstash/redisare optional peers) to mount a shared/realtimeSSE route. The route authenticates each connection via yourauthMiddleware(the session is read from the request cookie —EventSourcecan't send auth headers) and authorizes every channel against that identity: a user may subscribe only to their own privatenotif:<userId>channel (fails closed when unauthenticated), whileab:live:<testId>stays world-readable. The server is the broker — the browser only ever talks to your same-origin route; the Upstash credentials never leave the server. Realtime is Upstash-only (no pluggable transport).Separately,
notifications: falseoncreateCMSfully disables the notifications feature: the tables aren't generated, the routes never register, andclient.notificationspluscms.notifyare absent from the inferred types (a stray call is a compile error). Default: enabled. Use a literalfalse.notificationsandrealtimeare independent — A/B live results can userealtimewithnotifications: false.
0.2.8
Patch Changes
-
#22
8ca638bThanks @weepaho3! - Resolveimageblock properties to{ id, slug }on the rendered read path.imageproperties store the asset id (ast_…).getPublishedContent(andgetBlockTreeunlessraw) now resolves each one to{ id, slug }— exactly aslinkandreferenceproperties are resolved — so a renderer builds the gate URL/media/asset/{slug}straight from the slug, with no second lookup. This keeps the SEO-friendly slug in the URL and routes every image request through the status-checked gate, while the id stays the stored value (usage tracking and the archive guard are unaffected).Resolves to
nullwhen the asset is archived or out of scope — the resolver is scoped, so a forged cross-tenant id in content never leaks another tenant's slug, symmetric with link resolution. Arawread keeps the stored id for editor re-picking. Type: inresolvedmode animageproperty now infers asResolvedImage({ id, slug } | null).
0.2.7
Patch Changes
-
#20
a78f391Thanks @weepaho3! - Media: ready publicurlper asset, newlistFolders, removedgetAssetUrlAuthenticated.listAssets(and thecreateSignedUpload/uploadAssetsresponses) now include a direct objecturlper asset (${publicUrl}/${objectKey}), built server-side — so internal/admin tooling (a media library) needs no URL helper and never has to knowpublicUrlitself. This URL bypasses the gate (no status check, no transforms); it is for admin display, not for embedding in content. Content references an asset by id and is served through the gate,GET /media/asset/{slug}.- New
listFolders({ parentId? })read endpoint: returns the direct child folders ofparentId(or the root-level folders when omitted), sorted by name. This is the missing read counterpart tocreateFolder/moveFolder/deleteFolder, so a media-library UI can navigate the folder tree. - Removed
getAssetUrlAuthenticated(and the internalsignGetObjecthelper). Uploaded objects arepublic-read, so the presigned-GET path was redundant —statusis a visibility flag gating the public/media/asset/{slug}redirect, not a hard-privacy boundary. Serve assets through that gate; flip an asset topublicwithupdateAssetStatusto serve it there.
0.2.6
Patch Changes
-
#15
0fcc2b4Thanks @weepaho3! - Add alinkblock-property type — a language-aware link resolved to the current path at read time.A
linkis a discriminated union overkind:internal(an entry),external(a URL),email, orphone. The property config takes optionalallowedKindsandallowedCollections. On araw: falseread (getBlockTree/getPublishedContent) every kind is normalised to anhref: an internal link is resolved to the target entry's current, language-aware path (via the same reference resolver + path resolver redirects use — the active-language sibling, ancestor-aware, following slug changes), withfragment/queryappended; external/email/phone are static pass-throughs (url/mailto:/tel:). A gone / out-of-scope target resolves tohref: null. Withraw: truethe stored value is returned unchanged so the editor can re-pick the target.Unlike
reference, a link resolves only a path — nothing is embedded. Internal link targets are indexed incontentUsages(targetKind: 'link') for the usage UI, but deleting a link target is a warning, not a hard block (a dangling link is recoverable).Schema change, no backfill (beta): the
content_usage_targetenum gains'link'. Recreate the database.
0.2.5
Patch Changes
-
#13
341195cThanks @weepaho3! - Add an opt-ingetBlockTree({ includeReferencePreviews: true })flag that returns areferencessidecar alongside the tree.The sidecar is a
Record<storedReferenceValue, tree>of the published render tree of every reference embedded in the entry (its own nested references resolved and{{variables}}substituted, through the active tenant/language scope). This lets a page editor fetch the raw editable tree and all embedded reusable-block previews in a single call instead of onegetPublishedContentper reference (the N+1). Combine withraw: trueto keep the main tree editable while still getting rendered previews. References that are not published (or out of scope) are omitted. Opt-in because the resolution is more expensive; existinggetBlockTreecallers are unaffected. Reuses the same resolution machinery asgetPublishedContent(no duplication). -
#13
341195cThanks @weepaho3! -listBranchesnow returnshasPublications(a boolean) per branch, so callers can tell which branches are currently published without a separate query — analogous tohasPublicationsonlistRoots. The value was already computed internally (it drivesisDeletable); it is now exposed on eachBranchListItem. -
#13
341195cThanks @weepaho3! -branchProtectioncan now be overridden per collection. A collection definition accepts its ownbranchProtection(aPartial<BranchProtectionConfig>): each field set there wins over the global config for that collection only, and unset fields inherit the global value (then the default).This makes governance flexible per content type — e.g. a
reusableBlockcollection can setbranchProtection: { protectPublishedBranches: false }to stay directly editable, while pages keep the global protection. The same applies torequireApprovalToMerge,requireApprovalBeforePublish, andrequiredReviewers. Backward compatible: collections without an override behave exactly as before. (defaultBranchNameandmergeStrategyremain global.) -
#13
341195cThanks @weepaho3! - Templates now participate in i18n / multi-tenant scoping and are applied server-side oncreateBlock.- Scoped templates. With the
i18nplugin a template is per language; withmulti-tenantit is per tenant. All template CRUD is scope-filtered, and the(collection, blockType, propertyKey)uniqueness is enforced within the active scope — so the same key can have a different default per language and per tenant. (The core DB unique was demoted to a lookup index; per-scope uniqueness is the app-level authority, mirroring redirects.) - Server-side application.
createBlocknow seeds any optional property the caller leaves unset from its template — no client wiring needed. Required properties must still be provided (input is validated before defaults apply);duplicateBlockandupdateBlocksdo not re-apply templates (they copy / apply a client-authoritative tree). Caller-provided values always win. The raw template string is stored, so embedded{{variables}}stay live (resolved at read time), not frozen at creation. - Validated targets.
createTemplatenow rejects a template whosepropertyKeydoes not exist on the block type, or is not a text property (string/richText), with the newTEMPLATE_PROPERTY_INVALIDerror — a string template can no longer be seeded into a number/select/image/reference field.
Schema change, no backfill (beta): the
templatesunique index is demoted to non-unique, and thei18n/multi-tenantplugins add alanguage/tenant_slugcolumn totemplates. Recreate the database. - Scoped templates. With the
-
#13
341195cThanks @weepaho3! - Variables now participate in multi-tenant and i18n scoping.- multi-tenant — variables are partitioned per tenant. The same key is independent across tenants, and content resolves the active tenant's value.
- i18n — variables are per-language with fallback, exactly like a translated entry: a value is resolved in the active language, falling back through the configured chain to the default language when it has no value there. Define shared values once (in the default language) and override only the few that need translating. Content rendering (
getBlockTree/getPublishedContent) and template-embedded variables both resolve through this. Implemented via a plugin-providedVariableResolveron the resolved scope (mirrors the reference resolver). - Management (create/list/update/delete) targets the exact active cell (tenant + language) — no fallback when editing. Uniqueness is per
(tenant, language, key), enforced at the app level (the corekeyunique index is demoted to a lookup, since the compound key can't be expressed by either plugin alone). The delete guard and revalidation are tenant-scoped (language-spanning, since a base value can be rendered via fallback in any language).
Schema change, no backfill (beta): the
variablesunique index is demoted to non-unique, and thei18n/multi-tenantplugins add alanguage/tenant_slugcolumn tovariables. Recreate the database.
0.2.4
Patch Changes
-
#11
bd91957Thanks @weepaho3! - Fix a client/server path mismatch that made thevariables,templates, andsearchnamespaces unreachable from the client.The client proxy builds every request URL as
/<namespace>/<method>(e.g.client.variables.listVariables()→/variables/listVariables), but these endpoints were mounted at hand-written paths that didn't follow that convention (/variables,/variables/get,/templates/create,/search, …). Every such call 404'd. Handler-level tests didn't catch it becausecms.api.<ns>.<method>()invokes the handler directly and never exercises HTTP routing.- All
variablesendpoints now mount at/variables/<method>(e.g./variables/listVariables,/variables/getVariable). - All
templatesendpoints now mount at/templates/<method>(e.g./templates/listTemplates,/templates/getTemplate). searchnow mounts at/search/search(matchingclient.search.search()).
A new test asserts every RPC endpoint is mounted at exactly
/<namespace>/<method>, so this class of drift can't regress. (Direct-URL routes with a path parameter, like the public/media/asset/{assetSlug}redirect, are intentionally exempt.) - All
-
#11
ff516e9Thanks @weepaho3! - Add a configurable merge strategy forexecuteMerge.mergeStrategy(CMS config) —'fast-forward'(default) or'merge-commit'. Controls howexecuteMergeintegrates when a fast-forward is possible (the target has not diverged).'merge-commit'always records an explicit merge commit (git's--no-ff) so every integration is visible in history. A diverged target always produces a merge commit regardless.executeMerge({ noFastForward })— per-call override of the configured strategy.trueforces a merge commit,falseforces a fast-forward.
A merge with nothing to integrate (the source and target heads are already equal) stays a no-op fast-forward even under
noFastForward/'merge-commit', so no empty merge commit is fabricated. Default behavior is unchanged ('fast-forward'). -
Replace
branchProtection.protectMainwithbranchProtection.protectPublishedBranches.Breaking:
protectMain(shipped in 0.2.3) is removed. It protected the default branch by name; the replacement instead locks a branch against direct content mutations for exactly as long as it is published — published content is the live, production-facing tree, so it is made immutable in place. Changes go via another branch + merge, then a re-publish; unpublishing makes the branch directly editable again. This applies to any published branch (a root can have several at once, e.g. A/B variants), not just the default one, and a never-published branch is freely editable.- Enforced by a shared
assertBranchWritableguard on every content-mutation route, includingrevertBranch(which rewrites a published branch's head in place). createRootis never gated (it seeds a fresh, unpublished branch).- Still throws
PROTECTED_BRANCH(403).
Migration: rename
protectMain: truetoprotectPublishedBranches: true. Note the new semantics — protection now follows the publication state, not the branch name. - Enforced by a shared
0.2.3
Patch Changes
-
#9
be8c643Thanks @weepaho3! - Add branch-protection and approval governance to the CMS config, plus a configurable default branch name.branchProtection.protectMain— reject direct content mutations on the default branch (create/update/delete/move/duplicate of blocks, andupdateRoot); edits must go via a branch + merge.createRootis exempt. Throws the newPROTECTED_BRANCH(403) error.branchProtection.requireApprovalBeforePublish— makepublishBranchalways require approvals, not just when one was explicitly requested. Defaultfalse(existing conditional behavior).branchProtection.requiredReviewers— minimum distinct approved reviewers for the merge / publish gates (default1).defaultBranchName— the branch every root is seeded with, replacing the hard-coded'main'throughout (rename/delete guards, read/search resolution, and i18n translation copy-seeding).
Breaking:
branchProtection.requireApprovalToMergedefaults tofalse. PreviouslyexecuteMergeALWAYS required approvals; merges now succeed without approval unless you setrequireApprovalToMerge: true. Set it explicitly to keep the prior gate. -
#9
1d9bf1fThanks @weepaho3! - Add aforceCommitMessageoption to the CMS config. Whentrue, every content mutation (createRoot / createBlock / updateBlock / deleteBlock / moveBlock / duplicateBlock / updateBlocks / updateRoot) requires a non-emptymessage— an empty or whitespace-only message is rejected with the newCOMMIT_MESSAGE_REQUIREDerror instead of falling back to an auto-generated default. Off by default, so existing behavior is unchanged. -
#9
a45021aThanks @weepaho3! -getRootHistorynow attributes each commit to the branch it was created on, deterministically — fixing wrong branch labels for shared ancestors.Previously the branch label was inferred with a recursive "nearest branch tip wins (
MIN(depth))" heuristic, which mis-attributed commits that lie on more than one branch's first-parent chain (a feature branch with fewer post-fork commits could "claim" main's shared history). The originating branch is now stored on each commit and read directly.commitsgainsbranchId(links to the live branch — follows renames; no FK) andoriginBranchName(a deletion-proof name snapshot). Both are set at commit-write time.getRootHistoryresolvesbranch = COALESCE(live branch name, originBranchName)via a simple join — the recursive CTE is gone (O(n), deterministic).
Schema change, no backfill (beta): the new
origin_branch_namecolumn isNOT NULL; recreate the database. There is no migration of existing commit rows.
0.2.2
Patch Changes
-
#7
1cc595fThanks @weepaho3! - FixcreateTrackedBlocks(...).useTrackedBlock('myBlock')rejecting a block that declaredeventswhen the collection is used in its declared form (e.g.typeof myCollection).eventsis optional onBlockDefinition, so theFunctionalBlockskey-filter sawTEvents | undefinedand filtered out every block ((X | undefined) extends Record<…>is false). The key-filter nowNonNullables theeventsaccess, matching the value side — functional blocks are detected again andfirestays narrowed. -
#7
9009209Thanks @weepaho3! - Add an optionalgroupstring to block property definitions — an editor hint for the field-group (fieldset/section) a field is shown under in the property panel (e.g.group: 'SEO'). Presentational only; free-form, use a sharedas constfor consistent, autocompleted group names. Mirrors the block-levelgroup.
0.2.1
Patch Changes
-
#5
d9f6988Thanks @weepaho3! - Add an optionalgroupstring to block definitions — an editor hint for the block-picker category a block appears under (e.g.group: 'Forms'). Presentational only; the package does not act on it. Free-form by design; reference a sharedas constobject for consistent, autocompleted group names across blocks. -
#5
ff46dc7Thanks @weepaho3! - FixBlockProps<typeof collection, 'blockType'>failing to compile. The helper required a non-optionalblocksfield, butblocksis optional onCollectionDefinition, so passing a collection definition errored with "Type 'undefined' is not assignable to type 'Record<string, AnyBlockDefinition>'". The constraint now accepts the optional shape and resolves it viaNonNullable, soBlockProps<typeof myCollection, 'myBlock'>works and the block name still autocompletes. -
#5
060e6aeThanks @weepaho3! -createBlocksMapnow bundles the collection definition on the returnedBlocksMap(a typed_collection), so a single object can drive both rendering and an editor — components, events, and the collection's schema/placement/grouping in one handoff, with no separatecollectionprop.BlocksMapgained an optional type parameter that defaults to the erased collection type, so existingBlocksMapannotations andBlocksRendererare unaffected.
0.2.0
Minor Changes
-
#3
f263c1fThanks @weepaho3! - Block placement constraints. Collections now take astructuremap that controls which blocks may be nested where, replacing the removedallowedChildBlocksfield.structureis keyed by parent block name (or the literal'root') with three mutually exclusive modes per entry: open ({}/{ accepts: '*' }), whitelist ({ accepts: ['x'] }, fail-closed), or blacklist ({ excludes: ['x'] }, fail-open). A concreteacceptslist together withexcludesis a compile error. Block names autocomplete against the collection's blocks and typos are caught at compile time.allowChildrenis now enforced on the server: a non-container block (withoutallowChildren: true) rejects all children. The root always accepts children.createBlock,moveBlock, andduplicateBlockenforce these rules and throw the newBLOCK_NOT_ALLOWED_IN_PARENTerror; the visual editor reads the same rules for drop-zone gating, so the two can't diverge.
Breaking:
allowedChildBlocksis removed — express the same intent withstructure(e.g.structure: { section: { accepts: ['featureItem'] } }). Blocks that hold children must now declareallowChildren: true.
0.1.1
Patch Changes
028d2f2Thanks @weepaho3! - Fixcreatecms generatefailing on configs that use the idiomaticdefineCollection/defineCollections/defineAuthMiddlewareAPI. The config-loading shim now stubs these helpers (they are pure identity functions at runtime), so a config written exactly as the docs show loads correctly during schema generation.