Changelog
Release notes for @createcms/core and @createcms/react, generated from Changesets.
@createcms/core
0.7.0
Minor Changes
- #91
2e132f7Thanks @weepaho3! - Block components receive aneditprop with the editor anchors as plain data (edit.block,edit.field.<key>,edit.active) —NO_EDIToutside an editor, realdata-editor-block/data-editor-fieldanchors when the renderer is givenedit="preview".BlockComponentProps.propertiesis now typed as an object (neverundefined). Breaking for code that renders a block component by hand: passedit={NO_EDIT}.
Patch Changes
-
#93
c449046Thanks @weepaho3! -POST /{collection}/resolveTreeresolves a posted, unsaved tree the waygetBlockTreeresolves a stored one — variables substituted, links resolved, references as areferencessidecar (includeReferencePreviews) and/or inlined into the tree (inlineReferences) — without writing anything, so an editor can preview its working copy. -
#94
db18bedThanks @weepaho3! - Export the pure{{key}}helpersresolveTemplateString,extractVariableKeysandVAR_PATTERNfrom@createcms/coreand@createcms/core/react, and the spec typesListBlockPropertySpec,ListElementSpec,ListElementType,SelectOption,StringConstraints,NumberConstraintsfrom the package root.
0.6.0
Minor Changes
-
#76
1e6edbfThanks @weepaho3! - Three-way merges now auto-resolve blocks where source and target changed disjoint sets of top-level properties (git-style: block ≈ file, property ≈ line). Same-key edits — including richText — remain conflicts, as do delete-vs-edit, type changes, and divergent children.checkConflictsandcreateMergeRequestresponses gainautoMergeableBlockIds.Agreement counts per property: when both sides make the identical change to the same property, that agreement merges together with each side's remaining disjoint edits.
0.5.0
Minor Changes
-
#70
e557806Thanks @weepaho3! - fix(media): add a browser-callable asset replace flow and stop leaking S3 objects on replaceMigration note (browser callers of
replaceAssetonly):replaceAssettakes an in-processbuffer: Blob | ArrayBufferand always required a server-side caller — aFileselected in the browser cannot survive the client's JSON request body (it serializes to{}), so a browser call type-checked but failed at runtime.replaceAssetitself is unchanged and remains available for server-side callers; browser callers must migrate to the new signed flow:createSignedReplace(mint a slot + signed PUT URL) thencommitReplace(repoint the row) once the client's PUT to S3 succeeds. The React client wraps this asclient.media.useReplaceAsset()(mirrorsuseUploadAssets); the vanilla client exposes the same state as a raw nanostores atom atclient.media.replaceState.Two more media fixes bundled with the above:
- Replacing an asset no longer leaks its superseded S3 object.
replaceAsset(and nowcommitReplace) minted a new object and repointed the row, but nothing ever named the old object again, so the pruning pass's reclaim query — which only deletes objects read off an archived asset row — could never find it. Both endpoints now also insert a tombstone: a fresh, immediately archived asset row that reuses the superseded slug/object key (with a new id, so it isn't held alive by the original's content references), which the existing pruning pass picks up and deletes once the trash window elapses. The same treatment covers the TOCTOU rollback path, which used to abandon the just-uploaded object outright. - Upload/replace failures no longer return the raw S3 provider error to
clients.
uploadAssetsandreplaceAssetused to put the S3 error message ondata.cause, which reaches the wire. The full detail is still logged server-side (console.error); only the client-facing error is trimmed to a status code.
- Replacing an asset no longer leaks its superseded S3 object.
-
#70
62a19ffThanks @weepaho3! - feat(types): hide server-only endpoints from the client's type surfacemedia.uploadAssetsandmedia.replaceAssetno longer appear incmsClient's /client's inferred types. Both take an in-processbuffer: Blob | ArrayBufferbody that can't survive the client's JSON request — aFileselected in the browser serializes to{}over the wire — so a browser call used to type-check and fail only at runtime. They're now markedscope: 'server'in their endpoint metadata, and the client's type builder omits any endpoint carrying that mark:client.media.uploadAssetsandclient.media.replaceAssetare now compile errors, not just runtime ones. Their browser-callable counterparts are unaffected and unchanged —createSignedUploadfor uploads,createSignedReplace+commitReplace(or theuseReplaceAssetclient hook) for replaces.This is a type-level guard only — no runtime behavior changed. The client's request proxy still dispatches any method name it's given, so a caller that bypasses the type system (
as any, plainfetch, etc.) reaches the same server route and fails the same way it always did. A runtime guard on the client proxy is a separate, not-yet-made decision.cms.api.media.uploadAssetsandcms.api.media.replaceAsset— the server-side API used by in-process callers — are completely unchanged: same signatures, same behavior, still fully callable. Only the client's inferred type surface shrank.
0.4.0
Minor Changes
-
17fdd6bThanks @weepaho3! - fix(routes): close two permission-resource bypassesduplicateBlockminted a new top-level root whentargetParentBlockIdwas omitted, while declaring onlyblock:create— so a host grantingblock:createbut denyingroot:createcould be bypassed through duplication. This was the same defect already fixed onduplicateRoot, still reachable through the older door.Breaking:
targetParentBlockIdis now required onduplicateBlock, which is child-duplication only. UseduplicateRootto duplicate a subtree into a new top-level entry — it takes the same arguments and has always been guarded asroot:create.duplicateBlocknow returns a non-union type (modeis always'child'), so callers no longer need to narrow it.publishReleasemade content live underrelease:updatewhile the equivalentpublishBranchrequirespublication:create.Breaking:
publishReleasenow declarespublication:create. Hosts grantingrelease:updatefor release curation must also grantpublication:createto allow publishing. -
64488a7Thanks @weepaho3! - fix(comments): enforce the active scope on every thread-addressed endpointOnly
deleteCommentThreadenforced the caller's scope; every other thread-addressed comment endpoint resolved a thread by id and collection alone. Under the multi-tenant plugin that allowed cross-tenant reads and writes of comment threads. All thread endpoints now go through a shared scope-enforcing loader,createCommentThreadvalidates the suppliedrootId, andresolveCommentThread/reopenCommentThreadno longer operate on soft-deleted threads.Breaking:
listMentionsfiltered on a caller-suppliedmentionedUserId, letting any caller read another user's mention inbox. It now derives the filter from the session user, and thementionedUserIdquery parameter has been removed. -
5a4ee09Thanks @weepaho3! - fix(branches): deleting a branch no longer fails once it has merge historydeleteBranchthrew a raw foreign-key error on any branch that had been merged or had an approval request, so the standard branch → merge request → merge → delete workflow failed at the last step with an opaque 500 and the branch stayed permanently undeletable.merge_requests.source_branch_id,merge_requests.target_branch_idandapprovals.branch_idare now nullable withON DELETE SET NULL, so merge and approval history survives the branch row. Open merge requests still block deletion, and publications still block deletion.Breaking:
ApprovalOutput.branchIdis nowstring | null. It is null for approvals whose branch has since been deleted. Consumers readingbranchIdoff an approval must handle null.Migration: this changes the database schema. Regenerate and apply your Drizzle migrations (
drizzle-kit generate) after upgrading. -
b8085b7Thanks @weepaho3! - feat(merges): adddismissStaleApprovalsbranch-protection flagBy default an approval keeps counting after new commits are pushed to a merge request's source branch, matching GitHub's default pull-request behaviour. This is unchanged.
Teams that need every merged commit to have been reviewed can now set
branchProtection.dismissStaleApprovals: true(globally or per collection), the equivalent of GitHub's "Dismiss stale pull request approvals when new commits are pushed". With it on, the merge gate only counts approvals recorded against the source branch's current head, and a superseded approval fails with the newAPPROVALS_STALEerror. -
4b7a75fThanks @weepaho3! - feat(pkg): publish as ESM-onlyBreaking:
@createcms/coreno longer ships a CommonJS build. Themainandmodulefields are gone and everyexportssubpath now resolves to ESM only.CommonJS projects do not need to migrate to
import: Node resolves ESM fromrequire()natively since 22.12, sorequire('@createcms/core')keeps working. That is why the minimum Node version is now 22.12 (engines.nodewas>=20).Dropping the dual build also removes the dual-package hazard: the client layer holds module-level state in nanostores atoms, and a consumer whose graph loaded both the ESM and the CJS copy would previously end up with two independent store instances.
-
0d22f89Thanks @weepaho3! - fix(deps): require Next.js >= 16.2.11 and bump runtime dependenciesBreaking (Next.js users only): the
nextpeer range moves from>=16to>=16.2.11. Every 16.x below that carries nine security advisories — four of them high, including SSRF in Server Actions, a middleware/proxy bypass in App Router applications, and SSRF via rewrites. createCMS ships anext/middlewareintegration, so pairing it with an affected Next is a real exposure rather than a theoretical one.nextremains an optional peer: projects not using Next.js are unaffected.Runtime dependencies moved to their current releases within the existing ranges:
better-call2.0.5,nanostores1.4.2,fast-xml-parser5.10.1,nanoid5.1.16 andora9.4.1. -
06d9e28Thanks @weepaho3! - fix(blocks): validatepositionandtargetPropertieson the block write pathscreateBlock'spositionwas an unconstrained number handed toArray.prototype.splice, so a negative value silently inserted the block near the end of its parent's children instead of failing, and a fractional value was truncated. It is nowz.number().int().min(0)— matchingmoveBlock'snewIndex— and the insert index is clamped to the child count.targetPropertieson the duplicate paths was written into content with only a cast, bypassing the per-block property schema that every other write path enforces.runDuplicatenow parses it withbuildPropertiesSchema, so declared constraints (maxLength, numeric ranges, required keys) apply to duplication too.
0.3.0
Minor Changes
-
f0b9dbdThanks @weepaho3! - fix(blocks): guard duplicateRoot as root:create, not block:createduplicateRootmints a NEW top-level root (forced root mode), the same privileged actcreateRootguards aspermissionResource: 'root', but it was labeledpermissionResource: 'block'. A consumer grantingblock:createwhile denyingroot:createcould create roots through duplication. The metadata now readsroot. BREAKING for any authMiddleware policy that mappedduplicateRootunderblock— remap it toroot. -
#57
e83ada8Thanks @weepaho3! - feat(diff): visual diff — precise change classification, property-level detail, and a renderable annotated treeBREAKING (pre-1.0):
getDiffsemantics and response shape changed.- Identity-based
moved(noise fix): blocks are only flaggedmovedwhen they were reparented or are a true reorder outlier among surviving siblings (LIS-based). Siblings whose index merely shifted because another block was inserted/removed/moved around them are no longer flagged. Inserting one block among N siblings now yields exactly one diff entry (was N+2). - Truthful
childrenReordered: only set on a parent when the relative order of its surviving common children changed — never for pure child additions/removals. - Property-level detail: modified entries carry
propertyChanges({ path, kind: 'added' | 'removed' | 'changed', from, to }with deep paths and array LCS alignment),typeChange, and word-leveltextDiffsegments (same/ins/del) forrichTextproperties (including list-of-richText items). - Slug changes are first-class: a draft-slug change on the root yields
slugChange: { from, to }instead of an opaquemodified; the reserved__slugkey no longer leaks into any returned version properties. - Annotated diff tree: new
viewquery param ('list' | 'tree' | 'both', default'both'). The response is now{ diff, tree, summary, sourceCommitId, targetCommitId, commonAncestorCommitId }—treeis the draft tree with per-nodediffannotations and deleted blocks re-inserted as ghost nodes at their old position (with their last content), directly renderable by the existing component maps.summarycarries per-changeType counts. - React:
BlocksRendererandcreateContentRendereraccept an opt-indiffprop that wraps changed blocks in<div data-diff="added | deleted | modified | moved" data-diff-types data-diff-props>(or a customwrapcallback). New helpers:getBlockDiff(node),diffSegmentsToHtml(segments)for inline rich-text ins/del highlighting;BlockComponentMapis now exported.
New exported types:
ChangeType,PropertyChange,TextDiffSegment,MovedInfo,BlockChange,DiffSummary,BlockDiffAnnotation,AnnotatedBlockTreeNode,DiffView. - Identity-based
Patch Changes
-
a3218b6Thanks @weepaho3! - perf(blocks): batch updateBlocks reference-existence checks into a single asset query and one roots query per collection, shortening the write-transaction critical section for large page saves -
#57
d6ed93bThanks @weepaho3! - feat(diff): generalize getDiff to refs (branch | commit | published), per-change attribution, and history change countsgetDiffnow compares arbitrary refs: pass exactly one ofsourceBranchId|sourceCommitIdand one oftargetBranchId|targetCommitId|targetPublishedper side. Branch refs resolve to their head; commit refs are used as-is;targetPublishedresolves to the live head of the entry's publication branch (exactly whatgetPublishedContentserves) and throwsPUBLICATION_NOT_FOUNDfor unpublished entries. When one ref is an ancestor of the other the three-way diff degenerates to an exact two-way comparison — a commit vs its parent yields exactly that commit's changes, a draft vstargetPublishedyields exactly the edits that are not live yet. Commit refs enforce the same collection + root scoping as branch refs.withAttributionongetDiff: every diff entry and tree annotation gainsattribution: { commitId, changedAt, changedBy, changedByUser? }. Own-version changes attribute the commit that created the source version; pure position moves attribute the commit that actually repositioned the block (derived by walking the new parent's version history; omitted when the move arrived via a merge).changedByUserfollows thewithUser/exposeColumnsrules.withChangesongetRootHistory: each commit entry gainschanges: { added, modified, deleted }— computed as a version-id-level snapshot set-diff in a single SQL query per page (no properties loaded), intended for history badges. Merge and revert commits count absence-based deletions correctly (their snapshots carry no tombstones); merge commits diff against their first parent.- Boolean query flags (
targetPublished,withAttribution,withChanges) decode strictly over the wire — the string'false'means false.
New exported type:
ChangeAttribution. -
93561b4Thanks @weepaho3! - chore(deps): raise thefast-xml-parserdependency floor from ^5.4.2 to ^5.7.0 so downstream consumers cannot resolve a version affected by the <5.7.0 prototype-pollution / XML-injection advisory -
04f63f2Thanks @weepaho3! - docs(readme): fix links that 404 on npm, correct the slug example (prefix, not root), and the Node engine version -
46c9a7fThanks @weepaho3! - feat(cli): addcreatecms generate --check— verify the committed schema is up to date without writing, exiting non-zero on drift (for CI). Reuses the exact generator, so it never diverges from whatgeneratewould write. -
53c71baThanks @weepaho3! - fix(media): scope variant refs to the active tenant and measure real upload bytesvalidateVariantRefslooked upvariantOfids with no scope filter, letting a multi-tenant caller reference or probe another tenant's asset ids. It now ANDs the active scope's assetwhere, mirroring every other media query.On the two server buffer-upload paths (
uploadAssets,replaceAsset) themaxFileSizecheck, the persistedsizecolumn, and the S3contentLengthall used the client-declaredsize— a body field the server never verified. They now use the true byte length measured from the held buffer, so an under-declaredsizecan no longer bypass the limit or corrupt stored metadata. The client-sidecreateSignedUploadpath is unchanged (the server never holds those bytes). -
#57
818d102Thanks @weepaho3! - fix(merge): stop dropping one-side-added blocks from merged snapshots (data loss)buildMergedSnapshot's delete-vs-edit exclusion treated "absent from the other side" as "deleted by the other side" — but a block with no live version at the common ancestor is simply NEW on whichever side carries it. On any merge where the target had diverged (the real three-way path), this silently dropped blocks that were added after the branch point on either side: content vanished from the merged tree with no conflict and no error. The exclusion is now gated on a live base version, so one-side additions always survive; genuine delete-vs-edit cases (block existed at the ancestor) still resolve exactly as before. Fast-forward and forced-merge-on-undiverged-target paths were never affected. -
025fa3aThanks @weepaho3! - chore(pruning): remove the unused, unbudgeted collectRootExecutionPlans exportcollectRootExecutionPlansloaded every root with no limit and then serially ran the full per-root planning bundle (planRootPruning+collectPluginPruningPlans) with no bound — an unbounded N+1 whose memory footprint grows with root count. It had zero callers anywhere in the repo, was never re-exported from the package entrypoint, and had no./adminsubpath to reach it externally. The production entry point,runPruningPass, already does the budgeted equivalent directly (bounded bymaxRoots/maxDurationMs), so this was dead code — not a reachable dry-run/reporting API. Removed. -
e393ea1Thanks @weepaho3! - fix(routes): decode remaining GET boolean query flags wire-safelyz.coerce.boolean()turns the wire string'false'intotrue(Boolean('false') === true), so passingfalseover HTTP inverted the flag. Migrated the last GET flags —listBranches(isDeletable / hasPublications / hasOpenMergeRequests),listRoots(hasPublications),notifications.list(unreadOnly),getBlockTree(raw / includeReferencePreviews),getPublishedContent(raw), andlistAssets(unfiled) / the media asset gate (download) — towireBooleanSchema+wireBooleanIsTrue. Tri-state filters keep true/false/absent distinct. In-process callers passing real booleans are unaffected.
0.2.12
Patch Changes
-
#33
db59336Thanks @weepaho3! - API-design consistency + hardening pass (api-02 … api-22). Pre-1.0, breaking changes are applied cleanly (no compat shims).Correctness / hardening
- api-02: the client now dispatches HTTP methods from a server-generated
$pathMethodsmap instead of guessing from body presence, so optional-body POST endpoints (admin.reindexSearch,admin.runPruning,notifications.markNotificationsRead/Unread) no longer wrongly send GET. - api-03:
createCMSnow throws when a collection name collides with a reserved namespace (admin/media/variables/templates/search/notifications/realtime) or a plugin id, or isn't a URL-safe slug — previously such a collection was silently clobbered and its routes 404'd. - api-09: plugin ids must be valid JS identifiers and every plugin endpoint
path must be
/<id>/<method>(throws otherwise). The bundled multi-tenant plugin's id changedmulti-tenant→multiTenantto comply. - api-10: endpoint-conflict detection now spans the full core+collection+plugin
surface and throws on a duplicate path (was plugin-only +
console.warn). - api-19: server-side
cms.api.*callers accept{ headers?, context? }; acontext.userIdis honored as the actor when no auth middleware resolves one (HTTP clients can't setcontext, so this stays a trusted server-side channel). - api-04 (security): approval endpoints no longer take
reviewedBy/requestedByfrom the request body (spoofable). The actor is derived from the auth context; a reviewer must match one ofrequestedReviewers. Corrected the branches/merges JSDoc that described the wrongcreatedByprecedence. - api-08 / api-16 / api-20: vanilla client exposes
media.uploadState(a real atom) instead of a mistypeduseUploadAssets;$ERROR_CODES/err.cmsCodenow cover core and plugin codes; removed the dead$InferServerPlugin.
Renames (breaking)
deleteRoot→archiveRoot(it soft-archives).search.search→search.query(paramq→search).notifications.listNotifications/templates.listTemplates/variables.listVariables→.list.media.archiveAsset→archiveAssets;media.updateAssetStatus→updateAssetsStatus.- List direction param unified to
sortDirection(wassortOrderonlistAssets/listPublications). moveRootbody paramsortOrder→position(the result field stayssortOrder).- Template endpoints'
idparam →templateId;listFoldersparentId→parentFolderId. moveFolderaccepts an explicitnullparent (detach), matchingmoveRoot/moveAssets.
Smaller items
- api-11:
resolveTemplateis now GET. api-17/18: documentedlistFolders(intentionally per-level) andlistRoots.filterValueILIKE-pattern semantics. api-21: addedduplicateRoot(a thin, statically-typed wrapper overduplicateBlock's root mode). api-22: documented the two identifier-lookup conventions.
- api-02: the client now dispatches HTTP methods from a server-generated
-
#45
bdd6e2aThanks @weepaho3! - Code-cleanup pass (cc-01 … cc-16) — mostly internal (behavior-preserving dedup, dead-code removal, comment cleanup), with three consumer-visible fixes:- Reopening a comment thread now emits a
threadReopenednotification (was incorrectlythreadResolved); added to the notification-type enum + router meta map (cc-11). deleteCommentMessagenow reportsoperation: 'delete'to yourauthMiddleware/permission matrix (was'update', inconsistent with every other delete) (cc-11).- List endpoints parse raw timestamps as UTC (
listRoots, root history,listMergeRequests) viaparseTimestamp, fixing an off-by-timezoneDateon non-UTC hosts (cc-06).
Internal: extracted
lockWritableBranch(7 duplicated branch-lock preambles),loadVersionMapAtCommit+collectDescendantIds,patchSingleVersion(updateBlock/updateRoot shared core),withNotifications(11 collect-then-flush sites),isUniqueViolation+chainFor,loadBoundaryMessages+mapRawThreadRow, andtoInsertRow; removed dead code (unused import/var, two unusedTableDefinitiontype params, theblockToOutputwrapper), tightened a few micro-nits, and stripped undocumented internal design codenames from comments. - Reopening a comment thread now emits a
-
#54
5f22103Thanks @weepaho3! - CLI hardening (wc-01, wc-04, wc-05).createcms generateno longer silently drops plugin schemas (wc-01). The subpath alias map is now derived from the package's ownexports(so any exported plugin subpath, including the documented@createcms/core/plugins/i18n, resolves to its real module instead of an inert stub), the loader logs which specifiers were stubbed, and it now HARD-FAILS if a configured plugin resolved to the stub rather than emitting a schema with that plugin's tables missing. The config-loader shim also exposesdefinePlugin/definePluginSchema, so a config that authors a local plugin loads during generate instead of crashing.--force/--yesfor non-interactive generate (wc-04).createcms generateover an existing schema in a non-TTY (CI) previously printed "cancelled" and exited 0, so acms:generate && drizzle-kit generatestep silently ran against a stale schema. It now exits non-zero in that case and accepts--force(alias--yes) to regenerate unattended.- Per-invocation temp dir for config loading (wc-05, security). The loader
wrote executable stub modules to a fixed shared
os.tmpdir()/createcms-stubs/path with a silent-swallow on write failure, a local code-execution vector on multi-user hosts (an attacker could pre-plant a stub that runs in the victim's process during generate). It now uses a uniquemkdtempdirectory per run, cleaned up afterward, and fails hard instead of swallowing write errors.
-
#50
30f9bddThanks @weepaho3! - Product-capability pass (cms-02, 03, 04, 06, 08, 10, 13, 14, 18). New content features plus a search-scope security fix. Some changes are behavior-affecting; schema-affecting ones need a regenerate + migration.- Search scope (security, cms-08).
search.querynow applies the SAME read boundary as normal endpoints: a result is returned only if its underlying entity is visible under the active scope (a correlatedEXISTSper entity type), and notifications are filtered to the requesting user. This closes a cross-tenant content leak and a cross-user notification-title leak. When no scoping plugin is active, behavior is unchanged. - Scheduled publishing / expiry (cms-02, schema). New
scheduled_publicationstable + endpoints to schedule a publish/unpublish, andadmin.runScheduled(call it from your cron, likeadmin.runPruning) which processes due rows via the real publish machinery. Claims each row atomically so overlapping cron runs never double-publish. - Releases / atomic multi-page publish (cms-13, schema). New
releases/release_itemstables +publishRelease, which publishes every item in ONE transaction (all-or-nothing), reusing the existing per-root publish path. - List / multi-reference property type (cms-03). New
listproperty kind ({ type: 'list', of: <scalar | reference> }) validated as a JSON array with optional min/max length. List references are indexed for the reusable-block delete guard and resolved at read exactly like a single reference. - Stricter property validation (cms-04, behavior).
dateis now validated as an ISO datetime, string/number properties accept declarativeminLength/maxLength/pattern/min/maxconstraints, andimage/referenceids (including inside lists) are checked to EXIST at write time. Writes that previously stored an invalid date or a dangling id now throw a validation error instead of failing later at render. - Merge approval gate (cms-06, behavior).
executeMergenow blocks a merge by default when an OPEN approval request exists on it (previously only enforced behind the governance flags), mirroringpublishBranch. A merge with no approval requests is unaffected. Also fixes the content-workflow guide, which wrongly claimed every merge requires an approval. - Optimistic concurrency (cms-18). Content mutations accept an optional
expectedHeadCommitId; if the branch head has moved, the write is rejected with aHEAD_MISMATCH(409) conflict instead of silently interleaving. Omitted = unchanged behavior. - Numeric list sort (cms-10).
listRootssorts numeric properties as numbers (guarded cast, non-numeric values sort last) instead of as text where "10" < "9". - Typed plugin hook actions (cms-14).
CMSHookActionis now the finite union of real endpoint keys (plus an open arm), so plugin hook actions autocomplete.
Regenerate your Drizzle schema (
createcms generate) and add a migration to pick up the newscheduled_publications,releases, andrelease_itemstables. - Search scope (security, cms-08).
-
#51
a727384Thanks @weepaho3! - Versioned slug (cms-05). The rootslugis now part of the versioned content (isolated per branch) and is materialized to the live URL only on publish, so a draft-branch slug edit no longer changes the live URL immediately, andrevertBranchrestores the slug along with the content.Behavior changes:
- A draft slug edit is isolated until publish.
updateRootnow commits the slug to the branch (stored on the root block version) instead of writing the globalroots.slug. The live URL only changes when the default/identity branch is published.createRootleaves the entry's live slug unset until its first publish. - Uniqueness is enforced at publish, not at draft-write. Draft branches may
hold colliding slugs; publishing a colliding slug throws the new typed
PUBLISH_SLUG_CONFLICT(an atomicpublishReleaserolls the whole release back on conflict). The cheap empty/format check stays at write time. - Slug-change redirects are created at publish, not at the draft edit, so a never-published slug edit creates no redirect.
revertBranch(and merge/history) now carry the slug, since it is versioned content; two branches editing the slug produce a normal root-block merge conflict instead of a silent overwrite.parentRootId/moveRootare unchanged (a page move still changes the live URL immediately); only the slug is versioned.
No schema migration is required (the draft slug rides an existing JSONB column). A
backfillDraftSlugsscript copies each existing entry's current live slug into its draft content so existing pages have a draft slug to edit and re-publish.Known limitations (documented): clearing a slug to make an
allowRootpage the home page is not re-materialized on republish; and publish-time slug uniqueness is scoped to the active request scope, so publish i18n translations within their own language context. - A draft slug edit is isolated until publish.
-
#37
40f6f35Thanks @weepaho3! - Documentation-accuracy pass (docs-01 … docs-13, readme-01 … readme-14). No API changes; corrects the docs + README to match the shipped code.Package-facing (npm) fixes:
- The
README.mdrender sample imports the RSC-safe@createcms/core/react/blocks(not the client-only/reactbarrel), imports the collection from the path the scaffolder actually writes (@/cms/collections/pages), and orders the quickstart so thecreateCMS(...)config is created beforecreatecms generatereads it. - Added a "Requirements" block (Node ≥18, PostgreSQL,
drizzle-orm+reactpeers, optionalnext), the missingi18nandconsentplugin rows, a docs/examples/ changelog/contributing links block, and an MIT license section. - The published tarball now ships
LICENSE(added topackage.json#files).
Docs-site fixes (apps/docs): completed the server-API reference (root methods +
deleteCommentThread), corrected thegetBlockTreerawnote (images are never resolved), the branch guards (configureddefaultBranchName, not a hardcodedmain), the multi-tenant scoped-table list (addstemplates/variables), the block field-type count (nine, withlink) and image-storage note (asset id, not object key), thewithUser/recipientId/actorUsernotification fields, thecreateCMSQuerysignature (reactive function form,method?: string), theatomListenersclient-plugin field, a new "Plugin schema columns" reference plus fixed cross-links, and an A/B-test server-endpoints reference. - The
-
#52
0beeb80Thanks @weepaho3! - Editor-DX pass (toe-ed-01 … 13). Hardens the visual-editor write path and exposes the primitives an editor needs, so consumers stop re-implementing package internals.updateBlocksnow enforces structural validation (toe-ed-01), matching the single-block routes: it checks the posted rootblockIdequals the entry root, that every written block type exists, validates each written block's properties against its type schema, and asserts placement over the tree. Validation is diff-scoped: newly-created blocks are validated strictly (required props enforced) while updated blocks and the root use patch semantics, and unchanged blocks are left alone, so a stale sibling can't block an unrelated save.- Lossless root-type round-trip (toe-ed-02): posting a tree loaded from
getBlockTree(whose root node istype: 'root') back throughupdateBlocksno longer persists the literal'root'; the root is normalized to the collection type. defaultValueis now applied (toe-ed-09): a block property's declareddefaultValueseeds newly-created blocks (lowest priority: defaults < template prefill < caller values). NewdefaultPropertiesFor(blockDef)export computes a block's initial properties for editor use.- Required links are enforced (toe-ed-10): link targets (
url/rootId/email/phone) must be non-empty, so arequiredlink with an empty target is now rejected instead of silently passing. - New exports for editors:
buildPlacementIndex,isPlacementAllowed,allowedChildTypes,PlacementIndex(toe-ed-04);isResolvedReference+toStoredReferencefrom the root entry, not just/react(toe-ed-07);RefMode;getCollection(map)/getComponents(map)accessors so editors stop reaching intoBlocksMapunderscore internals (toe-ed-06). BlockComponentProps<TProps, M extends RefMode = 'resolved'>is now parameterized (toe-ed-11), so an editor canvas rendering raw store values can type components instead of falling back toany.- Docs: an
updateBlocksreference section (body, root-type rule, validation scope, optimistic concurrency) and editor-guide notes ongetTemplateDefaultsprefill andblk_id minting (toe-ed-05/08/12/13).
-
#43
20d913aThanks @weepaho3! - Error-handling hardening (err-01 … err-18).Consumer-visible:
cms.$ERROR_CODESis now the complete registry (core codes + plugin codes), matchingclient.$ERROR_CODES— a plugin-free install previously exposed{}on the server (err-02). Duplicate/shadowing plugin codes nowconsole.warninstead of silently overriding (err-16).- Validation errors carry their details: every
VALIDATION_ERROR(400) response body now includes the Zodissuesarray (err-06). Documented in the errors reference. - New
onAPIError(error, request)option oncreateCMSto attach logging/monitoring for unexpected, validation, and middleware errors (err-07). - State-conflict codes are now
409(were400):BRANCH_NAME_ALREADY_EXISTS,MERGE_REQUEST_ALREADY_EXISTS,ROOT_HAS_CHILDREN,FOLDER_HAS_CONTENT,BRANCH_HAS_PUBLICATIONS,BRANCH_HAS_OPEN_MERGE_REQUESTS(err-11). CMSErrordatanow reaches the wire and is surfaced onCMSClientError.data(block-placement + type-mismatch context, err-01).getCMSErrorCode/isCMSErrornow recognize plugin error codes, not just core ones (err-03).- Network/transport failures (offline/DNS/CORS) are now wrapped in
CMSClientError(status: 0,code: 'NETWORK_ERROR') soerr instanceof CMSClientErrorholds (err-14). - Removed never-thrown codes
MERGE_REQUEST_OUTDATED,COMMENT_BODY_REQUIRED,AB_TEST_WEIGHTS_INVALID, and the decorativemedia-optimize$ERROR_CODES(err-04, err-05). - The Next revalidate webhook now returns the standard
{ message }error shape and rejects malformed JSON with a clean400(err-15).
Observability: S3 upload failures now log + carry the underlying cause; Upstash realtime distinguishes a missing peer from a real misconfiguration; the A/B middleware and analytics sinks emit a dev-only warning on the first fail-open (err-08, err-12, err-18). Plugin error-code tables added to the ab-test and i18n docs (err-13).
-
#53
2c138a6Thanks @weepaho3! - Integration-DX pass (toe-int-02 … 15). Fills the gaps consumers had to work around when building on the API.- User discovery endpoints (toe-int-06/05):
users.listReviewersreturns candidate reviewers as{ id, ...exposeColumns }(so an approval UI can build a reviewer picker), andusers.whoamireturns{ userId, user }. Both are permission-gated (userresource) and only ever expose the configureduser.exposeColumns(never password hashes/tokens). - Branch-by-name lookup (toe-int-02):
getBranchnow accepts{ rootId, name }as well as{ branchId }, so named-branch URLs no longer page throughlistBranches+.find(which broke past 100 branches). - Root-level asset listing + bulk resolve + cursor paging (toe-int-03/15):
listAssets({ unfiled: true })lists assets with no folder (wire-safe boolean, replacing the un-serializablefolderId: null); newgetAssets({ ids })bulk resolves assets by id (single id works over HTTP); andlistAssetsgained precision-exact cursor pagination so a media library can page past the 100-item ceiling without skipping or duplicating rows. - Uploads auto-optimize (toe-int-04): when the media-optimize client plugin is
installed,
useUploadAssetsoptimizes images by default (opt out per call withoptimize: false); previously the registered config was never read. - Notifications without wiring your auth client (toe-int-05/08):
useNotificationsresolves the current user viausers.whoamiwhenuserIdis omitted, and nestingRealtimeProvidernow shares the single SSE connection (with a dev warning) instead of silently opening a second stream. - Consistent revalidation paths (toe-int-14):
RevalidateEvent's bareslugis renamedstoredSlugand every URL-shaped value inpathsis a leading-slash path, so using an event value as a Next.js cache tag no longer silently fails to bust. Consumers readingevent.slugshould readevent.paths(for tags) orevent.storedSlug(the bare slug). - Docs (toe-int-09): route-mount snippets now show
export const dynamic = 'force-dynamic'for the realtime SSE stream.
- User discovery endpoints (toe-int-06/05):
-
#56
359cc8cThanks @weepaho3! - Naming-consistency pass (naming-01 … 25). Many breaking renames (pre-1.0, no aliases) that remove diverged/ambiguous names across the public surface.Renamed identifiers:
- Context types standardized on the
Contextsuffix:CMSMiddlewareCtx→CMSMiddlewareContext,CMSProcedureCtx→CMSProcedureContext,CMSHandlerCtx→CMSHandlerContext,CMSSystemHandlerCtx→CMSSystemHandlerContext. The duplicateCMSEndpointCtxis removed; useCMSEndpointContext(now the complete shape). - Slug config:
slug.root→slug.prefix,slug.allowRoot→slug.allowIndex(avoids colliding with the top-levelbasePathand the many other meanings of "root"). - List rows:
RootListItem/RootSummaryexpose their own key asid(wasrootId), matching every other list item. - Permission resources:
'variables'→'variable'and'templates'→'template'(now all singular); new exportedCMSPermissionResourceunion so a typo in a permission matrix is a compile error. - Endpoint keys:
resolveConflicts→applyConflictResolutions,approve→submitApproval,reject→submitRejection(the bare verbs implied a lookup / were ambiguous as hook actions). - Generated schema:
createcms generatenow emitsexport const cmsSchema = pgSchema('cms')(wascms), sotypeof cmsno longer collides with yourcreateCMSinstance. Regenerate your schema. - Client store:
CMSClientStore.notify(signal)→invalidate(signal)(it toggles a cache-invalidation signal, distinct fromcms.notifywhich sends a user notification). - Inference marker:
cms.$notifications→cms.$InferNotifications(type-only phantom; joins the$Infer*family). - Renderers:
createBlocksRendereris removed (it wascreateBlocksMap+BlocksRendererinline); usecreateContentRenderer(the one convenience factory) orcreateBlocksMap+BlocksRendererfor the low-level path. - Type-prefix cleanups:
ABTest*→AbTest*,GA4Payload/GA4ServerConfig→Ga4Payload/Ga4ServerConfig,CMSEvent→AnalyticsEvent,MultilingualMiddlewareResult→I18nMiddlewareResult, generic paramTCms→TCMS, zod exportnotificationEvent→notificationEventSchema, and the unprefixed client typesQueryState/MediaUploadState/MediaUploadFileState/MediaUploadOptions→CMS*-prefixed.
Docs-only: error messages and JSDoc now say "root"/"entry" instead of "page" for an entry; the variant-selection verb ladder is documented;
CMSHooksandCMSConfigHookscross-reference each other.(naming-06, naming-07, naming-09, naming-19 were already resolved by earlier passes.)
- Context types standardized on the
-
#48
d3f6406Thanks @weepaho3! - Performance pass (pf-03 … pf-15). Query, index, and payload optimizations on the hot read/merge/prune paths. Two consumer-visible changes; the rest are internal.- Schema (regenerate after upgrade): added a
(collection, slug)index onrootsfor the publicgetPublishedContentslug lookup (pf-03), and removed the unusedbv_properties_ginGIN index onblock_versions.properties— it could not serve any existing query (all predicates are->> ... ILIKE, which jsonb_ops GIN does not accelerate) and only cost write amplification on the highest-churn table (pf-06). Runcreatecms generate+ a migration to pick up both. getPublishedContentA/B shape + branch selector (pf-07): added an optionalbranchNamequery param — when set, only that published branch is resolved (still returned as a length-1variants[]); omitted, behavior is unchanged. Embedded A/B references no longer serialize the control branch twice: the control tree is the top-leveltree/properties, andabTest.variantsnow carries only the non-control variants. Consumers that previously read the control snapshot out ofabTest.variantsshould read the top-level tree instead.
Internal (no API change):
resolveLinkPathsresolves internal link targets in parallel instead of serially on the published-content path (pf-04);listRootsuses a slimCOUNT(DISTINCT roots.id)query for the total instead of re-running the full aggregate/enrichment join twice (pf-05);executeRootPruningdeletes prunable commits in a single set-based statement instead of one round trip per commit (pf-11);executeMergecomputes the merge base before taking the branchFOR UPDATElocks and reuses it only when the locked heads are unchanged, shortening lock hold time without changing merge-base semantics (pf-08);archiveAssetsbatches the live-reference check across the input instead of one query per asset (pf-12).Also documents several intentional cost/scaling tradeoffs (snapshot-per-commit,
listRootssearch vs. thesearchendpoint, the default A/B edge resolver's per-request fetch, and the realtime per-recipient publish model). - Schema (regenerate after upgrade): added a
-
#35
a26143aThanks @weepaho3! - React / client-layer hardening (react-01 … react-16). Highlights:- react-01: the browser realtime entry no longer bundles the entire generated
Drizzle schema (+
drizzle-orm/pg-core+nanoid). The notification wire schema used to runtime-import the DB enum fromschema.generated; the core types now live in a dependency-freeNOTIFICATION_TYPESconstants module (shared withcore-schema), and the wiretypeaccepts any string so plugin notifications still push. - react-02 / react-03: added the missing
'use client'directive to the React client entries (client/react.ts,client/react-store.ts),react/realtime.ts, and theab-test/media-optimizehook modules, so importing@createcms/core/react(and wrapping your app inRealtimeProvider) no longer fails to bundle in an RSC app. JSDoc examples corrected to import the RSC-safe rendering API from@createcms/core/react/blocksin Server Components. - react-14: split the pure
CMS_ERRORSdata from theCMSError extends APIErrorclass so the browser client no longer pulls thebetter-callserver lib; added"sideEffects": falseso bundlers can tree-shake it. - react-04:
useOptimizenow keys its memo on a file content-key (name/size/ lastModified), so replacing a file array with a same-length one re-runs. - react-08: client
runPluginInitfailures are caught + logged (no more unhandled rejection); the docstring matches the synchronous-build reality. - react-09:
useNotificationsmerges the reconcile poll into state by id (a racing live push is no longer overwritten), re-adding only genuine mid-poll pushes — items newer than the newest polled row — so the unread badge never drifts or reorders when the list is longer than the page size. Polls are guarded with a monotonic request id, and a failed poll now surfaces anerrorinstead of being swallowed. - react-10:
CMSClientStore.listenreturns its unsubscribe and usesatom.listen(wassubscribe, immediate-fire, and leaked). - react-13: proxy atom-listeners fire only on MUTATING calls (a GET read no
longer forces subscribed query atoms to refetch); deferral uses
queueMicrotask. - react-12: renderer threads
fromReferencethrough the whole referenced subtree (no stray "No component mapped" dev warnings on grandchildren) and keys the inlined reference fragments. - react-11 / react-15: documented the stable-client / non-referentially-stable
proxy constraint; dropped the
resolveWireNamere-export from the'use client'tracking module (import it from the core entry server-side).
react-05/06/07 were already fixed by the earlier api-design client work; react-16 (render-test suite) is intentionally out of scope.
- react-01: the browser realtime entry no longer bundles the entire generated
Drizzle schema (+
-
#32
de20debThanks @weepaho3! - API return-shape consistency pass (ret-01 … ret-22). Pre-1.0, these replace the old shapes cleanly (no compat shims). Highlights:- Commit envelope. Every commit-producing mutation (createRoot, createBlock,
moveBlock, deleteBlock, duplicateBlock, updateBlock, updateBlocks, updateRoot,
revertBranch, executeMerge) now returns
{ commit: { id, message, createdAt, createdBy }, ... }— one uniform shape instead of three divergent keys (commitId/newCommitId/mergeCommitId).executeMergefast-forward now returns the resulting head commit instead ofmergeCommitId: null.updateBlocksaddschanged: booleanso a no-op save is distinguishable from a real commit. - Entity envelope. Entity-returning mutations wrap the row as
{ <resource>: row }:createBranch/renameBranch→{ branch, isDeletable },createMergeRequest→{ mergeRequest, hasConflicts, conflicts },update/close/reopenMergeRequest→{ mergeRequest },publishBranch→{ publication }(now incl.branchName),approve/reject→{ approval }, comment message mutations →{ message }. - Richer reads/mutations.
getRoot/getRootBySlugnow return the fullRootListItem(counts + path), not a bare summary.createRoot/updateRoot/duplicateBlockreturn the server-normalizedslug/path;moveRoot/deleteRoot/updateRootreturnredirectsCreated(andmoveRootthe effectivesortOrder);deleteBlockreturnsdeletedBlockIds;unpublishBranchreturnsunpublishedCommitId/unpublishedAt. - List consistency.
getRootHistorynow returns{ commits, total, hasMore }(was{ data, total, offset, limit });listTemplates/listVariablesgainlimit/offset/search+{ …, total, hasMore }.updateAssetStatusreturns{ updated, updatedIds, skipped };uploadAssets/replaceAssetreturn the full asset row. - Type fixes.
getRootHistory.createdAtandcreateSignedUpload.expiresAtare nowDate(were an ISO string / epoch-ms).deleteTemplate/deleteVariableecho the deleted id ({ templateId }/{ variableId }) instead of{ deleted: true }.getDiff/checkConflicts/checkDivergenceare nowGET(werePOST). - Bug fix.
listMentionsnow populates each message'smentions(was always[]).
- Commit envelope. Every commit-producing mutation (createRoot, createBlock,
moveBlock, deleteBlock, duplicateBlock, updateBlock, updateBlocks, updateRoot,
revertBranch, executeMerge) now returns
-
#46
d7c10bdThanks @weepaho3! - Security-hardening pass (sec-01, sec-03 … sec-08). Several breaking changes that close real holes; all fail secure by default.authMiddlewareis now required (sec-01).createCMSthrows at construction if it is missing, so auth can never be silently absent. To run with no auth on purpose (public/dev), pass the newallowAnonymous()export, which authorizes every request and is byte-identical to the old omitted- middleware behavior. The undocumentedmiddlewarealias forauthMiddlewarewas removed.- Default
allowedMimeTypesis now an explicit allowlist (sec-04):image/png,image/jpeg,image/webp,image/gif,video/mp4,video/webm,application/pdf— noimage/*/video/*wildcards, soimage/svg+xml(a stored-XSS vector) is excluded by default. Uploads that carry file bytes are additionally checked against their real magic bytes, so a file declared asimage/pngbut containing SVG/HTML is rejected before it reaches storage. Upgraders who relied on wildcard formats (avif, heic, mov, …) must re-add them explicitly viamedia.allowedMimeTypes. user.exposeColumnsis now required when ausertable is configured (sec-06). It previously defaulted to every column, which leaked password hashes / tokens throughwithUser.resolveUserConfignow throws instead of defaulting to all columns — you must name the safe columns explicitly.- Multi-tenant slug is no longer taken from the request body/query by
default (sec-03).
resolveTenantSlugreturns the session-derived fallback and ignores request-supplied slugs unless you opt in with{ allowRequestOverride: true }(intended only behind an admin check), closing a cross-tenant access path.
Internal hardening (no API change): magic-byte MIME sniffing helper; fixed a prefix-match bug in
isFileTypeAllowed(imagexml/evilno longer matched theimageprefix);SAFE_IDENTIFIERvalidation applied to every table/column identifier spliced intosql.raw()in the user-join helpers.Also adds a Security documentation page covering the required-auth model, media privacy and MIME/SVG handling, rate limiting, CSRF, and multi-tenant isolation.
-
#42
fe5e9c5Thanks @weepaho3! - Fixcreatecms --versionreporting a stale hardcoded0.0.1— the CLI now reports the real package version (inlined frompackage.jsonat build time). The rest of this change is an internal repository cleanup with no API surface change: removed three dead modules, consolidated shared test helpers undersrc/test-utils/, foldedcore/assets.tsintocore/media/, and added the missing plugin READMEs plus apackages/cms/srclayout guide in CONTRIBUTING. -
#44
3da4035Thanks @weepaho3! - Testing-strategy hardening (test-01 … test-17). Mostly internal (coverage tooling, new test suites, a faster test harness), with two shipped changes:- New
cms.$flushNotifications()— awaits all in-flight fire-and-forget notification dispatches. Handy in serverless/short-lived contexts (and the deterministic seam the tests now use instead of realsetTimeoutwaits). - The Next revalidate webhook loads
next/cachevia a normalawait import(...)instead of aFunction-eval'd import — behaviour-identical in a Next app, and now mockable/testable.
Coverage tooling (
@vitest/coverage-v8,bun run test:coverage) plus new suites for the previously-untested browser client, Next adapter, React render paths (happy-dom), the error contract, and the client/server HTTP-method contract; the notification and publication suites are now deterministic (no real-clock sleeps), and the test DB harness memoizes generated migrations (~23% faster suite). - New
-
#34
a8196e1Thanks @weepaho3! - TypeScript-hardening pass (ts-01 … ts-18). Pre-1.0; the breaking items are applied cleanly. Highlights:- ts-01 (client timestamps): the HTTP client now types every timestamp as the
ISO
stringthe wire actually delivers, via aSerialize<T>mapped type at the client boundary (server-sidecms.api.*still returns realDates). TheuseNotificationspoll and live push are unified on the serialized shape, so a notification item never mixesstringandDate. - ts-06:
withUser-enrichable list endpoints (listRoots,listMergeRequests,listBranches, …) now typecreatedByUseroff the user table instead ofunknown, generalized from the notifications-only path. - ts-08:
createCMSClientinfersTPlugins(default[]) so a no-plugins client no longer gets aRecord<string, unknown>action index signature (client.anyTypois now a compile error). - ts-02 / ts-03 / ts-04:
definePluginSchemais curried (definePluginSchema<CoreTables>()({ … })) so the schema DSL is actually type-checked; newdefinePlugin(keeps a literalid+ endpoint contributions, rejects typo'd keys) anddefineUserConfig(checksexposeColumnsagainst the real user table) helpers. - ts-09: config hook
actionis now the closed union of endpoint keys (dropped the(string & {})escape) — a misspelled action is a compile error, not a silent no-op. ts-05:check-typesnow type-checks thetest/suite too. - ts-17:
MediaConfig/OptimizationConfig(and provider variants) are exported from the root. ts-14/15: replaced unprincipledtx as any/scope.where as any/as unknown asresult-shaping casts with a typedDbOrTxalias,SQL[]condition arrays, and structurally-checked row builders. - Smaller: ts-07 stop exporting the unused
InferPluginRealtimeEvents; ts-10 made the phantom$notificationstype-only field unmistakable; ts-12 tightenedDrizzleInstance's schema param; ts-18 corrected theCMSMiddlewareRequestJSDoc; ts-16 added type-check files for the clientSerialize, block-property inference, the plugin schema DSL, anddefinePlugin.
- ts-01 (client timestamps): the HTTP client now types every timestamp as the
ISO
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.
@createcms/react
0.3.0
Minor Changes
- #105
6c9a64bThanks @weepaho3! -Canvas.Rootrenders the store tree through acomponentsmap (plain or{ _components }), with data-onlyeditanchors, aresolvelayer, andinteractivemodes.componentsis required;childrenare the overlay slot, not the tree.
Patch Changes
-
#100
ee68b60Thanks @weepaho3! - A11y contract for@createcms/react/editor:useEditorKeyboard(scopeRef)binds undo/redo (Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z, Ctrl+Y) and optionally Delete/Escape; built-in controls setaria-requiredwhen the spec is required; README tables for keyboard, ARIA and focus; SSR hydration test forEditor.Root+Editor.Form. -
#107
d9df66bThanks @weepaho3! -Canvas.BlockToolbarandCanvas.InsertButtonsit on the overlay.resolveInsertAt/useInsertTargetpick a line or box insert from measured rects and the parent layout flow. -
#106
45a51e3Thanks @weepaho3! -Canvas.Overlayportals an unstyled layer over the canvas. Selection, hover and field rings follow measured block and field rects viauseBlockRect/useFieldRect. -
#103
be7d148Thanks @weepaho3! -Editor.FramePreviewshows compiled HTML or Blob output in a double-buffered sandboxed iframe, with selectable anchors, stale-response discarding, andonIssuesfor relative URLs, missing hrefs and leftover editor anchors. -
#102
c0e0db3Thanks @weepaho3! -Editor.Previewrenders a delayed raw store tree;Editor.Form autoScrollscrolls the focused block into view;useEditor().scrollToscrolls a registered form or a[data-block-id]inside an optional container.
0.2.0
Minor Changes
-
#98
bea8451Thanks @weepaho3! - Structure parts for@createcms/react/editor:Editor.OutlineItem(tree row with selection, arrow navigation, Alt+arrow reorder, Delete with anonDeleteveto and focus return, Escape),Editor.AddBlock(inserts a palette type at the selection point and selects it),useBlockActions(id)(placement-gated add/remove/duplicate/moveUp/moveDown withcanMoveUp,canMoveDown,canHaveChildren,allowedChildTypes), typed in the factory asTypedBlockActions.BREAKING:
useChildren(parentId)returns child refs{ id, type, index }instead of a string array (the factory narrowstypeto the schema's block types). Readchild.idwhere an id was used before.
0.1.1
Patch Changes
- #96
e510694Thanks @weepaho3! - Field parts for@createcms/react/editor:Editor.Field,Editor.FieldLabel,Editor.FieldControl,Editor.FieldDescription,Editor.FieldErrorandEditor.Form, a typedfieldsmap onEditor.Rootfor per-kind controls, built-in headless controls forstring,richText,number,boolean,date,selectandlist, anduseMissingRequired().
0.1.0
Minor Changes
-
#86
91c75caThanks @weepaho3! - Scaffold the package: subpath entries@createcms/react/editor,@createcms/react/editor/canvasand@createcms/react/editor/cms, a shared editor context (Editor.Root,useEditorContext,Canvas.Rootplaceholder) and a localuseRender/mergeProps/composeRefscopy forrenderprops. Zero runtime dependencies (reactas peer,react-domas optional peer for the canvas entry). -
#87
6b3d46fThanks @weepaho3! - Editor schema helpers, pure and React-free, exported from@createcms/react/editor:getPlacement,canPlace,allowedChildTypes(the same rules as core's placement index),defaultValuesFor(with afillDefaultsoption),propertiesOf,groupFields,paletteItems,groupPaletteItems,isEmptyValue,validateField(stable error codes) andmissingRequired, plus theEditorSchema,FieldKind,FieldSpecOf,FieldValueOf,SchemaFieldtypes. -
#88
dcbbe62Thanks @weepaho3! - Patch-based editor store: JSON operations (add,remove,move,update,load) with computed inverses viaapplyOp,createEditorStorewith undo/redo of op groups (rapid updates of the same keys coalesce within 400 ms),applyRemotefor foreign ops without history oronChange, per-user selection state, structural-hash dirty tracking,save, and the helpersflattenTree,serializeToTree,stableHash,createBlockId. -
#89
9ce8d49Thanks @weepaho3! -Editor.Rootcreates and owns the store (schema,defaultValue,onChange,onSave,genId,userId);useEditorSelector/useEditorStore(auseSyncExternalStorebinding with shallow-equal slices); untyped hooksuseEditor,useAnyBlock,useAnyField,useFields,useChildren,useSelection,useHistory,useSave,useDirty,usePalette; and thecreateEditor({ schema })factory that returns the same hooks typed from the collection definition (TreeOf,BlockHandleOf,PropValueOf).