Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.19% covered (success)
96.19%
278 / 289
71.43% covered (warning)
71.43%
10 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
SolutionsBranding
96.19% covered (success)
96.19%
278 / 289
71.43% covered (warning)
71.43%
10 / 14
58
0.00% covered (danger)
0.00%
0 / 1
 build_for_container
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
11
 get_localization_branding
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_plugin_install_tab_icon_inline_script
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 get_primary_color_hex
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 defaults_for_plugin_id
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
5
 preset_maps
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
1
 preset_base_skeleton
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
1
 square_grid_tab_icon_markup
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 module_asset_url
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 deep_merge_arrays
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sanitize_branding_payload
81.40% covered (warning)
81.40%
35 / 43
0.00% covered (danger)
0.00%
0 / 1
26.41
 sanitize_url_field
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 sanitize_tab_icon_svg_markup
98.97% covered (success)
98.97%
96 / 97
0.00% covered (danger)
0.00%
0 / 1
3
 sanitize_hex_color_allow_short
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3namespace NewfoldLabs\WP\Module\Solutions;
4
5use NewfoldLabs\WP\ModuleLoader\Container;
6
7/**
8 * Branding defaults and merge order for Solutions UI (localized to JS).
9 */
10class SolutionsBranding {
11
12    /** CTB UUID shared by BH/HG commerce family upsells (matches host plugin runtime.ctbs.ecomFamily). */
13    public const DEFAULT_ECOM_FAMILY_CTB_ID = '5dc83bdd-9274-4557-a6d7-0b2adbc3919f';
14
15    /**
16     * Cached branding keyed by serialized container fingerprint is unsafe; rebuild per request.
17     *
18     * @param Container $container Module loader container.
19     * @return array<string, mixed>
20     */
21    public static function build_for_container( Container $container ): array {
22        $plugin    = $container->plugin();
23        $plugin_id = isset( $plugin->id ) ? (string) $plugin->id : 'bluehost';
24        $defaults  = self::defaults_for_plugin_id( $plugin_id );
25
26        if ( isset( $plugin->brand ) && is_string( $plugin->brand ) ) {
27            $defaults['brandKey'] = $plugin->brand;
28        }
29
30        $merged = $defaults;
31
32        $override = null;
33        if ( method_exists( $container, 'has' ) && $container->has( 'solutions_branding' ) ) {
34            $override = $container->get( 'solutions_branding' );
35        }
36        if ( null !== $override ) {
37            if ( is_callable( $override ) ) {
38                $resolved = call_user_func( $override, $container );
39                if ( is_array( $resolved ) ) {
40                    $merged = self::deep_merge_arrays( $merged, $resolved );
41                }
42            } elseif ( is_array( $override ) ) {
43                $merged = self::deep_merge_arrays( $merged, $override );
44            }
45        }
46
47        /**
48         * Filter branding for Solutions localized data and scripts.
49         *
50         * @param array     $merged    Branding data.
51         * @param Container $container Loader container.
52         */
53        $filtered = \apply_filters( 'nfd_solutions_branding', $merged, $container );
54        if ( ! is_array( $filtered ) ) {
55            $filtered = $merged;
56        }
57
58        return self::sanitize_branding_payload( $filtered );
59    }
60
61    /**
62     * JSON-serializable localization payload keyed as `branding` for NewfoldSolutions.
63     *
64     * @param Container $container Module loader container.
65     * @return array<string, mixed>
66     */
67    public static function get_localization_branding( Container $container ): array {
68        return self::build_for_container( $container );
69    }
70
71    /**
72     * Inline JS for Plugin Install tab icon (kses-sanitized SVG markup JSON-encoded).
73     *
74     * @param Container $container Module loader container.
75     * @return string Script body (no script tags).
76     */
77    public static function get_plugin_install_tab_icon_inline_script( Container $container ): string {
78        $branding      = self::build_for_container( $container );
79        $icon_markup   = isset( $branding['assets']['tabIconSvg'] ) ? (string) $branding['assets']['tabIconSvg'] : '';
80        $icon_json     = wp_json_encode( $icon_markup );
81        $safe_icon_var = false === $icon_json ? '""' : $icon_json;
82
83        return "
84            document.addEventListener('DOMContentLoaded', function() {
85                let icon = {$safe_icon_var};
86                const filterPremiumLink = document.querySelector('.plugin-install-nfd_solutions > a');
87                if (filterPremiumLink && icon) {
88                    filterPremiumLink.innerHTML = icon + filterPremiumLink.innerHTML;
89                }
90            });
91        ";
92    }
93
94    /**
95     * Sanitized CSS hex primary color for inline :root overrides.
96     *
97     * @param Container $container Module loader container.
98     * @return string
99     */
100    public static function get_primary_color_hex( Container $container ): string {
101        $branding = self::build_for_container( $container );
102        $hex      = isset( $branding['colors']['primary'] ) ? (string) $branding['colors']['primary'] : '';
103        return self::sanitize_hex_color_allow_short( $hex );
104    }
105
106    /**
107     * Build defaults for branding values using the active plugin slug.
108     *
109     * @param string $plugin_id Plugin screen id slug.
110     * @return array<string, mixed>
111     */
112    protected static function defaults_for_plugin_id( string $plugin_id ): array {
113        $plugin_id = strtolower( $plugin_id );
114
115        $display_names = array(
116            'bluehost'  => __( 'Bluehost', 'wp-module-solutions' ),
117            'hostgator' => __( 'HostGator', 'wp-module-solutions' ),
118        );
119
120        $brand_display = isset( $display_names[ $plugin_id ] )
121            ? $display_names[ $plugin_id ]
122            : ucfirst( $plugin_id );
123
124        $maps = self::preset_maps();
125
126        if ( isset( $maps[ $plugin_id ] ) ) {
127            $preset = self::deep_merge_arrays(
128                self::preset_base_skeleton( $brand_display, $plugin_id ),
129                $maps[ $plugin_id ]
130            );
131
132            $preset['brandDisplayName'] = $brand_display;
133            $preset['pluginId']         = $plugin_id;
134
135            /*
136             * Tab icon markup: BH uses the lattice mark. Other brands default to empty so host plugins
137             * can supply imagery (assets.tabIconSvg) without inheriting BH-specific SVG.
138             */
139            if ( 'bluehost' === $plugin_id ) {
140                $tab_fill                        = isset( $preset['colors']['tabIconFill'] )
141                    ? (string) $preset['colors']['tabIconFill']
142                    : '#196BDE';
143                $preset['assets']['tabIconSvg']  = self::square_grid_tab_icon_markup( $tab_fill );
144                $preset['assets']['wordmarkUrl'] = esc_url(
145                    self::module_asset_url( 'assets/wordmarks/bluehost.svg' )
146                );
147            } else {
148                /*
149                 * Other brands (e.g. HostGator): tab icon/wordmark imagery comes from
150                 * host plugins via container merge; Solutions header falls back to
151                 * brandDisplayName until marketing supplies assets.wordmarkUrl.
152                 */
153                $preset['assets']['tabIconSvg'] = '';
154            }
155
156            return $preset;
157        }
158
159        return self::deep_merge_arrays(
160            self::preset_base_skeleton( $brand_display, $plugin_id ),
161            array(
162                'colors'           => array(
163                    'primary'     => '#336AD7',
164                    'tabIconFill' => '#196BDE',
165                ),
166                'assets'           => array(
167                    'tabIconSvg' => self::square_grid_tab_icon_markup( '#196BDE' ),
168                ),
169                'brandDisplayName' => $brand_display,
170                'pluginId'         => $plugin_id,
171            )
172        );
173    }
174
175    /**
176     * Host-specific URL presets keyed by loader plugin slug.
177     *
178     * @return array<string, array<string, mixed>>
179     */
180    protected static function preset_maps(): array {
181        return array(
182            'bluehost'  => array(
183                'urls'   => array(
184                    'accountCenterLearnMore' => 'https://www.bluehost.com/my-account/account-center?utm_source=wp-admin%2Fplugins.php&utm_medium=bluehost_plugin',
185                    'helpArticleSolutions'   => 'https://www.bluehost.com/help/article/wp-solutions?utm_source=wp-admin&utm_medium=brand_plugin',
186                    'ecomFamilyUpgrade'      => 'https://www.bluehost.com/my-account/hosting/details#click-to-buy-WP_SOLUTION_FAMILY',
187                ),
188                'ctbs'   => array(
189                    'ecomFamily' => array(
190                        'id'  => self::DEFAULT_ECOM_FAMILY_CTB_ID,
191                        'url' => 'https://www.bluehost.com/my-account/market-place#marketplace-WordPress%20Solutions',
192                    ),
193                ),
194                'colors' => array(
195                    'primary'     => '#336AD7',
196                    'tabIconFill' => '#196BDE',
197                ),
198            ),
199            'hostgator' => array(
200                'urls'   => array(
201                    'accountCenterLearnMore' => 'https://www.hostgator.com/my-account/account-center?utm_source=wp-admin%2Fplugins.php&utm_medium=hostgator_plugin',
202                    'helpArticleSolutions'   => 'https://www.hostgator.com/help/article/wordpress-solutions?utm_source=wp-admin&utm_medium=brand_plugin',
203                    'ecomFamilyUpgrade'      => 'https://www.hostgator.com/my-account/hosting/details#click-to-buy-WP_SOLUTION_FAMILY',
204                ),
205                'ctbs'   => array(
206                    'ecomFamily' => array(
207                        'id'  => self::DEFAULT_ECOM_FAMILY_CTB_ID,
208                        'url' => 'https://www.hostgator.com/my-account/hosting/details#click-to-buy-WP_SOLUTION_FAMILY',
209                    ),
210                ),
211                'colors' => array(
212                    'primary'     => '#336AD7',
213                    'tabIconFill' => '#2C9E4B',
214                ),
215            ),
216        );
217    }
218
219    /**
220     * Neutral defaults before preset merge.
221     *
222     * @param string $brand_display Localized hosting brand label.
223     * @param string $plugin_id      Plugin screen id slug.
224     * @return array<string, mixed>
225     */
226    protected static function preset_base_skeleton( string $brand_display, string $plugin_id ): array {
227        return array(
228            'brandDisplayName' => $brand_display,
229            'pluginId'         => $plugin_id,
230            'brandKey'         => $plugin_id,
231            'urls'             => array(
232                'accountCenterLearnMore' => '',
233                'helpArticleSolutions'   => '',
234                'ecomFamilyUpgrade'      => '',
235            ),
236            'ctbs'             => array(
237                'ecomFamily' => array(
238                    'id'  => self::DEFAULT_ECOM_FAMILY_CTB_ID,
239                    'url' => '',
240                ),
241            ),
242            'colors'           => array(
243                'primary'     => '#336AD7',
244                'tabIconFill' => '#196BDE',
245            ),
246            'assets'           => array(
247                'tabIconSvg' => '',
248            ),
249        );
250    }
251
252    /**
253     * Generate default square-mark SVG markup for Plugin Install tabs.
254     *
255     * @param string $fill Hex color for icon paths.
256     * @return string
257     */
258    protected static function square_grid_tab_icon_markup( string $fill ): string {
259        $fill = self::sanitize_hex_color_allow_short( $fill );
260
261        return '<svg id="nfd-tools-plugin-brand-icon" data-testid="nfd-solutions-install-tab-icon" width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">'
262            . '<path fill-rule="evenodd" clip-rule="evenodd" d=\''
263            . 'M16 4.46067V0H11.5302V4.46067H16ZM16 5.76933V10.2307H11.5302V5.76933H16ZM4.46778 16V11.5387H0V16H4.46778ZM10.2339 11.5387V16H5.76409V11.5387H10.2339ZM16 11.5387V16H11.5302V11.5387H16ZM10.2339 10.2307V5.76933H5.76409V10.2307H10.2339ZM4.46778 5.76933V10.2307H0V5.76933H4.46778ZM10.2305 0V4.46067H5.76409V0H10.2305ZM4.46778 4.46067V0H0V4.46067H4.46778Z\''
264            . ' fill="' . esc_attr( $fill ) . '"/></svg>';
265    }
266
267    /**
268     * Public URL for a static file shipped inside this Composer package (e.g. SVG wordmarks).
269     *
270     * @param string $relative Path relative to the module root (use forward slashes).
271     * @return string
272     */
273    protected static function module_asset_url( string $relative ): string {
274        $relative  = str_replace( '\\', '/', $relative );
275        $relative  = ltrim( $relative, '/' );
276        $bootstrap = dirname( __DIR__ ) . '/bootstrap.php';
277
278        return plugins_url( $relative, $bootstrap );
279    }
280
281    /**
282     * Deep-merge helper for branding payloads.
283     *
284     * @param array $base    Base associative array.
285     * @param array $overlay Overlay merged with array_replace_recursive.
286     * @return array
287     */
288    protected static function deep_merge_arrays( array $base, array $overlay ): array {
289        return array_replace_recursive( $base, $overlay );
290    }
291
292    /**
293     * Sanitize localization payload before exposing it to JavaScript consumers.
294     *
295     * @param array<string, mixed> $branding Branding payload.
296     * @return array<string, mixed>
297     */
298    protected static function sanitize_branding_payload( array $branding ): array {
299        if ( ! isset( $branding['urls'] ) || ! is_array( $branding['urls'] ) ) {
300            $branding['urls'] = array();
301        }
302        $url_keys = array(
303            'accountCenterLearnMore',
304            'helpArticleSolutions',
305            'ecomFamilyUpgrade',
306        );
307        foreach ( $url_keys as $key ) {
308            $branding['urls'][ $key ] = self::sanitize_url_field(
309                isset( $branding['urls'][ $key ] ) ? $branding['urls'][ $key ] : ''
310            );
311        }
312
313        if ( ! isset( $branding['ctbs'] ) || ! is_array( $branding['ctbs'] ) ) {
314            $branding['ctbs'] = array();
315        }
316        if ( ! isset( $branding['ctbs']['ecomFamily'] ) || ! is_array( $branding['ctbs']['ecomFamily'] ) ) {
317            $branding['ctbs']['ecomFamily'] = array();
318        }
319        $branding['ctbs']['ecomFamily']['id']  = isset( $branding['ctbs']['ecomFamily']['id'] ) && is_scalar( $branding['ctbs']['ecomFamily']['id'] )
320            ? (string) $branding['ctbs']['ecomFamily']['id']
321            : self::DEFAULT_ECOM_FAMILY_CTB_ID;
322        $branding['ctbs']['ecomFamily']['url'] = self::sanitize_url_field(
323            isset( $branding['ctbs']['ecomFamily']['url'] ) ? $branding['ctbs']['ecomFamily']['url'] : ''
324        );
325
326        if ( ! isset( $branding['assets'] ) || ! is_array( $branding['assets'] ) ) {
327            $branding['assets'] = array();
328        }
329        $branding['assets']['tabIconSvg'] = self::sanitize_tab_icon_svg_markup(
330            isset( $branding['assets']['tabIconSvg'] ) ? (string) $branding['assets']['tabIconSvg'] : ''
331        );
332        if ( array_key_exists( 'wordmarkUrl', $branding['assets'] ) ) {
333            $wordmark_url = $branding['assets']['wordmarkUrl'];
334            if ( false === $wordmark_url ) {
335                $branding['assets']['wordmarkUrl'] = false;
336            } elseif ( is_string( $wordmark_url ) ) {
337                $branding['assets']['wordmarkUrl'] = self::sanitize_url_field( $wordmark_url );
338            } elseif ( null !== $wordmark_url ) {
339                unset( $branding['assets']['wordmarkUrl'] );
340            }
341        }
342
343        if ( ! isset( $branding['colors'] ) || ! is_array( $branding['colors'] ) ) {
344            $branding['colors'] = array();
345        }
346        $branding['colors']['primary']     = self::sanitize_hex_color_allow_short(
347            isset( $branding['colors']['primary'] ) ? (string) $branding['colors']['primary'] : ''
348        );
349        $branding['colors']['tabIconFill'] = self::sanitize_hex_color_allow_short(
350            isset( $branding['colors']['tabIconFill'] ) ? (string) $branding['colors']['tabIconFill'] : ''
351        );
352
353        return $branding;
354    }
355
356    /**
357     * Restrict URLs to expected protocols for localization payloads.
358     *
359     * @param mixed $value Candidate URL.
360     * @return string
361     */
362    protected static function sanitize_url_field( $value ): string {
363        if ( ! is_string( $value ) ) {
364            return '';
365        }
366        return esc_url_raw( trim( $value ), array( 'http', 'https' ) );
367    }
368
369    /**
370     * Allowlist-safe SVG markup for the plugin-install tab icon.
371     *
372     * @param string $markup SVG markup candidate.
373     * @return string
374     */
375    protected static function sanitize_tab_icon_svg_markup( string $markup ): string {
376        $trimmed = trim( $markup );
377        if ( '' === $trimmed ) {
378            return '';
379        }
380        if ( 0 !== strpos( strtolower( $trimmed ), '<svg' ) ) {
381            return '';
382        }
383
384        $allowed = array(
385            'svg'      => array(
386                'id'                  => true,
387                'data-testid'         => true,
388                'class'               => true,
389                'xmlns'               => true,
390                'viewbox'             => true,
391                'width'               => true,
392                'height'              => true,
393                'fill'                => true,
394                'stroke'              => true,
395                'stroke-width'        => true,
396                'role'                => true,
397                'aria-hidden'         => true,
398                'focusable'           => true,
399                'preserveaspectratio' => true,
400            ),
401            'g'        => array(
402                'fill'         => true,
403                'stroke'       => true,
404                'stroke-width' => true,
405                'transform'    => true,
406                'opacity'      => true,
407            ),
408            'path'     => array(
409                'd'            => true,
410                'fill'         => true,
411                'stroke'       => true,
412                'stroke-width' => true,
413                'fill-rule'    => true,
414                'clip-rule'    => true,
415                'transform'    => true,
416                'opacity'      => true,
417            ),
418            'rect'     => array(
419                'x'            => true,
420                'y'            => true,
421                'width'        => true,
422                'height'       => true,
423                'rx'           => true,
424                'ry'           => true,
425                'fill'         => true,
426                'stroke'       => true,
427                'stroke-width' => true,
428                'opacity'      => true,
429            ),
430            'circle'   => array(
431                'cx'           => true,
432                'cy'           => true,
433                'r'            => true,
434                'fill'         => true,
435                'stroke'       => true,
436                'stroke-width' => true,
437                'opacity'      => true,
438            ),
439            'ellipse'  => array(
440                'cx'           => true,
441                'cy'           => true,
442                'rx'           => true,
443                'ry'           => true,
444                'fill'         => true,
445                'stroke'       => true,
446                'stroke-width' => true,
447                'opacity'      => true,
448            ),
449            'line'     => array(
450                'x1'           => true,
451                'x2'           => true,
452                'y1'           => true,
453                'y2'           => true,
454                'stroke'       => true,
455                'stroke-width' => true,
456                'opacity'      => true,
457            ),
458            'polyline' => array(
459                'points'       => true,
460                'fill'         => true,
461                'stroke'       => true,
462                'stroke-width' => true,
463                'opacity'      => true,
464            ),
465            'polygon'  => array(
466                'points'       => true,
467                'fill'         => true,
468                'stroke'       => true,
469                'stroke-width' => true,
470                'opacity'      => true,
471            ),
472            'title'    => array(),
473            'desc'     => array(),
474        );
475
476        return trim( wp_kses( $trimmed, $allowed ) );
477    }
478
479    /**
480     * Permit 3-digit or 6-digit hex for inline CSS/SVG usage.
481     *
482     * @param string $color Candidate color string.
483     * @return string
484     */
485    protected static function sanitize_hex_color_allow_short( string $color ): string {
486        $trim = strtolower( trim( $color ) );
487        if ( preg_match( '/^#([a-f0-9]{6}|[a-f0-9]{3})$/', $trim ) ) {
488            return $trim;
489        }
490        if ( preg_match( '/^([a-f0-9]{6}|[a-f0-9]{3})$/', $trim ) ) {
491            return '#' . $trim;
492        }
493        return '#336AD7';
494    }
495}