Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
72.22% |
143 / 198 |
|
60.00% |
9 / 15 |
CRAP | n/a |
0 / 0 |
|
| wpcom_ai_launchpad_remap_task_id | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
2 | |||
| wpcom_ai_launchpad_resolve_goal | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
110 | |||
| wpcom_ai_launchpad_to_simple_plugins_path | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
56 | |||
| wpcom_ai_launchpad_tracks_context | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
9 | |||
| wpcom_ai_launchpad_is_test | |
80.00% |
8 / 10 |
|
0.00% |
0 / 1 |
6.29 | |||
| wpcom_ai_launchpad_is_a11n | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| wpcom_ai_launchpad_standard_props | |
100.00% |
26 / 26 |
|
100.00% |
1 / 1 |
10 | |||
| wpcom_ai_launchpad_shape_tracks_identity | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 | |||
| wpcom_ai_launchpad_tracks_identity | |
40.00% |
2 / 5 |
|
0.00% |
0 / 1 |
4.94 | |||
| wpcom_ai_launchpad_record_tracks_event | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
2 | |||
| wpcom_ai_launchpad_get_ai_task_ids | |
23.08% |
3 / 13 |
|
0.00% |
0 / 1 |
77.54 | |||
| wpcom_ai_launchpad_site_locale | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
4 | |||
| wpcom_ai_launchpad_in_site_language | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
4 | |||
| wpcom_ai_launchpad_site_copy | |
100.00% |
32 / 32 |
|
100.00% |
1 / 1 |
1 | |||
| wpcom_ai_launchpad_script_translations | |
100.00% |
20 / 20 |
|
100.00% |
1 / 1 |
12 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * AI Launchpad shared helpers used by more than one class. |
| 4 | * |
| 5 | * @package automattic/jetpack-mu-wpcom |
| 6 | */ |
| 7 | |
| 8 | if ( ! function_exists( 'wpcom_ai_launchpad_remap_task_id' ) ) { |
| 9 | /** |
| 10 | * Normalizes a persisted task id onto the task the AI Launchpad actually renders. |
| 11 | * |
| 12 | * Some catalog tasks are broken or meaningless in this context, so their ids are replaced on |
| 13 | * read: `woo_launch_site` dead-ends in the WooCommerce onboarding list and never completes when |
| 14 | * the guided setup was skipped; `post_sharing_enabled` is born completed (the sharing module is |
| 15 | * active by default on wpcom); `design_selected` is born completed and `design_completed` has no |
| 16 | * wp-admin completion path, so both consolidate onto the actionable `site_theme_selected` task. |
| 17 | * Catalog `id_map` twins are the same underlying task under two names, so each pair collapses |
| 18 | * onto the one the menu still offers. The prompt no longer offers any of these ids, so this only |
| 19 | * catches stray AI emissions and outputs persisted before the replacement. |
| 20 | * |
| 21 | * @param string $task_id A task id from the persisted AI output. |
| 22 | * @return string The task id to render (and listen/skip) instead. |
| 23 | */ |
| 24 | function wpcom_ai_launchpad_remap_task_id( $task_id ) { |
| 25 | $remap = array( |
| 26 | 'woo_launch_site' => 'site_launched', |
| 27 | 'post_sharing_enabled' => 'connect_social_media', |
| 28 | 'design_selected' => 'site_theme_selected', |
| 29 | 'design_completed' => 'site_theme_selected', |
| 30 | // id_map twins, keyed dropped => kept. |
| 31 | 'drive_traffic' => 'connect_social_media', |
| 32 | 'first_post_published_newsletter' => 'first_post_published', |
| 33 | 'link_in_bio_launched' => 'site_launched', |
| 34 | 'videopress_launched' => 'site_launched', |
| 35 | 'subscribers_added' => 'import_subscribers', |
| 36 | ); |
| 37 | |
| 38 | return $remap[ $task_id ] ?? $task_id; |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | if ( ! function_exists( 'wpcom_ai_launchpad_resolve_goal' ) ) { |
| 43 | /** |
| 44 | * The site's goal, resolved once for every path that behaves differently because of it. |
| 45 | * |
| 46 | * The wizard option wins: it is the user's own choice, written by the server. `payload.inferred.goal` |
| 47 | * is only what the prompt asked the model to echo back, so anything keyed off it can be driven by a |
| 48 | * wrong echo — enforcement (update_tailored() drops the tasks the goal forbids) and shape |
| 49 | * (get_current_tasks() leads a sell site with the store sequence) alike. Read and write must agree on |
| 50 | * one authority, or a newsletter site can have every commerce task stripped at PUT and still be told |
| 51 | * to install WooCommerce at GET. |
| 52 | * |
| 53 | * The payload is the fallback and only covers one race: the wizard PUT is fire-and-forget in |
| 54 | * wizard/wizard.tsx, so the tailor can land before the option is written. The output schema |
| 55 | * has already validated the payload's goal against the same six slugs. |
| 56 | * |
| 57 | * @param array $payload The AI output payload — the persisted one on read, the incoming one at PUT. |
| 58 | * @return string The goal slug, or '' when neither source holds one. |
| 59 | */ |
| 60 | function wpcom_ai_launchpad_resolve_goal( $payload ) { |
| 61 | $wizard = get_option( 'wpcom_ai_launchpad_wizard' ); |
| 62 | if ( is_array( $wizard ) && isset( $wizard['goal'] ) && is_string( $wizard['goal'] ) && '' !== $wizard['goal'] ) { |
| 63 | return $wizard['goal']; |
| 64 | } |
| 65 | |
| 66 | $inferred = is_array( $payload ) && isset( $payload['inferred'] ) && is_array( $payload['inferred'] ) |
| 67 | ? $payload['inferred'] |
| 68 | : array(); |
| 69 | |
| 70 | return isset( $inferred['goal'] ) && is_string( $inferred['goal'] ) ? $inferred['goal'] : ''; |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | if ( ! function_exists( 'wpcom_ai_launchpad_to_simple_plugins_path' ) ) { |
| 75 | /** |
| 76 | * On Simple sites, rewrite a wp-admin plugins-screen CTA to its Calypso equivalent. |
| 77 | * |
| 78 | * Simple sites have no reachable wp-admin plugins UI, so any task whose CTA lands on `plugins.php` or |
| 79 | * `plugin-install.php` would dead-end. Those are mapped to the Calypso plugins page — a specific plugin |
| 80 | * when a slug is given, otherwise the site's plugins list. Non-plugin paths and Atomic sites pass through. |
| 81 | * |
| 82 | * Shared rather than private to AI_Launchpad_REST because both task sources need it and each resolves its |
| 83 | * CTA in a different place: the catalog's path is rewritten as build_tasks() enriches it, while a registry |
| 84 | * task resolves its own `calypso_path` and returns before that point, so it has to apply the rewrite |
| 85 | * itself. Two implementations would be one host check away from disagreeing. |
| 86 | * |
| 87 | * @param string|null $path The resolved CTA path. |
| 88 | * @param string $plugin_slug Optional plugin slug to deep-link to on Calypso. |
| 89 | * @return string|null |
| 90 | */ |
| 91 | function wpcom_ai_launchpad_to_simple_plugins_path( $path, $plugin_slug = '' ) { |
| 92 | if ( ! ( defined( 'IS_WPCOM' ) && IS_WPCOM ) || ! is_string( $path ) ) { |
| 93 | return $path; |
| 94 | } |
| 95 | |
| 96 | if ( false === strpos( $path, 'plugins.php' ) && false === strpos( $path, 'plugin-install.php' ) ) { |
| 97 | return $path; |
| 98 | } |
| 99 | |
| 100 | $slug_segment = '' !== $plugin_slug ? rawurlencode( $plugin_slug ) . '/' : ''; |
| 101 | return '/plugins/' . $slug_segment . rawurlencode( wpcom_get_site_slug() ); |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | if ( ! function_exists( 'wpcom_ai_launchpad_tracks_context' ) ) { |
| 106 | /** |
| 107 | * The shared analytics context merged into every AI Launchpad Tracks event, mirroring the |
| 108 | * client-side context in `js/lib/tracks.ts`. Values are null until the corresponding data |
| 109 | * exists. Only model-inferred fields are included — never the user's raw title/description |
| 110 | * (`brand_name` echoes them near-verbatim and is excluded). |
| 111 | * |
| 112 | * @param string[]|null $rendered_task_ids The rendered task ids, when the caller has them. |
| 113 | * @return array The context props. |
| 114 | */ |
| 115 | function wpcom_ai_launchpad_tracks_context( $rendered_task_ids = null ) { |
| 116 | $ai_output = get_option( 'wpcom_ai_launchpad_ai_output' ); |
| 117 | $inferred = array(); |
| 118 | if ( is_array( $ai_output ) && isset( $ai_output['payload']['inferred'] ) && is_array( $ai_output['payload']['inferred'] ) ) { |
| 119 | $inferred = $ai_output['payload']['inferred']; |
| 120 | } |
| 121 | |
| 122 | $string_or_null = static function ( $value ) { |
| 123 | return is_string( $value ) && '' !== trim( $value ) ? $value : null; |
| 124 | }; |
| 125 | |
| 126 | $goal = $string_or_null( $inferred['goal'] ?? null ); |
| 127 | if ( null === $goal ) { |
| 128 | $wizard = get_option( 'wpcom_ai_launchpad_wizard' ); |
| 129 | $goal = is_array( $wizard ) ? $string_or_null( $wizard['goal'] ?? null ) : null; |
| 130 | } |
| 131 | |
| 132 | return array( |
| 133 | 'goal' => $goal, |
| 134 | 'niche' => $string_or_null( $inferred['niche'] ?? null ), |
| 135 | 'theme_category' => $string_or_null( $inferred['theme_category'] ?? null ), |
| 136 | 'vibe' => $string_or_null( $inferred['vibe'] ?? null ), |
| 137 | 'audience' => $string_or_null( $inferred['audience'] ?? null ), |
| 138 | 'rendered_list' => is_array( $rendered_task_ids ) ? wp_json_encode( array_values( $rendered_task_ids ), JSON_UNESCAPED_SLASHES ) : null, |
| 139 | 'inferred_goal' => $string_or_null( $inferred['inferred_goal'] ?? null ), |
| 140 | ); |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | if ( ! function_exists( 'wpcom_ai_launchpad_is_test' ) ) { |
| 145 | /** |
| 146 | * Whether this request comes from a development or test environment. |
| 147 | * |
| 148 | * Environment-based, never user-based. The AI property standard keeps `is_test` and |
| 149 | * `is_a11n` independently filterable, so an Automattician working on a production site is |
| 150 | * a11n but not test. The standard also lists a set of internal Atomic client IDs, which is |
| 151 | * deliberately not implemented here: the reference implementation |
| 152 | * (packages/agents-manager, is_dev_mode()) gates that clause behind AT_PROXIED_REQUEST, |
| 153 | * which would make this user-based again, and ungated it would risk reporting real Atomic |
| 154 | * sites as tests. Automattician sessions on real test sites are covered by is_a11n instead. |
| 155 | * |
| 156 | * @return bool |
| 157 | */ |
| 158 | function wpcom_ai_launchpad_is_test() { |
| 159 | $host = wp_parse_url( get_site_url(), PHP_URL_HOST ); |
| 160 | |
| 161 | if ( ! is_string( $host ) || '' === $host ) { |
| 162 | return false; |
| 163 | } |
| 164 | |
| 165 | $host = strtolower( $host ); |
| 166 | |
| 167 | if ( 'localhost' === $host ) { |
| 168 | return true; |
| 169 | } |
| 170 | |
| 171 | foreach ( array( '.jurassic.tube', '.jurassic.ninja' ) as $suffix ) { |
| 172 | if ( substr( $host, - strlen( $suffix ) ) === $suffix ) { |
| 173 | return true; |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | return false; |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | if ( ! function_exists( 'wpcom_ai_launchpad_is_a11n' ) ) { |
| 182 | /** |
| 183 | * Whether an Automattician is acting on this site. |
| 184 | * |
| 185 | * Mirrors the platform split used elsewhere in the package: on Simple the platform's own |
| 186 | * is_automattician() is authoritative, and on Atomic the a8c proxy is what identifies us. |
| 187 | * Both branches fail closed when their primitive is missing. |
| 188 | * |
| 189 | * Records who is acting, not who owns the site — an Automattician working on a customer's |
| 190 | * site is a11n, a customer working on an a8c-owned site is not. |
| 191 | * |
| 192 | * @return bool |
| 193 | */ |
| 194 | function wpcom_ai_launchpad_is_a11n() { |
| 195 | if ( ( new \Automattic\Jetpack\Status\Host() )->is_wpcom_simple() ) { |
| 196 | return function_exists( 'is_automattician' ) && (bool) is_automattician(); |
| 197 | } |
| 198 | |
| 199 | return \Automattic\Jetpack\Constants::is_true( 'AT_PROXIED_REQUEST' ); |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | if ( ! function_exists( 'wpcom_ai_launchpad_standard_props' ) ) { |
| 204 | /** |
| 205 | * The AI standard Tracks properties, merged into every AI Launchpad event by both |
| 206 | * recorders — the server one below and the client one in `js/lib/tracks.ts`, which reads |
| 207 | * this same array from the page's `window.wpcomAiLaunchpadTracks` global. |
| 208 | * |
| 209 | * Every value is a string or an int, never null: the standard requires the string "none" |
| 210 | * for a missing value, because null breaks group-by aggregations downstream. |
| 211 | * |
| 212 | * @return array The standard props. |
| 213 | */ |
| 214 | function wpcom_ai_launchpad_standard_props() { |
| 215 | $ai_output = get_option( 'wpcom_ai_launchpad_ai_output' ); |
| 216 | |
| 217 | $string_or_none = static function ( $value ) { |
| 218 | return is_string( $value ) && '' !== $value ? $value : 'none'; |
| 219 | }; |
| 220 | |
| 221 | $source = $string_or_none( is_array( $ai_output ) ? ( $ai_output['source'] ?? null ) : null ); |
| 222 | $ai_session_id = $string_or_none( is_array( $ai_output ) ? ( $ai_output['ai_session_id'] ?? null ) : null ); |
| 223 | |
| 224 | // Redundant with $source by construction. The standard requires it and cross-product AI |
| 225 | // dashboards group by it, so it is derived here rather than stored a second time. |
| 226 | $outcome = 'none'; |
| 227 | if ( 'ai' === $source ) { |
| 228 | $outcome = 'success'; |
| 229 | } elseif ( 'fallback' === $source ) { |
| 230 | $outcome = 'error'; |
| 231 | } |
| 232 | |
| 233 | return array( |
| 234 | 'channel' => 'web', |
| 235 | 'surface' => 'dashboard', |
| 236 | // The Site Setup page's own $pagenow, hardcoded rather than read live: the two |
| 237 | // server-fired events fire inside REST requests, where $pagenow is index.php, and |
| 238 | // the two recorders have to report the same screen for the same user. |
| 239 | 'screen' => 'admin.php', |
| 240 | 'ref' => 'ai_launchpad', |
| 241 | 'site_type' => ( new \Automattic\Jetpack\Status\Host() )->is_wpcom_simple() ? 'simple' : 'atomic', |
| 242 | 'agent_name' => 'ai_launchpad', |
| 243 | 'agent_version' => \Automattic\Jetpack\Jetpack_Mu_Wpcom::PACKAGE_VERSION, |
| 244 | // Stringified rather than left as PHP bools: http_build_query() (both PHP Tracks |
| 245 | // clients end in one) renders a bool as "1"/"0", while the client recorder's |
| 246 | // encodeURIComponent() renders it as "true"/"false" — the same logical value would |
| 247 | // reach Tracks differently depending on which recorder fired it. The literal strings |
| 248 | // 'true'/'false' are what the Fieldguide standard documents and the only |
| 249 | // representation both encoders pass through unchanged. |
| 250 | 'is_test' => wpcom_ai_launchpad_is_test() ? 'true' : 'false', |
| 251 | 'is_a11n' => wpcom_ai_launchpad_is_a11n() ? 'true' : 'false', |
| 252 | // Explicit rather than left to the Tracks super prop: on the client the super prop |
| 253 | // is missing on about one page-view fire in twenty, which understates every |
| 254 | // per-site rate that uses `viewed` as its denominator. |
| 255 | 'blog_id' => (int) get_wpcom_blog_id(), |
| 256 | 'source' => $source, |
| 257 | 'outcome' => $outcome, |
| 258 | 'ai_session_id' => $ai_session_id, |
| 259 | ); |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | if ( ! function_exists( 'wpcom_ai_launchpad_shape_tracks_identity' ) ) { |
| 264 | /** |
| 265 | * Reduces a Tracks client identity to the two fields the browser is allowed to see. |
| 266 | * |
| 267 | * The client helper also returns an email address, a blog id and a locale. Only the id and |
| 268 | * the login may reach a JS global, so this rebuilds the array rather than unsetting keys — |
| 269 | * a field added to the helper later cannot leak through by default. |
| 270 | * |
| 271 | * @param mixed $identity The raw identity from Jetpack_Tracks_Client, or false. |
| 272 | * @return array|null The id and login, or null when the identity is unusable. |
| 273 | */ |
| 274 | function wpcom_ai_launchpad_shape_tracks_identity( $identity ) { |
| 275 | if ( ! is_array( $identity ) || empty( $identity['userid'] ) || empty( $identity['username'] ) ) { |
| 276 | return null; |
| 277 | } |
| 278 | |
| 279 | return array( |
| 280 | 'userid' => (int) $identity['userid'], |
| 281 | 'username' => (string) $identity['username'], |
| 282 | ); |
| 283 | } |
| 284 | } |
| 285 | |
| 286 | if ( ! function_exists( 'wpcom_ai_launchpad_tracks_identity' ) ) { |
| 287 | /** |
| 288 | * The Tracks user identity the client recorder pushes as `identifyUser`, on Atomic only. |
| 289 | * |
| 290 | * On Simple, wpcom's stats.php already pushes identifyUser for every admin page load. On |
| 291 | * Atomic nothing does, so without this the client events would land anonymous. The local |
| 292 | * user id is not usable there — Tracks wants the connected WordPress.com identity. |
| 293 | * |
| 294 | * Returns only the id and the login: the Tracks client helper also hands back an email |
| 295 | * address, which must not reach a JS global. |
| 296 | * |
| 297 | * @return array|null The identity, or null on Simple / when no connected user is available. |
| 298 | */ |
| 299 | function wpcom_ai_launchpad_tracks_identity() { |
| 300 | if ( ( new \Automattic\Jetpack\Status\Host() )->is_wpcom_simple() ) { |
| 301 | return null; |
| 302 | } |
| 303 | |
| 304 | if ( ! class_exists( 'Jetpack_Tracks_Client' ) ) { |
| 305 | return null; |
| 306 | } |
| 307 | |
| 308 | return wpcom_ai_launchpad_shape_tracks_identity( \Jetpack_Tracks_Client::get_connected_user_tracks_identity() ); |
| 309 | } |
| 310 | } |
| 311 | |
| 312 | if ( ! function_exists( 'wpcom_ai_launchpad_record_tracks_event' ) ) { |
| 313 | /** |
| 314 | * Records an AI Launchpad Tracks event server-side, merging three layers before it is sent: |
| 315 | * |
| 316 | * 1. `wpcom_ai_launchpad_standard_props()` — the AI standard props shared with the client |
| 317 | * recorder. Applied first and never dropped: "none" and "false" are values, not gaps. |
| 318 | * 2. `wpcom_ai_launchpad_tracks_context( $rendered_task_ids )` — the feature-specific |
| 319 | * context (goal, niche, rendered list, …). Its null-valued keys are filtered out here |
| 320 | * (mirroring the client recorder) rather than reaching Tracks as literal "null" strings, |
| 321 | * so an unset context value is simply absent, unlike an unset standard prop. |
| 322 | * 3. `$props`, the call site's own properties, which win over both of the above. |
| 323 | * |
| 324 | * @param string $event_name The Tracks event name, already feature-prefixed. |
| 325 | * @param array $props Event properties. No PII: task IDs are fine, free text is not. |
| 326 | * @param string[]|null $rendered_task_ids The rendered task ids, when the caller has them. |
| 327 | * @return void |
| 328 | */ |
| 329 | function wpcom_ai_launchpad_record_tracks_event( $event_name, $props = array(), $rendered_task_ids = null ) { |
| 330 | $context = array_merge( wpcom_ai_launchpad_tracks_context( $rendered_task_ids ), $props ); |
| 331 | |
| 332 | // Null-valued context props are omitted, mirroring the client recorder: the Tracks |
| 333 | // pipeline would otherwise record them as literal "null" strings. |
| 334 | $context = array_filter( |
| 335 | $context, |
| 336 | static function ( $value ) { |
| 337 | return null !== $value; |
| 338 | } |
| 339 | ); |
| 340 | |
| 341 | // Merged outside that filter, and first, so the standard props are never dropped |
| 342 | // (`is_test: 'false'` is a value, not a gap) and a call site still wins over both. |
| 343 | $props = array_merge( wpcom_ai_launchpad_standard_props(), $context ); |
| 344 | |
| 345 | /** |
| 346 | * Fires for every server-side AI Launchpad analytics event, before it is sent to |
| 347 | * Tracks. Exists as an observation seam (tests hook it; neither Tracks client is |
| 348 | * loaded in the unit-test environment). |
| 349 | * |
| 350 | * @param string $event_name The Tracks event name. |
| 351 | * @param array $props The merged event properties. |
| 352 | */ |
| 353 | do_action( 'wpcom_ai_launchpad_tracks_event', $event_name, $props ); |
| 354 | |
| 355 | if ( function_exists( '\Automattic\Jetpack\Jetpack_Mu_Wpcom\Common\wpcom_record_tracks_event' ) ) { |
| 356 | \Automattic\Jetpack\Jetpack_Mu_Wpcom\Common\wpcom_record_tracks_event( $event_name, $props ); |
| 357 | } |
| 358 | } |
| 359 | } |
| 360 | |
| 361 | if ( ! function_exists( 'wpcom_ai_launchpad_get_ai_task_ids' ) ) { |
| 362 | /** |
| 363 | * The AI-selected task IDs from the `wpcom_ai_launchpad_ai_output` option, remapped |
| 364 | * onto the ids the launchpad renders so listeners and skip validation see the same |
| 365 | * ids as the task cards. |
| 366 | * |
| 367 | * @return string[] Task IDs, empty when the option is unset or malformed. |
| 368 | */ |
| 369 | function wpcom_ai_launchpad_get_ai_task_ids() { |
| 370 | $ai_output = get_option( 'wpcom_ai_launchpad_ai_output' ); |
| 371 | if ( ! is_array( $ai_output ) || ! isset( $ai_output['payload'] ) || ! is_array( $ai_output['payload'] ) ) { |
| 372 | return array(); |
| 373 | } |
| 374 | $payload = $ai_output['payload']; |
| 375 | |
| 376 | $task_ids = array(); |
| 377 | if ( isset( $payload['tasks'] ) && is_array( $payload['tasks'] ) ) { |
| 378 | foreach ( $payload['tasks'] as $task ) { |
| 379 | if ( is_array( $task ) && isset( $task['id'] ) && is_string( $task['id'] ) ) { |
| 380 | $task_ids[] = wpcom_ai_launchpad_remap_task_id( $task['id'] ); |
| 381 | } |
| 382 | } |
| 383 | } |
| 384 | |
| 385 | $task_ids = array_values( array_unique( $task_ids ) ); |
| 386 | |
| 387 | // Sell sites always render a Choose-a-theme task (see AI_Launchpad_REST::get_current_tasks), so the |
| 388 | // switch_theme listener and skip validation must count it even when the AI did not pick one — and |
| 389 | // even when a partial write left the payload with an inferred goal but no task list. Resolved |
| 390 | // through the shared helper so this mirror cannot disagree with the list it is mirroring. |
| 391 | if ( 'sell' === wpcom_ai_launchpad_resolve_goal( $payload ) && ! in_array( 'site_theme_selected', $task_ids, true ) ) { |
| 392 | $task_ids[] = 'site_theme_selected'; |
| 393 | } |
| 394 | |
| 395 | return $task_ids; |
| 396 | } |
| 397 | } |
| 398 | |
| 399 | if ( ! function_exists( 'wpcom_ai_launchpad_site_locale' ) ) { |
| 400 | /** |
| 401 | * The site's own language, as opposed to the language the current request is being read in. |
| 402 | * |
| 403 | * `get_locale()` is not it on WordPress.com Simple: there the locale follows the logged-in user |
| 404 | * through wp-admin and the REST calls it makes, so a French site read by an Italian admin reports |
| 405 | * Italian — and the pages we create, plus the language the AI is told to write in, would follow |
| 406 | * the reader instead of the site. `get_blog_lang_code()` is the blog's own `lang_id` setting. |
| 407 | * Same resolution the coming-soon page already uses for the same reason. |
| 408 | * |
| 409 | * @return string A WordPress locale, or a WordPress.com language code on Simple. |
| 410 | */ |
| 411 | function wpcom_ai_launchpad_site_locale() { |
| 412 | if ( function_exists( 'get_blog_lang_code' ) ) { |
| 413 | $code = get_blog_lang_code(); |
| 414 | // Empty when the blog has no language set; fall through to the request's own. |
| 415 | if ( is_string( $code ) && '' !== $code ) { |
| 416 | return $code; |
| 417 | } |
| 418 | } |
| 419 | |
| 420 | return get_locale(); |
| 421 | } |
| 422 | } |
| 423 | |
| 424 | if ( ! function_exists( 'wpcom_ai_launchpad_in_site_language' ) ) { |
| 425 | /** |
| 426 | * Runs a callback with translations switched to the site language. |
| 427 | * |
| 428 | * Requests from wp-admin, its REST calls included, translate into the admin user's language. Copy |
| 429 | * that ends up in the site's own posts and pages is public content and follows the site language. |
| 430 | * |
| 431 | * A site language WordPress cannot load — the pack is missing or mid-install — falls back to |
| 432 | * English rather than to whatever the reader happens to use, which would publish one admin's |
| 433 | * language onto a site that does not speak it, and give the next admin a different page. |
| 434 | * |
| 435 | * @param callable $callback The callback to run. |
| 436 | * @return mixed The callback's return value. |
| 437 | */ |
| 438 | function wpcom_ai_launchpad_in_site_language( $callback ) { |
| 439 | $site_locale = wpcom_ai_launchpad_site_locale(); |
| 440 | $switched = false; |
| 441 | |
| 442 | if ( $site_locale !== determine_locale() ) { |
| 443 | // en_US is always switchable, so the fallback cannot fail in turn. |
| 444 | $switched = switch_to_locale( $site_locale ) || switch_to_locale( 'en_US' ); |
| 445 | } |
| 446 | |
| 447 | try { |
| 448 | return $callback(); |
| 449 | } finally { |
| 450 | if ( $switched ) { |
| 451 | restore_previous_locale(); |
| 452 | } |
| 453 | } |
| 454 | } |
| 455 | } |
| 456 | |
| 457 | if ( ! function_exists( 'wpcom_ai_launchpad_site_copy' ) ) { |
| 458 | /** |
| 459 | * The copy the client writes into the site's posts and pages, in the site language. |
| 460 | * |
| 461 | * The `fallback_*` templates take the site name as their `%s`; the client fills it in. |
| 462 | * |
| 463 | * @return array<string, string|string[]> |
| 464 | */ |
| 465 | function wpcom_ai_launchpad_site_copy() { |
| 466 | return wpcom_ai_launchpad_in_site_language( |
| 467 | static function () { |
| 468 | return array( |
| 469 | 'about_page_title' => _x( 'About', 'page title', 'jetpack-mu-wpcom' ), |
| 470 | 'contact_page_title' => _x( 'Contact', 'page title', 'jetpack-mu-wpcom' ), |
| 471 | 'contact_page_heading' => __( 'Get in touch', 'jetpack-mu-wpcom' ), |
| 472 | 'contact_form_name_label' => __( 'Name', 'jetpack-mu-wpcom' ), |
| 473 | 'contact_form_email_label' => __( 'Email', 'jetpack-mu-wpcom' ), |
| 474 | 'contact_form_message_label' => __( 'Message', 'jetpack-mu-wpcom' ), |
| 475 | 'events_page_title' => _x( 'Events', 'page title', 'jetpack-mu-wpcom' ), |
| 476 | 'events_page_heading' => __( 'Upcoming events', 'jetpack-mu-wpcom' ), |
| 477 | 'event_name_placeholder' => __( 'Event name', 'jetpack-mu-wpcom' ), |
| 478 | 'event_details_placeholder' => __( 'Date, time, and place', 'jetpack-mu-wpcom' ), |
| 479 | 'video_page_title' => _x( 'Videos', 'page title', 'jetpack-mu-wpcom' ), |
| 480 | 'video_page_heading' => _x( 'Watch', 'video page heading', 'jetpack-mu-wpcom' ), |
| 481 | 'gallery_page_title' => _x( 'Gallery', 'page title', 'jetpack-mu-wpcom' ), |
| 482 | 'gallery_page_heading' => _x( 'Take a look', 'gallery page heading', 'jetpack-mu-wpcom' ), |
| 483 | 'portfolio_piece_placeholder' => __( 'What this project was, who it was for, and what you did.', 'jetpack-mu-wpcom' ), |
| 484 | 'fallback_site_name' => _x( 'your new site', 'stands in for a blank site name', 'jetpack-mu-wpcom' ), |
| 485 | /* translators: %s: the site name. */ |
| 486 | 'fallback_post_title' => __( 'Getting started with %s', 'jetpack-mu-wpcom' ), |
| 487 | /* translators: %s: the site name. */ |
| 488 | 'fallback_post_subtitle' => __( 'Introduce %s to your readers.', 'jetpack-mu-wpcom' ), |
| 489 | 'fallback_post_paragraphs' => array( |
| 490 | /* translators: %s: the site name. */ |
| 491 | __( 'This is the first post on %s. It marks the starting point of something new, and there is plenty more to come.', 'jetpack-mu-wpcom' ), |
| 492 | __( 'Thanks for being here at the very beginning. Stay tuned for what comes next.', 'jetpack-mu-wpcom' ), |
| 493 | ), |
| 494 | 'fallback_about_paragraphs' => array( |
| 495 | /* translators: %s: the site name. */ |
| 496 | __( 'This is where the story of %s begins. Use this page to share who is behind the site and what it is all about.', 'jetpack-mu-wpcom' ), |
| 497 | __( 'Tell visitors how it started, what they can expect to find here, and where it is headed next.', 'jetpack-mu-wpcom' ), |
| 498 | ), |
| 499 | ); |
| 500 | } |
| 501 | ); |
| 502 | } |
| 503 | } |
| 504 | |
| 505 | if ( ! function_exists( 'wpcom_ai_launchpad_script_translations' ) ) { |
| 506 | /** |
| 507 | * Inline JS that installs the translation catalogs of the page's wp-build bundles into `wp.i18n`. |
| 508 | * |
| 509 | * Core's `load_script_textdomain()` on a registered, never-enqueued handle per bundle picks up the |
| 510 | * platform filters that point this package's catalogs at `languages/mu-plugins/`, which no URL |
| 511 | * serves for the client-side loader the other wp-build dashboards use. |
| 512 | * |
| 513 | * @param string $manifest_file Path to the build's `i18n-manifest.json` (from `stamp-textdomains`). |
| 514 | * @param string $build_url URL of the build directory the bundles are served from. |
| 515 | * @param string $route The wp-build route whose bundles to cover, besides the shared modules and scripts. |
| 516 | * @param string $domain The text domain. |
| 517 | * @return string|null The inline script, or null when no bundle has a catalog for this locale. |
| 518 | */ |
| 519 | function wpcom_ai_launchpad_script_translations( $manifest_file, $build_url, $route = 'site-setup', $domain = 'jetpack-mu-wpcom' ) { |
| 520 | if ( ! is_readable( $manifest_file ) ) { |
| 521 | return null; |
| 522 | } |
| 523 | $manifest = json_decode( (string) file_get_contents( $manifest_file ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local build artifact. |
| 524 | $bundles = is_array( $manifest ) && isset( $manifest['bundles'] ) && is_array( $manifest['bundles'] ) ? $manifest['bundles'] : array(); |
| 525 | |
| 526 | $scripts = array(); |
| 527 | foreach ( $bundles as $index => $bundle ) { |
| 528 | // The manifest lists paths under the build directory's own name, e.g. `build/routes/x/content.js`. |
| 529 | if ( ! is_string( $bundle ) || ! preg_match( '#^[^/]+/(routes/' . preg_quote( $route, '#' ) . '|scripts|modules)/#', $bundle ) ) { |
| 530 | continue; |
| 531 | } |
| 532 | $handle = 'wpcom-ai-launchpad-i18n-' . $index; |
| 533 | wp_register_script( $handle, trailingslashit( $build_url ) . substr( $bundle, strpos( $bundle, '/' ) + 1 ), array(), false, false ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.NoExplicitVersion -- Never printed; only registered so core can locate its catalog. |
| 534 | $json = load_script_textdomain( $handle, $domain ); |
| 535 | wp_deregister_script( $handle ); |
| 536 | if ( ! $json ) { |
| 537 | continue; |
| 538 | } |
| 539 | |
| 540 | $data = json_decode( $json, true ); |
| 541 | $messages = $data['locale_data'][ $domain ] ?? $data['locale_data']['messages'] ?? null; |
| 542 | if ( ! is_array( $messages ) || array() === $messages ) { |
| 543 | continue; |
| 544 | } |
| 545 | // HEX_TAG keeps a literal "</script>" in a translation from closing the inline script tag. |
| 546 | $scripts[] = 'wp.i18n.setLocaleData( ' . wp_json_encode( $messages, JSON_HEX_TAG | JSON_HEX_AMP ) . ', ' . wp_json_encode( $domain, JSON_HEX_TAG | JSON_HEX_AMP ) . ' );'; |
| 547 | } |
| 548 | |
| 549 | return $scripts ? implode( "\n", $scripts ) : null; |
| 550 | } |
| 551 | } |