⚠️ Work in progress — createCMS is pre-1.0 and not production-ready (not tested in production). Expect breaking changes.
createCMS

Changelog

Release notes for @createcms/core and @createcms/react, generated from Changesets.

@createcms/core

0.7.0

Minor Changes

  • #91 2e132f7 Thanks @weepaho3! - Block components receive an edit prop with the editor anchors as plain data (edit.block, edit.field.<key>, edit.active) — NO_EDIT outside an editor, real data-editor-block / data-editor-field anchors when the renderer is given edit="preview". BlockComponentProps.properties is now typed as an object (never undefined). Breaking for code that renders a block component by hand: pass edit={NO_EDIT}.

Patch Changes

  • #93 c449046 Thanks @weepaho3! - POST /{collection}/resolveTree resolves a posted, unsaved tree the way getBlockTree resolves a stored one — variables substituted, links resolved, references as a references sidecar (includeReferencePreviews) and/or inlined into the tree (inlineReferences) — without writing anything, so an editor can preview its working copy.

  • #94 db18bed Thanks @weepaho3! - Export the pure {{key}} helpers resolveTemplateString, extractVariableKeys and VAR_PATTERN from @createcms/core and @createcms/core/react, and the spec types ListBlockPropertySpec, ListElementSpec, ListElementType, SelectOption, StringConstraints, NumberConstraints from the package root.

0.6.0

