feat: Add editor content engine with visual editor - #820
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 6248fad0bc
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const [base] = useState<BlockUnknownData>(() => instance.data); | ||
| const [specs] = useState(() => blockFieldSpecs(entry.definition)); | ||
| const [formSchema] = useState(() => | ||
| buildFormSchemaFromSpec(blockFormSpec(entry), base), |
There was a problem hiding this comment.
Reset the properties form when discarding edits
When the selected block's data changes outside this form—for example after clicking Discard or after a save returns normalized canonical data—the instance prop updates, but base, the schema defaults, and AutoForm's values remain frozen from the original mount because the form is keyed only by the block identity. The controls therefore continue showing the discarded/stale values, and the next valid field change dispatches the entire stale form, reintroducing values the user explicitly discarded or overwriting the server's canonical values. Synchronize or remount the form when its baseline data changes.
Useful? React with 👍 / 👎.
| ); | ||
| } | ||
|
|
||
| if (max !== undefined && shipped.length > max) { |
There was a problem hiding this comment.
Count area children when validating default bounds
For a default containing Areas, shipped.length counts each Area as one node, while the save validator counts the blocks inside Areas and does not count the Area itself. Thus one Area with two children passes a zone with max: 1, and an empty Area can appear to satisfy min: 1; the page then serves a shipped default that the first unchanged save rejects. Compute the bounds from the same flattened block count used by zodBlockInstances.
Useful? React with 👍 / 👎.
6248fad to
25a85fc
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 25a85fcecf
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| { contentTypeId: id }, | ||
| ); | ||
| } | ||
| if (min !== undefined && max !== undefined && min > max) { |
There was a problem hiding this comment.
Validate min against the effective default maximum
When a caller declares field.blocks({ min: 201 }) without an explicit max, this guard is skipped because max is undefined. zodBlockInstances later applies its default maximum of 200, so every possible value violates either min or max and the content type can never create a row. Compare min against max ?? CONTENT_BLOCKS_DEFAULT_MAX and reject the unsatisfiable declaration here.
Useful? React with 👍 / 👎.
25a85fc to
b5b3b78
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b5b3b78816
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (fieldValue.kind === "blocks") { | ||
| const { allowed, max, min } = fieldValue; | ||
|
|
||
| if (max !== undefined && (max <= 0 || max > CONTENT_BLOCKS_ABSOLUTE_MAX)) { |
There was a problem hiding this comment.
Reject non-finite and fractional block bounds
Reject NaN and non-integer bounds here, as the current comparisons accept values such as field.blocks({ max: Number.NaN }) or max: 1.5. zodBlockInstances later enforces the limit using ordinary numeric comparisons, so NaN silently disables the maximum while fractional values produce undocumented effective limits; the corresponding min guard has the same problem. Validate both bounds with Number.isInteger/Number.isFinite, as the editable-page declaration already does.
Useful? React with 👍 / 👎.
b5b3b78 to
04e1337
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 04e1337ac3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const parsed = field?.safeParse(values[name]); | ||
|
|
||
| return parsed?.success ? [[name, parsed.data] as const] : []; |
There was a problem hiding this comment.
Allow optional date fields to be cleared
When a block declares a non-nullable optional field.dateTime(), clearing an existing value makes AutoFormDateTime emit null, but the optional ISO-date schema rejects null here. The field is consequently omitted from the patch, no update is dispatched, and the old timestamp remains in the block despite the control appearing cleared. Handle the cleared state as a property deletion (or normalize it to undefined) so optional dates can be removed.
Useful? React with 👍 / 👎.
04e1337 to
eca4bbc
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: eca4bbc7c6
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| assertContentZoneId(id); | ||
|
|
||
| const declared = blocks === undefined ? page?.resolveZone(id) : undefined; |
There was a problem hiding this comment.
Preserve the page allowlist when overriding zone blocks
When a ContentZone inside an EditablePage supplies an explicit blocks value but omits allowedBlocks, this condition prevents the page definition from being resolved at all, so allowed becomes undefined instead of inheriting the zone's declared allowlist. In edit mode the catalog and drop checks consequently permit every registered block, only for the page-layout API to reject the eventual save; in view mode the same override also bypasses the page's allowlist validation. Resolve the declared zone independently and override its blocks and allowlist separately.
Useful? React with 👍 / 👎.
| return { | ||
| allowedBlocks: zone.allowed, | ||
| blocks: Object.hasOwn(zones, zoneId) ? zones[zoneId] : zone.default, | ||
| }; |
There was a problem hiding this comment.
Propagate editable-zone bounds into editor state
For pages that declare min or max, the context passes only the allowlist and blocks into ContentZone, so the editor never learns either bound. A user can therefore delete below min or add/move/duplicate blocks beyond max; the UI continues to enable Save, but the page-layout API then returns 400 and the user must manually undo work. Include these bounds in the mount/state capabilities and enforce them across editor mutations.
Useful? React with 👍 / 👎.
| const pending = insertTarget?.zoneId === zoneId ? insertTarget : null; | ||
| const areaId = | ||
| request.areaId === undefined | ||
| ? (pending?.areaId ?? null) | ||
| : request.areaId; | ||
| const container: EditorContainerRef = { areaId, zoneId }; | ||
| const nodes = containerNodes(state, container); | ||
| if (nodes === null) return; | ||
|
|
||
| const instance = createBlockInstanceFor(entry); | ||
|
|
There was a problem hiding this comment.
Stop insertions when an area reaches its child limit
When the selected insertion target is an area that already has 50 children, this path still creates and inserts another block, and the drag path has the same missing capacity check. zodBlockInstances caps every area at AREA_CHILDREN_DEFAULT_MAX, so the resulting document can never be saved and the server rejects it after the editor has allowed the operation. Check the target container's capacity before both click insertion and drop/move resolution.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8707919ff7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| {panel === "blocks" ? ( | ||
| <AvailableBlocksPanel /> | ||
| ) : ( | ||
| <EditorPropertiesPanel /> |
There was a problem hiding this comment.
Preserve sidebar panel state with Activity
Switching from the block catalogue to a selected block conditionally unmounts AvailableBlocksPanel, so returning to the catalogue loses its search query, scroll position, and other internal UI state; entering Preview unmounts it as well. Keep the alternate panels mounted with React <Activity> so ordinary editor navigation restores the user's catalogue state as required by the repository convention.
AGENTS.md reference: AGENTS.md:L16-L23
Useful? React with 👍 / 👎.
| const EMPTY_UNSETS: ReadonlySet<ContentFormFieldSpec["kind"]> = new Set([ | ||
| "dateTime", | ||
| "slug", | ||
| ]); |
There was a problem hiding this comment.
Delete optional text values when cleared
When a block has a non-nullable optional text or textarea field with minLength > 0, clearing a previously stored value produces "", but these kinds are absent from EMPTY_UNSETS. The field schema rejects the empty string, BlockDataSync therefore dispatches no patch, and the old value remains stored while the control appears blank. Treat a cleared optional text control as deletion just like the optional date case.
Useful? React with 👍 / 👎.
| value: unknown, | ||
| seen: ReadonlySet<string>, | ||
| ): BlockAreaInstance | null => { | ||
| if (!isBlockAreaInstance(value) || seen.has(value.id)) return null; |
There was a problem hiding this comment.
Classify oversized areas as invalid
For legacy or externally supplied content, an area with more than AREA_CHILDREN_DEFAULT_MAX children passes readableArea and is mounted as editable content. The reducer merely prevents further growth, and the unsafe-save gate does not check the existing child count, so editing anything else leaves Save enabled even though zodBlockInstances will reject the area. Reject oversized areas here so they enter the explicit invalid-entry workflow instead.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4ee63a1fd2
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| return ( | ||
| zone !== undefined && | ||
| (zone.invalid.length > 0 || holdsRejectedBlock(zone)) | ||
| ); |
There was a problem hiding this comment.
Treat out-of-bounds mounted zones as unsafe
When existing content violates a newly tightened min or max—or a generic ContentZone is mounted with already out-of-bounds blocks—this predicate still considers the zone safe because it checks only malformed entries and rejected blocks. The current version now propagates min/max into EditorZoneState and prevents mutations from worsening those bounds, but editing an unrelated block property still enables Save, after which zodBlockInstances rejects the unchanged block count and the user cannot persist the repair-independent edit. Include the mounted zone's block count and configured bounds in the unsafe-save check.
Useful? React with 👍 / 👎.
| onCheckedChange={checked => { | ||
| field.onChange( | ||
| checked | ||
| ? Object.fromEntries(leaves.map(leaf => [leaf.name, null])) | ||
| : null, | ||
| ); |
There was a problem hiding this comment.
Restore valid defaults when enabling nullable groups
When a nullable group contains any required or otherwise non-nullable leaf, turning the group back on initializes every leaf to null. The group's Zod schema rejects that object, so BlockDataSync dispatches no patch: the UI appears enabled while the stored group remains null, and saving without manually repairing every invalid leaf loses the toggle after reload. Initialize enabled groups from valid leaf defaults/current values rather than assigning null indiscriminately.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3b0085f4e4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const saveBody = z.object({ | ||
| pageId: z.string().min(1).max(EDITABLE_PAGE_ID_MAX_LENGTH), | ||
| zones: zodZones, | ||
| }); |
There was a problem hiding this comment.
Require the layout version when saving
When two moderators load the same page and edit the same zone, this request carries no updatedAt or other expected version. The advisory lock only serializes the writes; after the first save commits, the second transaction reads that new row and then replaces the zone with its stale snapshot, silently discarding the first moderator's work. Include the version returned by GET in the save contract and reject or merge stale submissions.
Useful? React with 👍 / 👎.
| const nodes = insertAt(zone.nodes, action.index, action.area); | ||
| if (!zoneKeepsBounds(zone, nodes)) return state; |
There was a problem hiding this comment.
Enforce the top-level node cap when inserting areas
An area contributes zero to zoneKeepsBounds, so repeated area insertion can grow zone.nodes beyond CONTENT_BLOCKS_ABSOLUTE_MAX while this reducer continues accepting every action. The editor then considers the zone safe and enables Save, but zodContentNodes rejects the payload because its top-level array exceeds the absolute cap. Check the resulting top-level node count here, and in other operations that can grow or unwrap the root list.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 124c1c2cd7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| .flatMap(name => { | ||
| const field = formSchema.shape[name] as undefined | z.ZodType; | ||
| const parsed = field?.safeParse(values[name]); | ||
|
|
||
| return parsed?.success ? [[name, parsed.data] as const] : []; |
There was a problem hiding this comment.
Delete cleared optional leaves inside groups
When an optional non-nullable group leaf is cleared—for example field.dateTime() or text with minLength > 0—this parses the whole group with the cleared leaf still present as null or "". The group schema rejects that value, so no patch is dispatched and the previous stored value remains even though the control appears empty and the editor is not marked dirty. Normalize cleared optional leaves by deleting their nested keys before validating and dispatching the group.
Useful? React with 👍 / 👎.
| setLastSave(payload); | ||
|
|
||
| return zodLayout.parse(await response.json()); |
There was a problem hiding this comment.
Adopt the returned layout timestamp after saving
After every successful save, the parsed response is returned only to EditablePage, which adopts its zones internally; SettingsScreen never updates its layout prop. Consequently the header continues to say that nobody has rearranged the page after the first save, or displays the previous updatedAt, until a reload. Store the canonical response in local layout state as well as returning it to the editor.
Useful? React with 👍 / 👎.
| <p className="text-sm leading-relaxed text-pretty"> | ||
| {entry ? t("invalid_block") : t("block.issue.unknown_type")} | ||
| </p> | ||
| <p className="text-xs leading-relaxed opacity-80">{issue}</p> |
There was a problem hiding this comment.
Keep the invalid-block diagnostic at least 14px
The block validation detail is important body copy, but text-xs renders it below the repository's 14px minimum and makes the repair guidance unnecessarily difficult to read. Use text-sm or another size of at least 14px.
AGENTS.md reference: AGENTS.md:L71-L72
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a6b4d8a17b
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| return { | ||
| component: component as BlockComponent, | ||
| defaultVariant, | ||
| description, | ||
| fields: assertBlockFields(id, fields), |
There was a problem hiding this comment.
Reject unsatisfiable block field constraints
When a plugin defines a block field with contradictory constraints, such as field.number({ integer: false, min: 10, max: 1 }) or text whose minLength exceeds maxLength, assertBlockFields checks only the field kind and localization, so the definition and registry still load. createBlockInstanceFor then inserts data that fails the derived schema, and in these examples no value can ever satisfy it, leaving the editor's zone permanently unsafe and unsaveable. Validate the supported fields' shared constraints when defineBlock is called, as content-type definitions already do.
Useful? React with 👍 / 👎.
| max: bounds.max, | ||
| min: bounds.min, |
There was a problem hiding this comment.
Apply the default maximum to standalone zones
When a standalone <ContentZone> is edited without an explicit max, bounds.max remains undefined and the reducer treats the zone as having no block-count ceiling. This is the normal documented pairing with a field.blocks() value, whose write schema defaults to 200 blocks, so the editor can build a zone with more than 200 blocks and only discover on Save that the API rejects it. Pass CONTENT_BLOCKS_DEFAULT_MAX when neither the page nor the call site declares a maximum.
Useful? React with 👍 / 👎.
| const min = narrowed(declared?.min, explicit.min, Math.max); | ||
| const max = narrowed(declared?.max, explicit.max, Math.min); | ||
|
|
||
| if (min !== undefined && max !== undefined && min > max) { |
There was a problem hiding this comment.
Validate standalone zone bounds before mounting
For a standalone zone whose bounds come from computed configuration, values such as max={Number.NaN}, a fractional maximum, or a negative minimum pass through because this only compares min > max. In particular, NaN makes every fitsZoneMax/fitsZoneMin comparison fail, so even an otherwise valid mounted zone is marked unsafe and Save stays disabled without identifying the bad declaration; fractional values also create undocumented effective limits. Reject non-finite, non-integer, and out-of-range bounds here, consistently with editable-page and blocks-field declarations.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 484e051f86
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| fallback = ({ instance, reason }) => | ||
| createElement(DevNotice, { instance, reason }), | ||
| registry, | ||
| validate = "development", |
There was a problem hiding this comment.
Validate stored block shapes in production
When a plugin changes a block definition after records have already been stored—for example by renaming a required field—the default "development" mode disables the structural check in production, so the stale data is passed directly to the updated component. Components that use the required value as its declared type can then throw and take down the public page; use the structural check by default in production, or otherwise guarantee/migrate stored data before rendering.
Useful? React with 👍 / 👎.
| if ( | ||
| !incomingChanged || | ||
| zoneChanged(zone) || | ||
| wasSuperseded(zone, next.nodes) | ||
| ) { | ||
| return synced === zone ? state : withZones(state, { [next.id]: synced }); |
There was a problem hiding this comment.
Retain incoming content for Discard
When a dirty zone receives genuinely new blocks from a refetch or router invalidation, this branch preserves the local edit but drops the incoming snapshot entirely. If the user then clicks Discard, the reducer restores the old zone.initial; the mount effect does not run again because its input has not changed, so the editor remains on stale content and a subsequent edit/save uses an outdated baseline, producing a conflict against the core adapter. Preserve the pending incoming snapshot and adopt it when the local edits are discarded.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 68d550fcda
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const zone = state.zones[next.id]; | ||
|
|
||
| if (zone) return syncMountedZone(state, zone, next); |
There was a problem hiding this comment.
Check zone ids as own properties before mounting
A valid zone id may be "constructor", but on the first mount state.zones[next.id] resolves the inherited Object.prototype.constructor rather than undefined. The truthy value is passed to syncMountedZone as though it were an EditorZoneState, causing the editor to crash while reading its state fields. Use an own-property check or a null-prototype map before treating an id as already mounted.
Useful? React with 👍 / 👎.
| const next = boundedUpdate(zone, found.container, nodes => | ||
| nodes.filter((_, at) => at !== found.index), | ||
| ); | ||
| if (!next) return state; |
There was a problem hiding this comment.
Allow rejected blocks to be removed while repairing bounds
When a zone has exact bounds such as min: 1, max: 1 and its sole stored block becomes unknown or disallowed after a plugin or allowlist change, Save is unsafe but this removal path is rejected for falling below min; inserting a replacement is also rejected at max, and the unknown block has no editable properties. The zone is therefore impossible to repair in the editor. Permit removal of rejected blocks while keeping Save disabled until the minimum is restored, or provide an atomic replacement operation.
Useful? React with 👍 / 👎.
| if (min !== undefined && max !== undefined && min > max) { | ||
| return `Field "${name}" has min ${min} greater than max ${max}.`; | ||
| } | ||
|
|
||
| if (defaultValue === undefined) return null; |
There was a problem hiding this comment.
Reject integer ranges containing no integer
The new shared validator catches min > max, but a required block field such as field.number({ integer: true, min: 0.1, max: 0.9, required: true }) still returns no issue here. createBlockInstanceFor then seeds the value 1, which the derived Zod schema rejects, and no integer can ever satisfy that schema, leaving every inserted instance permanently unsafe. Reject integer ranges when Math.ceil(min) > Math.floor(max).
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 86dff5ad84
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| savePageLayout, | ||
| } from "./page-layout-service"; | ||
|
|
||
| const zodZones = z.record(z.string(), z.array(zodContentNode)); |
There was a problem hiding this comment.
Let layout reads carry malformed entries for repair
When a stored zone contains a legacy or corrupt non-node value, readPageLayout deliberately accepts the array and the editor can classify and remove the bad entry, but this shared response schema still requires every item to satisfy zodContentNode. The shipped zones page likewise parses the GET response with that schema, so a value such as "oops" throws in the loader before the invalid-entry UI can mount, making the documented repair path unreachable. Use a permissive unknown[] schema/type for read payloads while keeping PUT input and successful save responses strict.
Useful? React with 👍 / 👎.
| onInput: handleInput, | ||
| onKeyDown: handleKeyDown, | ||
| onPaste: handlePaste, | ||
| ref: setElement, |
There was a problem hiding this comment.
Compose the wrapped field element's ref
When a block wraps a native element that already has an object or callback ref in BlockField, injecting ref: setElement replaces that ref during edit mode. The block then observes null and any focus, measurement, or observer logic using the element stops working until edit mode ends. Compose the child's existing ref with setElement instead of overwriting it.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 511502fe8a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| for (const [leaf, leafValue] of Object.entries( | ||
| contentInnerFields(fieldValue), | ||
| )) { | ||
| assertBlockField(blockId, `${name}.${leaf}`, leafValue); |
There was a problem hiding this comment.
Reject nested groups instead of stopping after one level
When a block declares a group inside another group, this loop validates the inner group descriptor but never traverses its fields. A disallowed descriptor such as field.file() at the next depth therefore passes defineBlock, allowing file IDs to be stored in the block JSON without the foreign-key lifecycle that blockFieldKindRefusal is intended to protect; the shallow renderer check also never examines those grandchildren. Reject group-valued leaves, since groups are documented as non-nesting, rather than accepting an unchecked subtree.
Useful? React with 👍 / 👎.
| explicit: BlockAllowedSpec | undefined; | ||
| id: string; | ||
| }): BlockAllowedSpec | undefined => { | ||
| if (declared === undefined) return explicit; |
There was a problem hiding this comment.
Validate standalone zone allowlists before returning them
When a standalone ContentZone receives an empty or malformed runtime allowlist such as ["core/text"], this early return bypasses all validation. Every registered block then fails isBlockAllowed, so existing blocks are classified as rejected and the editor offers no insertable blocks, leaving the zone unusable without explaining the bad declaration. Validate entries and reject empty lists here, consistently with blocks fields and editable-page zones.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d32c5b2f06
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| }) => { | ||
| const [editing, setEditing] = useState(canEdit && openEditing); | ||
| const [showSidebar, setShowSidebar] = useState(true); | ||
| const [layout, setLayout] = useState(loadedLayout); |
There was a problem hiding this comment.
Synchronize layout state after loader refreshes
When this route's loader reruns without remounting the page, loadedLayout receives the new server snapshot but this state remains frozen at its initial value. EditablePage therefore never sees the incoming layout, so another moderator's changes remain invisible and starting another edit uses the stale baseline until a full remount or reload. Synchronize this local state when loadedLayout changes; the editor reducer already knows how to preserve dirty local edits while holding an incoming snapshot.
Useful? React with 👍 / 👎.
| )} | ||
| ref={setDropRef} | ||
| > | ||
| {area.children.length === 0 ? ( |
There was a problem hiding this comment.
Keep the add-block action available in populated areas
Once an area contains its first block, this branch renders only children, removing the sole control that calls setInsertTarget with the area's id. Users can still drag blocks into the area, but they can no longer target that area and click a catalog entry to append another block, despite that insertion workflow being available while the area is empty. Render an add action for non-full populated areas as well.
Useful? React with 👍 / 👎.
| if (!containerAcceptsBlock(state, { areaId: null, zoneId: candidate })) { | ||
| return false; | ||
| } |
There was a problem hiding this comment.
Evaluate insertion capacity on the requested container
When a zone has reached the 1,000-node root cap but one of its areas still has room, zoneAccepts rejects the request by testing the root container first. Consequently both a targeted catalog click and a catalog drag resolved into that area are discarded, even though adding an area child does not grow the root and containerAcceptsBlock(state, insertTarget) would accept it. Separate registry/allowlist checks from capacity and apply capacity only to the actual destination container.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: dcffefbbc8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| return; | ||
| } | ||
|
|
||
| if (!isBlockInstance(value) || seen.has(value.id)) { |
There was a problem hiding this comment.
Quarantine nodes with noncanonical envelopes
When a standalone editable zone receives a recognizable block with an extra top-level property, isBlockInstance accepts it and this classifier treats it as valid. Editing that block preserves the extra property, unsafeZoneIds allows Save, and write schemas based on strict zodContentNode then reject the payload. Validate the complete storage envelope here so these legacy or externally supplied nodes enter the invalid-entry repair workflow.
Useful? React with 👍 / 👎.
| onCompositionStart: handleCompositionStart, | ||
| onCompositionUpdate: handleCompositionStart, | ||
| onFocus: handleFocus, | ||
| onInput: handleInput, |
There was a problem hiding this comment.
Compose the wrapped element's event callbacks
When the native element wrapped by BlockField already defines onInput, onKeyDown, onFocus, or another callback injected here, cloneElement replaces the block's handler with the editor handler. Any block behavior tied to those callbacks silently stops working in edit mode; compose each existing callback with the editor callback instead of overwriting it.
Useful? React with 👍 / 👎.
e16d1c9 to
1cbeec1
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1cbeec11a8
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| className?: string; | ||
| id: string; | ||
| }) => ( | ||
| <ContentZone className={className} id={id} registry={pageWidgetsRegistry()} /> |
There was a problem hiding this comment.
Load the application registry for page widget zones
When a route uses the documented PageWidgetsZone abstraction without separately importing the generated blocks.gen.ts, pageWidgetsRegistry() falls back to a registry containing only core blocks. The core settings route does exactly this, and no application code imports the generated registry, so configured plugin widgets cannot be rendered or inserted there even though the API registry accepts them; previously stored plugin widgets also disappear from the page. Pass the application registry into this abstraction or ensure the generated registry is loaded by every route using it.
Useful? React with 👍 / 👎.
| if (!response.ok) return null; | ||
|
|
||
| const body: unknown = await response.json(); | ||
|
|
||
| return isLayoutPayload(body) ? body : null; |
There was a problem hiding this comment.
Withhold zone defaults when the layout read fails
When this request fails or returns a malformed payload, the query resolves to null, but PageWidgets passes that to EditablePage, whose empty layout state resolves every declared zone to zone.default. A transient 500 or invalid response therefore renders the shipped defaults in place of the unknown stored layout, contrary to the stated null contract and potentially showing obsolete content; distinguish a failed read from the legitimate server response that contains effective defaults so failed reads render no zones.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: db65e03a93
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const allowed = readAllowed(pageId, zoneId, declared.allowed); | ||
| const max = readCount(pageId, zoneId, "max", declared.max); | ||
| const min = readCount(pageId, zoneId, "min", declared.min); | ||
| const shipped = readDefault(pageId, zoneId, declared.default); |
There was a problem hiding this comment.
Validate shipped defaults against the zone allowlist
When a zone restricts allowed but its default contains another block type, including as an area child, defineEditablePage accepts the declaration because the subsequent checks cover only shape, IDs, and counts. The first layout GET then runs the default through zodBlockInstances, rejects the disallowed block, and returns 500 for every visitor; reject this mismatch while constructing the page definition.
Useful? React with 👍 / 👎.
| const pluginWidgets = readOptionalPluginModules<ResolvedBlocksModule>( | ||
| pluginIds, | ||
| WIDGETS_SUBPATH, | ||
| resolvePackageFile, |
There was a problem hiding this comment.
Watch plugins that do not yet export widgets
When a configured plugin has no resolvable widgets module at startup and one is created while vitnode dev is running, this discovery call returns neither a module nor a watch path. In the inspected configureServer logic, only the config, already-resolved files, and the host routes directory trigger regeneration, so the new compiled widgets.js never updates blocks.gen.ts until the app server is restarted or another watched input changes.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: da7f309cec
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| export const moderatorPermissionsQueryKey = () => | ||
| ["vitnode", "users", "permissions", "moderator"] as const; |
There was a problem hiding this comment.
Clear moderator permissions across identity changes
This identity-dependent response is cached under one global key, but removeUserIdentityQueries only removes file and device queries during sign-in, sign-out, and SSO transitions. If one user loads an editable page and another user subsequently signs in in the same browser, the loader can reuse the first user's cached permissions—especially because loadPageWidgets warms this query with staleTime: "static"—so the second user may incorrectly see or lose the Edit widgets action. Add this query family to the identity cleanup or scope its key by the current user.
Useful? React with 👍 / 👎.
| layout != null && | ||
| permission.plugin !== undefined && |
There was a problem hiding this comment.
Resolve omitted permission plugins before gating edits
When a plugin page uses the supported shorthand permission: { module, permission }, registerEditablePage resolves the missing plugin to the registering plugin on the server, but this browser path instead makes permission.plugin === undefined categorically non-editable. Consequently <PageWidgets> never offers editing—even to a root moderator—although the same user is authorized to save the page. Either carry the resolved plugin into the client definition or require/provide it explicitly at this abstraction boundary.
Useful? React with 👍 / 👎.
left a comment
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 750c2cb3bf
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| configResolved: async () => { | ||
| const packageNames = [PACKAGE_NAME, ...(await readPluginIds(appRoot))]; | ||
|
|
||
| directives = packageNames | ||
| .map(packageName => readBuildOutput(appRoot, packageName)) | ||
| .filter(directory => directory !== null) | ||
| .map(sourceDirective) |
There was a problem hiding this comment.
Refresh Tailwind sources after plugin-config changes
During vite dev, adding a plugin to vitnode.config.ts triggers vitNodePluginRoutes.configureServer to regenerate the plugin artifacts, but this plugin captures the configured package list only once in configResolved. The new plugin's components can therefore load while its build output is absent from Tailwind's @source directives, leaving its utility classes ungenerated until the developer restarts Vite. Recompute these directives when the configuration changes or force a server restart.
Useful? React with 👍 / 👎.
| return ( | ||
| <LeaveConfirmDialog | ||
| onConfirm={() => blocker.proceed?.()} | ||
| onOpenChange={open => { | ||
| if (!open) blocker.reset?.(); | ||
| }} | ||
| open={blocker.status === "blocked"} | ||
| /> |
There was a problem hiding this comment.
Prevent leaving while a layout save is in flight
If a user clicks Save and navigates before the PUT resolves, the editor remains dirty and this blocker opens the leave dialog, but it does not pass saveStatus, so the destructive leave action is enabled as though no save were running. The client fetch has no abort signal, so choosing “leave without saving” can still let that request commit after navigation. Pass the saving state here to disable leaving, or cancel the in-flight request before proceeding.
Useful? React with 👍 / 👎.
Content zones and blocks - Content node contract, block validation and shape checking, one definition of a stored node on every write path - Content Zones runtime, zone capacity limits and bounds validation, block variants and the public area renderer - `clearsToEmpty` on AutoForm fields, block field limits, refusal of content whose min can never be reached Visual editor - Visual edit mode with sidebar block insertion, properties panel, zone layout CRUD, drag and drop, inline field editing - Layout areas and variant controls, block reference system, canonical snapshot handling on save, invalid entry repair - Editable page widgets and layout management, edit-widgets entry in the user menu - Area spacing controls: `clampAreaSpacing`, `AreaSpacingSlider`, margin controls in the properties panel, numeric layout gap, and an area frame that shows open widget slots Supporting work - Tailwind sources plugin for scanning styles - Stage 4 editor i18n vocabulary, "blocks" renamed to "widgets" in the localization strings - Example plugin zones page built with AutoForm, callout and features widgets - Migrations: example block pages, example zones layouts, core page layouts - Docs under dev/widgets and built-in events - `apps/api` start script path corrected and `rootDir` set to `./src` - Obsolete test files removed and vitest configuration updated Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
e698931 to
cfae00b
Compare
left a comment
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cfae00b379
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| method: "get", | ||
| path: "/layout", | ||
| description: | ||
| "Read one registered page's effective layout: the zones it declares, each one the stored override or the shipped default. Public, because a visitor renders the page from it. An unknown page id is a 404.", |
There was a problem hiding this comment.
Protect layouts belonging to authenticated pages
The GET route returns every registered layout without checking the caller, but the newly registered core:settings page is rendered under defineAuthenticatedRoute in packages/vitnode/src/pages/settings/layout.tsx. An anonymous request to /pages/layout?pageId=core:settings can therefore read moderator-authored header and footer blocks intended for signed-in users. Add a per-page read-access policy and enforce it here rather than treating all editable pages as public.
Useful? React with 👍 / 👎.
…lockAreaStoredLayout`
left a comment
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2954edad93
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const full = | ||
| capacity !== null && !fitsZoneMax(capacity.max, capacity.blocks + 1); |
There was a problem hiding this comment.
Include the area's child cap in
full
When an area already contains AREA_CHILDREN_DEFAULT_MAX children but the zone-wide maximum still has capacity, full remains false, so the Add block action stays enabled. Clicking it cannot insert anything because containerAcceptsBlock rejects the target via areaHasRoom, causing the target to be cleared and making the visible action a no-op. Treat the area as full when its child cap is reached.
Useful? React with 👍 / 👎.
Improving Documentation
pnpm lint:fixto fix formatting issues before opening the PR.Description
What?
Why?