PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.7
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.7
16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 All 506 releases
jetpack / src / class-theme-styles-sync.php

class-theme-styles-sync.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.7, at src/class-theme-styles-sync.php

229 lines 7.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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
8 namespace Automattic\Jetpack\Plugin;
9
10 use 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 */
18 class 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 }
229