Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.22% covered (warning)
72.22%
143 / 198
60.00% covered (warning)
60.00%
9 / 15
CRAP
n/a
0 / 0
wpcom_ai_launchpad_remap_task_id
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
wpcom_ai_launchpad_resolve_goal
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
110
wpcom_ai_launchpad_to_simple_plugins_path
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
56
wpcom_ai_launchpad_tracks_context
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
9
wpcom_ai_launchpad_is_test
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
6.29
wpcom_ai_launchpad_is_a11n
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
wpcom_ai_launchpad_standard_props
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
10
wpcom_ai_launchpad_shape_tracks_identity
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
wpcom_ai_launchpad_tracks_identity
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
4.94
wpcom_ai_launchpad_record_tracks_event
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
2
wpcom_ai_launchpad_get_ai_task_ids
23.08% covered (danger)
23.08%
3 / 13
0.00% covered (danger)
0.00%
0 / 1
77.54
wpcom_ai_launchpad_site_locale
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
wpcom_ai_launchpad_in_site_language
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
wpcom_ai_launchpad_site_copy
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
1
wpcom_ai_launchpad_script_translations
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
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
8if ( ! 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
42if ( ! 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
74if ( ! 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
105if ( ! 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
144if ( ! 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
181if ( ! 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
203if ( ! 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
263if ( ! 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
286if ( ! 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
312if ( ! 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
361if ( ! 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
399if ( ! 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
424if ( ! 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
457if ( ! 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
505if ( ! 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}