Skip to content

Add /theme-settings REST endpoint + CBT_Theme_Settings_Save service#843

Open
bph wants to merge 5 commits into
trunkfrom
feat/edit-theme-settings-endpoint
Open

Add /theme-settings REST endpoint + CBT_Theme_Settings_Save service#843
bph wants to merge 5 commits into
trunkfrom
feat/edit-theme-settings-endpoint

Conversation

@bph
Copy link
Copy Markdown
Contributor

@bph bph commented May 6, 2026

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.json payload from the modal, deep-merges it into the active theme's theme.json, and writes the result.

  • Route: POST /create-block-theme/v1/theme-settings
  • Capability: edit_theme_options
  • Service: CBT_Theme_Settings_Save in includes/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"]
}
  • Only present keys are written. Missing keys leave theme.json untouched.
  • settings.* is deep-merged (leaves replace, lists replace, missing parent keys are created).
  • customTemplates / templateParts are full-list replace (the modal owns the canonical list per the prototype's UI).
  • removedShadowDefaults is the only operational key. It's reified server-side into settings.shadow.defaultPresets: false plus the kept WP core shadow defaults re-registered under settings.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

  • No user-customization wipe. Unlike 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.
  • Shadow reification on the server. The alternative (client expands removedShadowDefaults before sending) would push WP-core-defaults knowledge into JS. Server-side keeps the contract simpler and mirrors the plugin's PHP-heavy style.
  • Validation is shape-only. Top-level keys are an allowlist; sub-keys under settings are 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.php covering:

  • Validation: known/unknown keys, type mismatches per top-level field
  • Sanitization: tags stripped from string leaves, booleans/numbers preserved, camelCase keys preserved
  • Merge: partial leaves siblings untouched, empty array clears, missing parent keys created, list replace, operational key dropped
  • Shadow reification: defaultPresets: false + kept defaults re-registered, idempotent across repeat calls, user customs preserved

Run with npm run test:unit:php:base. Full suite (110 tests) passes; lint clean.

Test plan

  • PHPUnit Test_CBT_Theme_Settings_Save (16 tests) green
  • Full PHP test suite (110 tests) green
  • composer run-script lint clean
  • Smoke test from a REST client (Postman / cURL) against the endpoint with a small payload
  • Confirm endpoint round-trips on a theme without a prior settings.color key

Out of scope

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.
@bph
Copy link
Copy Markdown
Contributor Author

bph commented May 7, 2026

The smoke test via cURL

Add a color to the theme palette

❯ curl -sS -u "$APP_USER:$APP_PASS" \
  -X POST "$WP_URL/wp-json/create-block-theme/v1/theme-settings" \
  -H "Content-Type: application/json" \
  -d '{
    "settings": {
      "color": {
        "palette": [
          { "slug": "brand", "name": "Brand", "color": "#0066ff" }
        ]
      }
    }
  }' | jq .status
"SUCCESS"
Screenshot 2026-05-07 at 10 09 44

Remove default shadow preset "Natural" "Sharp"

❯ curl -sS -u "$APP_USER:$APP_PASS" \
  -X POST "$WP_URL/wp-json/create-block-theme/v1/theme-settings" \
  -H "Content-Type: application/json" \
  -d '{
    "removedShadowDefaults": ["natural", "sharp"]
  }' | jq '.theme_json.settings.shadow'

Results

{
  "defaultPresets": false,
  "presets": [
    {
      "name": "Deep",
      "slug": "deep",
      "shadow": "12px 12px 50px rgba(0, 0, 0, 0.4)"
    },
    {
      "name": "Outlined",
      "slug": "outlined",
      "shadow": "6px 6px 0px -3px rgb(255, 255, 255), 6px 6px rgb(0, 0, 0)"
    },
    {
      "name": "Crisp",
      "slug": "crisp",
      "shadow": "6px 6px 0px rgb(0, 0, 0)"
    }
  ]
}
Screenshot 2026-05-07 at 10 41 28

