diff --git a/projects/packages/premium-analytics/changelog/update-pa-widget-metadata-pipeline b/projects/packages/premium-analytics/changelog/update-pa-widget-metadata-pipeline new file mode 100644 index 000000000000..6b413aa325dc --- /dev/null +++ b/projects/packages/premium-analytics/changelog/update-pa-widget-metadata-pipeline @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Widget types: carry the declarative widget.json metadata (category, title, description, help, keywords) from the build manifest through the registry into the widget-modules REST record, translated and sanitized at registration. diff --git a/projects/packages/premium-analytics/src/class-widget-type.php b/projects/packages/premium-analytics/src/class-widget-type.php index c0ef1c5448eb..5c98791a8521 100644 --- a/projects/packages/premium-analytics/src/class-widget-type.php +++ b/projects/packages/premium-analytics/src/class-widget-type.php @@ -72,6 +72,57 @@ class Widget_Type { */ public $presentation = null; + /** + * Widget types are grouped into categories to help users browse and + * discover them. Static and declarative; not a user-editable attribute. + * + * Null when the widget did not declare the field. + * + * @var string|null + */ + public $category = null; + + /** + * Human-readable title that names the widget type. Translated + * at registration time using the widget's text domain. + * + * Null when the widget did not declare the field. + * + * @var string|null + */ + public $title = null; + + /** + * Human-readable description of what the widget type does. + * Translated at registration time using the widget's text domain. + * + * Null when the widget did not declare the field. + * + * @var string|null + */ + public $description = null; + + /** + * Contextual help note: `content` plus optional `links`. + * Translated at registration time using the widget's text domain. + * + * Null when the widget did not declare the field. + * + * @var array|null + */ + public $help = null; + + /** + * Alternative terms used to match the widget type when searching, + * e.g. "calendar" for an events widget. Translated at registration + * time using the widget's text domain. + * + * Null when the widget did not declare the field. + * + * @var string[]|null + */ + public $keywords = null; + /** * Constructor. * diff --git a/projects/packages/premium-analytics/src/widget-i18n.json b/projects/packages/premium-analytics/src/widget-i18n.json new file mode 100644 index 000000000000..859fa3ef5dc8 --- /dev/null +++ b/projects/packages/premium-analytics/src/widget-i18n.json @@ -0,0 +1,9 @@ +{ + "title": "widget title", + "description": "widget description", + "help": { + "content": "widget help content", + "links": [ { "label": "widget help link label" } ] + }, + "keywords": [ "widget keyword" ] +} diff --git a/projects/packages/premium-analytics/src/widget-modules.php b/projects/packages/premium-analytics/src/widget-modules.php index 1cb634305641..ec96a98df209 100644 --- a/projects/packages/premium-analytics/src/widget-modules.php +++ b/projects/packages/premium-analytics/src/widget-modules.php @@ -80,6 +80,11 @@ function get_widget_modules_response() { 'render_module' => $widget_type->render_module, 'widget_module' => $widget_type->widget_module, 'presentation' => $widget_type->presentation, + 'category' => $widget_type->category, + 'title' => $widget_type->title, + 'description' => $widget_type->description, + 'help' => $widget_type->help, + 'keywords' => $widget_type->keywords, ); } diff --git a/projects/packages/premium-analytics/src/widget-types.php b/projects/packages/premium-analytics/src/widget-types.php index 3acc82219790..624de7e6cd92 100644 --- a/projects/packages/premium-analytics/src/widget-types.php +++ b/projects/packages/premium-analytics/src/widget-types.php @@ -4,7 +4,8 @@ * * Copies the wp-build manifest (`jpa_get_registered_widget_modules()`) into the * in-memory Widget_Type_Registry, so the plugin queries the registry instead - * of re-parsing the manifest. + * of re-parsing the manifest. On the way in, user-facing metadata strings are + * translated (per the widget-i18n.json schema) and the `help` note sanitized. * * This is the problem-agnostic "core" layer (a PA-namespaced copy of the * experimental Gutenberg API): it exposes the hooks a consumer uses to scope @@ -33,6 +34,97 @@ */ const WIDGET_TYPES_FILTER = 'jetpack_premium_analytics_widget_types'; +/** + * Returns the i18n schema describing which widget metadata fields are + * translatable and the gettext context to use for each. + * + * Read once from widget-i18n.json and memoized for the rest of the request. + * Decoded as objects, not associative arrays: that is how + * `translate_settings_using_i18n_schema()` tells keyed maps apart from lists. + * + * @return object Map of translatable field name to gettext context. + */ +function get_widget_metadata_i18n_schema() { + static $i18n_schema = null; + + if ( null === $i18n_schema ) { + $schema = wp_json_file_decode( __DIR__ . '/widget-i18n.json' ); + $i18n_schema = is_object( $schema ) ? $schema : new \stdClass(); + } + + return $i18n_schema; +} + +/** + * Translates a widget's user-facing metadata strings. + * + * Runs `title`, `description`, `help`, and `keywords` through the widget + * i18n schema, leaving every other key untouched. Unlike the upstream copy, + * a widget with no `textdomain` falls back to the package text domain + * instead of skipping translation: every bundled widget shares it. + * + * @param array $widget Widget data from the build manifest. + * @return array Widget data with its translatable strings localized. + */ +function translate_widget_metadata( $widget ) { + $textdomain = ! empty( $widget['textdomain'] ) ? $widget['textdomain'] : 'jetpack-premium-analytics'; + $i18n_schema = get_widget_metadata_i18n_schema(); + + foreach ( array( 'title', 'description', 'help', 'keywords' ) as $field ) { + if ( isset( $widget[ $field ] ) && isset( $i18n_schema->$field ) ) { + $widget[ $field ] = translate_settings_using_i18n_schema( $i18n_schema->$field, $widget[ $field ], $textdomain ); + } + } + + return $widget; +} + +/** + * Constrains a widget help note to its allowed shape: `content` keeps + * only `em`/`strong` markup, and links are dropped unless they carry a + * `label` and an `href` that survives `esc_url_raw()`. + * + * @param array|null $help Help note from the build manifest. + * @return array|null Sanitized help note, or null when there is no content. + */ +function sanitize_widget_help( $help ) { + if ( ! is_array( $help ) || empty( $help['content'] ) || ! is_string( $help['content'] ) ) { + return null; + } + + $sanitized = array( + 'content' => wp_kses( + $help['content'], + array( + 'em' => array(), + 'strong' => array(), + ) + ), + ); + + if ( ! empty( $help['links'] ) && is_array( $help['links'] ) ) { + $links = array(); + foreach ( $help['links'] as $link ) { + if ( is_array( $link ) && ! empty( $link['label'] ) && ! empty( $link['href'] ) ) { + $href = esc_url_raw( $link['href'] ); + + if ( $href ) { + $links[] = array( + 'label' => $link['label'], + 'href' => $href, + ); + } + } + } + + if ( $links ) { + $sanitized['links'] = $links; + } + } + + return $sanitized; +} + /** * Hydrates the widget type registry from the build manifest. * @@ -48,7 +140,8 @@ function register_widget_types() { $registry = Widget_Type_Registry::get_instance(); - // @phan-suppress-next-line PhanUndeclaredFunction -- Generated by wp-build into build/widgets.php, outside Phan's analysis scope. The function_exists() guard above protects the call at runtime. + // Generated by wp-build into build/widgets.php, outside Phan's analysis scope. + // The function_exists() guard above protects the call at runtime. $jetpack_widget_modules = jpa_get_registered_widget_modules(); /** @@ -68,12 +161,19 @@ function register_widget_types() { continue; } + $widget = translate_widget_metadata( $widget ); + $registry->register( $widget['name'], array( 'render_module' => $widget['render_module'] ?? null, 'widget_module' => $widget['widget_module'] ?? null, 'presentation' => $widget['presentation'] ?? null, + 'category' => $widget['category'] ?? null, + 'title' => $widget['title'] ?? null, + 'description' => $widget['description'] ?? null, + 'help' => sanitize_widget_help( $widget['help'] ?? null ), + 'keywords' => $widget['keywords'] ?? null, ) ); } diff --git a/projects/packages/premium-analytics/tests/php/Widget_Metadata_Test.php b/projects/packages/premium-analytics/tests/php/Widget_Metadata_Test.php new file mode 100644 index 000000000000..244dd25c1de9 --- /dev/null +++ b/projects/packages/premium-analytics/tests/php/Widget_Metadata_Test.php @@ -0,0 +1,288 @@ + 'jpa/hello-world', + 'title' => 'Hello world', + 'description' => 'A friendly greeting.', + 'keywords' => array( 'greeting' ), + ) + ); + + remove_filter( 'gettext_with_context', $callback ); + + $this->assertSame( 'Hello world', $widget['title'], 'Untranslated strings pass through unchanged.' ); + $this->assertContains( array( 'Hello world', 'widget title', 'jetpack-premium-analytics' ), $calls, 'The title is translated under the package domain.' ); + $this->assertContains( array( 'A friendly greeting.', 'widget description', 'jetpack-premium-analytics' ), $calls, 'The description is translated under the package domain.' ); + $this->assertContains( array( 'greeting', 'widget keyword', 'jetpack-premium-analytics' ), $calls, 'Each keyword is translated under the package domain.' ); + } + + /** + * A widget-declared textdomain wins over the package default. + */ + public function test_translate_widget_metadata_honors_declared_textdomain() { + $calls = array(); + $callback = static function ( $translation, $text, $context, $domain ) use ( &$calls ) { + $calls[] = array( $text, $context, $domain ); + return $translation; + }; + add_filter( 'gettext_with_context', $callback, 10, 4 ); + + translate_widget_metadata( + array( + 'title' => 'Hello world', + 'textdomain' => 'my-widget-pack', + ) + ); + + remove_filter( 'gettext_with_context', $callback ); + + $this->assertContains( array( 'Hello world', 'widget title', 'my-widget-pack' ), $calls, 'The declared textdomain is used for translation.' ); + } + + /** + * Hydration registers manifest candidates with metadata translated, + * the help note sanitized, and every field mapped onto the type. + */ + public function test_register_widget_types_hydrates_metadata_from_manifest() { + $GLOBALS['jpa_test_widget_manifest'] = array( + array( + 'name' => 'test/hydration-sentinel', + 'render_module' => 'test/hydration/render', + 'widget_module' => 'test/hydration/widget', + 'presentation' => 'framed', + 'category' => 'stats', + 'title' => 'Hydration sentinel', + 'description' => 'Carries metadata through hydration.', + 'help' => array( + 'content' => 'Read this .', + 'links' => array( + array( + 'label' => 'Docs', + 'href' => 'https://example.com/docs', + ), + ), + ), + 'keywords' => array( 'sentinel' ), + ), + ); + + try { + register_widget_types(); + + $registered = get_registered_widget_types(); + $this->assertArrayHasKey( 'test/hydration-sentinel', $registered, 'The manifest candidate is registered.' ); + + $widget_type = $registered['test/hydration-sentinel']; + $this->assertSame( 'test/hydration/render', $widget_type->render_module, 'The render module is mapped.' ); + $this->assertSame( 'framed', $widget_type->presentation, 'The presentation is mapped.' ); + $this->assertSame( 'stats', $widget_type->category, 'The category is mapped.' ); + $this->assertSame( 'Hydration sentinel', $widget_type->title, 'The title is mapped.' ); + $this->assertSame( 'Carries metadata through hydration.', $widget_type->description, 'The description is mapped.' ); + $this->assertSame( array( 'sentinel' ), $widget_type->keywords, 'The keywords are mapped.' ); + $this->assertSame( + array( + 'content' => 'Read this carefully.', + 'links' => array( + array( + 'label' => 'Docs', + 'href' => 'https://example.com/docs', + ), + ), + ), + $widget_type->help, + 'The help note is sanitized during hydration.' + ); + } finally { + Widget_Type_Registry::get_instance()->unregister( 'test/hydration-sentinel' ); + unset( $GLOBALS['jpa_test_widget_manifest'] ); + } + } + + /** + * A help note without usable string content sanitizes to null. + */ + public function test_sanitize_widget_help_requires_string_content() { + $this->assertNull( sanitize_widget_help( null ), 'Null input stays null.' ); + $this->assertNull( sanitize_widget_help( array() ), 'A help note without content is dropped.' ); + $this->assertNull( sanitize_widget_help( array( 'content' => '' ) ), 'Empty content is dropped.' ); + $this->assertNull( sanitize_widget_help( array( 'content' => 42 ) ), 'Non-string content is dropped.' ); + } + + /** + * Help content keeps only `em`/`strong` markup. + */ + public function test_sanitize_widget_help_keeps_only_emphasis_markup() { + $this->assertSame( + array( 'content' => 'Use bold, emphasis and nothing else.' ), + sanitize_widget_help( + array( 'content' => 'Use bold, emphasis and nothing else.' ) + ), + 'Only em/strong markup survives sanitization, and no other key rides along.' + ); + } + + /** + * Links missing a label or href are dropped; surviving links are reduced + * to exactly label + href. + */ + public function test_sanitize_widget_help_drops_incomplete_links() { + $this->assertSame( + array( + 'content' => 'Read the docs.', + 'links' => array( + array( + 'label' => 'Docs', + 'href' => 'https://example.com/docs', + ), + ), + ), + sanitize_widget_help( + array( + 'content' => 'Read the docs.', + 'links' => array( + array( + 'label' => 'Docs', + 'href' => 'https://example.com/docs', + 'target' => '_blank', + ), + array( 'label' => 'No href' ), + array( 'href' => 'https://example.com/no-label' ), + 'not-a-link', + ), + ) + ), + 'Only complete links survive, reduced to exactly label + href.' + ); + } + + /** + * Each link href goes through esc_url_raw(): a disallowed protocol drops + * the whole link, and safe URLs pass through unchanged. + */ + public function test_sanitize_widget_help_rejects_unsafe_link_protocols() { + $this->assertSame( + array( + 'content' => 'Read the docs.', + 'links' => array( + array( + 'label' => 'Docs', + 'href' => 'https://example.com/docs', + ), + ), + ), + sanitize_widget_help( + array( + 'content' => 'Read the docs.', + 'links' => array( + array( + 'label' => 'Bad', + 'href' => 'javascript:alert(1)', + ), + array( + 'label' => 'Docs', + 'href' => 'https://example.com/docs', + ), + ), + ) + ), + 'A link whose href does not survive esc_url_raw() is dropped; safe links pass unchanged.' + ); + } + + /** + * When no link survives, the `links` key is omitted entirely. + */ + public function test_sanitize_widget_help_omits_links_when_none_survive() { + $this->assertSame( + array( 'content' => 'Plain.' ), + sanitize_widget_help( + array( + 'content' => 'Plain.', + 'links' => array( array( 'label' => 'No href' ) ), + ) + ), + 'The links key is omitted when no link survives.' + ); + } + + /** + * A registered widget type's metadata reaches the widget-modules REST + * record intact. + */ + public function test_widget_modules_record_carries_metadata() { + $registry = Widget_Type_Registry::get_instance(); + $registry->register( + 'test/metadata-sentinel', + array( + 'render_module' => 'test/render', + 'widget_module' => 'test/widget', + 'presentation' => 'framed', + 'category' => 'stats', + 'title' => 'Sentinel', + 'description' => 'Metadata carrier.', + 'help' => array( 'content' => 'Helpful.' ), + 'keywords' => array( 'sentinel' ), + ) + ); + + $records = get_widget_modules_response()->get_data(); + + $registry->unregister( 'test/metadata-sentinel' ); + + $record = null; + foreach ( $records as $candidate ) { + if ( 'test/metadata-sentinel' === $candidate['name'] ) { + $record = $candidate; + } + } + + $this->assertNotNull( $record, 'The registered widget type appears in the REST record list.' ); + $this->assertSame( 'stats', $record['category'], 'The category reaches the record.' ); + $this->assertSame( 'Sentinel', $record['title'], 'The title reaches the record.' ); + $this->assertSame( 'Metadata carrier.', $record['description'], 'The description reaches the record.' ); + $this->assertSame( array( 'content' => 'Helpful.' ), $record['help'], 'The help note reaches the record.' ); + $this->assertSame( array( 'sentinel' ), $record['keywords'], 'The keywords reach the record.' ); + } +} diff --git a/projects/packages/premium-analytics/tests/php/fixtures/widget-modules-manifest.php b/projects/packages/premium-analytics/tests/php/fixtures/widget-modules-manifest.php new file mode 100644 index 000000000000..a1f8f380f468 --- /dev/null +++ b/projects/packages/premium-analytics/tests/php/fixtures/widget-modules-manifest.php @@ -0,0 +1,23 @@ +