Add /theme-settings REST endpoint + CBT_Theme_Settings_Save service#843
Add /theme-settings REST endpoint + CBT_Theme_Settings_Save service#843bph wants to merge 5 commits into
Conversation
Adds the server-side foundation for the upcoming Edit Theme Settings
modal. The new endpoint accepts a partial-theme.json payload, validates
its shape, deep-merges it into the active theme's theme.json, and writes
the result.
Payload shape:
- settings: deep-merged into existing settings (creates missing
parent keys; empty arrays clear leaves; lists replace wholesale)
- customTemplates / templateParts: array-replace top-level
- removedShadowDefaults: operational key reified into
settings.shadow.defaultPresets: false plus the kept WP core shadow
defaults re-registered as customs (idempotent; preserves
user-defined presets whose slugs do not collide)
Capability gate: edit_theme_options (matches existing endpoints).
Part of #836. Closes #837 once merged.
…e handling
- sanitize() is now context-aware: text fields (name/title/label/description)
go through sanitize_text_field; slug fields (slug/area, postTypes entries)
through sanitize_key; everything else (CSS values like shadow, color,
gradient) through wp_kses_no_null. Fixes silent corruption of multi-segment
shadow values, gradients, and other CSS that contained whitespace or commas.
- validate() now rejects malformed slugs in removedShadowDefaults and the
name field on customTemplates/templateParts (must survive sanitize_key
unchanged and be non-empty). Catches client-side typos and bad input
before the merge step.
- run() now returns WP_Error('cbt_write_failed', 500) when the theme.json
write fails. Previously a write failure (permissions, disk full,
read-only filesystem) would silently swallow the error and return SUCCESS
with the merged-but-not-persisted payload. write_theme_file_contents()
now returns bool to support this; @ silences a permission-denied warning
so the boolean check is the source of truth.
Tests: 7 new cases (3 sanitization round-trips for shadow/gradient/postTypes,
3 validation rejections for invalid slugs, 1 integration test that proves
the write-failure path returns WP_Error). 23/23 in the new suite, 117/117
overall, lint clean.
Review feedback addressedPushed
7 new tests (CSS round-trips for shadow/gradient/postTypes, slug rejections, write-failure integration). 23/23 in the new suite, 117/117 overall, lint clean. Soft-skipped items (with rationale)A few suggestions weren't applied in this PR — calling them out here for visibility:
These all feel like they belong in a separate "tighten the CBT REST API conventions" PR rather than mixed into the foundation work for Edit Theme Settings. |
customTemplates entries must have non-empty 'name' and 'title' fields. templateParts entries must have non-empty 'name' and 'area' fields. Catches malformed entries (missing required keys, empty strings) at the REST boundary rather than letting them land verbatim in theme.json. The 'name' field continues to require slug format since it's used as a filename.
Round 2 review feedback addressedPushed `2e6b5ba`:
All-tests count: 28 in this suite (was 23), 122 overall, lint clean. Cross-cutting items moved to a tracking issueOpened #844 to track the items that apply uniformly to all CBT endpoints rather than just this one:
Each is small and reviewable on its own; piecemeal-fixing them only on new files would create the inconsistency the reviewer is trying to prevent. #844 is the right venue. The architectural suggestion to split `run()` into `prepare()` + `persist()` for testability is debatable — the public helpers (`validate`, `sanitize`, `merge`, `reify_shadow_removals`) already give the same testability benefit without changing the public API or forcing PR 1's resolver to call two methods. Happy to revisit if a clear pattern emerges across the plugin. |
|
Some thoughts on this: The merge almost implements JSON Merge Patch (RFC 7396), but PHP can’t distinguish {} from [] after json_decode — so the documented “empty arrays replace” rule means a frontend that sends {"settings": {}} (intending “no change”) wipes all settings. There's also no way to delete — to drop a single palette entry you have to send the whole palette. Workable, but it forces every modal interaction to be read-modify-write. One option is to adopt merge-patch semantics (use null to delete, treat {} as “no-op”) and document it. An alternative is to invert the model and require the frontend to send full subtrees (no merge at all). The current middle ground is a bit confusing. |
| // rather than emitting a PHP warning that may be promoted to an | ||
| // exception. Callers must check the boolean return value. | ||
| // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged | ||
| $bytes = @file_put_contents( static::get_file_path_from_theme( 'theme.json' ), $theme_json ); |
There was a problem hiding this comment.
If this fails then we'll break everything. We might want to think about writing the file first with a temporary name, and then renaming.
There was a problem hiding this comment.
Two admin tabs with the modal open will silently overwrite each other — last write wins, no warning. Not sure how big of a problem that is in reality.
| // exception. Callers must check the boolean return value. | ||
| // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged | ||
| $bytes = @file_put_contents( static::get_file_path_from_theme( 'theme.json' ), $theme_json ); | ||
| static::clean_cached_data(); |
There was a problem hiding this comment.
rest_save_theme also calls wp_get_theme()->cache_delete() after writing, should we do that here too?
| * Keys whose string values are user-facing labels and benefit from | ||
| * `sanitize_text_field()` (HTML stripping, whitespace normalization). | ||
| */ | ||
| const TEXT_FIELD_KEYS = array( 'name', 'title', 'label', 'description' ); |
There was a problem hiding this comment.
This could become a maintenance burden. If theme.json adds more key (say caption), it silently falls through to wp_kses_no_null and HTML is no longer stripped.
Addresses review feedback on PR.
JSON Merge Patch (RFC 7396) semantics:
- null in the payload deletes the corresponding key on disk.
- Empty object (`{}`) is a no-op. PHP cannot distinguish `{}` from `[]`
after json_decode(true), so we use a heuristic: if the existing value
at the key is a list, an empty payload value clears the list; otherwise
it's a no-op. This closes the `{settings: {}}`-wipes-everything footgun
flagged in review while preserving the explicit-clear semantics for
lists like `palette: []`.
- Lists still replace wholesale — RFC 7396 doesn't define per-element
list patching. To drop one palette entry, send the new full palette.
Atomic write + theme cache:
- write_theme_file_contents now writes to a .tmp sibling and renames into
place. If the write or rename fails partway, the original theme.json is
untouched. Previously file_put_contents would truncate-then-write,
leaving a corrupt file on interruption.
- After a successful write, wp_get_theme()->cache_delete() is also called
(matching rest_save_theme); clean_cached_data alone only clears the
resolver's caches, not the WP theme cache.
Maintenance note on TEXT_FIELD_KEYS: spelled out the trade-off so future
contributors know what to update when adding new label-class fields.
Concurrent edits are still last-write-wins; documented as a known
limitation. ETag/If-Match handling can be a follow-up.
Tests: 6 new merge cases (null deletes, null-on-missing no-op, {} at top
level no-op, {} nested no-op, empty-for-existing-list clears, empty-for-
missing-list no-op). Write-failure test updated to chmod the directory
rather than the file (the atomic write fails at the temp-file creation,
not the rename). 34/34 in this suite, 128/128 overall, lint clean.
|
@scruffian Thanks Ben for the thorough review. Claude and I pushed Merge semantics — adopted RFC 7396Thanks for pointing out the partial merge-patch semantics but PHP's
Per-element list patching (your "drop a single palette entry" concern) isn't part of RFC 7396. Arrays are replaced wholesale under the spec. To remove one palette entry the client still has to send the full new palette. I hope that's the right read-modify-write boundary for now. Cited RFC 7396 in the class docblock and added 6 new tests covering the new semantics. Atomic writeFixed.
|
Addresses three review items together since they share code paths.
Concurrency:
- write_theme_file_contents() now uses a per-request temp filename
(uniqid suffix) so two concurrent saves can no longer collide on a
shared staging path. The previous shared 'theme.json.tmp' allowed
one request to rename another's truncated payload.
- run() now holds an exclusive flock() on a sibling 'theme.json.lock'
around the read-merge-write block. Disjoint edits from two clients
no longer overwrite each other; the second request sees the first's
saved state before merging its own payload on top. Remaining
limitation: conflicting same-key edits are still last-write-wins;
ETag/If-Match is the right follow-up if that becomes painful.
- flock() acquisition failure surfaces as WP_Error('cbt_lock_failed',
status 503), so the client can retry rather than seeing silent
success.
Payload shape:
- validate() rejects JSON lists in the 'settings' object position and
JSON objects in the 'customTemplates' / 'templateParts' /
'removedShadowDefaults' list positions. Uses the internal is_list()
helper that merge() was already using internally; the helper now
doubles as a contract assertion at the validation boundary.
- Empty arrays remain accepted for either shape (PHP cannot distinguish
{} from [] post json_decode(true); merge() handles them correctly).
- merge() defensively normalizes lists via array_values() so any
sparse-keyed array can't slip through and serialize as a JSON object.
Docblock updated to document the flock-based serialization and its
distributed-host caveat. Tests: 4 new (settings-as-list rejection,
customTemplates-as-object rejection, removed-shadow-defaults-as-object
rejection, empty-arrays-accepted) + 1 list-normalization smoke test.
Write-failure test broadened to accept either cbt_lock_failed or
cbt_write_failed since a read-only theme directory blocks both.
39/39 in this suite, 133/133 overall, lint clean.
|
Thanks, looking good. A couple more things:
|