Random Error Message

❯ curl -sS -u "$APP_USER:$APP_PASS" \
  -X POST "$WP_URL/wp-json/create-block-theme/v1/theme-settings" \
  -H "Content-Type: application/json" \
  -d '{ "totallyMadeUp": true }' | jq .

Result:

{
  "code": "cbt_invalid_payload",
  "message": "Unknown top-level key: totallyMadeUp",
  "data": {
    "status": 400
  }
}

@bph bph marked this pull request as ready for review May 7, 2026 09:07
…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.
@bph
Copy link
Copy Markdown
Contributor Author

bph commented May 7, 2026

Review feedback addressed

Pushed edf4431 with the three high-value fixes:

  • Context-aware sanitize()sanitize_text_field is now applied only to label-class fields (name/title/label/description); slug fields go through sanitize_key; everything else (CSS values like shadow, color, gradient) through wp_kses_no_null, which preserves whitespace, commas, and parens. Fixes silent corruption of multi-segment shadows like 6px 6px 0px -3px rgb(255, 255, 255), 6px 6px rgb(0, 0, 0).
  • Slug validation in validate()removedShadowDefaults slugs and the name field on customTemplates/templateParts must now survive sanitize_key() unchanged and be non-empty. Rejects malformed input early.
  • Write-failure handlingrun() now returns WP_Error('cbt_write_failed', status: 500) when the theme.json write fails. write_theme_file_contents() returns bool to support this. Previously a write failure was silently swallowed and the response said SUCCESS.

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:

  • No args schema on the route. Existing CBT endpoints (/save, /update, /clone, /create-variation, etc.) all register without args schemas. Adding it just to this one would create inconsistency. Suggest a follow-up that retrofits all endpoints — happy to open it.
  • No defined('ABSPATH') || exit guard. None of the other files under includes/create-theme/ have this guard either (theme-save.php, theme-json.php, theme-utils.php, etc.). Same consistency argument — fold into a plugin-wide cleanup if desired.
  • No class_exists guard around the class declaration. Same — none of the sibling classes use one.
  • status: "SUCCESS" response shape. Mirrors the existing convention in rest_save_theme, rest_update_theme, rest_create_blank_theme, etc. Diverging here would be the bigger inconsistency.
  • No payload size cap. WP server-level post_max_size and PHP's memory_limit already gate this. Belt-and-braces could be added but feels out of scope for the foundation PR.

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.
@bph
Copy link
Copy Markdown
Contributor Author

bph commented May 7, 2026

Round 2 review feedback addressed

Pushed `2e6b5ba`:

  • Entry-level structural validation — `customTemplates` entries now require non-empty `name` + `title`. `templateParts` entries require non-empty `name` + `area`. `name` continues to require slug format.
  • 4 new tests covering the rejection paths (missing title, missing area, missing name, empty required key) plus a positive case for a complete templatePart entry.

All-tests count: 28 in this suite (was 23), 122 overall, lint clean.

Cross-cutting items moved to a tracking issue

Opened #844 to track the items that apply uniformly to all CBT endpoints rather than just this one:

  • `args` schemas on REST routes
  • `defined('ABSPATH') || exit` guards
  • `class_exists` guards
  • Response shape audit (`status: "SUCCESS"` consistency)
  • `WP_REST_Request` type hints
  • Optional payload size caps
  • Cross-endpoint entry validation

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.

@bph bph requested a review from scruffian May 7, 2026 10:35
@scruffian
Copy link
Copy Markdown
Contributor

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 );
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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' );
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@bph
Copy link
Copy Markdown
Contributor Author

bph commented May 12, 2026

@scruffian Thanks Ben for the thorough review.

Claude and I pushed 980c89d with the fixes. Walking through each comment:

Merge semantics — adopted RFC 7396

