diff --git a/includes/Classifai/Features/Prompts/ContentGeneration/return-format.php b/includes/Classifai/Features/Prompts/ContentGeneration/return-format.php index 893387b08..e8e796652 100644 --- a/includes/Classifai/Features/Prompts/ContentGeneration/return-format.php +++ b/includes/Classifai/Features/Prompts/ContentGeneration/return-format.php @@ -2,48 +2,70 @@ /** * Return-format instruction appended to the system message for the Content Generation feature. * - * Describes the WordPress block markup that the model should emit. + * Instructs the model to emit a constrained JSON "BlockTree" structure, which + * ClassifAI converts to valid WordPress block markup client-side (rather than + * asking the model to hand-author fragile `` markup). * * @package Classifai */ // phpcs:disable Squiz.PHP.Heredoc.NotAllowed, PluginCheck.CodeAnalysis.Heredoc.NotAllowed return <<<'INSTRUCTION' -The content returned should be valid WordPress block markup as described below, using elements like paragraphs and headings where appropriate. Be selective on the elements you use, defaulting to paragraphs. Please check the content before returning to ensure each element has proper opening and closing block markup and HTML tags and any required block attributes. Ensure elements don't nest inside each other, i.e. don't put a paragraph inside another paragraph or a list within a paragraph. Don't start the content with a heading, start with a paragraph. - -Markup available to use; don't use any other blocks, even if requested: - -

CONTENT

- - - -

CONTENT

- - - -
CONTENT
CONTENT
- - - -
-

CONTENT

-
- - - -

QUOTE

AUTHOR
- - - - - - - -
    -
  1. CONTENT
  2. -
