From 77cf3cc12ddc626a610ebaeae333f62556b04987 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Thu, 24 Sep 2026 10:39:02 +0100 Subject: [PATCH 1/4] document the widget catalog, the SDK and the Ads package widgets doc: translations per record, the SDK a plugin's widgets import, the Ads package as the real consumer; README extension entry point --- .agents/rules/widgets.md | 2 +- projects/packages/premium-analytics/README.md | 22 ++++++ .../docs/dashboard-sections.md | 49 ++++++------- .../docs/dashboard-widgets.md | 70 ++++++++++++------- 4 files changed, 92 insertions(+), 51 deletions(-) diff --git a/.agents/rules/widgets.md b/.agents/rules/widgets.md index 1ba30d83ca47..b05fd276cfbf 100644 --- a/.agents/rules/widgets.md +++ b/.agents/rules/widgets.md @@ -97,6 +97,6 @@ without them still falls back to it — the section date state this widget no lo follows. So the outer component defaults the attribute (`attributes.reportParams ?? DEFAULT_REPORT_PARAMS`) and wraps `WidgetRoot` in the scope its body supports (`` for a report with -no comparison). `widgets/wordads-chart-tabs/` is the reference. +no comparison). `projects/packages/ads/widgets/wordads-chart-tabs/` is the reference. diff --git a/projects/packages/premium-analytics/README.md b/projects/packages/premium-analytics/README.md index 542b6cfc9657..5a619bb67b42 100644 --- a/projects/packages/premium-analytics/README.md +++ b/projects/packages/premium-analytics/README.md @@ -32,6 +32,28 @@ capability-gated admin page serves the dashboard. - [Dashboard widget types](docs/dashboard-widgets.md): how a widget type is registered, filtered, served to the client and imported, and how another plugin registers one. +## Extending the dashboard from another plugin + +The package owns the dashboard, not the features: a section and its widgets belong to the code +that knows the feature is there, and `jetpack-mu-wpcom` registers on WordPress.com Simple and +Atomic where that fact is a plan feature. A plugin extends the dashboard from two actions, each +fired once when its registry hydrates on the first read after `init`: + +- `jetpack_premium_analytics_register_dashboard_sections`: call `register_dashboard_section()` + with the section's label, availability rule and default layout + ([Dashboard sections](docs/dashboard-sections.md)). +- `jetpack_premium_analytics_register_widget_types`: compare `WIDGET_API_VERSION`, require the + `build/build.php` wp-build generated for your `widgets/` folder, and call + `register_widget_types_from_manifest()` with its manifest, your text domain and the URL of your + `i18n-manifest.json` ([Dashboard widget types](docs/dashboard-widgets.md)). Your widgets import + the dashboard through `@automattic/jetpack-premium-analytics-sdk` + (`projects/js-packages/premium-analytics-sdk`); your build keeps that import external, and the + dashboard page's import map resolves it to the module this package registers. + +`projects/packages/ads` is the reference consumer: the Ads section and its three widgets, +called by the WordAds module of the Jetpack plugin and by `jetpack-mu-wpcom`. Report pages and +detail routes are the next contract; today they are the package's own. + ## Requirements - **PHP** >= 7.4 diff --git a/projects/packages/premium-analytics/docs/dashboard-sections.md b/projects/packages/premium-analytics/docs/dashboard-sections.md index 369f0c3c1c5e..cb3d21f8b827 100644 --- a/projects/packages/premium-analytics/docs/dashboard-sections.md +++ b/projects/packages/premium-analytics/docs/dashboard-sections.md @@ -21,18 +21,18 @@ This page covers sections only. Widget types have their own page, [Dashboard wid ## Files -| File | Role | -| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `src/class-dashboard-section.php` | The section model: properties, `is_available()`, `get_default_layout()`, `to_array()`. | -| `src/class-dashboard-section-registry.php` | The registry: `register()` with its validations, the reads, and the lazy hydration that fires the registration action. | -| `src/dashboard-sections.php` | The section API: the `register_dashboard_section()` family, the preview scope, the script data, the REST routes and their schema. | -| `src/dashboard-layout.php` | Layout primitives: `DASHBOARD_NAME`, the default-layout filter name, `get_dashboard_default_widget_instance()`, and the package's availability policy on default layouts. | -| `src/default-dashboard-sections.php` | The package's own sections: Traffic, Insights, Subscribers, Store, their gates and their default layouts, registered through the action like any plugin's. | -| `src/class-analytics.php` | Loads the three files above on wp-admin requests (`load_dashboard_components()`). | -| `src/class-dashboard-support-routes.php` | Loads them on REST requests (`boot_routes()`), on connected sites and, called directly by WordPress.com, on Simple. | -| `packages/data/src/entities/dashboard-entities.ts` | The `dashboardSection` core-data entity the client reads. | -| `routes/dashboard/` | `useDashboardSections()`, `useActiveSection()`, `useDashboardSectionLayout()`, and the stage that renders the navigation. | -| `routes/site-readiness.ts` | `isDashboardSectionInPreviewScope()`, the report routes' gate, fed by the inline script data. | +| File | Role | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/class-dashboard-section.php` | The section model: properties, `is_available()`, `get_default_layout()`, `to_array()`. | +| `src/class-dashboard-section-registry.php` | The registry: `register()` with its validations, the reads, and the lazy hydration that fires the registration action. | +| `src/dashboard-sections.php` | The section API: the `register_dashboard_section()` family, the preview scope, the script data, the REST routes and their schema. | +| `src/dashboard-layout.php` | Layout primitives: `DASHBOARD_NAME`, the default-layout filter name, `get_dashboard_default_widget_instance()`, and the package's availability policy on default layouts. | +| `src/default-dashboard-sections.php` | The package's own sections: Traffic, Insights, Subscribers, Store, their gates and their default layouts, registered through the action like any plugin's. | +| `src/class-analytics.php` | Loads the three files above on wp-admin requests (`load_dashboard_components()`). | +| `src/class-dashboard-support-routes.php` | Loads them on REST requests (`boot_routes()`), on connected sites and, called directly by WordPress.com, on Simple. | +| `packages/data/src/entities/dashboard-entities.ts` | The `dashboardSection` core-data entity the client reads. | +| `routes/dashboard/` | `useDashboardSections()`, `useActiveSection()`, `useDashboardSectionLayout()`, and the stage that renders the navigation. | +| `routes/site-readiness.ts` | `isDashboardSectionInPreviewScope()`, the report routes' gate, fed by the inline script data. | ## When the section files load @@ -185,25 +185,26 @@ A section that fails either rule is absent from the sections route, from the scr ![The WordAds module registers the Ads section on self-hosted sites and skips the WordPress.com platform, jetpack-mu-wpcom registers it on Atomic and Simple by plan feature, both at priority 20 skipping an existing ads slug, and the standalone plugin has no registrant.](diagrams/sections-ads-owners.svg) -WordAds is a module of the Jetpack plugin, so on a self-hosted site the module registers the section: `modules/wordads/php/class-wordads-premium-analytics.php` hooks the action at priority 20 from `class-wordads.php`, which loads only while the module is active on a connected site. +The section, its layout and its widgets live in the `jetpack-ads` package (`projects/packages/ads`, `Analytics_Dashboard`), which decides nothing about who gets Ads. WordAds is a module of the Jetpack plugin, so on a self-hosted site the module calls `Analytics_Dashboard::init()`: `modules/wordads/php/class-wordads-premium-analytics.php`, from `class-wordads.php`, which loads only while the module is active on a connected site. The package hooks the action at priority 20. -On the WordPress.com platform the module registrant skips, `Host::is_wpcom_platform()`, and `jetpack-mu-wpcom` registers the section instead when the plan includes WordAds (`src/features/premium-analytics/wordads-section.php`): Simple runs no Jetpack plugin, and on Atomic the module is routinely off while the plan carries the feature. One owner per environment, decided in code rather than by hook order. +On the WordPress.com platform the module registrant skips, `Host::is_wpcom_platform()`, and `jetpack-mu-wpcom` calls the package's registrants instead when the plan includes WordAds (`src/features/premium-analytics/wordads-section.php`): Simple runs no Jetpack plugin, and on Atomic the module is routinely off while the plan carries the feature. One owner per environment, decided in code rather than by hook order. The Jetpack plugin bundles the package and `jetpack-mu-wpcom` lists it as a test-only dependency: WordPress.com loads the Jetpack copy, so Simple and Atomic serve the widget bundles from it, the way they serve this package. Both registrants skip when a section with slug `ads` already exists. That matters during a deploy skew only: an older package that still registers the section itself keeps it. They read the slug through `get_registered_by_slug()` when the package offers it and through `get_all_registered()` otherwise, since the package and the registrants ship on different cadences. -Both register the same layout, `Analytics_Dashboard::get_default_layout()`, of the `wordads/*` widget types the same class registers. The standalone `premium-analytics` plugin has no registrant, and no Ads section. +Both register the same layout, `Analytics_Dashboard::get_default_layout()`, of the `wordads/*` widget types the same class registers (see [Dashboard widget types](dashboard-widgets.md#a-real-consumer-the-ads-widgets)). The standalone `premium-analytics` plugin has no registrant, and no Ads section. ## Where the tests are -| Behaviour | Test | -| ----------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -| Registry rules, hydration and the action, the built-in sections, preview scope, the sections route and its schema | `tests/php/Dashboard_Section_Test.php` | -| Default-layout primitives, the filter and the package's policy, the bundled layouts | `tests/php/Dashboard_Layout_Test.php` | -| The WordAds module's registrant | `projects/plugins/jetpack/tests/php/modules/wordads/WordAds_Premium_Analytics_Test.php` | -| The WordPress.com registrant | `projects/packages/jetpack-mu-wpcom/tests/php/features/premium-analytics/Wordads_Section_Test.php` | -| The REST entry point WordPress.com calls | `tests/php/Dashboard_Support_Routes_Test.php` | -| Navigation, active section, stored layouts, section heading | `routes/dashboard/**/*.test.ts(x)` | -| Reports behind a hidden section | `routes/reports/registry.test.ts`, `tests/js/site-readiness.test.ts` | +| Behaviour | Test | +| ----------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| Registry rules, hydration and the action, the built-in sections, preview scope, the sections route and its schema | `tests/php/Dashboard_Section_Test.php` | +| Default-layout primitives, the filter and the package's policy, the bundled layouts | `tests/php/Dashboard_Layout_Test.php` | +| The WordAds module's registrant | `projects/plugins/jetpack/tests/php/modules/wordads/WordAds_Premium_Analytics_Test.php` | +| The Ads package's registrants, with and without the widget contract loaded | `projects/packages/ads/tests/php/Analytics_Dashboard_Test.php`, `projects/packages/ads/tests/php/Analytics_Dashboard_Without_Widget_Types_Test.php` | +| The WordPress.com registrant | `projects/packages/jetpack-mu-wpcom/tests/php/features/premium-analytics/Wordads_Section_Test.php` | +| The REST entry point WordPress.com calls | `tests/php/Dashboard_Support_Routes_Test.php` | +| Navigation, active section, stored layouts, section heading | `routes/dashboard/**/*.test.ts(x)` | +| Reports behind a hidden section | `routes/reports/registry.test.ts`, `tests/js/site-readiness.test.ts` | ## Not covered here diff --git a/projects/packages/premium-analytics/docs/dashboard-widgets.md b/projects/packages/premium-analytics/docs/dashboard-widgets.md index 5f9783103b69..7ec91fd65df8 100644 --- a/projects/packages/premium-analytics/docs/dashboard-widgets.md +++ b/projects/packages/premium-analytics/docs/dashboard-widgets.md @@ -8,27 +8,31 @@ This page covers the registration path. Sections, which place widget instances i ## Vocabulary -| Term | Meaning | Example | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------- | -| Widget type name | Namespaced identifier, `/`, lowercase. The namespace names the owner. | `jpa/clicks`, `wordads/highlights` | -| Render module, widget module | The script-module ids of the widget's render entry and metadata entry, which the client `import()`s through the page import map. | `jetpack-premium-analytics/widgets/wordads-highlights/render` | -| Manifest | The widgets wp-build discovered under `widgets/`, generated into `build/widgets.php` and read through `jpa_get_registered_widget_modules()`. | see `Analytics::widget_manifest_path()` | -| Candidate | A manifest entry before the registry-time filter. A dropped candidate never registers. | `jetpack_premium_analytics_registrable_widget_types` | -| Metadata | `presentation`, `category`, `title`, `description`, `help`, `icon`, `actions`, `keywords`: what the picker shows, translated and sanitized on the way into the registry. | see `Widget_Type` | +| Term | Meaning | Example | +| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| Widget type name | Namespaced identifier, `/`, lowercase. The namespace names the owner. | `jpa/clicks`, `wordads/highlights` | +| Render module, widget module | The script-module ids of the widget's render entry and metadata entry, which the client `import()`s through the page import map. | `jetpack-premium-analytics/widgets/clicks/render` | +| Manifest | The widgets wp-build discovered under `widgets/`, generated into `build/widgets.php` and read through `jpa_get_registered_widget_modules()`. | see `Analytics::widget_manifest_path()` | +| Candidate | A manifest entry before the registry-time filter. A dropped candidate never registers. | `jetpack_premium_analytics_registrable_widget_types` | +| Metadata | `presentation`, `category`, `title`, `description`, `help`, `icon`, `actions`, `keywords`: what the picker shows, translated and sanitized on the way into the registry. | see `Widget_Type` | +| Catalog location | `textdomain` and `i18n_manifest`: the text domain the widget's bundles are stamped with, and the i18n manifest of the build that serves them, for the client's catalog loads. | see `Widget_Type` | +| SDK | `@automattic/jetpack-premium-analytics-sdk`: the one name a plugin's widgets import the dashboard through. A types-only package holds the contract; this package registers the implementation under that name. | `projects/js-packages/premium-analytics-sdk` | ## Files | File | Role | | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `src/class-widget-type.php` | The widget type model: the name, the two module ids, the metadata fields, `is_renderable()`. | +| `src/class-widget-type.php` | The widget type model: the name, the two module ids, the metadata fields, the catalog location, `is_renderable()`. | | `src/class-widget-type-registry.php` | The registry: `register()` with its validations, the reads, and the lazy hydration that fires the registration action. | | `src/widget-types.php` | The widget type API: `register_widget_types_from_manifest()`, `register_widget_type()`, `WIDGET_API_VERSION`, the package's own registrant, the metadata translation and sanitizers, the two availability filters, `get_available_widget_types()`. | | `src/widget-availability.php` | The package's policy on manifest candidates: environment, plugins present, capabilities. | | `src/widget-type-support.php` | The package's policy on default layouts: which types a site cannot serve. | | `src/widget-modules.php` | The two readers: `ensure_widget_registry_ready()`, the `/wpcom/v2/widget-modules` route, and the import-map entries on the page boot dependencies. | +| `src/sdk-module.php` | Registers the facade module built from `packages/sdk` a second time, as `@automattic/jetpack-premium-analytics-sdk`, with the facade's dependencies and version. | +| `packages/sdk/` | The SDK facade: re-exports of the toolkit, data, fields, dates and shared primitives a widget imports, built as the `@jetpack-premium-analytics/sdk` module. | | `build/widgets.php` | Generated by wp-build: the manifest, and `jpa_register_widget_modules()`, which registers the package's script modules. | | `routes/use-widget-modules.ts` | The client's read of the records, as a core-data entity. | -| `routes/widget-module-i18n.ts` | The client resolver: loads a widget bundle's translation catalog, then imports the module. | +| `routes/widget-module-i18n.ts` | The client resolver: loads a widget bundle's translation catalog from the domain and manifest its record declares, then imports the module. | ## When the widget type files load @@ -63,39 +67,42 @@ add_action( register_widget_types_from_manifest( jetpack_videopress_get_registered_widget_modules(), - array( 'textdomain' => 'jetpack-videopress-pkg' ) + array( + 'textdomain' => 'jetpack-videopress-pkg', + 'i18n_manifest' => plugins_url( 'i18n-manifest.json', __DIR__ . '/build/build.php' ), + ) ); }, 20 ); ``` -The `require_once` of the generated `build/build.php` is what registers the plugin's widget script modules: the generated `build/widgets.php` hooks that on `init` by itself. `jetpack_videopress_get_registered_widget_modules()` is the manifest accessor wp-build generates from `wpPlugin.name`; guard it with `function_exists()` where the build can be absent. +The `require_once` of the generated `build/build.php` is what registers the plugin's widget script modules: the generated `build/widgets.php` hooks that on `init` by itself, or runs at once when `init` has fired, so a plugin that pays the registration only on sites that qualify requires it from inside the callback. `jetpack_videopress_get_registered_widget_modules()` is the manifest accessor wp-build generates from `wpPlugin.name`; guard it with `function_exists()` where the build can be absent. `i18n_manifest` is the URL of the build's `i18n-manifest.json`, which `stamp-textdomains` writes next to the bundles. A single type written by hand goes through `register_widget_type( $name, $args )`, the primitive the helper is built on. The mechanics behind it: - **The contract.** The action hands over the `Widget_Type_Registry` being hydrated. `register_widget_types_from_manifest()` and `register_widget_type()` are what a plugin calls; both write to the main instance, which is that same registry in production. `$registry` is there for lookups, `is_registered()` and `get_all_registered()`. The package's own registrant writes into `$registry` directly, so a test can hydrate a fresh instance. -- **Manifests.** The helper runs the candidates through `jetpack_premium_analytics_registrable_widget_types`, gives each one without a `textdomain` the one passed in `$args`, translates the metadata with `translate_widget_metadata()`, sanitizes `help`, `icon` and `actions`, and skips a name already registered. The package's own `register_widget_types()` is its first caller. +- **Manifests.** The helper runs the candidates through `jetpack_premium_analytics_registrable_widget_types`, gives each one without a `textdomain` or an `i18n_manifest` the ones passed in `$args`, translates the metadata with `translate_widget_metadata()`, sanitizes `help`, `icon` and `actions`, and skips a name already registered. The package's own `register_widget_types()` is its first caller, with its text domain and no manifest: the page's boot init module loads the package's catalogs. - **Loading.** The files are required by `ensure_widget_registry_ready()`, not autoloaded. The action fires from the registry those files load, so a callback on it runs only once the API is there and needs no `function_exists()` guard. - **Validation in `register()`.** The name must be a lowercase `/` string, and must not be registered. Each failure is a `_doing_it_wrong()` and a `false` return. -- **Arguments.** Any public property of `Widget_Type`: `render_module`, `widget_module`, `presentation` (`framed`, `content-bleed` or `full-bleed`), `category`, `title`, `description`, `help` (`content` plus optional `links`), `icon` (`collection/name`), `actions`, `keywords`. `set_props()` copies every key onto the instance. Through `register_widget_type()` the strings arrive translated and `help`, `icon` and `actions` in shape; the manifest helper translates and sanitizes them itself. +- **Arguments.** Any public property of `Widget_Type`: `render_module`, `widget_module`, `presentation` (`framed`, `content-bleed` or `full-bleed`), `category`, `title`, `description`, `help` (`content` plus optional `links`), `icon` (`collection/name`), `actions`, `keywords`, `textdomain`, `i18n_manifest`. `set_props()` copies every key onto the instance. Through `register_widget_type()` the strings arrive translated and `help`, `icon` and `actions` in shape; the manifest helper translates and sanitizes them itself. - **Version.** `WIDGET_API_VERSION` names the contract a widget is built against (see below). A consumer compares it in the callback and skips registration when the major differs. -- **Script modules.** The module ids are what the client hands to `import()`. The package's are registered by the generated `jpa_register_widget_modules()`; a plugin's by the same generated file of its own build, required at plugin load, from a build that keeps `@jetpack-premium-analytics/widgets-toolkit` and `@jetpack-premium-analytics/data` external (`wpPlugin.externalNamespaces`), so they resolve through the page import map. +- **Script modules.** The module ids are what the client hands to `import()`. The package's are registered by the generated `jpa_register_widget_modules()`; a plugin's by the same generated file of its own build. A plugin's widgets import the dashboard through `@automattic/jetpack-premium-analytics-sdk` (`projects/js-packages/premium-analytics-sdk`), a types-only package that declares itself a script module (`wpScriptModuleExports`): wp-build finds it installed under that name, leaves the import external (`wpPlugin.externalNamespaces` lists the `automattic` scope) and records it as a module dependency of the widget. `src/sdk-module.php` registers the facade built from `packages/sdk` under that same name on `wp_default_scripts`, so the page import map resolves the SDK to the facade and the facade to the dashboard's own modules: one React, one toolkit, one query client for the dashboard and every widget on the page. - **Hydration.** `Widget_Type_Registry` fires the action from `ensure_hydrated()`, which `get_registered()` and `get_all_registered()` call on their first read after `init`. The latch is set before the action fires, so a callback that reads the registry does not re-enter it. `is_registered()` does not hydrate: `register()` relies on it, and a registrant may run before the action. - **Order.** The package's own widget types register at priority 10; a plugin that wants to see them registered first hooks later. -- **Translations on the client.** `routes/widget-module-i18n.ts` loads a translation catalog before importing a module only for the package's own bundles, the ids under `jetpack-premium-analytics/widgets/`. A plugin's module imports through the bare `import()` fallback, and its strings render in English on a localized site until the records carry a text domain and a manifest of their own. +- **Translations on the client.** Every record says where its bundles' catalogs live. `routes/widget-module-i18n.ts` derives the bundle path from the module id, `{handle-prefix}/widgets/{dir}/{render,widget}` to `build/widgets/{dir}/{render,widget}.js`, whatever the prefix; `createWidgetModuleResolver()` caches the record's manifest by URL once (`loadI18nManifest()` in wp-build-polyfills) and loads the bundle's catalog under the record's `textdomain` before importing, and the metadata bundles are preloaded the same way. A record without a text domain is treated as the package's own. What the catalog load hashes is the bundle path relative to the plugin, the way WordPress names JS translation files, so a package vendored inside a plugin needs its text domain aliased in the plugin's `i18n-map.php`, as this package's is. ## From the registry to the client On the server, `get_available_widget_types()` runs the registered map through `jetpack_premium_analytics_widget_types`, the runtime filter, and both readers use it, so the REST list and the import map share one policy. -`GET /wpcom/v2/widget-modules` returns one record per available type: `name`, `render_module`, `widget_module` and the metadata fields. It is gated on `Capabilities::current_user_can_view_analytics()`, the dashboard's own gate, and the `wpcom/v2` namespace is what lets WordPress.com expose it through public-api for Simple sites. +`GET /wpcom/v2/widget-modules` returns one record per available type: `name`, `render_module`, `widget_module`, the metadata fields, `textdomain` and `i18n_manifest`. It is gated on `Capabilities::current_user_can_view_analytics()`, the dashboard's own gate, and the `wpcom/v2` namespace is what lets WordPress.com expose it through public-api for Simple sites. `add_widget_modules_to_boot_deps()` adds each `render_module` and `widget_module` as a dynamic dependency of the page, which the generated page loader turns into import-map entries. A type whose module id no script module claims imports nothing, and an instance of it renders as "Widget is no longer available". -On the client, `useWidgetModules()` reads the records as a core-data entity, `useWidgetTypesWithI18n()` resolves the ones the active layout renders, and the widget dashboard offers the types in the picker and imports an instance's render module through `resolveWidgetModuleWithI18n()` when it renders. +On the client, `useWidgetModules()` reads the records as a core-data entity, `useWidgetTypesWithI18n()` resolves the ones the active layout renders, and the widget dashboard offers the types in the picker and imports an instance's render module through the resolver `useWidgetModuleResolver()` builds from the records when it renders. ## Availability @@ -110,20 +117,31 @@ A third policy, in `src/widget-type-support.php`, acts on default layouts rather ## Versioning the contract -`WIDGET_API_VERSION` names the contract a widget is built against: the shared module ids (`@jetpack-premium-analytics/widgets-toolkit`, `@jetpack-premium-analytics/data`, `@jetpack-premium-analytics/externals`), the `Widget_Type` fields the client reads, and the toolkit exports a widget relies on. The major changes when a widget built against the previous contract stops working; the minor when a consumer can rely on something new. +`WIDGET_API_VERSION` names the contract a widget is built against: the `@automattic/jetpack-premium-analytics-sdk` module and the exports it declares, the dashboard modules the facade re-exports from (`@jetpack-premium-analytics/widgets-toolkit`, `data`, `fields`, `datetime`, `externals`), and the `Widget_Type` fields the client reads. The major changes when a widget built against the previous contract stops working; the minor when a consumer can rely on something new. Inside `plugins/jetpack` the package and a consumer module ship together, so the check is a formality. With the standalone `plugins/premium-analytics` next to another plugin, each brings its own copy, and the check is what keeps a widget built against 1.x from registering on a 2.x package. +## A real consumer: the Ads widgets + +The three Ads widgets live in `projects/packages/ads`, a widgets-only wp-build project (`wpPlugin.name` `jetpack_ads`, handle prefix `jetpack-ads`, so the module ids are `jetpack-ads/widgets//render` and `…/widget`). Their code imports the dashboard by one name, `@automattic/jetpack-premium-analytics-sdk`: the package depends on it with `workspace:*` and lists the `automattic` scope in `wpPlugin.externalNamespaces`, and wp-build, which keeps a specifier external only when it finds that package installed under the specifier and declaring `wpScriptModuleExports`, leaves the import external the way it does `@wordpress/*`. The SDK package (`projects/js-packages/premium-analytics-sdk`) holds the contract only, the types of what a widget can import; this package provides the implementation, `packages/sdk`, a facade over the toolkit, data, fields, dates and shared primitives, built as `@jetpack-premium-analytics/sdk` and registered a second time under the SDK's name by `src/sdk-module.php`. So a widget runs on the module instances the dashboard renders with, and no `../` path or workspace alias points from the Ads package at this one: it requires this package with Composer, since it registers against its API. + +`Analytics_Dashboard::init()` hooks two registrants at priority 20. `register_section()` registers `wordads/ads` with its layout of `wordads/chart-tabs`, `wordads/highlights` and `wordads/earnings-history`, unless the `ads` slug is taken or `WIDGET_API_VERSION` moved to a major the package was not built against; an undefined version is not a mismatch, since the sections REST route hydrates the section registry before the dashboard loads `widget-types.php`. `register_widget_types()` waits for that version, requires the generated `build/build.php` from inside the callback, so the script modules register on the spot after `init` and only on sites that qualify, and hands the manifest to `register_widget_types_from_manifest()` with the text domain `jetpack-ads-pkg` and the URL of its `i18n-manifest.json`. + +Who calls it is the section's story, in [Dashboard sections](dashboard-sections.md#a-real-consumer-the-ads-section): the WordAds module outside the WordPress.com platform, `jetpack-mu-wpcom` on Simple and Atomic by plan feature, both against the copy the Jetpack plugin bundles, so Simple, which runs no Jetpack module, serves the bundles from it too. The types were `jpa/wordads-*` while they lived here; a layout persisted with those names renders its tiles as unavailable until it is reset. + ## Where the tests are -| Behaviour | Test | -| -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | -| Hydration and the action, `register_widget_type()`, `register_widget_types_from_manifest()`, `WIDGET_API_VERSION`, a plugin's type reaching both readers | `tests/php/Widget_Type_Registry_Test.php` | -| Metadata translation and sanitizing, the REST record | `tests/php/Widget_Metadata_Test.php` | -| The route's namespace and gate, hydration from the route | `tests/php/Widget_Modules_Test.php`, `tests/php/Analytics_Test.php` | -| The package's candidate policy and the runtime filter | `tests/php/Widget_Availability_Test.php` | -| The client's records read | `routes/use-widget-modules.test.ts` | +| Behaviour | Test | +| -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Hydration and the action, `register_widget_type()`, `register_widget_types_from_manifest()`, `WIDGET_API_VERSION`, a plugin's type reaching both readers | `tests/php/Widget_Type_Registry_Test.php` | +| Metadata translation and sanitizing, the REST record | `tests/php/Widget_Metadata_Test.php` | +| The route's namespace and gate, hydration from the route | `tests/php/Widget_Modules_Test.php`, `tests/php/Analytics_Test.php` | +| The package's candidate policy and the runtime filter | `tests/php/Widget_Availability_Test.php` | +| The client's records read | `routes/use-widget-modules.test.ts` | +| The client's catalog loads per record, the resolver, the metadata preload | `tests/js/widget-module-i18n.test.tsx`, `wp-build-polyfills/tests/js/load-i18n-catalogs.test.js` | +| The SDK registration: the facade's id, bundle, dependencies and version | `tests/php/Sdk_Module_Test.php` | +| The Ads package's registrants, with and without the widget contract loaded, and the module and mu-wpcom callers | `packages/ads/tests/php/Analytics_Dashboard_Test.php`, `packages/ads/tests/php/Analytics_Dashboard_Without_Widget_Types_Test.php`, `plugins/jetpack/tests/php/modules/wordads/WordAds_Premium_Analytics_Test.php`, `packages/jetpack-mu-wpcom/tests/php/features/premium-analytics/Wordads_Section_Test.php` | ## Not covered here -Translation catalogs for a module built by another plugin, and the first plugin-owned widgets, the Ads widgets from the WordAds module, are the next steps of this stack. +Stories and JS tests for a plugin's widgets: the Ads widgets left this package's Storybook and jest harness with their move, and `packages/ads` has neither yet. Precise types for the SDK: `projects/js-packages/premium-analytics-sdk` declares its exports loosely until a build step emits them from the facade. An alias for a renamed widget type in persisted layouts, so a move like the Ads one needs no reset. The metadata strings of `widget.json` reach no catalog in any package until the strings stub ships. From 38d0bb7cb3e4283f2fb2f0f7d6d36646154657cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Mon, 28 Sep 2026 11:07:29 +0100 Subject: [PATCH 2/4] add changelog entry for the widget docs --- .../changelog/update-pa-extensibility-widget-docs | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 projects/packages/premium-analytics/changelog/update-pa-extensibility-widget-docs diff --git a/projects/packages/premium-analytics/changelog/update-pa-extensibility-widget-docs b/projects/packages/premium-analytics/changelog/update-pa-extensibility-widget-docs new file mode 100644 index 000000000000..92c804564e3e --- /dev/null +++ b/projects/packages/premium-analytics/changelog/update-pa-extensibility-widget-docs @@ -0,0 +1,5 @@ +Significance: patch +Type: changed +Comment: Docs only: the SDK a plugin's widgets import and the Ads package as the reference consumer; nothing a site owner can observe. + + From 77e110644bb996e27420f3293c77b847619213ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Mon, 28 Sep 2026 11:21:09 +0100 Subject: [PATCH 3/4] restructure the widget docs around headings one h3 per concern and short paragraphs instead of bullet lists --- projects/packages/premium-analytics/README.md | 36 ++-- .../docs/dashboard-sections.md | 24 ++- .../docs/dashboard-widgets.md | 191 ++++++++++++++---- 3 files changed, 189 insertions(+), 62 deletions(-) diff --git a/projects/packages/premium-analytics/README.md b/projects/packages/premium-analytics/README.md index 5a619bb67b42..7b30d0bd9891 100644 --- a/projects/packages/premium-analytics/README.md +++ b/projects/packages/premium-analytics/README.md @@ -34,25 +34,23 @@ capability-gated admin page serves the dashboard. ## Extending the dashboard from another plugin -The package owns the dashboard, not the features: a section and its widgets belong to the code -that knows the feature is there, and `jetpack-mu-wpcom` registers on WordPress.com Simple and -Atomic where that fact is a plan feature. A plugin extends the dashboard from two actions, each -fired once when its registry hydrates on the first read after `init`: - -- `jetpack_premium_analytics_register_dashboard_sections`: call `register_dashboard_section()` - with the section's label, availability rule and default layout - ([Dashboard sections](docs/dashboard-sections.md)). -- `jetpack_premium_analytics_register_widget_types`: compare `WIDGET_API_VERSION`, require the - `build/build.php` wp-build generated for your `widgets/` folder, and call - `register_widget_types_from_manifest()` with its manifest, your text domain and the URL of your - `i18n-manifest.json` ([Dashboard widget types](docs/dashboard-widgets.md)). Your widgets import - the dashboard through `@automattic/jetpack-premium-analytics-sdk` - (`projects/js-packages/premium-analytics-sdk`); your build keeps that import external, and the - dashboard page's import map resolves it to the module this package registers. - -`projects/packages/ads` is the reference consumer: the Ads section and its three widgets, -called by the WordAds module of the Jetpack plugin and by `jetpack-mu-wpcom`. Report pages and -detail routes are the next contract; today they are the package's own. +The package owns the dashboard, not the features. A section and its widgets belong to the code that knows the feature is there, and `jetpack-mu-wpcom` registers on WordPress.com Simple and Atomic where that fact is a plan feature. + +A plugin extends the dashboard from two actions. Each fires once, when its registry hydrates on the first read after `init`. + +### Sections + +Hook `jetpack_premium_analytics_register_dashboard_sections` and call `register_dashboard_section()` with the section's label, availability rule and default layout. See [Dashboard sections](docs/dashboard-sections.md). + +### Widget types + +Hook `jetpack_premium_analytics_register_widget_types`, compare `WIDGET_API_VERSION`, require the `build/build.php` wp-build generated for your `widgets/` folder, and call `register_widget_types_from_manifest()` with its manifest, your text domain and the URL of your `i18n-manifest.json`. See [Dashboard widget types](docs/dashboard-widgets.md). + +Your widgets import the dashboard through `@automattic/jetpack-premium-analytics-sdk` (`projects/js-packages/premium-analytics-sdk`). Your build keeps that import external, and the dashboard page's import map resolves it to the module this package registers. + +### The reference consumer + +`projects/packages/ads`: the Ads section and its three widgets, called by the WordAds module of the Jetpack plugin and by `jetpack-mu-wpcom`. Report pages and detail routes are the next contract; today they are the package's own. ## Requirements diff --git a/projects/packages/premium-analytics/docs/dashboard-sections.md b/projects/packages/premium-analytics/docs/dashboard-sections.md index cb3d21f8b827..fb294d0fd72f 100644 --- a/projects/packages/premium-analytics/docs/dashboard-sections.md +++ b/projects/packages/premium-analytics/docs/dashboard-sections.md @@ -185,11 +185,29 @@ A section that fails either rule is absent from the sections route, from the scr ![The WordAds module registers the Ads section on self-hosted sites and skips the WordPress.com platform, jetpack-mu-wpcom registers it on Atomic and Simple by plan feature, both at priority 20 skipping an existing ads slug, and the standalone plugin has no registrant.](diagrams/sections-ads-owners.svg) -The section, its layout and its widgets live in the `jetpack-ads` package (`projects/packages/ads`, `Analytics_Dashboard`), which decides nothing about who gets Ads. WordAds is a module of the Jetpack plugin, so on a self-hosted site the module calls `Analytics_Dashboard::init()`: `modules/wordads/php/class-wordads-premium-analytics.php`, from `class-wordads.php`, which loads only while the module is active on a connected site. The package hooks the action at priority 20. +### Who owns the section -On the WordPress.com platform the module registrant skips, `Host::is_wpcom_platform()`, and `jetpack-mu-wpcom` calls the package's registrants instead when the plan includes WordAds (`src/features/premium-analytics/wordads-section.php`): Simple runs no Jetpack plugin, and on Atomic the module is routinely off while the plan carries the feature. One owner per environment, decided in code rather than by hook order. The Jetpack plugin bundles the package and `jetpack-mu-wpcom` lists it as a test-only dependency: WordPress.com loads the Jetpack copy, so Simple and Atomic serve the widget bundles from it, the way they serve this package. +The section, its layout and its widgets live in the `jetpack-ads` package (`projects/packages/ads`, `Analytics_Dashboard`). The package decides nothing about who gets Ads; whoever calls it hooks the action at priority 20. -Both registrants skip when a section with slug `ads` already exists. That matters during a deploy skew only: an older package that still registers the section itself keeps it. They read the slug through `get_registered_by_slug()` when the package offers it and through `get_all_registered()` otherwise, since the package and the registrants ship on different cadences. +### On self-hosted sites + +WordAds is a module of the Jetpack plugin, so the module calls `Analytics_Dashboard::init()` from `modules/wordads/php/class-wordads-premium-analytics.php`. `class-wordads.php` loads it only while the module is active on a connected site. + +### On WordPress.com + +The module registrant skips the platform, `Host::is_wpcom_platform()`. `jetpack-mu-wpcom` calls the package's registrants instead when the plan includes WordAds, from `src/features/premium-analytics/wordads-section.php`. + +Simple runs no Jetpack plugin, and on Atomic the module is routinely off while the plan carries the feature. One owner per environment, decided in code rather than by hook order. + +The Jetpack plugin bundles the package, and `jetpack-mu-wpcom` lists it as a test-only dependency. WordPress.com loads the Jetpack copy, so Simple and Atomic serve the widget bundles from it, the way they serve this package. + +### Deploy skew + +Both registrants skip when a section with slug `ads` already exists. That matters during a deploy skew only: an older package that still registers the section itself keeps it. + +They read the slug through `get_registered_by_slug()` when the package offers it and through `get_all_registered()` otherwise, since the package and the registrants ship on different cadences. + +### The layout Both register the same layout, `Analytics_Dashboard::get_default_layout()`, of the `wordads/*` widget types the same class registers (see [Dashboard widget types](dashboard-widgets.md#a-real-consumer-the-ads-widgets)). The standalone `premium-analytics` plugin has no registrant, and no Ads section. diff --git a/projects/packages/premium-analytics/docs/dashboard-widgets.md b/projects/packages/premium-analytics/docs/dashboard-widgets.md index 7ec91fd65df8..af54ad060a95 100644 --- a/projects/packages/premium-analytics/docs/dashboard-widgets.md +++ b/projects/packages/premium-analytics/docs/dashboard-widgets.md @@ -2,21 +2,21 @@ How a widget type of the Premium Analytics dashboard is registered, filtered, served to the client, and imported. A widget type is a namespaced name plus the script modules and the metadata the client needs to offer it in the picker and render it in a section's layout. -Widget types are registered on the server by whoever owns them, the package for the widgets in its build and another plugin for its own, through a single registry, and the client offers whatever the server publishes. +Widget types are registered on the server by whoever owns them: the package for the widgets in its build, another plugin for its own. One registry holds them all, and the client offers whatever the server publishes. This page covers the registration path. Sections, which place widget instances in default layouts, have their own page, [Dashboard sections](dashboard-sections.md). ## Vocabulary -| Term | Meaning | Example | -| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | -| Widget type name | Namespaced identifier, `/`, lowercase. The namespace names the owner. | `jpa/clicks`, `wordads/highlights` | -| Render module, widget module | The script-module ids of the widget's render entry and metadata entry, which the client `import()`s through the page import map. | `jetpack-premium-analytics/widgets/clicks/render` | -| Manifest | The widgets wp-build discovered under `widgets/`, generated into `build/widgets.php` and read through `jpa_get_registered_widget_modules()`. | see `Analytics::widget_manifest_path()` | -| Candidate | A manifest entry before the registry-time filter. A dropped candidate never registers. | `jetpack_premium_analytics_registrable_widget_types` | -| Metadata | `presentation`, `category`, `title`, `description`, `help`, `icon`, `actions`, `keywords`: what the picker shows, translated and sanitized on the way into the registry. | see `Widget_Type` | -| Catalog location | `textdomain` and `i18n_manifest`: the text domain the widget's bundles are stamped with, and the i18n manifest of the build that serves them, for the client's catalog loads. | see `Widget_Type` | -| SDK | `@automattic/jetpack-premium-analytics-sdk`: the one name a plugin's widgets import the dashboard through. A types-only package holds the contract; this package registers the implementation under that name. | `projects/js-packages/premium-analytics-sdk` | +| Term | Meaning | Example | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- | +| Widget type name | Namespaced identifier, `/`, lowercase. The namespace names the owner. | `jpa/clicks`, `wordads/highlights` | +| Render module, widget module | The script-module ids of the widget's render entry and metadata entry, which the client `import()`s through the page import map. | `jetpack-premium-analytics/widgets/clicks/render` | +| Manifest | The widgets wp-build discovered under `widgets/`, generated into `build/widgets.php` and read through `jpa_get_registered_widget_modules()`. | see `Analytics::widget_manifest_path()` | +| Candidate | A manifest entry before the registry-time filter. A dropped candidate never registers. | `jetpack_premium_analytics_registrable_widget_types` | +| Metadata | `presentation`, `category`, `title`, `description`, `help`, `icon`, `actions`, `keywords`: what the picker shows, translated and sanitized on the way into the registry. | see `Widget_Type` | +| Catalog location | `textdomain` and `i18n_manifest`: the text domain the widget's bundles are stamped with, and the i18n manifest of the build that serves them. | see `Widget_Type` | +| SDK | `@automattic/jetpack-premium-analytics-sdk`: the one name a plugin's widgets import the dashboard through. | `projects/js-packages/premium-analytics-sdk` | ## Files @@ -28,7 +28,7 @@ This page covers the registration path. Sections, which place widget instances i | `src/widget-availability.php` | The package's policy on manifest candidates: environment, plugins present, capabilities. | | `src/widget-type-support.php` | The package's policy on default layouts: which types a site cannot serve. | | `src/widget-modules.php` | The two readers: `ensure_widget_registry_ready()`, the `/wpcom/v2/widget-modules` route, and the import-map entries on the page boot dependencies. | -| `src/sdk-module.php` | Registers the facade module built from `packages/sdk` a second time, as `@automattic/jetpack-premium-analytics-sdk`, with the facade's dependencies and version. | +| `src/sdk-module.php` | Registers the facade module built from `packages/sdk` a second time, as `@automattic/jetpack-premium-analytics-sdk`. | | `packages/sdk/` | The SDK facade: re-exports of the toolkit, data, fields, dates and shared primitives a widget imports, built as the `@jetpack-premium-analytics/sdk` module. | | `build/widgets.php` | Generated by wp-build: the manifest, and `jpa_register_widget_modules()`, which registers the package's script modules. | | `routes/use-widget-modules.ts` | The client's read of the records, as a core-data entity. | @@ -36,19 +36,27 @@ This page covers the registration path. Sections, which place widget instances i ## When the widget type files load -`ensure_widget_registry_ready()` runs ahead of the two reads, and only then: on Simple the REST registration runs on every public-api request, and most never read the registry. It requires `widget-types.php` and `widget-availability.php`, then the manifest, so that when the registry hydrates, the package's registrant finds `jpa_get_registered_widget_modules()` and the registry-time filter is hooked. +`ensure_widget_registry_ready()` runs ahead of the two reads, and only then. On Simple the REST registration runs on every public-api request, and most never read the registry. + +It requires `widget-types.php` and `widget-availability.php`, then the manifest. When the registry hydrates, the package's registrant finds `jpa_get_registered_widget_modules()` and the registry-time filter is hooked. The two reads are `get_widget_modules_response()`, the REST route the client fetches, and `add_widget_modules_to_boot_deps()`, the callback on `jetpack-premium-analytics-wp-admin_boot_dependencies` that puts each module id in the page import map. Both happen after `init`. On WordPress.com Simple the route runs from public-api, through `Dashboard_Support_Routes::register()`. That is why the registration moment is not `init`: the registry hydrates on its first read, whichever reader gets there first, and fires one action then. -A read before `init` is a `_doing_it_wrong()`: it answers only what was registered directly, and does not latch, so the registrants hooked later still run on the first read after `init`. +A read before `init` is a `_doing_it_wrong()`. It answers only what was registered directly and does not latch, so the registrants hooked later still run on the first read after `init`. ## Registering a widget type ![The first read of the widget type registry after init latches, fires the registration action once, the package registers the manifest widgets at priority 10 and a plugin registers at priority 20, and register() refuses unnamespaced and duplicate names.](diagrams/widgets-hydration.svg) -The package's own widget types are registered by `register_widget_types()` in `src/widget-types.php`, from a callback on the action at priority 10, into the registry the action hands over. Each manifest candidate that survives `jetpack_premium_analytics_registrable_widget_types` is translated by `translate_widget_metadata()`, sanitized (`help`, `icon`, `actions`) and registered, skipped when its name is already registered. +### The package's own widgets + +`register_widget_types()` in `src/widget-types.php` registers them from a callback on the action at priority 10, into the registry the action hands over. + +Each manifest candidate that survives `jetpack_premium_analytics_registrable_widget_types` is translated by `translate_widget_metadata()`, sanitized (`help`, `icon`, `actions`) and registered. A name already registered is skipped. + +### A plugin's widgets A plugin with a `widgets/` folder of its own, built with wp-build, registers the whole manifest its build generates from a callback on `jetpack_premium_analytics_register_widget_types`: @@ -77,57 +85,157 @@ add_action( ); ``` -The `require_once` of the generated `build/build.php` is what registers the plugin's widget script modules: the generated `build/widgets.php` hooks that on `init` by itself, or runs at once when `init` has fired, so a plugin that pays the registration only on sites that qualify requires it from inside the callback. `jetpack_videopress_get_registered_widget_modules()` is the manifest accessor wp-build generates from `wpPlugin.name`; guard it with `function_exists()` where the build can be absent. `i18n_manifest` is the URL of the build's `i18n-manifest.json`, which `stamp-textdomains` writes next to the bundles. +The `require_once` of the generated `build/build.php` is what registers the plugin's widget script modules. The generated `build/widgets.php` hooks that on `init` by itself, or runs at once when `init` has fired, so a plugin that pays the registration only on sites that qualify requires it from inside the callback. + +`jetpack_videopress_get_registered_widget_modules()` is the manifest accessor wp-build generates from `wpPlugin.name`. Guard it with `function_exists()` where the build can be absent. + +`i18n_manifest` is the URL of the build's `i18n-manifest.json`, which `stamp-textdomains` writes next to the bundles. A single type written by hand goes through `register_widget_type( $name, $args )`, the primitive the helper is built on. -The mechanics behind it: +### The contract + +The action hands over the `Widget_Type_Registry` being hydrated. `register_widget_types_from_manifest()` and `register_widget_type()` are what a plugin calls; both write to the main instance, which is that same registry in production. + +`$registry` is there for lookups, `is_registered()` and `get_all_registered()`. The package's own registrant writes into `$registry` directly, so a test can hydrate a fresh instance. + +### What the manifest helper does + +It runs the candidates through `jetpack_premium_analytics_registrable_widget_types`, gives each one without a `textdomain` or an `i18n_manifest` the ones passed in `$args`, translates the metadata with `translate_widget_metadata()`, sanitizes `help`, `icon` and `actions`, and skips a name already registered. + +The package's own `register_widget_types()` is its first caller, with its text domain and no manifest: the page's boot init module loads the package's catalogs. + +### Loading + +The files are required by `ensure_widget_registry_ready()`, not autoloaded. The action fires from the registry those files load, so a callback on it runs only once the API is there and needs no `function_exists()` guard. + +### Validation in `register()` + +The name must be a lowercase `/` string, and must not be registered. Each failure is a `_doing_it_wrong()` and a `false` return. + +### Arguments + +Any public property of `Widget_Type`: `render_module`, `widget_module`, `presentation` (`framed`, `content-bleed` or `full-bleed`), `category`, `title`, `description`, `help` (`content` plus optional `links`), `icon` (`collection/name`), `actions`, `keywords`, `textdomain`, `i18n_manifest`. `set_props()` copies every key onto the instance. + +Through `register_widget_type()` the strings arrive translated and `help`, `icon` and `actions` in shape. The manifest helper translates and sanitizes them itself. + +### Version + +`WIDGET_API_VERSION` names the contract a widget is built against (see [Versioning the contract](#versioning-the-contract)). A consumer compares it in the callback and skips registration when the major differs. + +### Hydration and order + +`Widget_Type_Registry` fires the action from `ensure_hydrated()`, which `get_registered()` and `get_all_registered()` call on their first read after `init`. The latch is set before the action fires, so a callback that reads the registry does not re-enter it. + +`is_registered()` does not hydrate: `register()` relies on it, and a registrant may run before the action. + +The package's own widget types register at priority 10. A plugin that wants to see them registered first hooks later. + +### Script modules + +The module ids are what the client hands to `import()`. The package's are registered by the generated `jpa_register_widget_modules()`; a plugin's by the same generated file of its own build. + +### The SDK a plugin imports + +A plugin's widgets import the dashboard through `@automattic/jetpack-premium-analytics-sdk` (`projects/js-packages/premium-analytics-sdk`), a types-only package that declares itself a script module (`wpScriptModuleExports`). + +wp-build finds it installed under that name, leaves the import external (`wpPlugin.externalNamespaces` lists the `automattic` scope) and records it as a module dependency of the widget. + +`src/sdk-module.php` registers the facade built from `packages/sdk` under that same name on `wp_default_scripts`. The page import map resolves the SDK to the facade and the facade to the dashboard's own modules: one React, one toolkit, one query client for the dashboard and every widget on the page. -- **The contract.** The action hands over the `Widget_Type_Registry` being hydrated. `register_widget_types_from_manifest()` and `register_widget_type()` are what a plugin calls; both write to the main instance, which is that same registry in production. `$registry` is there for lookups, `is_registered()` and `get_all_registered()`. The package's own registrant writes into `$registry` directly, so a test can hydrate a fresh instance. -- **Manifests.** The helper runs the candidates through `jetpack_premium_analytics_registrable_widget_types`, gives each one without a `textdomain` or an `i18n_manifest` the ones passed in `$args`, translates the metadata with `translate_widget_metadata()`, sanitizes `help`, `icon` and `actions`, and skips a name already registered. The package's own `register_widget_types()` is its first caller, with its text domain and no manifest: the page's boot init module loads the package's catalogs. -- **Loading.** The files are required by `ensure_widget_registry_ready()`, not autoloaded. The action fires from the registry those files load, so a callback on it runs only once the API is there and needs no `function_exists()` guard. -- **Validation in `register()`.** The name must be a lowercase `/` string, and must not be registered. Each failure is a `_doing_it_wrong()` and a `false` return. -- **Arguments.** Any public property of `Widget_Type`: `render_module`, `widget_module`, `presentation` (`framed`, `content-bleed` or `full-bleed`), `category`, `title`, `description`, `help` (`content` plus optional `links`), `icon` (`collection/name`), `actions`, `keywords`, `textdomain`, `i18n_manifest`. `set_props()` copies every key onto the instance. Through `register_widget_type()` the strings arrive translated and `help`, `icon` and `actions` in shape; the manifest helper translates and sanitizes them itself. -- **Version.** `WIDGET_API_VERSION` names the contract a widget is built against (see below). A consumer compares it in the callback and skips registration when the major differs. -- **Script modules.** The module ids are what the client hands to `import()`. The package's are registered by the generated `jpa_register_widget_modules()`; a plugin's by the same generated file of its own build. A plugin's widgets import the dashboard through `@automattic/jetpack-premium-analytics-sdk` (`projects/js-packages/premium-analytics-sdk`), a types-only package that declares itself a script module (`wpScriptModuleExports`): wp-build finds it installed under that name, leaves the import external (`wpPlugin.externalNamespaces` lists the `automattic` scope) and records it as a module dependency of the widget. `src/sdk-module.php` registers the facade built from `packages/sdk` under that same name on `wp_default_scripts`, so the page import map resolves the SDK to the facade and the facade to the dashboard's own modules: one React, one toolkit, one query client for the dashboard and every widget on the page. -- **Hydration.** `Widget_Type_Registry` fires the action from `ensure_hydrated()`, which `get_registered()` and `get_all_registered()` call on their first read after `init`. The latch is set before the action fires, so a callback that reads the registry does not re-enter it. `is_registered()` does not hydrate: `register()` relies on it, and a registrant may run before the action. -- **Order.** The package's own widget types register at priority 10; a plugin that wants to see them registered first hooks later. -- **Translations on the client.** Every record says where its bundles' catalogs live. `routes/widget-module-i18n.ts` derives the bundle path from the module id, `{handle-prefix}/widgets/{dir}/{render,widget}` to `build/widgets/{dir}/{render,widget}.js`, whatever the prefix; `createWidgetModuleResolver()` caches the record's manifest by URL once (`loadI18nManifest()` in wp-build-polyfills) and loads the bundle's catalog under the record's `textdomain` before importing, and the metadata bundles are preloaded the same way. A record without a text domain is treated as the package's own. What the catalog load hashes is the bundle path relative to the plugin, the way WordPress names JS translation files, so a package vendored inside a plugin needs its text domain aliased in the plugin's `i18n-map.php`, as this package's is. +### Translations on the client + +Every record says where its bundles' catalogs live. `routes/widget-module-i18n.ts` derives the bundle path from the module id, `{handle-prefix}/widgets/{dir}/{render,widget}` to `build/widgets/{dir}/{render,widget}.js`, whatever the prefix. + +`createWidgetModuleResolver()` caches the record's manifest by URL once (`loadI18nManifest()` in wp-build-polyfills) and loads the bundle's catalog under the record's `textdomain` before importing. The metadata bundles are preloaded the same way. A record without a text domain is treated as the package's own. + +What the catalog load hashes is the bundle path relative to the plugin, the way WordPress names JS translation files. A package vendored inside a plugin therefore needs its text domain aliased in the plugin's `i18n-map.php`, as this package's is. ## From the registry to the client -On the server, `get_available_widget_types()` runs the registered map through `jetpack_premium_analytics_widget_types`, the runtime filter, and both readers use it, so the REST list and the import map share one policy. +### One policy for both readers + +On the server, `get_available_widget_types()` runs the registered map through `jetpack_premium_analytics_widget_types`, the runtime filter. Both readers use it, so the REST list and the import map share one policy. + +### The REST record + +`GET /wpcom/v2/widget-modules` returns one record per available type: `name`, `render_module`, `widget_module`, the metadata fields, `textdomain` and `i18n_manifest`. + +It is gated on `Capabilities::current_user_can_view_analytics()`, the dashboard's own gate. The `wpcom/v2` namespace is what lets WordPress.com expose it through public-api for Simple sites. + +### The import map -`GET /wpcom/v2/widget-modules` returns one record per available type: `name`, `render_module`, `widget_module`, the metadata fields, `textdomain` and `i18n_manifest`. It is gated on `Capabilities::current_user_can_view_analytics()`, the dashboard's own gate, and the `wpcom/v2` namespace is what lets WordPress.com expose it through public-api for Simple sites. +`add_widget_modules_to_boot_deps()` adds each `render_module` and `widget_module` as a dynamic dependency of the page, which the generated page loader turns into import-map entries. -`add_widget_modules_to_boot_deps()` adds each `render_module` and `widget_module` as a dynamic dependency of the page, which the generated page loader turns into import-map entries. A type whose module id no script module claims imports nothing, and an instance of it renders as "Widget is no longer available". +A type whose module id no script module claims imports nothing, and an instance of it renders as "Widget is no longer available". -On the client, `useWidgetModules()` reads the records as a core-data entity, `useWidgetTypesWithI18n()` resolves the ones the active layout renders, and the widget dashboard offers the types in the picker and imports an instance's render module through the resolver `useWidgetModuleResolver()` builds from the records when it renders. +### The client + +`useWidgetModules()` reads the records as a core-data entity, and `useWidgetTypesWithI18n()` resolves the ones the active layout renders. + +The widget dashboard offers the types in the picker and imports an instance's render module through the resolver `useWidgetModuleResolver()` builds from the records. ## Availability -Two filters, both problem-agnostic: +Two filters, both problem-agnostic, plus a policy on default layouts. + +### Registry-time filter + +`jetpack_premium_analytics_registrable_widget_types` runs over the manifest candidates in `register_widget_types()`. A dropped candidate never registers: gone from the REST list, the import map and every registry reader. For hard availability. + +The package's own policy hooks it, in `src/widget-availability.php`: developer-only widgets off production, the store and bookings categories without WooCommerce or Bookings, the store report categories without the capability. + +A plugin's manifest goes through the same filter when it registers through `register_widget_types_from_manifest()`. A type registered one by one with `register_widget_type()` does not. Either way, a plugin decides in its callback whether to register at all, as the section owners do. + +### Runtime filter -1. **Registry-time**, `jetpack_premium_analytics_registrable_widget_types`, over the manifest candidates in `register_widget_types()`. A dropped candidate never registers: gone from the REST list, the import map and every registry reader. For hard availability. -2. **Runtime**, `jetpack_premium_analytics_widget_types`, over the registered map on every read of `get_available_widget_types()`. The type stays registered. For request-dependent or soft state, e.g. a type shown locked. +`jetpack_premium_analytics_widget_types` runs over the registered map on every read of `get_available_widget_types()`. The type stays registered. For request-dependent or soft state, e.g. a type shown locked. -The package's own policy hooks the first, in `src/widget-availability.php`: developer-only widgets off production, the store and bookings categories without WooCommerce or Bookings, the store report categories without the capability. A plugin's manifest goes through the same filter when it registers through `register_widget_types_from_manifest()`; a type registered one by one with `register_widget_type()` does not. Either way, a plugin decides in its callback whether to register at all, as the section owners do. +### Default layouts -A third policy, in `src/widget-type-support.php`, acts on default layouts rather than on the registry: `remove_unsupported_default_layout_items()` drops from a section's default the instances whose type the site cannot serve. It reads a fixed list, not the registry (see [Default layouts](dashboard-sections.md#default-layouts)). +A third policy, in `src/widget-type-support.php`, acts on default layouts rather than on the registry. `remove_unsupported_default_layout_items()` drops from a section's default the instances whose type the site cannot serve. + +It reads a fixed list, not the registry (see [Default layouts](dashboard-sections.md#default-layouts)). ## Versioning the contract -`WIDGET_API_VERSION` names the contract a widget is built against: the `@automattic/jetpack-premium-analytics-sdk` module and the exports it declares, the dashboard modules the facade re-exports from (`@jetpack-premium-analytics/widgets-toolkit`, `data`, `fields`, `datetime`, `externals`), and the `Widget_Type` fields the client reads. The major changes when a widget built against the previous contract stops working; the minor when a consumer can rely on something new. +`WIDGET_API_VERSION` names the contract a widget is built against: the `@automattic/jetpack-premium-analytics-sdk` module and the exports it declares, the dashboard modules the facade re-exports from (`@jetpack-premium-analytics/widgets-toolkit`, `data`, `fields`, `datetime`, `externals`), and the `Widget_Type` fields the client reads. + +The major changes when a widget built against the previous contract stops working; the minor when a consumer can rely on something new. Inside `plugins/jetpack` the package and a consumer module ship together, so the check is a formality. With the standalone `plugins/premium-analytics` next to another plugin, each brings its own copy, and the check is what keeps a widget built against 1.x from registering on a 2.x package. ## A real consumer: the Ads widgets -The three Ads widgets live in `projects/packages/ads`, a widgets-only wp-build project (`wpPlugin.name` `jetpack_ads`, handle prefix `jetpack-ads`, so the module ids are `jetpack-ads/widgets//render` and `…/widget`). Their code imports the dashboard by one name, `@automattic/jetpack-premium-analytics-sdk`: the package depends on it with `workspace:*` and lists the `automattic` scope in `wpPlugin.externalNamespaces`, and wp-build, which keeps a specifier external only when it finds that package installed under the specifier and declaring `wpScriptModuleExports`, leaves the import external the way it does `@wordpress/*`. The SDK package (`projects/js-packages/premium-analytics-sdk`) holds the contract only, the types of what a widget can import; this package provides the implementation, `packages/sdk`, a facade over the toolkit, data, fields, dates and shared primitives, built as `@jetpack-premium-analytics/sdk` and registered a second time under the SDK's name by `src/sdk-module.php`. So a widget runs on the module instances the dashboard renders with, and no `../` path or workspace alias points from the Ads package at this one: it requires this package with Composer, since it registers against its API. +### Where the widgets live + +The three Ads widgets live in `projects/packages/ads`, a widgets-only wp-build project. `wpPlugin.name` is `jetpack_ads` and the handle prefix `jetpack-ads`, so the module ids are `jetpack-ads/widgets//render` and `…/widget`. + +The package requires this one with Composer, since it registers against its API. No `../` path or workspace alias points from the Ads package at this one. + +### One import: the SDK + +The widgets import the dashboard by one name, `@automattic/jetpack-premium-analytics-sdk`. The package depends on it with `workspace:*` and lists the `automattic` scope in `wpPlugin.externalNamespaces`. + +wp-build keeps a specifier external only when it finds that package installed under the specifier and declaring `wpScriptModuleExports`. The SDK package does, so the import stays external, the way `@wordpress/*` does. + +The SDK package (`projects/js-packages/premium-analytics-sdk`) holds the contract only, the types of what a widget can import. This package provides the implementation: `packages/sdk`, a facade over the toolkit, data, fields, dates and shared primitives, built as `@jetpack-premium-analytics/sdk` and registered a second time under the SDK's name by `src/sdk-module.php`. A widget therefore runs on the module instances the dashboard renders with. + +### What the package registers + +`Analytics_Dashboard::init()` hooks two registrants at priority 20. + +`register_section()` registers `wordads/ads` with its layout of `wordads/chart-tabs`, `wordads/highlights` and `wordads/earnings-history`. It skips when the `ads` slug is taken, or when `WIDGET_API_VERSION` moved to a major the package was not built against. An undefined version is not a mismatch: the sections REST route hydrates the section registry before the dashboard loads `widget-types.php`. + +`register_widget_types()` waits for that version, then requires the generated `build/build.php` from inside the callback, so the script modules register on the spot after `init` and only on sites that qualify. It hands the manifest to `register_widget_types_from_manifest()` with the text domain `jetpack-ads-pkg` and the URL of its `i18n-manifest.json`. + +### Who calls it + +That is the section's story, in [Dashboard sections](dashboard-sections.md#a-real-consumer-the-ads-section): the WordAds module outside the WordPress.com platform, `jetpack-mu-wpcom` on Simple and Atomic by plan feature. Both run against the copy the Jetpack plugin bundles, so Simple, which runs no Jetpack module, serves the bundles from it too. -`Analytics_Dashboard::init()` hooks two registrants at priority 20. `register_section()` registers `wordads/ads` with its layout of `wordads/chart-tabs`, `wordads/highlights` and `wordads/earnings-history`, unless the `ads` slug is taken or `WIDGET_API_VERSION` moved to a major the package was not built against; an undefined version is not a mismatch, since the sections REST route hydrates the section registry before the dashboard loads `widget-types.php`. `register_widget_types()` waits for that version, requires the generated `build/build.php` from inside the callback, so the script modules register on the spot after `init` and only on sites that qualify, and hands the manifest to `register_widget_types_from_manifest()` with the text domain `jetpack-ads-pkg` and the URL of its `i18n-manifest.json`. +### Layouts saved before the move -Who calls it is the section's story, in [Dashboard sections](dashboard-sections.md#a-real-consumer-the-ads-section): the WordAds module outside the WordPress.com platform, `jetpack-mu-wpcom` on Simple and Atomic by plan feature, both against the copy the Jetpack plugin bundles, so Simple, which runs no Jetpack module, serves the bundles from it too. The types were `jpa/wordads-*` while they lived here; a layout persisted with those names renders its tiles as unavailable until it is reset. +The types were `jpa/wordads-*` while they lived here. A layout persisted with those names renders its tiles as unavailable until it is reset. ## Where the tests are @@ -144,4 +252,7 @@ Who calls it is the section's story, in [Dashboard sections](dashboard-sections. ## Not covered here -Stories and JS tests for a plugin's widgets: the Ads widgets left this package's Storybook and jest harness with their move, and `packages/ads` has neither yet. Precise types for the SDK: `projects/js-packages/premium-analytics-sdk` declares its exports loosely until a build step emits them from the facade. An alias for a renamed widget type in persisted layouts, so a move like the Ads one needs no reset. The metadata strings of `widget.json` reach no catalog in any package until the strings stub ships. +- Stories and JS tests for a plugin's widgets: the Ads widgets left this package's Storybook and jest harness with their move, and `packages/ads` has neither yet. +- Precise types for the SDK: `projects/js-packages/premium-analytics-sdk` declares its exports loosely until a build step emits them from the facade. +- An alias for a renamed widget type in persisted layouts, so a move like the Ads one needs no reset. +- The metadata strings of `widget.json`, which reach no catalog in any package until the strings stub ships. From a648ce353f6c8e542bef570f885d9ee99cca4bab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dami=C3=A1n=20Su=C3=A1rez?= Date: Mon, 28 Sep 2026 12:05:14 +0100 Subject: [PATCH 4/4] say where the manifest helper writes and where the layout policy lives --- .../packages/premium-analytics/docs/dashboard-widgets.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/projects/packages/premium-analytics/docs/dashboard-widgets.md b/projects/packages/premium-analytics/docs/dashboard-widgets.md index af54ad060a95..757add5bdc27 100644 --- a/projects/packages/premium-analytics/docs/dashboard-widgets.md +++ b/projects/packages/premium-analytics/docs/dashboard-widgets.md @@ -95,9 +95,9 @@ A single type written by hand goes through `register_widget_type( $name, $args ) ### The contract -The action hands over the `Widget_Type_Registry` being hydrated. `register_widget_types_from_manifest()` and `register_widget_type()` are what a plugin calls; both write to the main instance, which is that same registry in production. +The action hands over the `Widget_Type_Registry` being hydrated. `register_widget_types_from_manifest()` and `register_widget_type()` are what a plugin calls. The helper takes `$registry` as its third argument and defaults to the main instance, which is that same registry in production; `register_widget_type()` always writes to the main instance. -`$registry` is there for lookups, `is_registered()` and `get_all_registered()`. The package's own registrant writes into `$registry` directly, so a test can hydrate a fresh instance. +`$registry` also serves lookups, `is_registered()` and `get_all_registered()`. The package's own registrant and the Ads one pass it through the helper, so a test can hydrate a fresh instance. ### What the manifest helper does @@ -193,7 +193,7 @@ A plugin's manifest goes through the same filter when it registers through `regi ### Default layouts -A third policy, in `src/widget-type-support.php`, acts on default layouts rather than on the registry. `remove_unsupported_default_layout_items()` drops from a section's default the instances whose type the site cannot serve. +A third policy acts on default layouts rather than on the registry: `remove_unsupported_default_layout_items()` in `src/dashboard-layout.php`, over the type lists of `src/widget-type-support.php`, drops from a section's default the instances whose type the site cannot serve. It reads a fixed list, not the registry (see [Default layouts](dashboard-sections.md#default-layouts)).