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 @@
+