Minor Changes

  • #76 1e6edbf Thanks @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. checkConflicts and createMergeRequest responses gain autoMergeableBlockIds.

    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 e557806 Thanks @weepaho3! - fix(media): add a browser-callable asset replace flow and stop leaking S3 objects on replace

    Migration note (browser callers of replaceAsset only): replaceAsset takes an in-process buffer: Blob | ArrayBuffer and always required a server-side caller — a File selected in the browser cannot survive the client's JSON request body (it serializes to {}), so a browser call type-checked but failed at runtime. replaceAsset itself 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) then commitReplace (repoint the row) once the client's PUT to S3 succeeds. The React client wraps this as client.media.useReplaceAsset() (mirrors useUploadAssets); the vanilla client exposes the same state as a raw nanostores atom at client.media.replaceState.

    Two more media fixes bundled with the above:

    • Replacing an asset no longer leaks its superseded S3 object. replaceAsset (and now commitReplace) 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. uploadAssets and replaceAsset used to put the S3 error message on data.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.
  • #70 62a19ff Thanks @weepaho3! - feat(types): hide server-only endpoints from the client's type surface

    media.uploadAssets and media.replaceAsset no longer appear in cmsClient's / client's inferred types. Both take an in-process buffer: Blob | ArrayBuffer body that can't survive the client's JSON request — a File selected in the browser serializes to {} over the wire — so a browser call used to type-check and fail only at runtime. They're now marked scope: 'server' in their endpoint metadata, and the client's type builder omits any endpoint carrying that mark: client.media.uploadAssets and client.media.replaceAsset are now compile errors, not just runtime ones. Their browser-callable counterparts are unaffected and unchanged — createSignedUpload for uploads, createSignedReplace + commitReplace (or the useReplaceAsset client 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, plain fetch, 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.uploadAssets and cms.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

  • 17fdd6b Thanks @weepaho3! - fix(routes): close two permission-resource bypasses

    duplicateBlock minted a new top-level root when targetParentBlockId was omitted, while declaring only block:create — so a host granting block:create but denying root:create could be bypassed through duplication. This was the same defect already fixed on duplicateRoot, still reachable through the older door.

    Breaking: targetParentBlockId is now required on duplicateBlock, which is child-duplication only. Use duplicateRoot to duplicate a subtree into a new top-level entry — it takes the same arguments and has always been guarded as root:create. duplicateBlock now returns a non-union type (mode is always 'child'), so callers no longer need to narrow it.

    publishRelease made content live under release:update while the equivalent publishBranch requires publication:create.

    Breaking: publishRelease now declares publication:create. Hosts granting release:update for release curation must also grant publication:create to allow publishing.

  • 64488a7 Thanks @weepaho3! - fix(comments): enforce the active scope on every thread-addressed endpoint

    Only deleteCommentThread enforced 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, createCommentThread validates the supplied rootId, and resolveCommentThread / reopenCommentThread no longer operate on soft-deleted threads.

    Breaking: listMentions filtered on a caller-supplied mentionedUserId, letting any caller read another user's mention inbox. It now derives the filter from the session user, and the mentionedUserId query parameter has been removed.

  • 5a4ee09 Thanks @weepaho3! - fix(branches): deleting a branch no longer fails once it has merge history

    deleteBranch threw 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_id and approvals.branch_id are now nullable with ON 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.branchId is now string | null. It is null for approvals whose branch has since been deleted. Consumers reading branchId off an approval must handle null.

    Migration: this changes the database schema. Regenerate and apply your Drizzle migrations (drizzle-kit generate) after upgrading.

  • b8085b7 Thanks @weepaho3! - feat(merges): add dismissStaleApprovals branch-protection flag

    By 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 new APPROVALS_STALE error.

  • 4b7a75f Thanks @weepaho3! - feat(pkg): publish as ESM-only

    Breaking: @createcms/core no longer ships a CommonJS build. The main and module fields are gone and every exports subpath now resolves to ESM only.

    CommonJS projects do not need to migrate to import: Node resolves ESM from require() natively since 22.12, so require('@createcms/core') keeps working. That is why the minimum Node version is now 22.12 (engines.node was >=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.

  • 0d22f89 Thanks @weepaho3! - fix(deps): require Next.js >= 16.2.11 and bump runtime dependencies

    Breaking (Next.js users only): the next peer range moves from >=16 to >=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 a next/middleware integration, so pairing it with an affected Next is a real exposure rather than a theoretical one. next remains an optional peer: projects not using Next.js are unaffected.

    Runtime dependencies moved to their current releases within the existing ranges: better-call 2.0.5, nanostores 1.4.2, fast-xml-parser 5.10.1, nanoid 5.1.16 and ora 9.4.1.

  • 06d9e28 Thanks @weepaho3! - fix(blocks): validate position and targetProperties on the block write paths

    createBlock's position was an unconstrained number handed to Array.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 now z.number().int().min(0) — matching moveBlock's newIndex — and the insert index is clamped to the child count.

    targetProperties on the duplicate paths was written into content with only a cast, bypassing the per-block property schema that every other write path enforces. runDuplicate now parses it with buildPropertiesSchema, so declared constraints (maxLength, numeric ranges, required keys) apply to duplication too.

0.3.0

Minor Changes

  • f0b9dbd Thanks @weepaho3! - fix(blocks): guard duplicateRoot as root:create, not block:create

    duplicateRoot mints a NEW top-level root (forced root mode), the same privileged act createRoot guards as permissionResource: 'root', but it was labeled permissionResource: 'block'. A consumer granting block:create while denying root:create could create roots through duplication. The metadata now reads root. BREAKING for any authMiddleware policy that mapped duplicateRoot under block — remap it to root.

  • #57 e83ada8 Thanks @weepaho3! - feat(diff): visual diff — precise change classification, property-level detail, and a renderable annotated tree

    BREAKING (pre-1.0): getDiff semantics and response shape changed.

    • Identity-based moved (noise fix): blocks are only flagged moved when 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-level textDiff segments (same/ins/del) for richText properties (including list-of-richText items).
    • Slug changes are first-class: a draft-slug change on the root yields slugChange: { from, to } instead of an opaque modified; the reserved __slug key no longer leaks into any returned version properties.
    • Annotated diff tree: new view query param ('list' | 'tree' | 'both', default 'both'). The response is now { diff, tree, summary, sourceCommitId, targetCommitId, commonAncestorCommitId }tree is the draft tree with per-node diff annotations and deleted blocks re-inserted as ghost nodes at their old position (with their last content), directly renderable by the existing component maps. summary carries per-changeType counts.
    • React: BlocksRenderer and createContentRenderer accept an opt-in diff prop that wraps changed blocks in <div data-diff="added | deleted | modified | moved" data-diff-types data-diff-props> (or a custom wrap callback). New helpers: getBlockDiff(node), diffSegmentsToHtml(segments) for inline rich-text ins/del highlighting; BlockComponentMap is now exported.

    New exported types: ChangeType, PropertyChange, TextDiffSegment, MovedInfo, BlockChange, DiffSummary, BlockDiffAnnotation, AnnotatedBlockTreeNode, DiffView.

Patch Changes

  • a3218b6 Thanks @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 d6ed93b Thanks @weepaho3! - feat(diff): generalize getDiff to refs (branch | commit | published), per-change attribution, and history change counts

    • getDiff now compares arbitrary refs: pass exactly one of sourceBranchId | sourceCommitId and one of targetBranchId | targetCommitId | targetPublished per side. Branch refs resolve to their head; commit refs are used as-is; targetPublished resolves to the live head of the entry's publication branch (exactly what getPublishedContent serves) and throws PUBLICATION_NOT_FOUND for 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 vs targetPublished yields exactly the edits that are not live yet. Commit refs enforce the same collection + root scoping as branch refs.
    • withAttribution on getDiff: every diff entry and tree annotation gains attribution: { 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). changedByUser follows the withUser/exposeColumns rules.
    • withChanges on getRootHistory: each commit entry gains changes: { 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.

  • 93561b4 Thanks @weepaho3! - chore(deps): raise the fast-xml-parser dependency 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

  • 04f63f2 Thanks @weepaho3! - docs(readme): fix links that 404 on npm, correct the slug example (prefix, not root), and the Node engine version

  • 46c9a7f Thanks @weepaho3! - feat(cli): add createcms 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 what generate would write.

  • 53c71ba Thanks @weepaho3! - fix(media): scope variant refs to the active tenant and measure real upload bytes

    validateVariantRefs looked up variantOf ids with no scope filter, letting a multi-tenant caller reference or probe another tenant's asset ids. It now ANDs the active scope's asset where, mirroring every other media query.

    On the two server buffer-upload paths (uploadAssets, replaceAsset) the maxFileSize check, the persisted size column, and the S3 contentLength all used the client-declared size — a body field the server never verified. They now use the true byte length measured from the held buffer, so an under-declared size can no longer bypass the limit or corrupt stored metadata. The client-side createSignedUpload path is unchanged (the server never holds those bytes).

  • #57 818d102 Thanks @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.

  • 025fa3a Thanks @weepaho3! - chore(pruning): remove the unused, unbudgeted collectRootExecutionPlans export

    collectRootExecutionPlans loaded 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 ./admin subpath to reach it externally. The production entry point, runPruningPass, already does the budgeted equivalent directly (bounded by maxRoots/maxDurationMs), so this was dead code — not a reachable dry-run/reporting API. Removed.

  • e393ea1 Thanks @weepaho3! - fix(routes): decode remaining GET boolean query flags wire-safely

    z.coerce.boolean() turns the wire string 'false' into true (Boolean('false') === true), so passing false over HTTP inverted the flag. Migrated the last GET flags — listBranches (isDeletable / hasPublications / hasOpenMergeRequests), listRoots (hasPublications), notifications.list (unreadOnly), getBlockTree (raw / includeReferencePreviews), getPublishedContent (raw), and listAssets (unfiled) / the media asset gate (download) — to wireBooleanSchema + wireBooleanIsTrue. Tri-state filters keep true/false/absent distinct. In-process callers passing real booleans are unaffected.

0.2.12

Patch Changes

  • #33 db59336 Thanks @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 $pathMethods map 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: createCMS now 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 changed multi-tenantmultiTenant to 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? }; a context.userId is honored as the actor when no auth middleware resolves one (HTTP clients can't set context, so this stays a trusted server-side channel).
    • api-04 (security): approval endpoints no longer take reviewedBy/requestedBy from the request body (spoofable). The actor is derived from the auth context; a reviewer must match one of requestedReviewers. Corrected the branches/merges JSDoc that described the wrong createdBy precedence.
    • api-08 / api-16 / api-20: vanilla client exposes media.uploadState (a real atom) instead of a mistyped useUploadAssets; $ERROR_CODES / err.cmsCode now cover core and plugin codes; removed the dead $InferServerPlugin.

    Renames (breaking)

    • deleteRootarchiveRoot (it soft-archives).
    • search.searchsearch.query (param qsearch).
    • notifications.listNotifications / templates.listTemplates / variables.listVariables.list.
    • media.archiveAssetarchiveAssets; media.updateAssetStatusupdateAssetsStatus.
    • List direction param unified to sortDirection (was sortOrder on listAssets/listPublications).
    • moveRoot body param sortOrderposition (the result field stays sortOrder).
    • Template endpoints' id param → templateId; listFolders parentIdparentFolderId.
    • moveFolder accepts an explicit null parent (detach), matching moveRoot/moveAssets.

    Smaller items

    • api-11: resolveTemplate is now GET. api-17/18: documented listFolders (intentionally per-level) and listRoots.filterValue ILIKE-pattern semantics. api-21: added duplicateRoot (a thin, statically-typed wrapper over duplicateBlock's root mode). api-22: documented the two identifier-lookup conventions.
  • #45 bdd6e2a Thanks @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 threadReopened notification (was incorrectly threadResolved); added to the notification-type enum + router meta map (cc-11).
    • deleteCommentMessage now reports operation: 'delete' to your authMiddleware/permission matrix (was 'update', inconsistent with every other delete) (cc-11).
    • List endpoints parse raw timestamps as UTC (listRoots, root history, listMergeRequests) via parseTimestamp, fixing an off-by-timezone Date on 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, and toInsertRow; removed dead code (unused import/var, two unused TableDefinition type params, the blockToOutput wrapper), tightened a few micro-nits, and stripped undocumented internal design codenames from comments.

  • #54 5f22103 Thanks @weepaho3! - CLI hardening (wc-01, wc-04, wc-05).

    • createcms generate no longer silently drops plugin schemas (wc-01). The subpath alias map is now derived from the package's own exports (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 exposes definePlugin / definePluginSchema, so a config that authors a local plugin loads during generate instead of crashing.
    • --force / --yes for non-interactive generate (wc-04). createcms generate over an existing schema in a non-TTY (CI) previously printed "cancelled" and exited 0, so a cms:generate && drizzle-kit generate step 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 unique mkdtemp directory per run, cleaned up afterward, and fails hard instead of swallowing write errors.
  • #50 30f9bdd Thanks @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.query now applies the SAME read boundary as normal endpoints: a result is returned only if its underlying entity is visible under the active scope (a correlated EXISTS per 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_publications table + endpoints to schedule a publish/unpublish, and admin.runScheduled (call it from your cron, like admin.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_items tables + 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 list property 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). date is now validated as an ISO datetime, string/number properties accept declarative minLength/maxLength/pattern/min/max constraints, and image / reference ids (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). executeMerge now blocks a merge by default when an OPEN approval request exists on it (previously only enforced behind the governance flags), mirroring publishBranch. 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 a HEAD_MISMATCH (409) conflict instead of silently interleaving. Omitted = unchanged behavior.
    • Numeric list sort (cms-10). listRoots sorts numeric properties as numbers (guarded cast, non-numeric values sort last) instead of as text where "10" < "9".
    • Typed plugin hook actions (cms-14). CMSHookAction is 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 new scheduled_publications, releases, and release_items tables.

  • #51 a727384 Thanks @weepaho3! - Versioned slug (cms-05). The root slug is 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, and revertBranch restores the slug along with the content.

    Behavior changes:

    • A draft slug edit is isolated until publish. updateRoot now commits the slug to the branch (stored on the root block version) instead of writing the global roots.slug. The live URL only changes when the default/identity branch is published. createRoot leaves 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 atomic publishRelease rolls 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 / moveRoot are 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 backfillDraftSlugs script 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 allowRoot page 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.

  • #37 40f6f35 Thanks @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.md render sample imports the RSC-safe @createcms/core/react/blocks (not the client-only /react barrel), imports the collection from the path the scaffolder actually writes (@/cms/collections/pages), and orders the quickstart so the createCMS(...) config is created before createcms generate reads it.
    • Added a "Requirements" block (Node ≥18, PostgreSQL, drizzle-orm + react peers, optional next), the missing i18n and consent plugin rows, a docs/examples/ changelog/contributing links block, and an MIT license section.
    • The published tarball now ships LICENSE (added to package.json#files).

    Docs-site fixes (apps/docs): completed the server-API reference (root methods + deleteCommentThread), corrected the getBlockTree raw note (images are never resolved), the branch guards (configured defaultBranchName, not a hardcoded main), the multi-tenant scoped-table list (adds templates/variables), the block field-type count (nine, with link) and image-storage note (asset id, not object key), the withUser/recipientId/actorUser notification fields, the createCMSQuery signature (reactive function form, method?: string), the atomListeners client-plugin field, a new "Plugin schema columns" reference plus fixed cross-links, and an A/B-test server-endpoints reference.

  • #52 0beeb80 Thanks @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.

    • updateBlocks now enforces structural validation (toe-ed-01), matching the single-block routes: it checks the posted root blockId equals 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 is type: 'root') back through updateBlocks no longer persists the literal 'root'; the root is normalized to the collection type.
    • defaultValue is now applied (toe-ed-09): a block property's declared defaultValue seeds newly-created blocks (lowest priority: defaults < template prefill < caller values). New defaultPropertiesFor(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 a required link with an empty target is now rejected instead of silently passing.
    • New exports for editors: buildPlacementIndex, isPlacementAllowed, allowedChildTypes, PlacementIndex (toe-ed-04); isResolvedReference + toStoredReference from the root entry, not just /react (toe-ed-07); RefMode; getCollection(map) / getComponents(map) accessors so editors stop reaching into BlocksMap underscore 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 to any.
    • Docs: an updateBlocks reference section (body, root-type rule, validation scope, optimistic concurrency) and editor-guide notes on getTemplateDefaults prefill and blk_ id minting (toe-ed-05/08/12/13).
  • #43 20d913a Thanks @weepaho3! - Error-handling hardening (err-01 … err-18).

    Consumer-visible:

    • cms.$ERROR_CODES is now the complete registry (core codes + plugin codes), matching client.$ERROR_CODES — a plugin-free install previously exposed {} on the server (err-02). Duplicate/shadowing plugin codes now console.warn instead of silently overriding (err-16).
    • Validation errors carry their details: every VALIDATION_ERROR (400) response body now includes the Zod issues array (err-06). Documented in the errors reference.
    • New onAPIError(error, request) option on createCMS to attach logging/monitoring for unexpected, validation, and middleware errors (err-07).
    • State-conflict codes are now 409 (were 400): BRANCH_NAME_ALREADY_EXISTS, MERGE_REQUEST_ALREADY_EXISTS, ROOT_HAS_CHILDREN, FOLDER_HAS_CONTENT, BRANCH_HAS_PUBLICATIONS, BRANCH_HAS_OPEN_MERGE_REQUESTS (err-11).
    • CMSError data now reaches the wire and is surfaced on CMSClientError.data (block-placement + type-mismatch context, err-01).
    • getCMSErrorCode/isCMSError now 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') so err instanceof CMSClientError holds (err-14).
    • Removed never-thrown codes MERGE_REQUEST_OUTDATED, COMMENT_BODY_REQUIRED, AB_TEST_WEIGHTS_INVALID, and the decorative media-optimize $ERROR_CODES (err-04, err-05).
    • The Next revalidate webhook now returns the standard { message } error shape and rejects malformed JSON with a clean 400 (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 2c138a6 Thanks @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.listReviewers returns candidate reviewers as { id, ...exposeColumns } (so an approval UI can build a reviewer picker), and users.whoami returns { userId, user }. Both are permission-gated (user resource) and only ever expose the configured user.exposeColumns (never password hashes/tokens).
    • Branch-by-name lookup (toe-int-02): getBranch now accepts { rootId, name } as well as { branchId }, so named-branch URLs no longer page through listBranches + .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-serializable folderId: null); new getAssets({ ids }) bulk resolves assets by id (single id works over HTTP); and listAssets gained 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, useUploadAssets optimizes images by default (opt out per call with optimize: false); previously the registered config was never read.
    • Notifications without wiring your auth client (toe-int-05/08): useNotifications resolves the current user via users.whoami when userId is omitted, and nesting RealtimeProvider now shares the single SSE connection (with a dev warning) instead of silently opening a second stream.
    • Consistent revalidation paths (toe-int-14): RevalidateEvent's bare slug is renamed storedSlug and every URL-shaped value in paths is a leading-slash path, so using an event value as a Next.js cache tag no longer silently fails to bust. Consumers reading event.slug should read event.paths (for tags) or event.storedSlug (the bare slug).
    • Docs (toe-int-09): route-mount snippets now show export const dynamic = 'force-dynamic' for the realtime SSE stream.
  • #56 359cc8c Thanks @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 Context suffix: CMSMiddlewareCtxCMSMiddlewareContext, CMSProcedureCtxCMSProcedureContext, CMSHandlerCtxCMSHandlerContext, CMSSystemHandlerCtxCMSSystemHandlerContext. The duplicate CMSEndpointCtx is removed; use CMSEndpointContext (now the complete shape).
    • Slug config: slug.rootslug.prefix, slug.allowRootslug.allowIndex (avoids colliding with the top-level basePath and the many other meanings of "root").
    • List rows: RootListItem / RootSummary expose their own key as id (was rootId), matching every other list item.
    • Permission resources: 'variables''variable' and 'templates''template' (now all singular); new exported CMSPermissionResource union so a typo in a permission matrix is a compile error.
    • Endpoint keys: resolveConflictsapplyConflictResolutions, approvesubmitApproval, rejectsubmitRejection (the bare verbs implied a lookup / were ambiguous as hook actions).
    • Generated schema: createcms generate now emits export const cmsSchema = pgSchema('cms') (was cms), so typeof cms no longer collides with your createCMS instance. Regenerate your schema.
    • Client store: CMSClientStore.notify(signal)invalidate(signal) (it toggles a cache-invalidation signal, distinct from cms.notify which sends a user notification).
    • Inference marker: cms.$notificationscms.$InferNotifications (type-only phantom; joins the $Infer* family).
    • Renderers: createBlocksRenderer is removed (it was createBlocksMap + BlocksRenderer inline); use createContentRenderer (the one convenience factory) or createBlocksMap + BlocksRenderer for the low-level path.
    • Type-prefix cleanups: ABTest*AbTest*, GA4Payload/GA4ServerConfigGa4Payload/Ga4ServerConfig, CMSEventAnalyticsEvent, MultilingualMiddlewareResultI18nMiddlewareResult, generic param TCmsTCMS, zod export notificationEventnotificationEventSchema, and the unprefixed client types QueryState / MediaUploadState / MediaUploadFileState / MediaUploadOptionsCMS*-prefixed.

    Docs-only: error messages and JSDoc now say "root"/"entry" instead of "page" for an entry; the variant-selection verb ladder is documented; CMSHooks and CMSConfigHooks cross-reference each other.

    (naming-06, naming-07, naming-09, naming-19 were already resolved by earlier passes.)

  • #48 d3f6406 Thanks @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 on roots for the public getPublishedContent slug lookup (pf-03), and removed the unused bv_properties_gin GIN index on block_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). Run createcms generate + a migration to pick up both.
    • getPublishedContent A/B shape + branch selector (pf-07): added an optional branchName query param — when set, only that published branch is resolved (still returned as a length-1 variants[]); omitted, behavior is unchanged. Embedded A/B references no longer serialize the control branch twice: the control tree is the top-level tree/properties, and abTest.variants now carries only the non-control variants. Consumers that previously read the control snapshot out of abTest.variants should read the top-level tree instead.

    Internal (no API change): resolveLinkPaths resolves internal link targets in parallel instead of serially on the published-content path (pf-04); listRoots uses a slim COUNT(DISTINCT roots.id) query for the total instead of re-running the full aggregate/enrichment join twice (pf-05); executeRootPruning deletes prunable commits in a single set-based statement instead of one round trip per commit (pf-11); executeMerge computes the merge base before taking the branch FOR UPDATE locks and reuses it only when the locked heads are unchanged, shortening lock hold time without changing merge-base semantics (pf-08); archiveAssets batches 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, listRoots search vs. the search endpoint, the default A/B edge resolver's per-request fetch, and the realtime per-recipient publish model).

  • #35 a26143a Thanks @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 from schema.generated; the core types now live in a dependency-free NOTIFICATION_TYPES constants module (shared with core-schema), and the wire type accepts 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 the ab-test / media-optimize hook modules, so importing @createcms/core/react (and wrapping your app in RealtimeProvider) no longer fails to bundle in an RSC app. JSDoc examples corrected to import the RSC-safe rendering API from @createcms/core/react/blocks in Server Components.
    • react-14: split the pure CMS_ERRORS data from the CMSError extends APIError class so the browser client no longer pulls the better-call server lib; added "sideEffects": false so bundlers can tree-shake it.
    • react-04: useOptimize now 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 runPluginInit failures are caught + logged (no more unhandled rejection); the docstring matches the synchronous-build reality.
    • react-09: useNotifications merges 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 an error instead of being swallowed.
    • react-10: CMSClientStore.listen returns its unsubscribe and uses atom.listen (was subscribe, 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 fromReference through 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 resolveWireName re-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.

  • #32 de20deb Thanks @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 &#123; commit: &#123; id, message, createdAt, createdBy }, ... } — one uniform shape instead of three divergent keys (commitId / newCommitId / mergeCommitId). executeMerge fast-forward now returns the resulting head commit instead of mergeCommitId: null. updateBlocks adds changed: boolean so 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/getRootBySlug now return the full RootListItem (counts + path), not a bare summary. createRoot/updateRoot/ duplicateBlock return the server-normalized slug/path; moveRoot/deleteRoot/ updateRoot return redirectsCreated (and moveRoot the effective sortOrder); deleteBlock returns deletedBlockIds; unpublishBranch returns unpublishedCommitId/unpublishedAt.
    • List consistency. getRootHistory now returns { commits, total, hasMore } (was { data, total, offset, limit }); listTemplates/listVariables gain limit/offset/search + { …, total, hasMore }. updateAssetStatus returns { updated, updatedIds, skipped }; uploadAssets/replaceAsset return the full asset row.
    • Type fixes. getRootHistory.createdAt and createSignedUpload.expiresAt are now Date (were an ISO string / epoch-ms). deleteTemplate/deleteVariable echo the deleted id ({ templateId }/{ variableId }) instead of { deleted: true }. getDiff/checkConflicts/checkDivergence are now GET (were POST).
    • Bug fix. listMentions now populates each message's mentions (was always []).
  • #46 d7c10bd Thanks @weepaho3! - Security-hardening pass (sec-01, sec-03 … sec-08). Several breaking changes that close real holes; all fail secure by default.

    • authMiddleware is now required (sec-01). createCMS throws at construction if it is missing, so auth can never be silently absent. To run with no auth on purpose (public/dev), pass the new allowAnonymous() export, which authorizes every request and is byte-identical to the old omitted- middleware behavior. The undocumented middleware alias for authMiddleware was removed.
    • Default allowedMimeTypes is now an explicit allowlist (sec-04): image/png, image/jpeg, image/webp, image/gif, video/mp4, video/webm, application/pdf — no image/*/video/* wildcards, so image/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 as image/png but containing SVG/HTML is rejected before it reaches storage. Upgraders who relied on wildcard formats (avif, heic, mov, …) must re-add them explicitly via media.allowedMimeTypes.
    • user.exposeColumns is now required when a user table is configured (sec-06). It previously defaulted to every column, which leaked password hashes / tokens through withUser. resolveUserConfig now 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). resolveTenantSlug returns 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/evil no longer matched the image prefix); SAFE_IDENTIFIER validation applied to every table/column identifier spliced into sql.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 fe5e9c5 Thanks @weepaho3! - Fix createcms --version reporting a stale hardcoded 0.0.1 — the CLI now reports the real package version (inlined from package.json at 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 under src/test-utils/, folded core/assets.ts into core/media/, and added the missing plugin READMEs plus a packages/cms/src layout guide in CONTRIBUTING.

  • #44 3da4035 Thanks @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 real setTimeout waits).
    • The Next revalidate webhook loads next/cache via a normal await import(...) instead of a Function-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).

  • #34 a8196e1 Thanks @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 string the wire actually delivers, via a Serialize<T> mapped type at the client boundary (server-side cms.api.* still returns real Dates). The useNotifications poll and live push are unified on the serialized shape, so a notification item never mixes string and Date.
    • ts-06: withUser-enrichable list endpoints (listRoots, listMergeRequests, listBranches, …) now type createdByUser off the user table instead of unknown, generalized from the notifications-only path.
    • ts-08: createCMSClient infers TPlugins (default []) so a no-plugins client no longer gets a Record<string, unknown> action index signature (client.anyTypo is now a compile error).
    • ts-02 / ts-03 / ts-04: definePluginSchema is curried (definePluginSchema<CoreTables>()({ … })) so the schema DSL is actually type-checked; new definePlugin (keeps a literal id + endpoint contributions, rejects typo'd keys) and defineUserConfig (checks exposeColumns against the real user table) helpers.
    • ts-09: config hook action is 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-types now type-checks the test/ suite too.
    • ts-17: MediaConfig / OptimizationConfig (and provider variants) are exported from the root. ts-14/15: replaced unprincipled tx as any / scope.where as any / as unknown as result-shaping casts with a typed DbOrTx alias, SQL[] condition arrays, and structurally-checked row builders.
    • Smaller: ts-07 stop exporting the unused InferPluginRealtimeEvents; ts-10 made the phantom $notifications type-only field unmistakable; ts-12 tightened DrizzleInstance's schema param; ts-18 corrected the CMSMiddlewareRequest JSDoc; ts-16 added type-check files for the client Serialize, block-property inference, the plugin schema DSL, and definePlugin.

0.2.11

Patch Changes

  • #29 ba326e3 Thanks @weepaho3! - Fix createNotificationRouter<typeof cms> typing when no plugin contributes notification types: the empty plugin registry resolved to Record<string, never> (a string index signature), which widened the known-type union to string and collapsed every resolver's n.meta to never. The registry's index signature is now stripped, so core (and app NotificationMetaMap) meta types correctly even with no notification-type plugins.

    Notification meta now also carries everything a deep link needs, so the synchronous createNotificationRouter never has to look anything up:

    • approvalRequested / approvalApproved / approvalRejected now include rootId and branchName (previously only branchId).
    • comment now includes rootId (it already had messageId/threadId), and the reply-path mention notification carries rootId too.

    CoreNotificationMetaMap is updated to match, so router resolvers get the new fields typed. No schema change — these are meta (jsonb) fields populated from data already joined at emit time.

0.2.10

Patch Changes

  • #26 ba13c71 Thanks @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 notification type that builds a deep link from the item's fields; each resolver gets meta narrowed to that type. Pass createNotificationRouter<typeof cms>(…) to type core and plugin-contributed types. A required fallback keeps routing total. Pure and client-side — no realtime peer, no server or schema change.
    • Plugin-extensible notification types — a new notificationTypes plugin seam (a Zod meta map): its keys fold into the notification_type enum at createcms generate (so a plugin persists its own type) and are inferred into typeof cms so the router types each plugin meta. The emit side (cms.notify / notificationService.notify) accepts plugin/app type strings. App-only custom types can also be typed by augmenting NotificationMetaMap.
    • Typed actorUserlistNotifications (with withUser) and useNotifications now type actorUser off your user config (a partial of the user-table row) instead of unknown, inferred straight from typeof cms.
    • Actor on the live push — the realtime notification event now carries actorUser, resolved server-side from the user config's exposeColumns (batched). The responsible user's name/avatar are available the instant a push lands, no second poll. actorUser is also passed to onNotification handlers.
  • #26 87b41c0 Thanks @weepaho3! - useNotifications now takes your typed createCMSClient instance directly — no as unknown as Parameters<typeof useNotifications>[0] cast. The hook's internal client shape brands query.withUser as true (matching the client's WithUserQuery) instead of plain boolean, so a real typed client is structurally assignable.

    userId is 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 from listNotifications) and opens the notif:<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 238208f Thanks @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: measurementanalytics_storage, marketingad_storage/ad_user_data/ad_personalization; necessary/functionality/experience ignored). 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 wires useConsentManager() in. (If c15t already emits Consent Mode commands onto window.dataLayer via 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 57029a5 Thanks @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 longer immutable) 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 answered 200 with an empty body). Both are fixed; the gate is now covered by tests that drive a real Request through cms.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; an image block property is a plain asset-id string on both the write and read paths.
  • #24 584d981 Thanks @weepaho3! - Add media.moveAssets — move assets between folders (and to the root).

    moveAssets({ assetIds, folderId }) sets the folder of one or more assets (folderId: null moves them to the root) — the missing write counterpart to moveFolder for drag-and-drop in a media library. Bulk-by-ids and scoped like updateAssetStatus: non-existent, out-of-scope, and archived ids are skipped and returned in skipped so 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 }. Throws FOLDER_NOT_FOUND for an unknown (or out-of-scope) target folder, ASSET_NOT_FOUND if none of the ids reference a live asset.

  • #24 584d981 Thanks @weepaho3! - Add media.replaceAsset — swap the bytes behind an existing asset, keeping its id.

    replaceAsset({ assetId, file }) replaces an asset's content while keeping its id (and folderId / 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_VARIANT if the target is itself a variant (replace the original instead), ASSET_NOT_FOUND, FILE_TOO_LARGE / INVALID_FILE_TYPE, or UPLOAD_FAILED (which leaves the asset unchanged).

  • #24 7cdf688 Thanks @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: the trackEvent ingest publishes each delta over ctx.realtime to the public ab:live:<testId> channel — decoupled from the analytics storage adapter, so it works with the Postgres adapter too — and useLiveResults rides the same RealtimeProvider connection as useNotifications.

    useLiveResults moves off the client proxy to its own subpath, @createcms/core/plugins/ab-test/live (which pulls in the optional @upstash/realtime peer, keeping the main A/B client peer-free). It applies live increments and reconciles against the absolute getResults aggregate on (re)connect; without realtime the stream never connects and the SSR initial (+ any getResults reconcile) stands.

  • #24 8c43239 Thanks @weepaho3! - Real-time notifications — automatic per-user push + a realtime-only useNotifications hook.

    When realtime is 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 call useNotifications(client, { userId }). It seeds list + unread count from the listNotifications poll, prepends live pushes de-duped by id, and the provider replays anything missed across a reconnect. useNotifications is realtime-only and type-requires client.notifications, so it only compiles when notifications are enabled. Without realtime there's no built-in polling hook — read the durable list yourself via client.notifications.listNotifications.

  • #24 5bdaa2c Thanks @weepaho3! - Add an optional, Upstash-backed realtime layer — and a notifications on/off switch.

    Configure realtime: { url, token } (your Upstash Redis credentials; @upstash/realtime + @upstash/redis are optional peers) to mount a shared /realtime SSE route. The route authenticates each connection via your authMiddleware (the session is read from the request cookie — EventSource can't send auth headers) and authorizes every channel against that identity: a user may subscribe only to their own private notif:<userId> channel (fails closed when unauthenticated), while ab: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: false on createCMS fully disables the notifications feature: the tables aren't generated, the routes never register, and client.notifications plus cms.notify are absent from the inferred types (a stray call is a compile error). Default: enabled. Use a literal false. notifications and realtime are independent — A/B live results can use realtime with notifications: false.

0.2.8

Patch Changes

  • #22 8ca638b Thanks @weepaho3! - Resolve image block properties to { id, slug } on the rendered read path.

    image properties store the asset id (ast_…). getPublishedContent (and getBlockTree unless raw) now resolves each one to { id, slug } — exactly as link and reference properties 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 null when 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. A raw read keeps the stored id for editor re-picking. Type: in resolved mode an image property now infers as ResolvedImage ({ id, slug } | null).

0.2.7

Patch Changes

  • #20 a78f391 Thanks @weepaho3! - Media: ready public url per asset, new listFolders, removed getAssetUrlAuthenticated.

    • listAssets (and the createSignedUpload / uploadAssets responses) now include a direct object url per asset (${publicUrl}/${objectKey}), built server-side — so internal/admin tooling (a media library) needs no URL helper and never has to know publicUrl itself. 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 of parentId (or the root-level folders when omitted), sorted by name. This is the missing read counterpart to createFolder/moveFolder/deleteFolder, so a media-library UI can navigate the folder tree.
    • Removed getAssetUrlAuthenticated (and the internal signGetObject helper). Uploaded objects are public-read, so the presigned-GET path was redundant — status is a visibility flag gating the public /media/asset/{slug} redirect, not a hard-privacy boundary. Serve assets through that gate; flip an asset to public with updateAssetStatus to serve it there.

0.2.6

Patch Changes

  • #15 0fcc2b4 Thanks @weepaho3! - Add a link block-property type — a language-aware link resolved to the current path at read time.

    A link is a discriminated union over kind: internal (an entry), external (a URL), email, or phone. The property config takes optional allowedKinds and allowedCollections. On a raw: false read (getBlockTree / getPublishedContent) every kind is normalised to an href: 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), with fragment / query appended; external/email/phone are static pass-throughs (url / mailto: / tel:). A gone / out-of-scope target resolves to href: null. With raw: true the 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 in contentUsages (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_target enum gains 'link'. Recreate the database.

0.2.5

Patch Changes

  • #13 341195c Thanks @weepaho3! - Add an opt-in getBlockTree({ includeReferencePreviews: true }) flag that returns a references sidecar 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 one getPublishedContent per reference (the N+1). Combine with raw: true to 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; existing getBlockTree callers are unaffected. Reuses the same resolution machinery as getPublishedContent (no duplication).

  • #13 341195c Thanks @weepaho3! - listBranches now returns hasPublications (a boolean) per branch, so callers can tell which branches are currently published without a separate query — analogous to hasPublications on listRoots. The value was already computed internally (it drives isDeletable); it is now exposed on each BranchListItem.

  • #13 341195c Thanks @weepaho3! - branchProtection can now be overridden per collection. A collection definition accepts its own branchProtection (a Partial<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 reusableBlock collection can set branchProtection: { protectPublishedBranches: false } to stay directly editable, while pages keep the global protection. The same applies to requireApprovalToMerge, requireApprovalBeforePublish, and requiredReviewers. Backward compatible: collections without an override behave exactly as before. (defaultBranchName and mergeStrategy remain global.)

  • #13 341195c Thanks @weepaho3! - Templates now participate in i18n / multi-tenant scoping and are applied server-side on createBlock.

    • Scoped templates. With the i18n plugin a template is per language; with multi-tenant it 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. createBlock now 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); duplicateBlock and updateBlocks do 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. createTemplate now rejects a template whose propertyKey does not exist on the block type, or is not a text property (string / richText), with the new TEMPLATE_PROPERTY_INVALID error — a string template can no longer be seeded into a number/select/image/reference field.

    Schema change, no backfill (beta): the templates unique index is demoted to non-unique, and the i18n / multi-tenant plugins add a language / tenant_slug column to templates. Recreate the database.

  • #13 341195c Thanks @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-provided VariableResolver on 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 core key unique 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 variables unique index is demoted to non-unique, and the i18n / multi-tenant plugins add a language / tenant_slug column to variables. Recreate the database.

0.2.4

Patch Changes

  • #11 bd91957 Thanks @weepaho3! - Fix a client/server path mismatch that made the variables, templates, and search namespaces 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 because cms.api.<ns>.<method>() invokes the handler directly and never exercises HTTP routing.

    • All variables endpoints now mount at /variables/<method> (e.g. /variables/listVariables, /variables/getVariable).
    • All templates endpoints now mount at /templates/<method> (e.g. /templates/listTemplates, /templates/getTemplate).
    • search now mounts at /search/search (matching client.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.)

  • #11 ff516e9 Thanks @weepaho3! - Add a configurable merge strategy for executeMerge.

    • mergeStrategy (CMS config) — 'fast-forward' (default) or 'merge-commit'. Controls how executeMerge integrates 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. true forces a merge commit, false forces 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.protectMain with branchProtection.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 assertBranchWritable guard on every content-mutation route, including revertBranch (which rewrites a published branch's head in place).
    • createRoot is never gated (it seeds a fresh, unpublished branch).
    • Still throws PROTECTED_BRANCH (403).

    Migration: rename protectMain: true to protectPublishedBranches: true. Note the new semantics — protection now follows the publication state, not the branch name.

0.2.3

Patch Changes

  • #9 be8c643 Thanks @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, and updateRoot); edits must go via a branch + merge. createRoot is exempt. Throws the new PROTECTED_BRANCH (403) error.
    • branchProtection.requireApprovalBeforePublish — make publishBranch always require approvals, not just when one was explicitly requested. Default false (existing conditional behavior).
    • branchProtection.requiredReviewers — minimum distinct approved reviewers for the merge / publish gates (default 1).
    • 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.requireApprovalToMerge defaults to false. Previously executeMerge ALWAYS required approvals; merges now succeed without approval unless you set requireApprovalToMerge: true. Set it explicitly to keep the prior gate.

  • #9 1d9bf1f Thanks @weepaho3! - Add a forceCommitMessage option to the CMS config. When true, every content mutation (createRoot / createBlock / updateBlock / deleteBlock / moveBlock / duplicateBlock / updateBlocks / updateRoot) requires a non-empty message — an empty or whitespace-only message is rejected with the new COMMIT_MESSAGE_REQUIRED error instead of falling back to an auto-generated default. Off by default, so existing behavior is unchanged.

  • #9 a45021a Thanks @weepaho3! - getRootHistory now 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.

    • commits gains branchId (links to the live branch — follows renames; no FK) and originBranchName (a deletion-proof name snapshot). Both are set at commit-write time.
    • getRootHistory resolves branch = 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_name column is NOT NULL; recreate the database. There is no migration of existing commit rows.

0.2.2

Patch Changes

  • #7 1cc595f Thanks @weepaho3! - Fix createTrackedBlocks(...).useTrackedBlock('myBlock') rejecting a block that declared events when the collection is used in its declared form (e.g. typeof myCollection). events is optional on BlockDefinition, so the FunctionalBlocks key-filter saw TEvents | undefined and filtered out every block ((X | undefined) extends Record<…> is false). The key-filter now NonNullables the events access, matching the value side — functional blocks are detected again and fire stays narrowed.

  • #7 9009209 Thanks @weepaho3! - Add an optional group string 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 shared as const for consistent, autocompleted group names. Mirrors the block-level group.

0.2.1

Patch Changes

  • #5 d9f6988 Thanks @weepaho3! - Add an optional group string 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 shared as const object for consistent, autocompleted group names across blocks.

  • #5 ff46dc7 Thanks @weepaho3! - Fix BlockProps<typeof collection, 'blockType'> failing to compile. The helper required a non-optional blocks field, but blocks is optional on CollectionDefinition, 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 via NonNullable, so BlockProps<typeof myCollection, 'myBlock'> works and the block name still autocompletes.

  • #5 060e6ae Thanks @weepaho3! - createBlocksMap now bundles the collection definition on the returned BlocksMap (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 separate collection prop. BlocksMap gained an optional type parameter that defaults to the erased collection type, so existing BlocksMap annotations and BlocksRenderer are unaffected.

0.2.0

Minor Changes

  • #3 f263c1f Thanks @weepaho3! - Block placement constraints. Collections now take a structure map that controls which blocks may be nested where, replacing the removed allowedChildBlocks field.

    • structure is 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 concrete accepts list together with excludes is a compile error. Block names autocomplete against the collection's blocks and typos are caught at compile time.
    • allowChildren is now enforced on the server: a non-container block (without allowChildren: true) rejects all children. The root always accepts children.
    • createBlock, moveBlock, and duplicateBlock enforce these rules and throw the new BLOCK_NOT_ALLOWED_IN_PARENT error; the visual editor reads the same rules for drop-zone gating, so the two can't diverge.

    Breaking: allowedChildBlocks is removed — express the same intent with structure (e.g. structure: { section: { accepts: ['featureItem'] } }). Blocks that hold children must now declare allowChildren: true.

0.1.1

Patch Changes

  • 028d2f2 Thanks @weepaho3! - Fix createcms generate failing on configs that use the idiomatic defineCollection / defineCollections / defineAuthMiddleware API. 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 6c9a64b Thanks @weepaho3! - Canvas.Root renders the store tree through a components map (plain or { _components }), with data-only edit anchors, a resolve layer, and interactive modes. components is required; children are the overlay slot, not the tree.

Patch Changes

  • #100 ee68b60 Thanks @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 set aria-required when the spec is required; README tables for keyboard, ARIA and focus; SSR hydration test for Editor.Root + Editor.Form.

  • #107 d9df66b Thanks @weepaho3! - Canvas.BlockToolbar and Canvas.InsertButton sit on the overlay. resolveInsertAt / useInsertTarget pick a line or box insert from measured rects and the parent layout flow.

  • #106 45a51e3 Thanks @weepaho3! - Canvas.Overlay portals an unstyled layer over the canvas. Selection, hover and field rings follow measured block and field rects via useBlockRect / useFieldRect.

  • #103 be7d148 Thanks @weepaho3! - Editor.FramePreview shows compiled HTML or Blob output in a double-buffered sandboxed iframe, with selectable anchors, stale-response discarding, and onIssues for relative URLs, missing hrefs and leftover editor anchors.

  • #102 c0e0db3 Thanks @weepaho3! - Editor.Preview renders a delayed raw store tree; Editor.Form autoScroll scrolls the focused block into view; useEditor().scrollTo scrolls a registered form or a [data-block-id] inside an optional container.

0.2.0

Minor Changes

  • #98 bea8451 Thanks @weepaho3! - Structure parts for @createcms/react/editor: Editor.OutlineItem (tree row with selection, arrow navigation, Alt+arrow reorder, Delete with an onDelete veto 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 with canMoveUp, canMoveDown, canHaveChildren, allowedChildTypes), typed in the factory as TypedBlockActions.

    BREAKING: useChildren(parentId) returns child refs { id, type, index } instead of a string array (the factory narrows type to the schema's block types). Read child.id where an id was used before.

0.1.1

Patch Changes

  • #96 e510694 Thanks @weepaho3! - Field parts for @createcms/react/editor: Editor.Field, Editor.FieldLabel, Editor.FieldControl, Editor.FieldDescription, Editor.FieldError and Editor.Form, a typed fields map on Editor.Root for per-kind controls, built-in headless controls for string, richText, number, boolean, date, select and list, and useMissingRequired().

0.1.0

Minor Changes

  • #86 91c75ca Thanks @weepaho3! - Scaffold the package: subpath entries @createcms/react/editor, @createcms/react/editor/canvas and @createcms/react/editor/cms, a shared editor context (Editor.Root, useEditorContext, Canvas.Root placeholder) and a local useRender / mergeProps / composeRefs copy for render props. Zero runtime dependencies (react as peer, react-dom as optional peer for the canvas entry).

  • #87 6b3d46f Thanks @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 a fillDefaults option), propertiesOf, groupFields, paletteItems, groupPaletteItems, isEmptyValue, validateField (stable error codes) and missingRequired, plus the EditorSchema, FieldKind, FieldSpecOf, FieldValueOf, SchemaField types.

  • #88 dcbbe62 Thanks @weepaho3! - Patch-based editor store: JSON operations (add, remove, move, update, load) with computed inverses via applyOp, createEditorStore with undo/redo of op groups (rapid updates of the same keys coalesce within 400 ms), applyRemote for foreign ops without history or onChange, per-user selection state, structural-hash dirty tracking, save, and the helpers flattenTree, serializeToTree, stableHash, createBlockId.

  • #89 9ce8d49 Thanks @weepaho3! - Editor.Root creates and owns the store (schema, defaultValue, onChange, onSave, genId, userId); useEditorSelector / useEditorStore (a useSyncExternalStore binding with shallow-equal slices); untyped hooks useEditor, useAnyBlock, useAnyField, useFields, useChildren, useSelection, useHistory, useSave, useDirty, usePalette; and the createEditor({ schema }) factory that returns the same hooks typed from the collection definition (TreeOf, BlockHandleOf, PropValueOf).

On this page