- +Return the content as a single JSON object describing a flat WordPress "block tree". Do not return HTML, Markdown, block comment markup, code fences, or any prose. Output only the JSON object. + +## JSON Structure + +Output valid JSON matching this structure: + +interface BlockTree { + root: string; // Key of the root element + elements: Record; // Map of key -> element +} + +interface BlockElement { + key: string; // Unique identifier, matching its key in `elements` + type: string; // Block name, e.g. "core/paragraph" + props: Record; // Block attributes (use {} when there are none) + children?: string[]; // Ordered keys of child elements (for blocks that hold inner blocks) + parentKey?: string; // Key of the parent element +} + +Rules: +- Every element's `key` must match its key in `elements`. +- The root must be a single element. To return multiple top-level blocks, make the root an element of type "fragment" with no props and list the top-level block keys in its `children`. "fragment" is a virtual wrapper only; it produces no markup of its own. +- Omit `children` for blocks that hold no inner blocks. +- Default to paragraphs; be selective with other blocks. Do not start the content with a heading; start with a paragraph. +- Use only the block types listed below; do not use any other blocks, even if requested. + +## Available blocks + +- core/paragraph — props: { "content": string }. +- core/heading — props: { "content": string, "level": 2 or 3 }. +- core/list — props: { "ordered": boolean (optional, default false) }. Supports children: core/list-item. +- core/list-item — props: { "content": string }. Must be inside core/list. +- core/quote — props: { "citation": string (optional) }. Supports children: core/paragraph. +- core/pullquote — props: { "value": string, "citation": string (optional) }. +- core/table — props: { "body": [ { "cells": [ { "content": string, "tag": "td" } ] } ] }. Each row is an object with a "cells" array; each cell has "content" and "tag" ("td"). +- core/separator — props: {}. +- core/image — props: { "url": string, "alt": string (optional), "caption": string (optional) }. +- core/group — props: { "layout": { "type": "constrained" } }. Supports children: any blocks. Use to group related blocks. +- core/columns — props: {}. Supports children: core/column (two or more). +- core/column — props: { "width": string (optional, e.g. "50%") }. Must be inside core/columns. Supports children: any blocks. +- core/buttons — props: {}. Supports children: core/button. +- core/button — props: { "text": string, "url": string (optional) }. Must be inside core/buttons. + +## Block requirements + +- core/list: must use core/list-item children for list items; the deprecated "values" attribute is not supported. +- core/quote: place the quoted text in one or more core/paragraph children; use the optional "citation" prop for attribution. +- core/button: must be a direct child of a core/buttons wrapper. +- core/column: must be a direct child of a core/columns wrapper. + +## Example + +Input: "A short intro about a topic, a section heading, and two key points." + +Output: +{"root":"r","elements":{"r":{"key":"r","type":"fragment","props":{},"children":["p1","h1","l1"]},"p1":{"key":"p1","type":"core/paragraph","props":{"content":"An opening paragraph that introduces the topic."},"parentKey":"r"},"h1":{"key":"h1","type":"core/heading","props":{"content":"A section heading","level":2},"parentKey":"r"},"l1":{"key":"l1","type":"core/list","props":{"ordered":false},"children":["li1","li2"],"parentKey":"r"},"li1":{"key":"li1","type":"core/list-item","props":{"content":"First point"},"parentKey":"l1"},"li2":{"key":"li2","type":"core/list-item","props":{"content":"Second point"},"parentKey":"l1"}}} INSTRUCTION; // phpcs:enable diff --git a/includes/Classifai/Features/QuickDraftIntegration.php b/includes/Classifai/Features/QuickDraftIntegration.php index 2b140cdca..56dbe13a0 100644 --- a/includes/Classifai/Features/QuickDraftIntegration.php +++ b/includes/Classifai/Features/QuickDraftIntegration.php @@ -206,19 +206,9 @@ public function endpoint_callback( WP_REST_Request $request ) { return $result; } - // Update the post with generated content. - $updated_post = array( - 'ID' => $post_id, - 'post_content' => $result, - 'post_status' => 'draft', - ); - - $update_result = wp_update_post( $updated_post ); - - if ( is_wp_error( $update_result ) ) { - return new WP_Error( 'post_update_failed', esc_html__( 'Failed to update post with generated content.', 'classifai' ) ); - } - + // $result is a JSON BlockTree. The draft is left empty here; the client + // renders it to block markup (using the editor's block registry) and + // saves it back via the core REST API before redirecting the user. return rest_ensure_response( array( 'post_id' => $post_id, diff --git a/includes/Classifai/Helpers.php b/includes/Classifai/Helpers.php index 63908b64a..7da4e670a 100644 --- a/includes/Classifai/Helpers.php +++ b/includes/Classifai/Helpers.php @@ -988,3 +988,52 @@ function get_temperature( float $temperature, int $results = 1 ): float { return (float) min( 2.0, $temperature + ( $results / 10 ) ); } + +/** + * Recursively sanitize the values of an AI-generated block tree. + * + * The Content Generation feature receives a JSON "block tree" from the model + * and renders its string props (paragraph and heading content, captions, list + * items, table cells, etc.) as HTML in the editor and saves them to post + * content. Sanitize every string value with wp_kses_post() and treat `url` + * props as URLs so untrusted markup (e.g. script tags or javascript: URLs) + * can't reach the browser. + * + * Decode the tree with json_decode( $json ) (objects, not associative arrays) + * so that empty objects such as `"props":{}` survive re-encoding; decoding to + * associative arrays would turn them into `[]`, which the client-side block + * tree schema rejects. + * + * @param mixed $value Decoded block tree, or a nested value within it. + * @return mixed Sanitized value. + */ +function sanitize_generated_block_tree( $value ) { + if ( is_object( $value ) ) { + foreach ( get_object_vars( $value ) as $key => $item ) { + if ( 'url' === $key && is_string( $item ) ) { + $value->$key = esc_url_raw( $item ); + } else { + $value->$key = sanitize_generated_block_tree( $item ); + } + } + return $value; + } + + if ( is_array( $value ) ) { + $sanitized = array(); + foreach ( $value as $key => $item ) { + if ( 'url' === $key && is_string( $item ) ) { + $sanitized[ $key ] = esc_url_raw( $item ); + } else { + $sanitized[ $key ] = sanitize_generated_block_tree( $item ); + } + } + return $sanitized; + } + + if ( is_string( $value ) ) { + return wp_kses_post( $value ); + } + + return $value; +} diff --git a/includes/Classifai/Providers/Azure/OpenAI.php b/includes/Classifai/Providers/Azure/OpenAI.php index a4fcb5854..9e443bac8 100644 --- a/includes/Classifai/Providers/Azure/OpenAI.php +++ b/includes/Classifai/Providers/Azure/OpenAI.php @@ -18,6 +18,7 @@ use function Classifai\sanitize_number_of_responses_field; use function Classifai\safe_wp_remote_post; use function Classifai\get_temperature; +use function Classifai\sanitize_generated_block_tree; class OpenAI extends Provider { @@ -954,8 +955,9 @@ public function generate_content( int $post_id = 0, array $args = array() ) { $body = apply_filters( 'classifai_azure_openai_content_request_body', array( - 'messages' => $messages, - 'temperature' => 0.9, + 'messages' => $messages, + 'temperature' => 0.9, + 'response_format' => array( 'type' => 'json_object' ), ), $post_id ); @@ -977,17 +979,28 @@ public function generate_content( int $post_id = 0, array $args = array() ) { return $response; } - // If we have a message, return it. - $return = ''; + // Pull the message content out of the response. + $content = ''; if ( ! empty( $response['choices'] ) ) { foreach ( $response['choices'] as $choice ) { if ( isset( $choice['message'], $choice['message']['content'] ) ) { - $return = wp_kses_post( trim( $choice['message']['content'], ' "\'' ) ); + $content = trim( $choice['message']['content'] ); } } } - return $return; + // The response should be a JSON BlockTree; validate before returning. + // Decode to objects (not arrays) so empty objects like "props":{} are + // preserved when re-encoded rather than becoming "props":[]. + $decoded = json_decode( $content ); + if ( null === $decoded || JSON_ERROR_NONE !== json_last_error() ) { + return new WP_Error( 'invalid_content_response', esc_html__( 'The generated content was not in the expected format. Please try again.', 'classifai' ) ); + } + + // Sanitize the block tree's string values before they are rendered/saved. + $decoded = sanitize_generated_block_tree( $decoded ); + + return wp_json_encode( $decoded ); } /** diff --git a/includes/Classifai/Providers/Localhost/Ollama.php b/includes/Classifai/Providers/Localhost/Ollama.php index 75eda18a0..095dc6aa1 100644 --- a/includes/Classifai/Providers/Localhost/Ollama.php +++ b/includes/Classifai/Providers/Localhost/Ollama.php @@ -17,6 +17,7 @@ use function Classifai\get_default_prompt; use function Classifai\sanitize_number_of_responses_field; +use function Classifai\sanitize_generated_block_tree; /** * Ollama class @@ -787,6 +788,7 @@ public function generate_content( int $post_id = 0, array $args = array() ) { 'model' => $settings[ static::ID ]['model'] ?? '', 'messages' => $messages, 'stream' => false, + 'format' => 'json', ), $post_id ); @@ -804,13 +806,24 @@ public function generate_content( int $post_id = 0, array $args = array() ) { return $response; } - // If we have a message, return it. - $return = ''; + // Pull the message content out of the response. + $content = ''; if ( isset( $response['message'], $response['message']['content'] ) ) { - $return = wp_kses_post( trim( $response['message']['content'], ' "\'' ) ); + $content = trim( $response['message']['content'] ); } - return $return; + // The response should be a JSON BlockTree; validate before returning. + // Decode to objects (not arrays) so empty objects like "props":{} are + // preserved when re-encoded rather than becoming "props":[]. + $decoded = json_decode( $content ); + if ( null === $decoded || JSON_ERROR_NONE !== json_last_error() ) { + return new WP_Error( 'invalid_content_response', esc_html__( 'The generated content was not in the expected format. Please try again.', 'classifai' ) ); + } + + // Sanitize the block tree's string values before they are rendered/saved. + $decoded = sanitize_generated_block_tree( $decoded ); + + return wp_json_encode( $decoded ); } /** diff --git a/includes/Classifai/Providers/OpenAI/ChatGPT.php b/includes/Classifai/Providers/OpenAI/ChatGPT.php index fc8ac255c..d1c06d733 100644 --- a/includes/Classifai/Providers/OpenAI/ChatGPT.php +++ b/includes/Classifai/Providers/OpenAI/ChatGPT.php @@ -22,6 +22,7 @@ use function Classifai\get_modified_image_source_url; use function Classifai\get_largest_size_and_dimensions_image_url; use function Classifai\get_temperature; +use function Classifai\sanitize_generated_block_tree; class ChatGPT extends Provider { @@ -1216,9 +1217,10 @@ public function generate_content( int $post_id = 0, array $args = array() ) { $body = apply_filters( 'classifai_chatgpt_content_request_body', array( - 'model' => $this->chatgpt_model, - 'messages' => $messages, - 'temperature' => 0.9, + 'model' => $this->chatgpt_model, + 'messages' => $messages, + 'temperature' => 0.9, + 'response_format' => array( 'type' => 'json_object' ), ), $post_id ); @@ -1235,17 +1237,28 @@ public function generate_content( int $post_id = 0, array $args = array() ) { return $response; } - // If we have a message, return it. - $return = ''; + // Pull the message content out of the response. + $content = ''; if ( ! empty( $response['choices'] ) ) { foreach ( $response['choices'] as $choice ) { if ( isset( $choice['message'], $choice['message']['content'] ) ) { - $return = wp_kses_post( trim( $choice['message']['content'], ' "\'' ) ); + $content = trim( $choice['message']['content'] ); } } } - return $return; + // The response should be a JSON BlockTree; validate before returning. + // Decode to objects (not arrays) so empty objects like "props":{} are + // preserved when re-encoded rather than becoming "props":[]. + $decoded = json_decode( $content ); + if ( null === $decoded || JSON_ERROR_NONE !== json_last_error() ) { + return new WP_Error( 'invalid_content_response', esc_html__( 'The generated content was not in the expected format. Please try again.', 'classifai' ) ); + } + + // Sanitize the block tree's string values before they are rendered/saved. + $decoded = sanitize_generated_block_tree( $decoded ); + + return wp_json_encode( $decoded ); } /** diff --git a/package-lock.json b/package-lock.json index 932e44317..aa01388f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "3.8.0", "license": "GPL-2.0-or-later", "dependencies": { + "@10up/block-renderer-core": "^0.2.0", "@wordpress/icons": "^13.3.0", "choices.js": "^11.2.3", "clsx": "^2.1.1", @@ -28,6 +29,24 @@ "wp-hooks-documentor": "github:10up/wp-hooks-documentor#build" } }, + "node_modules/@10up/block-renderer-core": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@10up/block-renderer-core/-/block-renderer-core-0.2.0.tgz", + "integrity": "sha512-R1Ds+4pqHI8Nj3PbiBDpQtghEaEi/7gRbGYArnGM7wj6xgGA9zJLLcX6l/hqJ+XNUT4zoUHnK/7xULX30K0YqQ==", + "license": "MIT", + "dependencies": { + "zod": "^3.24.0" + } + }, + "node_modules/@10up/block-renderer-core/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -2273,6 +2292,25 @@ "tslib": "^2.4.0" } }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", + "license": "MIT", + "optional": true, + "peer": true + }, "node_modules/@es-joy/jsdoccomment": { "version": "0.50.2", "resolved": "https://registry.npmjs.org/@es-joy/jsdoccomment/-/jsdoccomment-0.50.2.tgz", @@ -5205,27 +5243,6 @@ "@parcel/watcher-win32-x64": "2.5.4" } }, - "node_modules/@parcel/watcher-android-arm64": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.4.tgz", - "integrity": "sha512-hoh0vx4v+b3BNI7Cjoy2/B0ARqcwVNrzN/n7DLq9ZB4I3lrsvhrkCViJyfTj/Qi5xM9YFiH4AmHGK6pgH1ss7g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/@parcel/watcher-darwin-arm64": { "version": "2.5.4", "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.4.tgz", @@ -5247,237 +5264,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.4.tgz", - "integrity": "sha512-UKaQFhCtNJW1A9YyVz3Ju7ydf6QgrpNQfRZ35wNKUhTQ3dxJ/3MULXN5JN/0Z80V/KUBDGa3RZaKq1EQT2a2gg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.4.tgz", - "integrity": "sha512-Dib0Wv3Ow/m2/ttvLdeI2DBXloO7t3Z0oCp4bAb2aqyqOjKPPGrg10pMJJAQ7tt8P4V2rwYwywkDhUia/FgS+Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.4.tgz", - "integrity": "sha512-I5Vb769pdf7Q7Sf4KNy8Pogl/URRCKu9ImMmnVKYayhynuyGYMzuI4UOWnegQNa2sGpsPSbzDsqbHNMyeyPCgw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-musl": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.4.tgz", - "integrity": "sha512-kGO8RPvVrcAotV4QcWh8kZuHr9bXi9a3bSZw7kFarYR0+fGliU7hd/zevhjw8fnvIKG3J9EO5G6sXNGCSNMYPQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.4.tgz", - "integrity": "sha512-KU75aooXhqGFY2W5/p8DYYHt4hrjHZod8AhcGAmhzPn/etTa+lYCDB2b1sJy3sWJ8ahFVTdy+EbqSBvMx3iFlw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.4.tgz", - "integrity": "sha512-Qx8uNiIekVutnzbVdrgSanM+cbpDD3boB1f8vMtnuG5Zau4/bdDbXyKwIn0ToqFhIuob73bcxV9NwRm04/hzHQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.4.tgz", - "integrity": "sha512-UYBQvhYmgAv61LNUn24qGQdjtycFBKSK3EXr72DbJqX9aaLbtCOO8+1SkKhD/GNiJ97ExgcHBrukcYhVjrnogA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.4.tgz", - "integrity": "sha512-YoRWCVgxv8akZrMhdyVi6/TyoeeMkQ0PGGOf2E4omODrvd1wxniXP+DBynKoHryStks7l+fDAMUBRzqNHrVOpg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-arm64": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.4.tgz", - "integrity": "sha512-iby+D/YNXWkiQNYcIhg8P5hSjzXEHaQrk2SLrWOUD7VeC4Ohu0WQvmV+HDJokZVJ2UjJ4AGXW3bx7Lls9Ln4TQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-ia32": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.4.tgz", - "integrity": "sha512-vQN+KIReG0a2ZDpVv8cgddlf67J8hk1WfZMMP7sMeZmJRSmEax5xNDNWKdgqSe2brOKTQQAs3aCCUal2qBHAyg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-x64": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.4.tgz", - "integrity": "sha512-3A6efb6BOKwyw7yk9ro2vus2YTt2nvcd56AuzxdMiVOxL9umDyN5PKkKfZ/gZ9row41SjVmTVQNWQhaRRGpOKw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/@parcel/watcher/node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", @@ -5818,13 +5604,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", - "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.61.0" + "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -7111,36 +6897,6 @@ "react": "^19.2.4" } }, - "node_modules/@types/wordpress__blocks/node_modules/@wordpress/data": { - "version": "10.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/data/-/data-10.47.0.tgz", - "integrity": "sha512-OAwxgplnv42U2oIKb7QkzR/v8OfStgSfl8/eHtA+aE4Z4yvRas71O1w6mmHABIKo3+zSwyxiTakag+LMD9/9Mw==", - "dev": true, - "license": "GPL-2.0-or-later", - "dependencies": { - "@wordpress/compose": "^8.0.0", - "@wordpress/deprecated": "^4.47.0", - "@wordpress/element": "^7.0.0", - "@wordpress/is-shallow-equal": "^5.47.0", - "@wordpress/priority-queue": "^3.47.0", - "@wordpress/private-apis": "^1.47.0", - "@wordpress/redux-routine": "^5.47.0", - "deepmerge": "^4.3.0", - "equivalent-key-map": "^0.2.2", - "is-plain-object": "^5.0.0", - "is-promise": "^4.0.0", - "redux": "^5.0.1", - "rememo": "^4.0.2", - "use-memo-one": "^1.1.1" - }, - "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" - }, - "peerDependencies": { - "react": "^19.2.4" - } - }, "node_modules/@types/wordpress__blocks/node_modules/@wordpress/element": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-7.0.0.tgz", @@ -7162,34 +6918,6 @@ "npm": ">=8.19.2" } }, - "node_modules/@types/wordpress__blocks/node_modules/@wordpress/rich-text": { - "version": "7.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/rich-text/-/rich-text-7.47.0.tgz", - "integrity": "sha512-CNQGDtfp9ObpwtIYsAO8UD0mb4hj9F5K8maLn1t83EkxaozwzTGjpGSaV6Y/J9eQd2ZkvG9Cu37YfKO4gRhbHw==", - "dev": true, - "license": "GPL-2.0-or-later", - "dependencies": { - "@wordpress/a11y": "^4.47.0", - "@wordpress/compose": "^8.0.0", - "@wordpress/data": "^10.47.0", - "@wordpress/deprecated": "^4.47.0", - "@wordpress/dom": "^4.47.0", - "@wordpress/element": "^7.0.0", - "@wordpress/escape-html": "^3.47.0", - "@wordpress/i18n": "^6.20.0", - "@wordpress/keycodes": "^4.47.0", - "@wordpress/private-apis": "^1.47.0", - "colord": "2.9.3", - "memize": "^2.1.0" - }, - "engines": { - "node": ">=18.12.0", - "npm": ">=8.19.2" - }, - "peerDependencies": { - "react": "^19.2.4" - } - }, "node_modules/@types/wordpress__blocks/node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -7665,9 +7393,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7682,9 +7407,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7699,9 +7421,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7716,9 +7435,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7733,9 +7449,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7750,9 +7463,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7767,9 +7477,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -7784,9 +7491,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7801,9 +7505,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -7818,9 +7519,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -8111,14 +7809,14 @@ } }, "node_modules/@wordpress/a11y": { - "version": "4.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/a11y/-/a11y-4.47.0.tgz", - "integrity": "sha512-Gq0PRRg7/jXFD4V8UfQNQEMR5DX8KxYUK1QUbHgRedsaW/ZhlgsXJHmBMZfeLubBn/tf0xpdPYdp0MNA5oc3Rg==", + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@wordpress/a11y/-/a11y-4.48.1.tgz", + "integrity": "sha512-BPU7wRoz2XRmP3ZgVtENPKS4iO5/+bKNid/xLrvD6cP1qMhIGowQsBNqmkP1V5+q71hQM/ID6tpFjeLhmogfPg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/dom-ready": "^4.47.0", - "@wordpress/i18n": "^6.20.0" + "@wordpress/dom-ready": "^4.48.1", + "@wordpress/i18n": "^6.21.1" }, "engines": { "node": ">=18.12.0", @@ -8126,9 +7824,9 @@ } }, "node_modules/@wordpress/autop": { - "version": "4.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/autop/-/autop-4.47.0.tgz", - "integrity": "sha512-2r/Fq6TeRV7ytsRfppJozKVaqY2jPGue2dwvthgo8/LruHOGuSBSFz67wmgI+0WUXPqq2+DjQ5mL0+416sxNMA==", + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@wordpress/autop/-/autop-4.48.1.tgz", + "integrity": "sha512-vMOdHhXIv559fYsg72AnWACblxZdojIerVeWxlX7a/ptoZK8MjqA0ZVhFsHezTBqdfiFyoRtiRECSFYLXWlSZg==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8172,9 +7870,9 @@ } }, "node_modules/@wordpress/blob": { - "version": "4.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/blob/-/blob-4.47.0.tgz", - "integrity": "sha512-uLFDeZlccLFuz42jRGgw+yH+B/5ISXDl6ht3kZugHo5auXD3oUikDLyz1+kiR/hhbWF2dzzoWOxAi52d+nHhSQ==", + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@wordpress/blob/-/blob-4.48.1.tgz", + "integrity": "sha512-iK3dtZu/UtnYpKfQ2aGZM2xrLXK5ff88QNU77XlraipaGV/C7zK2M0+sWY6cVL50TfMR7VX9prZ2XCpE5aRzSg==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8183,9 +7881,9 @@ } }, "node_modules/@wordpress/block-serialization-default-parser": { - "version": "5.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/block-serialization-default-parser/-/block-serialization-default-parser-5.47.0.tgz", - "integrity": "sha512-xRZ026shHpau7VWDXLGBvfaU6s/yC1efzOGKiDGZ0fbTbTQcr09ma7ToaDyCiHIfMoSu9lMYnriLLOEKVht8Sg==", + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@wordpress/block-serialization-default-parser/-/block-serialization-default-parser-5.48.1.tgz", + "integrity": "sha512-REsjN6tT2lXekrjuiu2O0+FYW13QHhy23j7C458zzSjpYcxROtl/T8AozOJYRvG9SkdC9Og3PkEP/9/nGC4IVw==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8238,6 +7936,37 @@ } } }, + "node_modules/@wordpress/data": { + "version": "10.48.1", + "resolved": "https://registry.npmjs.org/@wordpress/data/-/data-10.48.1.tgz", + "integrity": "sha512-74p4PiDLxS0SAd4tdkPEUF5rtHVtdRzoXExfhkZaumviE56tEceG07YbLjQWpZ+Vl1tZCvkyqLbMHK+Wj6ZerQ==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@types/react": "^18.3.27", + "@wordpress/compose": "^8.1.1", + "@wordpress/deprecated": "^4.48.1", + "@wordpress/element": "^8.0.1", + "@wordpress/is-shallow-equal": "^5.48.1", + "@wordpress/priority-queue": "^3.48.1", + "@wordpress/private-apis": "^1.48.1", + "@wordpress/redux-routine": "^5.48.1", + "deepmerge": "^4.3.1", + "equivalent-key-map": "^0.2.2", + "is-plain-object": "^5.0.0", + "is-promise": "^4.0.0", + "redux": "^5.0.1", + "rememo": "^4.0.2", + "use-memo-one": "^1.1.1" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, "node_modules/@wordpress/dependency-extraction-webpack-plugin": { "version": "6.49.0", "resolved": "https://registry.npmjs.org/@wordpress/dependency-extraction-webpack-plugin/-/dependency-extraction-webpack-plugin-6.49.0.tgz", @@ -8283,9 +8012,9 @@ } }, "node_modules/@wordpress/dom-ready": { - "version": "4.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/dom-ready/-/dom-ready-4.47.0.tgz", - "integrity": "sha512-j2s4GdhxxQi2pbgyqdSz+Xh59K626/ErASH/ZKF7GlnBk4fjf3X0Odg7wopZ5wQfJgZSfEjGr1sonRst5uMAVw==", + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@wordpress/dom-ready/-/dom-ready-4.48.1.tgz", + "integrity": "sha512-EYd2H8cYSk8H3wSnTK1wTtuC+hOCmMmZrCEBWzgHevs1n3B9g0nwBafSiRWpzc0vA2vculkNNtlSfy3ByJ2hag==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8294,9 +8023,9 @@ } }, "node_modules/@wordpress/e2e-test-utils-playwright": { - "version": "1.48.1", - "resolved": "https://registry.npmjs.org/@wordpress/e2e-test-utils-playwright/-/e2e-test-utils-playwright-1.48.1.tgz", - "integrity": "sha512-jaBTZHZ0SG1MQ/Nb4GdaH7DMcieFp/ysYOoCodmhfoScFoWTI66/e8egPxU8cOs06gXnOYNPvJ9hnCaeCTz3ug==", + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/@wordpress/e2e-test-utils-playwright/-/e2e-test-utils-playwright-1.49.0.tgz", + "integrity": "sha512-xuhny4GqmKBVVfrPzeJ/d+oa+v71184B9jSeZBE9dsXl4Cp61GIM0TLNwc24p8sO2vhv+eleRQMmRvMLfOCroA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8336,9 +8065,9 @@ } }, "node_modules/@wordpress/env": { - "version": "11.8.1", - "resolved": "https://registry.npmjs.org/@wordpress/env/-/env-11.8.1.tgz", - "integrity": "sha512-wboYnQNPfMfcgYgiNDbrGGZj8RkdeZtaz2UvmAVUrhOgAvHFpZXsKuXuLdCCSv7JdcHGG5xLWMYhvRLTGOcQ1g==", + "version": "11.9.0", + "resolved": "https://registry.npmjs.org/@wordpress/env/-/env-11.9.0.tgz", + "integrity": "sha512-PtPqrWONclS6dXZLQztZiiFIpqAuoJRBOpPMS85QJiKzkDlvbVmUk3CicmxQbGpktS6mXtVyV5ySPCXqV8OD1g==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8459,9 +8188,9 @@ } }, "node_modules/@wordpress/html-entities": { - "version": "4.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/html-entities/-/html-entities-4.47.0.tgz", - "integrity": "sha512-D3sVJF1uTkjTUJvaAVJsoV8dCkP8q8L29NpavP4qClVQAhlYEynhmMUpesoL163f65Fi7XHwfIVejGjGDrMoXA==", + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@wordpress/html-entities/-/html-entities-4.48.1.tgz", + "integrity": "sha512-Gq6j3yl+m0pc0989jFjAgYbtdwHyUS/5PR39zg+hQfq1IWiqCwfhFlJAqc8ymwx/gSklrxf9mmyhBwmUPWtMcw==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8670,9 +8399,9 @@ } }, "node_modules/@wordpress/redux-routine": { - "version": "5.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/redux-routine/-/redux-routine-5.47.0.tgz", - "integrity": "sha512-r2HV47NUYxk/XiaG47nLgmb5GeC+KtisHVS4WYlKsUe3JlCffKc1dphL58EST1xvCt9UBCiwXfd8+Y0GHaXUpw==", + "version": "5.48.1", + "resolved": "https://registry.npmjs.org/@wordpress/redux-routine/-/redux-routine-5.48.1.tgz", + "integrity": "sha512-+mUHB2DxfqGODfc9Lwdhz8D7jjojjWqhoa8w0ckUCzh84ZERiR3BcoiGhCkiWVSl9XedKu9itLFna5Q4gilEZw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8688,26 +8417,55 @@ "redux": ">=4" } }, + "node_modules/@wordpress/rich-text": { + "version": "7.48.1", + "resolved": "https://registry.npmjs.org/@wordpress/rich-text/-/rich-text-7.48.1.tgz", + "integrity": "sha512-pj+S2d2p4EUJ03V/tOhlvb9qGPixft7v1zj9KEyM70VY6nHD5mmVp95Q5ALtlioyDFWYhzSo609PEd9fJ8FTsQ==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@types/react": "^18.3.27", + "@wordpress/a11y": "^4.48.1", + "@wordpress/compose": "^8.1.1", + "@wordpress/data": "^10.48.1", + "@wordpress/deprecated": "^4.48.1", + "@wordpress/dom": "^4.48.1", + "@wordpress/element": "^8.0.1", + "@wordpress/escape-html": "^3.48.1", + "@wordpress/i18n": "^6.21.1", + "@wordpress/keycodes": "^4.48.1", + "@wordpress/private-apis": "^1.48.1", + "colord": "^2.9.3", + "memize": "^2.1.0" + }, + "engines": { + "node": ">=18.12.0", + "npm": ">=8.19.2" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, "node_modules/@wordpress/scripts": { - "version": "32.4.1", - "resolved": "https://registry.npmjs.org/@wordpress/scripts/-/scripts-32.4.1.tgz", - "integrity": "sha512-kcR0zvXUm9qgeHbXVUXlq0M6NaPHMmZ1RudRt7HyS+9I+YK5nomvVgyFzcF/ViO+gbWU8o02iyf6iluihKtloQ==", + "version": "32.5.0", + "resolved": "https://registry.npmjs.org/@wordpress/scripts/-/scripts-32.5.0.tgz", + "integrity": "sha512-fZysm4M+kNsm7X2jMtW4hKKdnNOvy92rLoMh6TLkHiuDEsTSLHhu6RT/tvTtqvtQ2NOiOBAHA/eF0bVfkWQCbw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/core": "^7.25.7", "@pmmmwh/react-refresh-webpack-plugin": "^0.5.11", "@svgr/webpack": "^8.0.1", - "@wordpress/babel-preset-default": "^8.48.1", - "@wordpress/browserslist-config": "^6.48.1", - "@wordpress/dependency-extraction-webpack-plugin": "^6.48.1", - "@wordpress/e2e-test-utils-playwright": "^1.48.1", - "@wordpress/eslint-plugin": "^25.4.1", - "@wordpress/jest-preset-default": "^12.48.1", - "@wordpress/npm-package-json-lint-config": "^5.48.1", - "@wordpress/postcss-plugins-preset": "^5.48.1", - "@wordpress/prettier-config": "^4.48.1", - "@wordpress/stylelint-config": "^23.40.1", + "@wordpress/babel-preset-default": "^8.49.0", + "@wordpress/browserslist-config": "^6.49.0", + "@wordpress/dependency-extraction-webpack-plugin": "^6.49.0", + "@wordpress/e2e-test-utils-playwright": "^1.49.0", + "@wordpress/eslint-plugin": "^25.5.0", + "@wordpress/jest-preset-default": "^12.49.0", + "@wordpress/npm-package-json-lint-config": "^5.49.0", + "@wordpress/postcss-plugins-preset": "^5.49.0", + "@wordpress/prettier-config": "^4.49.0", + "@wordpress/stylelint-config": "^23.41.0", "adm-zip": "^0.5.9", "babel-jest": "^29.7.0", "babel-loader": "^9.2.1", @@ -9021,13 +8779,13 @@ } }, "node_modules/@wordpress/shortcode": { - "version": "4.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/shortcode/-/shortcode-4.47.0.tgz", - "integrity": "sha512-WGCP3dWN6BwD9jOPLrICtMml4PcA+z01jliepQFrdblol9+McRsss9DZ8pvQQ9jiZEyRqw4SXw8RzDBirjFYYg==", + "version": "4.48.1", + "resolved": "https://registry.npmjs.org/@wordpress/shortcode/-/shortcode-4.48.1.tgz", + "integrity": "sha512-zfOz45verEOIf3YIE4zOlMcQoZ2za+OFuJ4SKE+nmglUFLmF2I0iLGlNQO0BNLPI26+krSVZ8kclzJRaB5Z6Kw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "memize": "^2.0.1" + "memize": "^2.1.0" }, "engines": { "node": ">=18.12.0", @@ -15494,10 +15252,11 @@ } }, "node_modules/iconv-lite": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.1.tgz", - "integrity": "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", "dev": true, + "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -18060,9 +17819,9 @@ } }, "node_modules/lint-staged": { - "version": "17.0.7", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.7.tgz", - "integrity": "sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA==", + "version": "17.0.8", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.8.tgz", + "integrity": "sha512-B2P/d+jVW0UXOQ0MVMLrB/9ydA1P+zz6jYfdrbbEd9ur3S2rcbduFWKiUCC02Sm5hbC8nrm7y24WuYMG54HfxA==", "dev": true, "license": "MIT", "dependencies": { @@ -20728,13 +20487,13 @@ } }, "node_modules/playwright": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", - "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.61.0" + "playwright-core": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -20747,9 +20506,9 @@ } }, "node_modules/playwright-core": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", - "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -21975,9 +21734,9 @@ } }, "node_modules/react-router": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz", - "integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", + "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -21997,12 +21756,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz", - "integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==", + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz", + "integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==", "license": "MIT", "dependencies": { - "react-router": "7.17.0" + "react-router": "7.18.0" }, "engines": { "node": ">=20.0.0" diff --git a/package.json b/package.json index c79dba2ab..af8113e7d 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "wp-hooks-documentor": "github:10up/wp-hooks-documentor#build" }, "dependencies": { + "@10up/block-renderer-core": "^0.2.0", "@wordpress/icons": "^13.3.0", "choices.js": "^11.2.3", "clsx": "^2.1.1", diff --git a/src/js/features/content-generation/components/ai-response.tsx b/src/js/features/content-generation/components/ai-response.tsx index d90016e99..8d37fd22e 100644 --- a/src/js/features/content-generation/components/ai-response.tsx +++ b/src/js/features/content-generation/components/ai-response.tsx @@ -9,6 +9,11 @@ import React from 'react'; */ import { decodeEntities } from '@wordpress/html-entities'; +/** + * Internal dependencies + */ +import { renderBlockTreeToMarkup } from '../utils/render-block-tree'; + /** * Props for the AIResponse component */ @@ -32,10 +37,15 @@ const contentStyles: CSSProperties = { * @return {React.ReactElement} AI response container */ export const AIResponse: React.FC< AIResponseProps > = ( { content } ) => { + // The response is a JSON BlockTree; render it to readable block markup for + // the preview, falling back to the raw response if it isn't valid JSON. + const markup = renderBlockTreeToMarkup( content ); + const html = markup !== null ? markup : decodeEntities( content ); + return (
); }; diff --git a/src/js/features/content-generation/components/chat-ui.tsx b/src/js/features/content-generation/components/chat-ui.tsx index 36e50ed1e..6385671b0 100644 --- a/src/js/features/content-generation/components/chat-ui.tsx +++ b/src/js/features/content-generation/components/chat-ui.tsx @@ -24,6 +24,7 @@ import { ChatHistory } from './chat-history'; import { ErrorMessage } from './error-message'; import { ChatInput } from './chat-input'; import type { ConversationEntry } from './types'; +import { renderBlockTreeToMarkup } from '../utils/render-block-tree'; // Define style objects outside of JSX const chatContainerStyles: CSSProperties = { @@ -241,20 +242,24 @@ export const ChatUI: React.FC = () => { content: '', } ) .then( () => { - const contentWithEntities = decodeEntities( content ); - - const containsBlockMarkup = - contentWithEntities.includes( '` markup. We render it to valid markup here using the + * editor's native `@wordpress/blocks` (`createBlock` + `serialize`), so no + * Node-only renderer needs to be bundled. + * + * Callers must ensure the relevant block types are registered before + * rendering (the block editor registers core blocks automatically; other + * contexts such as the dashboard must call `registerCoreBlocks()` first). + */ + +/** + * WordPress dependencies + */ +import { createBlock, serialize } from '@wordpress/blocks'; + +/** + * External dependencies + */ +import { + blockTreeSchema, + FRAGMENT_BLOCK_TYPE, +} from '@10up/block-renderer-core'; + +type BlockTree = ReturnType< typeof blockTreeSchema.parse >; + +/** A WordPress block instance, as produced by `createBlock`. */ +type BlockInstance = ReturnType< typeof createBlock >; + +/** + * Recursively build block instances for a single element key. + * + * A `fragment` element is virtual: its children are promoted to the current + * level rather than wrapped in a real block. Visited keys are tracked to + * guard against malformed trees that reference each other in a cycle. + * + * @param {string} key Element key to resolve. + * @param {BlockTree} tree The full block tree. + * @param {Set} seen Keys already visited on this branch. + * @return {BlockInstance[]} Resolved block instances. + */ +function elementToBlocks( + key: string, + tree: BlockTree, + seen: Set< string > +): BlockInstance[] { + if ( seen.has( key ) ) { + return []; + } + seen.add( key ); + + const element = tree.elements[ key ]; + if ( ! element ) { + return []; + } + + const childKeys = element.children ?? []; + const innerBlocks = childKeys.flatMap( ( childKey ) => + elementToBlocks( childKey, tree, seen ) + ); + + // A fragment is a virtual wrapper: surface its children directly. + if ( element.type === FRAGMENT_BLOCK_TYPE ) { + return innerBlocks; + } + + return [ + createBlock( + element.type, + ( element.props ?? {} ) as Record< string, unknown >, + innerBlocks + ), + ]; +} + +/** + * Render a JSON BlockTree string to WordPress block markup. + * + * Returns `null` when the input is not a valid BlockTree (invalid JSON, fails + * schema validation, or produces no blocks) so callers can fall back to + * treating the response as raw HTML. + * + * @param {string} json The raw JSON string returned by the provider. + * @return {string|null} Serialized block markup, or `null` on failure. + */ +export function renderBlockTreeToMarkup( json: string ): string | null { + let data: unknown; + try { + data = JSON.parse( json ); + } catch { + return null; + } + + const result = blockTreeSchema.safeParse( data ); + if ( ! result.success ) { + return null; + } + + try { + const blocks = elementToBlocks( + result.data.root, + result.data, + new Set() + ); + if ( ! blocks.length ) { + return null; + } + return serialize( blocks ); + } catch { + return null; + } +} diff --git a/src/js/types/wordpress.d.ts b/src/js/types/wordpress.d.ts index 871df2551..29cfe7f09 100644 --- a/src/js/types/wordpress.d.ts +++ b/src/js/types/wordpress.d.ts @@ -28,6 +28,8 @@ declare module '@wordpress/blocks' { export const pasteHandler: any; export const parse: any; export const registerBlockType: any; + export const createBlock: any; + export const serialize: any; } declare module '@wordpress/commands' { diff --git a/tests/Integration/HelpersTest.php b/tests/Integration/HelpersTest.php index 562f9ce00..9b7b75de1 100644 --- a/tests/Integration/HelpersTest.php +++ b/tests/Integration/HelpersTest.php @@ -564,4 +564,126 @@ public function test_safe_file_get_contents_remote_and_local() { $this->assertSame( 'local-body', safe_file_get_contents( $tmp ) ); unlink( $tmp ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink } + + /** + * sanitize_generated_block_tree() strips disallowed markup (e.g. scripts and + * event-handler attributes) from string props while preserving allowed + * inline HTML. + */ + function test_sanitize_generated_block_tree_strips_unsafe_html() { + $tree = array( + 'root' => 'p1', + 'elements' => array( + 'p1' => array( + 'key' => 'p1', + 'type' => 'core/paragraph', + 'props' => array( + 'content' => 'Hello world', + ), + ), + ), + ); + + $sanitized = sanitize_generated_block_tree( $tree ); + $content = $sanitized['elements']['p1']['props']['content']; + + $this->assertStringContainsString( 'world', $content, 'Allowed inline HTML should be preserved.' ); + $this->assertStringNotContainsString( '', + 'tag' => 'td', + ), + ), + ), + ), + ), + ), + 'l1' => array( + 'key' => 'l1', + 'type' => 'core/list', + 'props' => array( + 'ordered' => true, + ), + ), + ), + ); + + $sanitized = sanitize_generated_block_tree( $tree ); + + // Non-string scalars are preserved as-is. + $this->assertSame( 2, $sanitized['elements']['h1']['props']['level'] ); + $this->assertTrue( $sanitized['elements']['l1']['props']['ordered'] ); + + // Nested string values are sanitized. + $cell = $sanitized['elements']['t1']['props']['body'][0]['cells'][0]; + $this->assertStringNotContainsString( '"}}}}'; + + $encoded = wp_json_encode( sanitize_generated_block_tree( json_decode( $json ) ) ); + + $this->assertStringContainsString( '"props":{}', $encoded, 'Empty objects must remain objects.' ); + $this->assertStringNotContainsString( '"props":[]', $encoded, 'Empty objects must not become arrays.' ); + $this->assertStringNotContainsString( '