Server-side foundation for the upcoming Edit Theme Settings modal. UI is unaffected by this PR — the prototype's Update button remains a no-op until the resolver is wired up in a follow-up.
Closes #837. Part of #836.
What this adds
A new REST endpoint that accepts a partial-
theme.jsonpayload from the modal, deep-merges it into the active theme'stheme.json, and writes the result.POST /create-block-theme/v1/theme-settingsedit_theme_optionsCBT_Theme_Settings_Saveinincludes/create-theme/theme-settings-save.php. Pure helpers (validate,sanitize,merge,reify_shadow_removals) are public for unit testing.Payload contract
{ "settings": { "color": { }, "spacing": { }, "typography": { }, "layout": { } }, "customTemplates": [], "templateParts": [], "removedShadowDefaults": ["natural", "sharp"] }theme.jsonuntouched.settings.*is deep-merged (leaves replace, lists replace, missing parent keys are created).customTemplates/templatePartsare full-list replace (the modal owns the canonical list per the prototype's UI).removedShadowDefaultsis the only operational key. It's reified server-side intosettings.shadow.defaultPresets: falseplus the kept WP core shadow defaults re-registered undersettings.shadow.presets. Centralizes WP-core-defaults knowledge in PHP and keeps the client-side resolver dumb.Response
{ "status": "SUCCESS", "theme_json": { } }Returning the merged theme.json lets the client refresh its theme record without a separate fetch.
Decisions worth flagging
CBT_Theme_Save(whose purpose is promoting user changes into theme files), this endpoint edits the theme's offering. Existing user styles can continue to override what's seen in the editor; users can clear them via Site Editor → Styles → Reset. Open to revisit if testing shows confusion. See [Tracking] Edit Theme Settings: phased implementation #836 for the full reasoning.removedShadowDefaultsbefore sending) would push WP-core-defaults knowledge into JS. Server-side keeps the contract simpler and mirrors the plugin's PHP-heavy style.settingsare not enumerated against the full theme.json schema. Keeps the endpoint forward-compatible with new theme.json keys without requiring a service bump.Tests
16 new PHPUnit tests in
tests/test-theme-settings-save.phpcovering:defaultPresets: false+ kept defaults re-registered, idempotent across repeat calls, user customs preservedRun with
npm run test:unit:php:base. Full suite (110 tests) passes; lint clean.Test plan
Test_CBT_Theme_Settings_Save(16 tests) greencomposer run-script lintcleansettings.colorkeyOut of scope