1313 * rules compare real colors; raw values in the style object are returned
1414 * as-is. Facts that don't exist on a block type return null and rules skip.
1515 */
16- class Saddle_Lint_Gutenberg_Accessor implements Saddle_Lint_Accessor {
16+ class Saddle_Lint_Gutenberg_Accessor implements Saddle_Lint_Accessor, Saddle_Lint_Style_Accessor {
1717
1818 /**
1919 * theme.json color slug → value map, built once per instance.
@@ -22,6 +22,13 @@ class Saddle_Lint_Gutenberg_Accessor implements Saddle_Lint_Accessor {
2222 */
2323 private $ palette = null ;
2424
25+ /**
26+ * theme.json font-size slug → value map, built once per instance.
27+ *
28+ * @var array<string,string>|null
29+ */
30+ private $ font_sizes = null ;
31+
2532 /**
2633 * The node's background: style.color.background, or the resolved
2734 * backgroundColor preset. Gradients are unresolvable to one color → null.
@@ -130,6 +137,182 @@ public function title_text( array $node ) {
130137 return trim ( wp_strip_all_tags ( (string ) $ node ['innerHTML ' ] ) );
131138 }
132139
140+ /*
141+ ---------------------------------------------------------------------
142+ * Saddle_Lint_Style_Accessor (companion facts)
143+ * -------------------------------------------------------------------
144+ */
145+
146+ /**
147+ * style.border.radius: a plain string, or per-corner values serialized
148+ * clockwise from top-left so identical corner sets compare equal.
149+ *
150+ * @param array $node Raw block array.
151+ * @return string|null
152+ */
153+ public function border_radius ( array $ node ) {
154+ $ attrs = $ this ->attrs ( $ node );
155+ $ radius = isset ( $ attrs ['style ' ]['border ' ]['radius ' ] ) ? $ attrs ['style ' ]['border ' ]['radius ' ] : null ;
156+
157+ if ( is_string ( $ radius ) && '' !== trim ( $ radius ) ) {
158+ return trim ( $ radius );
159+ }
160+ if ( is_array ( $ radius ) && $ radius ) {
161+ $ corners = array ();
162+ foreach ( array ( 'topLeft ' , 'topRight ' , 'bottomRight ' , 'bottomLeft ' ) as $ corner ) {
163+ $ corners [] = isset ( $ radius [ $ corner ] ) && is_string ( $ radius [ $ corner ] ) ? trim ( $ radius [ $ corner ] ) : '0 ' ;
164+ }
165+ return implode ( ' ' , $ corners );
166+ }
167+ return null ;
168+ }
169+
170+ /**
171+ * style.spacing.blockGap: a plain string, or "row col" when the two axes
172+ * differ (blockGap objects carry top = row gap, left = column gap).
173+ *
174+ * @param array $node Raw block array.
175+ * @return string|null
176+ */
177+ public function gap ( array $ node ) {
178+ $ attrs = $ this ->attrs ( $ node );
179+ $ gap = isset ( $ attrs ['style ' ]['spacing ' ]['blockGap ' ] ) ? $ attrs ['style ' ]['spacing ' ]['blockGap ' ] : null ;
180+
181+ if ( is_string ( $ gap ) && '' !== trim ( $ gap ) ) {
182+ return trim ( $ gap );
183+ }
184+ if ( is_array ( $ gap ) && $ gap ) {
185+ $ row = isset ( $ gap ['top ' ] ) && is_string ( $ gap ['top ' ] ) ? trim ( $ gap ['top ' ] ) : '' ;
186+ $ col = isset ( $ gap ['left ' ] ) && is_string ( $ gap ['left ' ] ) ? trim ( $ gap ['left ' ] ) : '' ;
187+ if ( '' === $ row && '' === $ col ) {
188+ return null ;
189+ }
190+ if ( '' === $ row || '' === $ col || $ row === $ col ) {
191+ return '' !== $ row ? $ row : $ col ;
192+ }
193+ return $ row . ' ' . $ col ;
194+ }
195+ return null ;
196+ }
197+
198+ /**
199+ * style.typography.fontSize raw, or the fontSize preset slug resolved to
200+ * its theme.json value. Unknown slugs paint nothing → null.
201+ *
202+ * @param array $node Raw block array.
203+ * @return string|null
204+ */
205+ public function font_size ( array $ node ) {
206+ $ attrs = $ this ->attrs ( $ node );
207+
208+ if ( isset ( $ attrs ['style ' ]['typography ' ]['fontSize ' ] ) && is_string ( $ attrs ['style ' ]['typography ' ]['fontSize ' ] ) ) {
209+ return $ attrs ['style ' ]['typography ' ]['fontSize ' ];
210+ }
211+ if ( ! empty ( $ attrs ['fontSize ' ] ) && is_string ( $ attrs ['fontSize ' ] ) ) {
212+ return $ this ->resolve_font_size_slug ( $ attrs ['fontSize ' ] );
213+ }
214+ return null ;
215+ }
216+
217+ /**
218+ * core/image is the content image; its alt lives on the <img> tag in the
219+ * saved markup, not in attrs. Covers and other background media are
220+ * decorative by convention → null (never nag about them).
221+ *
222+ * @param array $node Raw block array.
223+ * @return string|null
224+ */
225+ public function image_alt ( array $ node ) {
226+ if ( 'core/image ' !== (string ) $ node ['blockName ' ] ) {
227+ return null ;
228+ }
229+ $ html = (string ) $ node ['innerHTML ' ];
230+ if ( false === stripos ( $ html , '<img ' ) ) {
231+ // An image block with no image yet — nothing rendered to judge.
232+ return null ;
233+ }
234+ if ( preg_match ( '/<img[^>]*\salt=("| \')(.*?)\1/is ' , $ html , $ m ) ) {
235+ return trim ( html_entity_decode ( $ m [2 ], ENT_QUOTES ) );
236+ }
237+ return '' ;
238+ }
239+
240+ /**
241+ * core/heading's level; the attr is only serialized when it differs from
242+ * the default h2.
243+ *
244+ * @param array $node Raw block array.
245+ * @return int|null
246+ */
247+ public function heading_level ( array $ node ) {
248+ if ( 'core/heading ' !== (string ) $ node ['blockName ' ] ) {
249+ return null ;
250+ }
251+ $ attrs = $ this ->attrs ( $ node );
252+ $ level = isset ( $ attrs ['level ' ] ) ? (int ) $ attrs ['level ' ] : 2 ;
253+ return ( $ level >= 1 && $ level <= 6 ) ? $ level : 2 ;
254+ }
255+
256+ /**
257+ * Gutenberg has no user-editable global preset entity (registered block
258+ * styles are code, not content) — nothing to couple to.
259+ *
260+ * @param array $node Raw block array.
261+ * @return null
262+ */
263+ public function global_preset_ref ( array $ node ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Fixed accessor signature.
264+ return null ;
265+ }
266+
267+ /**
268+ * Every var(--…) reference in the node's own attrs, plus Gutenberg's
269+ * internal var:preset|group|slug form normalized to its CSS custom
270+ * property name.
271+ *
272+ * @param array $node Raw block array.
273+ * @return string[]
274+ */
275+ public function variable_refs ( array $ node ) {
276+ $ attrs = $ this ->attrs ( $ node );
277+ if ( ! $ attrs ) {
278+ return array ();
279+ }
280+ $ blob = (string ) wp_json_encode ( $ attrs );
281+
282+ $ refs = array ();
283+ if ( preg_match_all ( '/var\((--[a-z0-9_\-]+)/i ' , $ blob , $ m ) ) {
284+ $ refs = $ m [1 ];
285+ }
286+ // Internal preset syntax: "var:preset|color|primary".
287+ if ( preg_match_all ( '/var:preset\|([a-z0-9_\-]+)\|([a-z0-9_\-]+)/i ' , $ blob , $ m , PREG_SET_ORDER ) ) {
288+ foreach ( $ m as $ hit ) {
289+ $ refs [] = '--wp--preset-- ' . $ hit [1 ] . '-- ' . $ hit [2 ];
290+ }
291+ }
292+ return array_values ( array_unique ( $ refs ) );
293+ }
294+
295+ /**
296+ * Free Gutenberg pages have no committed brief store — the brief is the
297+ * builder driver's concern. Returning null keeps the conformance rule
298+ * silent here (briefless pages get zero brief violations).
299+ *
300+ * @return null
301+ */
302+ public function design_brief () {
303+ return null ;
304+ }
305+
306+ /**
307+ * Tree-only accessor: the render pillar fills this seam later.
308+ *
309+ * @param array $node Raw block array.
310+ * @return null
311+ */
312+ public function computed_style ( array $ node ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Fixed accessor signature.
313+ return null ;
314+ }
315+
133316 /*
134317 ---------------------------------------------------------------------
135318 * Internals
@@ -182,4 +365,40 @@ private function resolve_palette_slug( $slug ) {
182365 }
183366 return isset ( $ this ->palette [ $ slug ] ) ? $ this ->palette [ $ slug ] : null ;
184367 }
368+
369+ /**
370+ * Resolve a font-size preset slug to its theme.json value. Unknown slugs
371+ * return null — same contract as resolve_palette_slug().
372+ *
373+ * @param string $slug Preset slug.
374+ * @return string|null
375+ */
376+ private function resolve_font_size_slug ( $ slug ) {
377+ if ( null === $ this ->font_sizes ) {
378+ $ this ->font_sizes = array ();
379+ if ( class_exists ( 'WP_Theme_JSON_Resolver ' ) ) {
380+ $ settings = WP_Theme_JSON_Resolver::get_merged_data ()->get_settings ();
381+ $ sizes = isset ( $ settings ['typography ' ]['fontSizes ' ] ) ? (array ) $ settings ['typography ' ]['fontSizes ' ] : array ();
382+ // Origin-keyed or flat; theme entries are the site's scale and win.
383+ if ( isset ( $ sizes [0 ] ) ) {
384+ $ groups = array ( $ sizes );
385+ } else {
386+ $ groups = array ();
387+ foreach ( array ( 'theme ' , 'custom ' , 'default ' ) as $ origin ) {
388+ if ( isset ( $ sizes [ $ origin ] ) && is_array ( $ sizes [ $ origin ] ) ) {
389+ $ groups [] = $ sizes [ $ origin ];
390+ }
391+ }
392+ }
393+ foreach ( $ groups as $ group ) {
394+ foreach ( $ group as $ preset ) {
395+ if ( is_array ( $ preset ) && isset ( $ preset ['slug ' ], $ preset ['size ' ] ) && ! isset ( $ this ->font_sizes [ $ preset ['slug ' ] ] ) ) {
396+ $ this ->font_sizes [ (string ) $ preset ['slug ' ] ] = (string ) $ preset ['size ' ];
397+ }
398+ }
399+ }
400+ }
401+ }
402+ return isset ( $ this ->font_sizes [ $ slug ] ) ? $ this ->font_sizes [ $ slug ] : null ;
403+ }
185404}
0 commit comments