Skip to content

Theme JSON: Extract sanitization and optionally report what it drops - #84666

Draft
ramonjd wants to merge 8 commits into
trunkfrom
update/theme.json-validation-api
Draft

ramonjd wants to merge 8 commits into
trunkfrom
update/theme.json-validation-api

Conversation

@ramonjd

@ramonjd ramonjd commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

Proof of concept. The first commit moves sanitization into its own class without changing behaviour. The later commits add the reporting. To be split into separate PRs once the approach is agreed.

What

A way to ask WordPress what it will ignore in a theme.json structure before saving it:

$report = gutenberg_validate_theme_json( $theme_json, array( 'origin' => 'custom' ) );

Returns a list of diagnostics. Each has a code, a path and a message. An empty list means WordPress keeps the structure as given.

Why

Sanitization drops silently. Generated theme.json, AI output included, invents keys, puts them in the wrong place, references blocks that do not exist and gets types wrong. WordPress discards all of it on read. The tool believes it succeeded, the user gets styles that do nothing, and wrong-typed leftovers have caused front-end fatals (#84523, #84534).

With this, a tool validates before it saves and gets back the exact paths WordPress will ignore. See #81313 and schemas/README.md: a rejection should be explained where the author can see it.

How

  • Nothing is collected unless gutenberg_validate_theme_json() is running. Every existing construction on the front end and in the editor is unchanged. Outside that window each drop point is a single if.
  • Reported today:
    • unknown keys
    • wrong types, where WordPress already knows the type: booleans, objects, preset items that are not objects, preset slugs that are not a string or number (the Global Styles: Remove preset items of unexpected types to avoid fatals #84534 check)
    • unregistered blocks and block style variations
    • styles or settings that are not objects
    • viewport breakpoints WordPress replaced or normalized
    • a missing version
  • No method signatures change. WP_Theme_JSON_Gutenberg keeps its sanitization methods, which now call the new WP_Theme_JSON_Sanitizer_Gutenberg class.

Testing

npm run wp-env-test -- run cli wp eval 'echo wp_json_encode( gutenberg_validate_theme_json( array( "version" => 3, "styles" => array( "color" => "red", "blocks" => array( "core/nope" => array() ) ), "settings" => array( "lightbox" => array( "enabled" => "yes" ) ) ) ), JSON_PRETTY_PRINT );'

Expect three diagnostics: unregistered block, styles.color not an object, settings.lightbox.enabled not a boolean. Pass lib/theme.json and expect an empty list.

Limits

Reports what sanitization drops, nothing more. WordPress does not yet know the expected type of most values, so an array styles.css or a string typography.fluid passes silently. Teaching it those types is the next step, and this PR gives that check somewhere to report to. Kept does not mean rendered. Results depend on the blocks and block styles registered at call time.

Shape

Codes today:

  • theme_json_missing_version
  • theme_json_unknown_key
  • theme_json_unregistered_block
  • theme_json_unregistered_variation
  • theme_json_expected_object
  • theme_json_expected_boolean
  • theme_json_expected_scalar
  • theme_json_invalid_viewport

Small on purpose and not final. Rely on code and path. message is display text, never something to branch on. Anything structured a consumer needs later is added as a new key or a more specific code, never by changing these. The function stays in the plugin, with no core backport, until a real consumer has used it. From #73116: no positional results, no matching on message text, no numbered severity scale.

Example return object:

[
    {
        "code": "theme_json_expected_object",
        "path": [
            "styles",
            "color"
        ],
        "message": "\"styles.color\" must be an object."
    },
    {
        "code": "theme_json_expected_boolean",
        "path": [
            "settings",
            "lightbox",
            "enabled"
        ],
        "message": "\"settings.lightbox.enabled\" must be a boolean."
    }
]

Not in this PR

  • An Abilities API endpoint, core/validate-global-styles, over this function, separate from saving.
  • CSS safety diagnostics from remove_insecure_properties(), and moving is_safe_css_declaration() towards the style engine.

@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

🤖 PR meta 🤖

⚡ Performance

Show the results

Client side metrics exclude the server response time.

front-end-block-theme

Metric 68b5d9a trunk % Change
timeToFirstByte 53.25 ms +14.27% -2.54% 54 ms +6.94% -3.89% -1.39%
largestContentfulPaint 96 ms +2.08% -6.25% 96 ms +4.17% -6.25% 0%
lcpMinusTtfb 38.6 ms +12.82% -10.1% 41.5 ms +5.06% -17.35% -6.99%
wpBeforeTemplate 28.68 ms +15.38% -1.64% 28.24 ms +12.36% -0.85% 1.56%
wpTemplate 20.83 ms +5.23% -3.94% 20.55 ms +6.03% -3.02% 1.36%
wpTotal 49.5 ms +13.88% -2.32% 50.09 ms +7.39% -3.67% -1.18%
wpMemoryUsage 7.68 MB +0% -0% 7.65 MB +0% -0% 0.43%
wpDbQueries 16 +0% -0% 16 +0% -0% 0%

front-end-classic-theme

Metric 68b5d9a trunk % Change
timeToFirstByte 45.7 ms +4.49% -1.75% 46.15 ms +5.74% -2.71% -0.98%
largestContentfulPaint 100 ms +4% -0% 100 ms +4% -0% 0%
lcpMinusTtfb 55.1 ms +1.18% -1.45% 54.2 ms +3.14% -1.85% 1.66%
wpBeforeTemplate 26.34 ms +3.72% -1.33% 26.63 ms +6.68% -2.93% -1.09%
wpTemplate 16.22 ms +2.47% -2.1% 15.92 ms +6.91% -1.57% 1.88%
wpTotal 42.7 ms +4.85% -1.78% 43.21 ms +5.76% -2.52% -1.18%
wpMemoryUsage 6.37 MB +0% -0% 6.34 MB +0% -0% 0.54%
wpDbQueries 13 +0% -0% 13 +0% -0% 0%

media-processing

Metric 68b5d9a trunk % Change
mediaProcessingJpeg 403.81 ms +0.3% -0.17% 398.46 ms +0.99% -1.05% 1.34%
mediaProcessingAvif 6080.91 ms +0.2% -0.16% 6007.36 ms +0.11% -0.14% 1.22%
mediaProcessingJpegToAvif 4199.95 ms +0.24% -0.15% 4166.63 ms +0.29% -0.26% 0.8%

media-upload

Metric 68b5d9a trunk % Change
jpegUploadProcessing 1419.78 ms +37.86% -0.33% 1427.47 ms +0.29% -0.59% -0.54%
pngUploadProcessing 185.99 ms +29.23% -11.99% 182.17 ms +17.63% -9.58% 2.1%
largeJpegUploadProcessing 1428.38 ms +0.3% -0.53% 1413.02 ms +0.19% -1.08% 1.09%
multipleImageUploadProcessing 1689.63 ms +21.67% -7.2% 1600.09 ms +27.85% -0.54% 5.6%

post-editor

Metric 68b5d9a trunk % Change
serverResponse 495.48 ms +10.72% -5.24% 502.84 ms +6.51% -1.2% -1.46%
firstPaint 409.49 ms +27.42% -38.36% 487.4 ms +12.23% -38.96% -15.98%
domContentLoaded 1235.11 ms +0.79% -1.7% 1237.65 ms +2.57% -1.61% -0.21%
loaded 1236.2 ms +0.81% -1.67% 1238.93 ms +2.56% -1.61% -0.22%
firstContentfulPaint 522.03 ms +3.22% -12.65% 537.4 ms +2.22% -17.85% -2.86%
firstBlock 3520.82 ms +1.43% -2.16% 3558.05 ms +0.7% -1.21% -1.05%
type 20.8 ms +9.47% -3.27% 18.61 ms +9.08% -6.18% 11.77%
typeWithoutInspector 20.82 ms +4.27% -3.99% 19.57 ms +8.43% -2.3% 6.39%
typeWithTopToolbar 24.78 ms +7.26% -9.48% 24.56 ms +3.95% -3.95% 0.9%
typeContainer 8.37 ms +10.75% -9.92% 8.38 ms +11.58% -12.05% -0.12%
focus 118.55 ms +8.43% -4.95% 119.98 ms +9.72% -3.58% -1.19%
firstFocus 235.44 ms +0% -0% 230.53 ms +0% -0% 2.13%
selectAll 561.74 ms +3.75% -0.68% 537.78 ms +4.1% -1.68% 4.46%
listViewOpen 85 ms +11.85% -7.21% 76.84 ms +8.25% -5.75% 10.62%
inserterOpen 44.12 ms +4.44% -5.83% 42.48 ms +10.66% -11.51% 3.86%
inserterHover 13.23 ms +3.55% -10.96% 11.42 ms +11.3% -2.71% 15.85%
inserterSearch 8.25 ms +5.7% -5.33% 8.39 ms +8.46% -6.32% -1.67%
loadPatterns 694.27 ms +21.52% -1.09% 644.38 ms +9% -2.44% 7.74%
wpTotal 486.2 ms +10.62% -5.32% 493.33 ms +6.33% -1.25% -1.45%
wpMemoryUsage 14.71 MB +0% -0% 14.68 MB +0% -0% 0.24%
wpDbQueries 55 +0% -1.82% 55 +0% -1.82% 0%

site-editor

Metric 68b5d9a trunk % Change
serverResponse 380.54 ms +6.4% -2.52% 394.96 ms +1.86% -8.93% -3.65%
firstPaint 257.04 ms +15.65% -11.99% 251.03 ms +69.17% -7.58% 2.39%
domContentLoaded 1111.71 ms +1.01% -2.87% 1114.51 ms +2.08% -3.94% -0.25%
loaded 1112.77 ms +1.01% -2.87% 1115.56 ms +2.06% -3.94% -0.25%
firstContentfulPaint 468.92 ms +1.41% -8.34% 460.51 ms +1.34% -7.78% 1.83%
firstBlock 3908.68 ms +0.64% -1.81% 3880.72 ms +0.83% -2.34% 0.72%
type 16.67 ms +7.56% -1.26% 17.31 ms +1.1% -2.54% -3.7%
navigate 102.32 ms +7.84% -2.8% 105.19 ms +9.19% -2.2% -2.73%
loadPatterns 1178.68 ms +6.11% -7.42% 1152.61 ms +4.45% -2.46% 2.26%
loadPages 1016.85 ms +3.72% -1.75% 1039.12 ms +1.77% -6.07% -2.14%
wpTotal 371.3 ms +6.64% -2.62% 385.76 ms +1.97% -9.22% -3.75%
wpMemoryUsage 12.73 MB +0% -0% 12.70 MB +0% -0% 0.24%
wpDbQueries 44 +2.27% -0% 44 +2.27% -0% 0%

cdb6720 Run

🏁 Flaky tests

Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

Custom Completer: should allow newlines after multiple option completions in /test/e2e/specs/editor/various/autocomplete-and-mentions.spec.js, passed after 1 failed attempt.
Error: expect(received).toBe(expected) // Object.is equality

- Expected  - 1
+ Received  + 1

@@ -5,11 +5,11 @@
  <!-- wp:paragraph -->
  <p>🫐 test</p>
  <!-- /wp:paragraph -->

  <!-- wp:paragraph -->
- <p>🫐 test</p>
+ <p>~🍎 test</p>
  <!-- /wp:paragraph -->

  <!-- wp:paragraph -->
  <p>🫐 test</p>
  <!-- /wp:paragraph -->

Call Log:
- Timeout 5000ms exceeded while waiting on the predicate
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/autocomplete-and-mentions.spec.js:466:6
The navigation background color should apply to all navigation links including submenu and overlay backgrounds in /test/e2e/specs/editor/blocks/navigation-colors.spec.js, passed after 1 failed attempt.
Error: apiRequestContext.fetch: socket hang up
Call log:
  - → GET http://localhost:8891/wp-json/wp/v2/pages?per_page=100&status=publish%2Cfuture%2Cdraft%2Cpending%2Cprivate%2Ctrash
    - user-agent: Playwright/1.63.0 (x64; ubuntu 24.04) node/24.20 CI/1
    - accept: */*
    - accept-encoding: gzip,deflate,br
    - X-WP-Nonce: bfd27c3623
    - cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_da433b7faafd8180ebeb1624ceeeab7e=admin%7C1793155078%7CIk05FY05F5Xv57ykACTmfgYR6Sn367LtgZw38LPl5Z5%7C2e712a2d7152a02fb454fe0d383fd96add2cc87d5da3ee043d73ce8367ba6f1b; wp-settings-time-1=1792982334

    at RequestUtils.rest (/home/runner/work/gutenberg/gutenberg/packages/e2e-test-utils-playwright/src/request-utils/rest.ts:111:39)
    at RequestUtils.deleteAllPages (/home/runner/work/gutenberg/gutenberg/packages/e2e-test-utils-playwright/src/request-utils/pages.ts:44:27)
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/blocks/navigation-colors.spec.js:38:17
Undo restores the post selection when another synced entity is loaded (RTC WebSocket) in /test/e2e/specs/editor/collaboration/collaboration-undo-redo.spec.ts, passed after 1 failed attempt.
Error: expect(received).toMatchObject(expected)

- Expected  - 2
+ Received  + 2

  Object {
    "attributeKey": "content",
    "blockIndex": 0,
    "content": "abcdef",
-   "endOffset": 3,
-   "offset": 3,
+   "endOffset": 6,
+   "offset": 6,
  }

Call Log:
- Timeout 5000ms exceeded while waiting on the predicate
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/collaboration/collaboration-undo-redo.spec.ts:363:5
Undo restores the post selection when another synced entity is loaded in /test/e2e/specs/editor/collaboration/collaboration-undo-redo.spec.ts, passed after 1 failed attempt.
Error: expect(received).toMatchObject(expected)

- Expected  - 2
+ Received  + 2

  Object {
    "attributeKey": "content",
    "blockIndex": 0,
    "content": "abcdef",
-   "endOffset": 3,
-   "offset": 3,
+   "endOffset": 6,
+   "offset": 6,
  }

Call Log:
- Timeout 5000ms exceeded while waiting on the predicate
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/collaboration/collaboration-undo-redo.spec.ts:363:5
does not select blocks in the canvas preview when editing from the sidebar (Site Editor v2) in /test/e2e/specs/site-editor/navigation-sidebar-list-view.spec.js, passed after 1 failed attempt.
Error: expect(locator).toBeVisible() failed

Locator: getByRole('listbox', { name: 'Search results' })
Expected: visible
Timeout: 5000ms
Error: element(s) not found

Call log:
  - Expect "toBeVisible" getByRole('listbox', { name: 'Search results' }) with timeout 5000ms
  - waiting for getByRole('listbox', { name: 'Search results' })

    at LinkControl.useLinkControlSearch (/home/runner/work/gutenberg/gutenberg/test/e2e/specs/site-editor/navigation-sidebar-list-view.spec.js:396:33)
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/site-editor/navigation-sidebar-list-view.spec.js:179:3
should create paragraph on Enter in quote block in /test/e2e/specs/editor/blocks/list.spec.js, passed after 1 failed attempt.
TimeoutError: locator.click: Timeout 10000ms exceeded.
Call log:
  - waiting for getByRole('option', { name: 'List', exact: true })

    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/blocks/list.spec.js:751:67
should gradually multi-select in /test/e2e/specs/editor/various/multi-block-selection.spec.js, passed after 1 failed attempt.
Error: expect(received).toMatchObject(expected)

- Expected  - 1
+ Received  + 1

  Array [
    Object {
-     "name": "core/column",
+     "name": "core/paragraph",
    },
  ]

Call Log:
- Timeout 5000ms exceeded while waiting on the predicate
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/multi-block-selection.spec.js:1145:5
should initialize, edit, and update date field correctly in /test/e2e/specs/site-editor/page-list.spec.js, passed after 2 failed attempts.
Error: apiRequestContext.fetch: socket hang up
Call log:
  - → GET http://localhost:8891/wp-json/wp/v2/users?per_page=100
    - user-agent: Playwright/1.63.0 (x64; ubuntu 24.04) node/24.20 CI/1
    - accept: */*
    - accept-encoding: gzip,deflate,br
    - X-WP-Nonce: 05a079ceff
    - cookie: wordpress_test_cookie=WP%20Cookie%20check; wordpress_logged_in_da433b7faafd8180ebeb1624ceeeab7e=admin%7C1793155217%7C7freNGMn39Ae3FULJisBg6Mc6Zy77PmIMURN0kFzQ5t%7C5587b0823a326ae50f8c5252b5769ea99d85028888cbddb6fc74fa59fc83bf7b; wp-settings-time-1=1792982801

    at RequestUtils.rest (/home/runner/work/gutenberg/gutenberg/packages/e2e-test-utils-playwright/src/request-utils/rest.ts:111:39)
    at RequestUtils.listUsers (/home/runner/work/gutenberg/gutenberg/packages/e2e-test-utils-playwright/src/request-utils/users.ts:34:30)
    at RequestUtils.deleteAllUsers (/home/runner/work/gutenberg/gutenberg/packages/e2e-test-utils-playwright/src/request-utils/users.ts:106:44)
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/site-editor/page-list.spec.js:376:23

should merge a following sibling list into the outermost list with Delete from a nested item (#77245) in /test/e2e/specs/editor/blocks/list.spec.js, passed after 1 failed attempt.
Error: expect(received).toMatchObject(expected)

- Expected  - 2
+ Received  + 2

@@ -8,11 +8,11 @@
          "innerBlocks": Array [
            Object {
              "innerBlocks": Array [
                Object {
                  "attributes": Object {
-                   "content": "b‸",
+                   "content": "b",
                  },
                  "name": "core/list-item",
                },
              ],
              "name": "core/list",
@@ -25,11 +25,11 @@
    },
    Object {
      "innerBlocks": Array [
        Object {
          "attributes": Object {
-           "content": "1",
+           "content": "‸1",
          },
          "innerBlocks": Array [
            Object {
              "innerBlocks": Array [
                Object {

Call Log:
- Timeout 5000ms exceeded while waiting on the predicate
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/blocks/list.spec.js:2854:41
should remove an anchored list when deleting its last item in /test/e2e/specs/editor/blocks/list.spec.js, passed after 1 failed attempt.
Error: expect(received).toMatchObject(expected)

- Expected  - 1
+ Received  + 1

@@ -5,11 +5,11 @@
      },
      "innerBlocks": Array [
        Object {
          "attributes": Object {
            "anchor": "item",
-           "content": "x",
+           "content": "",
          },
          "name": "core/list-item",
        },
      ],
      "name": "core/list",

Call Log:
- Timeout 5000ms exceeded while waiting on the predicate
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/blocks/list.spec.js:2961:41
should select by dragging into nested block in /test/e2e/specs/editor/various/multi-block-selection.spec.js, passed after 1 failed attempt.
Error: expect(received).toEqual(expected) // deep equality

- Expected  - 1
+ Received  + 0

  Array [
    1,
-   2,
  ]

Call Log:
- Timeout 5000ms exceeded while waiting on the predicate
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/multi-block-selection.spec.js:568:5
should update the URL from the last navigation if only varies in the URL fragment in /test/e2e/specs/interactivity/router-navigate.spec.ts, passed after 1 failed attempt.
Error: expect(locator).toHaveText(expected) failed

Locator:  getByTestId('title')
Expected: "Link 1"
Received: "Main"
Timeout:  5000ms

Call log:
  - Expect "toHaveText" getByTestId('title') with timeout 5000ms
  - waiting for getByTestId('title')
    14 × locator resolved to <h2 data-testid="title">Main</h2>
       - unexpected value "Main"

    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/interactivity/router-navigate.spec.ts:160:25
shows block preview when hovering over block in inserter in /test/e2e/specs/editor/various/inserting-blocks.spec.js, passed after 1 failed attempt.
Error: expect(locator).toBeInViewport() failed

Locator: locator('.block-editor-inserter__preview')
Expected: in viewport
Timeout: 5000ms
Error: element(s) not found

Call log:
  - Expect "toBeInViewport" locator('.block-editor-inserter__preview') with timeout 5000ms
  - waiting for locator('.block-editor-inserter__preview')

    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/inserting-blocks.spec.js:819:5
shows title and sync status while creating a new synced pattern in the post editor in /test/e2e/specs/editor/various/post-summary/post-summary.spec.js, passed after 1 failed attempt.
Error: expect(locator).toBeVisible() failed

Locator: getByRole('dialog', { name: 'Create pattern' })
Expected: visible
Timeout: 5000ms
Error: element(s) not found

Call log:
  - Expect "toBeVisible" getByRole('dialog', { name: 'Create pattern' }) with timeout 5000ms
  - waiting for getByRole('dialog', { name: 'Create pattern' })

    at createPatternFromModal (/home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/post-summary/post-summary.spec.js:179:26)
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/post-summary/post-summary.spec.js:101:4

cdb6720 Run

@ramonjd
ramonjd force-pushed the update/theme.json-validation-api branch from e94a3f3 to 7d044ae Compare October 15, 2026 05:14
@ramonjd

ramonjd commented Oct 15, 2026

Copy link
Copy Markdown
Member Author

Testing

Check out this branch and fire up a terminal in the root of the project (assumes you're running wp-env)

  1. Copy the command and paste it into the terminal. Don’t press enter yet.
  2. Copy a JSON block.
  3. Press enter.

Command

pbpaste | npm run wp-env -- run cli wp eval 'echo wp_json_encode( gutenberg_validate_theme_json( json_decode( stream_get_contents( STDIN ), true ), array( "origin" => "custom" ) ), JSON_PRETTY_PRINT );'

Unknown keys at every level

{
  "version": 3,
  "settings": {
    "color": { "palette": [], "invention": true },
    "typografy": { "fontSizes": [] }
  },
  "styles": {
    "typography": { "fontSize": "1rem", "fontSizes": [ "1rem", "2rem" ] },
    "colour": { "text": "red" }
  },
  "styels": []
}

Wrong types

{
  "version": 3,
  "settings": {
    "lightbox": { "enabled": "yes" },
    "typography": { "fluid": "true" }
  },
  "styles": {
    "color": "red",
    "css": [ "body { margin: 0 }" ],
    "elements": { "link": { "css": { "rules": "text-decoration: none" } } },
    "spacing": { "padding": { "top": [ "1rem" ] } }
  }
}

Unregistered block and variations

{
  "version": 3,
  "styles": {
    "blocks": {
      "acme/hero": { "color": { "background": "#000" } },
      "core/button": { "variations": { "ghost": { "color": { "text": "#fff" } } } }
    },
    "variations": { "ghost": { "color": { "background": "#333" } } }
  }
}

Invalid viewport and root responsive key

{
  "version": 3,
  "settings": {
    "viewport": { "mobile": "50%", "tablet": "calc(100vw - 1px)" }
  },
  "styles": {
    "@mobile": { "typography": { "fontSize": "14px" } }
  }
}

No version

{
  "settings": { "color": { "palette": [] } },
  "styles": { "color": { "text": "#000" } }
}

@ramonjd ramonjd added [Type] Experimental Experimental feature or API. [Package] Theme /packages/theme labels Oct 16, 2026
@ramonjd
ramonjd force-pushed the update/theme.json-validation-api branch from 7d044ae to 293fc19 Compare October 16, 2026 04:42
@github-actions github-actions Bot removed the [Package] Theme /packages/theme label Oct 16, 2026
@ramonjd ramonjd changed the title Theme JSON: Extract sanitization and report what it drops Theme JSON: Extract sanitization and optionally report what it drops Oct 16, 2026
Moves the theme.json sanitization logic out of WP_Theme_JSON_Gutenberg
into a new WP_Theme_JSON_Sanitizer_Gutenberg class, without changing
behaviour or the WP_Theme_JSON_Gutenberg interface.

Moved as-is: sanitize(), remove_keys_not_in_schema(),
schema_in_root_and_per_origin(), sanitize_viewport_settings() and its
two private helpers, resolve_custom_css_format() and
convert_custom_properties(). The method bodies are unchanged apart from
reading constants through WP_Theme_JSON_Gutenberg:: instead of static::.

WP_Theme_JSON_Gutenberg keeps sanitize(), remove_keys_not_in_schema()
and schema_in_root_and_per_origin() as one-line delegations with their
existing visibility, so the constructor, remove_insecure_properties()
and any subclass are untouched. The two remaining callers of
sanitize_viewport_settings() now call the sanitizer directly.
Adds an optional diagnostics collector to WP_Theme_JSON_Sanitizer_Gutenberg
and a static WP_Theme_JSON_Gutenberg::get_sanitization_diagnostics() entry
point that runs the constructor's migration, unwrapping and sanitization
steps on a raw theme.json structure and returns a list of diagnostics
instead of an object.

sanitize() and remove_keys_not_in_schema() take an optional by-reference
array. When it is null, which is what every existing caller passes, the
walk is unchanged. When an array is given, a diagnostic is appended at
each point a value is dropped or coerced: unknown keys, wrong types for
the boolean markers and object schemas, unregistered blocks and block
style variations, non-object styles or settings subtrees, viewport
breakpoints replaced or normalized, and a missing version. The `$schema`
key is exempt because every theme.json file carries it for editor tooling.

Each diagnostic has `code`, `category` (structure or type), `severity`
(dropped or coerced), `path` into the structure as given, `message` and
`data`. Unregistered names are reported before the walk and removed the
same way the walk would remove them, which the new equivalence test pins.
…eter

Replaces the by-reference `$diagnostics` parameter on sanitize() and
remove_keys_not_in_schema() with a static collector on the sanitizer
that is null unless a `collect( callable )` callback is running.

Call sites now pass a code, a path and optional data. Category and
severity come from a DIAGNOSTIC_CODES table and the translated message
is built inside add_diagnostic(), so outside collect() a drop costs one
null check and no gettext call. Previously the sprintf( __() ) arguments
were evaluated before the early return on every dropped key, which added
a translation lookup per drop to ordinary construction.

collect() restores the previous collector in a finally block, so nested
calls and exceptions leave the state clean. get_sanitization_diagnostics()
on the theme.json class wraps its pipeline in collect().
Removes WP_Theme_JSON_Gutenberg::get_sanitization_diagnostics() and adds
gutenberg_validate_theme_json( $theme_json, $args ) next to the other
public theme.json functions. It constructs a theme.json object inside the
sanitizer's collect() window and returns what the pipeline dropped or
coerced, so the theme.json class keeps only its delegating shims.

The two reports the entry point used to make itself now come from the
steps that drop the data: migrate() reports a missing version, or a
non-object structure, where it replaces the input, and
unwrap_shared_block_style_variations() reports a shared variation no
block registers where it discards it. Outside collect() both are a null
check.
The preset check added in #84534 removes items that are not objects, or
whose slug is not scalar, without a trace. Route it through the
diagnostics collector so each removed item is reported as
theme_json_invalid_type at its index, or at its slug.

The filter moves into remove_invalid_preset_items(), which keeps the
same acceptance rule and re-indexes the list as before.
Removes the `category` key from each diagnostic. It was derivable from
the code and nothing read it, so it was one more thing to keep stable
for no benefit. DIAGNOSTIC_CODES now maps each code to its severity.

Documents which keys callers can rely on: `code`, `path` and `severity`.
`data` is documented per code and may gain keys, and `message` is
display text. The function stays in the plugin until the shape has been
exercised by real callers.
@ramonjd
ramonjd force-pushed the update/theme.json-validation-api branch from 77162fa to eecd291 Compare October 26, 2026 01:54
Each code already implies what happened to the value, so `severity`
repeated information the consumer can derive, and was one more key to
keep stable. The viewport message still says the breakpoints were
replaced. Diagnostics are now `code`, `path`, `data` and `message`.
Nothing read it, and adding a key later is compatible while removing one
is not. Each diagnostic is now `code`, `path` and `message`.

The message builder used `data['expected']` to pick its wording, so
`theme_json_invalid_type` becomes three codes that say it themselves:
`theme_json_expected_object`, `theme_json_expected_boolean` and
`theme_json_expected_scalar`. The type-name helper goes with the key it
filled.
@ramonjd ramonjd self-assigned this Oct 26, 2026
@ramonjd ramonjd added [Feature] Themes Questions or issues with incorporating or styling blocks in a theme. Global Styles Anything related to the broader Global Styles efforts, including Styles Engine and theme.json labels Oct 26, 2026
@ramonjd
ramonjd requested a balanced review from Copilot October 26, 2026 02:45

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

This branch has not been deployed

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

Labels

[Feature] Themes Questions or issues with incorporating or styling blocks in a theme. Global Styles Anything related to the broader Global Styles efforts, including Styles Engine and theme.json [Type] Experimental Experimental feature or API.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants