Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.70% covered (success)
97.70%
85 / 87
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Theme_Styles_Sync
97.70% covered (success)
97.70%
85 / 87
66.67% covered (warning)
66.67%
4 / 6
26
0.00% covered (danger)
0.00%
0 / 1
 get_theme_styles
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
4.00
 preset_sources
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 without_font_files
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 inheritable_styles
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
2
 intersect
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
6.02
 flatten_presets
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
7
1<?php
2/**
3 * The slice of the active theme's design that a post email can inherit.
4 *
5 * @package automattic/jetpack
6 */
7
8namespace Automattic\Jetpack\Plugin;
9
10use WP_Theme_JSON_Resolver;
11
12/**
13 * Builds the `theme_styles` sync callable.
14 *
15 * Post emails render on WordPress.com against its own copy of a theme, so a site running a theme
16 * it does not ship inherits no design at all. See NL-943.
17 */
18class Theme_Styles_Sync {
19
20    /**
21     * Refuse to sync a slice larger than this, in bytes.
22     *
23     * A bound on a pathological palette, not a budget: ordinary themes are orders below it, and a
24     * theme that trips it syncs its name with no design rather than nothing at all.
25     */
26    const MAX_PAYLOAD_BYTES = 51200;
27
28    /**
29     * The active theme's inheritable design.
30     *
31     * @return array|null Null only where this WordPress cannot resolve theme.json at all.
32     */
33    public static function get_theme_styles() {
34        if ( ! class_exists( 'WP_Theme_JSON_Resolver' ) ) {
35            return null;
36        }
37
38        $theme = WP_Theme_JSON_Resolver::get_theme_data();
39        $raw   = $theme->get_raw_data();
40
41        // User customisations are deliberately absent: they already reach WordPress.com through the
42        // synced `wp_global_styles` post, which is layered over this.
43        $styles = self::inheritable_styles( $raw['styles'] ?? array() );
44
45        // Reported even when empty: sync skips a null value, which would leave the receiving end
46        // holding the previous theme's design with nothing to say the theme had changed.
47        $slice = array(
48            'stylesheet' => get_stylesheet(),
49            // Core's own, not a literal: it migrates raw data to the latest schema, so labelling a
50            // later shape with an older number would have the receiving end migrate it a second time.
51            'version'    => $raw['version'] ?? 3,
52            'settings'   => self::preset_sources( $raw['settings'] ?? array() ),
53            'styles'     => $styles,
54        );
55
56        // phpcs:ignore Jetpack.Functions.JsonEncodeFlags.Missing -- measuring the wire representation, which Sync encodes with default flags.
57        $encoded = wp_json_encode( $slice );
58        if ( false === $encoded || strlen( $encoded ) > self::MAX_PAYLOAD_BYTES ) {
59            // Reporting nothing would hit the same staleness the empty slice above exists to avoid,
60            // so carry the theme's name with no design and let the receiving end fall back.
61            $slice['settings'] = self::preset_sources( array() );
62            $slice['styles']   = array();
63        }
64
65        return $slice;
66    }
67
68    /**
69     * The preset definitions a style's `var(--wp--preset--…)` reference needs to be resolved.
70     *
71     * Styles travel unresolved on purpose. Resolving here would bake in the theme's stock value and
72     * lose the reference, so a creator who recolours a palette slug would keep getting the stock
73     * colour in their email while their site renders the new one. The receiving end resolves instead,
74     * against these merged under the creator's own record, which is the only place both are known.
75     *
76     * @param array $settings The theme's settings.
77     * @return array
78     */
79    private static function preset_sources( array $settings ) {
80        $sources = array( 'color' => array( 'palette' => self::flatten_presets( $settings['color']['palette'] ?? array() ) ) );
81
82        $wanted = array(
83            'typography' => array( 'fontSizes', 'fontFamilies' ),
84            'spacing'    => array( 'spacingSizes' ),
85        );
86
87        foreach ( $wanted as $group => $keys ) {
88            foreach ( $keys as $key ) {
89                $presets = self::flatten_presets( $settings[ $group ][ $key ] ?? array() );
90                if ( ! empty( $presets ) ) {
91                    $sources[ $group ][ $key ] = $presets;
92                }
93            }
94        }
95
96        // The font files are two thirds of a family's bytes and no use to a mail client, which cannot
97        // load a web font. Only the family name resolves a `var(--wp--preset--font-family--…)`.
98        if ( isset( $sources['typography']['fontFamilies'] ) ) {
99            $sources['typography']['fontFamilies'] = array_map( array( self::class, 'without_font_files' ), $sources['typography']['fontFamilies'] );
100        }
101
102        return $sources;
103    }
104
105    /**
106     * One font-family preset without its `fontFace` declarations.
107     *
108     * @param mixed $family A `fontFamilies` entry.
109     * @return mixed
110     */
111    private static function without_font_files( $family ) {
112        if ( is_array( $family ) ) {
113            unset( $family['fontFace'] );
114        }
115
116        return $family;
117    }
118
119    /**
120     * Keep only the style paths an email could act on.
121     *
122     * Deliberately a superset of the allowlist WordPress.com applies on receipt, so narrowing what
123     * an email inherits stays a WordPress.com-side change rather than one gated on a plugin release.
124     *
125     * @param array $styles The theme's styles.
126     * @return array
127     */
128    private static function inheritable_styles( array $styles ) {
129        $typography = array_fill_keys(
130            array( 'fontFamily', 'fontSize', 'fontWeight', 'fontStyle', 'lineHeight', 'letterSpacing', 'textDecoration', 'textTransform' ),
131            true
132        );
133        $color      = array(
134            'text'       => true,
135            'background' => true,
136        );
137
138        $element  = array(
139            'typography' => $typography,
140            'color'      => $color,
141        );
142        $elements = array(
143            'link'    => $element,
144            'heading' => $element,
145            'button'  => $element,
146            'caption' => $element,
147        );
148        foreach ( range( 1, 6 ) as $level ) {
149            $elements[ 'h' . $level ] = $element;
150        }
151
152        return self::intersect(
153            $styles,
154            array(
155                'color'      => $color,
156                'typography' => $typography,
157                'spacing'    => array(
158                    'blockGap' => true,
159                    'padding'  => true,
160                    'margin'   => true,
161                ),
162                'elements'   => $elements,
163            )
164        );
165    }
166
167    /**
168     * Keep only the branches an allowlist names, dropping empty ones entirely.
169     *
170     * @param array $styles    A styles tree, or a branch of one.
171     * @param array $allowlist The matching branch of the allowlist.
172     * @return array
173     */
174    private static function intersect( array $styles, array $allowlist ) {
175        $kept = array();
176
177        foreach ( $allowlist as $key => $permitted ) {
178            if ( ! isset( $styles[ $key ] ) ) {
179                continue;
180            }
181
182            if ( true === $permitted ) {
183                $kept[ $key ] = $styles[ $key ];
184                continue;
185            }
186
187            if ( ! is_array( $styles[ $key ] ) ) {
188                continue;
189            }
190
191            $branch = self::intersect( $styles[ $key ], $permitted );
192            if ( ! empty( $branch ) ) {
193                $kept[ $key ] = $branch;
194            }
195        }
196
197        return $kept;
198    }
199
200    /**
201     * Flatten an origin-keyed preset list into the flat list `WP_Theme_JSON` expects for one origin.
202     *
203     * Nothing here is assumed about the shape: core's schema leaves a non-array preset list
204     * untouched, and its constructor only origin-keys a scalar when `isset( $preset[0] ) || empty(
205     * $preset )`, so a theme.json declaring `true` or a number reaches this as that bare scalar.
206     *
207     * @param mixed $presets Preset list that may be origin-keyed, already flat, or not a list.
208     * @return array
209     */
210    private static function flatten_presets( $presets ) {
211        if ( ! is_array( $presets ) ) {
212            return array();
213        }
214
215        if ( empty( $presets ) || isset( $presets[0] ) ) {
216            return $presets;
217        }
218
219        $flat = array();
220        foreach ( array( 'default', 'blocks', 'theme', 'custom' ) as $origin ) {
221            if ( isset( $presets[ $origin ] ) && is_array( $presets[ $origin ] ) ) {
222                $flat = array_merge( $flat, $presets[ $origin ] );
223            }
224        }
225
226        return $flat;
227    }
228}