Thanks for pointing out the partial merge-patch semantics but PHP's json_decode(..., true) indistinguishability between {} and [] was the footgun. Updated merge() to:

  • null deletes the key{"settings":{"color":{"custom":null}}} removes settings.color.custom. Deleting a missing key is a no-op.
  • Empty object is a no-op{"settings":{}} no longer wipes everything. Heuristic since PHP can't tell {} from []: if the existing value at the key is a list, empty payload clears; otherwise no-op. This preserves the "send empty list to clear" semantics for palette: [], customTemplates: [], etc., while closing the wipe-everything case for settings: {}.

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 write

Fixed. write_theme_file_contents now writes to theme.json.tmp and rename()s into place. The original file is untouched if either step fails.

wp_get_theme()->cache_delete()

Added. Called after a successful rename. clean_cached_data() only clears the JSON resolver's caches; this also busts WP's theme cache to match rest_save_theme.

Concurrent edits

Initially documented as a known limitation. Followed up in dd93c0e — see Round 4 below.

TEXT_FIELD_KEYS maintenance

Real trade-off. Added a comment on the constant spelling out why this is an allowlist: the inverse rule ("strip HTML by default, allowlist CSS-value fields") silently corrupts CSS for any new CSS-like field that gets added to theme.json, which is worse than missing a label sanitization update. The maintenance cost is "add a slug to this list when theme.json adds a new label field" — visible in git blame, low-frequency.


Round 4 — pushed dd93c0e

Three follow-up review comments addressed together since they share code paths.

Shared theme.json.tmp was unsafe under concurrent saves

The atomic write was right in shape but wrong in detail — a fixed staging path means two concurrent requests can collide on the temp file and one can rename the other's truncated payload as the new theme.json. Fix: write_theme_file_contents() now uses a per-request theme.json.<uniqid>.tmp filename. Each request cleans up only its own temp file. No more shared-staging-path race.

Concurrent read-modify-write silently dropped disjoint edits

The previous "last write wins" caveat for concurrent edits wasn't acceptable for a settings save — disjoint edits should compose. Fix: run() now holds an exclusive flock() on a sibling theme.json.lock around the read-merge-write block. Request B blocks until A finishes, then re-reads A's saved state and merges its own payload on top. Disjoint edits (A touches palette, B touches typography) now both persist.

Conflicting same-key edits remain last-write-wins — that's the genuine domain of If-Match/ETag, which would require a contract change (client tracks theme.json hash, server returns 409 on mismatch). Candidate for a follow-up once the modal lands and we see whether that case bites in practice. On distributed hosts where flock() isn't honored (some NFS configs) the serialization degrades to best-effort, documented in the class docblock.

If flock acquisition fails for any reason (rare), the endpoint surfaces WP_Error('cbt_lock_failed', status 503) so the client can retry rather than seeing silent success.

Payload shape validation gap (array_is_list())

Right call. The internal is_list() helper was used inside merge() to route the merge but wasn't gating validation, so a non-empty JSON list passed where an object is required (e.g., settings: [{"foo":1}]) would slip through and land a malformed shape in theme.json. Fix: validate() now explicitly rejects:

  • JSON lists in the settings object position
  • JSON objects in the customTemplates / templateParts / removedShadowDefaults list positions

Empty arrays remain accepted for either (PHP can't tell them apart; merge() handles them correctly).

merge() also defensively normalizes lists via array_values() so any sparse-keyed array can't slip through and serialize as a JSON object.


133/133 tests pass, lint clean.

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.
@scruffian
Copy link
Copy Markdown
Contributor

Thanks, looking good. A couple more things:

  1. We don't check if wp_json_encode fails - if it does then we still write the empty contents to the theme.json, breaking it.
  2. The theme.json.lock file lives in the theme directory and ships in exports (theme-settings-save.php:83). I think we should move it elsewhere so it doesn't send up shipping with the theme.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

REST endpoint + CBT_Theme_Settings_Save service

2 participants